OXYGENHUB

Labels from oh: on proposals and issues, and the repository's labels 2291f8cc9c96

PATCH /proposals/{number} takes labels, the whole set by name, for
triage, every name checked before anything changes, recorded on the
timeline; a proposal's JSON lists its labels. oh proposal label N and
oh issue label N take --add and --remove; oh label list, create and
delete. ProposalEdit and ReleaseEdit join the schema list. Test.

closes #80
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-02 12:57 UTC
Signature
✔ signed by Andy McCall
Hash
2291f8cc9c9678aa1529be16a0e644cf466f14fb3602765d902993fcff467014
Depends on
0b20f1ec4007 1f1eb06a9009 5c6e92fd8d71 739a763a558c 749d9a5fcd9f 76f262e1a666 93be9ce4653e b5668108487f b64e2537d702 bf5101d394c0 cc1cd197df1d d23464c0ac60 de505df5cf8d
Depended on by
306aec1839aa 09ffa7b600a9 ba0a55bb5b3c 2a8b621676b4 dc59d1b56c3d 71e65fcf95ab 40dd3f86379a 17a3a84b00d1 62ceb2eec18c 5ea4872f23e8 b5229b56f49b 745d225de344 6a1780558353
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.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
23 atoms, 0 tree operations, 7 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Labels from `oh` (#80).** `oh proposal label N --add NAME --remove NAME` and `oh issue label N …` put labels on or take them off (triage), and `oh label list|create|delete` manage the repository's labels. Through the API, `PATCH /proposals/{n}` takes `labels` (the whole set, by name, every name checked before anything changes), and a proposal's JSON lists its labels.
8 +
7 9
- **A clearer release trust page (#79).** A row of badges at the top says whether the tag is signed and by whom, how many changes are signed or sealed, and how many assets an attested OxygenFlow build produced. The graph is drawn at its own size (it filled the screen), drops the builds column when nothing was built, and marks every box: a lock where the claim is signed and checked, `!` where it is not (unsigned, uploaded by hand), `✘` where it fails, with the reason underneath and the details on hover. Each asset's heading carries its badge too. The GitHub look no longer strokes the graph's text.
8 10
9 11
## 0.3.0 (2026-10-02)
…
31 unchanged lines
modified crates/hub-api/src/lib.rs +7 −0
…
172 unchanged lines
173 173
    /// Asked to review and not yet reviewing, in the order asked.
174 174
    #[serde(default)]
175 175
    pub requested_reviewers: Vec<String>,
176 +
    #[serde(default)]
177 +
    pub labels: Vec<Label>,
176 178
}
177 179
178 180
/// How a proposal was landed.
…
691 unchanged lines
870 872
    pub title: Option<String>,
871 873
    #[serde(default, skip_serializing_if = "Option::is_none")]
872 874
    pub body: Option<String>,
875 +
    /// The whole set of labels, by name (triage); oxygenhub#80.
876 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
877 +
    pub labels: Option<Vec<String>>,
873 878
}
874 879
875 880
/// `PATCH /repos/{owner}/{name}/releases/{tag}`: what to change; fields
…
62 unchanged lines
938 943
        ("NewRepository", schemars::schema_for!(NewRepository)),
939 944
        ("NewIssue", schemars::schema_for!(NewIssue)),
940 945
        ("IssueEdit", schemars::schema_for!(IssueEdit)),
946 +
        ("ReleaseEdit", schemars::schema_for!(ReleaseEdit)),
947 +
        ("ProposalEdit", schemars::schema_for!(ProposalEdit)),
941 948
        ("NewLabel", schemars::schema_for!(NewLabel)),
942 949
        ("RequiredChecks", schemars::schema_for!(RequiredChecks)),
943 950
        ("Project", schemars::schema_for!(Project)),
…
67 unchanged lines
modified crates/hub/src/api/mod.rs +32 −9
…
832 unchanged lines
833 833
    let me = c.require("proposals:write").or_else(|_| c.require("repo:write"))?.clone();
