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 |