Merge pull request #352 from flagon-io/347-close-any-commit
Closing a work item with a commit accepts any commit on the default branch from someone with Write, and a pull request's merge commit that names it
14 files+268−590/14 viewed
| 13 | 13 | const INSTRUCTIONS: &str = "g1t is where your code, its history and the work around it live: repositories, work items, pull requests, workflows, deployments and packages. You bring your own agent; these tools are how it reads and changes what is on g1t, as the person or token it acts for. Repositories are named \"owner/name\" and belong to a workspace. A work item is any tracked work (a feature, a bug, a chore) of a workspace, linked to the repositories it concerns: where it was filed (origin), where the work is meant to land (targets) and where it touched. A workspace numbers its work items and new pull requests in one sequence; pull requests live in their repository. | |
| 14 | 14 | Tools are resources, each with an `action`: search, repository, work_item, pull_request, session, workflow, observability, package, secret, webhook, access, team, workspace, ai, billing, security, notifications, account, artifact. The `action` field lists each action and the fields it needs. You see only what your token's scopes allow; a refusal names the scope it needs. | |
| 15 | 15 | Find a repository: account whoami lists your workspaces; repository list or search finds one. Find work: work_item next gives the work item to take next in a workspace (the first in the plan's Now nobody is on, else the top of Next), and work_item list the rest (with repo, the work items linked to it; with horizon, one horizon of the plan). | |
| 16 | − | Work on a work item: work_item work (everything about it: what done means, each repository with its pull requests and whether the work landed there, the targets still missing and suggested ones, who is on it, what it waits on), work_item claim (advisory: it tells you who else is on it), then pull_request create in a target repository with the work item's number as `item`: you get a draft with its own fork to clone and push to. Push your commits, then pull_request ready with a summary. Watch `overlaps` and `behind` on pull_request get, and its checks there: `statuses` from the repository's workflows and `required_checks`, which must pass before it merges. If one fails, read why with workflow get_run and job_logs, push a fix, and the checks run again. Merging lands the work item in that repository; it closes once it has landed in every target (or, with none, once anything that closes it lands). A commit pushed straight to a default branch lands it only when its message says `Closes #N` (or Fixes, Resolves), never with a bare `(#N)`; `Part of #N` ties work to it without closing it; and work_item close with commit (and repo) lands one after the fact. When the work must land in another repository too, work_item link it as a target. | |
| 16 | + | Work on a work item: work_item work (everything about it: what done means, each repository with its pull requests and whether the work landed there, the targets still missing and suggested ones, who is on it, what it waits on), work_item claim (advisory: it tells you who else is on it), then pull_request create in a target repository with the work item's number as `item`: you get a draft with its own fork to clone and push to. Push your commits, then pull_request ready with a summary. Watch `overlaps` and `behind` on pull_request get, and its checks there: `statuses` from the repository's workflows and `required_checks`, which must pass before it merges. If one fails, read why with workflow get_run and job_logs, push a fix, and the checks run again. Merging lands the work item in that repository; it closes once it has landed in every target (or, with none, once anything that closes it lands). A commit pushed straight to a default branch lands it only when its message says `Closes #N` (or Fixes, Resolves), never with a bare `(#N)`; `Part of #N` ties work to it without closing it; and work_item close with commit (and repo) lands one after the fact: with the Write role, any commit on the default branch, even one that does not name it. When the work must land in another repository too, work_item link it as a target. | |
| 17 | 17 | Coordinate: session start once per run of your harness (harness_session_id makes it idempotent), session update as a heartbeat, session get between work steps to read pending updates. Reading does not acknowledge: consider relevant changes, record a public decision summary, then session record with acknowledge containing only the exact update IDs considered. Keep your own session ID explicit, especially when sharing a checkout. Separate scopes may proceed concurrently; agree ownership before overlapping edits. session end when done; work_item release or handoff (with a note) when you stop. | |
| 18 | 18 | Record public summaries only, never private chain-of-thought, credentials or secrets. As you work, pull_request record_session appends user-facing decisions, evidence and tool activity to the pull request's session, so reviewers see why the change is what it is; pull_request read_session reads it back. pull_request record_decision keeps a decision (why the change is made this way, or what fixed a failure) and list_decisions reads them; repository why says why a line of code is the way it is. | |
| 19 | 19 | Merge queue: when your pull request fails combined with the ones ahead of it, g1t says which of them it breaks with and the check that failed (on the pull request, its work item and its session, and as integration.failed). Fix it and push: once its checks pass, it rejoins the queue by itself. |
| 1861 | 1861 | "Change a work item's title, body, labels, milestone, project, the people it is assigned to, what it is blocked by, or its parent. Only the fields given are changed; labels, assignees and blocked_by each replace the whole set, milestone null or 0 takes it out of its milestone, project (a slug) null or an empty string out of its project, and parent (as set_work_item_parent) null or 0 out of its parent. blocked_by lists the numbers of the work items of the workspace that have to land first: one that is not a work item, the work item itself, or a set that would make a cycle is refused with `422`, and a change is a dependency.changed event. Its author may change their own work item, as may the person g1t filed one for; anyone else needs the Triage role, and so do the milestone and the project. Each label added or removed is a work_item.labeled or work_item.unlabeled event. The workspace is `workspace`, its slug; with only `repo`, the repository's workspace." | |
| 1862 | 1862 | } | |
| 1863 | 1863 | Op::CloseWorkItem => { | |
| 1864 | − | "Close a work item without a pull request. A work item closes by itself once what closes it has landed in every target: a pull request whose title or description names it after a closing keyword (Fixes #12) merging into a repository's default branch, or such a commit pushed there, marks that repository landed. When a commit already on a default branch did the work, give it as commit, with repo the repository it is in: it lands there as its push would have, and the work item closes if nothing else is left to land. A commit that is not on the default branch, or whose message does not name the work item after a closing keyword, is refused with `422`; a work item already closed, with `409`. A work item with open sub-items is never closed with them silently: without sub_items it is refused with `409`, naming them; sub_items leave leaves them open, close closes them too (with the same reason, and their own open sub-items), and move makes them sub-items of move_to. A commit closing it leaves them. Closing the last open sub-item leaves its parent open, and says so on the parent. Its author may close their own work item, as may the person g1t filed one for; anyone else needs the Triage role. The workspace is `workspace`, its slug; with only `repo`, the repository's workspace." | |
| 1864 | + | "Close a work item without a pull request. A work item closes by itself once what closes it has landed in every target: a pull request whose title or description names it after a closing keyword (Fixes #12) merging into a repository's default branch, or such a commit pushed there, marks that repository landed. When a commit already on a default branch did the work, give it as commit, with repo the repository it is in: it lands there as its push would have, and the work item closes if nothing else is left to land. With the Write role on repo, any commit on its default branch will do, and the timeline says it was tied by hand; without it, the commit's message must name the work item after a closing keyword, or it must be the commit a pull request merged as whose own work item, closes or parts names it, else it is refused with `403`. A commit that is not on the default branch is refused with `422`; a work item already closed, with `409`. A work item with open sub-items is never closed with them silently: without sub_items it is refused with `409`, naming them; sub_items leave leaves them open, close closes them too (with the same reason, and their own open sub-items), and move makes them sub-items of move_to. A commit closing it leaves them. Closing the last open sub-item leaves its parent open, and says so on the parent. Its author may close their own work item, as may the person g1t filed one for; anyone else needs the Triage role. The workspace is `workspace`, its slug; with only `repo`, the repository's workspace." | |
| 1865 | 1865 | } | |
| 1866 | 1866 | Op::ReopenWorkItem => { | |
| 1867 | 1867 | "Reopen a closed work item. Its author may reopen their own work item, as may the person g1t filed one for; anyone else needs the Triage role. The workspace is `workspace`, its slug; with only `repo`, the repository's workspace." | |
| ⋯ | |||
| 3209 | 3209 | }, | |
| 3210 | 3210 | "commit": { | |
| 3211 | 3211 | "type": "string", | |
| 3212 | − | "description": "The commit that did it: one on repo's default branch whose message names this work item after a closing keyword (Fixes #12). It lands there as pushing it would have.", | |
| 3212 | + | "description": "The commit that did it, on repo's default branch: any commit there with the Write role, else one whose message names this work item after a closing keyword (Fixes #12), or a pull request's merge commit that names it. It lands there as pushing it would have.", | |
| 3213 | 3213 | }, | |
| 3214 | 3214 | "sub_items": { | |
| 3215 | 3215 | "type": "string", | |
| 1487 | 1487 | } | |
| 1488 | 1488 | ] | |
| 1489 | 1489 | }, | |
| 1490 | − | "notes": "With `commit` and `repo`, a commit already on that repository's default branch whose message names the work item after a closing keyword (`Fixes #12`), it lands there as pushing it would have: a resolution is recorded, the event is `work_item.landed` (and `work_item.closed` with `resolutions` once nothing else is left to land), and the timeline says \"landed this in\" the commit, linked. Give `commit` as its full hash. A commit not on the default branch, or one whose message does not name the work item, is refused (`422`); a work item already closed is `409`, saying what closed it. See [Closing work items](/guides/pull-requests/#closing-work-items)." | |
| 1490 | + | "notes": "With `commit` and `repo`, a commit already on that repository's default branch (any commit there with the Write role; without it, one whose message names the work item after a closing keyword, `Fixes #12`, or the commit a pull request naming it merged as), it lands there as pushing it would have: a resolution is recorded, the event is `work_item.landed` (and `work_item.closed` with `resolutions` once nothing else is left to land), and the timeline says \"landed this in\" the commit, linked. Give `commit` as its full hash. A commit not on the default branch is refused (`422`), and one nothing ties to the work item, from someone without Write, `403`; a work item already closed is `409`, saying what closed it. See [Closing work items](/guides/pull-requests/#closing-work-items)." | |
| 1491 | 1491 | }, | |
| 1492 | 1492 | "reopen_work_item": { | |
| 1493 | 1493 | "response": { |
| 1323 | 1323 | | `<NUMBER>` | | | |
| 1324 | 1324 | | `-r, --reason <REASON>` | One of `completed`, `not_planned`. | | |
| 1325 | 1325 | | `-c, --comment <COMMENT>` | Leave a comment as it closes. | | |
| 1326 | − | | `--commit <COMMIT>` | The commit on the repository's default branch that did it: its message names the work item after a closing keyword (Fixes #12). Its full hash. It lands there; the work item closes when nothing else is left to land. | | |
| 1326 | + | | `--commit <COMMIT>` | The commit on the repository's default branch that did it, by its full hash: any commit there with the Write role, else one whose message names the work item (Fixes #12) or a pull request's merge commit that names it. It lands there; the work item closes when nothing else is left to land. | | |
| 1327 | 1327 | | `--sub-items <SUB_ITEMS>` | What to do with its sub-items that are still open. One of `leave`, `close`, `move`. | | |
| 1328 | 1328 | | `--move-to <NUMBER>` | With --sub-items move: the number of the work item they become sub-items of. | | |
| 1329 | 1329 |
| 136 | 136 | | `g1t work comment N` | Comments on it. `--attach FILE` uploads an image to the work item and adds it to the comment. | | |
| 137 | 137 | | `g1t work check N BOX` | Ticks a box of its description's [task list](/work/task-lists/), by its number or words of its text; `--uncheck` unticks it. Only that box changes. | | |
| 138 | 138 | | `g1t work edit N` | Changes its title, description, labels, assignees or milestone. | | |
| 139 | − | | `g1t work close N` | Closes it, as completed, or `--reason not_planned`; `--comment` says why. `--commit SHA` names a commit on the default branch that did it, which lands it there. See [closing work items](/work/closing/). | | |
| 139 | + | | `g1t work close N` | Closes it, as completed, or `--reason not_planned`; `--comment` says why. `--commit SHA` names a commit on the default branch that did it, which lands it there: any commit with the Write role, even one that does not name it. See [closing work items](/work/closing/). | | |
| 140 | 140 | | `g1t work reopen N` | Opens it again. | | |
| 141 | 141 | | `g1t work link N` | Links a repository in a role: `--role target` (the default) or `touched`, the current repository or `-R owner/name`. | | |
| 142 | 142 | | `g1t work unlink N` | Unlinks a repository, never its origin. | |
| 127 | 127 | ||
| 128 | 128 | ## Close one with a commit | |
| 129 | 129 | ||
| 130 | − | A commit already on the default branch can land a work item later: one | |
| 131 | − | pushed before the work item was filed, or while `auto_close_items` was | |
| 132 | − | off. | |
| 130 | + | A commit already on the default branch can land a work item after the | |
| 131 | + | fact: one pushed before the work item was filed, one whose message never | |
| 132 | + | named it, or one pushed while `auto_close_items` was off. | |
| 133 | 133 | ||
| 134 | 134 | ```sh | |
| 135 | 135 | g1t work close 12 --commit 3f9a2c1e… | |
| 136 | 136 | ``` | |
| 137 | 137 | ||
| 138 | 138 | In the repository, give the commit's full hash. Over MCP and the API, | |
| 139 | − | `close` takes `repo` and `commit`. The commit's message must name the work | |
| 140 | − | item after a closing keyword. Its repository then lands in that commit, | |
| 141 | − | the way a push of it would have, by whoever did it, and the work item | |
| 142 | − | closes if no target is left. A commit that is not on the default branch, | |
| 143 | − | or whose message does not name the work item, is refused, and so is a work | |
| 144 | − | item that is already closed. | |
| 139 | + | `close` takes `repo` and `commit`. Which commits it accepts: | |
| 140 | + | ||
| 141 | + | - **With the Write role** on the repository, any commit on its default | |
| 142 | + | branch. Asking for it is what ties the commit to the work item, so its | |
| 143 | + | message need not name it. | |
| 144 | + | - **Without it** (the work item's owner, or the Triage role), a commit | |
| 145 | + | whose message names the work item after a closing keyword | |
| 146 | + | (`Fixes #12`), or the commit a pull request merged as, when that pull | |
| 147 | + | request was made for the work item, or its title or description names | |
| 148 | + | it after `Closes` or `Part of`. | |
| 149 | + | ||
| 150 | + | Its repository then lands in that commit, the way a push of it would have, | |
| 151 | + | by whoever did it, and the work item closes if no target is left. The | |
| 152 | + | timeline says who tied it and how: **closed this in acme/web@3f9a2c1, | |
| 153 | + | which names it**, **…, which merged acme/web#14**, or **…, tied by hand: | |
| 154 | + | the commit does not name it**. A commit that is not on the default branch | |
| 155 | + | is refused, and so is a work item that is already closed. | |
| 156 | + | ||
| 157 | + | Closing from pushes and merges is unchanged: a commit or pull request | |
| 158 | + | closes a work item by itself only when it names it after a closing | |
| 159 | + | keyword. | |
| 145 | 160 | ||
| 146 | 161 | ## Reopen one | |
| 147 | 162 |
| 62292 | 62292 | "Work items" | |
| 62293 | 62293 | ], | |
| 62294 | 62294 | "summary": "Close a work item", | |
| 62295 | − | "description": "Close a work item without a pull request. A work item closes by itself once what closes it has landed in every target: a pull request whose title or description names it after a closing keyword (Fixes #12) merging into a repository's default branch, or such a commit pushed there, marks that repository landed. When a commit already on a default branch did the work, give it as commit, with repo the repository it is in: it lands there as its push would have, and the work item closes if nothing else is left to land. A commit that is not on the default branch, or whose message does not name the work item after a closing keyword, is refused with `422`; a work item already closed, with `409`. A work item with open sub-items is never closed with them silently: without sub_items it is refused with `409`, naming them; sub_items leave leaves them open, close closes them too (with the same reason, and their own open sub-items), and move makes them sub-items of move_to. A commit closing it leaves them. Closing the last open sub-item leaves its parent open, and says so on the parent. Its author may close their own work item, as may the person g1t filed one for; anyone else needs the Triage role. The workspace is `workspace`, its slug; with only `repo`, the repository's workspace.\n\nWith `commit` and `repo`, a commit already on that repository's default branch whose message names the work item after a closing keyword (`Fixes #12`), it lands there as pushing it would have: a resolution is recorded, the event is `work_item.landed` (and `work_item.closed` with `resolutions` once nothing else is left to land), and the timeline says \"landed this in\" the commit, linked. Give `commit` as its full hash. A commit not on the default branch, or one whose message does not name the work item, is refused (`422`); a work item already closed is `409`, saying what closed it. See [Closing work items](/guides/pull-requests/#closing-work-items).", | |
| 62295 | + | "description": "Close a work item without a pull request. A work item closes by itself once what closes it has landed in every target: a pull request whose title or description names it after a closing keyword (Fixes #12) merging into a repository's default branch, or such a commit pushed there, marks that repository landed. When a commit already on a default branch did the work, give it as commit, with repo the repository it is in: it lands there as its push would have, and the work item closes if nothing else is left to land. With the Write role on repo, any commit on its default branch will do, and the timeline says it was tied by hand; without it, the commit's message must name the work item after a closing keyword, or it must be the commit a pull request merged as whose own work item, closes or parts names it, else it is refused with `403`. A commit that is not on the default branch is refused with `422`; a work item already closed, with `409`. A work item with open sub-items is never closed with them silently: without sub_items it is refused with `409`, naming them; sub_items leave leaves them open, close closes them too (with the same reason, and their own open sub-items), and move makes them sub-items of move_to. A commit closing it leaves them. Closing the last open sub-item leaves its parent open, and says so on the parent. Its author may close their own work item, as may the person g1t filed one for; anyone else needs the Triage role. The workspace is `workspace`, its slug; with only `repo`, the repository's workspace.\n\nWith `commit` and `repo`, a commit already on that repository's default branch (any commit there with the Write role; without it, one whose message names the work item after a closing keyword, `Fixes #12`, or the commit a pull request naming it merged as), it lands there as pushing it would have: a resolution is recorded, the event is `work_item.landed` (and `work_item.closed` with `resolutions` once nothing else is left to land), and the timeline says \"landed this in\" the commit, linked. Give `commit` as its full hash. A commit not on the default branch is refused (`422`), and one nothing ties to the work item, from someone without Write, `403`; a work item already closed is `409`, saying what closed it. See [Closing work items](/guides/pull-requests/#closing-work-items).", | |
| 62296 | 62296 | "x-operation": "close_work_item", | |
| 62297 | 62297 | "x-mcp-tool": "work_item", | |
| 62298 | 62298 | "x-mcp-action": "close", | |
| ⋯ | |||
| 62502 | 62502 | }, | |
| 62503 | 62503 | "commit": { | |
| 62504 | 62504 | "type": "string", | |
| 62505 | − | "description": "The commit that did it: one on repo's default branch whose message names this work item after a closing keyword (Fixes #12). It lands there as pushing it would have." | |
| 62505 | + | "description": "The commit that did it, on repo's default branch: any commit there with the Write role, else one whose message names this work item after a closing keyword (Fixes #12), or a pull request's merge commit that names it. It lands there as pushing it would have." | |
| 62506 | 62506 | }, | |
| 62507 | 62507 | "sub_items": { | |
| 62508 | 62508 | "type": "string", | |
| 405 | 405 | ||
| 406 | 406 | /// `close_work_item`: a [`WorkItemActionArgs`], and the commit that did | |
| 407 | 407 | /// it, if one did. With `commit` (and `repo`, the repository it is in), a | |
| 408 | − | /// commit on that repository's default branch whose message names the | |
| 409 | − | /// work item after a closing keyword (`Fixes #12`): it lands there as the | |
| 410 | − | /// push of that commit would have, and closes when nothing else is left to | |
| 411 | − | /// land; a commit not on the default branch, or one that does not name it, | |
| 412 | − | /// is refused. Returns `Outcome<WorkItem>`. | |
| 408 | + | /// commit on that repository's default branch: any commit there for | |
| 409 | + | /// someone with the Write role, else one whose message names the work item | |
| 410 | + | /// after a closing keyword (`Fixes #12`) or the commit a pull request | |
| 411 | + | /// naming it merged as. It lands there as the push of that commit would | |
| 412 | + | /// have, and closes when nothing else is left to land; a commit not on the | |
| 413 | + | /// default branch is refused. Returns `Outcome<WorkItem>`. | |
| 413 | 414 | #[derive(Debug, Serialize, Deserialize)] | |
| 414 | 415 | pub struct CloseWorkItemArgs { | |
| 415 | 416 | #[serde(flatten)] |
| 1596 | 1596 | /// Leave a comment as it closes. | |
| 1597 | 1597 | #[arg(short, long)] | |
| 1598 | 1598 | pub comment: Option<String>, | |
| 1599 | − | /// The commit on the repository's default branch that did it: its message names the work item | |
| 1600 | − | /// after a closing keyword (Fixes #12). Its full hash. It lands there; the work item closes when | |
| 1601 | − | /// nothing else is left to land. | |
| 1599 | + | /// The commit on the repository's default branch that did it, by its full hash: any commit | |
| 1600 | + | /// there with the Write role, else one whose message names the work item (Fixes #12) or a pull | |
| 1601 | + | /// request's merge commit that names it. It lands there; the work item closes when nothing else | |
| 1602 | + | /// is left to land. | |
| 1602 | 1603 | #[arg(long)] | |
| 1603 | 1604 | pub commit: Option<String>, | |
| 1604 | 1605 | /// What to do with its sub-items that are still open. |
| 172 | 172 | ||
| 173 | 173 | #[test] | |
| 174 | 174 | fn a_landing_that_leaves_a_blocker_open_never_stands_in_for_its_close() { | |
| 175 | − | let commit = items::Landing { pull: None, commit: Some("abc".into()), pushed_by: None }; | |
| 176 | − | let merged = items::Landing { pull: Some(5), commit: None, pushed_by: None }; | |
| 175 | + | let commit = items::Landing { pull: None, commit: Some("abc".into()), pushed_by: None, tie: None }; | |
| 176 | + | let merged = items::Landing { pull: Some(5), commit: None, pushed_by: None, tie: None }; | |
| 177 | 177 | let landed = resolution_key("wki_2", "wki_1", false, Some(&commit)); | |
| 178 | 178 | let closed = resolution_key("wki_2", "wki_1", true, None); | |
| 179 | 179 | assert_ne!(landed, closed); |
| 359 | 359 | return Ok(Outcome::fail(FailureCode::Invalid, "Say which repository the commit is in (repo).")); | |
| 360 | 360 | }; | |
| 361 | 361 | let found = check!(self.repo(path, &Some(a.actor.clone())).await?); | |
| 362 | − | let (commit, named) = check!(self.closing_commit(&found, &held, commit, &a.actor).await?); | |
| 362 | + | let (commit, named, tie) = check!(self.closing_commit(&found, &held, commit, &a.actor).await?); | |
| 363 | 363 | let session = match named { | |
| 364 | 364 | Some(session) => Some(session), | |
| 365 | 365 | None => self.caller_session(&held, &a.actor).await?, | |
| 366 | 366 | }; | |
| 367 | − | let landing = Landing { pull: None, commit: Some(commit), pushed_by: Some(a.actor.username.clone()) }; | |
| 367 | + | let landing = Landing { pull: None, commit: Some(commit), pushed_by: Some(a.actor.username.clone()), tie: Some(tie) }; | |
| 368 | 368 | self.land(&found, &landing, &a.actor, std::slice::from_ref(&held.item.id), &[], session.as_deref(), false).await?; | |
| 369 | 369 | return Ok(match self.item_by_id(&held.item.id).await? { | |
| 370 | 370 | Some(after) if after.item.state == State::Open && !after.item.missing().is_empty() => { |
| 7 | 7 | //! ([`LAND_SQL`]); the work item closes once it has landed in every target | |
| 8 | 8 | //! it has, or at once when it has none. What it is part of is linked | |
| 9 | 9 | //! (`touched`), and closes nothing. | |
| 10 | + | //! | |
| 11 | + | //! Closing a work item by hand with a commit (`close_work_item` with | |
| 12 | + | //! `commit`) lands it too, for a commit on the default branch that is tied | |
| 13 | + | //! to it ([`tie`]): one whose message names it after a closing keyword, the | |
| 14 | + | //! commit a pull request merged as whose own work item, `closes` or | |
| 15 | + | //! `parts` names it, or, for someone with the Write role, any commit, the | |
| 16 | + | //! explicit request standing for its message. Automatic closing, from | |
| 17 | + | //! pushes and merges, still needs the closing keyword. | |
| 10 | 18 | ||
| 11 | 19 | use g1t_contracts::events::WorkItemEvent; | |
| 12 | 20 | use g1t_contracts::repos::{BranchDriftArgs, BranchDrifts, Commit, LogArgs, Repo, RepoPath}; | |
| ⋯ | |||
| 29 | 37 | pub pull: Option<u32>, | |
| 30 | 38 | pub commit: Option<String>, | |
| 31 | 39 | pub pushed_by: Option<String>, | |
| 40 | + | /// For a commit tied to a work item by hand: how it was. | |
| 41 | + | pub tie: Option<Tie>, | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /// How a commit closing a work item by hand (`close_work_item` with | |
| 45 | + | /// `commit`) is tied to it. | |
| 46 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 47 | + | pub(crate) enum Tie { | |
| 48 | + | /// Its message names the work item after a closing keyword. | |
| 49 | + | Named, | |
| 50 | + | /// A pull request (this number) merged as it, and names the work item: | |
| 51 | + | /// as its own, in its `closes` or `parts`, or in its title or | |
| 52 | + | /// description. | |
| 53 | + | Merged(u32), | |
| 54 | + | /// Nothing names the work item: someone with the Write role tied it. | |
| 55 | + | ByHand, | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// How a commit is tied to a work item for an explicit close: whether its | |
| 59 | + | /// message `names` it, the pull request it is the merge of that names it | |
| 60 | + | /// (`merged`), and whether the actor `may_push` (has the Write role). None | |
| 61 | + | /// when it may not be: nothing names it and the actor lacks Write. | |
| 62 | + | pub(crate) fn tie(names: bool, merged: Option<u32>, may_push: bool) -> Option<Tie> { | |
| 63 | + | match (names, merged) { | |
| 64 | + | (true, _) => Some(Tie::Named), | |
| 65 | + | (false, Some(pull)) => Some(Tie::Merged(pull)), | |
| 66 | + | (false, None) => may_push.then_some(Tie::ByHand), | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | /// Where a commit stands against the default branch. | |
| 71 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 72 | + | pub(crate) enum OnBranch { | |
| 73 | + | On, | |
| 74 | + | Off, | |
| 75 | + | /// Too far back to tell. | |
| 76 | + | Unknown, | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | /// Where a commit stands against the default branch: on it when it is its | |
| 80 | + | /// head (`at_tip`), else by how far `ahead` of the head it is, when that | |
| 81 | + | /// could be told. Ahead of it by any commit, it is not on it. | |
| 82 | + | pub(crate) fn on_branch(at_tip: bool, ahead: Option<u32>) -> OnBranch { | |
| 83 | + | match (at_tip, ahead) { | |
| 84 | + | (true, _) => OnBranch::On, | |
| 85 | + | (false, Some(0)) => OnBranch::On, | |
| 86 | + | (false, Some(_)) => OnBranch::Off, | |
| 87 | + | (false, None) => OnBranch::Unknown, | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /// What a note of a landing by hand adds after `what` (landing_note): | |
| 92 | + | /// what tied the commit; the note's author is who did. | |
| 93 | + | pub(crate) fn tie_note(path: &str, tie: Option<Tie>) -> String { | |
| 94 | + | match tie { | |
| 95 | + | None => String::new(), | |
| 96 | + | Some(Tie::Named) => ", which names it".to_owned(), | |
| 97 | + | Some(Tie::Merged(pull)) => format!(", which merged {path}#{pull}"), | |
| 98 | + | Some(Tie::ByHand) => ", tied by hand: the commit does not name it".to_owned(), | |
| 99 | + | } | |
| 32 | 100 | } | |
| 33 | 101 | ||
| 102 | + | /// The pull requests of repository `?1` that merged as commit `?2`, and | |
| 103 | + | /// whether each names work item `?3` as its own or in what it closed or | |
| 104 | + | /// was part of when it merged. | |
| 105 | + | const MERGED_AS: &str = "SELECT number, title, body, | |
| 106 | + | (item_id IS ?3 OR EXISTS (SELECT 1 FROM json_each(pulls.closes_items) WHERE value = ?3) | |
| 107 | + | OR EXISTS (SELECT 1 FROM json_each(pulls.parts) WHERE value = ?3)) AS names | |
| 108 | + | FROM pulls WHERE repo_id = ?1 AND status = 'merged' AND merge_commit = ?2 | |
| 109 | + | ORDER BY number LIMIT 5"; | |
| 110 | + | ||
| 111 | + | #[derive(Deserialize)] | |
| 112 | + | struct MergedAs { | |
| 113 | + | number: u32, | |
| 114 | + | title: String, | |
| 115 | + | body: Option<String>, | |
| 116 | + | names: u8, | |
| 117 | + | } | |
| 118 | + | ||
| 34 | 119 | /// Landing a work item (`?1`) in repository `?2` at `?3`, by `?4` (an | |
| 35 | 120 | /// account id), resolved by `?5` (a stored resolution, JSON), whose pull | |
| 36 | 121 | /// request and commit are `?6` and `?7`, in one batch: linked (as touched, | |
| ⋯ | |||
| 139 | 224 | commit: landing.commit.clone(), | |
| 140 | 225 | pushed_by: landing.pushed_by.clone(), | |
| 141 | 226 | }; | |
| 142 | − | let what = landing_note(&path, landing.pull, landing.commit.as_deref()); | |
| 227 | + | let what = landing_note(&path, landing.pull, landing.commit.as_deref()) + &tie_note(&path, landing.tie); | |
| 143 | 228 | let mut closed = Vec::new(); | |
| 144 | 229 | for id in closes { | |
| 145 | 230 | let Some(before) = self.item_by_id(id).await? else { continue }; | |
| ⋯ | |||
| 235 | 320 | } | |
| 236 | 321 | ||
| 237 | 322 | /// `close_work_item` with a commit: the commit, on the default branch | |
| 238 | − | /// of `repo`, whose message names work item `held` after a closing | |
| 239 | − | /// keyword, with the session it names, if it names one of this | |
| 240 | − | /// repository's. Anything else is refused, saying why. | |
| 323 | + | /// of `repo`, tied to work item `held` (see [`tie`]), with the session | |
| 324 | + | /// it names, if it names one of this repository's, and how it is tied. | |
| 325 | + | /// Anything else is refused, saying why. | |
| 241 | 326 | pub(super) async fn closing_commit( | |
| 242 | 327 | &self, | |
| 243 | 328 | repo: &Repo, | |
| 244 | 329 | held: &Held, | |
| 245 | 330 | commit: &str, | |
| 246 | 331 | actor: &User, | |
| 247 | − | ) -> Result<Outcome<(String, Option<String>)>> { | |
| 332 | + | ) -> Result<Outcome<(String, Option<String>, Tie)>> { | |
| 248 | 333 | let number = held.item.number; | |
| 249 | 334 | let commit = commit.trim().to_lowercase(); | |
| 250 | 335 | if !(4..=64).contains(&commit.len()) || !commit.bytes().all(|b| b.is_ascii_hexdigit()) { | |
| ⋯ | |||
| 276 | 361 | break; | |
| 277 | 362 | } | |
| 278 | 363 | } | |
| 279 | − | if !names { | |
| 364 | + | let merged = if names { None } else { self.merged_naming(repo, held, &found.hash).await? }; | |
| 365 | + | let may_push = matches!(crate::allowed(Some(actor), repo, g1t_contracts::access::Capability::Push), Outcome::Ok(())); | |
| 366 | + | let Some(tie) = tie(names, merged, may_push) else { | |
| 280 | 367 | return Ok(Outcome::fail( | |
| 281 | − | FailureCode::Invalid, | |
| 282 | − | format!("Commit {short} does not close #{number}: its message names it after no closing keyword (Fixes #{number})."), | |
| 368 | + | FailureCode::Forbidden, | |
| 369 | + | format!( | |
| 370 | + | "Commit {short} does not name #{number} (Fixes #{number}), nor is it the merge of a pull request that does: \ | |
| 371 | + | tying it to #{number} by hand takes the Write role on {}/{}.", | |
| 372 | + | repo.namespace, repo.name | |
| 373 | + | ), | |
| 283 | 374 | )); | |
| 284 | − | } | |
| 375 | + | }; | |
| 285 | 376 | let head = match head? { | |
| 286 | 377 | Outcome::Ok(commits) => commits.into_iter().next(), | |
| 287 | 378 | Outcome::Fail(_) => None, | |
| ⋯ | |||
| 289 | 380 | let Some(head) = head else { | |
| 290 | 381 | return Ok(Outcome::fail(FailureCode::Invalid, format!("Commit {short} is not on {}.", repo.default_branch))); | |
| 291 | 382 | }; | |
| 292 | − | if head.hash != found.hash { | |
| 383 | + | let at_tip = head.hash == found.hash; | |
| 384 | + | let ahead = if at_tip { | |
| 385 | + | None | |
| 386 | + | } else { | |
| 293 | 387 | let drifts: Outcome<BranchDrifts> = g1t_kit::call( | |
| 294 | 388 | &self.repos, | |
| 295 | 389 | "branch_drift", | |
| 296 | 390 | &BranchDriftArgs { path: path.clone(), viewer: Some(actor.clone()), base: head.hash, heads: vec![found.hash.clone()] }, | |
| 297 | 391 | ) | |
| 298 | 392 | .await?; | |
| 299 | − | let drift = match drifts { | |
| 300 | − | Outcome::Ok(drifts) => drifts.branches.into_iter().next().and_then(|branch| branch.drift), | |
| 393 | + | match drifts { | |
| 394 | + | Outcome::Ok(drifts) => drifts.branches.into_iter().next().and_then(|branch| branch.drift).map(|drift| drift.ahead), | |
| 301 | 395 | Outcome::Fail(_) => None, | |
| 302 | − | }; | |
| 303 | − | match drift { | |
| 304 | − | Some(drift) if drift.ahead == 0 => {} | |
| 305 | − | Some(_) => { | |
| 306 | − | return Ok(Outcome::fail( | |
| 307 | − | FailureCode::Invalid, | |
| 308 | − | format!("Commit {short} is not on {}, the default branch: only a commit there closes a work item.", repo.default_branch), | |
| 309 | − | )); | |
| 310 | − | } | |
| 311 | − | None => { | |
| 312 | − | return Ok(Outcome::fail( | |
| 313 | − | FailureCode::Invalid, | |
| 314 | − | format!("Could not tell whether commit {short} is on {}: it is too far back.", repo.default_branch), | |
| 315 | − | )); | |
| 396 | + | } | |
| 397 | + | }; | |
| 398 | + | match on_branch(at_tip, ahead) { | |
| 399 | + | OnBranch::On => {} | |
| 400 | + | OnBranch::Off => { | |
| 401 | + | return Ok(Outcome::fail( | |
| 402 | + | FailureCode::Invalid, | |
| 403 | + | format!("Commit {short} is not on {}, the default branch: only a commit there closes a work item.", repo.default_branch), | |
| 404 | + | )); | |
| 405 | + | } | |
| 406 | + | OnBranch::Unknown => { | |
| 407 | + | return Ok(Outcome::fail( | |
| 408 | + | FailureCode::Invalid, | |
| 409 | + | format!("Could not tell whether commit {short} is on {}: it is too far back.", repo.default_branch), | |
| 410 | + | )); | |
| 411 | + | } | |
| 412 | + | } | |
| 413 | + | let session = self.trailer_session(&repo.id, &found.message).await?; | |
| 414 | + | Ok(Outcome::Ok((found.hash, session, tie))) | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | /// The pull request of `repo` that merged as commit `hash` and names | |
| 418 | + | /// work item `held`: as its own, in what it closed or was part of when | |
| 419 | + | /// it merged, or after `Closes` or `Part of` in its title or | |
| 420 | + | /// description as they read now. | |
| 421 | + | async fn merged_naming(&self, repo: &Repo, held: &Held, hash: &str) -> Result<Option<u32>> { | |
| 422 | + | let pulls = self | |
| 423 | + | .db | |
| 424 | + | .prepare(MERGED_AS) | |
| 425 | + | .bind(&[repo.id.as_str().into(), hash.into(), held.item.id.as_str().into()])? | |
| 426 | + | .all() | |
| 427 | + | .await? | |
| 428 | + | .results::<MergedAs>()?; | |
| 429 | + | for pull in pulls { | |
| 430 | + | if pull.names != 0 { | |
| 431 | + | return Ok(Some(pull.number)); | |
| 432 | + | } | |
| 433 | + | let named = refs::named(&format!("{}\n{}", pull.title, pull.body.as_deref().unwrap_or_default())); | |
| 434 | + | for reference in named.closes.iter().chain(&named.parts) { | |
| 435 | + | if self.ref_item(repo, reference).await?.as_deref() == Some(held.item.id.as_str()) { | |
| 436 | + | return Ok(Some(pull.number)); | |
| 316 | 437 | } | |
| 317 | 438 | } | |
| 318 | 439 | } | |
| 319 | − | let session = self.trailer_session(&repo.id, &found.message).await?; | |
| 320 | − | Ok(Outcome::Ok((found.hash, session))) | |
| 440 | + | Ok(None) | |
| 321 | 441 | } | |
| 322 | 442 | ||
| 323 | 443 | /// `closed_by_commit`: the work items a commit pushed to a repository | |
| ⋯ | |||
| 378 | 498 | } | |
| 379 | 499 | ||
| 380 | 500 | #[test] | |
| 501 | + | fn an_explicit_close_ties_any_commit_for_someone_with_write() { | |
| 502 | + | // A commit that names the work item, or the merge of a pull request | |
| 503 | + | // that does, needs nothing more. | |
| 504 | + | assert_eq!(tie(true, None, false), Some(Tie::Named)); | |
| 505 | + | assert_eq!(tie(true, Some(14), true), Some(Tie::Named)); | |
| 506 | + | assert_eq!(tie(false, Some(14), false), Some(Tie::Merged(14))); | |
| 507 | + | // One that does not is tied by hand, only with the Write role. | |
| 508 | + | assert_eq!(tie(false, None, true), Some(Tie::ByHand)); | |
| 509 | + | assert_eq!(tie(false, None, false), None); | |
| 510 | + | } | |
| 511 | + | ||
| 512 | + | #[test] | |
| 513 | + | fn only_a_commit_on_the_default_branch_closes_a_work_item() { | |
| 514 | + | assert_eq!(on_branch(true, None), OnBranch::On); | |
| 515 | + | assert_eq!(on_branch(false, Some(0)), OnBranch::On); | |
| 516 | + | assert_eq!(on_branch(false, Some(2)), OnBranch::Off); | |
| 517 | + | assert_eq!(on_branch(false, None), OnBranch::Unknown); | |
| 518 | + | } | |
| 519 | + | ||
| 520 | + | #[test] | |
| 521 | + | fn the_note_says_what_tied_the_commit() { | |
| 522 | + | let what = |tie| landing_note("acme/web", None, Some("abcdef1234")) + &tie_note("acme/web", tie); | |
| 523 | + | assert_eq!(what(None), "acme/web@abcdef1"); | |
| 524 | + | assert_eq!(what(Some(Tie::Named)), "acme/web@abcdef1, which names it"); | |
| 525 | + | assert_eq!(what(Some(Tie::Merged(14))), "acme/web@abcdef1, which merged acme/web#14"); | |
| 526 | + | assert_eq!(what(Some(Tie::ByHand)), "acme/web@abcdef1, tied by hand: the commit does not name it"); | |
| 527 | + | } | |
| 528 | + | ||
| 529 | + | #[test] | |
| 530 | + | fn a_merge_commit_names_what_its_pull_request_landed() { | |
| 531 | + | let db = crate::migrated::database(); | |
| 532 | + | db.execute_batch( | |
| 533 | + | "INSERT INTO work_items (id, workspace, number, title, body, state, resolutions, author_id, author_name, created_at, updated_at) | |
| 534 | + | VALUES ('wki_1', 'acme', 1, 'One', '', 'open', '[]', 'user', 'user', 'now', 'now'), | |
| 535 | + | ('wki_2', 'acme', 2, 'Two', '', 'open', '[]', 'user', 'user', 'now', 'now');", | |
| 536 | + | ) | |
| 537 | + | .unwrap(); | |
| 538 | + | let pull = |id: &str, repo: &str, number: u32, status: &str, merge: &str, item: Option<&str>, closes: &str, parts: &str| { | |
| 539 | + | db.execute( | |
| 540 | + | "INSERT INTO pulls (id, repo_id, number, title, body, agent, runtime, status, merge_commit, item_id, closes_items, parts, | |
| 541 | + | author_id, author_name, created_at, updated_at) | |
| 542 | + | VALUES (?1, ?2, ?3, 'Fix', NULL, 'a', 'r', ?4, ?5, ?6, ?7, ?8, 'user', 'user', 'now', 'now')", | |
| 543 | + | params![id, repo, number, status, merge, item, closes, parts], | |
| 544 | + | ) | |
| 545 | + | .unwrap(); | |
| 546 | + | }; | |
| 547 | + | pull("pr_own", "repo", 14, "merged", "m14", Some("wki_1"), "[]", "[]"); | |
| 548 | + | pull("pr_closes", "repo", 15, "merged", "m15", None, r#"["wki_1"]"#, "[]"); | |
| 549 | + | pull("pr_part", "repo", 16, "merged", "m16", None, "[]", r#"["wki_2","wki_1"]"#); | |
| 550 | + | pull("pr_other", "repo", 17, "merged", "m17", Some("wki_2"), r#"["wki_2"]"#, "[]"); | |
| 551 | + | pull("pr_open", "repo", 18, "open", "m18", Some("wki_1"), "[]", "[]"); | |
| 552 | + | pull("pr_away", "away", 14, "merged", "m14b", Some("wki_1"), "[]", "[]"); | |
| 553 | + | let merged = |repo: &str, commit: &str| -> Vec<(u32, u8)> { | |
| 554 | + | db.prepare(MERGED_AS) | |
| 555 | + | .unwrap() | |
| 556 | + | .query_map(params![repo, commit, "wki_1"], |row| Ok((row.get(0)?, row.get(3)?))) | |
| 557 | + | .unwrap() | |
| 558 | + | .map(Result::unwrap) | |
| 559 | + | .collect() | |
| 560 | + | }; | |
| 561 | + | assert_eq!(merged("repo", "m14"), vec![(14, 1)]); | |
| 562 | + | assert_eq!(merged("repo", "m15"), vec![(15, 1)]); | |
| 563 | + | assert_eq!(merged("repo", "m16"), vec![(16, 1)]); | |
| 564 | + | // Another work item's merge is found, naming nothing of #1's. | |
| 565 | + | assert_eq!(merged("repo", "m17"), vec![(17, 0)]); | |
| 566 | + | // Not merged, or merged elsewhere: nothing. | |
| 567 | + | assert_eq!(merged("repo", "m18"), vec![]); | |
| 568 | + | assert_eq!(merged("repo", "m14b"), vec![]); | |
| 569 | + | assert_eq!(g1t_kit::rename::parameters(MERGED_AS), 3); | |
| 570 | + | } | |
| 571 | + | ||
| 572 | + | #[test] | |
| 381 | 573 | fn landing_statements_take_the_item_repository_and_what_landed() { | |
| 382 | 574 | let counts: Vec<usize> = LAND_SQL.iter().map(|sql| g1t_kit::rename::parameters(sql)).collect(); | |
| 383 | 575 | assert_eq!(counts, vec![4, 3, 7, 3]); | |
| 2231 | 2231 | None => merging::merged_note(landed.method).to_owned(), | |
| 2232 | 2232 | }; | |
| 2233 | 2233 | self.note(&repo.id, pull.number, who, ¬e).await?; | |
| 2234 | − | let landing = items::Landing { pull: Some(pull.number), commit: None, pushed_by: None }; | |
| 2234 | + | let landing = items::Landing { pull: Some(pull.number), commit: None, pushed_by: None, tie: None }; | |
| 2235 | 2235 | items::land(self, repo, &landing, actor, &named.close_ids(), &named.part_ids(), None, false).await?; | |
| 2236 | 2236 | let through_queue = self.landed_through_queue(&pull.id).await?; | |
| 2237 | 2237 | self.keep_decision_quietly( |
| 191 | 191 | let message = pushed.commits.iter().find(|pushed| pushed.hash == *commit).map(|pushed| pushed.message.as_str()); | |
| 192 | 192 | let session = self.trailer_session(&repo.id, message.unwrap_or_default()).await?; | |
| 193 | 193 | let landing = | |
| 194 | − | items::Landing { pull: None, commit: Some(commit.clone()), pushed_by: Some(actor.username.clone()) }; | |
| 194 | + | items::Landing { pull: None, commit: Some(commit.clone()), pushed_by: Some(actor.username.clone()), tie: None }; | |
| 195 | 195 | let (closes, parts) = if closing { (vec![id], Vec::new()) } else { (Vec::new(), vec![id]) }; | |
| 196 | 196 | items::land(self, &repo, &landing, &actor, &closes, &parts, session.as_deref(), agent).await?; | |
| 197 | 197 | } |
Comments
Sign in to comment.