OXYGENHUB

Issue API: labels, assignees and milestone when creating, PATCH to edit an issue, and creating and deleting labels 76f262e1a666

closes #3
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-28 14:33 UTC
Signature
✔ signed by Andy McCall
Hash
76f262e1a6663e5a3e9f10e04826b7dca067762ff045ce96948162b228a2c4f1
Depends on
42a0ba4bac4e 93be9ce4653e b64e2537d702 d23464c0ac60
Depended on by
5a9a6e9a15bc 456f4197ade3 c8684d44d549 71e65fcf95ab 4d2e44d79b03 5ea4872f23e8 2291f8cc9c96 961c10bee174 ef20fdc14e8b 9756c67d3aec
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
16 atoms, 0 tree operations, 5 files

Browse the tree at this change

modified crates/hub-api/src/lib.rs +41 −0
…
532 unchanged lines
533 533
    pub title: String,
534 534
    #[serde(default)]
535 535
    pub body: String,
536 +
    /// Label names; each must exist. Needs triage.
537 +
    #[serde(default)]
538 +
    pub labels: Vec<String>,
539 +
    /// Usernames. Needs triage.
540 +
    #[serde(default)]
541 +
    pub assignees: Vec<String>,
542 +
    /// A milestone's title. Needs triage.
543 +
    #[serde(default)]
544 +
    pub milestone: Option<String>,