834 834
    let (repo, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?;
835 835
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?;
836 -
    if Some(me.id) != p.author_id && role < Role::Maintain {
836 +
    let text = body.title.is_some() || body.body.is_some();
837 +
    if text && Some(me.id) != p.author_id && role < Role::Maintain {
837 838
        return Err(ApiError(StatusCode::FORBIDDEN, "only the proposal's author or a maintainer can edit it".into()));
839 +
    }
840 +
    if body.labels.is_some() && role < Role::Triage {
841 +
        return Err(forbidden("setting labels needs triage access"));
842 +
    }
843 +
    // Every name is checked before anything changes.
844 +
    let labels = resolve_triage(&app, row.id, body.labels.as_deref(), None, None).await?.labels;
845 +
    if text {
846 +
        let title = match body.title.as_deref().map(str::trim) {
847 +
            Some("") => return Err(ApiError(StatusCode::BAD_REQUEST, "a proposal needs a title".into())),
848 +
            Some(t) => t,
849 +
            None => p.title.as_str(),
850 +
        };
851 +
        let text = body.body.as_deref().map(str::trim).unwrap_or(&p.body);
852 +
        db::proposals::edit(&app.db, p.id, title, text, &p.target).await?;
853 +
        db::proposals::add_event(&app.db, p.id, &me.username, "edited", "").await.ok();
838 854
    }
839 -
    let title = match body.title.as_deref().map(str::trim) {
840 -
        Some("") => return Err(ApiError(StatusCode::BAD_REQUEST, "a proposal needs a title".into())),
841 -
        Some(t) => t,
842 -
        None => p.title.as_str(),
843 -
    };
844 -
    let text = body.body.as_deref().map(str::trim).unwrap_or(&p.body);
845 -
    db::proposals::edit(&app.db, p.id, title, text, &p.target).await?;
846 -
    db::proposals::add_event(&app.db, p.id, &me.username, "edited", "").await.ok();
855 +
    if let Some(want) = labels {
856 +
        let have = db::issues::proposal_labels(&app.db, p.id).await?;
857 +
        for l in &have {
858 +
            if !want.iter().any(|w| w.id == l.id) {
859 +
                db::issues::set_proposal_label(&app.db, p.id, l.id, false).await?;
860 +
            }
861 +
        }
862 +
        for w in &want {
863 +
            if !have.iter().any(|l| l.id == w.id) {
864 +
                db::issues::set_proposal_label(&app.db, p.id, w.id, true).await?;
865 +
            }
866 +
        }
867 +
        let names: Vec<&str> = want.iter().map(|l| l.name.as_str()).collect();
868 +
        db::proposals::add_event(&app.db, p.id, &me.username, "set labels", &names.join(", ")).await.ok();
869 +
    }
847 870
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?;
848 871
    let source_url = match p.source_repo_id {
849 872
        Some(id) => db::repos::get_by_id(&app.db, id).await?.map(|r| format!("{}/{}", app.config.base_url(), r.slug())),
…
1133 unchanged lines
modified crates/hub/src/web/proposals.rs +6 −0
…
1255 unchanged lines
1256 1256
            when_ms: p.landed_ms,
1257 1257
        }),
1258 1258
        requested_reviewers: pdb::review_requests(&app.db, p.id).await.unwrap_or_default(),
1259 +
        labels: db::issues::proposal_labels(&app.db, p.id)
1260 +
            .await
1261 +
            .unwrap_or_default()
1262 +
            .into_iter()
1263 +
            .map(|l| hub_api::Label { name: l.name, colour: l.colour, description: l.description })
1264 +
            .collect(),
1259 1265
    }
1260 1266
}
1261 1267
…
128 unchanged lines
modified crates/hub/tests/api.rs +22 −0
…
459 unchanged lines
460 460
    let n = v["number"].as_i64().unwrap();
