An issue says what closed it, linked: on flagon-io/demo #1 the session that built the page closed the issue through the issue tool's close, and its timeline said only "syntaqx closed this as completed", with nothing to follow to the commit that did the work or the session that made it. close_issue (REST, the MCP issue tool's close, `g1t issue close --commit`) now takes an optional commit: one on the default branch (repos' log and branch_drift) whose message names the issue after a closing keyword (closing::references). The issue then closes as completed in that commit exactly as its push would (close_by_commit: resolved_commit, "closed this in <commit>", issue.closed with commit), by the caller; a commit not on the default branch, or one not naming the issue, is refused saying why, and an already-closed issue says what closed it. The work service's CloseIssueArgs flattens IssueActionArgs, so integrations and security keep calling close_issue as they do. When an issue is closed in a session, the note ends "via session ses_…" and issue.closed carries session: the session the closing commit names in a G1t-Session trailer (on a push too), or, for a close made with a token, the closer's own active session on the issue (attached or linked; the person it acts for when it acts for one). The timeline renders that id as a session chip linking /<owner>/<repo>/work/sessions/<id>, next to the existing commit chip and #N links (messageParts gains a session part). The pull requests guide's Closing issues, the sessions guide, webhooks, the API reference notes, openapi.json, the CLI reference and ops table say so. Deploy: work and api together, then the site; the CLI flag ships with the next CLI release. Checked: cargo test -p g1t-work (202), -p g1t-api (209, openapi regenerated), -p g1t-contracts, -p g1t (ops_table and cli_reference regenerated); wasm32 check of g1t-work and g1t-api; apps/web npm run typecheck; node --test app/lib/commit-message.test.ts.
- G1t-Session:
- ses_01m4z1z7nse8w9cjnecymt7rzn
20 files+107−224 files not counted0/20 viewed
| 1614 | 1614 | "Change an issue's title, body, labels, milestone, the people it is assigned to, or what it is blocked by. Only the fields given are changed; labels, assignees and blocked_by each replace the whole set, and milestone null or 0 takes it out of its milestone. blocked_by lists the numbers of the issues of the same repository that have to land first: one that is not an issue there, the issue itself, or a set that would make a cycle is refused with `422`, and a change is a dependency.changed event (as is a blocker closing, or its pull request merging, later). Its author may change their own issue, as may the person g1t filed one for; anyone else needs the Triage role or higher, and so does the milestone. Each label added or removed is an issue.labeled or issue.unlabeled event." | |
| 1615 | 1615 | } | |
| 1616 | 1616 | Op::CloseIssue => { | |
| 1617 | − | "Close an issue without a pull request. Merging a pull request made for an issue closes it for you. Its author may close their own issue, as may the person g1t filed one for; anyone else needs the Triage role or higher." | |
| 1617 | + | "Close an issue without a pull request. Merging a pull request made for an issue closes it for you, as does pushing a commit whose message names it after a closing keyword (Fixes #12) to the default branch. When a commit already on the default branch did the work, give it as commit: the issue closes as completed in that commit, as its push would have, and its timeline links the commit. A commit that is not on the default branch, or whose message does not name the issue after a closing keyword, is refused with `422`; an issue already closed, with `409`. Closed with a token from a session working on the issue (or by a commit with a `G1t-Session` trailer), its timeline links that session too. Its author may close their own issue, as may the person g1t filed one for; anyone else needs the Triage role or higher." | |
| 1618 | 1618 | } | |
| 1619 | 1619 | Op::ReopenIssue => "Reopen a closed issue. Its author may reopen their own issue, as may the person g1t filed one for; anyone else needs the Triage role or higher.", | |
| 1620 | 1620 | Op::ListLabels => { | |
| ⋯ | |||
| 2691 | 2691 | "enum": ["completed", "not_planned"], | |
| 2692 | 2692 | "description": "Defaults to completed.", | |
| 2693 | 2693 | }, | |
| 2694 | + | "commit": { | |
| 2695 | + | "type": "string", | |
| 2696 | + | "description": "The commit that closed it: one on the default branch whose message names this issue after a closing keyword (Fixes #12). It closes as completed in that commit, as pushing it would have.", | |
| 2697 | + | }, | |
| 2694 | 2698 | })), | |
| 2695 | 2699 | &["repo", "number"], | |
| 2696 | 2700 | ), | |
| ⋯ | |||
| 4985 | 4989 | } else { | |
| 4986 | 4990 | "reopen_issue" | |
| 4987 | 4991 | }; | |
| 4988 | − | pass( | |
| 4989 | − | work, | |
| 4990 | − | method, | |
| 4991 | − | &IssueActionArgs { | |
| 4992 | − | actor: actor(), | |
| 4993 | − | repo, | |
| 4994 | − | number, | |
| 4995 | − | reason: Some(reason), | |
| 4996 | − | }, | |
| 4997 | − | ) | |
| 4998 | − | .await | |
| 4992 | + | let issue = IssueActionArgs { | |
| 4993 | + | actor: actor(), | |
| 4994 | + | repo, | |
| 4995 | + | number, | |
| 4996 | + | reason: Some(reason), | |
| 4997 | + | }; | |
| 4998 | + | if self == Op::CloseIssue { | |
| 4999 | + | let commit = optional_text(input, "commit"); | |
| 5000 | + | return pass(work, method, &CloseIssueArgs { issue, commit }).await; | |
| 5001 | + | } | |
| 5002 | + | pass(work, method, &issue).await | |
| 4999 | 5003 | } | |
| 5000 | 5004 | Op::ListLabels => pass(work, "list_labels", &view()).await, | |
| 5001 | 5005 | Op::CreateLabel | Op::UpdateLabel => { | |
| 1489 | 1489 | "blocked_by": [], | |
| 1490 | 1490 | "agent": null, | |
| 1491 | 1491 | "milestone": null | |
| 1492 | − | } | |
| 1492 | + | }, | |
| 1493 | + | "notes": "With `commit`, a commit already on the default branch whose message names the issue after a closing keyword (`Fixes #12`), the issue closes as completed in that commit, as pushing it would have: `resolved_commit` is set, the event is `issue.closed` with `data.commit`, and the timeline says \"closed 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 issue, is refused (`422`); an issue already closed is `409`, saying what closed it. See [Closing issues](/guides/pull-requests/#closing-issues)." | |
| 1493 | 1494 | }, | |
| 1494 | 1495 | "reopen_issue": { | |
| 1495 | 1496 | "response": { |
| 160 | 160 | a("add_labels", Op::AddIssueLabels, "Add labels to an issue or pull request"), | |
| 161 | 161 | a("set_labels", Op::SetIssueLabels, "Replace the labels of an issue or pull request"), | |
| 162 | 162 | a("remove_labels", Op::RemoveIssueLabels, "Take labels off an issue or pull request"), | |
| 163 | − | a("close", Op::CloseIssue, "Close it without a pull request"), | |
| 163 | + | a("close", Op::CloseIssue, "Close it without a pull request; commit names the commit on the default branch that did it"), | |
| 164 | 164 | a("reopen", Op::ReopenIssue, "Reopen it"), | |
| 165 | 165 | a("comment", Op::AddComment, "Comment on an issue or pull request; path and line for one line of a change"), | |
| 166 | 166 | a("edit_comment", Op::EditComment, "Change a comment's text: your own, or any with the Maintain role"), |
| 525 | 525 | branches close nothing until they reach the default branch, and | |
| 526 | 526 | `auto_close_issues` turns this off as well. | |
| 527 | 527 | ||
| 528 | + | A commit that is already on the default branch can close an issue later, | |
| 529 | + | say one pushed before the issue was open or while `auto_close_issues` was | |
| 530 | + | off: [`close_issue`](/reference/api/issues/close-issue/) with `commit` (its | |
| 531 | + | full hash), `g1t issue close 12 --commit <hash>`, or the `issue` tool's | |
| 532 | + | `close` with `commit`. The commit's message must name the issue after a | |
| 533 | + | closing keyword. The issue then closes as completed in that commit, the way | |
| 534 | + | a push of it would have, by whoever closed it: **closed this in** the | |
| 535 | + | commit, `resolved_commit`, and `issue.closed` with `commit`. A commit that is | |
| 536 | + | not on the default branch, or whose message does not name the issue, is | |
| 537 | + | refused, and so is an issue that is already closed. | |
| 538 | + | ||
| 539 | + | An issue's timeline links whatever closed it: **closed this in** a commit | |
| 540 | + | links the commit, **closed this by merging #14** links the pull request, | |
| 541 | + | and when it was closed in a [session](/guides/sessions/) the line ends | |
| 542 | + | **via session**, linking that session. That session is the one the closing | |
| 543 | + | commit names in a `G1t-Session: ses_…` trailer, or, for an issue closed | |
| 544 | + | with a token (from an agent, the CLI or the MCP server), the closer's own | |
| 545 | + | session on the issue that is still going. `issue.closed` carries it as | |
| 546 | + | `session`. An issue closed on the site says only who closed it. | |
| 547 | + | ||
| 528 | 548 | Wherever a commit's message shows, `#12` links to that issue or pull | |
| 529 | 549 | request, with its title and state when you hover over it. | |
| 530 | 550 |
| 255 | 255 | A session's links cover its subagents too, and the issue and pull request | |
| 256 | 256 | pages list every session tied to them with its subagents. Put the trailer | |
| 257 | 257 | on the commits a session makes (`git commit --trailer "G1t-Session: | |
| 258 | − | $G1T_SESSION"`) and they lead back to it. | |
| 258 | + | $G1T_SESSION"`) and they lead back to it. An issue a session closes says | |
| 259 | + | so on its timeline, **via session** and a link to it: closed with the | |
| 260 | + | session's token while it works on the issue, or by a commit naming it in | |
| 261 | + | that trailer (see [Closing issues](/guides/pull-requests/#closing-issues)). | |
| 259 | 262 | ||
| 260 | 263 | When a file a session touched was touched by another active session as | |
| 261 | 264 | well (outside its own parents and subagents), g1t sends `overlap.detected` |
| 87 | 87 | | `issue.labeled`, `issue.unlabeled`, `pull.labeled`, `pull.unlabeled` | A [label](/guides/labels/) was put on an issue or a pull request, or taken off: one event for each label. `data.number` and `data.label` (`name` and `color`). | | |
| 88 | 88 | | `issue.milestoned`, `issue.demilestoned`, `pull.milestoned`, `pull.demilestoned` | An issue or a pull request was put in a [milestone](/guides/milestones/), or taken out of one. `data.milestone` (`number` and `title`). Moving it from one to another is both. | | |
| 89 | 89 | | `pull.base_changed` | The branch a pull request merges into changed. `data.base` names it. See [pull requests into other branches](/guides/base-branches/). | | |
| 90 | − | | `issue.opened`, `issue.updated`, `issue.assigned`, `issue.closed`, `issue.reopened` | An issue changed. `data.number` and `data.author` (`id` and `username`); on close, `data.reason` and `data.resolved_by` (the pull request that closed it) or `data.commit` (the commit pushed to the default branch that did); on assignment, `data.assignees` and the newly assigned `data.added`. | | |
| 90 | + | | `issue.opened`, `issue.updated`, `issue.assigned`, `issue.closed`, `issue.reopened` | An issue changed. `data.number` and `data.author` (`id` and `username`); on close, `data.reason` and `data.resolved_by` (the pull request that closed it) or `data.commit` (the commit on the default branch that did), and `data.session` when it was closed in a session; on assignment, `data.assignees` and the newly assigned `data.added`. | | |
| 91 | 91 | | `comment.created` | A comment or review on an issue or pull request. | | |
| 92 | 92 | | `comment.edited` | A comment's text was [edited](/guides/pull-requests/#editing-and-deleting-comments). `data.comment_id`, `data.number`, `data.pull_id` on a pull request, and `data.changes.body.from`, what it said before. | | |
| 93 | 93 | | `comment.deleted` | A comment was [deleted](/guides/pull-requests/#editing-and-deleting-comments). `data.comment_id`, `data.number`, `data.pull_id` on a pull request, and `data.comment` as it was: `id`, `body`, `author` (`id` and `username`), `created_at`, `path` and `line`. | |
| 330 | 330 | | `<NUMBER>` | | | |
| 331 | 331 | | `-r, --reason <REASON>` | One of `completed`, `not_planned`. | | |
| 332 | 332 | | `-c, --comment <COMMENT>` | Leave a comment as it closes. | | |
| 333 | + | | `--commit <COMMIT>` | The commit on the default branch that closed it: its message names the issue after a closing keyword (Fixes #12). Its full hash. | | |
| 333 | 334 | ||
| 334 | 335 | Calls [`close_issue`](/reference/api/issues/close-issue/), [`add_comment`](/reference/api/issues/add-comment/). | |
| 335 | 336 |
Binary or large file; its contents are not shown.
| 6 | 6 | * conversation shows them (components/chat/text.tsx for people's messages, | |
| 7 | 7 | * components/ai-elements/response.tsx for g1t's answers). | |
| 8 | 8 | */ | |
| 9 | − | import { CircleDot, GitCommitHorizontal, GitPullRequest, Link2 } from "lucide-react"; | |
| 9 | + | import { Bot, CircleDot, GitCommitHorizontal, GitPullRequest, Link2 } from "lucide-react"; | |
| 10 | 10 | import { type ReactNode, useEffect, useState } from "react"; | |
| 11 | 11 | import { Link } from "react-router"; | |
| 12 | 12 | ||
| ⋯ | |||
| 109 | 109 | ); | |
| 110 | 110 | } | |
| 111 | 111 | ||
| 112 | + | /** A session, by the end of its id, which is what tells sessions started together apart. */ | |
| 113 | + | export function SessionChip({ href, id }: { href: string; id: string }) { | |
| 114 | + | return ( | |
| 115 | + | <Link to={href} className={cn(CHIP, "font-mono")} data-ref="session"> | |
| 116 | + | <Bot size={12} className="text-faint" /> | |
| 117 | + | {id.length > 12 ? `${id.slice(0, 4)}…${id.slice(-6)}` : id} | |
| 118 | + | </Link> | |
| 119 | + | ); | |
| 120 | + | } | |
| 121 | + | ||
| 112 | 122 | /** Another page of the site, linked by its path or a full link to it. */ | |
| 113 | 123 | export function PageChip({ href, children }: { href: string; children: ReactNode }) { | |
| 114 | 124 | return ( | |
| 8 | 8 | import { messageParts } from "../lib/commit-message"; | |
| 9 | 9 | import { chipStyle } from "../lib/labels"; | |
| 10 | 10 | import { repoAt } from "../lib/markdown-plugins"; | |
| 11 | − | import { CommitChip } from "./chat/ref-chip"; | |
| 11 | + | import { CommitChip, SessionChip } from "./chat/ref-chip"; | |
| 12 | 12 | import { RefLink } from "./commit-message"; | |
| 13 | 13 | import { Markdown } from "./markdown"; | |
| 14 | 14 | import { Reactions } from "./reactions"; | |
| ⋯ | |||
| 409 | 409 | ) : part.kind === "commit" ? ( | |
| 410 | 410 | // The commit that closed or merged it, by its short hash. | |
| 411 | 411 | <CommitChip key={at} href={`${base}/commit/${part.sha}`} sha={part.sha} /> | |
| 412 | + | ) : part.kind === "session" ? ( | |
| 413 | + | // The session it was closed in. | |
| 414 | + | <SessionChip key={at} href={`${base}/work/sessions/${part.id}`} id={part.id} /> | |
| 412 | 415 | ) : ( | |
| 413 | 416 | part.text | |
| 414 | 417 | ), | |
| 55 | 55 | } | |
| 56 | 56 | assert.deepEqual(messageParts(""), []); | |
| 57 | 57 | }); | |
| 58 | + | ||
| 59 | + | test("a session's id is a session, as a note says what it closed the issue in", () => { | |
| 60 | + | const sha = "0123456789abcdef0123456789abcdef01234567"; | |
| 61 | + | const id = "ses_01m4z1z7nse8w9cjnecymt7rzn"; | |
| 62 | + | assert.deepEqual(messageParts(`closed this in ${sha} via session ${id}`), [ | |
| 63 | + | { kind: "text", text: "closed this in " }, | |
| 64 | + | { kind: "commit", text: sha, sha }, | |
| 65 | + | { kind: "text", text: " via session " }, | |
| 66 | + | { kind: "session", text: id, id }, | |
| 67 | + | ]); | |
| 68 | + | for (const text of ["ses_short", "uses_01m4z1z7nse8w9cjnecymt7rzn"]) { | |
| 69 | + | assert.deepEqual(messageParts(text), [{ kind: "text", text }], text); | |
| 70 | + | } | |
| 71 | + | }); |
| 41 | 41 | /** | |
| 42 | 42 | * A run of a commit message, or of a note on an issue's timeline: plain | |
| 43 | 43 | * text, a reference to an issue or pull request (`#12` in the repository | |
| 44 | − | * it is in, `acme/web#12` in another), or a commit by its full hash. | |
| 44 | + | * it is in, `acme/web#12` in another), a commit by its full hash, or a | |
| 45 | + | * session by its id (`ses_…`, as in "closed this in … via session ses_…"). | |
| 45 | 46 | */ | |
| 46 | 47 | export type MessagePart = | |
| 47 | 48 | | { kind: "text"; text: string } | |
| 48 | 49 | | { kind: "ref"; text: string; repo: string | null; number: number } | |
| 49 | − | | { kind: "commit"; text: string; sha: string }; | |
| 50 | + | | { kind: "commit"; text: string; sha: string } | |
| 51 | + | | { kind: "session"; text: string; id: string }; | |
| 50 | 52 | ||
| 51 | − | // `#12` and `acme/web#12`, but not `a#12` or a link's `/page#12`; a full hash on its own. | |
| 52 | − | const REFERENCE = /(?<![\w/#.-])(?:([A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+))?#(\d+)\b|\b([0-9a-f]{40})\b/g; | |
| 53 | + | // `#12` and `acme/web#12`, but not `a#12` or a link's `/page#12`; a full hash on its own; a session's id. | |
| 54 | + | const REFERENCE = | |
| 55 | + | /(?<![\w/#.-])(?:([A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+))?#(\d+)\b|\b([0-9a-f]{40})\b|\b(ses_[0-9a-z]{10,60})\b/g; | |
| 53 | 56 | ||
| 54 | 57 | /** Splits text into its references and the text between them; `Closes #12` keeps `Closes ` as text. */ | |
| 55 | 58 | export function messageParts(text: string): MessagePart[] { | |
| ⋯ | |||
| 59 | 62 | const start = match.index; | |
| 60 | 63 | if (start > at) parts.push({ kind: "text", text: text.slice(at, start) }); | |
| 61 | 64 | if (match[3]) parts.push({ kind: "commit", text: match[0], sha: match[3] }); | |
| 65 | + | else if (match[4]) parts.push({ kind: "session", text: match[0], id: match[4] }); | |
| 62 | 66 | else parts.push({ kind: "ref", text: match[0], repo: match[1] ?? null, number: Number(match[2]) }); | |
| 63 | 67 | at = start + match[0].length; | |
| 64 | 68 | } | |
| 183 | 183 | /// closed it (`Fixes #12`), when one did. | |
| 184 | 184 | #[serde(skip_serializing_if = "Option::is_none")] | |
| 185 | 185 | pub commit: Option<String>, | |
| 186 | + | /// On close: the session it was closed in, when it was: the one the | |
| 187 | + | /// closing commit names (`G1t-Session: ses_…`), or the caller's own | |
| 188 | + | /// session on the issue when it closed it with a token. | |
| 189 | + | #[serde(skip_serializing_if = "Option::is_none")] | |
| 190 | + | pub session: Option<String>, | |
| 186 | 191 | /// On `issue.assigned`: the people it is now assigned to. | |
| 187 | 192 | #[serde(skip_serializing_if = "Option::is_none")] | |
| 188 | 193 | pub assignees: Option<Vec<String>>, |
| 1840 | 1840 | } | |
| 1841 | 1841 | ||
| 1842 | 1842 | /// `close_issue` and `reopen_issue`. Each returns `Outcome<Issue>`. | |
| 1843 | + | /// `close_issue` takes [`CloseIssueArgs`], which these are a part of. | |
| 1843 | 1844 | #[derive(Debug, Serialize, Deserialize)] | |
| 1844 | 1845 | pub struct IssueActionArgs { | |
| 1845 | 1846 | pub actor: User, | |
| ⋯ | |||
| 1850 | 1851 | pub reason: Option<IssueReason>, | |
| 1851 | 1852 | } | |
| 1852 | 1853 | ||
| 1854 | + | /// `close_issue`: an [`IssueActionArgs`], and the commit that did it, if | |
| 1855 | + | /// one did. With `commit`, a commit on the default branch whose message | |
| 1856 | + | /// names the issue after a closing keyword (`Fixes #12`), it closes as the | |
| 1857 | + | /// push of that commit would have: completed, in that commit, by the | |
| 1858 | + | /// caller; a commit that is not on the default branch, or does not name | |
| 1859 | + | /// the issue, is refused. Returns `Outcome<Issue>`. | |
| 1860 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 1861 | + | pub struct CloseIssueArgs { | |
| 1862 | + | #[serde(flatten)] | |
| 1863 | + | pub issue: IssueActionArgs, | |
| 1864 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 1865 | + | pub commit: Option<String>, | |
| 1866 | + | } | |
| 1867 | + | ||
| 1853 | 1868 | /// `add_comment`, on an issue or a pull request. On a pull request it may | |
| 1854 | 1869 | /// name a line of the change, and may carry a verdict; nobody can give a | |
| 1855 | 1870 | /// verdict on their own pull request. Returns `Outcome<Comment>`. | |
| 444 | 444 | /// Leave a comment as it closes. | |
| 445 | 445 | #[arg(short, long)] | |
| 446 | 446 | pub comment: Option<String>, | |
| 447 | + | /// The commit on the default branch that closed it: its message names | |
| 448 | + | /// the issue after a closing keyword (Fixes #12). Its full hash. | |
| 449 | + | #[arg(long)] | |
| 450 | + | pub commit: Option<String>, | |
| 447 | 451 | } | |
| 448 | 452 | ||
| 449 | 453 | #[derive(Debug, Args)] |
| 356 | 356 | .repo(&repo) | |
| 357 | 357 | .arg("number", args.number) | |
| 358 | 358 | .field_opt("reason", reason) | |
| 359 | + | .field_opt("commit", args.commit.as_deref()) | |
| 359 | 360 | .send()?; | |
| 360 | 361 | done( | |
| 361 | 362 | ctx, |
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Comments
Sign in to comment.