@oh-my-pi/pi-coding-agent 13.17.1 → 13.17.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/CHANGELOG.md +93 -3
  2. package/package.json +10 -8
  3. package/scripts/format-prompts.ts +3 -3
  4. package/src/cli/plugin-cli.ts +114 -25
  5. package/src/commands/plugin.ts +5 -0
  6. package/src/commit/agentic/prompts/analyze-file.md +1 -1
  7. package/src/commit/agentic/prompts/session-user.md +1 -1
  8. package/src/commit/agentic/prompts/split-confirm.md +1 -1
  9. package/src/commit/agentic/prompts/system.md +1 -1
  10. package/src/commit/prompts/analysis-system.md +1 -1
  11. package/src/commit/prompts/analysis-user.md +1 -1
  12. package/src/commit/prompts/changelog-system.md +1 -1
  13. package/src/commit/prompts/changelog-user.md +1 -1
  14. package/src/commit/prompts/file-observer-system.md +1 -1
  15. package/src/commit/prompts/file-observer-user.md +1 -1
  16. package/src/commit/prompts/reduce-system.md +1 -1
  17. package/src/commit/prompts/reduce-user.md +1 -1
  18. package/src/commit/prompts/summary-retry.md +1 -1
  19. package/src/commit/prompts/summary-system.md +1 -1
  20. package/src/commit/prompts/summary-user.md +1 -1
  21. package/src/commit/prompts/types-description.md +1 -1
  22. package/src/config/settings-schema.ts +16 -0
  23. package/src/discovery/claude-plugins.ts +5 -5
  24. package/src/discovery/helpers.ts +144 -24
  25. package/src/extensibility/custom-commands/bundled/ci-green/index.ts +37 -0
  26. package/src/extensibility/custom-commands/loader.ts +7 -0
  27. package/src/extensibility/plugins/marketplace/fetcher.ts +55 -37
  28. package/src/extensibility/plugins/marketplace/manager.ts +296 -73
  29. package/src/extensibility/plugins/marketplace/registry.ts +15 -0
  30. package/src/extensibility/plugins/marketplace/types.ts +17 -3
  31. package/src/internal-urls/docs-index.generated.ts +2 -1
  32. package/src/main.ts +13 -4
  33. package/src/modes/components/assistant-message.ts +2 -1
  34. package/src/modes/components/plugin-selector.ts +20 -11
  35. package/src/modes/components/tool-execution.ts +2 -3
  36. package/src/modes/controllers/command-controller.ts +19 -7
  37. package/src/modes/controllers/selector-controller.ts +7 -4
  38. package/src/modes/interactive-mode.ts +4 -0
  39. package/src/modes/types.ts +1 -0
  40. package/src/modes/utils/tools-markdown.ts +27 -0
  41. package/src/prompts/agents/designer.md +1 -1
  42. package/src/prompts/agents/explore.md +1 -1
  43. package/src/prompts/agents/frontmatter.md +1 -1
  44. package/src/prompts/agents/init.md +1 -1
  45. package/src/prompts/agents/librarian.md +1 -1
  46. package/src/prompts/agents/oracle.md +1 -1
  47. package/src/prompts/agents/plan.md +1 -1
  48. package/src/prompts/agents/reviewer.md +1 -1
  49. package/src/prompts/agents/task.md +1 -1
  50. package/src/prompts/ci-green-request.md +36 -0
  51. package/src/prompts/compaction/branch-summary-context.md +1 -1
  52. package/src/prompts/compaction/branch-summary-preamble.md +1 -1
  53. package/src/prompts/compaction/branch-summary.md +1 -1
  54. package/src/prompts/compaction/compaction-short-summary.md +1 -1
  55. package/src/prompts/compaction/compaction-summary-context.md +1 -1
  56. package/src/prompts/compaction/compaction-summary.md +1 -1
  57. package/src/prompts/compaction/compaction-turn-prefix.md +1 -1
  58. package/src/prompts/compaction/compaction-update-summary.md +1 -1
  59. package/src/prompts/memories/consolidation.md +1 -1
  60. package/src/prompts/memories/read-path.md +1 -1
  61. package/src/prompts/memories/stage_one_input.md +1 -1
  62. package/src/prompts/memories/stage_one_system.md +1 -1
  63. package/src/prompts/review-request.md +1 -1
  64. package/src/prompts/system/agent-creation-architect.md +1 -1
  65. package/src/prompts/system/agent-creation-user.md +1 -1
  66. package/src/prompts/system/auto-handoff-threshold-focus.md +1 -1
  67. package/src/prompts/system/btw-user.md +1 -1
  68. package/src/prompts/system/commit-message-system.md +1 -1
  69. package/src/prompts/system/custom-system-prompt.md +1 -1
  70. package/src/prompts/system/eager-todo.md +1 -1
  71. package/src/prompts/system/file-operations.md +1 -1
  72. package/src/prompts/system/handoff-document.md +1 -1
  73. package/src/prompts/system/plan-mode-active.md +1 -1
  74. package/src/prompts/system/plan-mode-approved.md +1 -1
  75. package/src/prompts/system/plan-mode-reference.md +1 -1
  76. package/src/prompts/system/plan-mode-subagent.md +1 -1
  77. package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
  78. package/src/prompts/system/subagent-submit-reminder.md +1 -1
  79. package/src/prompts/system/subagent-system-prompt.md +1 -1
  80. package/src/prompts/system/subagent-user-prompt.md +1 -1
  81. package/src/prompts/system/summarization-system.md +1 -1
  82. package/src/prompts/system/system-prompt.md +1 -1
  83. package/src/prompts/system/title-system.md +1 -1
  84. package/src/prompts/system/ttsr-interrupt.md +1 -1
  85. package/src/prompts/system/web-search.md +1 -1
  86. package/src/prompts/tools/ask.md +1 -1
  87. package/src/prompts/tools/ast-edit.md +1 -1
  88. package/src/prompts/tools/ast-grep.md +1 -1
  89. package/src/prompts/tools/async-result.md +1 -1
  90. package/src/prompts/tools/await.md +1 -1
  91. package/src/prompts/tools/bash.md +1 -1
  92. package/src/prompts/tools/browser.md +1 -1
  93. package/src/prompts/tools/calculator.md +1 -1
  94. package/src/prompts/tools/cancel-job.md +1 -1
  95. package/src/prompts/tools/checkpoint.md +1 -1
  96. package/src/prompts/tools/exit-plan-mode.md +1 -1
  97. package/src/prompts/tools/fetch.md +1 -1
  98. package/src/prompts/tools/find.md +1 -1
  99. package/src/prompts/tools/gemini-image.md +1 -1
  100. package/src/prompts/tools/gh-issue-view.md +11 -0
  101. package/src/prompts/tools/gh-pr-checkout.md +12 -0
  102. package/src/prompts/tools/gh-pr-diff.md +12 -0
  103. package/src/prompts/tools/gh-pr-push.md +11 -0
  104. package/src/prompts/tools/gh-pr-view.md +11 -0
  105. package/src/prompts/tools/gh-repo-view.md +11 -0
  106. package/src/prompts/tools/gh-run-watch.md +12 -0
  107. package/src/prompts/tools/gh-search-issues.md +11 -0
  108. package/src/prompts/tools/gh-search-prs.md +11 -0
  109. package/src/prompts/tools/grep.md +1 -1
  110. package/src/prompts/tools/hashline.md +1 -1
  111. package/src/prompts/tools/inspect-image-system.md +1 -1
  112. package/src/prompts/tools/inspect-image.md +1 -1
  113. package/src/prompts/tools/lsp.md +1 -1
  114. package/src/prompts/tools/patch.md +1 -1
  115. package/src/prompts/tools/python.md +1 -1
  116. package/src/prompts/tools/read.md +6 -3
  117. package/src/prompts/tools/render-mermaid.md +1 -1
  118. package/src/prompts/tools/replace.md +1 -1
  119. package/src/prompts/tools/resolve.md +1 -1
  120. package/src/prompts/tools/rewind.md +1 -1
  121. package/src/prompts/tools/search-tool-bm25.md +1 -1
  122. package/src/prompts/tools/ssh.md +1 -1
  123. package/src/prompts/tools/task-summary.md +1 -1
  124. package/src/prompts/tools/task.md +1 -1
  125. package/src/prompts/tools/todo-write.md +1 -1
  126. package/src/prompts/tools/web-search.md +1 -1
  127. package/src/prompts/tools/write.md +2 -1
  128. package/src/sdk.ts +3 -1
  129. package/src/session/messages.ts +11 -7
  130. package/src/session/session-manager.ts +13 -3
  131. package/src/slash-commands/builtin-registry.ts +109 -37
  132. package/src/slash-commands/marketplace-install-parser.ts +99 -0
  133. package/src/task/discovery.ts +1 -1
  134. package/src/tools/archive-reader.ts +315 -0
  135. package/src/tools/fetch.ts +21 -19
  136. package/src/tools/gh-cli.ts +125 -0
  137. package/src/tools/gh-renderer.ts +305 -0
  138. package/src/tools/gh.ts +2719 -0
  139. package/src/tools/index.ts +22 -0
  140. package/src/tools/read.ts +286 -34
  141. package/src/tools/render-utils.ts +20 -0
  142. package/src/tools/renderers.ts +2 -0
  143. package/src/tools/write.ts +175 -4
  144. package/src/utils/markit.ts +81 -0
  145. package/src/utils/tools-manager.ts +1 -6
  146. package/src/web/scrapers/arxiv.ts +3 -3
  147. package/src/web/scrapers/iacr.ts +3 -3
  148. package/src/web/scrapers/utils.ts +6 -34