461 461
    assert_eq!(edit(&bob_rw, n, r#"{"body":"mine now"}"#).await.0, StatusCode::FORBIDDEN);
462 462
    assert_eq!(edit(&andy_ro, 2, r#"{"body":"x"}"#).await.0, StatusCode::FORBIDDEN, "a read-only token cannot edit");
463 +
464 +
    // oxygenhub#80: labels on a proposal, the whole set by name, checked
465 +
    // before anything changes, shown in its JSON.
466 +
    for l in ["ui", "flow"] {
467 +
        let body = format!(r#"{{"name":"{l}"}}"#);
468 +
        assert_eq!(api(app, "POST", "/api/v1/repos/andy/demo/labels", Some(&andy_admin), Some(&body)).await.0, StatusCode::CREATED);
469 +
    }
470 +
    assert_eq!(edit(&bob_rw, 2, r#"{"labels":["ui"]}"#).await.0, StatusCode::FORBIDDEN, "labels need triage, not authorship");
471 +
    let (st, v) = edit(&andy_pw, 2, r#"{"labels":["ui","flow"]}"#).await;
472 +
    assert_eq!(st, StatusCode::OK, "{v}");
473 +
    let names = |v: &serde_json::Value| -> Vec<String> {
474 +
        v["labels"].as_array().unwrap().iter().map(|l| l["name"].as_str().unwrap().to_string()).collect()
475 +
    };
476 +
    assert_eq!(names(&v), ["flow", "ui"]);
477 +
    assert_eq!(v["title"], "Feat, renamed", "labels alone leave the text");
478 +
    let (st, _) = edit(&andy_pw, 2, r#"{"labels":["ui","nope"]}"#).await;
479 +
    assert_eq!(st, StatusCode::BAD_REQUEST, "an unknown label is refused");
480 +
    let (_, v) = api(app, "GET", "/api/v1/repos/andy/demo/proposals/2", None, None).await;
481 +
    assert_eq!(names(&v), ["flow", "ui"], "and nothing changed");
482 +
    let (_, v) = edit(&andy_pw, 2, r#"{"labels":["flow"]}"#).await;
483 +
    assert_eq!(names(&v), ["flow"]);
484 +
    assert_eq!(edit(&andy_ro, 2, r#"{"labels":[]}"#).await.0, StatusCode::FORBIDDEN);
463 485
}
464 486
465 487
#[tokio::test(flavor = "multi_thread")]
…
841 unchanged lines
modified crates/oh/src/main.rs +121 −1
…
66 unchanged lines
67 67
        #[command(subcommand)]
68 68
        cmd: ReleaseCmd,
69 69
    },
70 +
    /// The repository's labels
71 +
    Label {
72 +
        #[command(subcommand)]
73 +
        cmd: LabelCmd,
74 +
    },
70 75
    /// Call the API directly: `oh api /repos/OWNER/NAME/issues`
71 76
    Api {
72 77
        /// A path under /api/v1
…
35 unchanged lines
108 113
109 114
#[derive(Subcommand)]
110 115
enum IssueCmd {
116 +
    /// Put labels on an issue or take them off (triage)
117 +
    Label(LabelChange),
111 118
    /// Open issues (or --state closed|all)
112 119
    List {
113 120
        #[arg(long, default_value = "open")]
…
69 unchanged lines
183 190
        #[arg(long, short = 'F', conflicts_with = "body")]
184 191
        body_file: Option<String>,
185 192
    },
193 +
    /// Put labels on a proposal or take them off (triage)
194 +
    Label(LabelChange),
186 195
    /// Comment on a proposal's conversation
187 196
    Comment {
188 197
        number: i64,
…
7 unchanged lines
196 205
    Checks { number: i64 },
197 206
    /// Land a proposal on its target, as the page's Land button does
198 207
    Land { number: i64 },
208 +
}
209 +
210 +
#[derive(Subcommand)]
211 +
enum LabelCmd {
212 +
    /// The repository's labels
213 +
    List,
214 +
    /// Create a label, or change the one of that name (triage)
215 +
    Create {
216 +
        name: String,
217 +
        /// `#rrggbb` (default: one the hub picks)
218 +
        #[arg(long)]
219 +
        colour: Option<String>,
220 +
        #[arg(long, short, default_value = "")]
221 +
        description: String,
222 +
    },
223 +
    /// Delete a label, taking it off every issue and proposal (triage)
224 +
    Delete { name: String },
225 +
}
226 +
227 +
/// Labels to put on or take off an issue or proposal.
228 +
#[derive(clap::Args)]
229 +
struct LabelChange {
230 +
    number: i64,
231 +
    /// A label to add (repeatable)
232 +
    #[arg(long, short)]
233 +
    add: Vec<String>,
234 +
    /// A label to take off (repeatable)
235 +
    #[arg(long, short)]
236 +
    remove: Vec<String>,
237 +
}
238 +
239 +
impl LabelChange {
240 +
    /// The set after the change, from the one there now.
241 +
    fn apply(&self, now: &[hub_api::Label]) -> Result<Vec<String>> {
242 +
        if self.add.is_empty() && self.remove.is_empty() {
243 +
            bail!("say what to change: --add NAME or --remove NAME");
244 +
        }
245 +
        let mut names: Vec<String> = now.iter().map(|l| l.name.clone()).filter(|n| !self.remove.contains(n)).collect();
246 +
        for a in &self.add {
247 +
            if !names.contains(a) {
248 +
                names.push(a.clone());
249 +
            }
250 +
        }
251 +
        Ok(names)
252 +
    }
253 +
}
254 +
255 +
fn label_names(labels: &[String]) -> String {
256 +
    if labels.is_empty() {
257 +
        ui::dim("no labels")
258 +
    } else {
259 +
        labels.iter().map(|l| format!("[{l}]")).collect::<Vec<_>>().join(" ")
260 +
    }
199 261
}
200 262
201 263
#[derive(Subcommand)]
…
133 unchanged lines
335 397
        Cmd::Proposal { cmd } => proposal(&c, &t, cmd, json),
336 398
        Cmd::Run { cmd } => flow(&c, &t, cmd, json),
337 399
        Cmd::Release { cmd } => release(&c, &t, cmd, json),
400 +
        Cmd::Label { cmd } => label(&c, &t, cmd, json),
338 401
    }
339 402
}
340 403
…
149 unchanged lines
490 553
            let i: Issue =
491 554
                c.send("POST", &format!("{base}/issues/{number}"), &IssueStateChange { state: "open".into(), reason: String::new() })?;
492 555
            println!("{} reopened #{} {}", ui::ok("✔"), i.number, i.title);
556 +
        }
557 +
        IssueCmd::Label(change) => {
558 +
            let path = format!("{base}/issues/{}", change.number);
559 +
            let now: Issue = c.get(&path)?;
560 +
            let labels = change.apply(&now.labels)?;
561 +
            let edit = hub_api::IssueEdit { title: None, body: None, labels: Some(labels.clone()), assignees: None, milestone: None };
562 +
            let i: Issue = c.send("PATCH", &path, &edit)?;
563 +
            if json {
564 +
                println!("{}", serde_json::to_string(&i)?);
565 +
            } else {
566 +
                println!("{} #{} {}", ui::ok("✔"), i.number, label_names(&labels));
567 +
            }
493 568
        }
494 569
        IssueCmd::Comment { number, body } => {
495 570
            c.send_text("POST", &format!("{base}/issues/{number}/comments"), &NewComment { body, path: None, line: None })?;
…
94 unchanged lines
590 665
            if title.is_none() && body.is_none() {
591 666
                bail!("say what to change: --title, --body or --body-file");
592 667
            }
593 -
            let edit = hub_api::ProposalEdit { title, body };
668 +
            let edit = hub_api::ProposalEdit { title, body, labels: None };
594 669
            let p: Proposal = c.send("PATCH", &format!("{base}/proposals/{number}"), &edit)?;
595 670
            if json {
596 671
                println!("{}", serde_json::to_string(&p)?);
597 672
            } else {
598 673
                println!("{} edited #{} {}", ui::ok("✔"), p.number, ui::dim(&p.url));
674 +
            }
675 +
        }
676 +
        ProposalCmd::Label(change) => {
677 +
            let path = format!("{base}/proposals/{}", change.number);
678 +
            let now: Proposal = c.get(&path)?;
679 +
            let labels = change.apply(&now.labels)?;
680 +
            let edit = hub_api::ProposalEdit { labels: Some(labels.clone()), ..Default::default() };
681 +
            let p: Proposal = c.send("PATCH", &path, &edit)?;
682 +
            if json {
683 +
                println!("{}", serde_json::to_string(&p)?);
684 +
            } else {
685 +
                println!("{} #{} {}", ui::ok("✔"), p.number, label_names(&labels));
599 686
            }
600 687
        }
601 688
        ProposalCmd::Comment { number, body, body_file } => {
…
158 unchanged lines
760 847
                println!("{started}");
761 848
            } else {
762 849
                println!("{} started run #{} {}", ui::ok("✔"), started["number"], ui::dim(started["url"].as_str().unwrap_or("")));
850 +
            }
851 +
        }
852 +
    }
853 +
    Ok(0)
854 +
}
855 +
856 +
fn label(c: &Client, t: &Target, cmd: LabelCmd, json: bool) -> Result<i32> {
857 +
    let base = t.repo_path()?;
858 +
    match cmd {
859 +
        LabelCmd::List => {
860 +
            let path = format!("{base}/labels");
861 +
            if json {
862 +
                println!("{}", c.get_text(&path)?);
863 +
                return Ok(0);
864 +
            }
865 +
            let labels: Vec<hub_api::Label> = c.get(&path)?;
866 +
            if labels.is_empty() {
867 +
                println!("{}", ui::dim("No labels."));
763 868
            }
869 +
            for l in labels {
870 +
                println!("{} {}  {}", ui::dim(&l.colour), l.name, ui::dim(&l.description));
871 +
            }
872 +
        }
873 +
        LabelCmd::Create { name, colour, description } => {
874 +
            let l: hub_api::Label = c.send("POST", &format!("{base}/labels"), &hub_api::NewLabel { name, colour, description })?;
875 +
            if json {
876 +
                println!("{}", serde_json::to_string(&l)?);
877 +
            } else {
878 +
                println!("{} label {} {}", ui::ok("✔"), l.name, ui::dim(&l.colour));
879 +
            }
880 +
        }
881 +
        LabelCmd::Delete { name } => {
882 +
            c.send_text("DELETE", &format!("{base}/labels/{}", enc(&name)), &serde_json::json!({}))?;
883 +
            println!("{} deleted label {name}", ui::ok("✔"));
764 884
        }
765 885
    }
766 886
    Ok(0)
…
94 unchanged lines
modified docs/spec.md +1 −1
…
440 unchanged lines
441 441
442 442
### 12.1 API
443 443
444 -
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history`, `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones`, `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags`, `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/notifications` (the caller's inbox and unread count; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, `/cards` to add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one; `repo:write` and the page's rights to change), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state), `/flow/runs/{n}` (a run with its jobs), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link.
444 +
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history`, `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones`, `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags`, `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/notifications` (the caller's inbox and unread count; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, `/cards` to add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one; `repo:write` and the page's rights to change), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state), `/flow/runs/{n}` (a run with its jobs), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link.
445 445
446 446
### 12.2 Webhooks
447 447
…
215 unchanged lines