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 |