536 545
}
537 546
547 +
/// `PATCH /repos/{o}/{n}/issues/{number}`: only the fields given change.
548 +
/// Title and body: the author or triage. The rest: triage.
549 +
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
550 +
pub struct IssueEdit {
551 +
    #[serde(default)]
552 +
    pub title: Option<String>,
553 +
    #[serde(default)]
554 +
    pub body: Option<String>,
555 +
    /// The whole set of labels.
556 +
    #[serde(default)]
557 +
    pub labels: Option<Vec<String>>,
558 +
    /// The whole set of assignees.
559 +
    #[serde(default)]
560 +
    pub assignees: Option<Vec<String>>,
561 +
    /// A milestone's title; the empty string clears it.
562 +
    #[serde(default)]
563 +
    pub milestone: Option<String>,
564 +
}
565 +
566 +
/// `POST /repos/{o}/{n}/labels`: create a label, or change one of that name.
567 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
568 +
pub struct NewLabel {
569 +
    pub name: String,
570 +
    /// `#rrggbb`; a default when absent.
571 +
    #[serde(default)]
572 +
    pub colour: Option<String>,
573 +
    #[serde(default)]
574 +
    pub description: String,
575 +
}
576 +
538 577
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
539 578
pub struct NewComment {
540 579
    pub body: String,
…
133 unchanged lines
674 713
        ("Organisation", schemars::schema_for!(Organisation)),
675 714
        ("NewRepository", schemars::schema_for!(NewRepository)),
676 715
        ("NewIssue", schemars::schema_for!(NewIssue)),
716 +
        ("IssueEdit", schemars::schema_for!(IssueEdit)),
717 +
        ("NewLabel", schemars::schema_for!(NewLabel)),
677 718
        ("NewComment", schemars::schema_for!(NewComment)),
678 719
        ("NewProposal", schemars::schema_for!(NewProposal)),
679 720
        ("NewReview", schemars::schema_for!(NewReview)),
…
8 unchanged lines
modified crates/hub-api/ts/index.d.ts +17 −0
…
333 unchanged lines
334 334
};
335 335
336 336
export type NewIssue = {
337 +
  assignees?: string[];
337 338
  body?: string;
339 +
  labels?: string[];
340 +
  milestone?: string | null;
338 341
  title: string;
342 +
};
343 +
344 +
export type IssueEdit = {
345 +
  assignees?: string[] | null;
346 +
  body?: string | null;
347 +
  labels?: string[] | null;
348 +
  milestone?: string | null;
349 +
  title?: string | null;
350 +
};
351 +
352 +
export type NewLabel = {
353 +
  colour?: string | null;
354 +
  description?: string;
355 +
  name: string;
339 356
};
340 357
341 358
export type NewComment = {
…
69 unchanged lines
modified crates/hub/src/api/mod.rs +194 −3
…
44 unchanged lines
45 45
        .route("/repos/{owner}/{name}/proposals/{number}/files", get(proposal_files))
46 46
        .route("/repos/{owner}/{name}/proposals/{number}/content/{side}/{*path}", get(proposal_content))
47 47
        .route("/repos/{owner}/{name}/issues", get(issues).post(create_issue))
48 -
        .route("/repos/{owner}/{name}/issues/{number}", get(issue).post(issue_state))
48 +
        .route("/repos/{owner}/{name}/issues/{number}", get(issue).post(issue_state).patch(issue_edit))
49 49
        .route("/repos/{owner}/{name}/issues/{number}/comments", get(issue_comments).post(issue_comment))
50 -
        .route("/repos/{owner}/{name}/labels", get(labels))
50 +
        .route("/repos/{owner}/{name}/labels", get(labels).post(create_label))
51 +
        .route("/repos/{owner}/{name}/labels/{label}", axum::routing::delete(delete_label))
51 52
        .route("/repos/{owner}/{name}/milestones", get(milestones))
52 53
        .route("/repos/{owner}/{name}/hooks", get(hooks).post(create_hook))
53 54
        .route("/repos/{owner}/{name}/hooks/{id}/delete", post(delete_hook))
…
903 unchanged lines
957 958
) -> Result<(StatusCode, Json<Issue>), ApiError> {
958 959
    let c = caller(&app, &headers).await?;
959 960
    let me = c.require("issues:write").or_else(|_| c.require("repo:write"))?.clone();
960 -
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
961 +
    let (_, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?;
961 962
    if body.title.trim().is_empty() {
962 963
        return Err(ApiError(StatusCode::BAD_REQUEST, "an issue needs a title".into()));
963 964
    }
965 +
    let triage = !body.labels.is_empty() || !body.assignees.is_empty() || body.milestone.is_some();
966 +
    if triage && role < Role::Triage {
967 +
        return Err(forbidden("setting labels, assignees or a milestone needs triage access"));
968 +
    }
969 +
    // Check the names before anything is created.
970 +
    resolve_triage(&app, row.id, Some(&body.labels), Some(&body.assignees), body.milestone.as_deref()).await?;
964 971
    let i = db::issues::create(
965 972
        &app.db,
966 973
        db::issues::NewIssue {
…
6 unchanged lines
973 980
    )
974 981
    .await?;
975 982
    db::issues::add_event(&app.db, i.id, &me.username, "opened", "").await.ok();
983 +
    if triage {
984 +
        let t = resolve_triage(&app, row.id, Some(&body.labels), Some(&body.assignees), body.milestone.as_deref()).await?;
985 +
        apply_triage(&app, &i, &me, t).await?;
986 +
    }
987 +
    let i = db::issues::get(&app.db, row.id, i.number).await?.unwrap_or(i);
976 988
    crate::web::issues::reindex(&app, row.id, &i).await;
977 989
    let api = issue_of(&app, &row, &i).await?;
978 990
    crate::notify::deliver(
…
118 unchanged lines
1097 1109
    let api = Comment { id: cm.id, author: cm.author, body: cm.body, created_ms: cm.created_ms, path: None, anchor: None, resolved: false };
1098 1110
    crate::hooks::emit(&app, &row, "comment.issue", &me.username, serde_json::json!({ "number": number, "comment": api })).await;
1099 1111
    Ok((StatusCode::CREATED, Json(api)))
1112 +
}
1113 +
1114 +
/// Labels, assignees and milestone named in a request, looked up.
1115 +
struct Triage {
1116 +
    labels: Option<Vec<db::issues::Label>>,
1117 +
    assignees: Option<Vec<Account>>,
1118 +
    /// `Some(None)` clears the milestone.
1119 +
    milestone: Option<Option<db::issues::Milestone>>,
1120 +
}
1121 +
1122 +
async fn resolve_triage(
1123 +
    app: &Shared,
1124 +
    repo_id: i64,
1125 +
    labels: Option<&[String]>,
1126 +
    assignees: Option<&[String]>,
1127 +
    milestone: Option<&str>,
1128 +
) -> Result<Triage, ApiError> {
1129 +
    let bad = |m: String| ApiError(StatusCode::BAD_REQUEST, m);
1130 +
    let labels = match labels {
1131 +
        Some(names) => {
1132 +
            let mut out = Vec::new();
1133 +
            for n in names {
1134 +
                out.push(
1135 +
                    db::issues::label_by_name(&app.db, repo_id, n.trim())
1136 +
                        .await?
1137 +
                        .ok_or_else(|| bad(format!("no label {n:?}; create it first")))?,
1138 +
                );
1139 +
            }
1140 +
            Some(out)
1141 +
        }
1142 +
        None => None,
1143 +
    };
1144 +
    let assignees = match assignees {
1145 +
        Some(names) => {
1146 +
            let mut out = Vec::new();
1147 +
            for n in names {
1148 +
                out.push(db::accounts::account_by_name(&app.db, n.trim()).await?.ok_or_else(|| bad(format!("no account {n:?}")))?);
1149 +
            }
1150 +
            Some(out)
1151 +
        }
1152 +
        None => None,
1153 +
    };
1154 +
    let milestone = match milestone.map(str::trim) {
1155 +
        None => None,
1156 +
        Some("") => Some(None),
1157 +
        Some(title) => Some(Some(
1158 +
            db::issues::milestones(&app.db, repo_id)
1159 +
                .await?
1160 +
                .into_iter()
1161 +
                .find(|m| m.title == title)
1162 +
                .ok_or_else(|| bad(format!("no milestone {title:?}")))?,
1163 +
        )),
1164 +
    };
1165 +
    Ok(Triage { labels, assignees, milestone })
1166 +
}
1167 +
1168 +
/// Make an issue's labels, assignees and milestone what `t` says, with
1169 +
/// timeline events for what changed.
1170 +
async fn apply_triage(app: &Shared, i: &db::issues::IssueRow, me: &Account, t: Triage) -> Result<(), ApiError> {
1171 +
    if let Some(want) = t.labels {
1172 +
        let have = db::issues::issue_labels(&app.db, i.id).await?;
1173 +
        for l in &have {
1174 +
            if !want.iter().any(|w| w.id == l.id) {
1175 +
                db::issues::set_issue_label(&app.db, i.id, l.id, false).await?;
1176 +
            }
1177 +
        }
1178 +
        for w in &want {
1179 +
            if !have.iter().any(|l| l.id == w.id) {
1180 +
                db::issues::set_issue_label(&app.db, i.id, w.id, true).await?;
1181 +
            }
1182 +
        }
1183 +
        let names: Vec<&str> = want.iter().map(|l| l.name.as_str()).collect();
1184 +
        db::issues::add_event(&app.db, i.id, &me.username, "set labels", &names.join(", ")).await.ok();
1185 +
    }
1186 +
    if let Some(want) = t.assignees {
1187 +
        let have = db::issues::assignees(&app.db, i.id).await?;
1188 +
        for name in &have {
1189 +
            if !want.iter().any(|a| &a.username == name) {
1190 +
                if let Some(a) = db::accounts::account_by_name(&app.db, name).await? {
1191 +
                    db::issues::set_assignee(&app.db, i.id, a.id, false).await?;
1192 +
                    db::issues::add_event(&app.db, i.id, &me.username, "unassigned", name).await.ok();
1193 +
                }
1194 +
            }
1195 +
        }
1196 +
        for a in &want {
1197 +
            if !have.contains(&a.username) {
1198 +
                db::issues::set_assignee(&app.db, i.id, a.id, true).await?;
1199 +
                db::issues::add_event(&app.db, i.id, &me.username, "assigned", &a.username).await.ok();
1200 +
            }
1201 +
        }
1202 +
    }
1203 +
    if let Some(m) = t.milestone {
1204 +
        db::issues::set_milestone(&app.db, i.id, m.as_ref().map(|m| m.id)).await?;
1205 +
        db::issues::add_event(&app.db, i.id, &me.username, "set milestone", m.as_ref().map(|m| m.title.as_str()).unwrap_or("")).await.ok();
1206 +
    }
1207 +
    Ok(())
1208 +
}
1209 +
1210 +
/// `PATCH /repos/{o}/{n}/issues/{number}`.
1211 +
async fn issue_edit(
1212 +
    State(app): State<Shared>,
1213 +
    headers: HeaderMap,
1214 +
    Path((owner, name, number)): Path<(String, String, i64)>,
1215 +
    Json(body): Json<IssueEdit>,
1216 +
) -> Result<Json<Issue>, ApiError> {
1217 +
    let c = caller(&app, &headers).await?;
1218 +
    let me = c.require("issues:write").or_else(|_| c.require("repo:write"))?.clone();
1219 +
    let (_, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?;
1220 +
    let i = db::issues::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such issue"))?;
1221 +
    let text = body.title.is_some() || body.body.is_some();
1222 +
    let triage = body.labels.is_some() || body.assignees.is_some() || body.milestone.is_some();
1223 +
    if text && role < Role::Triage && Some(me.id) != i.author_id {
1224 +
        return Err(forbidden("only the author or triage may edit the title and body"));
1225 +
    }
1226 +
    if triage && role < Role::Triage {
1227 +
        return Err(forbidden("setting labels, assignees or a milestone needs triage access"));
1228 +
    }
1229 +
    if i.locked && role < Role::Triage {
1230 +
        return Err(forbidden("this issue is locked"));
1231 +
    }
1232 +
    let t = resolve_triage(&app, row.id, body.labels.as_deref(), body.assignees.as_deref(), body.milestone.as_deref()).await?;
1233 +
    if text {
1234 +
        let title = body.title.as_deref().map(str::trim).unwrap_or(&i.title);
1235 +
        if title.is_empty() {
1236 +
            return Err(ApiError(StatusCode::BAD_REQUEST, "an issue needs a title".into()));
1237 +
        }
1238 +
        db::issues::edit(&app.db, i.id, title, body.body.as_deref().unwrap_or(&i.body)).await?;
1239 +
        db::issues::add_event(&app.db, i.id, &me.username, "edited", "").await.ok();
1240 +
    }
1241 +
    apply_triage(&app, &i, &me, t).await?;
1242 +
    db::issues::touch(&app.db, i.id).await.ok();
1243 +
    let fresh = db::issues::get(&app.db, row.id, number).await?.unwrap_or(i);
1244 +
    crate::web::issues::reindex(&app, row.id, &fresh).await;
1245 +
    let api = issue_of(&app, &row, &fresh).await?;
1246 +
    crate::hooks::emit(&app, &row, "issue.edited", &me.username, serde_json::json!({ "issue": api })).await;
1247 +
    Ok(Json(api))
1248 +
}
1249 +
1250 +
/// `POST /repos/{o}/{n}/labels` (triage).
1251 +
async fn create_label(
1252 +
    State(app): State<Shared>,
1253 +
    headers: HeaderMap,
1254 +
    Path((owner, name)): Path<(String, String)>,
1255 +
    Json(body): Json<NewLabel>,
1256 +
) -> Result<(StatusCode, Json<Label>), ApiError> {
1257 +
    let c = caller(&app, &headers).await?;
1258 +
    c.require("issues:write").or_else(|_| c.require("repo:write"))?;
1259 +
    let (_, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?;
1260 +
    if role < Role::Triage {
1261 +
        return Err(forbidden("labels need triage access"));
1262 +
    }
1263 +
    let label = body.name.trim();
1264 +
    if label.is_empty() || label.len() > 50 {
1265 +
        return Err(ApiError(StatusCode::BAD_REQUEST, "a label has a name of 1 to 50 characters".into()));
1266 +
    }
1267 +
    let colour = match body.colour.as_deref() {
1268 +
        Some(c) if c.len() == 7 && c.starts_with('#') && c[1..].chars().all(|x| x.is_ascii_hexdigit()) => c.to_string(),
1269 +
        Some(c) => return Err(ApiError(StatusCode::BAD_REQUEST, format!("colour {c:?} is not #rrggbb"))),
1270 +
        None => "#5aa8ff".to_string(),
1271 +
    };
1272 +
    let l = db::issues::upsert_label(&app.db, row.id, label, &colour, body.description.trim()).await?;
1273 +
    Ok((StatusCode::CREATED, Json(Label { name: l.name, colour: l.colour, description: l.description })))
1274 +
}
1275 +
1276 +
/// `DELETE /repos/{o}/{n}/labels/{label}` (triage).
1277 +
async fn delete_label(
1278 +
    State(app): State<Shared>,
1279 +
    headers: HeaderMap,
1280 +
    Path((owner, name, label)): Path<(String, String, String)>,
1281 +
) -> Result<StatusCode, ApiError> {
1282 +
    let c = caller(&app, &headers).await?;
1283 +
    c.require("issues:write").or_else(|_| c.require("repo:write"))?;
1284 +
    let (_, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?;
1285 +
    if role < Role::Triage {
1286 +
        return Err(forbidden("labels need triage access"));
1287 +
    }
1288 +
    let l = db::issues::label_by_name(&app.db, row.id, &label).await?.ok_or_else(|| not_found("no such label"))?;
1289 +
    db::issues::delete_label(&app.db, l.id).await?;
1290 +
    Ok(StatusCode::NO_CONTENT)
1100 1291
}
1101 1292
1102 1293
async fn labels(
…
136 unchanged lines
modified crates/hub/tests/api.rs +108 −0
…
349 unchanged lines
350 350
    // Another account's notification is not the caller's to clear, and a token without user:read sees nothing.
351 351
    assert_eq!(api(app, "GET", "/api/v1/notifications", Some(&bob_rw), None).await.0, StatusCode::FORBIDDEN);
352 352
}
353 +
354 +
#[tokio::test(flavor = "multi_thread")]
355 +
async fn api_issue_triage_and_labels() {
356 +
    let h = hub().await;
357 +
    let app = &h.app;
358 +
    let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery"))
359 +
        .await
360 +
        .cookie
361 +
        .unwrap();
362 +
    let bob = call(app, "POST", "/register", None, None, None, Some("username=bob&email=b%40example.com&password=correct-horse-battery"))
363 +
        .await
364 +
        .cookie
365 +
        .unwrap();
366 +
    let mint = |cookie: String, scopes: &'static str| {
367 +
        let app = app.clone();
368 +
        async move {
369 +
            let page = call(&app, "GET", "/settings/tokens", None, Some(&cookie), None, None).await;
370 +
            let csrf = csrf_of(&page.body);
371 +
            let r = call(&app, "POST", "/settings/tokens", None, Some(&cookie), None, Some(&format!("_csrf={csrf}&name=t&{scopes}"))).await;
372 +
            r.body.split("oxyhub_").nth(1).map(|s| format!("oxyhub_{}", s.split('<').next().unwrap().trim())).unwrap()
373 +
        }
374 +
    };
375 +
    let andy_t = mint(andy, "scopes=repo%3Aadmin").await;
376 +
    let bob_t = mint(bob, "scopes=repo%3Aread&scopes=repo%3Awrite").await;
377 +
    let (st, _) = api(app, "POST", "/api/v1/repos", Some(&andy_t), Some(r#"{"owner":"andy","name":"demo","visibility":"public"}"#)).await;
378 +
    assert_eq!(st, StatusCode::CREATED);
379 +
380 +
    // Labels through the API; bad colours and non-triage callers are refused.
381 +
    let (st, v) = api(
382 +
        app,
383 +
        "POST",
384 +
        "/api/v1/repos/andy/demo/labels",
385 +
        Some(&andy_t),
386 +
        Some(r##"{"name":"bug","colour":"#ff0000","description":"Broken"}"##),
387 +
    )
388 +
    .await;
389 +
    assert_eq!((st, v["name"].as_str()), (StatusCode::CREATED, Some("bug")));
390 +
    assert_eq!(api(app, "POST", "/api/v1/repos/andy/demo/labels", Some(&andy_t), Some(r#"{"name":"flow"}"#)).await.0, StatusCode::CREATED);
391 +
    assert_eq!(
392 +
        api(app, "POST", "/api/v1/repos/andy/demo/labels", Some(&andy_t), Some(r##"{"name":"x","colour":"red"}"##)).await.0,
393 +
        StatusCode::BAD_REQUEST
394 +
    );
395 +
    assert_eq!(api(app, "POST", "/api/v1/repos/andy/demo/labels", Some(&bob_t), Some(r#"{"name":"mine"}"#)).await.0, StatusCode::FORBIDDEN);
396 +
    let (_, labels) = api(app, "GET", "/api/v1/repos/andy/demo/labels", None, None).await;
397 +
    assert_eq!(labels.as_array().unwrap().len(), 2);
398 +
399 +
    // An issue created with its labels and assignee in one call.
400 +
    let (st, v) = api(
401 +
        app,
402 +
        "POST",
403 +
        "/api/v1/repos/andy/demo/issues",
404 +
        Some(&andy_t),
405 +
        Some(r#"{"title":"Crash","body":"b","labels":["bug"],"assignees":["bob"]}"#),
406 +
    )
407 +
    .await;
408 +
    assert_eq!(st, StatusCode::CREATED, "{v}");
409 +
    assert_eq!(v["labels"][0]["name"], "bug");
410 +
    assert_eq!(v["assignees"], serde_json::json!(["bob"]));
411 +
    let n = v["number"].as_i64().unwrap();
412 +
    // Unknown names are refused before anything is created.
413 +
    let (st, v) = api(app, "POST", "/api/v1/repos/andy/demo/issues", Some(&andy_t), Some(r#"{"title":"X","labels":["nope"]}"#)).await;
414 +
    assert_eq!(st, StatusCode::BAD_REQUEST);
415 +
    assert!(v["detail"].as_str().unwrap().contains("no label"));
416 +
    let (_, list) = api(app, "GET", "/api/v1/repos/andy/demo/issues?state=all", None, None).await;
417 +
    assert_eq!(list.as_array().unwrap().len(), 1, "nothing half-made");
418 +
    // Without triage, labels cannot be set, but a plain issue can be opened.
419 +
    assert_eq!(
420 +
        api(app, "POST", "/api/v1/repos/andy/demo/issues", Some(&bob_t), Some(r#"{"title":"Y","labels":["bug"]}"#)).await.0,
421 +
        StatusCode::FORBIDDEN
422 +
    );
423 +
    let (st, bobs) = api(app, "POST", "/api/v1/repos/andy/demo/issues", Some(&bob_t), Some(r#"{"title":"From Bob"}"#)).await;
424 +
    assert_eq!(st, StatusCode::CREATED);
425 +
    let bob_n = bobs["number"].as_i64().unwrap();
426 +
427 +
    // PATCH: the whole label set, assignees and milestone, with events.
428 +
    let (st, v) =
429 +
        api(app, "PATCH", &format!("/api/v1/repos/andy/demo/issues/{n}"), Some(&andy_t), Some(r#"{"labels":["flow"],"assignees":[]}"#))
430 +
            .await;
431 +
    assert_eq!(st, StatusCode::OK, "{v}");
432 +
    assert_eq!(v["labels"].as_array().unwrap().iter().map(|l| l["name"].as_str().unwrap()).collect::<Vec<_>>(), ["flow"]);
433 +
    assert_eq!(v["assignees"], serde_json::json!([]));
434 +
    let (st, _) = api(app, "PATCH", &format!("/api/v1/repos/andy/demo/issues/{n}"), Some(&andy_t), Some(r#"{"milestone":"v9"}"#)).await;
435 +
    assert_eq!(st, StatusCode::BAD_REQUEST, "no such milestone");
436 +
    let issue_page = call(app, "GET", &format!("/andy/demo/issue/{n}"), None, None, None, None).await.body;
437 +
    assert!(issue_page.contains("set labels") && issue_page.contains("unassigned"), "timeline events");
438 +
    // The author edits their own title; others cannot; nobody but triage sets labels.
439 +
    let (st, v) =
440 +
        api(app, "PATCH", &format!("/api/v1/repos/andy/demo/issues/{bob_n}"), Some(&bob_t), Some(r#"{"title":"From Bob, clearer"}"#)).await;
441 +
    assert_eq!((st, v["title"].as_str()), (StatusCode::OK, Some("From Bob, clearer")));
442 +
    assert_eq!(
443 +
        api(app, "PATCH", &format!("/api/v1/repos/andy/demo/issues/{n}"), Some(&bob_t), Some(r#"{"title":"mine now"}"#)).await.0,
444 +
        StatusCode::FORBIDDEN
445 +
    );
446 +
    assert_eq!(
447 +
        api(app, "PATCH", &format!("/api/v1/repos/andy/demo/issues/{bob_n}"), Some(&bob_t), Some(r#"{"labels":["bug"]}"#)).await.0,
448 +
        StatusCode::FORBIDDEN
449 +
    );
450 +
    assert_eq!(
451 +
        api(app, "PATCH", &format!("/api/v1/repos/andy/demo/issues/{n}"), Some(&andy_t), Some(r#"{"title":"  "}"#)).await.0,
452 +
        StatusCode::BAD_REQUEST
453 +
    );
454 +
455 +
    // Deleting a label takes it off the issues.
456 +
    assert_eq!(api(app, "DELETE", "/api/v1/repos/andy/demo/labels/flow", Some(&andy_t), None).await.0, StatusCode::NO_CONTENT);
457 +
    let (_, v) = api(app, "GET", &format!("/api/v1/repos/andy/demo/issues/{n}"), None, None).await;
458 +
    assert_eq!(v["labels"], serde_json::json!([]));
459 +
    assert_eq!(api(app, "DELETE", "/api/v1/repos/andy/demo/labels/flow", Some(&andy_t), None).await.0, StatusCode::NOT_FOUND);
460 +
}
modified docs/spec.md +1 −1
…
365 unchanged lines
366 366
367 367
### 12.1 API
368 368
369 -
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses, 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`, `/proposals/{n}/comments|reviews|checks|files|land`, `/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`, `/labels`, `/milestones`, `/releases`, `/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}`, `/search`. 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.
369 +
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses, 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`, `/proposals/{n}/comments|reviews|checks|files|land`, `/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`, `/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}`, `/search`. 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.
370 370
371 371
### 12.2 Webhooks
372 372
…
172 unchanged lines