@@ -0,0 +1,2719 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
4
+ import { abortableSleep, isEnoent, untilAborted } from "@oh-my-pi/pi-utils";
5
+ import { type Static, Type } from "@sinclair/typebox";
6
+ import { $ } from "bun";
7
+ import { renderPromptTemplate } from "../config/prompt-templates";
8
+ import ghIssueViewDescription from "../prompts/tools/gh-issue-view.md" with { type: "text" };
9
+ import ghPrCheckoutDescription from "../prompts/tools/gh-pr-checkout.md" with { type: "text" };
10
+ import ghPrDiffDescription from "../prompts/tools/gh-pr-diff.md" with { type: "text" };
11
+ import ghPrPushDescription from "../prompts/tools/gh-pr-push.md" with { type: "text" };
12
+ import ghPrViewDescription from "../prompts/tools/gh-pr-view.md" with { type: "text" };
13
+ import ghRepoViewDescription from "../prompts/tools/gh-repo-view.md" with { type: "text" };
14
+ import ghRunWatchDescription from "../prompts/tools/gh-run-watch.md" with { type: "text" };
15
+ import ghSearchIssuesDescription from "../prompts/tools/gh-search-issues.md" with { type: "text" };
16
+ import ghSearchPrsDescription from "../prompts/tools/gh-search-prs.md" with { type: "text" };
17
+ import type { ToolSession } from ".";
18
+ import { isGhAvailable, runGhCommand, runGhJson, runGhText } from "./gh-cli";
19
+ import type { OutputMeta } from "./output-meta";
20
+ import { ToolError, throwIfAborted } from "./tool-errors";
21
+ import { toolResult } from "./tool-result";
22
+
23
+ const GH_REPO_FIELDS = [
24
+ "nameWithOwner",
25
+ "description",
26
+ "url",
27
+ "defaultBranchRef",
28
+ "homepageUrl",
29
+ "forkCount",
30
+ "isArchived",
31
+ "isFork",
32
+ "primaryLanguage",
33
+ "repositoryTopics",
34
+ "stargazerCount",
35
+ "updatedAt",
36
+ "viewerPermission",
37
+ "visibility",
38
+ ];
39
+ const GH_ISSUE_FIELDS = [
40
+ "author",
41
+ "body",
42
+ "comments",
43
+ "createdAt",
44
+ "labels",
45
+ "number",
46
+ "state",
47
+ "stateReason",
48
+ "title",
49
+ "updatedAt",
50
+ "url",
51
+ ];
52
+ const GH_ISSUE_FIELDS_NO_COMMENTS = [
53
+ "author",
54
+ "body",
55
+ "createdAt",
56
+ "labels",
57
+ "number",
58
+ "state",
59
+ "stateReason",
60
+ "title",
61
+ "updatedAt",
62
+ "url",
63
+ ];
64
+ const GH_PR_FIELDS = [
65
+ "author",
66
+ "baseRefName",
67
+ "body",
68
+ "comments",
69
+ "createdAt",
70
+ "files",
71
+ "headRefName",
72
+ "isDraft",
73
+ "labels",
74
+ "mergeStateStatus",
75
+ "number",
76
+ "reviewDecision",
77
+ "state",
78
+ "title",
79
+ "updatedAt",
80
+ "url",
81
+ ];
82
+ const GH_PR_FIELDS_NO_COMMENTS = [
83
+ "author",
84
+ "baseRefName",
85
+ "body",
86
+ "createdAt",
87
+ "files",
88
+ "headRefName",
89
+ "isDraft",
90
+ "labels",
91
+ "mergeStateStatus",
92
+ "number",
93
+ "reviews",
94
+ "reviewDecision",
95
+ "state",
96
+ "title",
97
+ "updatedAt",
98
+ "url",
99
+ ];
100
+ const GH_REPO_CLONE_FIELDS = ["nameWithOwner", "sshUrl", "url"];
101
+ const GH_PR_CHECKOUT_FIELDS = [
102
+ "baseRefName",
103
+ "headRefName",
104
+ "headRefOid",
105
+ "headRepository",
106
+ "headRepositoryOwner",
107
+ "isCrossRepository",
108
+ "maintainerCanModify",
109
+ "number",
110
+ "title",
111
+ "url",
112
+ ];
113
+ const GH_SEARCH_FIELDS = [
114
+ "author",
115
+ "createdAt",
116
+ "labels",
117
+ "number",
118
+ "repository",
119
+ "state",
120
+ "title",
121
+ "updatedAt",
122
+ "url",
123
+ ];
124
+ const SEARCH_LIMIT_DEFAULT = 10;
125
+ const SEARCH_LIMIT_MAX = 50;
126
+ const FILE_PREVIEW_LIMIT = 50;
127
+ const RUN_WATCH_INTERVAL_DEFAULT = 3;
128
+ const RUN_WATCH_GRACE_DEFAULT = 5;
129
+ const RUN_WATCH_TAIL_DEFAULT = 15;
130
+ const RUN_WATCH_TAIL_MAX = 200;
131
+ const REVIEW_COMMENTS_PAGE_SIZE = 100;
132
+ const RUN_JOBS_PAGE_SIZE = 100;
133
+ const PR_URL_PATTERN = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/.*)?$/;
134
+ const RUN_URL_PATTERN = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/actions\/runs\/(\d+)(?:\/.*)?$/;
135
+ const RUN_SUCCESS_CONCLUSIONS = new Set(["success", "neutral", "skipped"]);
136
+ const RUN_FAILURE_CONCLUSIONS = new Set(["failure", "timed_out", "cancelled", "action_required", "startup_failure"]);
137
+ const JOB_FAILURE_CONCLUSIONS = new Set(["failure", "timed_out", "cancelled", "action_required"]);
138
+
139
+ const ghRepoViewSchema = Type.Object({
140
+ repo: Type.Optional(
141
+ Type.String({
142
+ description: "Repository in OWNER/REPO format. Defaults to the current GitHub repository context.",
143
+ }),
144
+ ),
145
+ branch: Type.Optional(Type.String({ description: "Branch name to inspect instead of the default branch." })),
146
+ });
147
+
148
+ const ghIssueViewSchema = Type.Object({
149
+ issue: Type.String({ description: "Issue number or full GitHub issue URL." }),
150
+ repo: Type.Optional(
151
+ Type.String({ description: "Repository in OWNER/REPO format. Omit when passing a full issue URL." }),
152
+ ),
153
+ comments: Type.Optional(Type.Boolean({ description: "Include issue comments (default: true)." })),
154
+ });
155
+
156
+ const ghPrViewSchema = Type.Object({
157
+ pr: Type.Optional(
158
+ Type.String({
159
+ description:
160
+ "Pull request number, full GitHub pull request URL, or branch name. Defaults to the current branch PR.",
161
+ }),
162
+ ),
163
+ repo: Type.Optional(
164
+ Type.String({ description: "Repository in OWNER/REPO format. Omit when passing a full pull request URL." }),
165
+ ),
166
+ comments: Type.Optional(Type.Boolean({ description: "Include pull request comments (default: true)." })),
167
+ });
168
+
169
+ const ghPrDiffSchema = Type.Object({
170
+ pr: Type.Optional(
171
+ Type.String({
172
+ description:
173
+ "Pull request number, full GitHub pull request URL, or branch name. Defaults to the current branch PR.",
174
+ }),
175
+ ),
176
+ repo: Type.Optional(
177
+ Type.String({ description: "Repository in OWNER/REPO format. Omit when passing a full pull request URL." }),
178
+ ),
179
+ nameOnly: Type.Optional(
180
+ Type.Boolean({ description: "Return only changed file names instead of unified diff output." }),
181
+ ),
182
+ exclude: Type.Optional(
183
+ Type.Array(Type.String({ description: "Glob pattern for files to exclude from the diff." }), {
184
+ description: "File globs to exclude from the diff output.",
185
+ }),
186
+ ),
187
+ });
188
+
189
+ const ghPrCheckoutSchema = Type.Object({
190
+ pr: Type.Optional(
191
+ Type.String({
192
+ description:
193
+ "Pull request number, full GitHub pull request URL, or branch name. Defaults to the current branch PR.",
194
+ }),
195
+ ),
196
+ repo: Type.Optional(
197
+ Type.String({ description: "Repository in OWNER/REPO format. Omit when passing a full pull request URL." }),
198
+ ),
199
+ branch: Type.Optional(Type.String({ description: "Local branch name to create or reuse (default: pr-<number>)." })),
200
+ worktree: Type.Optional(
201
+ Type.String({ description: "Worktree path to create. Defaults to <repo>/.worktrees/<branch>." }),
202
+ ),
203
+ force: Type.Optional(
204
+ Type.Boolean({
205
+ description: "Reset an existing local branch to the PR head when it is not already checked out elsewhere.",
206
+ }),
207
+ ),
208
+ });
209
+
210
+ const ghPrPushSchema = Type.Object({
211
+ branch: Type.Optional(
212
+ Type.String({
213
+ description: "Local branch name to push. Defaults to the current checked-out git branch.",
214
+ }),
215
+ ),
216
+ forceWithLease: Type.Optional(Type.Boolean({ description: "Use --force-with-lease when pushing the PR branch." })),
217
+ });
218
+
219
+ const ghSearchIssuesSchema = Type.Object({
220
+ query: Type.String({ description: "GitHub issue search query. Supports GitHub search syntax." }),
221
+ repo: Type.Optional(Type.String({ description: "Repository in OWNER/REPO format to scope the search." })),
222
+ limit: Type.Optional(Type.Number({ description: "Maximum results to return (default: 10, max: 50)." })),
223
+ });
224
+
225
+ const ghSearchPrsSchema = Type.Object({
226
+ query: Type.String({ description: "GitHub pull request search query. Supports GitHub search syntax." }),
227
+ repo: Type.Optional(Type.String({ description: "Repository in OWNER/REPO format to scope the search." })),
228
+ limit: Type.Optional(Type.Number({ description: "Maximum results to return (default: 10, max: 50)." })),
229
+ });
230
+
231
+ const ghRunWatchSchema = Type.Object({
232
+ run: Type.Optional(
233
+ Type.String({
234
+ description:
235
+ "GitHub Actions run ID or full run URL. Omitting this watches the workflow runs for the current HEAD commit on the selected branch.",
236
+ }),
237
+ ),
238
+ branch: Type.Optional(
239
+ Type.String({
240
+ description: "Branch to inspect when omitting `run`. Defaults to the current checked-out git branch.",
241
+ }),
242
+ ),
243
+ tail: Type.Optional(
244
+ Type.Number({ description: "Number of log lines to include per failed job (default: 15, max: 200)." }),
245
+ ),
246
+ });
247
+
248
+ type GhRepoViewInput = Static<typeof ghRepoViewSchema>;
249
+ type GhIssueViewInput = Static<typeof ghIssueViewSchema>;
250
+ type GhPrViewInput = Static<typeof ghPrViewSchema>;
251
+ type GhPrDiffInput = Static<typeof ghPrDiffSchema>;
252
+ type GhPrCheckoutInput = Static<typeof ghPrCheckoutSchema>;
253
+ type GhPrPushInput = Static<typeof ghPrPushSchema>;
254
+ type GhSearchIssuesInput = Static<typeof ghSearchIssuesSchema>;
255
+ type GhSearchPrsInput = Static<typeof ghSearchPrsSchema>;
256
+ type GhRunWatchInput = Static<typeof ghRunWatchSchema>;
257
+
258
+ export interface GhToolDetails {
259
+ meta?: OutputMeta;
260
+ artifactId?: string;
261
+ repo?: string;
262
+ branch?: string;
263
+ worktreePath?: string;
264
+ remote?: string;
265
+ remoteBranch?: string;
266
+ headSha?: string;
267
+ runId?: number;
268
+ runIds?: number[];
269
+ status?: string;
270
+ conclusion?: string;
271
+ failedJobs?: string[];
272
+ watch?: GhRunWatchViewDetails;
273
+ }
274
+
275
+ export interface GhRunWatchJobDetails {
276
+ id: number;
277
+ name: string;
278
+ status?: string;
279
+ conclusion?: string;
280
+ durationSeconds?: number;
281
+ url?: string;
282
+ }
283
+
284
+ export interface GhRunWatchRunDetails {
285
+ id: number;
286
+ workflowName?: string;
287
+ displayTitle?: string;
288
+ status?: string;
289
+ conclusion?: string;
290
+ branch?: string;
291
+ headSha?: string;
292
+ url?: string;
293
+ jobs: GhRunWatchJobDetails[];
294
+ }
295
+
296
+ export interface GhRunWatchFailedLogDetails {
297
+ runId: number;
298
+ workflowName?: string;
299
+ jobName: string;
300
+ conclusion?: string;
301
+ tail?: string;
302
+ available: boolean;
303
+ }
304
+
305
+ export interface GhRunWatchViewDetails {
306
+ mode: "run" | "commit";
307
+ state: "watching" | "completed";
308
+ repo: string;
309
+ branch?: string;
310
+ headSha?: string;
311
+ pollCount?: number;
312
+ note?: string;
313
+ run?: GhRunWatchRunDetails;
314
+ runs?: GhRunWatchRunDetails[];
315
+ failedLogs?: GhRunWatchFailedLogDetails[];
316
+ }
317
+
318
+ interface GhUser {
319
+ login?: string;
320
+ name?: string | null;
321
+ }
322
+
323
+ interface GhLabel {
324
+ name?: string;
325
+ }
326
+
327
+ interface GhComment {
328
+ author?: GhUser | null;
329
+ body?: string;
330
+ createdAt?: string;
331
+ url?: string;
332
+ isMinimized?: boolean;
333
+ minimizedReason?: string | null;
334
+ }
335
+
336
+ interface GhRepoTopic {
337
+ name?: string;
338
+ topic?: { name?: string };
339
+ }
340
+
341
+ interface GhRepoLanguage {
342
+ name?: string;
343
+ }
344
+
345
+ interface GhRepoBranch {
346
+ name?: string;
347
+ }
348
+
349
+ interface GhRepoViewData {
350
+ nameWithOwner?: string;
351
+ description?: string | null;
352
+ url?: string;
353
+ sshUrl?: string;
354
+ defaultBranchRef?: GhRepoBranch | null;
355
+ homepageUrl?: string | null;
356
+ forkCount?: number;
357
+ isArchived?: boolean;
358
+ isFork?: boolean;
359
+ primaryLanguage?: GhRepoLanguage | null;
360
+ repositoryTopics?: GhRepoTopic[];
361
+ stargazerCount?: number;
362
+ updatedAt?: string;
363
+ viewerPermission?: string | null;
364
+ visibility?: string | null;
365
+ }
366
+
367
+ interface GhIssueViewData {
368
+ author?: GhUser | null;
369
+ body?: string | null;
370
+ comments?: GhComment[];
371
+ createdAt?: string;
372
+ labels?: GhLabel[];
373
+ number?: number;
374
+ state?: string;
375
+ stateReason?: string | null;
376
+ title?: string;
377
+ updatedAt?: string;
378
+ url?: string;
379
+ }
380
+
381
+ interface GhPrFile {
382
+ path?: string;
383
+ additions?: number;
384
+ deletions?: number;
385
+ changeType?: string;
386
+ }
387
+
388
+ interface GhPrViewData extends GhIssueViewData {
389
+ baseRefName?: string;
390
+ files?: GhPrFile[];
391
+ headRefName?: string;
392
+ headRefOid?: string;
393
+ headRepository?: GhRepoViewData | null;
394
+ headRepositoryOwner?: GhUser | null;
395
+ isCrossRepository?: boolean;
396
+ isDraft?: boolean;
397
+ maintainerCanModify?: boolean;
398
+ mergeStateStatus?: string;
399
+ reviewComments?: GhPrReviewComment[];
400
+ reviews?: GhPrReview[];
401
+ reviewDecision?: string;
402
+ }
403
+
404
+ interface GitCommandResult {
405
+ exitCode: number;
406
+ stdout: string;
407
+ stderr: string;
408
+ }
409
+
410
+ interface GitWorktreeEntry {
411
+ path: string;
412
+ head?: string;
413
+ branch?: string;
414
+ detached: boolean;
415
+ }
416
+
417
+ interface GhPrReviewCommit {
418
+ oid?: string | null;
419
+ }
420
+
421
+ interface GhPrReview {
422
+ author?: GhUser | null;
423
+ body?: string | null;
424
+ commit?: GhPrReviewCommit | null;
425
+ state?: string | null;
426
+ submittedAt?: string | null;
427
+ }
428
+
429
+ interface GhPrReviewCommentApi {
430
+ body?: string | null;
431
+ created_at?: string | null;
432
+ html_url?: string | null;
433
+ id?: number;
434
+ in_reply_to_id?: number | null;
435
+ line?: number | null;
436
+ original_line?: number | null;
437
+ path?: string | null;
438
+ side?: string | null;
439
+ user?: GhUser | null;
440
+ }
441
+
442
+ interface GhPrReviewComment {
443
+ author?: GhUser | null;
444
+ body?: string | null;
445
+ createdAt?: string;
446
+ id: number;
447
+ inReplyToId?: number;
448
+ line?: number;
449
+ originalLine?: number;
450
+ path?: string;
451
+ side?: string;
452
+ url?: string;
453
+ }
454
+
455
+ interface GhBranchApiResponse {
456
+ commit?: {
457
+ sha?: string | null;
458
+ } | null;
459
+ }
460
+
461
+ interface GhSearchRepository {
462
+ nameWithOwner?: string;
463
+ }
464
+
465
+ interface GhSearchResult {
466
+ author?: GhUser | null;
467
+ createdAt?: string;
468
+ labels?: GhLabel[];
469
+ number?: number;
470
+ repository?: GhSearchRepository | null;
471
+ state?: string;
472
+ title?: string;
473
+ updatedAt?: string;
474
+ url?: string;
475
+ }
476
+
477
+ interface GhRunReference {
478
+ repo?: string;
479
+ runId?: number;
480
+ }
481
+
482
+ interface GhActionsRunListResponse {
483
+ workflow_runs?: GhActionsRunApi[];
484
+ }
485
+
486
+ interface GhActionsRunApi {
487
+ id?: number;
488
+ name?: string | null;
489
+ display_title?: string | null;
490
+ status?: string | null;
491
+ conclusion?: string | null;
492
+ head_branch?: string | null;
493
+ head_sha?: string | null;
494
+ created_at?: string | null;
495
+ updated_at?: string | null;
496
+ html_url?: string | null;
497
+ }
498
+
499
+ interface GhActionsJobsResponse {
500
+ total_count?: number;
501
+ jobs?: GhActionsJobApi[];
502
+ }
503
+
504
+ interface GhActionsJobApi {
505
+ id?: number;
506
+ name?: string | null;
507
+ status?: string | null;
508
+ conclusion?: string | null;
509
+ started_at?: string | null;
510
+ completed_at?: string | null;
511
+ html_url?: string | null;
512
+ }
513
+
514
+ interface GhRunJobSnapshot {
515
+ id: number;
516
+ name: string;
517
+ status?: string;
518
+ conclusion?: string;
519
+ startedAt?: string;
520
+ completedAt?: string;
521
+ url?: string;
522
+ }
523
+
524
+ interface GhRunSnapshot {
525
+ id: number;
526
+ workflowName?: string;
527
+ displayTitle?: string;
528
+ status?: string;
529
+ conclusion?: string;
530
+ branch?: string;
531
+ headSha?: string;
532
+ createdAt?: string;
533
+ updatedAt?: string;
534
+ url?: string;
535
+ jobs: GhRunJobSnapshot[];
536
+ }
537
+
538
+ interface GhFailedJobLog {
539
+ run: GhRunSnapshot;
540
+ job: GhRunJobSnapshot;
541
+ full?: string;
542
+ tail?: string;
543
+ available: boolean;
544
+ }
545
+
546
+ function normalizeText(value: string | null | undefined): string {
547
+ return (value ?? "").replaceAll("\r\n", "\n").replaceAll("\r", "\n").replaceAll("\t", " ").trim();
548
+ }
549
+
550
+ function normalizeBlock(value: string | null | undefined): string {
551
+ return (value ?? "").replaceAll("\r\n", "\n").replaceAll("\r", "\n").replaceAll("\t", " ").trimEnd();
552
+ }
553
+
554
+ function looksLikeGitHubUrl(value: string | undefined): boolean {
555
+ return value?.startsWith("https://github.com/") ?? false;
556
+ }
557
+
558
+ function normalizeOptionalString(value: string | null | undefined): string | undefined {
559
+ const normalized = value?.trim();
560
+ return normalized ? normalized : undefined;
561
+ }
562
+
563
+ function formatShortSha(value: string | undefined): string | undefined {
564
+ if (!value) {
565
+ return undefined;
566
+ }
567
+
568
+ return value.slice(0, 12);
569
+ }
570
+
571
+ function requireNonEmpty(value: string | null | undefined, label: string): string {
572
+ const normalized = normalizeOptionalString(value);
573
+ if (!normalized) {
574
+ throw new ToolError(`${label} must not be empty`);
575
+ }
576
+ return normalized;
577
+ }
578
+
579
+ function resolveSearchLimit(value: number | undefined): number {
580
+ if (value === undefined) {
581
+ return SEARCH_LIMIT_DEFAULT;
582
+ }
583
+
584
+ if (!Number.isFinite(value) || value <= 0) {
585
+ throw new ToolError("limit must be a positive number");
586
+ }
587
+
588
+ return Math.min(Math.floor(value), SEARCH_LIMIT_MAX);
589
+ }
590
+
591
+ function resolveTailLimit(value: number | undefined): number {
592
+ if (value === undefined) {
593
+ return RUN_WATCH_TAIL_DEFAULT;
594
+ }
595
+
596
+ if (!Number.isFinite(value) || value <= 0) {
597
+ throw new ToolError("tail must be a positive number");
598
+ }
599
+
600
+ return Math.min(Math.floor(value), RUN_WATCH_TAIL_MAX);
601
+ }
602
+
603
+ function appendRepoFlag(args: string[], repo: string | undefined, identifier?: string): void {
604
+ if (!repo || looksLikeGitHubUrl(identifier)) {
605
+ return;
606
+ }
607
+
608
+ args.push("--repo", repo);
609
+ }
610
+
611
+ function buildGhSearchArgs(
612
+ command: "issues" | "prs",
613
+ query: string,
614
+ limit: number,
615
+ repo: string | undefined,
616
+ ): string[] {
617
+ const args = ["search", command, "--limit", String(limit), "--json", GH_SEARCH_FIELDS.join(",")];
618
+ appendRepoFlag(args, repo);
619
+ args.push("--", query);
620
+ return args;
621
+ }
622
+
623
+ function sanitizeRemoteName(value: string): string {
624
+ const sanitized = value
625
+ .toLowerCase()
626
+ .replace(/[^a-z0-9]+/g, "-")
627
+ .replace(/^-+/g, "")
628
+ .replace(/-+$/g, "");
629
+ return sanitized.length > 0 ? `fork-${sanitized}` : "fork";
630
+ }
631
+
632
+ function toLocalBranchRef(value: string): string {
633
+ return `refs/heads/${value}`;
634
+ }
635
+
636
+ function stripHeadsRef(value: string | undefined): string | undefined {
637
+ if (!value) {
638
+ return undefined;
639
+ }
640
+
641
+ return value.startsWith("refs/heads/") ? value.slice("refs/heads/".length) : value;
642
+ }
643
+
644
+ function formatGitFailure(args: string[], result: GitCommandResult): string {
645
+ const output = normalizeOptionalString(result.stderr) ?? normalizeOptionalString(result.stdout);
646
+ if (output) {
647
+ return output;
648
+ }
649
+
650
+ return `git ${args.join(" ")} failed with exit code ${result.exitCode}`;
651
+ }
652
+
653
+ async function runGitCommand(cwd: string, args: string[], signal?: AbortSignal): Promise<GitCommandResult> {
654
+ return untilAborted(signal, async () => {
655
+ throwIfAborted(signal);
656
+ const child = Bun.spawn(["git", ...args], {
657
+ cwd,
658
+ stdin: "ignore",
659
+ stdout: "pipe",
660
+ stderr: "pipe",
661
+ windowsHide: true,
662
+ signal,
663
+ });
664
+ throwIfAborted(signal);
665
+
666
+ if (!child.stdout || !child.stderr) {
667
+ throw new ToolError("Failed to capture git command output.");
668
+ }
669
+
670
+ const [stdout, stderr, exitCode] = await Promise.all([
671
+ new Response(child.stdout).text(),
672
+ new Response(child.stderr).text(),
673
+ child.exited,
674
+ ]);
675
+ throwIfAborted(signal);
676
+
677
+ return {
678
+ exitCode: exitCode ?? 0,
679
+ stdout: normalizeBlock(stdout),
680
+ stderr: normalizeBlock(stderr),
681
+ };
682
+ });
683
+ }
684
+
685
+ async function runGitTextChecked(cwd: string, args: string[], signal?: AbortSignal): Promise<string> {
686
+ const result = await runGitChecked(cwd, args, signal);
687
+
688
+ const text = normalizeOptionalString(result.stdout);
689
+ if (!text) {
690
+ throw new ToolError(`git ${args.join(" ")} returned empty output.`);
691
+ }
692
+
693
+ return text;
694
+ }
695
+
696
+ async function runGitChecked(cwd: string, args: string[], signal?: AbortSignal): Promise<GitCommandResult> {
697
+ const result = await runGitCommand(cwd, args, signal);
698
+ if (result.exitCode !== 0) {
699
+ throw new ToolError(formatGitFailure(args, result));
700
+ }
701
+
702
+ return result;
703
+ }
704
+
705
+ async function tryRunGitText(cwd: string, args: string[], signal?: AbortSignal): Promise<string | undefined> {
706
+ const result = await runGitCommand(cwd, args, signal);
707
+ if (result.exitCode !== 0) {
708
+ return undefined;
709
+ }
710
+
711
+ return normalizeOptionalString(result.stdout);
712
+ }
713
+
714
+ async function resolveGitRepoRoot(cwd: string, signal?: AbortSignal): Promise<string> {
715
+ return runGitTextChecked(cwd, ["rev-parse", "--show-toplevel"], signal);
716
+ }
717
+
718
+ async function resolvePrimaryGitRepoRoot(repoRoot: string, signal?: AbortSignal): Promise<string> {
719
+ const commonDir = await runGitTextChecked(
720
+ repoRoot,
721
+ ["rev-parse", "--path-format=absolute", "--git-common-dir"],
722
+ signal,
723
+ );
724
+ if (path.basename(commonDir) === ".git") {
725
+ return path.dirname(commonDir);
726
+ }
727
+
728
+ return repoRoot;
729
+ }
730
+
731
+ function parseGitWorktreeList(text: string): GitWorktreeEntry[] {
732
+ const trimmed = text.trim();
733
+ if (!trimmed) {
734
+ return [];
735
+ }
736
+
737
+ return trimmed
738
+ .split(/\n\s*\n/)
739
+ .map(block => block.trim())
740
+ .filter(Boolean)
741
+ .map(block => {
742
+ const entry: GitWorktreeEntry = {
743
+ path: "",
744
+ detached: false,
745
+ };
746
+ for (const line of block.split("\n")) {
747
+ if (line.startsWith("worktree ")) {
748
+ entry.path = line.slice("worktree ".length);
749
+ continue;
750
+ }
751
+ if (line.startsWith("HEAD ")) {
752
+ entry.head = line.slice("HEAD ".length);
753
+ continue;
754
+ }
755
+ if (line.startsWith("branch ")) {
756
+ entry.branch = line.slice("branch ".length);
757
+ continue;
758
+ }
759
+ if (line === "detached") {
760
+ entry.detached = true;
761
+ }
762
+ }
763
+ return entry;
764
+ });
765
+ }
766
+
767
+ async function listGitWorktrees(repoRoot: string, signal?: AbortSignal): Promise<GitWorktreeEntry[]> {
768
+ const output = await runGitTextChecked(repoRoot, ["worktree", "list", "--porcelain"], signal);
769
+ return parseGitWorktreeList(output);
770
+ }
771
+
772
+ async function gitRefExists(repoRoot: string, ref: string, signal?: AbortSignal): Promise<boolean> {
773
+ const result = await runGitCommand(repoRoot, ["show-ref", "--verify", "--quiet", ref], signal);
774
+ return result.exitCode === 0;
775
+ }
776
+
777
+ async function ensureGitWorktreePathAvailable(
778
+ worktreePath: string,
779
+ existingWorktrees: GitWorktreeEntry[],
780
+ ): Promise<void> {
781
+ const normalizedTarget = path.resolve(worktreePath);
782
+ const conflictingWorktree = existingWorktrees.find(entry => path.resolve(entry.path) === normalizedTarget);
783
+ if (conflictingWorktree) {
784
+ throw new ToolError(`worktree path is already registered: ${conflictingWorktree.path}`);
785
+ }
786
+
787
+ try {
788
+ await fs.stat(normalizedTarget);
789
+ throw new ToolError(`worktree path already exists: ${normalizedTarget}`);
790
+ } catch (error) {
791
+ if (isEnoent(error)) {
792
+ return;
793
+ }
794
+ throw error;
795
+ }
796
+ }
797
+
798
+ function selectPrCloneUrl(originUrl: string | undefined, repo: Pick<GhRepoViewData, "url" | "sshUrl">): string {
799
+ if (originUrl?.startsWith("http://") || originUrl?.startsWith("https://")) {
800
+ return normalizeOptionalString(repo.url) ?? normalizeOptionalString(repo.sshUrl) ?? "";
801
+ }
802
+
803
+ return normalizeOptionalString(repo.sshUrl) ?? normalizeOptionalString(repo.url) ?? "";
804
+ }
805
+
806
+ async function getRemoteUrls(repoRoot: string, signal?: AbortSignal): Promise<Map<string, string>> {
807
+ const remoteList = await tryRunGitText(repoRoot, ["remote"], signal);
808
+ const remotes =
809
+ remoteList
810
+ ?.split("\n")
811
+ .map(value => value.trim())
812
+ .filter(Boolean) ?? [];
813
+ const urls = new Map<string, string>();
814
+ for (const remoteName of remotes) {
815
+ const remoteUrl = await tryRunGitText(repoRoot, ["remote", "get-url", remoteName], signal);
816
+ if (remoteUrl) {
817
+ urls.set(remoteName, remoteUrl);
818
+ }
819
+ }
820
+ return urls;
821
+ }
822
+
823
+ async function ensurePrRemote(
824
+ repoRoot: string,
825
+ data: GhPrViewData,
826
+ signal?: AbortSignal,
827
+ ): Promise<{ name: string; url: string }> {
828
+ if (!data.isCrossRepository) {
829
+ const originUrl = normalizeOptionalString(await tryRunGitText(repoRoot, ["remote", "get-url", "origin"], signal));
830
+ if (!originUrl) {
831
+ throw new ToolError("origin remote is unavailable for this repository.");
832
+ }
833
+
834
+ return {
835
+ name: "origin",
836
+ url: originUrl,
837
+ };
838
+ }
839
+
840
+ const headRepository = requireNonEmpty(data.headRepository?.nameWithOwner, "head repository");
841
+ const repoSummary = await runGhJson<GhRepoViewData>(
842
+ repoRoot,
843
+ ["repo", "view", headRepository, "--json", GH_REPO_CLONE_FIELDS.join(",")],
844
+ signal,
845
+ { repoProvided: true },
846
+ );
847
+ const originUrl = await tryRunGitText(repoRoot, ["remote", "get-url", "origin"], signal);
848
+ const remoteUrl = selectPrCloneUrl(originUrl, repoSummary);
849
+ if (!remoteUrl) {
850
+ throw new ToolError(`Could not determine a clone URL for ${headRepository}.`);
851
+ }
852
+
853
+ const remotes = await getRemoteUrls(repoRoot, signal);
854
+ for (const [remoteName, url] of remotes) {
855
+ if (url === remoteUrl) {
856
+ return { name: remoteName, url };
857
+ }
858
+ }
859
+
860
+ const preferredRemoteName = sanitizeRemoteName(
861
+ data.headRepositoryOwner?.login ?? headRepository.split("/")[0] ?? "fork",
862
+ );
863
+ let remoteName = preferredRemoteName;
864
+ let suffix = 2;
865
+ while (remotes.has(remoteName)) {
866
+ remoteName = `${preferredRemoteName}-${suffix}`;
867
+ suffix += 1;
868
+ }
869
+
870
+ const result = await runGitCommand(repoRoot, ["remote", "add", remoteName, remoteUrl], signal);
871
+ if (result.exitCode !== 0) {
872
+ throw new ToolError(formatGitFailure(["remote", "add", remoteName, remoteUrl], result));
873
+ }
874
+
875
+ return {
876
+ name: remoteName,
877
+ url: remoteUrl,
878
+ };
879
+ }
880
+
881
+ async function setBranchConfig(
882
+ repoRoot: string,
883
+ localBranch: string,
884
+ key: string,
885
+ value: string,
886
+ signal?: AbortSignal,
887
+ ): Promise<void> {
888
+ const result = await runGitCommand(repoRoot, ["config", `branch.${localBranch}.${key}`, value], signal);
889
+ if (result.exitCode !== 0) {
890
+ throw new ToolError(formatGitFailure(["config", `branch.${localBranch}.${key}`, value], result));
891
+ }
892
+ }
893
+
894
+ async function getBranchConfig(
895
+ repoRoot: string,
896
+ localBranch: string,
897
+ key: string,
898
+ signal?: AbortSignal,
899
+ ): Promise<string | undefined> {
900
+ return tryRunGitText(repoRoot, ["config", "--get", `branch.${localBranch}.${key}`], signal);
901
+ }
902
+
903
+ async function resolvePrBranchPushTarget(
904
+ repoRoot: string,
905
+ localBranch: string,
906
+ signal?: AbortSignal,
907
+ ): Promise<{
908
+ remoteName: string;
909
+ remoteBranch: string;
910
+ remoteUrl?: string;
911
+ prUrl?: string;
912
+ maintainerCanModify?: boolean;
913
+ isCrossRepository: boolean;
914
+ }> {
915
+ const pushRemote = await getBranchConfig(repoRoot, localBranch, "pushRemote", signal);
916
+ const remote = await getBranchConfig(repoRoot, localBranch, "remote", signal);
917
+ const mergeRef = await getBranchConfig(repoRoot, localBranch, "merge", signal);
918
+ const headRef = await getBranchConfig(repoRoot, localBranch, "ompPrHeadRef", signal);
919
+ const prUrl = await getBranchConfig(repoRoot, localBranch, "ompPrUrl", signal);
920
+ const maintainerCanModifyValue = await getBranchConfig(repoRoot, localBranch, "ompPrMaintainerCanModify", signal);
921
+ const isCrossRepositoryValue = await getBranchConfig(repoRoot, localBranch, "ompPrIsCrossRepository", signal);
922
+
923
+ const remoteName = pushRemote ?? remote;
924
+ if (!remoteName) {
925
+ throw new ToolError(`branch ${localBranch} has no configured push remote`);
926
+ }
927
+
928
+ const remoteBranch = headRef ?? stripHeadsRef(mergeRef);
929
+ if (!remoteBranch) {
930
+ throw new ToolError(`branch ${localBranch} has no tracked PR head ref`);
931
+ }
932
+
933
+ return {
934
+ remoteName,
935
+ remoteBranch,
936
+ remoteUrl: await tryRunGitText(repoRoot, ["remote", "get-url", remoteName], signal),
937
+ prUrl,
938
+ maintainerCanModify:
939
+ maintainerCanModifyValue === undefined
940
+ ? undefined
941
+ : ["1", "true", "yes", "on"].includes(maintainerCanModifyValue.toLowerCase()),
942
+ isCrossRepository: ["1", "true", "yes", "on"].includes((isCrossRepositoryValue ?? "").toLowerCase()),
943
+ };
944
+ }
945
+
946
+ function formatAuthor(author: GhUser | null | undefined): string | undefined {
947
+ if (!author) return undefined;
948
+ if (author.login) return `@${author.login}`;
949
+ if (author.name) return author.name;
950
+ return undefined;
951
+ }
952
+
953
+ function formatLabels(labels: GhLabel[] | undefined): string | undefined {
954
+ const names = labels?.map(label => label.name).filter((value): value is string => Boolean(value)) ?? [];
955
+ if (names.length === 0) return undefined;
956
+ return names.join(", ");
957
+ }
958
+
959
+ function pushLine(lines: string[], label: string, value: string | number | boolean | undefined): void {
960
+ if (value === undefined || value === "") return;
961
+ lines.push(`${label}: ${value}`);
962
+ }
963
+
964
+ function parseRunReference(value: string | undefined): GhRunReference {
965
+ const run = normalizeOptionalString(value);
966
+ if (!run) {
967
+ return {};
968
+ }
969
+
970
+ if (/^\d+$/.test(run)) {
971
+ return { runId: Number(run) };
972
+ }
973
+
974
+ const match = run.match(RUN_URL_PATTERN);
975
+ if (!match) {
976
+ throw new ToolError("run must be a numeric workflow run ID or a full GitHub Actions run URL");
977
+ }
978
+
979
+ return {
980
+ repo: match[1],
981
+ runId: Number(match[2]),
982
+ };
983
+ }
984
+
985
+ function parsePullRequestUrl(value: string | undefined): { repo?: string; prNumber?: number } {
986
+ const normalized = normalizeOptionalString(value);
987
+ if (!normalized) {
988
+ return {};
989
+ }
990
+
991
+ const match = normalized.match(PR_URL_PATTERN);
992
+ if (!match) {
993
+ return {};
994
+ }
995
+
996
+ return {
997
+ repo: match[1],
998
+ prNumber: Number(match[2]),
999
+ };
1000
+ }
1001
+
1002
+ function normalizePrReviewComment(comment: GhPrReviewCommentApi): GhPrReviewComment | null {
1003
+ if (typeof comment.id !== "number") {
1004
+ return null;
1005
+ }
1006
+
1007
+ return {
1008
+ author: comment.user ?? null,
1009
+ body: comment.body,
1010
+ createdAt: normalizeOptionalString(comment.created_at),
1011
+ id: comment.id,
1012
+ inReplyToId: typeof comment.in_reply_to_id === "number" ? comment.in_reply_to_id : undefined,
1013
+ line: typeof comment.line === "number" ? comment.line : undefined,
1014
+ originalLine: typeof comment.original_line === "number" ? comment.original_line : undefined,
1015
+ path: normalizeOptionalString(comment.path),
1016
+ side: normalizeOptionalString(comment.side),
1017
+ url: normalizeOptionalString(comment.html_url),
1018
+ };
1019
+ }
1020
+
1021
+ function normalizeRunJob(job: GhActionsJobApi): GhRunJobSnapshot | null {
1022
+ if (typeof job.id !== "number") {
1023
+ return null;
1024
+ }
1025
+
1026
+ return {
1027
+ id: job.id,
1028
+ name: normalizeOptionalString(job.name) ?? `job-${job.id}`,
1029
+ status: normalizeOptionalString(job.status),
1030
+ conclusion: normalizeOptionalString(job.conclusion),
1031
+ startedAt: normalizeOptionalString(job.started_at),
1032
+ completedAt: normalizeOptionalString(job.completed_at),
1033
+ url: normalizeOptionalString(job.html_url),
1034
+ };
1035
+ }
1036
+
1037
+ function normalizeRunSnapshot(run: GhActionsRunApi, jobs: GhRunJobSnapshot[]): GhRunSnapshot {
1038
+ if (typeof run.id !== "number") {
1039
+ throw new ToolError("GitHub Actions run response did not include a run ID.");
1040
+ }
1041
+
1042
+ return {
1043
+ id: run.id,
1044
+ workflowName: normalizeOptionalString(run.name),
1045
+ displayTitle: normalizeOptionalString(run.display_title),
1046
+ status: normalizeOptionalString(run.status),
1047
+ conclusion: normalizeOptionalString(run.conclusion),
1048
+ branch: normalizeOptionalString(run.head_branch),
1049
+ headSha: normalizeOptionalString(run.head_sha),
1050
+ createdAt: normalizeOptionalString(run.created_at),
1051
+ updatedAt: normalizeOptionalString(run.updated_at),
1052
+ url: normalizeOptionalString(run.html_url),
1053
+ jobs,
1054
+ };
1055
+ }
1056
+
1057
+ function getRunOutcome(value: string | undefined): "success" | "failure" | "pending" {
1058
+ if (!value) {
1059
+ return "pending";
1060
+ }
1061
+
1062
+ if (RUN_SUCCESS_CONCLUSIONS.has(value)) {
1063
+ return "success";
1064
+ }
1065
+
1066
+ if (RUN_FAILURE_CONCLUSIONS.has(value)) {
1067
+ return "failure";
1068
+ }
1069
+
1070
+ return "pending";
1071
+ }
1072
+
1073
+ function getRunSnapshotOutcome(run: GhRunSnapshot): "success" | "failure" | "pending" {
1074
+ if (run.status !== "completed") {
1075
+ return "pending";
1076
+ }
1077
+
1078
+ return getRunOutcome(run.conclusion);
1079
+ }
1080
+
1081
+ function getRunCollectionOutcome(runs: GhRunSnapshot[]): "success" | "failure" | "pending" {
1082
+ if (runs.length === 0) {
1083
+ return "pending";
1084
+ }
1085
+
1086
+ let pending = false;
1087
+ for (const run of runs) {
1088
+ const outcome = getRunSnapshotOutcome(run);
1089
+ if (outcome === "failure") {
1090
+ return "failure";
1091
+ }
1092
+ if (outcome === "pending") {
1093
+ pending = true;
1094
+ }
1095
+ }
1096
+
1097
+ return pending ? "pending" : "success";
1098
+ }
1099
+
1100
+ function getRunCollectionSignature(runs: GhRunSnapshot[]): string {
1101
+ return runs
1102
+ .map(run => run.id)
1103
+ .sort((left, right) => left - right)
1104
+ .join(",");
1105
+ }
1106
+
1107
+ function isFailedJob(job: GhRunJobSnapshot): boolean {
1108
+ return job.conclusion !== undefined && JOB_FAILURE_CONCLUSIONS.has(job.conclusion);
1109
+ }
1110
+
1111
+ function formatJobState(job: GhRunJobSnapshot): string {
1112
+ return job.conclusion ?? job.status ?? "unknown";
1113
+ }
1114
+
1115
+ function parseTimestampMs(value: string | undefined): number | undefined {
1116
+ if (!value) {
1117
+ return undefined;
1118
+ }
1119
+
1120
+ const timestamp = Date.parse(value);
1121
+ return Number.isNaN(timestamp) ? undefined : timestamp;
1122
+ }
1123
+
1124
+ function getJobDurationSeconds(job: GhRunJobSnapshot, observedAtMs: number): number | undefined {
1125
+ const startedAtMs = parseTimestampMs(job.startedAt);
1126
+ if (startedAtMs === undefined) {
1127
+ return undefined;
1128
+ }
1129
+
1130
+ const completedAtMs = parseTimestampMs(job.completedAt) ?? observedAtMs;
1131
+ return Math.max(0, Math.floor((completedAtMs - startedAtMs) / 1000));
1132
+ }
1133
+
1134
+ function buildRunWatchJobDetails(job: GhRunJobSnapshot, observedAtMs: number): GhRunWatchJobDetails {
1135
+ return {
1136
+ id: job.id,
1137
+ name: job.name,
1138
+ status: job.status,
1139
+ conclusion: job.conclusion,
1140
+ durationSeconds: getJobDurationSeconds(job, observedAtMs),
1141
+ url: job.url,
1142
+ };
1143
+ }
1144
+
1145
+ function buildRunWatchRunDetails(run: GhRunSnapshot, observedAtMs: number): GhRunWatchRunDetails {
1146
+ return {
1147
+ id: run.id,
1148
+ workflowName: run.workflowName,
1149
+ displayTitle: run.displayTitle,
1150
+ status: run.status,
1151
+ conclusion: run.conclusion,
1152
+ branch: run.branch,
1153
+ headSha: run.headSha,
1154
+ url: run.url,
1155
+ jobs: run.jobs.map(job => buildRunWatchJobDetails(job, observedAtMs)),
1156
+ };
1157
+ }
1158
+
1159
+ function buildFailedLogDetails(failedJobLogs: GhFailedJobLog[]): GhRunWatchFailedLogDetails[] {
1160
+ return failedJobLogs.map(entry => ({
1161
+ runId: entry.run.id,
1162
+ workflowName: entry.run.workflowName,
1163
+ jobName: entry.job.name,
1164
+ conclusion: entry.job.conclusion,
1165
+ tail: entry.tail,
1166
+ available: entry.available,
1167
+ }));
1168
+ }
1169
+
1170
+ function renderJobsSection(jobs: GhRunJobSnapshot[]): string[] {
1171
+ if (jobs.length === 0) {
1172
+ return ["## Jobs", "", "No jobs reported yet."];
1173
+ }
1174
+
1175
+ const lines: string[] = [`## Jobs (${jobs.length})`, ""];
1176
+ for (const job of jobs) {
1177
+ lines.push(`- [${formatJobState(job)}] ${job.name}`);
1178
+ if (job.startedAt) {
1179
+ pushLine(lines, " Started", job.startedAt);
1180
+ }
1181
+ if (job.completedAt) {
1182
+ pushLine(lines, " Completed", job.completedAt);
1183
+ }
1184
+ if (job.url) {
1185
+ pushLine(lines, " URL", job.url);
1186
+ }
1187
+ }
1188
+
1189
+ return lines;
1190
+ }
1191
+
1192
+ function renderFailedJobLogs(
1193
+ failedJobLogs: GhFailedJobLog[],
1194
+ options: { mode: "tail"; tail: number } | { mode: "full" },
1195
+ ): string[] {
1196
+ if (failedJobLogs.length === 0) {
1197
+ return [];
1198
+ }
1199
+
1200
+ const lines: string[] = ["## Failed Jobs", ""];
1201
+ for (const entry of failedJobLogs) {
1202
+ lines.push(`### ${entry.job.name} [${entry.job.conclusion ?? "failed"}]`);
1203
+ pushLine(lines, "Run", `#${entry.run.id}`);
1204
+ pushLine(lines, "Workflow", entry.run.workflowName ?? undefined);
1205
+ if (entry.job.startedAt) {
1206
+ pushLine(lines, "Started", entry.job.startedAt);
1207
+ }
1208
+ if (entry.job.completedAt) {
1209
+ pushLine(lines, "Completed", entry.job.completedAt);
1210
+ }
1211
+ if (entry.job.url) {
1212
+ pushLine(lines, "URL", entry.job.url);
1213
+ }
1214
+ lines.push("");
1215
+ const logText = options.mode === "full" ? entry.full : entry.tail;
1216
+ if (entry.available && logText) {
1217
+ lines.push(options.mode === "full" ? "Full log:" : `Last ${options.tail} log lines:`);
1218
+ lines.push("```text");
1219
+ lines.push(logText);
1220
+ lines.push("```");
1221
+ } else {
1222
+ lines.push(options.mode === "full" ? "Full log unavailable." : "Log tail unavailable.");
1223
+ }
1224
+ lines.push("");
1225
+ }
1226
+
1227
+ return lines;
1228
+ }
1229
+
1230
+ function renderRunSection(run: GhRunSnapshot): string[] {
1231
+ const label = run.workflowName ? `### Run #${run.id} - ${run.workflowName}` : `### Run #${run.id}`;
1232
+ const lines: string[] = [label, ""];
1233
+ pushLine(lines, "Title", run.displayTitle ?? undefined);
1234
+ pushLine(lines, "Branch", run.branch ?? undefined);
1235
+ pushLine(lines, "Commit", formatShortSha(run.headSha));
1236
+ pushLine(lines, "Status", run.status);
1237
+ pushLine(lines, "Conclusion", run.conclusion ?? undefined);
1238
+ pushLine(lines, "Created", run.createdAt);
1239
+ pushLine(lines, "Updated", run.updatedAt);
1240
+ pushLine(lines, "URL", run.url);
1241
+ lines.push("");
1242
+ lines.push(...renderJobsSection(run.jobs));
1243
+ return lines;
1244
+ }
1245
+
1246
+ function formatRunWatchSnapshot(
1247
+ repo: string,
1248
+ run: GhRunSnapshot,
1249
+ pollCount: number,
1250
+ note?: string,
1251
+ includeOutcome: boolean = false,
1252
+ ): string {
1253
+ const failedJobs = run.jobs.filter(isFailedJob);
1254
+ const lines: string[] = [`# Watching GitHub Actions Run #${run.id}`, ""];
1255
+ pushLine(lines, "Repository", repo);
1256
+ pushLine(lines, "Workflow", run.workflowName ?? undefined);
1257
+ pushLine(lines, "Title", run.displayTitle ?? undefined);
1258
+ pushLine(lines, "Branch", run.branch ?? undefined);
1259
+ pushLine(lines, "Status", run.status);
1260
+ pushLine(lines, "Conclusion", run.conclusion ?? undefined);
1261
+ pushLine(lines, "Created", run.createdAt);
1262
+ pushLine(lines, "Updated", run.updatedAt);
1263
+ pushLine(lines, "URL", run.url);
1264
+ pushLine(lines, "Poll", pollCount);
1265
+ pushLine(lines, "Failed jobs", failedJobs.length || undefined);
1266
+
1267
+ if (note) {
1268
+ lines.push("");
1269
+ lines.push(`Note: ${note}`);
1270
+ }
1271
+
1272
+ lines.push("");
1273
+ lines.push(...renderJobsSection(run.jobs));
1274
+
1275
+ if (includeOutcome) {
1276
+ lines.push("");
1277
+ lines.push(failedJobs.length > 0 ? "Failures detected." : "All jobs passed.");
1278
+ }
1279
+
1280
+ return lines.join("\n").trim();
1281
+ }
1282
+
1283
+ function formatRunWatchResult(
1284
+ repo: string,
1285
+ run: GhRunSnapshot,
1286
+ failedJobLogs: GhFailedJobLog[],
1287
+ tail: number,
1288
+ options?: { mode?: "tail" | "full" },
1289
+ ): string {
1290
+ const failedJobs = run.jobs.filter(isFailedJob);
1291
+ const lines: string[] = [`# GitHub Actions Run #${run.id}`, ""];
1292
+ pushLine(lines, "Repository", repo);
1293
+ pushLine(lines, "Workflow", run.workflowName ?? undefined);
1294
+ pushLine(lines, "Title", run.displayTitle ?? undefined);
1295
+ pushLine(lines, "Branch", run.branch ?? undefined);
1296
+ pushLine(lines, "Status", run.status);
1297
+ pushLine(lines, "Conclusion", run.conclusion ?? undefined);
1298
+ pushLine(lines, "Created", run.createdAt);
1299
+ pushLine(lines, "Updated", run.updatedAt);
1300
+ pushLine(lines, "URL", run.url);
1301
+ lines.push("");
1302
+ lines.push(...renderJobsSection(run.jobs));
1303
+
1304
+ if (failedJobs.length > 0) {
1305
+ lines.push("");
1306
+ lines.push(
1307
+ ...renderFailedJobLogs(failedJobLogs, options?.mode === "full" ? { mode: "full" } : { mode: "tail", tail }),
1308
+ );
1309
+ lines.push("Run failed.");
1310
+ } else if (getRunOutcome(run.conclusion) === "success") {
1311
+ lines.push("");
1312
+ lines.push("All jobs passed.");
1313
+ } else {
1314
+ lines.push("");
1315
+ lines.push("Run completed without successful jobs, but no failed job logs were available.");
1316
+ }
1317
+
1318
+ return lines.join("\n").trim();
1319
+ }
1320
+
1321
+ function formatCommitRunWatchSnapshot(
1322
+ repo: string,
1323
+ headSha: string,
1324
+ branch: string | undefined,
1325
+ runs: GhRunSnapshot[],
1326
+ pollCount: number,
1327
+ note?: string,
1328
+ ): string {
1329
+ const failedJobs = runs.flatMap(run => run.jobs.filter(isFailedJob));
1330
+ const completedRuns = runs.filter(run => run.status === "completed").length;
1331
+ const lines: string[] = [`# Watching GitHub Actions for ${formatShortSha(headSha) ?? headSha}`, ""];
1332
+ pushLine(lines, "Repository", repo);
1333
+ pushLine(lines, "Branch", branch);
1334
+ pushLine(lines, "Commit", headSha);
1335
+ pushLine(lines, "Poll", pollCount);
1336
+ pushLine(lines, "Runs", runs.length);
1337
+ pushLine(lines, "Completed runs", `${completedRuns}/${runs.length}`);
1338
+ pushLine(lines, "Failed jobs", failedJobs.length || undefined);
1339
+
1340
+ if (note) {
1341
+ lines.push("");
1342
+ lines.push(`Note: ${note}`);
1343
+ }
1344
+
1345
+ if (runs.length === 0) {
1346
+ lines.push("");
1347
+ lines.push("Waiting for workflow runs for this commit.");
1348
+ return lines.join("\n").trim();
1349
+ }
1350
+
1351
+ for (const run of runs) {
1352
+ lines.push("");
1353
+ lines.push(...renderRunSection(run));
1354
+ }
1355
+
1356
+ return lines.join("\n").trim();
1357
+ }
1358
+
1359
+ function formatCommitRunWatchResult(
1360
+ repo: string,
1361
+ headSha: string,
1362
+ branch: string | undefined,
1363
+ runs: GhRunSnapshot[],
1364
+ failedJobLogs: GhFailedJobLog[],
1365
+ tail: number,
1366
+ options?: { mode?: "tail" | "full" },
1367
+ ): string {
1368
+ const outcome = getRunCollectionOutcome(runs);
1369
+ const lines: string[] = [`# GitHub Actions for ${formatShortSha(headSha) ?? headSha}`, ""];
1370
+ pushLine(lines, "Repository", repo);
1371
+ pushLine(lines, "Branch", branch);
1372
+ pushLine(lines, "Commit", headSha);
1373
+ pushLine(lines, "Runs", runs.length);
1374
+
1375
+ for (const run of runs) {
1376
+ lines.push("");
1377
+ lines.push(...renderRunSection(run));
1378
+ }
1379
+
1380
+ if (failedJobLogs.length > 0) {
1381
+ lines.push("");
1382
+ lines.push(
1383
+ ...renderFailedJobLogs(failedJobLogs, options?.mode === "full" ? { mode: "full" } : { mode: "tail", tail }),
1384
+ );
1385
+ lines.push("Workflow runs for this commit failed.");
1386
+ } else if (outcome === "success") {
1387
+ lines.push("");
1388
+ lines.push("All workflow runs for this commit passed.");
1389
+ } else {
1390
+ lines.push("");
1391
+ lines.push("Workflow runs for this commit did not complete successfully.");
1392
+ }
1393
+
1394
+ return lines.join("\n").trim();
1395
+ }
1396
+
1397
+ function buildGhDetails(repo: string, run: GhRunSnapshot): GhToolDetails {
1398
+ return {
1399
+ repo,
1400
+ branch: run.branch,
1401
+ headSha: run.headSha,
1402
+ runId: run.id,
1403
+ runIds: [run.id],
1404
+ status: run.status,
1405
+ conclusion: run.conclusion,
1406
+ failedJobs: run.jobs.filter(isFailedJob).map(job => job.name),
1407
+ };
1408
+ }
1409
+
1410
+ function buildRunWatchDetails(
1411
+ repo: string,
1412
+ run: GhRunSnapshot,
1413
+ options?: {
1414
+ state?: GhRunWatchViewDetails["state"];
1415
+ pollCount?: number;
1416
+ note?: string;
1417
+ failedJobLogs?: GhFailedJobLog[];
1418
+ },
1419
+ ): GhToolDetails {
1420
+ const observedAtMs = Date.now();
1421
+ return {
1422
+ ...buildGhDetails(repo, run),
1423
+ watch: {
1424
+ mode: "run",
1425
+ state: options?.state ?? "completed",
1426
+ repo,
1427
+ branch: run.branch,
1428
+ headSha: run.headSha,
1429
+ pollCount: options?.pollCount,
1430
+ note: options?.note,
1431
+ run: buildRunWatchRunDetails(run, observedAtMs),
1432
+ failedLogs: buildFailedLogDetails(options?.failedJobLogs ?? []),
1433
+ },
1434
+ };
1435
+ }
1436
+
1437
+ function buildGhRunCollectionDetails(
1438
+ repo: string,
1439
+ headSha: string,
1440
+ branch: string | undefined,
1441
+ runs: GhRunSnapshot[],
1442
+ ): GhToolDetails {
1443
+ const outcome = getRunCollectionOutcome(runs);
1444
+ return {
1445
+ repo,
1446
+ branch,
1447
+ headSha,
1448
+ runIds: runs.map(run => run.id),
1449
+ status: runs.length > 0 && runs.every(run => run.status === "completed") ? "completed" : "in_progress",
1450
+ conclusion: outcome,
1451
+ failedJobs: runs.flatMap(run =>
1452
+ run.jobs.filter(isFailedJob).map(job => `${run.workflowName ?? `run ${run.id}`}: ${job.name}`),
1453
+ ),
1454
+ };
1455
+ }
1456
+
1457
+ function buildCommitRunWatchDetails(
1458
+ repo: string,
1459
+ headSha: string,
1460
+ branch: string | undefined,
1461
+ runs: GhRunSnapshot[],
1462
+ options?: {
1463
+ state?: GhRunWatchViewDetails["state"];
1464
+ pollCount?: number;
1465
+ note?: string;
1466
+ failedJobLogs?: GhFailedJobLog[];
1467
+ },
1468
+ ): GhToolDetails {
1469
+ const observedAtMs = Date.now();
1470
+ return {
1471
+ ...buildGhRunCollectionDetails(repo, headSha, branch, runs),
1472
+ watch: {
1473
+ mode: "commit",
1474
+ state: options?.state ?? "completed",
1475
+ repo,
1476
+ branch,
1477
+ headSha,
1478
+ pollCount: options?.pollCount,
1479
+ note: options?.note,
1480
+ runs: runs.map(run => buildRunWatchRunDetails(run, observedAtMs)),
1481
+ failedLogs: buildFailedLogDetails(options?.failedJobLogs ?? []),
1482
+ },
1483
+ };
1484
+ }
1485
+
1486
+ async function resolveCurrentGitBranch(cwd: string, signal?: AbortSignal): Promise<string> {
1487
+ return untilAborted(signal, async () => {
1488
+ throwIfAborted(signal);
1489
+ const result = await $`git symbolic-ref --short HEAD`.cwd(cwd).quiet().nothrow();
1490
+ throwIfAborted(signal);
1491
+
1492
+ if (result.exitCode !== 0) {
1493
+ throw new ToolError("Current git branch is unavailable. Pass `branch` or `run` explicitly.");
1494
+ }
1495
+
1496
+ const branch = normalizeOptionalString(result.text());
1497
+ if (!branch) {
1498
+ throw new ToolError("Current git branch is unavailable. Pass `branch` or `run` explicitly.");
1499
+ }
1500
+
1501
+ return branch;
1502
+ });
1503
+ }
1504
+
1505
+ async function resolveCurrentGitHead(cwd: string, signal?: AbortSignal): Promise<string> {
1506
+ return untilAborted(signal, async () => {
1507
+ throwIfAborted(signal);
1508
+ const result = await $`git rev-parse HEAD`.cwd(cwd).quiet().nothrow();
1509
+ throwIfAborted(signal);
1510
+
1511
+ if (result.exitCode !== 0) {
1512
+ throw new ToolError("Current git HEAD is unavailable. Pass `run` explicitly.");
1513
+ }
1514
+
1515
+ const headSha = normalizeOptionalString(result.text());
1516
+ if (!headSha) {
1517
+ throw new ToolError("Current git HEAD is unavailable. Pass `run` explicitly.");
1518
+ }
1519
+
1520
+ return headSha;
1521
+ });
1522
+ }
1523
+
1524
+ async function resolveGitHubRepo(
1525
+ cwd: string,
1526
+ repo: string | undefined,
1527
+ runRepo: string | undefined,
1528
+ signal?: AbortSignal,
1529
+ ): Promise<string> {
1530
+ if (repo && runRepo && repo !== runRepo) {
1531
+ throw new ToolError("run URL repository does not match the provided repo");
1532
+ }
1533
+
1534
+ if (repo) {
1535
+ return repo;
1536
+ }
1537
+
1538
+ if (runRepo) {
1539
+ return runRepo;
1540
+ }
1541
+
1542
+ const resolved = await runGhText(cwd, ["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"], signal);
1543
+ return requireNonEmpty(resolved, "repo");
1544
+ }
1545
+
1546
+ async function resolveGitHubBranchHead(
1547
+ cwd: string,
1548
+ repo: string,
1549
+ branch: string,
1550
+ signal?: AbortSignal,
1551
+ ): Promise<string> {
1552
+ const response = await runGhJson<GhBranchApiResponse>(
1553
+ cwd,
1554
+ ["api", "--method", "GET", `/repos/${repo}/branches/${encodeURIComponent(branch)}`],
1555
+ signal,
1556
+ { repoProvided: true },
1557
+ );
1558
+ return requireNonEmpty(response.commit?.sha, `head SHA for branch ${branch}`);
1559
+ }
1560
+
1561
+ async function fetchRunsForCommit(
1562
+ cwd: string,
1563
+ repo: string,
1564
+ headSha: string,
1565
+ branch: string | undefined,
1566
+ signal?: AbortSignal,
1567
+ ): Promise<GhRunSnapshot[]> {
1568
+ const response = await runGhJson<GhActionsRunListResponse>(
1569
+ cwd,
1570
+ [
1571
+ "api",
1572
+ "--method",
1573
+ "GET",
1574
+ `/repos/${repo}/actions/runs`,
1575
+ "-F",
1576
+ `head_sha=${headSha}`,
1577
+ "-F",
1578
+ `per_page=${RUN_JOBS_PAGE_SIZE}`,
1579
+ ...(branch ? ["-F", `branch=${branch}`] : []),
1580
+ ],
1581
+ signal,
1582
+ { repoProvided: true },
1583
+ );
1584
+
1585
+ return Promise.all(
1586
+ (response.workflow_runs ?? [])
1587
+ .filter((run): run is GhActionsRunApi & { id: number } => typeof run.id === "number")
1588
+ .map(async run => {
1589
+ const jobs = await fetchRunJobs(cwd, repo, run.id, signal);
1590
+ return normalizeRunSnapshot(run, jobs);
1591
+ }),
1592
+ );
1593
+ }
1594
+
1595
+ async function fetchRunJobs(
1596
+ cwd: string,
1597
+ repo: string,
1598
+ runId: number,
1599
+ signal?: AbortSignal,
1600
+ ): Promise<GhRunJobSnapshot[]> {
1601
+ const jobs: GhRunJobSnapshot[] = [];
1602
+ let page = 1;
1603
+
1604
+ while (true) {
1605
+ const response = await runGhJson<GhActionsJobsResponse>(
1606
+ cwd,
1607
+ [
1608
+ "api",
1609
+ "--method",
1610
+ "GET",
1611
+ `/repos/${repo}/actions/runs/${runId}/jobs`,
1612
+ "-F",
1613
+ `per_page=${RUN_JOBS_PAGE_SIZE}`,
1614
+ "-F",
1615
+ `page=${page}`,
1616
+ ],
1617
+ signal,
1618
+ { repoProvided: true },
1619
+ );
1620
+ const pageJobs = (response.jobs ?? [])
1621
+ .map(job => normalizeRunJob(job))
1622
+ .filter((job): job is GhRunJobSnapshot => job !== null);
1623
+ jobs.push(...pageJobs);
1624
+
1625
+ if (pageJobs.length < RUN_JOBS_PAGE_SIZE) {
1626
+ break;
1627
+ }
1628
+
1629
+ if ((response.total_count ?? 0) <= jobs.length) {
1630
+ break;
1631
+ }
1632
+
1633
+ page += 1;
1634
+ }
1635
+
1636
+ return jobs;
1637
+ }
1638
+
1639
+ async function fetchPrReviewComments(
1640
+ cwd: string,
1641
+ repo: string,
1642
+ prNumber: number,
1643
+ signal?: AbortSignal,
1644
+ ): Promise<GhPrReviewComment[]> {
1645
+ const reviewComments: GhPrReviewComment[] = [];
1646
+ let page = 1;
1647
+
1648
+ while (true) {
1649
+ const response = await runGhJson<GhPrReviewCommentApi[]>(
1650
+ cwd,
1651
+ [
1652
+ "api",
1653
+ "--method",
1654
+ "GET",
1655
+ `/repos/${repo}/pulls/${prNumber}/comments`,
1656
+ "-F",
1657
+ `per_page=${REVIEW_COMMENTS_PAGE_SIZE}`,
1658
+ "-F",
1659
+ `page=${page}`,
1660
+ ],
1661
+ signal,
1662
+ { repoProvided: true },
1663
+ );
1664
+
1665
+ const pageComments = response
1666
+ .map(comment => normalizePrReviewComment(comment))
1667
+ .filter((comment): comment is GhPrReviewComment => comment !== null);
1668
+ reviewComments.push(...pageComments);
1669
+
1670
+ if (pageComments.length < REVIEW_COMMENTS_PAGE_SIZE) {
1671
+ break;
1672
+ }
1673
+
1674
+ page += 1;
1675
+ }
1676
+
1677
+ return reviewComments;
1678
+ }
1679
+
1680
+ async function fetchRunSnapshot(
1681
+ cwd: string,
1682
+ repo: string,
1683
+ runId: number,
1684
+ signal?: AbortSignal,
1685
+ ): Promise<GhRunSnapshot> {
1686
+ const [run, jobs] = await Promise.all([
1687
+ runGhJson<GhActionsRunApi>(cwd, ["api", "--method", "GET", `/repos/${repo}/actions/runs/${runId}`], signal, {
1688
+ repoProvided: true,
1689
+ }),
1690
+ fetchRunJobs(cwd, repo, runId, signal),
1691
+ ]);
1692
+
1693
+ return normalizeRunSnapshot(run, jobs);
1694
+ }
1695
+
1696
+ function tailLogLines(log: string, tail: number): string | undefined {
1697
+ const normalized = normalizeBlock(log);
1698
+ if (!normalized) {
1699
+ return undefined;
1700
+ }
1701
+
1702
+ const lines = normalized.split("\n");
1703
+ return lines.slice(-tail).join("\n").trimEnd();
1704
+ }
1705
+
1706
+ async function fetchFailedJobLogs(
1707
+ cwd: string,
1708
+ repo: string,
1709
+ failedJobs: Array<{ run: GhRunSnapshot; job: GhRunJobSnapshot }>,
1710
+ tail: number,
1711
+ signal?: AbortSignal,
1712
+ ): Promise<GhFailedJobLog[]> {
1713
+ return Promise.all(
1714
+ failedJobs.map(async entry => {
1715
+ const result = await runGhCommand(cwd, ["api", `/repos/${repo}/actions/jobs/${entry.job.id}/logs`], signal);
1716
+ const fullLog = result.exitCode === 0 ? normalizeBlock(result.stdout) : undefined;
1717
+ const logTail = fullLog ? tailLogLines(fullLog, tail) : undefined;
1718
+ return {
1719
+ run: entry.run,
1720
+ job: entry.job,
1721
+ full: fullLog,
1722
+ tail: logTail,
1723
+ available: Boolean(fullLog),
1724
+ };
1725
+ }),
1726
+ );
1727
+ }
1728
+
1729
+ function formatCommentsSection(comments: GhComment[] | undefined): string[] {
1730
+ if (!comments || comments.length === 0) {
1731
+ return [];
1732
+ }
1733
+
1734
+ const visible = comments.filter(comment => !comment.isMinimized);
1735
+ const hiddenCount = comments.length - visible.length;
1736
+ const lines: string[] = ["## Comments", ""];
1737
+
1738
+ if (visible.length === 0) {
1739
+ lines.push(`No visible comments. Minimized comments omitted: ${hiddenCount}.`);
1740
+ return lines;
1741
+ }
1742
+
1743
+ lines[0] = `## Comments (${visible.length})`;
1744
+
1745
+ for (const comment of visible) {
1746
+ const author = formatAuthor(comment.author) ?? "unknown";
1747
+ const createdAt = comment.createdAt ? ` · ${comment.createdAt}` : "";
1748
+ lines.push(`### ${author}${createdAt}`);
1749
+ lines.push("");
1750
+ lines.push(normalizeText(comment.body) || "No comment body.");
1751
+ if (comment.url) {
1752
+ lines.push("");
1753
+ lines.push(`URL: ${comment.url}`);
1754
+ }
1755
+ lines.push("");
1756
+ }
1757
+
1758
+ if (hiddenCount > 0) {
1759
+ lines.push(`Minimized comments omitted: ${hiddenCount}.`);
1760
+ }
1761
+
1762
+ return lines;
1763
+ }
1764
+
1765
+ function formatReviewsSection(reviews: GhPrReview[] | undefined): string[] {
1766
+ if (!reviews || reviews.length === 0) {
1767
+ return [];
1768
+ }
1769
+
1770
+ const lines: string[] = [`## Reviews (${reviews.length})`, ""];
1771
+ for (const review of reviews) {
1772
+ const author = formatAuthor(review.author) ?? "unknown";
1773
+ const submittedAt = review.submittedAt ? ` - ${review.submittedAt}` : "";
1774
+ const state = review.state ? ` [${review.state}]` : "";
1775
+ lines.push(`### ${author}${submittedAt}${state}`);
1776
+ if (review.commit?.oid) {
1777
+ lines.push("");
1778
+ lines.push(`Commit: ${formatShortSha(review.commit.oid)}`);
1779
+ }
1780
+ lines.push("");
1781
+ lines.push(normalizeText(review.body) || "No review body.");
1782
+ lines.push("");
1783
+ }
1784
+
1785
+ return lines;
1786
+ }
1787
+
1788
+ function formatReviewCommentLocation(comment: GhPrReviewComment): string | undefined {
1789
+ if (!comment.path) {
1790
+ return undefined;
1791
+ }
1792
+
1793
+ const line = comment.line ?? comment.originalLine;
1794
+ return line === undefined ? comment.path : `${comment.path}:${line}`;
1795
+ }
1796
+
1797
+ function formatReviewCommentsSection(comments: GhPrReviewComment[] | undefined): string[] {
1798
+ if (!comments || comments.length === 0) {
1799
+ return [];
1800
+ }
1801
+
1802
+ const lines: string[] = [`## Review Comments (${comments.length})`, ""];
1803
+ for (const comment of comments) {
1804
+ const author = formatAuthor(comment.author) ?? "unknown";
1805
+ const createdAt = comment.createdAt ? ` · ${comment.createdAt}` : "";
1806
+ lines.push(`### ${author}${createdAt}`);
1807
+ lines.push("");
1808
+ pushLine(lines, "Location", formatReviewCommentLocation(comment));
1809
+ pushLine(lines, "Side", comment.side);
1810
+ pushLine(lines, "Reply to", comment.inReplyToId);
1811
+ pushLine(lines, "URL", comment.url);
1812
+ lines.push("");
1813
+ lines.push(normalizeText(comment.body) || "No review comment body.");
1814
+ lines.push("");
1815
+ }
1816
+
1817
+ return lines;
1818
+ }
1819
+
1820
+ function formatRepoView(data: GhRepoViewData, input: GhRepoViewInput): string {
1821
+ const lines: string[] = [];
1822
+ const name = data.nameWithOwner ?? input.repo ?? "GitHub Repository";
1823
+ lines.push(`# ${name}`);
1824
+ lines.push("");
1825
+ lines.push(normalizeText(data.description) || "No description provided.");
1826
+ lines.push("");
1827
+ pushLine(lines, "URL", data.url);
1828
+ pushLine(lines, "Default branch", data.defaultBranchRef?.name);
1829
+ pushLine(lines, "Branch", normalizeOptionalString(input.branch));
1830
+ pushLine(lines, "Visibility", data.visibility ?? undefined);
1831
+ pushLine(lines, "Viewer permission", data.viewerPermission ?? undefined);
1832
+ pushLine(lines, "Primary language", data.primaryLanguage?.name);
1833
+ pushLine(lines, "Stars", data.stargazerCount);
1834
+ pushLine(lines, "Forks", data.forkCount);
1835
+ pushLine(lines, "Archived", data.isArchived);
1836
+ pushLine(lines, "Fork", data.isFork);
1837
+ pushLine(lines, "Updated", data.updatedAt);
1838
+ pushLine(lines, "Homepage", data.homepageUrl ?? undefined);
1839
+ const topics = data.repositoryTopics
1840
+ ?.map(topic => topic.name ?? topic.topic?.name)
1841
+ .filter((value): value is string => Boolean(value))
1842
+ .join(", ");
1843
+ pushLine(lines, "Topics", topics || undefined);
1844
+ return lines.join("\n").trim();
1845
+ }
1846
+
1847
+ function formatIssueView(data: GhIssueViewData, input: GhIssueViewInput): string {
1848
+ const lines: string[] = [];
1849
+ const issueNumber = data.number ?? input.issue;
1850
+ lines.push(`# Issue #${issueNumber}: ${data.title ?? "Untitled"}`);
1851
+ lines.push("");
1852
+ pushLine(lines, "State", data.state);
1853
+ pushLine(lines, "State reason", data.stateReason ?? undefined);
1854
+ pushLine(lines, "Author", formatAuthor(data.author));
1855
+ pushLine(lines, "Created", data.createdAt);
1856
+ pushLine(lines, "Updated", data.updatedAt);
1857
+ pushLine(lines, "Labels", formatLabels(data.labels));
1858
+ pushLine(lines, "URL", data.url);
1859
+ lines.push("");
1860
+ lines.push("## Body");
1861
+ lines.push("");
1862
+ lines.push(normalizeText(data.body) || "No description provided.");
1863
+
1864
+ if ((input.comments ?? true) && data.comments) {
1865
+ const commentSection = formatCommentsSection(data.comments);
1866
+ if (commentSection.length > 0) {
1867
+ lines.push("");
1868
+ lines.push(...commentSection);
1869
+ }
1870
+ }
1871
+
1872
+ return lines.join("\n").trim();
1873
+ }
1874
+
1875
+ function formatPrFiles(files: GhPrFile[] | undefined): string[] {
1876
+ if (!files || files.length === 0) return [];
1877
+
1878
+ const lines: string[] = [`## Files (${files.length})`, ""];
1879
+ for (const file of files.slice(0, FILE_PREVIEW_LIMIT)) {
1880
+ const changeType = file.changeType ?? "CHANGED";
1881
+ const additions = file.additions ?? 0;
1882
+ const deletions = file.deletions ?? 0;
1883
+ lines.push(`- ${file.path ?? "(unknown file)"} [${changeType}] (+${additions} -${deletions})`);
1884
+ }
1885
+
1886
+ if (files.length > FILE_PREVIEW_LIMIT) {
1887
+ lines.push(`- ... ${files.length - FILE_PREVIEW_LIMIT} more files`);
1888
+ }
1889
+
1890
+ return lines;
1891
+ }
1892
+
1893
+ function formatPrView(data: GhPrViewData, input: GhPrViewInput): string {
1894
+ const lines: string[] = [];
1895
+ const prIdentifier = data.number ?? input.pr ?? "current";
1896
+ lines.push(`# Pull Request #${prIdentifier}: ${data.title ?? "Untitled"}`);
1897
+ lines.push("");
1898
+ pushLine(lines, "State", data.state);
1899
+ pushLine(lines, "Draft", data.isDraft);
1900
+ pushLine(lines, "Author", formatAuthor(data.author));
1901
+ pushLine(lines, "Base", data.baseRefName);
1902
+ pushLine(lines, "Head", data.headRefName);
1903
+ pushLine(lines, "Review decision", data.reviewDecision ?? undefined);
1904
+ pushLine(lines, "Merge state", data.mergeStateStatus);
1905
+ pushLine(lines, "Created", data.createdAt);
1906
+ pushLine(lines, "Updated", data.updatedAt);
1907
+ pushLine(lines, "Labels", formatLabels(data.labels));
1908
+ pushLine(lines, "URL", data.url);
1909
+ lines.push("");
1910
+ lines.push("## Body");
1911
+ lines.push("");
1912
+ lines.push(normalizeText(data.body) || "No description provided.");
1913
+
1914
+ const fileSection = formatPrFiles(data.files);
1915
+ if (fileSection.length > 0) {
1916
+ lines.push("");
1917
+ lines.push(...fileSection);
1918
+ }
1919
+
1920
+ if ((input.comments ?? true) && data.reviews) {
1921
+ const reviewSection = formatReviewsSection(data.reviews);
1922
+ if (reviewSection.length > 0) {
1923
+ lines.push("");
1924
+ lines.push(...reviewSection);
1925
+ }
1926
+ }
1927
+
1928
+ if ((input.comments ?? true) && data.reviewComments) {
1929
+ const reviewCommentsSection = formatReviewCommentsSection(data.reviewComments);
1930
+ if (reviewCommentsSection.length > 0) {
1931
+ lines.push("");
1932
+ lines.push(...reviewCommentsSection);
1933
+ }
1934
+ }
1935
+
1936
+ if ((input.comments ?? true) && data.comments) {
1937
+ const commentSection = formatCommentsSection(data.comments);
1938
+ if (commentSection.length > 0) {
1939
+ lines.push("");
1940
+ lines.push(...commentSection);
1941
+ }
1942
+ }
1943
+
1944
+ return lines.join("\n").trim();
1945
+ }
1946
+
1947
+ function formatPrCheckoutResult(options: {
1948
+ data: GhPrViewData;
1949
+ localBranch: string;
1950
+ worktreePath: string;
1951
+ remoteName: string;
1952
+ remoteUrl: string;
1953
+ reused: boolean;
1954
+ }): string {
1955
+ const { data, localBranch, worktreePath, remoteName, remoteUrl, reused } = options;
1956
+ const lines: string[] = [
1957
+ reused ? `# Pull Request #${data.number ?? "?"} Worktree` : `# Checked Out Pull Request #${data.number ?? "?"}`,
1958
+ "",
1959
+ ];
1960
+ pushLine(lines, "Title", data.title ?? undefined);
1961
+ pushLine(lines, "URL", data.url);
1962
+ pushLine(lines, "Base", data.baseRefName);
1963
+ pushLine(lines, "Head", data.headRefName);
1964
+ pushLine(lines, "Local branch", localBranch);
1965
+ pushLine(lines, "Worktree", worktreePath);
1966
+ pushLine(lines, "Remote", remoteName);
1967
+ pushLine(lines, "Remote URL", remoteUrl);
1968
+ pushLine(lines, "Cross repository", data.isCrossRepository);
1969
+ pushLine(lines, "Maintainer can modify", data.maintainerCanModify);
1970
+ lines.push("");
1971
+ lines.push(
1972
+ reused
1973
+ ? "Reused the existing PR worktree."
1974
+ : "Created a dedicated worktree for this PR and configured the local branch to push back to the PR head branch.",
1975
+ );
1976
+ return lines.join("\n").trim();
1977
+ }
1978
+
1979
+ function formatPrPushResult(options: {
1980
+ localBranch: string;
1981
+ remoteName: string;
1982
+ remoteBranch: string;
1983
+ remoteUrl?: string;
1984
+ prUrl?: string;
1985
+ forceWithLease: boolean;
1986
+ }): string {
1987
+ const lines: string[] = ["# Pushed Pull Request Branch", ""];
1988
+ pushLine(lines, "Local branch", options.localBranch);
1989
+ pushLine(lines, "Remote", options.remoteName);
1990
+ pushLine(lines, "Remote branch", options.remoteBranch);
1991
+ pushLine(lines, "Remote URL", options.remoteUrl);
1992
+ pushLine(lines, "PR", options.prUrl);
1993
+ pushLine(lines, "Force with lease", options.forceWithLease);
1994
+ lines.push("");
1995
+ lines.push(`Pushed ${options.localBranch} to ${options.remoteName}:${options.remoteBranch}.`);
1996
+ return lines.join("\n").trim();
1997
+ }
1998
+
1999
+ function formatSearchResults(
2000
+ kind: "issues" | "pull requests",
2001
+ query: string,
2002
+ repo: string | undefined,
2003
+ items: GhSearchResult[],
2004
+ ): string {
2005
+ const lines: string[] = [`# GitHub ${kind} search`, "", `Query: ${query}`];
2006
+ pushLine(lines, "Repository", repo);
2007
+ pushLine(lines, "Results", items.length);
2008
+
2009
+ if (items.length === 0) {
2010
+ lines.push("");
2011
+ lines.push(`No ${kind} found.`);
2012
+ return lines.join("\n").trim();
2013
+ }
2014
+
2015
+ for (const item of items) {
2016
+ lines.push("");
2017
+ lines.push(`- #${item.number ?? "?"} ${item.title ?? "Untitled"}`);
2018
+ pushLine(lines, " Repo", item.repository?.nameWithOwner);
2019
+ pushLine(lines, " State", item.state);
2020
+ pushLine(lines, " Author", formatAuthor(item.author));
2021
+ pushLine(lines, " Labels", formatLabels(item.labels));
2022
+ pushLine(lines, " Created", item.createdAt);
2023
+ pushLine(lines, " Updated", item.updatedAt);
2024
+ pushLine(lines, " URL", item.url);
2025
+ }
2026
+
2027
+ return lines.join("\n").trim();
2028
+ }
2029
+
2030
+ async function saveArtifactText(session: ToolSession, toolType: string, text: string): Promise<string | undefined> {
2031
+ const { path: artifactPath, id: artifactId } = (await session.allocateOutputArtifact?.(toolType)) ?? {};
2032
+ if (!artifactPath || !artifactId) {
2033
+ return undefined;
2034
+ }
2035
+
2036
+ await Bun.write(artifactPath, text);
2037
+ return artifactId;
2038
+ }
2039
+
2040
+ function appendArtifactReference(text: string, artifactId: string | undefined, label: string): string {
2041
+ if (!artifactId) {
2042
+ return text;
2043
+ }
2044
+
2045
+ return `${text}\n\n${label}: artifact://${artifactId}`;
2046
+ }
2047
+
2048
+ function buildTextResult(
2049
+ text: string,
2050
+ sourceUrl?: string,
2051
+ details?: GhToolDetails,
2052
+ options?: { artifactId?: string; artifactLabel?: string },
2053
+ ): AgentToolResult<GhToolDetails> {
2054
+ const builder = toolResult<GhToolDetails>(details).text(
2055
+ appendArtifactReference(text, options?.artifactId, options?.artifactLabel ?? "Saved artifact"),
2056
+ );
2057
+ if (sourceUrl) {
2058
+ builder.sourceUrl(sourceUrl);
2059
+ }
2060
+ return builder.done();
2061
+ }
2062
+
2063
+ export class GhRepoViewTool implements AgentTool<typeof ghRepoViewSchema, GhToolDetails> {
2064
+ readonly name = "gh_repo_view";
2065
+ readonly label = "GitHub Repo";
2066
+ readonly description = renderPromptTemplate(ghRepoViewDescription);
2067
+ readonly parameters = ghRepoViewSchema;
2068
+ readonly strict = true;
2069
+
2070
+ constructor(private readonly session: ToolSession) {}
2071
+
2072
+ static createIf(session: ToolSession): GhRepoViewTool | null {
2073
+ if (!isGhAvailable()) return null;
2074
+ return new GhRepoViewTool(session);
2075
+ }
2076
+
2077
+ async execute(
2078
+ _toolCallId: string,
2079
+ params: GhRepoViewInput,
2080
+ signal?: AbortSignal,
2081
+ _onUpdate?: AgentToolUpdateCallback<GhToolDetails>,
2082
+ _context?: AgentToolContext,
2083
+ ): Promise<AgentToolResult<GhToolDetails>> {
2084
+ return untilAborted(signal, async () => {
2085
+ const repo = normalizeOptionalString(params.repo);
2086
+ const branch = normalizeOptionalString(params.branch);
2087
+ const args = ["repo", "view"];
2088
+ if (repo) {
2089
+ args.push(repo);
2090
+ }
2091
+ if (branch) {
2092
+ args.push("--branch", branch);
2093
+ }
2094
+ args.push("--json", GH_REPO_FIELDS.join(","));
2095
+
2096
+ const data = await runGhJson<GhRepoViewData>(this.session.cwd, args, signal, { repoProvided: Boolean(repo) });
2097
+ return buildTextResult(formatRepoView(data, { repo, branch }), data.url);
2098
+ });
2099
+ }
2100
+ }
2101
+
2102
+ export class GhIssueViewTool implements AgentTool<typeof ghIssueViewSchema, GhToolDetails> {
2103
+ readonly name = "gh_issue_view";
2104
+ readonly label = "GitHub Issue";
2105
+ readonly description = renderPromptTemplate(ghIssueViewDescription);
2106
+ readonly parameters = ghIssueViewSchema;
2107
+ readonly strict = true;
2108
+
2109
+ constructor(private readonly session: ToolSession) {}
2110
+
2111
+ static createIf(session: ToolSession): GhIssueViewTool | null {
2112
+ if (!isGhAvailable()) return null;
2113
+ return new GhIssueViewTool(session);
2114
+ }
2115
+
2116
+ async execute(
2117
+ _toolCallId: string,
2118
+ params: GhIssueViewInput,
2119
+ signal?: AbortSignal,
2120
+ _onUpdate?: AgentToolUpdateCallback<GhToolDetails>,
2121
+ _context?: AgentToolContext,
2122
+ ): Promise<AgentToolResult<GhToolDetails>> {
2123
+ return untilAborted(signal, async () => {
2124
+ const issue = requireNonEmpty(params.issue, "issue");
2125
+ const repo = normalizeOptionalString(params.repo);
2126
+ const includeComments = params.comments ?? true;
2127
+ const args = ["issue", "view", issue];
2128
+ appendRepoFlag(args, repo, issue);
2129
+ args.push("--json", (includeComments ? GH_ISSUE_FIELDS : GH_ISSUE_FIELDS_NO_COMMENTS).join(","));
2130
+
2131
+ const data = await runGhJson<GhIssueViewData>(this.session.cwd, args, signal, { repoProvided: Boolean(repo) });
2132
+ return buildTextResult(formatIssueView(data, { issue, repo, comments: includeComments }), data.url);
2133
+ });
2134
+ }
2135
+ }
2136
+
2137
+ export class GhPrViewTool implements AgentTool<typeof ghPrViewSchema, GhToolDetails> {
2138
+ readonly name = "gh_pr_view";
2139
+ readonly label = "GitHub PR";
2140
+ readonly description = renderPromptTemplate(ghPrViewDescription);
2141
+ readonly parameters = ghPrViewSchema;
2142
+ readonly strict = true;
2143
+
2144
+ constructor(private readonly session: ToolSession) {}
2145
+
2146
+ static createIf(session: ToolSession): GhPrViewTool | null {
2147
+ if (!isGhAvailable()) return null;
2148
+ return new GhPrViewTool(session);
2149
+ }
2150
+
2151
+ async execute(
2152
+ _toolCallId: string,
2153
+ params: GhPrViewInput,
2154
+ signal?: AbortSignal,
2155
+ _onUpdate?: AgentToolUpdateCallback<GhToolDetails>,
2156
+ _context?: AgentToolContext,
2157
+ ): Promise<AgentToolResult<GhToolDetails>> {
2158
+ return untilAborted(signal, async () => {
2159
+ const pr = normalizeOptionalString(params.pr);
2160
+ const repo = normalizeOptionalString(params.repo);
2161
+ const includeComments = params.comments ?? true;
2162
+ const args = ["pr", "view"];
2163
+ if (pr) {
2164
+ args.push(pr);
2165
+ }
2166
+ appendRepoFlag(args, repo, pr);
2167
+ args.push("--json", (includeComments ? GH_PR_FIELDS : GH_PR_FIELDS_NO_COMMENTS).join(","));
2168
+
2169
+ const data = await runGhJson<GhPrViewData>(this.session.cwd, args, signal, { repoProvided: Boolean(repo) });
2170
+ const resolvedRepo = repo ?? parsePullRequestUrl(data.url).repo;
2171
+ if (includeComments && resolvedRepo && typeof data.number === "number") {
2172
+ data.reviewComments = await fetchPrReviewComments(this.session.cwd, resolvedRepo, data.number, signal);
2173
+ }
2174
+ return buildTextResult(formatPrView(data, { pr, repo, comments: includeComments }), data.url);
2175
+ });
2176
+ }
2177
+ }
2178
+
2179
+ export class GhPrDiffTool implements AgentTool<typeof ghPrDiffSchema, GhToolDetails> {
2180
+ readonly name = "gh_pr_diff";
2181
+ readonly label = "GitHub PR Diff";
2182
+ readonly description = renderPromptTemplate(ghPrDiffDescription);
2183
+ readonly parameters = ghPrDiffSchema;
2184
+ readonly strict = true;
2185
+
2186
+ constructor(private readonly session: ToolSession) {}
2187
+
2188
+ static createIf(session: ToolSession): GhPrDiffTool | null {
2189
+ if (!isGhAvailable()) return null;
2190
+ return new GhPrDiffTool(session);
2191
+ }
2192
+
2193
+ async execute(
2194
+ _toolCallId: string,
2195
+ params: GhPrDiffInput,
2196
+ signal?: AbortSignal,
2197
+ _onUpdate?: AgentToolUpdateCallback<GhToolDetails>,
2198
+ _context?: AgentToolContext,
2199
+ ): Promise<AgentToolResult<GhToolDetails>> {
2200
+ return untilAborted(signal, async () => {
2201
+ const pr = normalizeOptionalString(params.pr);
2202
+ const repo = normalizeOptionalString(params.repo);
2203
+ const args = ["pr", "diff"];
2204
+ if (pr) {
2205
+ args.push(pr);
2206
+ }
2207
+ appendRepoFlag(args, repo, pr);
2208
+ args.push("--color", "never");
2209
+ if (params.nameOnly) {
2210
+ args.push("--name-only");
2211
+ }
2212
+ for (const pattern of params.exclude ?? []) {
2213
+ const normalizedPattern = requireNonEmpty(pattern, "exclude pattern");
2214
+ args.push("--exclude", normalizedPattern);
2215
+ }
2216
+
2217
+ const output = await runGhText(this.session.cwd, args, signal, {
2218
+ repoProvided: Boolean(repo),
2219
+ trimOutput: false,
2220
+ });
2221
+ const title = params.nameOnly ? "# Pull Request Files" : "# Pull Request Diff";
2222
+ const body = output.length > 0 ? output : params.nameOnly ? "No changed files." : "No diff output.";
2223
+ return buildTextResult(`${title}\n\n${body}`);
2224
+ });
2225
+ }
2226
+ }
2227
+
2228
+ export class GhPrCheckoutTool implements AgentTool<typeof ghPrCheckoutSchema, GhToolDetails> {
2229
+ readonly name = "gh_pr_checkout";
2230
+ readonly label = "GitHub PR Checkout";
2231
+ readonly description = renderPromptTemplate(ghPrCheckoutDescription);
2232
+ readonly parameters = ghPrCheckoutSchema;
2233
+ readonly strict = true;
2234
+
2235
+ constructor(private readonly session: ToolSession) {}
2236
+
2237
+ static createIf(session: ToolSession): GhPrCheckoutTool | null {
2238
+ if (!isGhAvailable()) return null;
2239
+ return new GhPrCheckoutTool(session);
2240
+ }
2241
+
2242
+ async execute(
2243
+ _toolCallId: string,
2244
+ params: GhPrCheckoutInput,
2245
+ signal?: AbortSignal,
2246
+ _onUpdate?: AgentToolUpdateCallback<GhToolDetails>,
2247
+ _context?: AgentToolContext,
2248
+ ): Promise<AgentToolResult<GhToolDetails>> {
2249
+ return untilAborted(signal, async () => {
2250
+ const pr = normalizeOptionalString(params.pr);
2251
+ const repo = normalizeOptionalString(params.repo);
2252
+ const requestedBranch = normalizeOptionalString(params.branch);
2253
+ const requestedWorktree = normalizeOptionalString(params.worktree);
2254
+ const force = params.force ?? false;
2255
+ const args = ["pr", "view"];
2256
+ if (pr) {
2257
+ args.push(pr);
2258
+ }
2259
+ appendRepoFlag(args, repo, pr);
2260
+ args.push("--json", GH_PR_CHECKOUT_FIELDS.join(","));
2261
+
2262
+ const data = await runGhJson<GhPrViewData>(this.session.cwd, args, signal, {
2263
+ repoProvided: Boolean(repo),
2264
+ });
2265
+ const prNumber = data.number;
2266
+ if (typeof prNumber !== "number") {
2267
+ throw new ToolError("GitHub CLI did not return a pull request number.");
2268
+ }
2269
+
2270
+ const headRefName = requireNonEmpty(data.headRefName, "head branch");
2271
+ const headRefOid = requireNonEmpty(data.headRefOid, "head commit");
2272
+ const repoRoot = await resolveGitRepoRoot(this.session.cwd, signal);
2273
+ const primaryRepoRoot = await resolvePrimaryGitRepoRoot(repoRoot, signal);
2274
+ const localBranch = requestedBranch ?? `pr-${prNumber}`;
2275
+ const worktreePath = requestedWorktree
2276
+ ? path.resolve(this.session.cwd, requestedWorktree)
2277
+ : path.join(primaryRepoRoot, ".worktrees", localBranch);
2278
+ const existingWorktrees = await listGitWorktrees(repoRoot, signal);
2279
+ const existingWorktree = existingWorktrees.find(entry => entry.branch === toLocalBranchRef(localBranch));
2280
+
2281
+ const remote = await ensurePrRemote(repoRoot, data, signal);
2282
+ await runGitChecked(
2283
+ repoRoot,
2284
+ ["fetch", remote.name, `+refs/heads/${headRefName}:refs/remotes/${remote.name}/${headRefName}`],
2285
+ signal,
2286
+ );
2287
+
2288
+ if (!existingWorktree) {
2289
+ const localBranchRef = toLocalBranchRef(localBranch);
2290
+ const localBranchExists = await gitRefExists(repoRoot, localBranchRef, signal);
2291
+ if (localBranchExists) {
2292
+ const existingOid = await runGitTextChecked(repoRoot, ["rev-parse", localBranchRef], signal);
2293
+ if (existingOid !== headRefOid) {
2294
+ if (!force) {
2295
+ throw new ToolError(
2296
+ `local branch ${localBranch} already exists at ${formatShortSha(existingOid) ?? existingOid}; pass force=true to reset it`,
2297
+ );
2298
+ }
2299
+
2300
+ const resetResult = await runGitCommand(
2301
+ repoRoot,
2302
+ ["branch", "--force", localBranch, `refs/remotes/${remote.name}/${headRefName}`],
2303
+ signal,
2304
+ );
2305
+ if (resetResult.exitCode !== 0) {
2306
+ throw new ToolError(formatGitFailure(["branch", "--force", localBranch], resetResult));
2307
+ }
2308
+ }
2309
+ } else {
2310
+ const createResult = await runGitCommand(
2311
+ repoRoot,
2312
+ ["branch", localBranch, `refs/remotes/${remote.name}/${headRefName}`],
2313
+ signal,
2314
+ );
2315
+ if (createResult.exitCode !== 0) {
2316
+ throw new ToolError(formatGitFailure(["branch", localBranch], createResult));
2317
+ }
2318
+ }
2319
+ }
2320
+
2321
+ await setBranchConfig(repoRoot, localBranch, "remote", remote.name, signal);
2322
+ await setBranchConfig(repoRoot, localBranch, "merge", `refs/heads/${headRefName}`, signal);
2323
+ await setBranchConfig(repoRoot, localBranch, "pushRemote", remote.name, signal);
2324
+ await setBranchConfig(repoRoot, localBranch, "ompPrHeadRef", headRefName, signal);
2325
+ await setBranchConfig(repoRoot, localBranch, "ompPrUrl", data.url ?? "", signal);
2326
+ await setBranchConfig(
2327
+ repoRoot,
2328
+ localBranch,
2329
+ "ompPrIsCrossRepository",
2330
+ String(Boolean(data.isCrossRepository)),
2331
+ signal,
2332
+ );
2333
+ await setBranchConfig(
2334
+ repoRoot,
2335
+ localBranch,
2336
+ "ompPrMaintainerCanModify",
2337
+ String(Boolean(data.maintainerCanModify)),
2338
+ signal,
2339
+ );
2340
+
2341
+ const finalWorktreePath = existingWorktree?.path ?? worktreePath;
2342
+ if (!existingWorktree) {
2343
+ await ensureGitWorktreePathAvailable(finalWorktreePath, existingWorktrees);
2344
+ await fs.mkdir(path.dirname(finalWorktreePath), { recursive: true });
2345
+ const addResult = await runGitCommand(
2346
+ repoRoot,
2347
+ ["worktree", "add", finalWorktreePath, localBranch],
2348
+ signal,
2349
+ );
2350
+ if (addResult.exitCode !== 0) {
2351
+ throw new ToolError(formatGitFailure(["worktree", "add", finalWorktreePath, localBranch], addResult));
2352
+ }
2353
+ }
2354
+
2355
+ return buildTextResult(
2356
+ formatPrCheckoutResult({
2357
+ data,
2358
+ localBranch,
2359
+ worktreePath: finalWorktreePath,
2360
+ remoteName: remote.name,
2361
+ remoteUrl: remote.url,
2362
+ reused: Boolean(existingWorktree),
2363
+ }),
2364
+ data.url,
2365
+ {
2366
+ repo: repo ?? data.headRepository?.nameWithOwner,
2367
+ branch: localBranch,
2368
+ worktreePath: finalWorktreePath,
2369
+ remote: remote.name,
2370
+ remoteBranch: headRefName,
2371
+ },
2372
+ );
2373
+ });
2374
+ }
2375
+ }
2376
+
2377
+ export class GhPrPushTool implements AgentTool<typeof ghPrPushSchema, GhToolDetails> {
2378
+ readonly name = "gh_pr_push";
2379
+ readonly label = "GitHub PR Push";
2380
+ readonly description = renderPromptTemplate(ghPrPushDescription);
2381
+ readonly parameters = ghPrPushSchema;
2382
+ readonly strict = true;
2383
+
2384
+ constructor(private readonly session: ToolSession) {}
2385
+
2386
+ static createIf(session: ToolSession): GhPrPushTool | null {
2387
+ if (!isGhAvailable()) return null;
2388
+ return new GhPrPushTool(session);
2389
+ }
2390
+
2391
+ async execute(
2392
+ _toolCallId: string,
2393
+ params: GhPrPushInput,
2394
+ signal?: AbortSignal,
2395
+ _onUpdate?: AgentToolUpdateCallback<GhToolDetails>,
2396
+ _context?: AgentToolContext,
2397
+ ): Promise<AgentToolResult<GhToolDetails>> {
2398
+ return untilAborted(signal, async () => {
2399
+ const repoRoot = await resolveGitRepoRoot(this.session.cwd, signal);
2400
+ const localBranch =
2401
+ normalizeOptionalString(params.branch) ?? (await resolveCurrentGitBranch(repoRoot, signal));
2402
+ const refExists = await gitRefExists(repoRoot, toLocalBranchRef(localBranch), signal);
2403
+ if (!refExists) {
2404
+ throw new ToolError(`local branch ${localBranch} does not exist`);
2405
+ }
2406
+
2407
+ const target = await resolvePrBranchPushTarget(repoRoot, localBranch, signal);
2408
+ const currentBranch = await tryRunGitText(repoRoot, ["branch", "--show-current"], signal);
2409
+ const sourceRef = currentBranch === localBranch ? "HEAD" : toLocalBranchRef(localBranch);
2410
+ const refspec = `${sourceRef}:refs/heads/${target.remoteBranch}`;
2411
+ const pushArgs = ["push"];
2412
+ if (params.forceWithLease) {
2413
+ pushArgs.push("--force-with-lease");
2414
+ }
2415
+ pushArgs.push(target.remoteName, refspec);
2416
+
2417
+ const pushResult = await runGitCommand(repoRoot, pushArgs, signal);
2418
+ if (pushResult.exitCode !== 0) {
2419
+ throw new ToolError(formatGitFailure(pushArgs, pushResult));
2420
+ }
2421
+
2422
+ return buildTextResult(
2423
+ formatPrPushResult({
2424
+ localBranch,
2425
+ remoteName: target.remoteName,
2426
+ remoteBranch: target.remoteBranch,
2427
+ remoteUrl: target.remoteUrl,
2428
+ prUrl: target.prUrl,
2429
+ forceWithLease: params.forceWithLease ?? false,
2430
+ }),
2431
+ target.prUrl,
2432
+ {
2433
+ branch: localBranch,
2434
+ remote: target.remoteName,
2435
+ remoteBranch: target.remoteBranch,
2436
+ },
2437
+ );
2438
+ });
2439
+ }
2440
+ }
2441
+
2442
+ export class GhSearchIssuesTool implements AgentTool<typeof ghSearchIssuesSchema, GhToolDetails> {
2443
+ readonly name = "gh_search_issues";
2444
+ readonly label = "GitHub Issue Search";
2445
+ readonly description = renderPromptTemplate(ghSearchIssuesDescription);
2446
+ readonly parameters = ghSearchIssuesSchema;
2447
+ readonly strict = true;
2448
+
2449
+ constructor(private readonly session: ToolSession) {}
2450
+
2451
+ static createIf(session: ToolSession): GhSearchIssuesTool | null {
2452
+ if (!isGhAvailable()) return null;
2453
+ return new GhSearchIssuesTool(session);
2454
+ }
2455
+
2456
+ async execute(
2457
+ _toolCallId: string,
2458
+ params: GhSearchIssuesInput,
2459
+ signal?: AbortSignal,
2460
+ _onUpdate?: AgentToolUpdateCallback<GhToolDetails>,
2461
+ _context?: AgentToolContext,
2462
+ ): Promise<AgentToolResult<GhToolDetails>> {
2463
+ return untilAborted(signal, async () => {
2464
+ const query = requireNonEmpty(params.query, "query");
2465
+ const repo = normalizeOptionalString(params.repo);
2466
+ const limit = resolveSearchLimit(params.limit);
2467
+ const args = buildGhSearchArgs("issues", query, limit, repo);
2468
+
2469
+ const items = await runGhJson<GhSearchResult[]>(this.session.cwd, args, signal, {
2470
+ repoProvided: Boolean(repo),
2471
+ });
2472
+ return buildTextResult(formatSearchResults("issues", query, repo, items));
2473
+ });
2474
+ }
2475
+ }
2476
+
2477
+ export class GhSearchPrsTool implements AgentTool<typeof ghSearchPrsSchema, GhToolDetails> {
2478
+ readonly name = "gh_search_prs";
2479
+ readonly label = "GitHub PR Search";
2480
+ readonly description = renderPromptTemplate(ghSearchPrsDescription);
2481
+ readonly parameters = ghSearchPrsSchema;
2482
+ readonly strict = true;
2483
+
2484
+ constructor(private readonly session: ToolSession) {}
2485
+
2486
+ static createIf(session: ToolSession): GhSearchPrsTool | null {
2487
+ if (!isGhAvailable()) return null;
2488
+ return new GhSearchPrsTool(session);
2489
+ }
2490
+
2491
+ async execute(
2492
+ _toolCallId: string,
2493
+ params: GhSearchPrsInput,
2494
+ signal?: AbortSignal,
2495
+ _onUpdate?: AgentToolUpdateCallback<GhToolDetails>,
2496
+ _context?: AgentToolContext,
2497
+ ): Promise<AgentToolResult<GhToolDetails>> {
2498
+ return untilAborted(signal, async () => {
2499
+ const query = requireNonEmpty(params.query, "query");
2500
+ const repo = normalizeOptionalString(params.repo);
2501
+ const limit = resolveSearchLimit(params.limit);
2502
+ const args = buildGhSearchArgs("prs", query, limit, repo);
2503
+
2504
+ const items = await runGhJson<GhSearchResult[]>(this.session.cwd, args, signal, {
2505
+ repoProvided: Boolean(repo),
2506
+ });
2507
+ return buildTextResult(formatSearchResults("pull requests", query, repo, items));
2508
+ });
2509
+ }
2510
+ }
2511
+
2512
+ export class GhRunWatchTool implements AgentTool<typeof ghRunWatchSchema, GhToolDetails> {
2513
+ readonly name = "gh_run_watch";
2514
+ readonly label = "GitHub Run Watch";
2515
+ readonly description = renderPromptTemplate(ghRunWatchDescription);
2516
+ readonly parameters = ghRunWatchSchema;
2517
+ readonly strict = true;
2518
+
2519
+ constructor(private readonly session: ToolSession) {}
2520
+
2521
+ static createIf(session: ToolSession): GhRunWatchTool | null {
2522
+ if (!isGhAvailable()) return null;
2523
+ return new GhRunWatchTool(session);
2524
+ }
2525
+
2526
+ async execute(
2527
+ _toolCallId: string,
2528
+ params: GhRunWatchInput,
2529
+ signal?: AbortSignal,
2530
+ onUpdate?: AgentToolUpdateCallback<GhToolDetails>,
2531
+ _context?: AgentToolContext,
2532
+ ): Promise<AgentToolResult<GhToolDetails>> {
2533
+ return untilAborted(signal, async () => {
2534
+ const branchInput = normalizeOptionalString(params.branch);
2535
+ const runReference = parseRunReference(params.run);
2536
+ const repo = await resolveGitHubRepo(this.session.cwd, undefined, runReference.repo, signal);
2537
+ const intervalSeconds = RUN_WATCH_INTERVAL_DEFAULT;
2538
+ const graceSeconds = RUN_WATCH_GRACE_DEFAULT;
2539
+ const tail = resolveTailLimit(params.tail);
2540
+ if (runReference.runId !== undefined) {
2541
+ const runId = runReference.runId;
2542
+ let pollCount = 0;
2543
+
2544
+ while (true) {
2545
+ throwIfAborted(signal);
2546
+ pollCount += 1;
2547
+
2548
+ let run = await fetchRunSnapshot(this.session.cwd, repo, runId, signal);
2549
+ const details = buildRunWatchDetails(repo, run, {
2550
+ state: "watching",
2551
+ pollCount,
2552
+ });
2553
+ onUpdate?.({
2554
+ content: [{ type: "text", text: formatRunWatchSnapshot(repo, run, pollCount) }],
2555
+ details,
2556
+ });
2557
+
2558
+ const failedJobs = run.jobs.filter(isFailedJob);
2559
+ const runCompleted = run.status === "completed";
2560
+
2561
+ if (failedJobs.length > 0) {
2562
+ if (!runCompleted && graceSeconds > 0) {
2563
+ const note = `Failure detected. Waiting ${graceSeconds}s to capture concurrent failures before fetching logs.`;
2564
+ onUpdate?.({
2565
+ content: [
2566
+ {
2567
+ type: "text",
2568
+ text: formatRunWatchSnapshot(repo, run, pollCount, note),
2569
+ },
2570
+ ],
2571
+ details: buildRunWatchDetails(repo, run, {
2572
+ state: "watching",
2573
+ pollCount,
2574
+ note,
2575
+ }),
2576
+ });
2577
+ await abortableSleep(graceSeconds * 1000, signal);
2578
+ run = await fetchRunSnapshot(this.session.cwd, repo, runId, signal);
2579
+ }
2580
+
2581
+ const failedJobLogs = await fetchFailedJobLogs(
2582
+ this.session.cwd,
2583
+ repo,
2584
+ run.jobs.filter(isFailedJob).map(job => ({ run, job })),
2585
+ tail,
2586
+ signal,
2587
+ );
2588
+ const finalDetails = buildRunWatchDetails(repo, run, {
2589
+ state: "completed",
2590
+ failedJobLogs,
2591
+ });
2592
+ const artifactId = await saveArtifactText(
2593
+ this.session,
2594
+ this.name,
2595
+ formatRunWatchResult(repo, run, failedJobLogs, tail, { mode: "full" }),
2596
+ );
2597
+ return buildTextResult(
2598
+ formatRunWatchResult(repo, run, failedJobLogs, tail),
2599
+ run.url,
2600
+ { ...finalDetails, artifactId },
2601
+ { artifactId, artifactLabel: "Full failed-job logs" },
2602
+ );
2603
+ }
2604
+
2605
+ if (runCompleted) {
2606
+ const finalDetails = buildRunWatchDetails(repo, run, {
2607
+ state: "completed",
2608
+ });
2609
+ return buildTextResult(formatRunWatchResult(repo, run, [], tail), run.url, finalDetails);
2610
+ }
2611
+
2612
+ await abortableSleep(intervalSeconds * 1000, signal);
2613
+ }
2614
+ }
2615
+
2616
+ const branch = branchInput ?? (await resolveCurrentGitBranch(this.session.cwd, signal));
2617
+ const headSha = branchInput
2618
+ ? await resolveGitHubBranchHead(this.session.cwd, repo, branch, signal)
2619
+ : await resolveCurrentGitHead(this.session.cwd, signal);
2620
+ let pollCount = 0;
2621
+ let settledSuccessSignature: string | undefined;
2622
+
2623
+ while (true) {
2624
+ throwIfAborted(signal);
2625
+ pollCount += 1;
2626
+
2627
+ let runs = await fetchRunsForCommit(this.session.cwd, repo, headSha, branch, signal);
2628
+ const details = buildCommitRunWatchDetails(repo, headSha, branch, runs, {
2629
+ state: "watching",
2630
+ pollCount,
2631
+ });
2632
+ onUpdate?.({
2633
+ content: [{ type: "text", text: formatCommitRunWatchSnapshot(repo, headSha, branch, runs, pollCount) }],
2634
+ details,
2635
+ });
2636
+
2637
+ const outcome = getRunCollectionOutcome(runs);
2638
+ if (outcome === "failure") {
2639
+ if (graceSeconds > 0) {
2640
+ const note = `Failure detected. Waiting ${graceSeconds}s to capture concurrent failures before fetching logs.`;
2641
+ onUpdate?.({
2642
+ content: [
2643
+ {
2644
+ type: "text",
2645
+ text: formatCommitRunWatchSnapshot(repo, headSha, branch, runs, pollCount, note),
2646
+ },
2647
+ ],
2648
+ details: buildCommitRunWatchDetails(repo, headSha, branch, runs, {
2649
+ state: "watching",
2650
+ pollCount,
2651
+ note,
2652
+ }),
2653
+ });
2654
+ await abortableSleep(graceSeconds * 1000, signal);
2655
+ runs = await fetchRunsForCommit(this.session.cwd, repo, headSha, branch, signal);
2656
+ }
2657
+
2658
+ const failedJobLogs = await fetchFailedJobLogs(
2659
+ this.session.cwd,
2660
+ repo,
2661
+ runs.flatMap(run => run.jobs.filter(isFailedJob).map(job => ({ run, job }))),
2662
+ tail,
2663
+ signal,
2664
+ );
2665
+ const finalDetails = buildCommitRunWatchDetails(repo, headSha, branch, runs, {
2666
+ state: "completed",
2667
+ failedJobLogs,
2668
+ });
2669
+ const artifactId = await saveArtifactText(
2670
+ this.session,
2671
+ this.name,
2672
+ formatCommitRunWatchResult(repo, headSha, branch, runs, failedJobLogs, tail, { mode: "full" }),
2673
+ );
2674
+ return buildTextResult(
2675
+ formatCommitRunWatchResult(repo, headSha, branch, runs, failedJobLogs, tail),
2676
+ undefined,
2677
+ { ...finalDetails, artifactId },
2678
+ { artifactId, artifactLabel: "Full failed-job logs" },
2679
+ );
2680
+ }
2681
+
2682
+ if (outcome === "success") {
2683
+ const signature = getRunCollectionSignature(runs);
2684
+ if (signature === settledSuccessSignature) {
2685
+ const finalDetails = buildCommitRunWatchDetails(repo, headSha, branch, runs, {
2686
+ state: "completed",
2687
+ });
2688
+ return buildTextResult(
2689
+ formatCommitRunWatchResult(repo, headSha, branch, runs, [], tail),
2690
+ undefined,
2691
+ finalDetails,
2692
+ );
2693
+ }
2694
+
2695
+ settledSuccessSignature = signature;
2696
+ const note = `All known workflow runs completed successfully. Waiting ${intervalSeconds}s to ensure no additional runs appear for this commit.`;
2697
+ onUpdate?.({
2698
+ content: [
2699
+ {
2700
+ type: "text",
2701
+ text: formatCommitRunWatchSnapshot(repo, headSha, branch, runs, pollCount, note),
2702
+ },
2703
+ ],
2704
+ details: buildCommitRunWatchDetails(repo, headSha, branch, runs, {
2705
+ state: "watching",
2706
+ pollCount,
2707
+ note,
2708
+ }),
2709
+ });
2710
+ await abortableSleep(intervalSeconds * 1000, signal);
2711
+ continue;
2712
+ }
2713
+
2714
+ settledSuccessSignature = undefined;
2715
+ await abortableSleep(intervalSeconds * 1000, signal);
2716
+ }
2717
+ });
2718
+ }
2719
+ }