OXYGENHUB

Milestone 0.5: API, webhooks, checks b64e2537d702

/api/v1 covers every resource with scoped bearer tokens and RFC 7807
problems; hub-api types carry JSON Schema and make ts emits TypeScript
declarations for the extensions. Webhooks per repository and
organisation with keyed-BLAKE3 signatures, a retrying worker, a delivery
log and redelivery. Checks on state hashes reported by any checks:write
token; required checks gate landing on the projected state; the proposal
page streams check events. End-to-end test with a flaky local receiver.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-27 19:26 UTC
Signature
✔ signed by Andy McCall
Hash
b64e2537d7028806bc3d1da95b489e1071f40abda62dfbe43d1d038a65115d13
Depends on
18e381ea88c2 19bb31ff1f5c 9ff27da517fe d23464c0ac60 de505df5cf8d
Depended on by
1062a0f8e985 40a43921ef42 f2d2776b2a14 306aec1839aa 8f1d76f4169a 041a945f55f8 04f9c984439e 851a26a2a6d1 4dfa4599b3f7 70c3d02998a2 aeed63c00ad7 15d3f6f7871b d7e2c2754807 d2263e299da7 5d818ec7d5fc f4e138301198 439ecd8708a1 6a306cd03fc1 ecac6e8d9b41 456f4197ade3 ba0a55bb5b3c 166f6e9bf4f1 c7067c7bda56 cb7b3cd45dc0 3571c519ada6 33d98147511b e57765dee61a 0ee5c3fb32eb 86a69b53aff0 2a8b621676b4 205d8f863e5c 05f1a85a2842 4f40eed0588f 965b950f9b96 107eaba52c9d c92a99490cba c8684d44d549 3b1bc27fe805 5d90e0e045e2 a2ef53c5db26 7e01a7665aa5 417184c73f4e d74ce22fa80e aa0bfbd11f02 c98935537c57 9a777fec4ed9 1870e4fa9808 2e9188ca4f98 d3b048b27277 17ca6cddc534 eb2a3502601d acc85ac0f70b 71e65fcf95ab e71583501b1d f677d6270298 a46d15e72101 ac713df7b38b 92a797b20912 c8df33a6829f a0dd06b0094e efb51b39db1c b210727578e8 617c2840446c 36e5a9c15a84 0b203bbf7cc3 17a3a84b00d1 554ccacfc123 1f78655a0466 4d2e44d79b03 62ceb2eec18c 66fd9d6b1452 d0fbea2eb46e d1727c573f30 4ac01a732552 2a83f5303458 7d2eb590051f e71846aa07b9 3556d08e17f9 8933faff2043 f28389bd6aab 5ea4872f23e8 745d225de344 d7ce41acf22c 9e50caedf4db 8e96499f2705 4da8b828a822 6a1780558353 2291f8cc9c96 739a763a558c 070a7b64b368 60f11b71a740 b66e70b7b6c7 6978fd76a066 cc1cd197df1d df71c7892d27 c96b32865000 129c23effb73 d3e75fb41e61 0b20f1ec4007 bf1ab3458db1 e9bfea8e26db a1b9869565aa 38833ed78707 1f1eb06a9009 df940d75cd3d 2bfe5ef66b0c 92574625ee9a 65be9f9c3074 5e6d6484c28e 760a7dd30d6f 90326c9601f9 1edac4b68c6d e271a37b629a 9008bf6c2b8c 961c10bee174 ef20fdc14e8b 9756c67d3aec e1ec9fbf538e 76f262e1a666 8fb35df7d66e eb0c44a9ad37 43e8ca33bc5d 42fa95540468 8d2caf122517 64caed18236a 07b1a5bbf481 42a0ba4bac4e c0fac31d36fd 3f02e5ea4f2d 25ab8c73514a 3f4b0ea6eabf 9bc6b4720fc1 db72849cd22e 81a6254b92af
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
125 atoms, 10 tree operations, 25 files

Browse the tree at this change

added crates/hub-api/src/bin/typescript.rs +78 −0
1 +
//! Emit TypeScript declarations for every API type, from their JSON
2 +
//! Schemas, so the VS Code extension can never drift from the server:
3 +
//!
4 +
//! `cargo run -p hub-api --bin hub-api-typescript > ts/index.d.ts`
5 +
6 +
use serde_json::Value;
7 +
8 +
fn ts_type(schema: &Value) -> String {
9 +
    if let Some(r) = schema.get("$ref").and_then(Value::as_str) {
10 +
        return r.rsplit('/').next().unwrap_or("unknown").to_string();
11 +
    }
12 +
    if let Some(e) = schema.get("enum").and_then(Value::as_array) {
13 +
        return e.iter().map(|v| serde_json::to_string(v).unwrap_or_default()).collect::<Vec<_>>().join(" | ");
14 +
    }
15 +
    if let Some(c) = schema.get("const") {
16 +
        return serde_json::to_string(c).unwrap_or_default();
17 +
    }
18 +
    if let Some(variants) = schema.get("oneOf").or_else(|| schema.get("anyOf")).and_then(Value::as_array) {
19 +
        return variants.iter().map(|v| ts_type(v)).collect::<Vec<_>>().join(" | ");
20 +
    }
21 +
    let nullable = schema.get("type").and_then(Value::as_array).is_some_and(|a| a.iter().any(|t| t == "null"));
22 +
    let ty = match schema.get("type") {
23 +
        Some(Value::String(t)) => t.clone(),
24 +
        Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).find(|t| *t != "null").unwrap_or("unknown").to_string(),
25 +
        _ => {
26 +
            if schema.get("properties").is_some() {
27 +
                "object".into()
28 +
            } else {
29 +
                "unknown".into()
30 +
            }
31 +
        }
32 +
    };
33 +
    let base = match ty.as_str() {
34 +
        "string" => "string".to_string(),
35 +
        "integer" | "number" => "number".to_string(),
36 +
        "boolean" => "boolean".to_string(),
37 +
        "array" => format!("{}[]", schema.get("items").map(|i| ts_type(i)).unwrap_or_else(|| "unknown".into())),
38 +
        "object" => {
39 +
            let props = schema.get("properties").and_then(Value::as_object);
40 +
            let required: Vec<&str> =
41 +
                schema.get("required").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
42 +
            match props {
43 +
                Some(p) => {
44 +
                    let fields: Vec<String> = p
45 +
                        .iter()
46 +
                        .map(|(k, v)| format!("  {k}{}: {};", if required.contains(&k.as_str()) { "" } else { "?" }, ts_type(v)))
47 +
                        .collect();
48 +
                    format!("{{\n{}\n}}", fields.join("\n"))
49 +
                }
50 +
                None => "Record<string, unknown>".into(),
51 +
            }
52 +
        }
53 +
        _ => "unknown".into(),
54 +
    };
55 +
    if nullable {
56 +
        format!("{base} | null")
57 +
    } else {
58 +
        base
59 +
    }
