Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

Merge branch 'g1t-takes-issues' into ship-42-43

syntaqxcommitted Parents834f299ad1fbddBrowse files
36 files+481−2320 files not counted0/36 viewed
+2−0
506506 actor: actor(),
507507 repo,
508508 issue,
509+ pull: None,
510+ asked: None,
509511 title: text(input, "title"),
510512 body: text(input, "body"),
511513 instruction: text(input, "instruction"),
+2−0
359359 Op::AddComment,
360360 Op::EditComment,
361361 Op::DeleteComment,
362+ Op::React,
362363 Op::ListIssueLabels,
363364 Op::AddIssueLabels,
364365 Op::SetIssueLabels,
734735 Op::AddComment => "Add a comment",
735736 Op::EditComment => "Edit a comment",
736737 Op::DeleteComment => "Delete a comment",
738+ Op::React => "React with an emoji",
737739 Op::ReviewPullRequest => "Review a pull request",
738740 Op::ListPullRequests => "List pull requests",
739741 Op::GetPullRequest => "Get a pull request",
+35−1
190190 AddComment,
191191 EditComment,
192192 DeleteComment,
193+ React,
193194 ReviewPullRequest,
194195 ListPullRequests,
195196 GetPullRequest,
811812 }
812813
813814 impl Op {
814− pub const ALL: [Op; 405] = [
815+ pub const ALL: [Op; 406] = [
815816 Op::Whoami,
816817 Op::GetWorkspace,
817818 Op::CreateWorkspace,
885886 Op::AddComment,
886887 Op::EditComment,
887888 Op::DeleteComment,
889+ Op::React,
888890 Op::ReviewPullRequest,
889891 Op::ListPullRequests,
890892 Op::GetPullRequest,
12991301 Op::AddComment => "add_comment",
13001302 Op::EditComment => "edit_comment",
13011303 Op::DeleteComment => "delete_comment",
1304+ Op::React => "react",
13021305 Op::ReviewPullRequest => "review_pull_request",
13031306 Op::ListPullRequests => "list_pull_requests",
13041307 Op::GetPullRequest => "get_pull_request",
16631666 Op::DeleteComment => {
16641667 "Delete a comment on an issue or a pull request, named by comment_id. Its author may delete it, and so may anyone with the Maintain role or higher. A review that approved or requested changes cannot be deleted, only edited, and notes of what happened cannot be deleted. This cannot be undone. Publishes comment.deleted with the comment as it was."
16651668 }
1669+ Op::React => {
1670+ "React to an issue or a pull request with an emoji: to its description, or with comment_id to one of its comments. content is one of +1, -1, laugh, hooray, confused, heart, rocket and eyes; remove true takes your reaction back. Anyone who can read the repository may. Answers with the reactions it has now, each with its count and the usernames of who reacted (`g1t` for g1t, which reacts eyes to what it was asked to take on). get_issue and get_pull_request give each comment's reactions, and the description's as reactions."
1671+ }
16661672 Op::ReviewPullRequest => {
16671673 "Give a verdict on a pull request: approve it, or request changes and say what. Read get_pull_request_changes first. You cannot review a pull request you opened, or one g1t made for you (you are its requested_by)."
16681674 }
27162722 json!({ "repo": repo_schema(), "comment_id": comment_id_schema() }),
27172723 &["repo", "comment_id"],
27182724 ),
2725+ Op::React => object(
2726+ numbered(json!({
2727+ "content": {
2728+ "type": "string",
2729+ "enum": ["+1", "-1", "laugh", "hooray", "confused", "heart", "rocket", "eyes"],
2730+ },
2731+ "comment_id": {
2732+ "type": "string",
2733+ "description": "A comment on it, to react to that comment rather than to its description.",
2734+ },
2735+ "remove": { "type": "boolean", "description": "Take your reaction back." },
2736+ })),
2737+ &["repo", "number", "content"],
2738+ ),
27192739 Op::ReviewPullRequest => object(
27202740 numbered(json!({
27212741 "verdict": { "type": "string", "enum": ["approve", "request_changes"] },
52385258 Op::ClosePullRequest => pass(work, "close_pull", &pull_action()).await,
52395259 Op::ReopenPullRequest => pass(work, "reopen_pull", &pull_action()).await,
52405260 Op::ConvertPullRequestToDraft => pass(work, "convert_pull_to_draft", &pull_action()).await,
5261+ Op::React => {
5262+ let Some(content) = input["content"].as_str().and_then(ReactionContent::parse) else {
5263+ return failed(FailureCode::Invalid, "content must be one of +1, -1, laugh, hooray, confused, heart, rocket and eyes.");
5264+ };
5265+ let asked = ReactArgs {
5266+ actor: actor(),
5267+ repo,
5268+ number,
5269+ comment_id: optional_text(input, "comment_id"),
5270+ content,
5271+ remove: input["remove"].as_bool().unwrap_or(false),
5272+ };
5273+ pass(work, "react", &asked).await
5274+ }
52415275 Op::EditComment | Op::DeleteComment => {
52425276 let asked = CommentActionArgs {
52435277 actor: actor(),
+12−0
16041604 },
16051605 "notes": "Answers `204` with no body. Its author, or someone with the Maintain role or higher. A review that approved or requested changes answers `409`: edit it instead."
16061606 },
1607+ "react": {
1608+ "params": {
1609+ "number": 12,
1610+ "comment_id": "cmt_01m43sv4c8e2g6j0m4q8t2x6a1",
1611+ "content": "heart"
1612+ },
1613+ "response": [
1614+ { "content": "heart", "count": 1, "users": ["ana"] },
1615+ { "content": "eyes", "count": 1, "users": ["g1t"] }
1616+ ],
1617+ "notes": "Reacting twice the same way is one reaction. Without comment_id it reacts to the issue's or pull request's description."
1618+ },
16071619 "list_labels": {
16081620 "response": [
16091621 {
+2−0
10461046 ),
10471047 route("PATCH", "/repos/:owner/:name/issues/comments/:comment_id", Op::EditComment, &[]),
10481048 route("DELETE", "/repos/:owner/:name/issues/comments/:comment_id", Op::DeleteComment, &[]),
1049+ route("POST", "/repos/:owner/:name/issues/:number/reactions", Op::React, &[]),
10491050 route(
10501051 "GET",
10511052 "/repos/:owner/:name/pulls",
15791580 assert_eq!(input, json!({ "comment_id": "cmt_1", "repo": "acme/web" }));
15801581 // Still an issue's comments, labels and subscription.
15811582 assert_eq!(op("POST", "/repos/acme/web/issues/7/comments"), Op::AddComment);
1583+ assert_eq!(op("POST", "/repos/acme/web/issues/7/reactions"), Op::React);
15821584 assert_eq!(op("DELETE", "/repos/acme/web/issues/7/labels"), Op::RemoveIssueLabels);
15831585 assert_eq!(op("DELETE", "/repos/acme/web/issues/7/subscription"), Op::DeleteThreadSubscription);
15841586 }
+1−0
165165 a("comment", Op::AddComment, "Comment on an issue or pull request; path and line for one line of a change"),
166166 a("edit_comment", Op::EditComment, "Change a comment's text: your own, or any with the Maintain role"),
167167 a("delete_comment", Op::DeleteComment, "Delete a comment: your own, or any with the Maintain role"),
168+ a("react", Op::React, "React with an emoji to it, or with comment_id to one of its comments; remove takes it back"),
168169 a("import", Op::ImportIssue, "Open an issue from a Jira, Linear or Sentry item"),
169170 a("work", Op::Coordination(CoordinationOp::GetWork), "Everything to pick it up: done means, claims, sessions, pull requests, blockers, decisions, recent events"),
170171 a("claim", Op::Coordination(CoordinationOp::ClaimIssue), "Say you are working on it; lists who else is, and what it is blocked by"),
+25−0
182182
183183 ## Sessions
184184
185+Each session that works on a pull request, or on its issue, shows in the
186+conversation as a card where it started, the way a comment or a review
187+does: who it is (or g1t, working for someone), where it stands, its
188+branch, and its report once it ends, linked to its page. Mention `@g1t`
189+in a comment to have g1t carry on with the pull request; see
190+[assign g1t, or ask it in the thread](/guides/sessions/#assign-g1t-or-ask-it-in-the-thread).
191+
185192 A coding agent that works on a pull request can record how it made the
186193 change, entry by entry, with the `pull_request` tool's `record_session`
187194 action. Read it on the pull request's **Session** tab, or with
293300 `changes.body.from`; deleting publishes `comment.deleted`, with the comment
294301 as it was in `comment`.
295302
303+## Reactions
304+
305+React to an issue's or pull request's description, or to any comment on
306+it, with one of eight emoji: 👍 👎 😄 🎉 😕 ❤️ 🚀 👀. Select the smiley
307+under it, or a reaction already there to add or take back yours; hovering
308+one says who reacted. Anyone who can read the repository may react. g1t
309+reacts 👀 to what it was asked to take on.
310+
311+Through the API, `POST /repos/{owner}/{name}/issues/{number}/reactions`
312+with `content` (`+1`, `-1`, `laugh`, `hooray`, `confused`, `heart`,
313+`rocket` or `eyes`), and `comment_id` to react to a comment rather than
314+the description, reacts and answers with the reactions it has now;
315+`remove: true` takes yours back. On the MCP server it is the `issue`
316+tool's `react` action. Each comment's `reactions`, and the description's,
317+are in `GET /repos/{owner}/{name}/issues/{number}` and
318+`GET /repos/{owner}/{name}/pulls/{number}`: each with its `content`,
319+`count` and the `users` who reacted.
320+
296321 ## Commit comments
297322
298323 You can comment on any commit: on the whole commit, under its changes on
+36−0
313313 result. Whoever started it can stop it from its page. It runs on your
314314 workspace's key and Anthropic bills the account for it; g1t bills nothing.
315315
316+## Assign g1t, or ask it in the thread
317+
318+You can also hand work to g1t from the issue or pull request itself, the
319+way you would ask a teammate:
320+
321+- **Assign the issue to `g1t`**: **Assign to g1t** in the issue's
322+ sidebar, `g1t` among the assignees in
323+ [`update_issue`](/reference/api/issues/update-issue/), or
324+ `g1t issue edit 12 --add-assignee g1t`.
325+- **Mention `@g1t` in a comment** on an issue or a pull request:
326+ "@g1t the button still overlaps on a phone, fix that". On a pull
327+ request, the session carries on with that pull request on its branch.
328+ On an issue, it takes the issue on.
329+
330+g1t reacts 👀 to what asked it, the comment or (for an assignment) the
331+issue, then starts a session on the workspace's Anthropic account, as
332+the person who asked, with what they wrote as its instruction. Each
333+session shows in the issue's or pull request's conversation as a card,
334+where it started, the way a comment or a review does: who it works for,
335+where it stands, its branch, and its report when it ends, linked to its
336+page. Not happy with what a session did? Say so with `@g1t` in the same
337+thread. The next session is told to fix the work on the same branch and
338+pull request, not start over, and `g1t work context` shows it every
339+session and pull request so far. If the session you started on that
340+thread is still going, your comment goes to it instead of starting
341+another.
342+
343+Only the workspace's members can ask g1t; anyone else's `@g1t` is an
344+ordinary mention. A mention in quoted text or code (`` `@g1t` ``) asks
345+nothing. g1t answers one thread at most six times an hour. When it cannot
346+start a session, for instance because the workspace has no Anthropic AI
347+provider, it says why in the thread. On an update pull request g1t opened,
348+`@g1t rebase` and the other
349+[comment commands](/guides/dependency-updates/#comment-commands) still do what
350+they say.
351+
316352 ## Claims
317353
318354 A claim says you are working on an issue. It is advisory: anyone may claim
+0−0

Binary or large file; its contents are not shown.

+89−1
7777 import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
7878 import { RadioGroup, RadioOption } from "./ui/radio-group";
7979 import { Textarea } from "./ui/textarea";
80−import { PersonLink } from "./work";
80+import { Markdown } from "./markdown";
81+import { PersonLink, TimelineItem } from "./work";
82+import { repoAt } from "../lib/markdown-plugins";
8183 import { IssueIcon, PullIcon } from "./work-icons";
8284
8385 /** Text with each `#N` linked to that issue or pull request (the issue's address leads to a pull request too). */
223225 );
224226 }
225227
228+/**
229+ * A session in an issue's or pull request's conversation, where it
230+ * started, as a card like a comment or a review: g1t working for someone
231+ * (a session g1t started on the workspace's Claude, when it was assigned
232+ * the issue or asked in the thread), or someone working in their own
233+ * harness. What it is about, where it stands now, its branch, and what it
234+ * reported once it ended, linked to its page.
235+ */
236+export function TimelineSession({ session, base }: { session: WorkSession; base: string }) {
237+ const g1t = session.harness === "claude_managed_agents";
238+ const ended = session.state === "ended";
239+ return (
240+ <TimelineItem
241+ id={session.id}
242+ author={g1t ? "g1t" : session.actor.username}
243+ bot={g1t}
244+ at={session.startedAt}
245+ action={
246+ <>
247+ {g1t ? (
248+ <span>
249+ {ended ? "worked on this for" : "is working on this for"}{" "}
250+ <PersonLink name={session.actor.username} className="font-medium text-fg hover:underline">
251+ @{session.actor.username}
252+ </PersonLink>
253+ </span>
254+ ) : (
255+ <span>{ended ? "worked on this in a session" : "is working on this in a session"}</span>
256+ )}
257+ <HarnessBadge harness={session.harness} />
258+ <SessionStateBadge state={session.state} />
259+ </>
260+ }
261+ aside={
262+ <Link
263+ to={`${base}/work/sessions/${session.id}`}
264+ prefetch="intent"
265+ className="inline-flex items-center gap-1 text-xs text-faint hover:text-fg"
266+ >
267+ <Activity size={12} />
268+ View session
269+ </Link>
270+ }
271+ >
272+ <Link to={`${base}/work/sessions/${session.id}`} prefetch="intent" className="font-medium hover:text-accent">
273+ {sessionTitle(session)}
274+ </Link>
275+ <p className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted">
276+ {ended ? (
277+ <span>
278+ Ended <TimeAgo at={session.endedAt ?? session.lastSeenAt} />
279+ </span>
280+ ) : (
281+ <span>
282+ Last heard from <TimeAgo at={session.lastSeenAt} />
283+ </span>
284+ )}
285+ {session.branch && (
286+ <span className="inline-flex min-w-0 items-center gap-1 font-mono">
287+ · <GitBranch size={11} />
288+ <span className="truncate">{session.branch}</span>
289+ </span>
290+ )}
291+ {session.pull != null && (
292+ <span>
293+ · on{" "}
294+ <Link to={`${base}/pull/${session.pull}`} className="text-fg hover:text-accent">
295+ #{session.pull}
296+ </Link>
297+ </span>
298+ )}
299+ {(session.subagents ?? 0) > 0 && (
300+ <span>
301+ · {session.subagents} {session.subagents === 1 ? "subagent" : "subagents"}
302+ </span>
303+ )}
304+ </p>
305+ {ended && session.result && (
306+ <div className="mt-3 border-t border-line pt-3 text-sm">
307+ <Markdown source={session.result} repo={repoAt(base)} />
308+ </div>
309+ )}
310+ </TimelineItem>
311+ );
312+}
313+
226314 /** A subagent in a line: its kind, its task, where it stands, linked to its page. */
227315 export function SubagentLine({ session, base }: { session: WorkSession; base: string }) {
228316 return (
+135−0
1+import { SmilePlus } from "lucide-react";
2+import { useFetcher } from "react-router";
3+
4+import { REACTIONS, type ReactionContent, type ReactionGroup } from "@g1t/contracts";
5+
6+import { Button } from "./ui/button";
7+import { Hint } from "./ui/hint";
8+import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
9+
10+const EMOJI = Object.fromEntries(REACTIONS.map((one) => [one.content, one])) as Record<
11+ ReactionContent,
12+ (typeof REACTIONS)[number]
13+>;
14+
15+/** "ana, bo and g1t" for who reacted; "You" for the viewer. */
16+function who(users: string[], viewer: string | null) {
17+ const names = users.map((name) => (name === viewer ? "You" : name));
18+ if (names.length <= 1) return names.join("");
19+ return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
20+}
21+
22+/** `groups` with the viewer's reaction added or taken back, as the page shows it before the service answers. */
23+function applied(groups: ReactionGroup[], viewer: string, content: ReactionContent, remove: boolean): ReactionGroup[] {
24+ const next = groups
25+ .map((group) => {
26+ if (group.content !== content) return group;
27+ const users = remove ? group.users.filter((name) => name !== viewer) : group.users.includes(viewer) ? group.users : [...group.users, viewer];
28+ return { ...group, users, count: users.length };
29+ })
30+ .filter((group) => group.count > 0);
31+ if (!remove && !next.some((group) => group.content === content)) next.push({ content, count: 1, users: [viewer] });
32+ const order = REACTIONS.map((one) => one.content);
33+ return next.sort((a, b) => order.indexOf(a.content) - order.indexOf(b.content));
34+}
35+
36+/**
37+ * How people (and g1t) reacted to an issue's or pull request's description
38+ * (no `commentId`) or to one of its comments: a pill per emoji with how
39+ * many, lit when the viewer is one of them, and who when hovered. Signed
40+ * in, a pill adds or takes back the viewer's own and the smiley offers the
41+ * rest; the page's action answers `react`. g1t's 👀 says it has seen what
42+ * it was asked and is on it.
43+ */
44+export function Reactions({
45+ reactions,
46+ commentId,
47+ viewer,
48+ className = "",
49+}: {
50+ reactions?: ReactionGroup[];
51+ commentId?: string;
52+ /** The signed-in viewer's username, or null: then the pills only show. */
53+ viewer: string | null;
54+ className?: string;
55+}) {
56+ const fetcher = useFetcher();
57+ let groups = reactions ?? [];
58+ // What the viewer just did, shown at once.
59+ const pending = fetcher.formData;
60+ if (viewer && pending?.get("action") === "react") {
61+ const content = String(pending.get("content")) as ReactionContent;
62+ if (EMOJI[content]) groups = applied(groups, viewer, content, pending.get("remove") === "1");
63+ }
64+ if (groups.length === 0 && !viewer) return null;
65+ const react = (content: ReactionContent, remove: boolean) =>
66+ fetcher.submit(
67+ { action: "react", comment: commentId ?? "", content, remove: remove ? "1" : "" },
68+ { method: "post", preventScrollReset: true },
69+ );
70+ return (
71+ <div className={`flex flex-wrap items-center gap-1 ${className}`} aria-label="Reactions">
72+ {groups.map((group) => {
73+ const mine = viewer != null && group.users.includes(viewer);
74+ const { emoji, label } = EMOJI[group.content];
75+ return (
76+ <Hint key={group.content} label={`${who(group.users, viewer)} reacted with ${label.toLowerCase()}`}>
77+ <button
78+ type="button"
79+ aria-pressed={mine}
80+ aria-label={`${label}, ${group.count} ${group.count === 1 ? "reaction" : "reactions"}${mine ? ", including yours" : ""}`}
81+ disabled={!viewer}
82+ onClick={() => react(group.content, mine)}
83+ className={`flex h-6 items-center gap-1 rounded-full border px-1.5 text-xs font-medium tabular-nums transition-colors disabled:cursor-default ${
84+ mine
85+ ? "border-accent/60 bg-accent/15 text-accent hover:bg-accent/25"
86+ : "border-line bg-surface text-muted hover:border-line-strong hover:bg-raised hover:text-fg"
87+ }`}
88+ >
89+ <span aria-hidden className="text-sm leading-none">
90+ {emoji}
91+ </span>
92+ {group.count}
93+ </button>
94+ </Hint>
95+ );
96+ })}
97+ {viewer && (
98+ <Popover>
99+ <PopoverTrigger asChild>
100+ <Button
101+ type="button"
102+ aria-label="Add a reaction"
103+ variant="outline"
104+ className="h-6 rounded-full bg-surface px-1.5 text-faint hover:bg-raised"
105+ >
106+ <SmilePlus size={14} />
107+ </Button>
108+ </PopoverTrigger>
109+ <PopoverContent align="start" className="w-auto p-1.5">
110+ <div className="flex gap-0.5">
111+ {REACTIONS.map(({ content, emoji, label }) => {
112+ const mine = groups.some((group) => group.content === content && group.users.includes(viewer));
113+ return (
114+ <Hint key={content} label={label}>
115+ <Button
116+ type="button"
117+ variant="ghost"
118+ size="icon-sm"
119+ aria-label={label}
120+ aria-pressed={mine}
121+ className={`text-base ${mine ? "bg-accent/15" : ""}`}
122+ onClick={() => react(content, mine)}
123+ >
124+ {emoji}
125+ </Button>
126+ </Hint>
127+ );
128+ })}
129+ </div>
130+ </PopoverContent>
131+ </Popover>
132+ )}
133+ </div>
134+ );
135+}
+46−9
11 import { Bot, CircleCheck, CircleSlash, Pencil, Trash2 } from "lucide-react";
2−import type { CSSProperties, ReactNode } from "react";
2+import { type CSSProperties, Fragment, type ReactNode } from "react";
33 import { Form, Link } from "react-router";
44
55 import type { AgentRef, Comment, Issue, Pull, State } from "@g1t/contracts";
88 import { chipStyle } from "../lib/labels";
99 import { repoAt } from "../lib/markdown-plugins";
1010 import { Markdown } from "./markdown";
11+import { Reactions } from "./reactions";
1112 import { IssueIcon, PullIcon } from "./work-icons";
1213 import { MentionTextarea } from "./mention-textarea";
1314 import { ErrorText, SubmitButton, TimeAgo } from "./ui";
254255 * wrote. The author's avatar sits beside it, as on any forge.
255256 */
256257 export function TimelineItem({
258+ id,
257259 author,
260+ bot = false,
258261 agent,
259262 action,
260263 at,
262265 aside,
263266 children,
264267 }: {
268+ /** Its anchor, so a link can point at it (`#cmt_…`). */
269+ id?: string;
265270 author: string;
271+ /** g1t, or an agent in someone's harness: drawn as an agent, with no profile to link to. */
272+ bot?: boolean;
266273 /**
267274 * Set when one of the workspace's former agents wrote it, as itself:
268275 * shown by its name with an Agent badge, and whom it acted for, in place
282289 agent ? (
283290 <Avatar name={agent.ref.displayName} agent size={size} />
284291 ) : (
285− <Avatar name={author} size={size} />
292+ <Avatar name={author} size={size} agent={bot} />
286293 );
287294 return (
288− <div className="flex gap-3">
295+ <div id={id} className="flex scroll-mt-20 gap-3">
289296 <span className="mt-1 hidden shrink-0 sm:block">
290− {agent ? (
297+ {agent || bot ? (
291298 face(32)
292299 ) : (
293300 <PersonLink name={author} label={`${author}'s profile`}>
301308 <span className="sm:hidden">{face(18)}</span>
302309 {agent ? (
303310 <AgentName agent={agent.ref} />
311+ ) : bot ? (
312+ <span className="font-medium text-fg">{author}</span>
304313 ) : (
305314 <PersonLink name={author} className="font-medium text-fg hover:underline" />
306315 )}
555564 );
556565 }
557566
567+/** Something else in a conversation, put among its comments by when it happened: a session, say. */
568+export type TimelineExtra = { id: string; at: string; node: ReactNode };
569+
558570 /**
559571 * The conversation of an issue or a pull request, oldest first: what
560− * people and agents wrote, and between those, what happened.
572+ * people and agents wrote, and between those, what happened, with
573+ * `extra` (its sessions) where each happened.
561574 */
562575 export function CommentList({
563576 comments,
564577 review,
565578 base,
566579 viewerId,
580+ viewerName = null,
567581 canModerate = false,
568582 failed,
583+ extra = [],
569584 }: {
570585 comments: Comment[];
571586 review?: Review;
577592 canModerate?: boolean;
578593 /** Why editing or deleting a comment was refused, under that comment. */
579594 failed?: { comment: string; error?: string | null } | null;
595+ /** The signed-in viewer's username, who may react; none to only show reactions. */
596+ viewerName?: string | null;
597+ extra?: TimelineExtra[];
580598 }) {
599+ // Each extra goes before the first comment written after it.
600+ const later = [...extra].sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
601+ const before = (at: string) => {
602+ const due: TimelineExtra[] = [];
603+ while (later.length > 0 && Date.parse(later[0].at) <= Date.parse(at)) due.push(later.shift()!);
604+ return due.map((one) => <Fragment key={one.id}>{one.node}</Fragment>);
605+ };
581606 return (
582607 <>
583− {comments.map((comment) => {
608+ {comments.map((comment) => (
609+ <Fragment key={comment.id}>
610+ {before(comment.createdAt)}
611+ {entry(comment)}
612+ </Fragment>
613+ ))}
614+ {later.map((one) => (
615+ <Fragment key={one.id}>{one.node}</Fragment>
616+ ))}
617+ </>
618+ );
619+
620+ function entry(comment: Comment) {
584621 if (comment.kind === "event") {
585622 return <TimelineEvent key={comment.id} comment={comment} base={base} filesUrl={review?.changesUrl} />;
586623 }
593630 return (
594631 <TimelineItem
595632 key={comment.id}
633+ id={comment.id}
596634 author={comment.author.username}
597635 agent={
598636 comment.agent && {
626664 }
627665 >
628666 {comment.body && <Markdown source={comment.body} repo={repoAt(base)} />}
667+ <Reactions reactions={comment.reactions} commentId={comment.id} viewer={viewerName} className="mt-3" />
629668 {(can.edit || can.delete) && (
630669 <CommentActions comment={comment} can={can} error={failed?.comment === comment.id ? failed.error : null} />
631670 )}
632671 </TimelineItem>
633672 );
634− })}
635− </>
636− );
673+ }
637674 }
638675
639676 /** Where a signed-in person writes a comment, or a review. */
+43−6
11 import { Bot, ExternalLink, GitCommitHorizontal, GitMerge } from "lucide-react";
22 import { Form, Link, redirect } from "react-router";
33
4−import { AGENT_MODES, type Pull, PROVIDERS, workOwner } from "@g1t/contracts";
4+import { AGENT_MODES, type Pull, PROVIDERS, REACTIONS, workOwner } from "@g1t/contracts";
55
66 import type { Route } from "./+types/issue";
77 import { excerpt, page } from "../../lib/meta";
3939 import { DetailsDisclosure, detailsSummary } from "../../components/details-disclosure";
4040 import { repoPages } from "../../lib/repo-live";
4141 import { useRepoLive } from "../../lib/use-repo-live";
42−import { DecisionList, IntegrationFailure, IssueWorkPanel, StartSessionDialog } from "../../components/coordination";
42+import { DecisionList, IntegrationFailure, IssueWorkPanel, StartSessionDialog, TimelineSession } from "../../components/coordination";
43+import { Reactions } from "../../components/reactions";
4344 import { parseBlockedBy, resolutionOf } from "../../lib/coordination";
4445
4546
173174 });
174175 return result.ok ? null : { error: result.error.message };
175176 }
177+ // A reaction to the issue or one of its comments, or taking one back.
178+ case "react": {
179+ const content = REACTIONS.find((one) => one.content === form.get("content"))?.content;
180+ if (!content) return { error: "Pick a reaction.", action: "react" };
181+ const result = await work.react(user, path, number, {
182+ commentId: String(form.get("comment") ?? "") || undefined,
183+ content,
184+ remove: form.get("remove") === "1",
185+ });
186+ return result.ok ? null : { error: result.error.message, action: "react" };
187+ }
176188 case "assign": {
177189 const result = await work.updateIssue(user, path, number, {
178190 assignees: [
383395 </Link>
384396 )}
385397
386− {issue.body && (
398+ {issue.body ? (
387399 <div className={`${issue.resolvedBy != null ? "mt-5" : "lg:mt-5"} rounded-xl border border-line bg-surface p-4 sm:p-5`}>
388400 <Markdown source={issue.body} repo={{ namespace: params.owner, name: params.repo }} />
401+ <Reactions reactions={loaderData.reactions} viewer={viewer?.username ?? null} className="mt-4" />
389402 </div>
403+ ) : (
404+ (loaderData.reactions?.length ?? 0) > 0 && (
405+ <Reactions reactions={loaderData.reactions} viewer={viewer?.username ?? null} className="mt-3 lg:mt-5" />
406+ )
390407 )}
391408
392409 <div className="mt-10 flex items-baseline justify-between">
437454 comments={comments}
438455 base={base}
439456 viewerId={viewer?.id ?? null}
457+ viewerName={viewer?.username ?? null}
458+ extra={(loaderData.sessionTrees ?? [])
459+ .filter((session) => !session.parentId)
460+ .map((session) => ({ id: session.id, at: session.startedAt, node: <TimelineSession session={session} base={base} /> }))}
440461 canModerate={loaderData.canModerate}
441462 failed={actionData && "comment" in actionData ? { comment: String(actionData.comment), error: actionData.error } : null}
442463 />
463484 </div>
464485 <div className="mt-2">
465486 {/* A refused comment edit says so under the comment. */}
466− {!(actionData && ("comment" in actionData || ("action" in actionData && (actionData.action === "blocked-by" || actionData.action === "start-session")))) && (
487+ {!(actionData && ("comment" in actionData || ("action" in actionData && (actionData.action === "blocked-by" || actionData.action === "start-session" || actionData.action === "react")))) && (
467488 <ErrorText>{actionData?.error}</ErrorText>
468489 )}
469490 </div>
530551 <ul className="mt-2 space-y-1.5 text-sm">
531552 {issue.assignees.map((name) => (
532553 <li key={name} className="flex items-center gap-2 px-1">
533− <Avatar name={name} size={20} />
554+ <Avatar name={name} size={20} agent={name === "g1t"} />
534555 <span className="grow truncate font-mono text-xs">
535− <PersonLink name={name} className="hover:underline" />
556+ {name === "g1t" ? "g1t" : <PersonLink name={name} className="hover:underline" />}
536557 </span>
537558 </li>
538559 ))}
557578
558579 {viewer && can.triage && open && (
559580 <div className="mt-3 space-y-2">
581+ {/* g1t takes it on in a session on the workspace's Claude, as whoever assigned it. */}
582+ {loaderData.agentSetup && !issue.assignees.includes("g1t") && (
583+ <Form method="post">
584+ <input type="hidden" name="action" value="assign" />
585+ {issue.assignees.map((name) => (
586+ <input key={name} type="hidden" name="assignee" value={name} />
587+ ))}
588+ <input type="hidden" name="assignee" value="g1t" />
589+ <div className="*:w-full">
590+ <SubmitButton variant="outline" name="who" value="g1t" pending="Assigning…">
591+ <Bot size={14} />
592+ Assign to g1t
593+ </SubmitButton>
594+ </div>
595+ </Form>
596+ )}
560597 {!issue.assignees.includes(viewer.username) && (
561598 <Form method="post">
562599 <input type="hidden" name="action" value="assign" />
+27−4
4040 type Viewer,
4141 fail,
4242 pullComparison,
43+ REACTIONS,
4344 workOwner,
4445 } from "@g1t/contracts";
4546
6970 import { CheckboxOption } from "../../components/ui/checkbox";
7071 import { Hint } from "../../components/ui/hint";
7172 import { TabStrip } from "../../components/ui/tab-strip";
72−import { DecisionList, IntegrationFailure, SessionTreeList } from "../../components/coordination";
73+import { DecisionList, IntegrationFailure, SessionTreeList, TimelineSession } from "../../components/coordination";
74+import { Reactions } from "../../components/reactions";
7375 import { resolutionOf, sessionTree, standingFailure } from "../../lib/coordination";
7476 import { WorkflowStatuses } from "../../components/actions";
7577 import { AddCiPrompt } from "../../components/add-ci";
180182 // Its decision records: what the merge queue found when it was combined
181183 // with others, and the reasons kept on it (decisions.rs).
182184 tab !== "changes" ? work.listDecisions(path, number, viewer).catch(() => null) : null,
183− // The harness sessions tied to it in any way, with their subagents, for the Session tab.
184− tab === "session" ? work.listWorkSessions(path, viewer, { pull: number, subagents: true }).catch(() => null) : null,
185+ // The harness sessions tied to it in any way: with their subagents for
186+ // the Session tab, and in the conversation where each started.
187+ tab === "session" || tab === "conversation"
188+ ? work.listWorkSessions(path, viewer, { pull: number, subagents: tab === "session" }).catch(() => null)
189+ : null,
185190 ]);
186191 if (!found.ok) {
187192 // Issues and pull requests share numbers; this one may be an issue.
438443 ...String(form.get("others") ?? "").split(/[\s,]+/).map((name) => name.replace(/^@/, "")),
439444 ];
440445 // Its labels, milestone and base, from the sidebar's menus and the header.
446+ // A reaction to the pull request or one of its comments, or taking one back.
447+ if (action === "react") {
448+ const content = REACTIONS.find((one) => one.content === form.get("content"))?.content;
449+ if (!content) return { error: "Pick a reaction.", action };
450+ const reacted = await work.react(user, path, number, {
451+ commentId: String(form.get("comment") ?? "") || undefined,
452+ content,
453+ remove: form.get("remove") === "1",
454+ });
455+ return reacted.ok ? null : { error: reacted.error.message, action };
456+ }
441457 if (action === "labels") {
442458 const set = await work.setLabels(user, path, number, form.getAll("label").map(String));
443459 return set.ok ? null : { error: set.error.message, action };
11671183 }
11681184 >
11691185 {pull.body ? (
1170− <Markdown source={pull.body} repo={{ namespace: params.owner, name: params.repo }} />
1186+ <>
1187+ <Markdown source={pull.body} repo={{ namespace: params.owner, name: params.repo }} />
1188+ <Reactions reactions={loaderData.reactions} viewer={viewer?.username ?? null} className="mt-3" />
1189+ </>
11711190 ) : (
11721191 <p className="text-sm text-muted">
11731192 {working
12011220 review={review}
12021221 base={base}
12031222 viewerId={viewer?.id ?? null}
1223+ viewerName={viewer?.username ?? null}
1224+ extra={harnessSessions
1225+ .filter((session) => !session.parentId)
1226+ .map((session) => ({ id: session.id, at: session.startedAt, node: <TimelineSession session={session} base={base} /> }))}
12041227 canModerate={canModerate}
12051228 failed={actionData && "comment" in actionData ? { comment: String(actionData.comment), error: actionData.error } : null}
12061229 />
+25−2
4949 /// The most characters of an instruction kept.
5050 pub const MAX_INSTRUCTION_CHARS: usize = 4_000;
5151
52+/// Who asked g1t in an issue or a pull request, rather than from a
53+/// button: by assigning it the issue, or by mentioning `@g1t` in a comment.
54+#[derive(Clone, Debug, Default, Serialize, Deserialize)]
55+pub struct Asked {
56+ /// Their username.
57+ pub by: String,
58+ /// What their comment said; none for an assignment.
59+ #[serde(default)]
60+ pub comment: Option<String>,
61+ /// Where they asked: the comment, or the issue.
62+ pub url: String,
63+}
64+
5265 /// `start_agent_session`: work on `issue` (or a new issue from `title` and
53−/// `body`), as `actor`. Returns `Outcome<AgentSessionStarted>`.
66+/// `body`), or carry on with pull request `pull`, as `actor`. Returns
67+/// `Outcome<AgentSessionStarted>`.
5468 #[derive(Debug, Serialize, Deserialize)]
5569 pub struct StartAgentSessionArgs {
5670 pub actor: User,
5771 pub repo: RepoPath,
5872 #[serde(default)]
5973 pub issue: Option<u32>,
74+ /// A pull request to carry on with, on its branch: in place of `issue`.
75+ #[serde(default)]
76+ pub pull: Option<u32>,
77+ /// Set when g1t was asked in the thread (integrations' summon.rs).
78+ #[serde(default)]
79+ pub asked: Option<Asked>,
6080 /// Without `issue`: a new issue's title, opened first.
6181 #[serde(default)]
6282 pub title: Option<String>,
7898 #[derive(Clone, Debug, Default, Serialize, Deserialize)]
7999 #[serde(rename_all = "camelCase")]
80100 pub struct AgentSessionStarted {
81− /// The issue it works on.
101+ /// The issue it works on: for a pull request with none, its number.
82102 pub issue: u32,
103+ /// The pull request it carries on with, when it was started on one.
104+ #[serde(default)]
105+ pub pull: Option<u32>,
83106 /// The session on g1t, when one was started here.
84107 #[serde(default)]
85108 pub session: Option<Session>,
+1−0
11341134 ("add_comment", Scope::IssuesWrite),
11351135 ("edit_comment", Scope::IssuesWrite),
11361136 ("delete_comment", Scope::IssuesWrite),
1137+ ("react", Scope::IssuesWrite),
11371138 ("create_commit_comment", Scope::IssuesWrite),
11381139 ("update_commit_comment", Scope::IssuesWrite),
11391140 ("delete_commit_comment", Scope::IssuesWrite),
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…

Comments

Sign in to comment.