60 +
}
61 +
62 +
fn main() {
63 +
    println!("// Generated by `cargo run -p hub-api --bin hub-api-typescript`; do not edit.");
64 +
    println!("// Types of the Oxygenhub JSON API (hub spec §12.1).\n");
65 +
    let mut printed = std::collections::BTreeSet::new();
66 +
    for (name, schema) in hub_api::schemas() {
67 +
        let v = serde_json::to_value(&schema).unwrap();
68 +
        let defs = v.get("$defs").and_then(Value::as_object).cloned().unwrap_or_default();
69 +
        for (dname, dschema) in &defs {
70 +
            if printed.insert(dname.clone()) {
71 +
                println!("export type {dname} = {};\n", ts_type(dschema));
72 +
            }
73 +
        }
74 +
        if printed.insert(name.to_string()) {
75 +
            println!("export type {name} = {};\n", ts_type(&v));
76 +
        }
77 +
    }
78 +
}
added crates/hub-api/ts/index.d.ts +354 −0
1 +
// Generated by `cargo run -p hub-api --bin hub-api-typescript`; do not edit.
2 +
// Types of the Oxygenhub JSON API (hub spec §12.1).
3 +
4 +
export type Visibility = "public" | "internal" | "private";
5 +
6 +
export type Repository = {
7 +
  clone_url: string;
8 +
  created_ms: number;
9 +
  default_bookmark: string;
10 +
  description: string;
11 +
  name: string;
12 +
  owner: string;
13 +
  updated_ms: number;
14 +
  visibility: Visibility;
15 +
};
16 +
17 +
export type Bookmark = {
18 +
  changes: number;
19 +
  default: boolean;
20 +
  heads: string[];
21 +
  name: string;
22 +
};
23 +
24 +
export type Tag = {
25 +
  heads: string[];
26 +
  message: string;
27 +
  name: string;
28 +
  signer?: string | null;
29 +
};
30 +
31 +
export type Author = {
32 +
  account?: string | null;
33 +
  email: string;
34 +
  key: string;
35 +
  name: string;
36 +
};
37 +
38 +
export type Signature = {
39 +
  status: "unsigned";
40 +
} | {
41 +
  key: string;
42 +
  revoked: boolean;
43 +
  sealed: boolean;
44 +
  status: "valid";
45 +
} | {
46 +
  reason: string;
47 +
  status: "invalid";
48 +
};
49 +
50 +
export type ChangeSummary = {
51 +
  author: Author;
52 +
  deps: string[];
53 +
  hash: string;
54 +
  head: boolean;
55 +
  message: string;
56 +
  signature: Signature;
57 +
  when_ms: number;
58 +
};
59 +
60 +
export type Problem = {
61 +
  detail: string;
62 +
  status: number;
63 +
  title: string;
64 +
};
65 +
66 +
export type Approval = {
67 +
  account: string;
68 +
  key?: string | null;
69 +
  sealed: boolean;
70 +
};
71 +
72 +
export type ProposalSource = {
73 +
  bookmark: string;
74 +
  kind: "bookmark";
75 +
} | {
76 +
  bookmark: string;
77 +
  kind: "fork";
78 +
  name: string;
79 +
  owner: string;
80 +
} | {
81 +
  hashes: string[];
82 +
  kind: "changes";
83 +
};
84 +
85 +
export type ProposalState = "draft" | "open" | "landed" | "closed";
86 +
87 +
export type Proposal = {
88 +
  approvals: Approval[];
89 +
  author: string;
90 +
  blockers: string[];
91 +
  body: string;
92 +
  changes: ChangeSummary[];
93 +
  conflicted: boolean;
94 +
  created_ms: number;
95 +
  landable: boolean;
96 +
  number: number;
97 +
  projected_heads: string[];
98 +
  projected_state?: string | null;
99 +
  repository_url: string;
100 +
  source: ProposalSource;
101 +
  source_url?: string | null;
102 +
  state: ProposalState;
103 +
  target: string;
104 +
  title: string;
105 +
  updated_ms: number;
106 +
  url: string;
107 +
};
108 +
109 +
export type Label = {
110 +
  colour: string;
111 +
  description: string;
112 +
  name: string;
113 +
};
114 +
115 +
export type Issue = {
116 +
  assignees: string[];
117 +
  author: string;
118 +
  body: string;
119 +
  closed_ms?: number | null;
120 +
  comments: number;
121 +
  created_ms: number;
122 +
  labels: Label[];
123 +
  locked: boolean;
124 +
  milestone?: string | null;
125 +
  number: number;
126 +
  pinned: boolean;
127 +
  reason: string;
128 +
  state: string;
129 +
  title: string;
130 +
  updated_ms: number;
131 +
  url: string;
132 +
};
133 +
134 +
export type Milestone = {
135 +
  closed: number;
136 +
  description: string;
137 +
  due_ms?: number | null;
138 +
  open: number;
139 +
  state: string;
140 +
  title: string;
141 +
};
142 +
143 +
export type Atom = {
144 +
  change: string;
145 +
  index: number;
146 +
};
147 +
148 +
export type Comment = {
149 +
  anchor?: Atom | unknown;
150 +
  author: string;
151 +
  body: string;
152 +
  created_ms: number;
153 +
  id: number;
154 +
  path?: string | null;
155 +
  resolved: boolean;
156 +
};
157 +
158 +
export type Review = {
159 +
  author: string;
160 +
  body: string;
161 +
  created_ms: number;
162 +
  id: number;
163 +
  sealed: boolean;
164 +
  verdict: string;
165 +
};
166 +
167 +
export type TreeEntry = {
168 +
  conflicted: boolean;
169 +
  exec: boolean;
170 +
  kind: string;
171 +
  name: string;
172 +
  path: string;
173 +
  size?: number | null;
174 +
};
175 +
176 +
export type BlameLine = {
177 +
  author: string;
178 +
  change: string;
179 +
  index: number;
180 +
  line: number;
181 +
  text: string;
182 +
  when_ms: number;
183 +
};
184 +
185 +
export type FileChange = {
186 +
  added: number;
187 +
  from?: string | null;
188 +
  kind: string;
189 +
  path: string;
190 +
  removed: number;
191 +
};
192 +
193 +
export type ChangeDetail = {
194 +
  body: string;
195 +
  change: ChangeSummary;
196 +
  dependents: string[];
197 +
  files: FileChange[];
198 +
  in_bookmarks: string[];
199 +
  in_tags: string[];
200 +
};
201 +
202 +
export type History = {
203 +
  changes: ChangeSummary[];
204 +
  heads: string[];
205 +
  offset: number;
206 +
  reference: string;
207 +
  total: number;
208 +
};
209 +
210 +
export type Conflict = {
211 +
  bookmark: string;
212 +
  contributors: string[];
213 +
  kind: string;
214 +
  path: string;
215 +
};
216 +
217 +
export type Push = {
218 +
  at_ms: number;
219 +
  changes: number;
220 +
  heads: number;
221 +
  new_state: string;
222 +
  ok: boolean;
223 +
  old_state: string;
224 +
  pusher: string;
225 +
  reason: string;
226 +
  reference: string;
227 +
};
228 +
229 +
export type Policy = {
230 +
  expires_ms: number;
231 +
  root_threshold: number;
232 +
  roots: string[];
233 +
  rules: Rule[];
234 +
  serial: number;
235 +
};
236 +
237 +
export type Rule = {
238 +
  allow_conflicts: boolean;
239 +
  keys: string[];
240 +
  pattern: string;
241 +
  require: string;
242 +
  threshold: number;
243 +
};
244 +
245 +
export type Trust = {
246 +
  hub_key: string;
247 +
  invalid: string[];
248 +
  policy?: Policy | unknown;
249 +
  sealed: number;
250 +
  signed: number;
251 +
  unsigned: number;
252 +
};
253 +
254 +
export type Hook = {
255 +
  active: boolean;
256 +
  created_ms: number;
257 +
  events: string;
258 +
  id: number;
259 +
  url: string;
260 +
};
261 +
262 +
export type NewHook = {
263 +
  events?: string;
264 +
  secret: string;
265 +
  url: string;
266 +
};
267 +
268 +
export type CheckStatus = "queued" | "running" | "passed" | "failed" | "cancelled" | "skipped";
269 +
270 +
export type Check = {
271 +
  finished_ms?: number | null;
272 +
  name: string;
273 +
  reporter: string;
274 +
  seal?: string | null;
275 +
  started_ms: number;
276 +
  state: string;
277 +
  status: CheckStatus;
278 +
  summary: string;
279 +
  url: string;
280 +
};
281 +
282 +
export type NewCheck = {
283 +
  name: string;
284 +
  seal?: string | null;
285 +
  state: string;
286 +
  status: CheckStatus;
287 +
  summary?: string;
288 +
  url?: string;
289 +
};
290 +
291 +
export type User = {
292 +
  created_ms: number;
293 +
  display_name: string;
294 +
  is_admin: boolean;
295 +
  keys: string[];
296 +
  username: string;
297 +
};
298 +
299 +
export type Organisation = {
300 +
  description: string;
301 +
  display_name: string;
302 +
  members: string[];
303 +
  name: string;
304 +
};
305 +
306 +
export type NewRepository = {
307 +
  description?: string;
308 +
  name: string;
309 +
  owner: string;
310 +
  visibility?: Visibility;
311 +
};
312 +
313 +
export type NewIssue = {
314 +
  body?: string;
315 +
  title: string;
316 +
};
317 +
318 +
export type NewComment = {
319 +
  body: string;
320 +
};
321 +
322 +
export type NewProposal = {
323 +
  body?: string;
324 +
  draft?: boolean;
325 +
  source: string;
326 +
  target: string;
327 +
  title: string;
328 +
};
329 +
330 +
export type NewReview = {
331 +
  body?: string;
332 +
  verdict: string;
333 +
};
334 +
335 +
export type IssueStateChange = {
336 +
  reason?: string;
337 +
  state: string;
338 +
};
339 +
340 +
export type WebhookEnvelope = {
341 +
  actor: string;
342 +
  at_ms: number;
343 +
  data: unknown;
344 +
  delivery: string;
345 +
  event: string;
346 +
  repository?: Repository | unknown;
347 +
};
348 +
349 +
export type ProposalEvent = {
350 +
  check?: Check | unknown;
351 +
  kind: string;
352 +
  number: number;
353 +
};
354 +
added crates/hub/migrations/0006_hooks_checks.sql +50 −0
1 +
-- Webhooks with a delivery log, and checks on state hashes (hub spec
2 +
-- §12.2, §13.1).
3 +
4 +
CREATE TABLE hooks (
5 +
    id         INTEGER PRIMARY KEY,
6 +
    repo_id    INTEGER REFERENCES repositories(id) ON DELETE CASCADE,
7 +
    org_id     INTEGER REFERENCES organisations(id) ON DELETE CASCADE,
8 +
    url        TEXT NOT NULL,
9 +
    secret     TEXT NOT NULL DEFAULT '',
10 +
    events     TEXT NOT NULL DEFAULT '*',
11 +
    active     INTEGER NOT NULL DEFAULT 1,
12 +
    created_ms INTEGER NOT NULL,
13 +
    CHECK ((repo_id IS NULL) <> (org_id IS NULL))
14 +
);
15 +
16 +
CREATE TABLE deliveries (
17 +
    id          INTEGER PRIMARY KEY,
18 +
    hook_id     INTEGER NOT NULL REFERENCES hooks(id) ON DELETE CASCADE,
19 +
    delivery    TEXT NOT NULL,
20 +
    event       TEXT NOT NULL,
21 +
    payload     TEXT NOT NULL,
22 +
    created_ms  INTEGER NOT NULL,
23 +
    attempts    INTEGER NOT NULL DEFAULT 0,
24 +
    next_ms     INTEGER NOT NULL,
25 +
    status      INTEGER,
26 +
    response    TEXT NOT NULL DEFAULT '',
27 +
    delivered_ms INTEGER
28 +
);
29 +
CREATE INDEX deliveries_due ON deliveries (next_ms) WHERE delivered_ms IS NULL;
30 +
CREATE INDEX deliveries_by_hook ON deliveries (hook_id, created_ms DESC);
31 +
32 +
CREATE TABLE checks (
33 +
    id          INTEGER PRIMARY KEY,
34 +
    repo_id     INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
35 +
    state       TEXT NOT NULL,
36 +
    name        TEXT NOT NULL,
37 +
    status      TEXT NOT NULL CHECK (status IN ('queued', 'running', 'passed', 'failed', 'cancelled', 'skipped')),
38 +
    summary     TEXT NOT NULL DEFAULT '',
39 +
    url         TEXT NOT NULL DEFAULT '',
40 +
    reporter_id INTEGER REFERENCES accounts(id) ON DELETE SET NULL,
41 +
    reporter    TEXT NOT NULL DEFAULT '',
42 +
    seal        TEXT NOT NULL DEFAULT '',
43 +
    started_ms  INTEGER NOT NULL,
44 +
    finished_ms INTEGER,
45 +
    UNIQUE (repo_id, state, name)
46 +
);
47 +
CREATE INDEX checks_by_state ON checks (repo_id, state);
48 +
49 +
ALTER TABLE repositories ADD COLUMN required_checks TEXT NOT NULL DEFAULT '';
50 +
ALTER TABLE tokens ADD COLUMN scopes_note TEXT NOT NULL DEFAULT '';
added crates/hub/src/db/hooks.rs +226 −0
1 +
//! Webhooks, their delivery log, and checks (hub spec §12.2, §13.1).
2 +
3 +
use super::{now_ms, Pool};
4 +
use sqlx::FromRow;
5 +
6 +
#[derive(Clone, Debug, FromRow)]
7 +
pub struct Hook {
8 +
    pub id: i64,
9 +
    pub repo_id: Option<i64>,
10 +
    pub org_id: Option<i64>,
11 +
    pub url: String,
12 +
    pub secret: String,
13 +
    pub events: String,
14 +
    pub active: bool,
15 +
    pub created_ms: i64,
16 +
}
17 +
18 +
impl Hook {
19 +
    /// Whether the hook wants `event` (`*`, or a comma list of prefixes such as `push,proposal`).
20 +
    pub fn wants(&self, event: &str) -> bool {
21 +
        self.active && self.events.split(',').map(str::trim).any(|e| e == "*" || event == e || event.starts_with(&format!("{e}.")))
22 +
    }
23 +
}
24 +
25 +
#[derive(Clone, Debug, FromRow)]
26 +
pub struct Delivery {
27 +
    pub id: i64,
28 +
    pub hook_id: i64,
29 +
    pub delivery: String,
30 +
    pub event: String,
31 +
    pub payload: String,
32 +
    pub created_ms: i64,
33 +
    pub attempts: i64,
34 +
    pub next_ms: i64,
35 +
    pub status: Option<i64>,
36 +
    pub response: String,
37 +
    pub delivered_ms: Option<i64>,
38 +
}
39 +
40 +
pub async fn hooks_for_repo(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<Hook>> {
41 +
    sqlx::query_as("SELECT * FROM hooks WHERE repo_id = ? ORDER BY id").bind(repo_id).fetch_all(pool).await
42 +
}
43 +
44 +
pub async fn hooks_for_org(pool: &Pool, org_id: i64) -> sqlx::Result<Vec<Hook>> {
45 +
    sqlx::query_as("SELECT * FROM hooks WHERE org_id = ? ORDER BY id").bind(org_id).fetch_all(pool).await
46 +
}
47 +
48 +
/// Every hook that should hear about an event in a repository: its own
49 +
/// and its organisation's.
50 +
pub async fn hooks_hearing(pool: &Pool, repo_id: i64, owner: &str) -> sqlx::Result<Vec<Hook>> {
51 +
    let mut out = hooks_for_repo(pool, repo_id).await?;
52 +
    if let Some(org) = super::accounts::org_by_name(pool, owner).await? {
53 +
        out.extend(hooks_for_org(pool, org.id).await?);
54 +
    }
55 +
    Ok(out)
56 +
}
57 +
58 +
pub async fn hook(pool: &Pool, id: i64) -> sqlx::Result<Option<Hook>> {
59 +
    sqlx::query_as("SELECT * FROM hooks WHERE id = ?").bind(id).fetch_optional(pool).await
60 +
}
61 +
62 +
pub async fn create_hook(
63 +
    pool: &Pool,
64 +
    repo_id: Option<i64>,
65 +
    org_id: Option<i64>,
66 +
    url: &str,
67 +
    secret: &str,
68 +
    events: &str,
69 +
) -> sqlx::Result<Hook> {
70 +
    sqlx::query_as("INSERT INTO hooks (repo_id, org_id, url, secret, events, created_ms) VALUES (?, ?, ?, ?, ?, ?) RETURNING *")
71 +
        .bind(repo_id)
72 +
        .bind(org_id)
73 +
        .bind(url)
74 +
        .bind(secret)
75 +
        .bind(events)
76 +
        .bind(now_ms())
77 +
        .fetch_one(pool)
78 +
        .await
79 +
}
80 +
81 +
pub async fn set_hook_active(pool: &Pool, id: i64, active: bool) -> sqlx::Result<()> {
82 +
    sqlx::query("UPDATE hooks SET active = ? WHERE id = ?").bind(active).bind(id).execute(pool).await?;
83 +
    Ok(())
84 +
}
85 +
86 +
pub async fn delete_hook(pool: &Pool, id: i64) -> sqlx::Result<()> {
87 +
    sqlx::query("DELETE FROM hooks WHERE id = ?").bind(id).execute(pool).await?;
88 +
    Ok(())
89 +
}
90 +
91 +
pub async fn queue_delivery(pool: &Pool, hook_id: i64, delivery: &str, event: &str, payload: &str) -> sqlx::Result<i64> {
92 +
    let now = now_ms();
93 +
    sqlx::query_scalar(
94 +
        "INSERT INTO deliveries (hook_id, delivery, event, payload, created_ms, next_ms) VALUES (?, ?, ?, ?, ?, ?) RETURNING id",
95 +
    )
96 +
    .bind(hook_id)
97 +
    .bind(delivery)
98 +
    .bind(event)
99 +
    .bind(payload)
100 +
    .bind(now)
101 +
    .bind(now)
102 +
    .fetch_one(pool)
103 +
    .await
104 +
}
105 +
106 +
pub async fn due_deliveries(pool: &Pool, limit: i64) -> sqlx::Result<Vec<Delivery>> {
107 +
    sqlx::query_as("SELECT * FROM deliveries WHERE delivered_ms IS NULL AND next_ms <= ? ORDER BY next_ms LIMIT ?")
108 +
        .bind(now_ms())
109 +
        .bind(limit)
110 +
        .fetch_all(pool)
111 +
        .await
112 +
}
113 +
114 +
pub async fn deliveries_for_hook(pool: &Pool, hook_id: i64, limit: i64) -> sqlx::Result<Vec<Delivery>> {
115 +
    sqlx::query_as("SELECT * FROM deliveries WHERE hook_id = ? ORDER BY created_ms DESC, id DESC LIMIT ?")
116 +
        .bind(hook_id)
117 +
        .bind(limit)
118 +
        .fetch_all(pool)
119 +
        .await
120 +
}
121 +
122 +
pub async fn delivery(pool: &Pool, id: i64) -> sqlx::Result<Option<Delivery>> {
123 +
    sqlx::query_as("SELECT * FROM deliveries WHERE id = ?").bind(id).fetch_optional(pool).await
124 +
}
125 +
126 +
/// Record an attempt: done on a 2xx, else retried with backoff up to a day.
127 +
pub async fn record_attempt(pool: &Pool, id: i64, attempts: i64, status: Option<i64>, response: &str) -> sqlx::Result<()> {
128 +
    let ok = status.is_some_and(|s| (200..300).contains(&s));
129 +
    let now = now_ms();
130 +
    let delivered: Option<i64> = if ok { Some(now) } else { None };
131 +
    // 1 min, 5 min, 30 min, 2 h, 6 h, then give up after a day's worth.
132 +
    let backoff_ms = [60_000i64, 300_000, 1_800_000, 7_200_000, 21_600_000];
133 +
    let next = if ok || attempts as usize >= backoff_ms.len() { i64::MAX / 4 } else { now + backoff_ms[attempts as usize] };
134 +
    sqlx::query("UPDATE deliveries SET attempts = ?, status = ?, response = ?, delivered_ms = ?, next_ms = ? WHERE id = ?")
135 +
        .bind(attempts + 1)
136 +
        .bind(status)
137 +
        .bind(&response.chars().take(2000).collect::<String>())
138 +
        .bind(delivered)
139 +
        .bind(next)
140 +
        .bind(id)
141 +
        .execute(pool)
142 +
        .await?;
143 +
    Ok(())
144 +
}
145 +
146 +
/// Requeue a delivery for an immediate retry.
147 +
pub async fn redeliver(pool: &Pool, id: i64) -> sqlx::Result<()> {
148 +
    sqlx::query("UPDATE deliveries SET delivered_ms = NULL, next_ms = ? WHERE id = ?").bind(now_ms()).bind(id).execute(pool).await?;
149 +
    Ok(())
150 +
}
151 +
152 +
pub async fn prune_deliveries(pool: &Pool, older_than_ms: i64) -> sqlx::Result<()> {
153 +
    sqlx::query("DELETE FROM deliveries WHERE created_ms < ?").bind(older_than_ms).execute(pool).await?;
154 +
    Ok(())
155 +
}
156 +
157 +
// ------------------------------------------------------------------ checks
158 +
159 +
#[derive(Clone, Debug, FromRow)]
160 +
pub struct Check {
161 +
    pub id: i64,
162 +
    pub repo_id: i64,
163 +
    pub state: String,
164 +
    pub name: String,
165 +
    pub status: String,
166 +
    pub summary: String,
167 +
    pub url: String,
168 +
    pub reporter_id: Option<i64>,
169 +
    pub reporter: String,
170 +
    pub seal: String,
171 +
    pub started_ms: i64,
172 +
    pub finished_ms: Option<i64>,
173 +
}
174 +
175 +
impl Check {
176 +
    pub fn done(&self) -> bool {
177 +
        matches!(self.status.as_str(), "passed" | "failed" | "cancelled" | "skipped")
178 +
    }
179 +
}
180 +
181 +
pub async fn checks_for_state(pool: &Pool, repo_id: i64, state: &str) -> sqlx::Result<Vec<Check>> {
182 +
    sqlx::query_as("SELECT * FROM checks WHERE repo_id = ? AND state = ? ORDER BY name").bind(repo_id).bind(state).fetch_all(pool).await
183 +
}
184 +
185 +
/// Create or update a check by `(state, name)`.
186 +
pub async fn upsert_check(
187 +
    pool: &Pool,
188 +
    repo_id: i64,
189 +
    state: &str,
190 +
    name: &str,
191 +
    status: &str,
192 +
    summary: &str,
193 +
    url: &str,
194 +
    reporter: Option<(i64, &str)>,
195 +
    seal: &str,
196 +
) -> sqlx::Result<Check> {
197 +
    let now = now_ms();
198 +
    let finished: Option<i64> = if matches!(status, "passed" | "failed" | "cancelled" | "skipped") { Some(now) } else { None };
199 +
    sqlx::query_as(
200 +
        "INSERT INTO checks (repo_id, state, name, status, summary, url, reporter_id, reporter, seal, started_ms, finished_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \
201 +
         ON CONFLICT (repo_id, state, name) DO UPDATE SET status = excluded.status, summary = excluded.summary, url = excluded.url, reporter_id = excluded.reporter_id, reporter = excluded.reporter, seal = CASE WHEN excluded.seal = '' THEN checks.seal ELSE excluded.seal END, finished_ms = excluded.finished_ms RETURNING *",
202 +
    )
203 +
    .bind(repo_id)
204 +
    .bind(state)
205 +
    .bind(name)
206 +
    .bind(status)
207 +
    .bind(summary)
208 +
    .bind(url)
209 +
    .bind(reporter.map(|r| r.0))
210 +
    .bind(reporter.map(|r| r.1).unwrap_or(""))
211 +
    .bind(seal)
212 +
    .bind(now)
213 +
    .bind(finished)
214 +
    .fetch_one(pool)
215 +
    .await
216 +
}
217 +
218 +
pub async fn required_checks(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<String>> {
219 +
    let s: String = sqlx::query_scalar("SELECT required_checks FROM repositories WHERE id = ?").bind(repo_id).fetch_one(pool).await?;
220 +
    Ok(s.split(',').map(str::trim).filter(|x| !x.is_empty()).map(String::from).collect())
221 +
}
222 +
223 +
pub async fn set_required_checks(pool: &Pool, repo_id: i64, names: &[String]) -> sqlx::Result<()> {
224 +
    sqlx::query("UPDATE repositories SET required_checks = ? WHERE id = ?").bind(names.join(",")).bind(repo_id).execute(pool).await?;
225 +
    Ok(())
226 +
}
added crates/hub/src/events.rs +27 −0
1 +
//! In-process event bus: nudges the webhook worker and streams proposal
2 +
//! updates to open pages over server-sent events (hub spec §13.1).
3 +
4 +
use hub_api::ProposalEvent;
5 +
use tokio::sync::broadcast;
6 +
7 +
pub struct Events {
8 +
    pub deliveries: broadcast::Sender<()>,
9 +
    pub proposals: broadcast::Sender<(i64, ProposalEvent)>,
10 +
}
11 +
12 +
impl Default for Events {
13 +
    fn default() -> Self {
14 +
        Events { deliveries: broadcast::channel(16).0, proposals: broadcast::channel(256).0 }
15 +
    }
16 +
}
17 +
18 +
impl Events {
19 +
    pub fn notify_deliveries(&self) {
20 +
        let _ = self.deliveries.send(());
21 +
    }
22 +
23 +
    /// Announce something about a proposal of a repository.
24 +
    pub fn proposal(&self, repo_id: i64, event: ProposalEvent) {
25 +
        let _ = self.proposals.send((repo_id, event));
26 +
    }
27 +
}
added crates/hub/src/hooks.rs +116 −0
1 +
//! Webhooks (hub spec §12.2): queueing an event for every hook that wants
2 +
//! it, signing and delivering with retries, and the payload envelope.
3 +
4 +
use crate::db::{self, hooks as hdb};
5 +
use crate::Shared;
6 +
use hub_api::WebhookEnvelope;
7 +
8 +
/// Queue `event` for the hooks of a repository (and its organisation).
9 +
pub async fn emit(app: &Shared, repo: &db::repos::RepoRow, event: &str, actor: &str, data: serde_json::Value) {
10 +
    let hooks = match hdb::hooks_hearing(&app.db, repo.id, &repo.owner).await {
11 +
        Ok(h) => h,
12 +
        Err(e) => {
13 +
            tracing::warn!("hooks: {e}");
14 +
            return;
15 +
        }
16 +
    };
17 +
    if hooks.is_empty() {
18 +
        return;
19 +
    }
20 +
    let repository = hub_api::Repository {
21 +
        owner: repo.owner.clone(),
22 +
        name: repo.name.clone(),
23 +
        description: repo.description.clone(),
24 +
        default_bookmark: repo.default_bookmark.clone(),
25 +
        visibility: repo.visibility(),
26 +
        created_ms: repo.created_ms,
27 +
        updated_ms: repo.updated_ms,
28 +
        clone_url: format!("{}/{}", app.config.base_url(), repo.slug()),
29 +
    };
30 +
    for h in hooks.into_iter().filter(|h| h.wants(event)) {
31 +
        let delivery = crate::auth::random_hex(16);
32 +
        let envelope = WebhookEnvelope {
33 +
            event: event.into(),
34 +
            delivery: delivery.clone(),
35 +
            repository: Some(repository.clone()),
36 +
            actor: actor.into(),
37 +
            at_ms: db::now_ms(),
38 +
            data: data.clone(),
39 +
        };
40 +
        let payload = serde_json::to_string(&envelope).unwrap_or_default();
41 +
        if let Err(e) = hdb::queue_delivery(&app.db, h.id, &delivery, event, &payload).await {
42 +
            tracing::warn!("queueing delivery: {e}");
43 +
        }
44 +
    }
45 +
    app.events.notify_deliveries();
46 +
}
47 +
48 +
/// `X-Oxyhub-Signature` for a body: keyed BLAKE3 with the hook's secret.
49 +
pub fn signature(secret: &str, body: &[u8]) -> String {
50 +
    let key = blake3::hash(secret.as_bytes());
51 +
    format!("blake3-keyed={}", hex::encode(blake3::keyed_hash(key.as_bytes(), body).as_bytes()))
52 +
}
53 +
54 +
/// Deliver everything that is due; returns how many were attempted.
55 +
pub async fn deliver_due(app: &Shared) -> usize {
56 +
    let due = match hdb::due_deliveries(&app.db, 50).await {
57 +
        Ok(d) => d,
58 +
        Err(_) => return 0,
59 +
    };
60 +
    let client = reqwest::Client::builder()
61 +
        .timeout(std::time::Duration::from_secs(20))
62 +
        .user_agent(format!("oxygenhub/{}", env!("CARGO_PKG_VERSION")))
63 +
        .build();
64 +
    let Ok(client) = client else { return 0 };
65 +
    let mut n = 0;
66 +
    for d in due {
67 +
        let Ok(Some(hook)) = hdb::hook(&app.db, d.hook_id).await else { continue };
68 +
        n += 1;
69 +
        let (status, response) = match client
70 +
            .post(&hook.url)
71 +
            .header("content-type", "application/json")
72 +
            .header("x-oxyhub-event", &d.event)
73 +
            .header("x-oxyhub-delivery", &d.delivery)
74 +
            .header("x-oxyhub-signature", signature(&hook.secret, d.payload.as_bytes()))
75 +
            .body(d.payload.clone())
76 +
            .send()
77 +
            .await
78 +
        {
79 +
            Ok(r) => {
80 +
                let status = r.status().as_u16() as i64;
81 +
                let text = r.text().await.unwrap_or_default();
82 +
                (Some(status), text)
83 +
            }
84 +
            Err(e) => (None, e.to_string()),
85 +
        };
86 +
        hdb::record_attempt(&app.db, d.id, d.attempts, status, &response).await.ok();
87 +
    }
88 +
    n
89 +
}
90 +
91 +
/// The background worker: delivers when nudged and every minute anyway.
92 +
pub async fn worker(app: Shared) {
93 +
    let mut rx = app.events.deliveries.subscribe();
94 +
    loop {
95 +
        deliver_due(&app).await;
96 +
        let cutoff = db::now_ms() - app.config.retention.webhook_log_days as i64 * 86_400_000;
97 +
        hdb::prune_deliveries(&app.db, cutoff).await.ok();
98 +
        tokio::select! {
99 +
            _ = rx.recv() => {}
100 +
            _ = tokio::time::sleep(std::time::Duration::from_secs(60)) => {}
101 +
        }
102 +
    }
103 +
}
104 +
105 +
#[cfg(test)]
106 +
mod tests {
107 +
    use super::*;
108 +
109 +
    #[test]
110 +
    fn signatures_are_keyed() {
111 +
        let a = signature("s1", b"body");
112 +
        assert!(a.starts_with("blake3-keyed="));
113 +
        assert_ne!(a, signature("s2", b"body"));
114 +
        assert_ne!(a, signature("s1", b"other"));
115 +
    }
116 +
}
added crates/hub/tests/api.rs +318 −0
1 +
//! Milestone 0.5 end to end: the JSON API with scoped tokens, webhooks
2 +
//! delivered to a local receiver with signatures and retries, checks on a
3 +
//! projected state that gate landing, and the proposal event stream.
4 +
5 +
use axum::body::Body;
6 +
use axum::http::{header, Request, StatusCode};
7 +
use axum::routing::post;
8 +
use axum::Router;
9 +
use http_body_util::BodyExt;
10 +
use oxy_core::repo::Repo;
11 +
use oxygenhub::config::Config;
12 +
use oxygenhub::db;
13 +
use oxygenhub::repos::RepoName;
14 +
use oxygenhub::Shared;
15 +
use std::path::Path;
16 +
use std::sync::{Arc, Mutex};
17 +
use tower::ServiceExt;
18 +
19 +
fn hermetic() {
20 +
    static ONCE: std::sync::Once = std::sync::Once::new();
21 +
    static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
22 +
    ONCE.call_once(|| {
23 +
        let d = tempfile::tempdir().unwrap();
24 +
        std::env::set_var("OXY_HOME", d.path());
25 +
        DIR.set(d).ok();
26 +
    });
27 +
}
28 +
29 +
fn write(root: &Path, rel: &str, content: &[u8]) {
30 +
    let p = root.join(rel);
31 +
    std::fs::create_dir_all(p.parent().unwrap()).unwrap();
32 +
    std::fs::write(p, content).unwrap();
33 +
}
34 +
35 +
struct Hub {
36 +
    app: Shared,
37 +
    base: String,
38 +
    _dir: tempfile::TempDir,
39 +
}
40 +
41 +
async fn hub() -> Hub {
42 +
    hermetic();
43 +
    let dir = tempfile::tempdir().unwrap();
44 +
    let mut config = Config::load(dir.path()).unwrap();
45 +
    config.auth.push_tokens = vec!["site".into()];
46 +
    config.registration.mode = "open".into();
47 +
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
48 +
    let port = listener.local_addr().unwrap().port();
49 +
    config.server.base_url = format!("http://127.0.0.1:{port}");
50 +
    let app = oxygenhub::build(config).await.unwrap();
51 +
    let router = oxygenhub::router(app.clone());
52 +
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
53 +
    Hub { app, base: format!("http://127.0.0.1:{port}"), _dir: dir }
54 +
}
55 +
56 +
struct Res {
57 +
    status: StatusCode,
58 +
    body: String,
59 +
    cookie: Option<String>,
60 +
}
61 +
62 +
async fn call(
63 +
    app: &Shared,
64 +
    method: &str,
65 +
    path: &str,
66 +
    bearer: Option<&str>,
67 +
    cookie: Option<&str>,
68 +
    json: Option<&str>,
69 +
    form: Option<&str>,
70 +
) -> Res {
71 +
    let mut req = Request::builder().method(method).uri(path);
72 +
    if let Some(b) = bearer {
73 +
        req = req.header(header::AUTHORIZATION, format!("Bearer {b}"));
74 +
    }
75 +
    if let Some(c) = cookie {
76 +
        req = req.header(header::COOKIE, format!("oxyhub={c}"));
77 +
    }
78 +
    let body = if let Some(j) = json {
79 +
        req = req.header(header::CONTENT_TYPE, "application/json");
80 +
        Body::from(j.to_string())
81 +
    } else if let Some(f) = form {
82 +
        req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
83 +
        Body::from(f.to_string())
84 +
    } else {
85 +
        Body::empty()
86 +
    };
87 +
    let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap();
88 +
    let status = res.status();
89 +
    let cookie = res
90 +
        .headers()
91 +
        .get(header::SET_COOKIE)
92 +
        .and_then(|v| v.to_str().ok())
93 +
        .and_then(|v| v.split(';').next())
94 +
        .and_then(|kv| kv.split_once('='))
95 +
        .map(|(_, v)| v.to_string());
96 +
    let bytes = res.into_body().collect().await.unwrap().to_bytes();
97 +
    Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie }
98 +
}
99 +
100 +
async fn api(app: &Shared, method: &str, path: &str, bearer: Option<&str>, json: Option<&str>) -> (StatusCode, serde_json::Value) {
101 +
    let r = call(app, method, path, bearer, None, json, None).await;
102 +
    let v = serde_json::from_str(&r.body).unwrap_or(serde_json::Value::String(r.body.clone()));
103 +
    (r.status, v)
104 +
}
105 +
106 +
fn csrf_of(page: &str) -> String {
107 +
    let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len();
108 +
    page[i..].split('"').next().unwrap().to_string()
109 +
}
110 +
111 +
/// A webhook receiver that records what it gets and fails the first time.
112 +
struct Receiver {
113 +
    url: String,
114 +
    seen: Arc<Mutex<Vec<(String, String, String)>>>,
115 +
    fail_first: Arc<Mutex<bool>>,
116 +
}
117 +
118 +
async fn receiver() -> Receiver {
119 +
    let seen: Arc<Mutex<Vec<(String, String, String)>>> = Arc::new(Mutex::new(vec![]));
120 +
    let fail_first = Arc::new(Mutex::new(true));
121 +
    let (s2, f2) = (seen.clone(), fail_first.clone());
122 +
    let router = Router::new().route(
123 +
        "/hook",
124 +
        post(move |headers: header::HeaderMap, body: String| {
125 +
            let (s2, f2) = (s2.clone(), f2.clone());
126 +
            async move {
127 +
                let event = headers.get("x-oxyhub-event").and_then(|v| v.to_str().ok()).unwrap_or("").to_string();
128 +
                let sig = headers.get("x-oxyhub-signature").and_then(|v| v.to_str().ok()).unwrap_or("").to_string();
129 +
                let mut f = f2.lock().unwrap();
130 +
                if *f {
131 +
                    *f = false;
132 +
                    return (StatusCode::INTERNAL_SERVER_ERROR, "flaky");
133 +
                }
134 +
                s2.lock().unwrap().push((event, sig, body));
135 +
                (StatusCode::OK, "ok")
136 +
            }
137 +
        }),
138 +
    );
139 +
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
140 +
    let port = listener.local_addr().unwrap().port();
141 +
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
142 +
    Receiver { url: format!("http://127.0.0.1:{port}/hook"), seen, fail_first }
143 +
}
144 +
145 +
#[tokio::test(flavor = "multi_thread")]
146 +
async fn api_webhooks_and_checks() {
147 +
    let h = hub().await;
148 +
    let app = &h.app;
149 +
    // Accounts and tokens through the pages.
150 +
    let r =
151 +
        call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")).await;
152 +
    let andy = r.cookie.unwrap();
153 +
    let r =
154 +
        call(app, "POST", "/register", None, None, None, Some("username=bob&email=b%40example.com&password=correct-horse-battery")).await;
155 +
    let bob = r.cookie.unwrap();
156 +
    let mint = |cookie: String, scopes: &'static str| {
157 +
        let app = app.clone();
158 +
        async move {
159 +
            let page = call(&app, "GET", "/settings/tokens", None, Some(&cookie), None, None).await;
160 +
            let csrf = csrf_of(&page.body);
161 +
            let r = call(&app, "POST", "/settings/tokens", None, Some(&cookie), None, Some(&format!("_csrf={csrf}&name=t&{scopes}"))).await;
162 +
            r.body.split("oxyhub_").nth(1).map(|s| format!("oxyhub_{}", s.split('<').next().unwrap().trim())).unwrap()
163 +
        }
164 +
    };
165 +
    let andy_admin = mint(andy.clone(), "scopes=repo%3Aadmin").await;
166 +
    let andy_ro = mint(andy.clone(), "scopes=repo%3Aread").await;
167 +
    let bob_rw = mint(bob.clone(), "scopes=repo%3Aread&scopes=repo%3Awrite").await;
168 +
169 +
    // Create a repository through the API; the read-only token cannot.
170 +
    let (st, _) = api(app, "POST", "/api/v1/repos", Some(&andy_ro), Some(r#"{"owner":"andy","name":"x"}"#)).await;
171 +
    assert_eq!(st, StatusCode::FORBIDDEN);
172 +
    let (st, v) = api(
173 +
        app,
174 +
        "POST",
175 +
        "/api/v1/repos",
176 +
        Some(&andy_admin),
177 +
        Some(r#"{"owner":"andy","name":"demo","description":"d","visibility":"public"}"#),
178 +
    )
179 +
    .await;
180 +
    assert_eq!(st, StatusCode::CREATED, "{v}");
181 +
    assert_eq!(v["clone_url"], format!("{}/andy/demo", h.base));
182 +
    let (st, _) = api(app, "POST", "/api/v1/repos", Some(&andy_admin), Some(r#"{"owner":"andy","name":"demo"}"#)).await;
183 +
    assert_eq!(st, StatusCode::CONFLICT);
184 +
    let (st, _) = api(app, "GET", "/api/v1/user", Some(&andy_ro), None).await;
185 +
    assert_eq!(st, StatusCode::FORBIDDEN, "user:read is a scope of its own");
186 +
    let (st, v) = api(app, "GET", "/api/v1/users/andy", None, None).await;
187 +
    assert_eq!(st, StatusCode::OK);
188 +
    assert_eq!(v["username"], "andy");
189 +
190 +
    // A webhook on the repository; push, then see the delivery with a valid signature after a retry.
191 +
    let rx = receiver().await;
192 +
    let (st, v) = api(
193 +
        app,
194 +
        "POST",
195 +
        "/api/v1/repos/andy/demo/hooks",
196 +
        Some(&andy_admin),
197 +
        Some(&format!(r#"{{"url":"{}","secret":"s3","events":"push,proposal,check"}}"#, rx.url)),
198 +
    )
199 +
    .await;
200 +
    assert_eq!(st, StatusCode::CREATED, "{v}");
201 +
    let work = tempfile::tempdir().unwrap();
202 +
    let mut client = Repo::init(work.path()).unwrap();
203 +
    client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap();
204 +
    client.config.set("remote.origin.token", "site").unwrap();
205 +
    client.save_config().unwrap();
206 +
    write(work.path(), "a.txt", b"a\n");
207 +
    client.snapshot("t").unwrap();
208 +
    let base = client.commit("base", &[]).unwrap();
209 +
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
210 +
    // The worker retries with backoff; drive it directly for the retry.
211 +
    tokio::time::sleep(std::time::Duration::from_millis(400)).await;
212 +
    assert!(rx.seen.lock().unwrap().is_empty(), "the first attempt failed");
213 +
    sqlx::query("UPDATE deliveries SET next_ms = 0").execute(&app.db).await.unwrap();
214 +
    oxygenhub::hooks::deliver_due(app).await;
215 +
    let seen = rx.seen.lock().unwrap().clone();
216 +
    assert_eq!(seen.len(), 1, "{seen:?}");
217 +
    assert_eq!(seen[0].0, "push");
218 +
    assert_eq!(seen[0].1, oxygenhub::hooks::signature("s3", seen[0].2.as_bytes()));
219 +
    let payload: serde_json::Value = serde_json::from_str(&seen[0].2).unwrap();
220 +
    assert_eq!(payload["repository"]["name"], "demo");
221 +
    assert_eq!(payload["data"]["updates"][0]["reference"], "main");
222 +
    let (_, v) = api(app, "GET", "/api/v1/repos/andy/demo/hooks", Some(&andy_admin), None).await;
223 +
    assert_eq!(v.as_array().unwrap().len(), 1);
224 +
225 +
    // History, tree, file, blame, bookmarks, trust, pushes through the API.
226 +
    let (st, v) = api(app, "GET", "/api/v1/repos/andy/demo/history/main", None, None).await;
227 +
    assert_eq!(st, StatusCode::OK);
228 +
    assert_eq!(v["changes"][0]["hash"], base.to_hex());
229 +
    let (_, v) = api(app, "GET", "/api/v1/repos/andy/demo/tree/main", None, None).await;
230 +
    assert_eq!(v[0]["name"], "a.txt");
231 +
    let r = call(app, "GET", "/api/v1/repos/andy/demo/file/main/a.txt", None, None, None, None).await;
232 +
    assert_eq!(r.body, "a\n");
233 +
    let (_, v) = api(app, "GET", "/api/v1/repos/andy/demo/blame/main/a.txt", None, None).await;
234 +
    assert_eq!(v[0]["change"], base.to_hex());
235 +
    let (_, v) = api(app, "GET", &format!("/api/v1/repos/andy/demo/changes/{}", base.to_hex()), None, None).await;
236 +
    assert_eq!(v["files"][0]["path"], "a.txt");
237 +
    let (_, v) = api(app, "GET", "/api/v1/repos/andy/demo/trust", None, None).await;
238 +
    assert!(v["policy"].is_null());
239 +
    let (_, v) = api(app, "GET", "/api/v1/repos/andy/demo/pushes", None, None).await;
240 +
    assert_eq!(v[0]["reference"], "main");
241 +
242 +
    // Issues through the API: Bob opens one, comments, and closes it.
243 +
    let (st, v) = api(app, "POST", "/api/v1/repos/andy/demo/issues", Some(&bob_rw), Some(r#"{"title":"Bug","body":"it broke"}"#)).await;
244 +
    assert_eq!(st, StatusCode::CREATED, "{v}");
245 +
    assert_eq!(v["number"], 1);
246 +
    let (st, _) = api(app, "POST", "/api/v1/repos/andy/demo/issues/1/comments", Some(&bob_rw), Some(r#"{"body":"more"}"#)).await;
247 +
    assert_eq!(st, StatusCode::CREATED);
248 +
    let (st, v) = api(app, "POST", "/api/v1/repos/andy/demo/issues/1", Some(&bob_rw), Some(r#"{"state":"closed"}"#)).await;
249 +
    assert_eq!(st, StatusCode::OK, "{v}");
250 +
    assert_eq!(v["state"], "closed");
251 +
    let (_, v) = api(app, "GET", "/api/v1/repos/andy/demo/issues?state=all", None, None).await;
252 +
    assert_eq!(v[0]["comments"], 1);
253 +
    let (_, v) = api(app, "GET", "/api/v1/search?q=broke", None, None).await;
254 +
    assert_eq!(v[0]["number"], 1);
255 +
256 +
    // A proposal through the API, then a required check gates its landing.
257 +
    client.bookmark_create("feat", Some(&[base])).unwrap();
258 +
    client.bookmark_switch("feat", false).unwrap();
259 +
    write(work.path(), "b.txt", b"b\n");
260 +
    client.snapshot("t").unwrap();
261 +
    let feat = client.commit("feat", &[]).unwrap();
262 +
    assert!(client.push_bookmark("origin", "feat", false).unwrap().iter().all(|r| r.ok));
263 +
    let (st, v) =
264 +
        api(app, "POST", "/api/v1/repos/andy/demo/proposals", Some(&bob_rw), Some(r#"{"title":"Feat","target":"main","source":"feat"}"#))
265 +
            .await;
266 +
    assert_eq!(st, StatusCode::CREATED, "{v}");
267 +
    assert_eq!(v["number"], 2);
268 +
    let state = v["projected_state"].as_str().unwrap().to_string();
269 +
    assert_eq!(v["landable"], true);
270 +
    // Require the "build" check.
271 +
    let settings = call(app, "GET", "/andy/demo/settings", None, Some(&andy), None, None).await;
272 +
    let csrf = csrf_of(&settings.body);
273 +
    assert_eq!(
274 +
        call(app, "POST", "/andy/demo/settings/checks", None, Some(&andy), None, Some(&format!("_csrf={csrf}&required_checks=build")))
275 +
            .await
276 +
            .status,
277 +
        StatusCode::OK
278 +
    );
279 +
    let (_, v) = api(app, "GET", "/api/v1/repos/andy/demo/proposals/2", None, None).await;
280 +
    assert_eq!(v["landable"], false);
281 +
    assert!(v["blockers"][0].as_str().unwrap().contains("build"), "{v}");
282 +
    // A checker reports on the projected state: first running, then passed; the page streams it.
283 +
    let (st, v) = api(
284 +
        app,
285 +
        "POST",
286 +
        "/api/v1/repos/andy/demo/checks",
287 +
        Some(&andy_ro),
288 +
        Some(&format!(r#"{{"state":"{state}","name":"build","status":"running"}}"#)),
289 +
    )
290 +
    .await;
291 +
    assert_eq!(st, StatusCode::FORBIDDEN, "{v}");
292 +
    let mut events = app.events.proposals.subscribe();
293 +
    let (st, v) = api(
294 +
        app,
295 +
        "POST",
296 +
        "/api/v1/repos/andy/demo/checks",
297 +
        Some(&bob_rw),
298 +
        Some(&format!(r#"{{"state":"{state}","name":"build","status":"passed","summary":"42 tests","url":"https://ci.example/1"}}"#)),
299 +
    )
300 +
    .await;
301 +
    assert_eq!(st, StatusCode::CREATED, "{v}");
302 +
    let (_, ev) = events.recv().await.unwrap();
303 +
    assert_eq!(ev.kind, "check");
304 +
    assert_eq!(ev.check.unwrap().name, "build");
305 +
    let (_, v) = api(app, "GET", "/api/v1/repos/andy/demo/proposals/2/checks", None, None).await;
306 +
    assert_eq!(v[0]["status"], "passed");
307 +
    let (_, v) = api(app, "GET", "/api/v1/repos/andy/demo/proposals/2", None, None).await;
308 +
    assert_eq!(v["landable"], true, "{v}");
309 +
    let page = call(app, "GET", "/andy/demo/proposal/2", None, Some(&andy), None, None).await;
310 +
    assert!(page.body.contains("42 tests"), "{}", page.body);
311 +
    // The check and the proposal reached the webhook too.
312 +
    sqlx::query("UPDATE deliveries SET next_ms = 0").execute(&app.db).await.unwrap();
313 +
    oxygenhub::hooks::deliver_due(app).await;
314 +
    let kinds: Vec<String> = rx.seen.lock().unwrap().iter().map(|s| s.0.clone()).collect();
315 +
    assert!(kinds.contains(&"proposal.opened".to_string()) && kinds.contains(&"check".to_string()), "{kinds:?}");
316 +
    let _ = feat;
317 +
    let _ = rx.fail_first;
318 +
}
added templates/deliveries.html +20 −0
1 +
{% extends "base.html" %}
2 +
{% import "repo_tabs.html" as m %}
3 +
{% block title %}Deliveries · {{ repo.owner }}/{{ repo.name }}{% endblock %}
4 +
{% block tabs %}{% call m::tabs(repo, "settings") %}{% endblock %}
5 +
{% block mainclass %} class="wide"{% endblock %}
6 +
{% block content %}
7 +
<div class="head"><h1>Deliveries <small>{{ hook.url }} · events {{ hook.events }}</small></h1></div>
8 +
<p><a href="{{ repo.url }}/settings">← settings</a></p>
9 +
{% if rows.is_empty() %}<p class="empty">Nothing delivered yet.</p>{% else %}
10 +
<div class="table"><table>
11 +
<tr><th>When</th><th>Event</th><th>Delivery</th><th>Attempts</th><th>Status</th><th>Response</th><th></th></tr>
12 +
{% for d in rows %}
13 +
<tr><td><time title="{{ d.created_ms|when_i }}">{{ d.created_ms|ago_i }}</time></td><td>{{ d.event }}</td><td><code>{{ d.delivery }}</code></td><td>{{ d.attempts }}</td>
14 +
<td>{% if let Some(s) = d.status %}{% if d.delivered_ms.is_some() %}<span class="sig sig-valid">✔</span>{% else %}<span class="sig sig-invalid">✘</span>{% endif %} {{ s }}{% else if d.attempts > 0 %}<span class="sig sig-invalid">✘</span> no response{% else %}<span class="mute">queued</span>{% endif %}</td>
15 +
<td class="small mute">{{ d.response|truncate(80) }}</td>
16 +
<td><form class="tiny" method="post" action="{{ repo.url }}/settings/hooks/{{ hook.id }}/redeliver/{{ d.id }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button>redeliver</button></form></td></tr>
17 +
{% endfor %}
18 +
</table></div>
19 +
{% endif %}
20 +
{% endblock %}
modified Cargo.lock +95 −0
…
883 unchanged lines
884 884
]
885 885
886 886
[[package]]
887 +
name = "dyn-clone"
888 +
version = "1.0.20"
889 +
source = "registry+https://github.com/rust-lang/crates.io-index"
890 +
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
891 +
892 +
[[package]]
887 893
name = "ed25519"
888 894
version = "3.0.0"
889 895
source = "registry+https://github.com/rust-lang/crates.io-index"
…
175 unchanged lines
1065 1071
]
1066 1072
1067 1073
[[package]]
1074 +
name = "futures"
1075 +
version = "0.3.34"
1076 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1077 +
checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
1078 +
dependencies = [
1079 +
 "futures-channel",
1080 +
 "futures-core",
1081 +
 "futures-executor",
1082 +
 "futures-io",
1083 +
 "futures-sink",
1084 +
 "futures-task",
1085 +
 "futures-util",
1086 +
]
1087 +
1088 +
[[package]]
1068 1089
name = "futures-channel"
1069 1090
version = "0.3.34"
1070 1091
source = "registry+https://github.com/rust-lang/crates.io-index"
…
38 unchanged lines
1109 1130
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
1110 1131
1111 1132
[[package]]
1133 +
name = "futures-macro"
1134 +
version = "0.3.34"
1135 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1136 +
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
1137 +
dependencies = [
1138 +
 "proc-macro2",
1139 +
 "quote",
1140 +
 "syn 3.0.6",
1141 +
]
1142 +
1143 +
[[package]]
1112 1144
name = "futures-sink"
1113 1145
version = "0.3.34"
1114 1146
source = "registry+https://github.com/rust-lang/crates.io-index"
…
11 unchanged lines
1126 1158
source = "registry+https://github.com/rust-lang/crates.io-index"
1127 1159
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
1128 1160
dependencies = [
1161 +
 "futures-channel",
1129 1162
 "futures-core",
1130 1163
 "futures-io",
1164 +
 "futures-macro",
1131 1165
 "futures-sink",
1132 1166
 "futures-task",
1133 1167
 "memchr",
…
195 unchanged lines
1329 1363
name = "hub-api"
1330 1364
version = "0.1.0"
1331 1365
dependencies = [
1366 +
 "schemars",
1332 1367
 "serde",
1368 +
 "serde_json",
1333 1369
]
1334 1370
1335 1371
[[package]]
…
670 unchanged lines
2006 2042
 "base32",
2007 2043
 "blake3",
2008 2044
 "clap",
2045 +
 "futures",
2009 2046
 "hex",
2010 2047
 "hmac",
2011 2048
 "http-body-util",
…
19 unchanged lines
2031 2068
 "thiserror 2.0.21",
2032 2069
 "time",
2033 2070
 "tokio",
2071 +
 "tokio-stream",
2034 2072
 "toml",
2035 2073
 "tower",
2036 2074
 "tower-http",
…
405 unchanged lines
2442 2480
]
2443 2481
2444 2482
[[package]]
2483 +
name = "ref-cast"
2484 +
version = "1.0.27"
2485 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2486 +
checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3"
2487 +
dependencies = [
2488 +
 "ref-cast-impl",
2489 +
]
2490 +
2491 +
[[package]]
2492 +
name = "ref-cast-impl"
2493 +
version = "1.0.27"
2494 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2495 +
checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
2496 +
dependencies = [
2497 +
 "proc-macro2",
2498 +
 "quote",
2499 +
 "syn 3.0.6",
2500 +
]
2501 +
2502 +
[[package]]
2445 2503
name = "regex"
2446 2504
version = "1.13.1"
2447 2505
source = "registry+https://github.com/rust-lang/crates.io-index"
…
198 unchanged lines
2646 2704
]
2647 2705
2648 2706
[[package]]
2707 +
name = "schemars"
2708 +
version = "1.2.2"
2709 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2710 +
checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
2711 +
dependencies = [
2712 +
 "dyn-clone",
2713 +
 "ref-cast",
2714 +
 "schemars_derive",
2715 +
 "serde",
2716 +
 "serde_json",
2717 +
]
2718 +
2719 +
[[package]]
2720 +
name = "schemars_derive"
2721 +
version = "1.2.2"
2722 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2723 +
checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba"
2724 +
dependencies = [
2725 +
 "proc-macro2",
2726 +
 "quote",
2727 +
 "serde_derive_internals",
2728 +
 "syn 3.0.6",
2729 +
]
2730 +
2731 +
[[package]]
2649 2732
name = "scopeguard"
2650 2733
version = "1.2.0"
2651 2734
source = "registry+https://github.com/rust-lang/crates.io-index"
…
36 unchanged lines
2688 2771
]
2689 2772
2690 2773
[[package]]
2774 +
name = "serde_derive_internals"
2775 +
version = "0.30.0"
2776 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2777 +
checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd"
2778 +
dependencies = [
2779 +
 "proc-macro2",
2780 +
 "quote",
2781 +
 "syn 3.0.6",
2782 +
]
2783 +
2784 +
[[package]]
2691 2785
name = "serde_html_form"
2692 2786
version = "0.2.8"
2693 2787
source = "registry+https://github.com/rust-lang/crates.io-index"
…
699 unchanged lines
3393 3487
 "futures-core",
3394 3488
 "pin-project-lite",
3395 3489
 "tokio",
3490 +
 "tokio-util",
3396 3491
]
3397 3492
3398 3493
[[package]]
…
781 unchanged lines
modified Cargo.toml +4 −1
…
47 unchanged lines
48 48
lettre = { version = "0.11", default-features = false, features = ["tokio1", "tokio1-rustls-tls", "smtp-transport", "builder"] }
49 49
regex = "1"
50 50
tempfile = "3"
51 -
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
51 +
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
52 +
schemars = "1"
53 +
futures = "0.3"
54 +
tokio-stream = { version = "0.1", features = ["sync"] }
52 55
53 56
[profile.release]
54 57
debug = 1
modified Makefile +5 −1
1 -
.PHONY: build check test fmt clippy run deploy
1 +
.PHONY: build check test fmt clippy run deploy ts
2 2
3 3
build:
4 4
	cargo build --release
…
12 unchanged lines
17 17
run:
18 18
	cargo run -- --data data serve
19 19
20 +
# TypeScript declarations of the API types, for the VS Code extensions.
21 +
ts:
22 +
	cargo run -q -p hub-api --bin hub-api-typescript > crates/hub-api/ts/index.d.ts
23 +
20 24
# Developer edition on Earth (docs/deploy.md).
21 25
deploy:
22 26
	rsync -a --delete --exclude target --exclude node_modules --exclude .oxy/objects/loose ../oxygen ../oxygenhub claude@earth:src/
…
1 unchanged lines
modified README.md +6 −3
…
9 unchanged lines
10 10
## Status
11 11
12 12
Milestones 0.1 (browse), 0.2 (accounts and access), 0.3 (proposals and
13 -
review) and 0.4 (issues and notifications) are done: pages show
13 +
review), 0.4 (issues and notifications) and 0.5 (API, webhooks, checks) are
14 +
done: pages show
14 15
trees, files, blame, history with the dependency graph, changes with diffs,
15 16
comparisons, bookmarks, tags, conflicts, the trust policy and the push log;
16 17
accounts sign in with a password and optional TOTP, create repositories and
…
4 unchanged lines
21 22
seals, and landing from the hub or from `oxy proposal land`; issues with
22 23
labels, assignees, milestones, cross references and closing keywords; an
23 24
inbox with watching, subscriptions, mentions and optional email; search
24 -
over issues, proposals, repositories and accounts. The API, webhooks and
25 -
checks follow in 0.5.
25 +
over issues, proposals, repositories and accounts; a JSON API for all of
26 +
it with scoped tokens, webhooks with signed deliveries and retries, and
27 +
checks on state hashes that gate landing. Web edits, releases and the Git
28 +
mirror follow in 0.6.
26 29
27 30
## Running
28 31
…
32 unchanged lines
modified crates/hub-api/Cargo.toml +6 −0
…
7 unchanged lines
8 8
9 9
[dependencies]
10 10
serde.workspace = true
11 +
serde_json.workspace = true
12 +
schemars = { version = "1", features = ["derive"] }
13 +
14 +
[[bin]]
15 +
name = "hub-api-typescript"
16 +
path = "src/bin/typescript.rs"
modified crates/hub-api/src/lib.rs +409 −12
…
5 unchanged lines
6 6
//! times as milliseconds since the epoch), so tools written against the
7 7
//! command line work against the hub.
8 8
9 +
use schemars::JsonSchema;
9 10
use serde::{Deserialize, Serialize};
10 11
11 12
/// A repository as the API lists it.
12 -
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
13 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
13 14
pub struct Repository {
14 15
    pub owner: String,
15 16
    pub name: String,
…
7 unchanged lines
23 24
    pub clone_url: String,
24 25
}
25 26
26 -
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
27 +
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
27 28
#[serde(rename_all = "lowercase")]
28 29
pub enum Visibility {
29 30
    Public,
…
21 unchanged lines
51 52
}
52 53
53 54
/// A bookmark: a name for a set of heads (spec §10.1).
54 -
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
55 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
55 56
pub struct Bookmark {
56 57
    pub name: String,
57 58
    pub heads: Vec<String>,
…
3 unchanged lines
61 62
}
62 63
63 64
/// A tag: a name for one state, with its attestation when signed.
64 -
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
65 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
65 66
pub struct Tag {
66 67
    pub name: String,
67 68
    pub heads: Vec<String>,
…
2 unchanged lines
70 71
}
71 72
72 73
/// One change in a history listing.
73 -
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
74 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
74 75
pub struct ChangeSummary {
75 76
    pub hash: String,
76 77
    pub message: String,
…
6 unchanged lines
83 84
84 85
/// Who wrote a change: the linked account when the signing key is linked,
85 86
/// else the key alone.
86 -
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
87 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
87 88
pub struct Author {
88 89
    pub name: String,
89 90
    pub email: String,
…
3 unchanged lines
93 94
}
94 95
95 96
/// Signature status of a change as `oxy verify` reports it.
96 -
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
97 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
97 98
#[serde(tag = "status", rename_all = "lowercase")]
98 99
pub enum Signature {
99 100
    Unsigned,
…
2 unchanged lines
102 103
}
103 104
104 105
/// An RFC 7807 problem response.
105 -
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
106 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
106 107
pub struct Problem {
107 108
    pub status: u16,
108 109
    pub title: String,
…
16 unchanged lines
125 126
// ---------------------------------------------------------------- proposals
126 127
127 128
/// A proposal: a request to add changes to a target bookmark (hub spec §7).
128 -
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
129 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
129 130
pub struct Proposal {
130 131
    pub number: i64,
131 132
    pub title: String,
…
21 unchanged lines
153 154
    pub source_url: Option<String>,
154 155
}
155 156
156 -
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
157 +
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
157 158
#[serde(rename_all = "lowercase")]
158 159
pub enum ProposalState {
159 160
    Draft,
…
23 unchanged lines
183 184
    }
184 185
}
185 186
186 -
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
187 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
187 188
#[serde(tag = "kind", rename_all = "lowercase")]
188 189
pub enum ProposalSource {
189 190
    /// A bookmark of the same repository.
…
6 unchanged lines
196 197
197 198
/// A reviewer's approval, counted only when backed by a seal over every
198 199
/// change of the proposal signed with a key linked to the reviewer.
199 -
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
200 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
200 201
pub struct Approval {
201 202
    pub account: String,
202 203
    pub sealed: bool,
203 204
    pub key: Option<String>,
204 205
}
206 +
207 +
// ------------------------------------------------------------------ issues
208 +
209 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
210 +
pub struct Issue {
211 +
    pub number: i64,
212 +
    pub title: String,
213 +
    pub body: String,
214 +
    pub author: String,
215 +
    pub state: String,
216 +
    pub reason: String,
217 +
    pub labels: Vec<Label>,
218 +
    pub assignees: Vec<String>,
219 +
    pub milestone: Option<String>,
220 +
    pub comments: i64,
221 +
    pub locked: bool,
222 +
    pub pinned: bool,
223 +
    pub created_ms: i64,
224 +
    pub updated_ms: i64,
225 +
    pub closed_ms: Option<i64>,
226 +
    pub url: String,
227 +
}
228 +
229 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
230 +
pub struct Label {
231 +
    pub name: String,
232 +
    pub colour: String,
233 +
    pub description: String,
234 +
}
235 +
236 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
237 +
pub struct Milestone {
238 +
    pub title: String,
239 +
    pub description: String,
240 +
    pub due_ms: Option<i64>,
241 +
    pub state: String,
242 +
    pub open: i64,
243 +
    pub closed: i64,
244 +
}
245 +
246 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
247 +
pub struct Comment {
248 +
    pub id: i64,
249 +
    pub author: String,
250 +
    pub body: String,
251 +
    pub created_ms: i64,
252 +
    /// For proposal comments: the file and the atom the comment anchors to.
253 +
    pub path: Option<String>,
254 +
    pub anchor: Option<Atom>,
255 +
    pub resolved: bool,
256 +
}
257 +
258 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
259 +
pub struct Atom {
260 +
    pub change: String,
261 +
    pub index: u32,
262 +
}
263 +
264 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
265 +
pub struct Review {
266 +
    pub id: i64,
267 +
    pub author: String,
268 +
    pub verdict: String,
269 +
    pub body: String,
270 +
    pub sealed: bool,
271 +
    pub created_ms: i64,
272 +
}
273 +
274 +
// ------------------------------------------------------------- repository
275 +
276 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
277 +
pub struct TreeEntry {
278 +
    pub name: String,
279 +
    pub path: String,
280 +
    pub kind: String,
281 +
    pub exec: bool,
282 +
    pub size: Option<u64>,
283 +
    pub conflicted: bool,
284 +
}
285 +
286 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
287 +
pub struct BlameLine {
288 +
    pub line: usize,
289 +
    pub change: String,
290 +
    pub index: u32,
291 +
    pub author: String,
292 +
    pub when_ms: u64,
293 +
    pub text: String,
294 +
}
295 +
296 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
297 +
pub struct FileChange {
298 +
    pub path: String,
299 +
    pub kind: String,
300 +
    pub from: Option<String>,
301 +
    pub added: usize,
302 +
    pub removed: usize,
303 +
}
304 +
305 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
306 +
pub struct ChangeDetail {
307 +
    pub change: ChangeSummary,
308 +
    pub body: String,
309 +
    pub dependents: Vec<String>,
310 +
    pub in_bookmarks: Vec<String>,
311 +
    pub in_tags: Vec<String>,
312 +
    pub files: Vec<FileChange>,
313 +
}
314 +
315 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
316 +
pub struct History {
317 +
    pub reference: String,
318 +
    pub heads: Vec<String>,
319 +
    pub total: usize,
320 +
    pub offset: usize,
321 +
    pub changes: Vec<ChangeSummary>,
322 +
}
323 +
324 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
325 +
pub struct Conflict {
326 +
    pub bookmark: String,
327 +
    pub path: String,
328 +
    pub kind: String,
329 +
    pub contributors: Vec<String>,
330 +
}
331 +
332 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
333 +
pub struct Push {
334 +
    pub at_ms: i64,
335 +
    pub pusher: String,
336 +
    pub reference: String,
337 +
    pub ok: bool,
338 +
    pub reason: String,
339 +
    pub old_state: String,
340 +
    pub new_state: String,
341 +
    pub heads: i64,
342 +
    pub changes: i64,
343 +
}
344 +
345 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
346 +
pub struct Trust {
347 +
    pub policy: Option<Policy>,
348 +
    pub signed: usize,
349 +
    pub sealed: usize,
350 +
    pub unsigned: usize,
351 +
    pub invalid: Vec<String>,
352 +
    pub hub_key: String,
353 +
}
354 +
355 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
356 +
pub struct Policy {
357 +
    pub serial: u64,
358 +
    pub expires_ms: u64,
359 +
    pub roots: Vec<String>,
360 +
    pub root_threshold: u32,
361 +
    pub rules: Vec<Rule>,
362 +
}
363 +
364 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
365 +
pub struct Rule {
366 +
    pub pattern: String,
367 +
    pub keys: Vec<String>,
368 +
    pub threshold: u32,
369 +
    pub require: String,
370 +
    pub allow_conflicts: bool,
371 +
}
372 +
373 +
// ---------------------------------------------------------- hooks, checks
374 +
375 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
376 +
pub struct Hook {
377 +
    pub id: i64,
378 +
    pub url: String,
379 +
    pub events: String,
380 +
    pub active: bool,
381 +
    pub created_ms: i64,
382 +
}
383 +
384 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
385 +
pub struct NewHook {
386 +
    pub url: String,
387 +
    pub secret: String,
388 +
    #[serde(default = "star")]
389 +
    pub events: String,
390 +
}
391 +
392 +
fn star() -> String {
393 +
    "*".into()
394 +
}
395 +
396 +
/// A check on a state hash (hub spec §13.1).
397 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
398 +
pub struct Check {
399 +
    pub state: String,
400 +
    pub name: String,
401 +
    pub status: CheckStatus,
402 +
    pub summary: String,
403 +
    pub url: String,
404 +
    pub reporter: String,
405 +
    /// The seal attestation's reference when the checker sealed the changes.
406 +
    pub seal: Option<String>,
407 +
    pub started_ms: i64,
408 +
    pub finished_ms: Option<i64>,
409 +
}
410 +
411 +
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
412 +
#[serde(rename_all = "lowercase")]
413 +
pub enum CheckStatus {
414 +
    Queued,
415 +
    Running,
416 +
    Passed,
417 +
    Failed,
418 +
    Cancelled,
419 +
    Skipped,
420 +
}
421 +
422 +
impl CheckStatus {
423 +
    pub fn as_str(self) -> &'static str {
424 +
        match self {
425 +
            CheckStatus::Queued => "queued",
426 +
            CheckStatus::Running => "running",
427 +
            CheckStatus::Passed => "passed",
428 +
            CheckStatus::Failed => "failed",
429 +
            CheckStatus::Cancelled => "cancelled",
430 +
            CheckStatus::Skipped => "skipped",
431 +
        }
432 +
    }
433 +
434 +
    pub fn parse(s: &str) -> Option<CheckStatus> {
435 +
        Some(match s {
436 +
            "queued" => CheckStatus::Queued,
437 +
            "running" => CheckStatus::Running,
438 +
            "passed" => CheckStatus::Passed,
439 +
            "failed" => CheckStatus::Failed,
440 +
            "cancelled" => CheckStatus::Cancelled,
441 +
            "skipped" => CheckStatus::Skipped,
442 +
            _ => return None,
443 +
        })
444 +
    }
445 +
}
446 +
447 +
/// What a checker posts.
448 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
449 +
pub struct NewCheck {
450 +
    pub state: String,
451 +
    pub name: String,
452 +
    pub status: CheckStatus,
453 +
    #[serde(default)]
454 +
    pub summary: String,
455 +
    #[serde(default)]
456 +
    pub url: String,
457 +
    /// Hex of a seal attestation reference the checker pushed, if any.
458 +
    #[serde(default)]
459 +
    pub seal: Option<String>,
460 +
}
461 +
462 +
// ------------------------------------------------------------- accounts
463 +
464 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
465 +
pub struct User {
466 +
    pub username: String,
467 +
    pub display_name: String,
468 +
    pub is_admin: bool,
469 +
    pub keys: Vec<String>,
470 +
    pub created_ms: i64,
471 +
}
472 +
473 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
474 +
pub struct Organisation {
475 +
    pub name: String,
476 +
    pub display_name: String,
477 +
    pub description: String,
478 +
    pub members: Vec<String>,
479 +
}
480 +
481 +
/// Forms accepted by the write endpoints.
482 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
483 +
pub struct NewRepository {
484 +
    pub owner: String,
485 +
    pub name: String,
486 +
    #[serde(default)]
487 +
    pub description: String,
488 +
    #[serde(default = "public")]
489 +
    pub visibility: Visibility,
490 +
}
491 +
492 +
fn public() -> Visibility {
493 +
    Visibility::Public
494 +
}
495 +
496 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
497 +
pub struct NewIssue {
498 +
    pub title: String,
499 +
    #[serde(default)]
500 +
    pub body: String,
501 +
}
502 +
503 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
504 +
pub struct NewComment {
505 +
    pub body: String,
506 +
}
507 +
508 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
509 +
pub struct NewProposal {
510 +
    pub title: String,
511 +
    #[serde(default)]
512 +
    pub body: String,
513 +
    pub target: String,
514 +
    /// A bookmark here, or `owner/name:bookmark` of a fork.
515 +
    pub source: String,
516 +
    #[serde(default)]
517 +
    pub draft: bool,
518 +
}
519 +
520 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
521 +
pub struct NewReview {
522 +
    pub verdict: String,
523 +
    #[serde(default)]
524 +
    pub body: String,
525 +
}
526 +
527 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
528 +
pub struct IssueStateChange {
529 +
    /// `open` or `closed`.
530 +
    pub state: String,
531 +
    #[serde(default)]
532 +
    pub reason: String,
533 +
}
534 +
535 +
/// A webhook payload's envelope (hub spec §12.2).
536 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
537 +
pub struct WebhookEnvelope {
538 +
    pub event: String,
539 +
    pub delivery: String,
540 +
    pub repository: Option<Repository>,
541 +
    pub actor: String,
542 +
    pub at_ms: i64,
543 +
    pub data: serde_json::Value,
544 +
}
545 +
546 +
/// A server-sent event on a proposal's stream.
547 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
548 +
pub struct ProposalEvent {
549 +
    pub number: i64,
550 +
    pub kind: String,
551 +
    pub check: Option<Check>,
552 +
}
553 +
554 +
/// Every type of the API, for schema generation.
555 +
pub fn schemas() -> Vec<(&'static str, schemars::Schema)> {
556 +
    vec![
557 +
        ("Repository", schemars::schema_for!(Repository)),
558 +
        ("Visibility", schemars::schema_for!(Visibility)),
559 +
        ("Bookmark", schemars::schema_for!(Bookmark)),
560 +
        ("Tag", schemars::schema_for!(Tag)),
561 +
        ("ChangeSummary", schemars::schema_for!(ChangeSummary)),
562 +
        ("Author", schemars::schema_for!(Author)),
563 +
        ("Signature", schemars::schema_for!(Signature)),
564 +
        ("Problem", schemars::schema_for!(Problem)),
565 +
        ("Proposal", schemars::schema_for!(Proposal)),
566 +
        ("ProposalState", schemars::schema_for!(ProposalState)),
567 +
        ("ProposalSource", schemars::schema_for!(ProposalSource)),
568 +
        ("Approval", schemars::schema_for!(Approval)),
569 +
        ("Issue", schemars::schema_for!(Issue)),
570 +
        ("Label", schemars::schema_for!(Label)),
571 +
        ("Milestone", schemars::schema_for!(Milestone)),
572 +
        ("Comment", schemars::schema_for!(Comment)),
573 +
        ("Atom", schemars::schema_for!(Atom)),
574 +
        ("Review", schemars::schema_for!(Review)),
575 +
        ("TreeEntry", schemars::schema_for!(TreeEntry)),
576 +
        ("BlameLine", schemars::schema_for!(BlameLine)),
577 +
        ("FileChange", schemars::schema_for!(FileChange)),
578 +
        ("ChangeDetail", schemars::schema_for!(ChangeDetail)),
579 +
        ("History", schemars::schema_for!(History)),
580 +
        ("Conflict", schemars::schema_for!(Conflict)),
581 +
        ("Push", schemars::schema_for!(Push)),
582 +
        ("Trust", schemars::schema_for!(Trust)),
583 +
        ("Policy", schemars::schema_for!(Policy)),
584 +
        ("Rule", schemars::schema_for!(Rule)),
585 +
        ("Hook", schemars::schema_for!(Hook)),
586 +
        ("NewHook", schemars::schema_for!(NewHook)),
587 +
        ("Check", schemars::schema_for!(Check)),
588 +
        ("CheckStatus", schemars::schema_for!(CheckStatus)),
589 +
        ("NewCheck", schemars::schema_for!(NewCheck)),
590 +
        ("User", schemars::schema_for!(User)),
591 +
        ("Organisation", schemars::schema_for!(Organisation)),
592 +
        ("NewRepository", schemars::schema_for!(NewRepository)),
593 +
        ("NewIssue", schemars::schema_for!(NewIssue)),
594 +
        ("NewComment", schemars::schema_for!(NewComment)),
595 +
        ("NewProposal", schemars::schema_for!(NewProposal)),
596 +
        ("NewReview", schemars::schema_for!(NewReview)),
597 +
        ("IssueStateChange", schemars::schema_for!(IssueStateChange)),
598 +
        ("WebhookEnvelope", schemars::schema_for!(WebhookEnvelope)),
599 +
        ("ProposalEvent", schemars::schema_for!(ProposalEvent)),
600 +
    ]
601 +
}
modified crates/hub/Cargo.toml +3 −1
…
47 unchanged lines
48 48
blake3.workspace = true
49 49
lettre.workspace = true
50 50
regex.workspace = true
51 +
reqwest.workspace = true
52 +
futures.workspace = true
53 +
tokio-stream.workspace = true
51 54
52 55
[dev-dependencies]
53 56
tempfile.workspace = true
54 -
reqwest.workspace = true
55 57
tower = { version = "0.5", features = ["util"] }
56 58
http-body-util = "0.1"
modified crates/hub/src/api/mod.rs +981 −77
1 -
//! The JSON API (hub spec §12.1) under `/api/v1`. Milestone 0.1 exposes
2 -
//! repositories, bookmarks and tags; the rest follows the pages.
1 +
//! The JSON API (hub spec §12.1) under `/api/v1`: bearer tokens, RFC 7807
2 +
//! problems, the same shapes as `oxy --json` where one exists. Reads need
3 +
//! `repo:read` (anonymous for public repositories); writes the scope named
4 +
//! on each handler.
3 5
6 +
use crate::auth::{self, Access, Role};
7 +
use crate::db::{self, accounts::Account};
4 8
use crate::repos::{view, RepoError, RepoName};
5 -
use crate::{db, Shared};
6 -
use axum::extract::{Path, State};
7 -
use axum::http::StatusCode;
9 +
use crate::Shared;
10 +
use axum::extract::{Path, Query, State};
11 +
use axum::http::{HeaderMap, StatusCode};
8 12
use axum::response::{IntoResponse, Response};
9 -
use axum::routing::get;
13 +
use axum::routing::{get, post};
10 14
use axum::{Json, Router};
11 -
use hub_api::{Bookmark, Problem, Repository, Tag};
15 +
use hub_api::*;
16 +
use serde::Deserialize;
12 17
13 18
pub fn router() -> Router<Shared> {
14 19
    Router::new()
15 -
        .route("/repos", get(list_repos))
20 +
        .route("/user", get(me))
21 +
        .route("/users/{name}", get(user))
22 +
        .route("/orgs/{name}", get(org))
23 +
        .route("/search", get(search))
24 +
        .route("/repos", get(list_repos).post(create_repo))
16 25
        .route("/repos/{owner}/{name}", get(get_repo))
17 26
        .route("/repos/{owner}/{name}/bookmarks", get(bookmarks))
18 27
        .route("/repos/{owner}/{name}/tags", get(tags))
19 -
        .route("/repos/{owner}/{name}/proposals", get(proposals))
28 +
        .route("/repos/{owner}/{name}/history/{r}", get(history))
29 +
        .route("/repos/{owner}/{name}/changes/{hash}", get(change))
30 +
        .route("/repos/{owner}/{name}/tree/{r}", get(tree_root))
31 +
        .route("/repos/{owner}/{name}/tree/{r}/{*path}", get(tree))
32 +
        .route("/repos/{owner}/{name}/file/{r}/{*path}", get(file))
33 +
        .route("/repos/{owner}/{name}/blame/{r}/{*path}", get(blame))
34 +
        .route("/repos/{owner}/{name}/conflicts", get(conflicts))
35 +
        .route("/repos/{owner}/{name}/trust", get(trust))
36 +
        .route("/repos/{owner}/{name}/pushes", get(pushes))
37 +
        .route("/repos/{owner}/{name}/proposals", get(proposals).post(create_proposal))
20 38
        .route("/repos/{owner}/{name}/proposals/{number}", get(proposal))
39 +
        .route("/repos/{owner}/{name}/proposals/{number}/comments", get(proposal_comments).post(proposal_comment))
40 +
        .route("/repos/{owner}/{name}/proposals/{number}/reviews", get(proposal_reviews).post(proposal_review))
41 +
        .route("/repos/{owner}/{name}/proposals/{number}/checks", get(proposal_checks))
42 +
        .route("/repos/{owner}/{name}/issues", get(issues).post(create_issue))
43 +
        .route("/repos/{owner}/{name}/issues/{number}", get(issue).post(issue_state))
44 +
        .route("/repos/{owner}/{name}/issues/{number}/comments", get(issue_comments).post(issue_comment))
45 +
        .route("/repos/{owner}/{name}/labels", get(labels))
46 +
        .route("/repos/{owner}/{name}/milestones", get(milestones))
47 +
        .route("/repos/{owner}/{name}/hooks", get(hooks).post(create_hook))
48 +
        .route("/repos/{owner}/{name}/hooks/{id}/delete", post(delete_hook))
49 +
        .route("/repos/{owner}/{name}/checks", post(post_check))
50 +
        .route("/repos/{owner}/{name}/checks/{state}", get(checks_for_state))
21 51
}
22 52
23 -
/// The account a bearer token resolves to, for API calls that must see
24 -
/// what the caller may see.
25 -
async fn caller(app: &Shared, headers: &axum::http::HeaderMap) -> Result<Option<db::accounts::Account>, ApiError> {
26 -
    let Some(t) = oxy_server::bearer_token(headers) else { return Ok(None) };
27 -
    if app.config.auth.push_tokens.iter().any(|x| crate::auth::mac_equal(x, t)) {
28 -
        // A site token acts as an administrator.
29 -
        return Ok(db::accounts::accounts(&app.db).await?.into_iter().find(|a| a.is_admin));
53 +
pub struct ApiError(StatusCode, String);
54 +
55 +
impl IntoResponse for ApiError {
56 +
    fn into_response(self) -> Response {
57 +
        let body = Problem { status: self.0.as_u16(), title: self.0.canonical_reason().unwrap_or("error").into(), detail: self.1 };
58 +
        (self.0, [("content-type", "application/problem+json")], Json(body)).into_response()
30 59
    }
31 -
    let token = db::accounts::token_by_hash(&app.db, &crate::auth::token_hash(t)).await?;
60 +
}
61 +
62 +
impl From<RepoError> for ApiError {
63 +
    fn from(e: RepoError) -> ApiError {
64 +
        match e {
65 +
            RepoError::NotFound => not_found("no such repository"),
66 +
            RepoError::Oxy(oxy_core::repo::RepoError::Msg(m)) => ApiError(StatusCode::NOT_FOUND, m),
67 +
            other => ApiError(StatusCode::INTERNAL_SERVER_ERROR, other.to_string()),
68 +
        }
69 +
    }
70 +
}
71 +
72 +
impl From<sqlx::Error> for ApiError {
73 +
    fn from(e: sqlx::Error) -> ApiError {
74 +
        ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
75 +
    }
76 +
}
77 +
78 +
fn not_found(what: &str) -> ApiError {
79 +
    ApiError(StatusCode::NOT_FOUND, what.into())
80 +
}
81 +
82 +
fn forbidden(what: &str) -> ApiError {
83 +
    ApiError(StatusCode::FORBIDDEN, what.into())
84 +
}
85 +
86 +
/// Who calls: the account behind a bearer token, with the token's scopes.
87 +
pub struct Caller {
88 +
    pub account: Option<Account>,
89 +
    pub scopes: Vec<String>,
90 +
    pub site: bool,
91 +
}
92 +
93 +
impl Caller {
94 +
    fn has(&self, scope: &str) -> bool {
95 +
        self.site
96 +
            || self.scopes.iter().any(|s| {
97 +
                s == scope
98 +
                    || (scope == "repo:read" && (s == "repo:write" || s == "repo:admin"))
99 +
                    || (scope == "repo:write" && s == "repo:admin")
100 +
            })
101 +
    }
102 +
103 +
    fn require(&self, scope: &str) -> Result<&Account, ApiError> {
104 +
        if !self.has(scope) {
105 +
            return Err(forbidden(&format!("this token lacks the {scope} scope")));
106 +
        }
107 +
        self.account.as_ref().ok_or_else(|| ApiError(StatusCode::UNAUTHORIZED, "a personal access token is required".into()))
108 +
    }
109 +
}
110 +
111 +
pub async fn caller(app: &Shared, headers: &HeaderMap) -> Result<Caller, ApiError> {
112 +
    let Some(t) = oxy_server::bearer_token(headers) else { return Ok(Caller { account: None, scopes: vec![], site: false }) };
113 +
    if app.config.auth.push_tokens.iter().any(|x| auth::mac_equal(x, t)) {
114 +
        let admin = db::accounts::accounts(&app.db).await?.into_iter().find(|a| a.is_admin);
115 +
        return Ok(Caller { account: admin, scopes: vec!["repo:admin".into(), "user:read".into(), "checks:write".into()], site: true });
116 +
    }
117 +
    let token = db::accounts::token_by_hash(&app.db, &auth::token_hash(t)).await?;
32 118
    match token {
33 -
        Some(tok) if !tok.expired(db::now_ms()) => Ok(db::accounts::account_by_id(&app.db, tok.account_id).await?),
119 +
        Some(tok) if !tok.expired(db::now_ms()) => {
120 +
            let account = db::accounts::account_by_id(&app.db, tok.account_id).await?.filter(|a| !a.banned);
121 +
            db::accounts::touch_token(&app.db, tok.id).await.ok();
122 +
            Ok(Caller { account, scopes: tok.scope_list().iter().map(|s| s.to_string()).collect(), site: false })
123 +
        }
34 124
        _ => Err(ApiError(StatusCode::UNAUTHORIZED, "unknown or expired token".into())),
35 125
    }
36 126
}
37 127
38 -
async fn readable(
128 +
fn repository(app: &Shared, r: &db::repos::RepoRow) -> Repository {
129 +
    Repository {
130 +
        owner: r.owner.clone(),
131 +
        name: r.name.clone(),
132 +
        description: r.description.clone(),
133 +
        default_bookmark: r.default_bookmark.clone(),
134 +
        visibility: r.visibility(),
135 +
        created_ms: r.created_ms,
136 +
        updated_ms: r.updated_ms,
137 +
        clone_url: format!("{}/{}/{}", app.config.base_url(), r.owner, r.name),
138 +
    }
139 +
}
140 +
141 +
/// A repository the caller may reach with `access`, else 404.
142 +
async fn reach(
39 143
    app: &Shared,
40 -
    headers: &axum::http::HeaderMap,
144 +
    c: &Caller,
41 145
    owner: &str,
42 146
    name: &str,
43 -
) -> Result<(RepoName, db::repos::RepoRow), ApiError> {
44 -
    let repo = RepoName::new(owner, name).ok_or(ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
45 -
    let row = db::repos::get(&app.db, owner, name).await?.ok_or(ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
46 -
    let account = caller(app, headers).await?;
47 -
    let role = crate::auth::roles::role_for(&app.db, account.as_ref(), &row).await?;
48 -
    if !crate::auth::roles::allows(role, crate::auth::Access::Read) {
49 -
        return Err(ApiError(StatusCode::NOT_FOUND, "no such repository".into()));
147 +
    access: Access,
148 +
) -> Result<(RepoName, db::repos::RepoRow, Role), ApiError> {
149 +
    let repo = RepoName::new(owner, name).ok_or_else(|| not_found("no such repository"))?;
150 +
    let row = db::repos::get(&app.db, owner, name).await?.ok_or_else(|| not_found("no such repository"))?;
151 +
    let role = auth::roles::role_for(&app.db, c.account.as_ref(), &row).await?;
152 +
    if !auth::roles::allows(role, access) {
153 +
        return Err(not_found("no such repository"));
154 +
    }
155 +
    Ok((repo, row, role.unwrap_or(Role::Read)))
156 +
}
157 +
158 +
fn summary(c: &view::ChangeView) -> ChangeSummary {
159 +
    ChangeSummary {
160 +
        hash: c.hash.clone(),
161 +
        message: if c.body.is_empty() { c.title.clone() } else { format!("{}\n\n{}", c.title, c.body) },
162 +
        author: Author {
163 +
            name: c.author.name.clone(),
164 +
            email: c.author.email.clone(),
165 +
            key: c.author.key.clone(),
166 +
            account: c.author.account.clone(),
167 +
        },
168 +
        when_ms: c.when_ms,
169 +
        deps: c.deps.clone(),
170 +
        head: c.head,
171 +
        signature: match &c.signature {
172 +
            view::SigView::Unsigned => Signature::Unsigned,
173 +
            view::SigView::Valid { key, sealed, revoked, .. } => Signature::Valid { key: key.clone(), sealed: *sealed, revoked: *revoked },
174 +
            view::SigView::Invalid(why) => Signature::Invalid { reason: why.clone() },
175 +
        },
176 +
    }
177 +
}
178 +
179 +
// ---------------------------------------------------------------- accounts
180 +
181 +
async fn me(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<User>, ApiError> {
182 +
    let c = caller(&app, &headers).await?;
183 +
    let a = c.require("user:read")?;
184 +
    Ok(Json(user_of(&app, a).await?))
185 +
}
186 +
187 +
async fn user_of(app: &Shared, a: &Account) -> Result<User, ApiError> {
188 +
    let keys = db::accounts::identities_of(&app.db, a.id).await?.into_iter().map(|i| i.key).collect();
189 +
    Ok(User { username: a.username.clone(), display_name: a.display_name.clone(), is_admin: a.is_admin, keys, created_ms: a.created_ms })
190 +
}
191 +
192 +
async fn user(State(app): State<Shared>, Path(name): Path<String>) -> Result<Json<User>, ApiError> {
193 +
    let a = db::accounts::account_by_name(&app.db, &name).await?.ok_or_else(|| not_found("no such account"))?;
194 +
    Ok(Json(user_of(&app, &a).await?))
195 +
}
196 +
197 +
async fn org(State(app): State<Shared>, Path(name): Path<String>) -> Result<Json<Organisation>, ApiError> {
198 +
    let o = db::accounts::org_by_name(&app.db, &name).await?.ok_or_else(|| not_found("no such organisation"))?;
199 +
    let members = db::accounts::org_members(&app.db, o.id).await?.into_iter().map(|(a, _)| a.username).collect();
200 +
    Ok(Json(Organisation { name: o.name, display_name: o.display_name, description: o.description, members }))
201 +
}
202 +
203 +
#[derive(Deserialize)]
204 +
struct SearchQuery {
205 +
    q: String,
206 +
}
207 +
208 +
async fn search(
209 +
    State(app): State<Shared>,
210 +
    headers: HeaderMap,
211 +
    Query(q): Query<SearchQuery>,
212 +
) -> Result<Json<Vec<serde_json::Value>>, ApiError> {
213 +
    let c = caller(&app, &headers).await?;
214 +
    let mut out = Vec::new();
215 +
    for h in db::notify::search(&app.db, &q.q, None, 50).await? {
216 +
        let Some(row) = db::repos::get_by_id(&app.db, h.repo_id).await? else { continue };
217 +
        let role = auth::roles::role_for(&app.db, c.account.as_ref(), &row).await?;
218 +
        if !auth::roles::allows(role, Access::Read) {
219 +
            continue;
220 +
        }
221 +
        out.push(
222 +
            serde_json::json!({ "kind": h.kind, "repository": row.slug(), "number": h.number, "title": h.title, "snippet": h.snippet }),
223 +
        );
224 +
    }
225 +
    Ok(Json(out))
226 +
}
227 +
228 +
// ------------------------------------------------------------ repositories
229 +
230 +
async fn list_repos(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<Vec<Repository>>, ApiError> {
231 +
    let c = caller(&app, &headers).await?;
232 +
    let mut out = Vec::new();
233 +
    for r in db::repos::list(&app.db).await? {
234 +
        let role = auth::roles::role_for(&app.db, c.account.as_ref(), &r).await?;
235 +
        if auth::roles::allows(role, Access::Read) {
236 +
            out.push(repository(&app, &r));
237 +
        }
50 238
    }
51 -
    Ok((repo, row))
239 +
    Ok(Json(out))
240 +
}
241 +
242 +
async fn create_repo(
243 +
    State(app): State<Shared>,
244 +
    headers: HeaderMap,
245 +
    Json(body): Json<NewRepository>,
246 +
) -> Result<(StatusCode, Json<Repository>), ApiError> {
247 +
    let c = caller(&app, &headers).await?;
248 +
    let me = c.require("repo:admin")?;
249 +
    let name = RepoName::new(&body.owner, &body.name).ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "invalid owner or name".into()))?;
250 +
    if !auth::roles::may_create_under(&app.db, me, &name.owner).await? {
251 +
        return Err(forbidden("you cannot create repositories under that owner"));
252 +
    }
253 +
    if db::repos::get(&app.db, &name.owner, &name.name).await?.is_some() || app.repos.exists(&name) {
254 +
        return Err(ApiError(StatusCode::CONFLICT, "that repository exists".into()));
255 +
    }
256 +
    app.repos.create(&name, None)?;
257 +
    let row = db::repos::create(&app.db, &name.owner, &name.name, &body.description, body.visibility).await?;
258 +
    db::accounts::audit(&app.db, Some(me), "repo.created", &name.to_string(), "api").await.ok();
259 +
    Ok((StatusCode::CREATED, Json(repository(&app, &row))))
260 +
}
261 +
262 +
async fn get_repo(
263 +
    State(app): State<Shared>,
264 +
    headers: HeaderMap,
265 +
    Path((owner, name)): Path<(String, String)>,
266 +
) -> Result<Json<Repository>, ApiError> {
267 +
    let c = caller(&app, &headers).await?;
268 +
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
269 +
    Ok(Json(repository(&app, &row)))
270 +
}
271 +
272 +
async fn bookmarks(
273 +
    State(app): State<Shared>,
274 +
    headers: HeaderMap,
275 +
    Path((owner, name)): Path<(String, String)>,
276 +
) -> Result<Json<Vec<Bookmark>>, ApiError> {
277 +
    let c = caller(&app, &headers).await?;
278 +
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
279 +
    let default = row.default_bookmark.clone();
280 +
    let list = app.repos.read(&repo, move |r, cache| view::bookmarks(r, cache, &default)).await?;
281 +
    Ok(Json(list.into_iter().map(|b| Bookmark { name: b.name, heads: b.heads, changes: b.changes, default: b.default }).collect()))
282 +
}
283 +
284 +
async fn tags(
285 +
    State(app): State<Shared>,
286 +
    headers: HeaderMap,
287 +
    Path((owner, name)): Path<(String, String)>,
288 +
) -> Result<Json<Vec<Tag>>, ApiError> {
289 +
    let c = caller(&app, &headers).await?;
290 +
    let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
291 +
    let list = app.repos.read(&repo, |r, _| view::tags(r)).await?;
292 +
    Ok(Json(list.into_iter().map(|t| Tag { name: t.name, heads: t.heads, message: t.message, signer: t.signer }).collect()))
293 +
}
294 +
295 +
#[derive(Deserialize)]
296 +
struct HistoryQuery {
297 +
    path: Option<String>,
298 +
    offset: Option<usize>,
299 +
    limit: Option<usize>,
300 +
}
301 +
302 +
async fn history(
303 +
    State(app): State<Shared>,
304 +
    headers: HeaderMap,
305 +
    Path((owner, name, r)): Path<(String, String, String)>,
306 +
    Query(q): Query<HistoryQuery>,
307 +
) -> Result<Json<History>, ApiError> {
308 +
    let c = caller(&app, &headers).await?;
309 +
    let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
310 +
    let (offset, limit) = (q.offset.unwrap_or(0), q.limit.unwrap_or(50).min(500));
311 +
    let h = app
312 +
        .repos
313 +
        .read(&repo, move |repo, cache| {
314 +
            let target = view::resolve(repo, cache, &r)?;
315 +
            view::history(repo, cache, target, q.path.as_deref(), offset, limit)
316 +
        })
317 +
        .await?;
318 +
    let mut entries = h.entries;
319 +
    crate::web::attribute(&app, &mut entries.iter_mut().collect::<Vec<_>>()).await;
320 +
    Ok(Json(History {
321 +
        reference: h.target.label,
322 +
        heads: h.target.heads.iter().map(|x| x.to_hex()).collect(),
323 +
        total: h.total,
324 +
        offset: h.offset,
325 +
        changes: entries.iter().map(summary).collect(),
326 +
    }))
327 +
}
328 +
329 +
async fn change(
330 +
    State(app): State<Shared>,
331 +
    headers: HeaderMap,
332 +
    Path((owner, name, hash)): Path<(String, String, String)>,
333 +
) -> Result<Json<ChangeDetail>, ApiError> {
334 +
    let c = caller(&app, &headers).await?;
335 +
    let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
336 +
    let d = app
337 +
        .repos
338 +
        .read(&repo, move |repo, cache| {
339 +
            let h = view::resolve_change(repo, cache, &hash)?;
340 +
            view::change(repo, cache, &h)
341 +
        })
342 +
        .await?;
343 +
    let mut v = d.view;
344 +
    crate::web::attribute(&app, &mut [&mut v]).await;
345 +
    let files = crate::web::history::render_files(&d.files, &d.bases)
346 +
        .into_iter()
347 +
        .map(|f| FileChange { path: f.path, kind: f.kind, from: f.from, added: f.added, removed: f.removed })
348 +
        .collect();
349 +
    Ok(Json(ChangeDetail {
350 +
        body: v.body.clone(),
351 +
        change: summary(&v),
352 +
        dependents: d.dependents.iter().map(|x| x.hash.clone()).collect(),
353 +
        in_bookmarks: d.in_bookmarks,
354 +
        in_tags: d.in_tags,
355 +
        files,
356 +
    }))
357 +
}
358 +
359 +
fn entry(e: &view::Entry) -> TreeEntry {
360 +
    TreeEntry {
361 +
        name: e.name.clone(),
362 +
        path: e.path.clone(),
363 +
        kind: match e.kind {
364 +
            view::EntryKind::Dir => "dir",
365 +
            view::EntryKind::File => "file",
366 +
            view::EntryKind::Symlink => "symlink",
367 +
            view::EntryKind::Subrepo => "subrepo",
368 +
        }
369 +
        .into(),
370 +
        exec: e.exec,
371 +
        size: e.size,
372 +
        conflicted: e.conflicted,
373 +
    }
374 +
}
375 +
376 +
async fn tree_root(
377 +
    State(app): State<Shared>,
378 +
    headers: HeaderMap,
379 +
    Path((owner, name, r)): Path<(String, String, String)>,
380 +
) -> Result<Json<Vec<TreeEntry>>, ApiError> {
381 +
    tree(State(app), headers, Path((owner, name, r, String::new()))).await
382 +
}
383 +
384 +
async fn tree(
385 +
    State(app): State<Shared>,
386 +
    headers: HeaderMap,
387 +
    Path((owner, name, r, path)): Path<(String, String, String, String)>,
388 +
) -> Result<Json<Vec<TreeEntry>>, ApiError> {
389 +
    let c = caller(&app, &headers).await?;
390 +
    let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
391 +
    let t = app
392 +
        .repos
393 +
        .read(&repo, move |repo, cache| {
394 +
            let target = view::resolve(repo, cache, &r)?;
395 +
            view::tree(repo, cache, target, &path)
396 +
        })
397 +
        .await?;
398 +
    Ok(Json(t.entries.iter().map(entry).collect()))
399 +
}
400 +
401 +
async fn file(
402 +
    State(app): State<Shared>,
403 +
    headers: HeaderMap,
404 +
    Path((owner, name, r, path)): Path<(String, String, String, String)>,
405 +
) -> Result<Response, ApiError> {
406 +
    let c = caller(&app, &headers).await?;
407 +
    let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
408 +
    let limit = app.config.limits.render_limit.0;
409 +
    let f = app
410 +
        .repos
411 +
        .read(&repo, move |repo, cache| {
412 +
            let target = view::resolve(repo, cache, &r)?;
413 +
            view::file(repo, cache, target, &path, limit)
414 +
        })
415 +
        .await?;
416 +
    Ok(crate::web::raw(f.bytes, &f.name, false))
417 +
}
418 +
419 +
async fn blame(
420 +
    State(app): State<Shared>,
421 +
    headers: HeaderMap,
422 +
    Path((owner, name, r, path)): Path<(String, String, String, String)>,
423 +
) -> Result<Json<Vec<BlameLine>>, ApiError> {
424 +
    let c = caller(&app, &headers).await?;
425 +
    let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
426 +
    let limit = app.config.limits.render_limit.0;
427 +
    let b = app
428 +
        .repos
429 +
        .read(&repo, move |repo, cache| {
430 +
            let target = view::resolve(repo, cache, &r)?;
431 +
            view::blame(repo, cache, target, &path, limit)
432 +
        })
433 +
        .await?;
434 +
    Ok(Json(
435 +
        b.lines
436 +
            .into_iter()
437 +
            .map(|l| BlameLine {
438 +
                line: l.line,
439 +
                change: l.hash.to_hex(),
440 +
                index: l.index,
441 +
                author: l.author,
442 +
                when_ms: l.when_ms,
443 +
                text: String::from_utf8_lossy(&l.text).into_owned(),
444 +
            })
445 +
            .collect(),
446 +
    ))
447 +
}
448 +
449 +
async fn conflicts(
450 +
    State(app): State<Shared>,
451 +
    headers: HeaderMap,
452 +
    Path((owner, name)): Path<(String, String)>,
453 +
) -> Result<Json<Vec<Conflict>>, ApiError> {
454 +
    let c = caller(&app, &headers).await?;
455 +
    let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
456 +
    let list = app.repos.read(&repo, view::conflicts).await?;
457 +
    Ok(Json(
458 +
        list.into_iter()
459 +
            .map(|c| Conflict {
460 +
                bookmark: c.bookmark,
461 +
                path: c.path,
462 +
                kind: c.kind,
463 +
                contributors: c.contributors.iter().map(|x| x.hash.clone()).collect(),
464 +
            })
465 +
            .collect(),
466 +
    ))
467 +
}
468 +
469 +
async fn trust(
470 +
    State(app): State<Shared>,
471 +
    headers: HeaderMap,
472 +
    Path((owner, name)): Path<(String, String)>,
473 +
) -> Result<Json<Trust>, ApiError> {
474 +
    let c = caller(&app, &headers).await?;
475 +
    let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
476 +
    let key = app.hub_key.key;
477 +
    let t = app.repos.read(&repo, move |r, cache| view::trust(r, cache, &key)).await?;
478 +
    Ok(Json(Trust {
479 +
        policy: t.policy.map(|p| Policy {
480 +
            serial: p.serial,
481 +
            expires_ms: p.expires_ms,
482 +
            roots: p.roots.iter().map(|k| k.key.clone()).collect(),
483 +
            root_threshold: p.root_threshold,
484 +
            rules: p
485 +
                .rules
486 +
                .iter()
487 +
                .map(|r| Rule {
488 +
                    pattern: r.pattern.clone(),
489 +
                    keys: r.keys.iter().map(|k| k.key.clone()).collect(),
490 +
                    threshold: r.threshold,
491 +
                    require: r.require.clone(),
492 +
                    allow_conflicts: r.allow_conflicts,
493 +
                })
494 +
                .collect(),
495 +
        }),
496 +
        signed: t.verification.signed,
497 +
        sealed: t.verification.sealed,
498 +
        unsigned: t.verification.unsigned,
499 +
        invalid: t.verification.invalid.iter().map(|(h, _)| h.clone()).collect(),
500 +
        hub_key: t.hub_key,
501 +
    }))
502 +
}
503 +
504 +
async fn pushes(
505 +
    State(app): State<Shared>,
506 +
    headers: HeaderMap,
507 +
    Path((owner, name)): Path<(String, String)>,
508 +
) -> Result<Json<Vec<Push>>, ApiError> {
509 +
    let c = caller(&app, &headers).await?;
510 +
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
511 +
    let rows = db::repos::pushes(&app.db, row.id, 200).await?;
512 +
    Ok(Json(
513 +
        rows.into_iter()
514 +
            .map(|p| Push {
515 +
                at_ms: p.at_ms,
516 +
                pusher: p.pusher,
517 +
                reference: p.reference,
518 +
                ok: p.ok,
519 +
                reason: p.reason,
520 +
                old_state: p.old_state,
521 +
                new_state: p.new_state,
522 +
                heads: p.heads,
523 +
                changes: p.changes,
524 +
            })
525 +
            .collect(),
526 +
    ))
52 527
}
53 528
529 +
// --------------------------------------------------------------- proposals
530 +
54 531
async fn proposals(
55 532
    State(app): State<Shared>,
56 -
    headers: axum::http::HeaderMap,
533 +
    headers: HeaderMap,
57 534
    Path((owner, name)): Path<(String, String)>,
58 -
) -> Result<Json<Vec<hub_api::Proposal>>, ApiError> {
59 -
    let (repo, row) = readable(&app, &headers, &owner, &name).await?;
535 +
) -> Result<Json<Vec<Proposal>>, ApiError> {
536 +
    let c = caller(&app, &headers).await?;
537 +
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
60 538
    let mut out = Vec::new();
61 539
    for p in db::proposals::list(&app.db, row.id, &["open", "draft"]).await? {
62 540
        let loaded = crate::web::proposals::load(&app, &repo, p).await?;
…
5 unchanged lines
68 546
69 547
async fn proposal(
70 548
    State(app): State<Shared>,
71 -
    headers: axum::http::HeaderMap,
549 +
    headers: HeaderMap,
72 550
    Path((owner, name, number)): Path<(String, String, i64)>,
73 -
) -> Result<Json<hub_api::Proposal>, ApiError> {
74 -
    let (repo, row) = readable(&app, &headers, &owner, &name).await?;
75 -
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or(ApiError(StatusCode::NOT_FOUND, "no such proposal".into()))?;
551 +
) -> Result<Json<Proposal>, ApiError> {
552 +
    let c = caller(&app, &headers).await?;
553 +
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
554 +
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?;
76 555
    let source_url = match p.source_repo_id {
77 556
        Some(id) => db::repos::get_by_id(&app.db, id).await?.map(|r| format!("{}/{}", app.config.base_url(), r.slug())),
78 557
        None => None,
…
3 unchanged lines
82 561
    Ok(Json(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, source_url).await))
83 562
}
84 563
85 -
pub struct ApiError(StatusCode, String);
564 +
async fn create_proposal(
565 +
    State(app): State<Shared>,
566 +
    headers: HeaderMap,
567 +
    Path((owner, name)): Path<(String, String)>,
568 +
    Json(body): Json<NewProposal>,
569 +
) -> Result<(StatusCode, Json<Proposal>), ApiError> {
570 +
    let c = caller(&app, &headers).await?;
571 +
    let me = c.require("proposals:write").or_else(|_| c.require("repo:write"))?.clone();
572 +
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
573 +
    let created =
574 +
        crate::web::proposals::open_proposal(&app, &repo, &row, &me, &body.title, &body.body, &body.target, &body.source, body.draft)
575 +
            .await
576 +
            .map_err(|e| ApiError(StatusCode::BAD_REQUEST, e))?;
577 +
    let loaded = crate::web::proposals::load(&app, &repo, created).await?;
578 +
    let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?;
579 +
    Ok((StatusCode::CREATED, Json(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, None).await)))
580 +
}
86 581
87 -
impl IntoResponse for ApiError {
88 -
    fn into_response(self) -> Response {
89 -
        let body = Problem { status: self.0.as_u16(), title: self.0.canonical_reason().unwrap_or("error").into(), detail: self.1 };
90 -
        (self.0, [("content-type", "application/problem+json")], Json(body)).into_response()
582 +
fn comment_of(c: &db::proposals::CommentRow) -> Comment {
583 +
    Comment {
584 +
        id: c.id,
585 +
        author: c.author.clone(),
586 +
        body: c.body.clone(),
587 +
        created_ms: c.created_ms,
588 +
        path: if c.path.is_empty() { None } else { Some(c.path.clone()) },
589 +
        anchor: if c.anchor_change.is_empty() {
590 +
            None
591 +
        } else {
592 +
            Some(Atom { change: c.anchor_change.clone(), index: c.anchor_index as u32 })
593 +
        },
594 +
        resolved: c.resolved,
91 595
    }
92 596
}
93 597
94 -
impl From<RepoError> for ApiError {
95 -
    fn from(e: RepoError) -> ApiError {
96 -
        match e {
97 -
            RepoError::NotFound => ApiError(StatusCode::NOT_FOUND, "no such repository".into()),
98 -
            other => ApiError(StatusCode::INTERNAL_SERVER_ERROR, other.to_string()),
598 +
async fn proposal_comments(
599 +
    State(app): State<Shared>,
600 +
    headers: HeaderMap,
601 +
    Path((owner, name, number)): Path<(String, String, i64)>,
602 +
) -> Result<Json<Vec<Comment>>, ApiError> {
603 +
    let c = caller(&app, &headers).await?;
604 +
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
605 +
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?;
606 +
    Ok(Json(db::proposals::comments(&app.db, p.id).await?.iter().map(comment_of).collect()))
607 +
}
608 +
609 +
async fn proposal_comment(
610 +
    State(app): State<Shared>,
611 +
    headers: HeaderMap,
612 +
    Path((owner, name, number)): Path<(String, String, i64)>,
613 +
    Json(body): Json<NewComment>,
614 +
) -> Result<(StatusCode, Json<Comment>), ApiError> {
615 +
    let c = caller(&app, &headers).await?;
616 +
    let me = c.require("proposals:write").or_else(|_| c.require("repo:write"))?.clone();
617 +
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
618 +
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?;
619 +
    if body.body.trim().is_empty() {
620 +
        return Err(ApiError(StatusCode::BAD_REQUEST, "an empty comment says nothing".into()));
621 +
    }
622 +
    let row_c = db::proposals::add_comment(&app.db, p.id, Some(me.id), &me.username, body.body.trim(), "", None).await?;
623 +
    crate::notify::deliver(
624 +
        &app,
625 +
        crate::notify::Event {
626 +
            kind: "proposal.comment",
627 +
            title: format!("{owner}/{name}#{number}: {}", p.title),
628 +
            url: format!("/{owner}/{name}/proposal/{number}"),
629 +
            actor: &me,
630 +
            repo_id: row.id,
631 +
            thread: Some(("proposal", p.id)),
632 +
            mention_text: &body.body,
633 +
            direct: p.author_id.into_iter().collect(),
634 +
        },
635 +
    )
636 +
    .await;
637 +
    crate::hooks::emit(
638 +
        &app,
639 +
        &row,
640 +
        "comment.proposal",
641 +
        &me.username,
642 +
        serde_json::json!({ "number": number, "comment": comment_of(&row_c) }),
643 +
    )
644 +
    .await;
645 +
    Ok((StatusCode::CREATED, Json(comment_of(&row_c))))
646 +
}
647 +
648 +
async fn proposal_reviews(
649 +
    State(app): State<Shared>,
650 +
    headers: HeaderMap,
651 +
    Path((owner, name, number)): Path<(String, String, i64)>,
652 +
) -> Result<Json<Vec<Review>>, ApiError> {
653 +
    let c = caller(&app, &headers).await?;
654 +
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
655 +
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?;
656 +
    let loaded = crate::web::proposals::load(&app, &repo, p).await?;
657 +
    let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?;
658 +
    Ok(Json(
659 +
        v.reviews
660 +
            .into_iter()
661 +
            .map(|r| Review {
662 +
                id: r.row.id,
663 +
                author: r.row.author,
664 +
                verdict: r.row.verdict,
665 +
                body: r.row.body,
666 +
                sealed: r.sealed,
667 +
                created_ms: r.row.created_ms,
668 +
            })
669 +
            .collect(),
670 +
    ))
671 +
}
672 +
673 +
async fn proposal_review(
674 +
    State(app): State<Shared>,
675 +
    headers: HeaderMap,
676 +
    Path((owner, name, number)): Path<(String, String, i64)>,
677 +
    Json(body): Json<NewReview>,
678 +
) -> Result<(StatusCode, Json<Review>), ApiError> {
679 +
    let c = caller(&app, &headers).await?;
680 +
    let me = c.require("proposals:write").or_else(|_| c.require("repo:write"))?.clone();
681 +
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
682 +
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?;
683 +
    if !matches!(body.verdict.as_str(), "comment" | "changes" | "approve") {
684 +
        return Err(ApiError(StatusCode::BAD_REQUEST, "verdict is comment, changes or approve".into()));
685 +
    }
686 +
    if body.verdict == "approve" && Some(me.id) == p.author_id {
687 +
        return Err(forbidden("you cannot approve your own proposal"));
688 +
    }
689 +
    let r = db::proposals::add_review(&app.db, p.id, me.id, &me.username, &body.verdict, body.body.trim()).await?;
690 +
    db::proposals::add_event(&app.db, p.id, &me.username, &format!("review.{}", body.verdict), "").await.ok();
691 +
    crate::hooks::emit(&app, &row, "review", &me.username, serde_json::json!({ "number": number, "verdict": body.verdict })).await;
692 +
    Ok((
693 +
        StatusCode::CREATED,
694 +
        Json(Review { id: r.id, author: r.author, verdict: r.verdict, body: r.body, sealed: false, created_ms: r.created_ms }),
695 +
    ))
696 +
}
697 +
698 +
async fn proposal_checks(
699 +
    State(app): State<Shared>,
700 +
    headers: HeaderMap,
701 +
    Path((owner, name, number)): Path<(String, String, i64)>,
702 +
) -> Result<Json<Vec<Check>>, ApiError> {
703 +
    let c = caller(&app, &headers).await?;
704 +
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
705 +
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?;
706 +
    let loaded = crate::web::proposals::load(&app, &repo, p).await?;
707 +
    Ok(Json(db::hooks::checks_for_state(&app.db, row.id, &loaded.state).await?.iter().map(check_of).collect()))
708 +
}
709 +
710 +
// ------------------------------------------------------------------ issues
711 +
712 +
async fn issue_of(app: &Shared, row: &db::repos::RepoRow, i: &db::issues::IssueRow) -> Result<Issue, ApiError> {
713 +
    let labels = db::issues::issue_labels(&app.db, i.id)
714 +
        .await?
715 +
        .into_iter()
716 +
        .map(|l| Label { name: l.name, colour: l.colour, description: l.description })
717 +
        .collect();
718 +
    let assignees = db::issues::assignees(&app.db, i.id).await?;
719 +
    let milestone = match i.milestone_id {
720 +
        Some(id) => db::issues::milestone(&app.db, id).await?.map(|m| m.title),
721 +
        None => None,
722 +
    };
723 +
    let comments: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issue_comments WHERE issue_id = ?").bind(i.id).fetch_one(&app.db).await?;
724 +
    Ok(Issue {
725 +
        number: i.number,
726 +
        title: i.title.clone(),
727 +
        body: i.body.clone(),
728 +
        author: i.author.clone(),
729 +
        state: i.state.clone(),
730 +
        reason: i.reason.clone(),
731 +
        labels,
732 +
        assignees,
733 +
        milestone,
734 +
        comments,
735 +
        locked: i.locked,
736 +
        pinned: i.pinned,
737 +
        created_ms: i.created_ms,
738 +
        updated_ms: i.updated_ms,
739 +
        closed_ms: i.closed_ms,
740 +
        url: format!("{}/{}/issue/{}", app.config.base_url(), row.slug(), i.number),
741 +
    })
742 +
}
743 +
744 +
#[derive(Deserialize)]
745 +
struct IssuesQuery {
746 +
    state: Option<String>,
747 +
}
748 +
749 +
async fn issues(
750 +
    State(app): State<Shared>,
751 +
    headers: HeaderMap,
752 +
    Path((owner, name)): Path<(String, String)>,
753 +
    Query(q): Query<IssuesQuery>,
754 +
) -> Result<Json<Vec<Issue>>, ApiError> {
755 +
    let c = caller(&app, &headers).await?;
756 +
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
757 +
    let state = q.state.unwrap_or_else(|| "open".into());
758 +
    let mut out = Vec::new();
759 +
    for i in db::issues::list(&app.db, row.id).await? {
760 +
        if state == "all" || i.state == state {
761 +
            out.push(issue_of(&app, &row, &i).await?);
99 762
        }
100 763
    }
764 +
    Ok(Json(out))
101 765
}
102 766
103 -
impl From<sqlx::Error> for ApiError {
104 -
    fn from(e: sqlx::Error) -> ApiError {
105 -
        ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
767 +
async fn create_issue(
768 +
    State(app): State<Shared>,
769 +
    headers: HeaderMap,
770 +
    Path((owner, name)): Path<(String, String)>,
771 +
    Json(body): Json<NewIssue>,
772 +
) -> Result<(StatusCode, Json<Issue>), ApiError> {
773 +
    let c = caller(&app, &headers).await?;
774 +
    let me = c.require("issues:write").or_else(|_| c.require("repo:write"))?.clone();
775 +
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
776 +
    if body.title.trim().is_empty() {
777 +
        return Err(ApiError(StatusCode::BAD_REQUEST, "an issue needs a title".into()));
106 778
    }
779 +
    let i = db::issues::create(
780 +
        &app.db,
781 +
        db::issues::NewIssue {
782 +
            repo_id: row.id,
783 +
            title: body.title.trim(),
784 +
            body: body.body.trim(),
785 +
            author_id: Some(me.id),
786 +
            author: &me.username,
787 +
        },
788 +
    )
789 +
    .await?;
790 +
    db::issues::add_event(&app.db, i.id, &me.username, "opened", "").await.ok();
791 +
    crate::web::issues::reindex(&app, row.id, &i).await;
792 +
    let api = issue_of(&app, &row, &i).await?;
793 +
    crate::notify::deliver(
794 +
        &app,
795 +
        crate::notify::Event {
796 +
            kind: "issue.opened",
797 +
            title: format!("{owner}/{name}#{}: {}", i.number, i.title),
798 +
            url: format!("/{owner}/{name}/issue/{}", i.number),
799 +
            actor: &me,
800 +
            repo_id: row.id,
801 +
            thread: Some(("issue", i.id)),
802 +
            mention_text: &body.body,
803 +
            direct: vec![],
804 +
        },
805 +
    )
806 +
    .await;
807 +
    crate::hooks::emit(&app, &row, "issue.opened", &me.username, serde_json::json!({ "issue": api })).await;
808 +
    Ok((StatusCode::CREATED, Json(api)))
809 +
}
810 +
811 +
async fn issue(
812 +
    State(app): State<Shared>,
813 +
    headers: HeaderMap,
814 +
    Path((owner, name, number)): Path<(String, String, i64)>,
815 +
) -> Result<Json<Issue>, ApiError> {
816 +
    let c = caller(&app, &headers).await?;
817 +
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
818 +
    let i = db::issues::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such issue"))?;
819 +
    Ok(Json(issue_of(&app, &row, &i).await?))
820 +
}
821 +
822 +
async fn issue_state(
823 +
    State(app): State<Shared>,
824 +
    headers: HeaderMap,
825 +
    Path((owner, name, number)): Path<(String, String, i64)>,
826 +
    Json(body): Json<IssueStateChange>,
827 +
) -> Result<Json<Issue>, ApiError> {
828 +
    let c = caller(&app, &headers).await?;
829 +
    let me = c.require("issues:write").or_else(|_| c.require("repo:write"))?.clone();
830 +
    let (_, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?;
831 +
    let i = db::issues::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such issue"))?;
832 +
    if role < Role::Triage && Some(me.id) != i.author_id {
833 +
        return Err(forbidden("only the author or triage may change the state"));
834 +
    }
835 +
    let (state, reason) = match body.state.as_str() {
836 +
        "closed" => ("closed", if body.reason == "not_planned" { "not_planned" } else { "completed" }),
837 +
        "open" => ("open", ""),
838 +
        _ => return Err(ApiError(StatusCode::BAD_REQUEST, "state is open or closed".into())),
839 +
    };
840 +
    db::issues::set_state(&app.db, i.id, state, reason).await?;
841 +
    db::issues::add_event(&app.db, i.id, &me.username, if state == "closed" { "closed" } else { "reopened" }, "").await.ok();
842 +
    let fresh = db::issues::get(&app.db, row.id, number).await?.unwrap_or(i);
843 +
    let api = issue_of(&app, &row, &fresh).await?;
844 +
    crate::hooks::emit(
845 +
        &app,
846 +
        &row,
847 +
        if state == "closed" { "issue.closed" } else { "issue.reopened" },
848 +
        &me.username,
849 +
        serde_json::json!({ "issue": api }),
850 +
    )
851 +
    .await;
852 +
    Ok(Json(api))
853 +
}
854 +
855 +
async fn issue_comments(
856 +
    State(app): State<Shared>,
857 +
    headers: HeaderMap,
858 +
    Path((owner, name, number)): Path<(String, String, i64)>,
859 +
) -> Result<Json<Vec<Comment>>, ApiError> {
860 +
    let c = caller(&app, &headers).await?;
861 +
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
862 +
    let i = db::issues::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such issue"))?;
863 +
    Ok(Json(
864 +
        db::issues::comments(&app.db, i.id)
865 +
            .await?
866 +
            .into_iter()
867 +
            .map(|c| Comment {
868 +
                id: c.id,
869 +
                author: c.author,
870 +
                body: c.body,
871 +
                created_ms: c.created_ms,
872 +
                path: None,
873 +
                anchor: None,
874 +
                resolved: false,
875 +
            })
876 +
            .collect(),
877 +
    ))
878 +
}
879 +
880 +
async fn issue_comment(
881 +
    State(app): State<Shared>,
882 +
    headers: HeaderMap,
883 +
    Path((owner, name, number)): Path<(String, String, i64)>,
884 +
    Json(body): Json<NewComment>,
885 +
) -> Result<(StatusCode, Json<Comment>), ApiError> {
886 +
    let c = caller(&app, &headers).await?;
887 +
    let me = c.require("issues:write").or_else(|_| c.require("repo:write"))?.clone();
888 +
    let (_, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?;
889 +
    let i = db::issues::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such issue"))?;
890 +
    if i.locked && role < Role::Triage {
891 +
        return Err(forbidden("this issue is locked"));
892 +
    }
893 +
    if body.body.trim().is_empty() {
894 +
        return Err(ApiError(StatusCode::BAD_REQUEST, "an empty comment says nothing".into()));
895 +
    }
896 +
    let cm = db::issues::add_comment(&app.db, i.id, Some(me.id), &me.username, body.body.trim()).await?;
897 +
    crate::web::issues::reindex(&app, row.id, &i).await;
898 +
    crate::notify::deliver(
899 +
        &app,
900 +
        crate::notify::Event {
901 +
            kind: "issue.comment",
902 +
            title: format!("{owner}/{name}#{number}: {}", i.title),
903 +
            url: format!("/{owner}/{name}/issue/{number}"),
904 +
            actor: &me,
905 +
            repo_id: row.id,
906 +
            thread: Some(("issue", i.id)),
907 +
            mention_text: &body.body,
908 +
            direct: i.author_id.into_iter().collect(),
909 +
        },
910 +
    )
911 +
    .await;
912 +
    let api = Comment { id: cm.id, author: cm.author, body: cm.body, created_ms: cm.created_ms, path: None, anchor: None, resolved: false };
913 +
    crate::hooks::emit(&app, &row, "comment.issue", &me.username, serde_json::json!({ "number": number, "comment": api })).await;
914 +
    Ok((StatusCode::CREATED, Json(api)))
107 915
}
108 916
109 -
fn repository(app: &Shared, r: &db::repos::RepoRow) -> Repository {
110 -
    Repository {
111 -
        owner: r.owner.clone(),
112 -
        name: r.name.clone(),
113 -
        description: r.description.clone(),
114 -
        default_bookmark: r.default_bookmark.clone(),
115 -
        visibility: r.visibility(),
116 -
        created_ms: r.created_ms,
117 -
        updated_ms: r.updated_ms,
118 -
        clone_url: format!("{}/{}/{}", app.config.base_url(), r.owner, r.name),
917 +
async fn labels(
918 +
    State(app): State<Shared>,
919 +
    headers: HeaderMap,
920 +
    Path((owner, name)): Path<(String, String)>,
921 +
) -> Result<Json<Vec<Label>>, ApiError> {
922 +
    let c = caller(&app, &headers).await?;
923 +
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
924 +
    Ok(Json(
925 +
        db::issues::labels(&app.db, row.id)
926 +
            .await?
927 +
            .into_iter()
928 +
            .map(|l| Label { name: l.name, colour: l.colour, description: l.description })
929 +
            .collect(),
930 +
    ))
931 +
}
932 +
933 +
async fn milestones(
934 +
    State(app): State<Shared>,
935 +
    headers: HeaderMap,
936 +
    Path((owner, name)): Path<(String, String)>,
937 +
) -> Result<Json<Vec<Milestone>>, ApiError> {
938 +
    let c = caller(&app, &headers).await?;
939 +
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
940 +
    let mut out = Vec::new();
941 +
    for m in db::issues::milestones(&app.db, row.id).await? {
942 +
        let (open, closed) = db::issues::milestone_counts(&app.db, m.id).await?;
943 +
        out.push(Milestone { title: m.title, description: m.description, due_ms: m.due_ms, state: m.state, open, closed });
119 944
    }
945 +
    Ok(Json(out))
946 +
}
947 +
948 +
// --------------------------------------------------------- hooks, checks
949 +
950 +
fn hook_of(h: &db::hooks::Hook) -> Hook {
951 +
    Hook { id: h.id, url: h.url.clone(), events: h.events.clone(), active: h.active, created_ms: h.created_ms }
952 +
}
953 +
954 +
async fn hooks(
955 +
    State(app): State<Shared>,
956 +
    headers: HeaderMap,
957 +
    Path((owner, name)): Path<(String, String)>,
958 +
) -> Result<Json<Vec<Hook>>, ApiError> {
959 +
    let c = caller(&app, &headers).await?;
960 +
    c.require("hooks:admin").or_else(|_| c.require("repo:admin"))?;
961 +
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Admin).await?;
962 +
    Ok(Json(db::hooks::hooks_for_repo(&app.db, row.id).await?.iter().map(hook_of).collect()))
120 963
}
121 964
122 -
async fn public_row(app: &Shared, owner: &str, name: &str) -> Result<(RepoName, db::repos::RepoRow), ApiError> {
123 -
    let repo = RepoName::new(owner, name).ok_or(ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
124 -
    let row = db::repos::get(&app.db, owner, name).await?.filter(|r| r.visibility() == hub_api::Visibility::Public);
125 -
    Ok((repo, row.ok_or(ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?))
965 +
async fn create_hook(
966 +
    State(app): State<Shared>,
967 +
    headers: HeaderMap,
968 +
    Path((owner, name)): Path<(String, String)>,
969 +
    Json(body): Json<NewHook>,
970 +
) -> Result<(StatusCode, Json<Hook>), ApiError> {
971 +
    let c = caller(&app, &headers).await?;
972 +
    let me = c.require("hooks:admin").or_else(|_| c.require("repo:admin"))?.clone();
973 +
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Admin).await?;
974 +
    if !(body.url.starts_with("http://") || body.url.starts_with("https://")) {
975 +
        return Err(ApiError(StatusCode::BAD_REQUEST, "the URL must be http or https".into()));
976 +
    }
977 +
    let h = db::hooks::create_hook(&app.db, Some(row.id), None, &body.url, &body.secret, &body.events).await?;
978 +
    db::accounts::audit(&app.db, Some(&me), "hook.created", &row.slug(), &body.url).await.ok();
979 +
    Ok((StatusCode::CREATED, Json(hook_of(&h))))
126 980
}
127 981
128 -
async fn list_repos(State(app): State<Shared>) -> Result<Json<Vec<Repository>>, ApiError> {
129 -
    let rows = db::repos::list(&app.db).await?;
130 -
    Ok(Json(rows.iter().filter(|r| r.visibility() == hub_api::Visibility::Public).map(|r| repository(&app, r)).collect()))
982 +
async fn delete_hook(
983 +
    State(app): State<Shared>,
984 +
    headers: HeaderMap,
985 +
    Path((owner, name, id)): Path<(String, String, i64)>,
986 +
) -> Result<StatusCode, ApiError> {
987 +
    let c = caller(&app, &headers).await?;
988 +
    c.require("hooks:admin").or_else(|_| c.require("repo:admin"))?;
989 +
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Admin).await?;
990 +
    if db::hooks::hook(&app.db, id).await?.is_some_and(|h| h.repo_id == Some(row.id)) {
991 +
        db::hooks::delete_hook(&app.db, id).await?;
992 +
    }
993 +
    Ok(StatusCode::NO_CONTENT)
131 994
}
132 995
133 -
async fn get_repo(State(app): State<Shared>, Path((owner, name)): Path<(String, String)>) -> Result<Json<Repository>, ApiError> {
134 -
    let (_, row) = public_row(&app, &owner, &name).await?;
135 -
    Ok(Json(repository(&app, &row)))
996 +
pub fn check_of(c: &db::hooks::Check) -> Check {
997 +
    Check {
998 +
        state: c.state.clone(),
999 +
        name: c.name.clone(),
1000 +
        status: CheckStatus::parse(&c.status).unwrap_or(CheckStatus::Queued),
1001 +
        summary: c.summary.clone(),
1002 +
        url: c.url.clone(),
1003 +
        reporter: c.reporter.clone(),
1004 +
        seal: if c.seal.is_empty() { None } else { Some(c.seal.clone()) },
1005 +
        started_ms: c.started_ms,
1006 +
        finished_ms: c.finished_ms,
1007 +
    }
136 1008
}
137 1009
138 -
async fn bookmarks(State(app): State<Shared>, Path((owner, name)): Path<(String, String)>) -> Result<Json<Vec<Bookmark>>, ApiError> {
139 -
    let (repo, row) = public_row(&app, &owner, &name).await?;
140 -
    let default = row.default_bookmark.clone();
141 -
    let list = app.repos.read(&repo, move |r, cache| view::bookmarks(r, cache, &default)).await?;
142 -
    Ok(Json(list.into_iter().map(|b| Bookmark { name: b.name, heads: b.heads, changes: b.changes, default: b.default }).collect()))
1010 +
/// `POST /repos/{o}/{n}/checks`: any `checks:write` (or write) token may
1011 +
/// report; the state hash names what was checked.
1012 +
async fn post_check(
1013 +
    State(app): State<Shared>,
1014 +
    headers: HeaderMap,
1015 +
    Path((owner, name)): Path<(String, String)>,
1016 +
    Json(body): Json<NewCheck>,
1017 +
) -> Result<(StatusCode, Json<Check>), ApiError> {
1018 +
    let c = caller(&app, &headers).await?;
1019 +
    let me = c.require("checks:write").or_else(|_| c.require("repo:write"))?.clone();
1020 +
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
1021 +
    if body.state.len() != 64 || !body.state.chars().all(|ch| ch.is_ascii_hexdigit()) {
1022 +
        return Err(ApiError(StatusCode::BAD_REQUEST, "state is a 64-character hex state hash".into()));
1023 +
    }
1024 +
    if body.name.trim().is_empty() || body.name.len() > 100 {
1025 +
        return Err(ApiError(StatusCode::BAD_REQUEST, "a check has a short name".into()));
1026 +
    }
1027 +
    let ch = db::hooks::upsert_check(
1028 +
        &app.db,
1029 +
        row.id,
1030 +
        &body.state,
1031 +
        body.name.trim(),
1032 +
        body.status.as_str(),
1033 +
        &body.summary,
1034 +
        &body.url,
1035 +
        Some((me.id, &me.username)),
1036 +
        body.seal.as_deref().unwrap_or(""),
1037 +
    )
1038 +
    .await?;
1039 +
    let api = check_of(&ch);
1040 +
    app.events.proposal(row.id, ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
1041 +
    crate::hooks::emit(&app, &row, "check", &me.username, serde_json::json!({ "check": api })).await;
1042 +
    Ok((StatusCode::CREATED, Json(api)))
143 1043
}
144 1044
145 -
async fn tags(State(app): State<Shared>, Path((owner, name)): Path<(String, String)>) -> Result<Json<Vec<Tag>>, ApiError> {
146 -
    let (repo, _) = public_row(&app, &owner, &name).await?;
147 -
    let list = app.repos.read(&repo, |r, _| view::tags(r)).await?;
148 -
    Ok(Json(list.into_iter().map(|t| Tag { name: t.name, heads: t.heads, message: t.message, signer: t.signer }).collect()))
1045 +
async fn checks_for_state(
1046 +
    State(app): State<Shared>,
1047 +
    headers: HeaderMap,
1048 +
    Path((owner, name, state)): Path<(String, String, String)>,
1049 +
) -> Result<Json<Vec<Check>>, ApiError> {
1050 +
    let c = caller(&app, &headers).await?;
1051 +
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
1052 +
    Ok(Json(db::hooks::checks_for_state(&app.db, row.id, &state).await?.iter().map(check_of).collect()))
149 1053
}
modified crates/hub/src/db/mod.rs +1 −0
…
1 unchanged lines
2 2
//! migrations embedded and applied at start. One module per table group.
3 3
4 4
pub mod accounts;
5 +
pub mod hooks;
5 6
pub mod issues;
6 7
pub mod notify;
7 8
pub mod proposals;
…
26 unchanged lines
modified crates/hub/src/lib.rs +14 −1
…
6 unchanged lines
7 7
pub mod auth;
8 8
pub mod config;
9 9
pub mod db;
10 +
pub mod events;
11 +
pub mod hooks;
10 12
pub mod keys;
11 13
pub mod notify;
12 14
pub mod protocol;
…
13 unchanged lines
26 28
    pub server: Arc<oxy_server::Server>,
27 29
    pub hub_key: keys::HubKey,
28 30
    pub cookie_key: auth::CookieKey,
31 +
    pub events: events::Events,
29 32
    pub started: std::time::Instant,
30 33
}
31 34
…
10 unchanged lines
42 45
    let backend = protocol::HubBackend::new(repos.clone(), db.clone(), config.auth.push_tokens.clone(), tokio::runtime::Handle::current());
43 46
    let slot = backend.app_slot();
44 47
    let server = oxy_server::Server::new(backend);
45 -
    let app = Arc::new(App { config, db, repos, server, hub_key, cookie_key, started: std::time::Instant::now() });
48 +
    let app = Arc::new(App {
49 +
        config,
50 +
        db,
51 +
        repos,
52 +
        server,
53 +
        hub_key,
54 +
        cookie_key,
55 +
        events: events::Events::default(),
56 +
        started: std::time::Instant::now(),
57 +
    });
46 58
    *slot.lock().unwrap() = Some(Arc::downgrade(&app));
59 +
    tokio::spawn(hooks::worker(app.clone()));
47 60
    Ok(app)
48 61
}
49 62
…
34 unchanged lines
modified crates/hub/src/protocol.rs +9 −0
…
177 unchanged lines
178 178
            let app = app_hint.lock().unwrap().as_ref().and_then(|w| w.upgrade());
179 179
            if let Some(app) = app {
180 180
                crate::web::issues::close_from_messages(&app, &row, &messages).await;
181 +
                let accepted: Vec<serde_json::Value> = results
182 +
                    .iter()
183 +
                    .filter(|r| r.ok)
184 +
                    .map(|r| serde_json::json!({ "reference": r.reference, "old_state": r.old_state, "new_state": r.new_state, "heads": r.heads, "changes": r.changes }))
185 +
                    .collect();
186 +
                if !accepted.is_empty() {
187 +
                    let actor = pusher.as_ref().map(|(_, n)| n.clone()).unwrap_or_default();
188 +
                    crate::hooks::emit(&app, &row, "push", &actor, serde_json::json!({ "updates": accepted })).await;
189 +
                }
181 190
            }
182 191
        });
183 192
    }
…
27 unchanged lines
modified crates/hub/src/web/mod.rs +6 −1
…
4 unchanged lines
5 5
6 6
mod admin;
7 7
pub mod filters;
8 -
mod history;
8 +
pub mod history;
9 9
mod home;
10 10
mod inbox;
11 11
pub mod issues;
…
87 unchanged lines
99 99
        .route("/{owner}/{name}/proposal/{number}/state", post(proposals::state))
100 100
        .route("/{owner}/{name}/proposal/{number}/land", post(proposals::land))
101 101
        .route("/{owner}/{name}/proposal/{number}/labels", post(proposals::set_labels))
102 +
        .route("/{owner}/{name}/proposal/{number}/events", get(proposals::events))
103 +
        .route("/{owner}/{name}/settings/hooks", post(repo_settings::hooks))
104 +
        .route("/{owner}/{name}/settings/hooks/{id}/deliveries", get(repo_settings::deliveries))
105 +
        .route("/{owner}/{name}/settings/hooks/{id}/redeliver/{delivery}", post(repo_settings::redeliver))
106 +
        .route("/{owner}/{name}/settings/checks", post(repo_settings::required_checks))
102 107
        .route("/{owner}/{name}/watch", post(issues::watch))
103 108
        .route("/{owner}/{name}/issues", get(issues::list))
104 109
        .route("/{owner}/{name}/issues/new", get(issues::new_form).post(issues::create))
…
301 unchanged lines
modified crates/hub/src/web/proposals.rs +127 −56
…
172 unchanged lines
173 173
    draft: Option<String>,
174 174
}
175 175
176 -
pub async fn create(
177 -
    State(app): State<Shared>,
178 -
    viewer: Viewer,
179 -
    Path((owner, name)): Path<(String, String)>,
180 -
    Form(f): Form<NewForm>,
181 -
) -> Result<Response, Error> {
182 -
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposals/new"), &viewer);
183 -
    let me = require(&viewer, &shell)?;
184 -
    csrf(&viewer, &f.csrf_token, &shell)?;
185 -
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
186 -
    let bookmarks = app
187 -
        .repos
188 -
        .read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::<Vec<_>>()))
189 -
        .await
190 -
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
191 -
    let fail = |msg: &str, preview: Vec<ChangeView>| {
192 -
        Page(NewProposalPage {
193 -
            shell: shell.clone(),
194 -
            repo: RepoShell::new(&app, &row, "proposals").with_role(role),
195 -
            bookmarks: bookmarks.clone(),
196 -
            source: f.source.clone(),
197 -
            target: f.target.clone(),
198 -
            error: Some(msg.into()),
199 -
            preview,
200 -
            conflicted: false,
201 -
        })
202 -
        .into_response()
203 -
    };
204 -
    if f.title.trim().is_empty() {
205 -
        return Ok(fail("Give the proposal a title.", vec![]));
176 +
/// Open a proposal from a form or the API: validate, create, index, notify.
177 +
#[allow(clippy::too_many_arguments)]
178 +
pub async fn open_proposal(
179 +
    app: &Shared,
180 +
    repo: &RepoName,
181 +
    row: &db::repos::RepoRow,
182 +
    me: &accounts::Account,
183 +
    title: &str,
184 +
    body: &str,
185 +
    target: &str,
186 +
    source: &str,
187 +
    draft: bool,
188 +
) -> Result<pdb::ProposalRow, String> {
189 +
    if title.trim().is_empty() {
190 +
        return Err("Give the proposal a title.".into());
206 191
    }
207 -
    if !bookmarks.contains(&f.target) {
208 -
        return Ok(fail("Pick a target bookmark of this repository.", vec![]));
192 +
    let bookmarks =
193 +
        app.repos.read(repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::<Vec<_>>())).await.map_err(|e| e.to_string())?;
194 +
    if !bookmarks.iter().any(|b| b == target) {
195 +
        return Err("Pick a target bookmark of this repository.".into());
209 196
    }
210 -
    let Some((fork, bookmark)) = parse_source(&app, &repo, f.source.trim()) else {
211 -
        return Ok(fail("The source is a bookmark of this repository, or owner/name:bookmark of a fork.", vec![]));
197 +
    let Some((fork, bookmark)) = parse_source(app, repo, source.trim()) else {
198 +
        return Err("The source is a bookmark of this repository, or owner/name:bookmark of a fork.".into());
212 199
    };
213 -
    if fork.is_none() && bookmark == f.target {
214 -
        return Ok(fail("The source and the target are the same bookmark.", vec![]));
200 +
    if fork.is_none() && bookmark == target {
201 +
        return Err("The source and the target are the same bookmark.".into());
215 202
    }
216 -
    let (preview, _) = preview_for(&app, &repo, &f.target, f.source.trim()).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
203 +
    let (preview, _) = preview_for(app, repo, target, source.trim()).await.map_err(|e| e.to_string())?;
217 204
    if preview.is_empty() {
218 -
        return Ok(fail("The source adds nothing to the target.", vec![]));
205 +
        return Err("The source adds nothing to the target.".into());
219 206
    }
220 207
    let source_repo_id = match &fork {
221 -
        Some(fk) => db::repos::get(&app.db, &fk.owner, &fk.name).await.map_err(|e| Error::from_db(shell.clone(), e))?.map(|r| r.id),
208 +
        Some(fk) => db::repos::get(&app.db, &fk.owner, &fk.name).await.map_err(|e| e.to_string())?.map(|r| r.id),
222 209
        None => None,
223 210
    };
224 211
    let created = pdb::create(
225 212
        &app.db,
226 213
        pdb::NewProposal {
227 214
            repo_id: row.id,
228 -
            title: f.title.trim(),
229 -
            body: f.body.trim(),
230 -
            author_id: Some(me.account.id),
231 -
            author: &me.account.username,
232 -
            draft: f.draft.is_some(),
233 -
            target: &f.target,
215 +
            title: title.trim(),
216 +
            body: body.trim(),
217 +
            author_id: Some(me.id),
218 +
            author: &me.username,
219 +
            draft,
220 +
            target,
234 221
            source_kind: if fork.is_some() { "fork" } else { "bookmark" },
235 222
            source_repo_id,
236 223
            source_bookmark: &bookmark,
…
1 unchanged lines
238 225
        },
239 226
    )
240 227
    .await
241 -
    .map_err(|e| Error::from_db(shell.clone(), e))?;
242 -
    pdb::add_event(&app.db, created.id, &me.account.username, "opened", "").await.ok();
243 -
    accounts::audit(&app.db, Some(&me.account), "proposal.opened", &format!("{owner}/{name}#{}", created.number), &f.title).await.ok();
228 +
    .map_err(|e| e.to_string())?;
229 +
    pdb::add_event(&app.db, created.id, &me.username, "opened", "").await.ok();
230 +
    accounts::audit(&app.db, Some(me), "proposal.opened", &format!("{}#{}", row.slug(), created.number), title).await.ok();
244 231
    db::notify::index(&app.db, "proposal", row.id, created.id, created.number, &created.title, &created.body).await.ok();
245 -
    let url = format!("/{owner}/{name}/proposal/{}", created.number);
232 +
    let url = format!("/{}/proposal/{}", row.slug(), created.number);
246 233
    crate::notify::deliver(
247 -
        &app,
234 +
        app,
248 235
        crate::notify::Event {
249 236
            kind: "proposal.opened",
250 -
            title: format!("{owner}/{name}#{}: {}", created.number, created.title),
251 -
            url: url.clone(),
252 -
            actor: &me.account,
237 +
            title: format!("{}#{}: {}", row.slug(), created.number, created.title),
238 +
            url,
239 +
            actor: me,
253 240
            repo_id: row.id,
254 241
            thread: Some(("proposal", created.id)),
255 -
            mention_text: &f.body,
242 +
            mention_text: body,
256 243
            direct: vec![],
257 244
        },
258 245
    )
259 246
    .await;
260 -
    Ok(Redirect::to(&url).into_response())
247 +
    crate::hooks::emit(
248 +
        app,
249 +
        row,
250 +
        "proposal.opened",
251 +
        &me.username,
252 +
        serde_json::json!({ "number": created.number, "title": created.title, "target": target, "source": source }),
253 +
    )
254 +
    .await;
255 +
    Ok(created)
256 +
}
257 +
258 +
pub async fn create(
259 +
    State(app): State<Shared>,
260 +
    viewer: Viewer,
261 +
    Path((owner, name)): Path<(String, String)>,
262 +
    Form(f): Form<NewForm>,
263 +
) -> Result<Response, Error> {
264 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposals/new"), &viewer);
265 +
    let me = require(&viewer, &shell)?;
266 +
    csrf(&viewer, &f.csrf_token, &shell)?;
267 +
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
268 +
    match open_proposal(&app, &repo, &row, &me.account, &f.title, &f.body, &f.target, &f.source, f.draft.is_some()).await {
269 +
        Ok(created) => Ok(Redirect::to(&format!("/{owner}/{name}/proposal/{}", created.number)).into_response()),
270 +
        Err(msg) => {
271 +
            let bookmarks =
272 +
                app.repos.read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::<Vec<_>>())).await.unwrap_or_default();
273 +
            Ok(Page(NewProposalPage {
274 +
                shell,
275 +
                repo: RepoShell::new(&app, &row, "proposals").with_role(role),
276 +
                bookmarks,
277 +
                source: f.source.clone(),
278 +
                target: f.target.clone(),
279 +
                error: Some(msg),
280 +
                preview: vec![],
281 +
                conflicted: false,
282 +
            })
283 +
            .into_response())
284 +
        }
285 +
    }
261 286
}
262 287
263 288
// ---------------------------------------------------------------- detail
…
160 unchanged lines
424 449
    if loaded.conflicted && !requirements.allow_conflicts {
425 450
        blockers.push("landing would carry conflicts, which this repository refuses".into());
426 451
    }
452 +
    let required = db::hooks::required_checks(&app.db, repo_id).await?;
453 +
    if !required.is_empty() {
454 +
        let checks = db::hooks::checks_for_state(&app.db, repo_id, &loaded.state).await?;
455 +
        let missing: Vec<String> =
456 +
            required.iter().filter(|n| !checks.iter().any(|c| &c.name == *n && c.status == "passed")).cloned().collect();
457 +
        if !missing.is_empty() {
458 +
            blockers.push(format!("required checks not passed on the projected state: {}", missing.join(", ")));
459 +
        }
460 +
    }
427 461
    let requesting: Vec<&String> = latest.iter().filter(|(_, r)| r.verdict == "changes").map(|(who, _)| who).collect();
428 462
    if !requesting.is_empty() {
429 463
        blockers.push(format!("changes requested by {}", requesting.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")));
…
50 unchanged lines
480 514
    api_url: String,
481 515
    labels: Vec<db::issues::Label>,
482 516
    all_labels: Vec<db::issues::Label>,
517 +
    checks: Vec<db::hooks::Check>,
518 +
    required_checks: Vec<String>,
483 519
}
484 520
485 521
#[derive(Deserialize)]
…
98 unchanged lines
584 620
        notice,
585 621
        error,
586 622
        heads: loaded.heads.iter().map(|h| h.to_hex()).collect(),
587 -
        state_hash: loaded.state,
623 +
        state_hash: loaded.state.clone(),
588 624
        source_label,
589 625
        api_url: format!("{}/api/v1/repos/{owner}/{name}/proposals/{number}", app.config.base_url()),
590 626
        labels: db::issues::proposal_labels(&app.db, loaded.row.id).await.unwrap_or_default(),
591 627
        all_labels: db::issues::labels(&app.db, row.id).await.unwrap_or_default(),
628 +
        checks: db::hooks::checks_for_state(&app.db, row.id, &loaded.state).await.unwrap_or_default(),
629 +
        required_checks: db::hooks::required_checks(&app.db, row.id).await.unwrap_or_default(),
592 630
    }))
593 631
}
594 632
…
114 unchanged lines
709 747
        .await
710 748
        .map_err(|e| Error::from_db(shell.clone(), e))?;
711 749
    pdb::add_event(&app.db, p.id, &me.account.username, &format!("review.{}", f.verdict), "").await.ok();
750 +
    crate::hooks::emit(&app, &row, "review", &me.account.username, serde_json::json!({ "number": number, "verdict": f.verdict })).await;
712 751
    let notice = if f.verdict == "approve" {
713 752
        Some(format!(
714 753
            "Approval recorded. It counts once you seal the changes: oxy seal --proposal {}/{owner}/{name}/proposal/{number}",
…
71 unchanged lines
786 825
    };
787 826
    pdb::set_state(&app.db, p.id, new_state, "").await.map_err(|e| Error::from_db(shell.clone(), e))?;
788 827
    pdb::add_event(&app.db, p.id, &me.account.username, new_state, "").await.ok();
828 +
    crate::hooks::emit(&app, &row, &format!("proposal.{new_state}"), &me.account.username, serde_json::json!({ "number": number })).await;
789 829
    detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), None, None).await
790 830
}
791 831
…
73 unchanged lines
865 905
        Ok(state) => {
866 906
            pdb::set_state(&app.db, p.id, "landed", &state).await.map_err(|e| Error::from_db(shell.clone(), e))?;
867 907
            pdb::add_event(&app.db, p.id, &me.account.username, "landed", "from the hub").await.ok();
908 +
            crate::hooks::emit(
909 +
                &app,
910 +
                &row,
911 +
                "proposal.landed",
912 +
                &me.account.username,
913 +
                serde_json::json!({ "number": number, "target": p.target, "state": state }),
914 +
            )
915 +
            .await;
868 916
            crate::notify::deliver(
869 917
                &app,
870 918
                crate::notify::Event {
…
67 unchanged lines
938 986
    detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), None, None).await
939 987
}
940 988
989 +
/// `GET /{owner}/{name}/proposal/{number}/events`: server-sent events for
990 +
/// checks on the projected state (hub spec §13.1).
991 +
pub async fn events(
992 +
    State(app): State<Shared>,
993 +
    viewer: Viewer,
994 +
    Path((owner, name, number)): Path<(String, String, i64)>,
995 +
) -> Result<axum::response::Sse<impl futures::Stream<Item = Result<axum::response::sse::Event, std::convert::Infallible>>>, Error> {
996 +
    use futures::StreamExt;
997 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
998 +
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
999 +
    let rx = app.events.proposals.subscribe();
1000 +
    let repo_id = row.id;
1001 +
    let stream = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(move |item| async move {
1002 +
        match item {
1003 +
            Ok((id, ev)) if id == repo_id => {
1004 +
                Some(Ok(axum::response::sse::Event::default().event(ev.kind.clone()).data(serde_json::to_string(&ev).unwrap_or_default())))
1005 +
            }
1006 +
            _ => None,
1007 +
        }
1008 +
    });
1009 +
    Ok(axum::response::Sse::new(stream).keep_alive(axum::response::sse::KeepAlive::default()))
1010 +
}
1011 +
941 1012
// -------------------------------------------------------------------- API
942 1013
943 1014
pub async fn to_api(app: &Shared, owner: &str, name: &str, loaded: &Loaded, v: &Verdict, source_url: Option<String>) -> hub_api::Proposal {
…
65 unchanged lines
modified crates/hub/src/web/repo_settings.rs +144 −2
1 1
//! Creating repositories and their settings pages (hub spec §6.3).
2 2
3 3
use super::settings::{csrf, require};
4 -
use super::{lookup_role, Error, Page, RepoShell, Shell};
4 +
use super::{filters, lookup_role, Error, Page, RepoShell, Shell};
5 5
use crate::auth::{roles, Access, Role, Viewer};
6 6
use crate::db::{self, accounts};
7 7
use crate::repos::RepoName;
…
79 unchanged lines
87 87
    teams: Vec<accounts::Team>,
88 88
    role: Role,
89 89
    requirements: db::proposals::Requirements,
90 +
    hooks: Vec<db::hooks::Hook>,
91 +
    required_checks: String,
90 92
    notice: Option<String>,
91 93
    error: Option<String>,
92 94
}
…
13 unchanged lines
106 108
    };
107 109
    let repo = RepoShell::new(app, &row, "settings").with_role(role);
108 110
    let requirements = db::proposals::requirements(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
109 -
    Ok(Page(RepoSettingsPage { shell, repo, row, collaborators, teams, role, requirements, notice, error }))
111 +
    let hooks = db::hooks::hooks_for_repo(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
112 +
    let required_checks = db::hooks::required_checks(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.join(", ");
113 +
    Ok(Page(RepoSettingsPage { shell, repo, row, collaborators, teams, role, requirements, hooks, required_checks, notice, error }))
110 114
}
111 115
112 116
pub async fn settings(
…
168 unchanged lines
281 285
    .ok();
282 286
    settings_page(&app, shell, row, role, Some("Landing requirements saved.".into()), None).await
283 287
}
288 +
289 +
#[derive(Deserialize)]
290 +
pub struct HookForm {
291 +
    #[serde(rename = "_csrf")]
292 +
    csrf_token: String,
293 +
    #[serde(default)]
294 +
    url: String,
295 +
    #[serde(default)]
296 +
    secret: String,
297 +
    #[serde(default = "star")]
298 +
    events: String,
299 +
    #[serde(default)]
300 +
    id: Option<i64>,
301 +
    #[serde(default)]
302 +
    action: String,
303 +
}
304 +
305 +
fn star() -> String {
306 +
    "*".into()
307 +
}
308 +
309 +
pub async fn hooks(
310 +
    State(app): State<Shared>,
311 +
    viewer: Viewer,
312 +
    Path((owner, name)): Path<(String, String)>,
313 +
    Form(f): Form<HookForm>,
314 +
) -> Result<Page<RepoSettingsPage>, Error> {
315 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
316 +
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
317 +
    csrf(&viewer, &f.csrf_token, &shell)?;
318 +
    let e = |e| Error::from_db(shell.clone(), e);
319 +
    let notice = match (f.action.as_str(), f.id) {
320 +
        ("delete", Some(id)) => {
321 +
            if db::hooks::hook(&app.db, id).await.map_err(e)?.is_some_and(|h| h.repo_id == Some(row.id)) {
322 +
                db::hooks::delete_hook(&app.db, id).await.map_err(e)?;
323 +
            }
324 +
            "Webhook deleted.".to_string()
325 +
        }
326 +
        ("toggle", Some(id)) => {
327 +
            if let Some(h) = db::hooks::hook(&app.db, id).await.map_err(e)?.filter(|h| h.repo_id == Some(row.id)) {
328 +
                db::hooks::set_hook_active(&app.db, id, !h.active).await.map_err(e)?;
329 +
            }
330 +
            "Webhook updated.".to_string()
331 +
        }
332 +
        ("ping", Some(id)) => {
333 +
            if let Some(h) = db::hooks::hook(&app.db, id).await.map_err(e)?.filter(|h| h.repo_id == Some(row.id)) {
334 +
                let delivery = crate::auth::random_hex(16);
335 +
                let env = hub_api::WebhookEnvelope {
336 +
                    event: "ping".into(),
337 +
                    delivery: delivery.clone(),
338 +
                    repository: None,
339 +
                    actor: viewer.account().map(|a| a.username.clone()).unwrap_or_default(),
340 +
                    at_ms: db::now_ms(),
341 +
                    data: serde_json::json!({ "zen": "changes commute" }),
342 +
                };
343 +
                db::hooks::queue_delivery(&app.db, h.id, &delivery, "ping", &serde_json::to_string(&env).unwrap_or_default())
344 +
                    .await
345 +
                    .map_err(e)?;
346 +
                app.events.notify_deliveries();
347 +
            }
348 +
            "Ping queued.".to_string()
349 +
        }
350 +
        _ => {
351 +
            if !(f.url.starts_with("http://") || f.url.starts_with("https://")) {
352 +
                return settings_page(&app, shell, row, role, None, Some("A webhook URL starts with http:// or https://.".into())).await;
353 +
            }
354 +
            db::hooks::create_hook(&app.db, Some(row.id), None, f.url.trim(), f.secret.trim(), f.events.trim()).await.map_err(e)?;
355 +
            accounts::audit(&app.db, viewer.account(), "hook.created", &format!("{owner}/{name}"), f.url.trim()).await.ok();
356 +
            "Webhook added.".to_string()
357 +
        }
358 +
    };
359 +
    settings_page(&app, shell, row, role, Some(notice), None).await
360 +
}
361 +
362 +
#[derive(Template)]
363 +
#[template(path = "deliveries.html")]
364 +
pub struct DeliveriesPage {
365 +
    shell: Shell,
366 +
    repo: RepoShell,
367 +
    hook: db::hooks::Hook,
368 +
    rows: Vec<db::hooks::Delivery>,
369 +
}
370 +
371 +
pub async fn deliveries(
372 +
    State(app): State<Shared>,
373 +
    viewer: Viewer,
374 +
    Path((owner, name, id)): Path<(String, String, i64)>,
375 +
) -> Result<Page<DeliveriesPage>, Error> {
376 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer);
377 +
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
378 +
    let hook = db::hooks::hook(&app.db, id)
379 +
        .await
380 +
        .map_err(|e| Error::from_db(shell.clone(), e))?
381 +
        .filter(|h| h.repo_id == Some(row.id))
382 +
        .ok_or_else(|| Error::not_found(shell.clone()))?;
383 +
    let rows = db::hooks::deliveries_for_hook(&app.db, id, 100).await.map_err(|e| Error::from_db(shell.clone(), e))?;
384 +
    Ok(Page(DeliveriesPage { shell, repo: RepoShell::new(&app, &row, "settings").with_role(role), hook, rows }))
385 +
}
386 +
387 +
pub async fn redeliver(
388 +
    State(app): State<Shared>,
389 +
    viewer: Viewer,
390 +
    Path((owner, name, id, delivery)): Path<(String, String, i64, i64)>,
391 +
    Form(f): Form<super::session::CsrfForm>,
392 +
) -> Result<Page<DeliveriesPage>, Error> {
393 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer);
394 +
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
395 +
    csrf(&viewer, &f.csrf, &shell)?;
396 +
    let mine = db::hooks::hook(&app.db, id).await.ok().flatten().is_some_and(|h| h.repo_id == Some(row.id));
397 +
    if mine && db::hooks::delivery(&app.db, delivery).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some_and(|d| d.hook_id == id)
398 +
    {
399 +
        db::hooks::redeliver(&app.db, delivery).await.map_err(|e| Error::from_db(shell.clone(), e))?;
400 +
        app.events.notify_deliveries();
401 +
    }
402 +
    deliveries(State(app), viewer, Path((owner, name, id))).await
403 +
}
404 +
405 +
#[derive(Deserialize)]
406 +
pub struct ChecksForm {
407 +
    #[serde(rename = "_csrf")]
408 +
    csrf_token: String,
409 +
    #[serde(default)]
410 +
    required_checks: String,
411 +
}
412 +
413 +
pub async fn required_checks(
414 +
    State(app): State<Shared>,
415 +
    viewer: Viewer,
416 +
    Path((owner, name)): Path<(String, String)>,
417 +
    Form(f): Form<ChecksForm>,
418 +
) -> Result<Page<RepoSettingsPage>, Error> {
419 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
420 +
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
421 +
    csrf(&viewer, &f.csrf_token, &shell)?;
422 +
    let names: Vec<String> = f.required_checks.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect();
423 +
    db::hooks::set_required_checks(&app.db, row.id, &names).await.map_err(|e| Error::from_db(shell.clone(), e))?;
424 +
    settings_page(&app, shell, row, role, Some("Required checks saved.".into()), None).await
425 +
}
modified docs/plan.md +10 −1
…
53 unchanged lines
54 54
- [ ] Email verification and password reset (needs the SMTP path proven on Earth).
55 55
- [ ] Import from GitHub and GitLab (1.x).
56 56
57 +
## 0.5 API, webhooks, checks (2026-09-27)
58 +
59 +
- [x] `/api/v1` covers repositories (list, create, get), bookmarks, tags, history, changes, tree, file, blame, conflicts, trust, pushes, proposals (list, create, get, comments, reviews, checks), issues (list, create, get, state, comments), labels, milestones, hooks, checks, user, users, orgs, search. Bearer tokens with scopes; a repository the caller may not read is a 404; RFC 7807 problems.
60 +
- [x] `hub-api` types carry JSON Schema; `make ts` writes `crates/hub-api/ts/index.d.ts` for the extensions.
61 +
- [x] Webhooks per repository (and organisation): events `push`, `proposal.*`, `review`, `comment.*`, `issue.*`, `check`, `ping`; keyed-BLAKE3 signature header; a worker with backoff over a day; delivery log with redeliver; settings page and API.
62 +
- [x] Checks on state hashes: any `checks:write` or write token reports; required checks per repository gate landing on the projected state; the proposal page shows them and reloads on a server-sent `check` event.
63 +
- [x] Tests: `tests/api.rs` with a local webhook receiver that fails the first attempt.
64 +
- [ ] Organisation webhooks page (the API and delivery path handle them; no settings page yet).
65 +
57 66
## Later
58 67
59 -
- 0.5 API, webhooks, checks · 0.6 web edits, releases, mirror · 0.7 runner · 1.0 hardening.
68 +
- 0.6 web edits, releases, mirror · 0.7 runner · 1.0 hardening.
60 69
61 70
## Notes
62 71
…
8 unchanged lines
modified templates/proposal.html +6 −0
…
114 unchanged lines
115 115
  <h3>Labels</h3>
116 116
  {% if labels.is_empty() %}<p class="mute small">None.</p>{% else %}<p>{% for l in labels %}<span class="label" style="--c: {{ l.colour }}">{{ l.name }}</span> {% endfor %}</p>{% endif %}
117 117
  {% if repo.can(crate::auth::Access::Write) && !all_labels.is_empty() %}<form class="tiny-form" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/labels"><input type="hidden" name="_csrf" value="{{ shell.csrf }}">{% for l in all_labels %}<label class="small"><input type="checkbox" name="label" value="{{ l.id }}"{% if labels.contains(l) %} checked{% endif %}> {{ l.name }}</label> {% endfor %}<button type="submit" class="small">SET</button></form>{% endif %}
118 +
  <h3>Checks</h3>
119 +
  <div id="checks">
120 +
  {% if checks.is_empty() %}<p class="mute small">None reported on the projected state{% if !required_checks.is_empty() %}; required: {{ required_checks.join(", ") }}{% endif %}.</p>{% else %}
121 +
  <ul class="plain">{% for c in checks %}<li><span class="sig{% if c.status == "passed" %} sig-valid{% else if c.status == "failed" %} sig-invalid{% else %} sig-unsigned{% endif %}">{% if c.status == "passed" %}✔{% else if c.status == "failed" %}✘{% else if c.status == "running" %}…{% else %}○{% endif %}</span> {% if c.url.is_empty() %}{{ c.name }}{% else %}<a href="{{ c.url }}">{{ c.name }}</a>{% endif %} <span class="mute small">{{ c.status }}{% if !c.summary.is_empty() %} · {{ c.summary }}{% endif %}{% if !c.seal.is_empty() %} · sealed{% endif %}</span></li>{% endfor %}</ul>{% endif %}
122 +
  </div>
123 +
  <script>(function(){if(!window.EventSource)return;var es=new EventSource("{{ repo.url }}/proposal/{{ p.number }}/events");es.addEventListener("check",function(){es.close();location.reload();});})();</script>
118 124
  <h3>Approvals</h3>
119 125
  {% if approvals.is_empty() %}<p class="mute small">None yet{% if requirements.required_approvals > 0 %}; {{ requirements.required_approvals }} sealed required{% endif %}.</p>{% else %}
120 126
  <ul class="plain">{% for a in approvals %}<li>{% if a.sealed %}<span class="sig sig-valid">◈</span>{% else %}<span class="sig sig-unsigned">○</span>{% endif %} <a href="/{{ a.account }}">{{ a.account }}</a>{% if !a.sealed %} <span class="mute small">seal pending</span>{% endif %}</li>{% endfor %}</ul>{% endif %}
…
25 unchanged lines
modified templates/repo_settings.html +23 −0
…
29 unchanged lines
30 30
  </div>
31 31
  <button type="submit">SAVE</button>
32 32
</form>
33 +
<form class="card wide" method="post" action="{{ repo.url }}/settings/checks">
34 +
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
35 +
  <h2>Required checks</h2>
36 +
  <label for="required_checks">Check names that must pass on a proposal's projected state (comma-separated)</label><input type="text" id="required_checks" name="required_checks" value="{{ required_checks }}" placeholder="build, test">
37 +
  <p class="small mute">Any token with <code>checks:write</code> reports checks with <code>POST {{ shell.base_url }}/api/v1/repos/{{ repo.owner }}/{{ repo.name }}/checks</code>.</p>
38 +
  <button type="submit">SAVE</button>
39 +
</form>
33 40
{% if role == crate::auth::Role::Admin %}
34 41
<section class="panel">
42 +
  <h2>Webhooks</h2>
43 +
  {% if hooks.is_empty() %}<p class="mute small">None.</p>{% else %}
44 +
  <table class="small"><tr><th>URL</th><th>Events</th><th>Active</th><th></th></tr>
45 +
  {% for h in hooks %}<tr><td>{{ h.url }}</td><td>{{ h.events }}</td><td>{% if h.active %}yes{% else %}no{% endif %}</td>
46 +
  <td><a class="small" href="{{ repo.url }}/settings/hooks/{{ h.id }}/deliveries">deliveries</a>
47 +
  <form class="tiny" method="post" action="{{ repo.url }}/settings/hooks"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="id" value="{{ h.id }}"><button name="action" value="ping">ping</button> <button name="action" value="toggle">{% if h.active %}disable{% else %}enable{% endif %}</button> <button name="action" value="delete">delete</button></form></td></tr>{% endfor %}
48 +
  </table>{% endif %}
49 +
  <form class="card" method="post" action="{{ repo.url }}/settings/hooks">
50 +
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
51 +
    <label for="hurl">URL</label><input type="text" id="hurl" name="url" placeholder="https://…" required>
52 +
    <label for="hsecret">Secret (signs each delivery)</label><input type="text" id="hsecret" name="secret">
53 +
    <label for="hevents">Events (<code>*</code>, or a list such as <code>push,proposal,issue</code>)</label><input type="text" id="hevents" name="events" value="*">
54 +
    <button type="submit">ADD WEBHOOK</button>
55 +
  </form>
56 +
</section>
57 +
<section class="panel">
35 58
  <h2>Collaborators</h2>
36 59
  {% if collaborators.is_empty() %}<p class="mute small">Nobody beyond the owner{% if !teams.is_empty() %} and the organisation's members{% endif %}.</p>{% else %}
37 60
  <table class="small">
…
26 unchanged lines