@workit-dev/core 0.0.1 → 1.0.0

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 (234) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -43
  3. package/package.json +56 -6
  4. package/schemas/config.json +74 -0
  5. package/schemas/vcs.json +79 -0
  6. package/schemas/workspaces.json +1029 -0
  7. package/schemas/youtrack.json +84 -0
  8. package/scripts/analyze-release-scope.ts +215 -0
  9. package/scripts/bundled-deps.json +131 -0
  10. package/scripts/fixtures/sample-plan.md +15 -0
  11. package/scripts/fixtures/sample-sdd/progress.md +2 -0
  12. package/scripts/install-codex-plugin.sh +28 -0
  13. package/scripts/install-cursor-plugin.sh +146 -0
  14. package/scripts/install-opencode-plugin.sh +79 -0
  15. package/scripts/install-pi-package.sh +56 -0
  16. package/scripts/lib/config-dir.sh +28 -0
  17. package/scripts/publish-changed-packages.ts +395 -0
  18. package/scripts/rewrite-workspace-deps.ts +53 -0
  19. package/scripts/run-cursor-mcp.sh +6 -0
  20. package/scripts/sync-release-manifests.ts +82 -0
  21. package/scripts/sync-runtime.sh +142 -0
  22. package/scripts/validate-cursor-marketplace.ts +171 -0
  23. package/scripts/verify-bundle-sources.ts +134 -0
  24. package/scripts/write-schemas.ts +16 -0
  25. package/skills/workit-architecture/SKILL.md +72 -0
  26. package/skills/workit-architecture/agents/openai.yaml +3 -0
  27. package/skills/workit-architecture/references/instruction-files.md +52 -0
  28. package/skills/workit-architecture/references/vocabulary.md +53 -0
  29. package/skills/workit-bdd/SKILL.md +53 -0
  30. package/skills/workit-continue/SKILL.md +54 -0
  31. package/skills/workit-debug/SKILL.md +45 -0
  32. package/skills/workit-deslop/SKILL.md +58 -0
  33. package/skills/workit-fanout/SKILL.md +71 -0
  34. package/skills/workit-fanout/references/brief.md +141 -0
  35. package/skills/workit-fanout/references/orchestration.md +169 -0
  36. package/skills/workit-implement/SKILL.md +71 -0
  37. package/skills/workit-retro/SKILL.md +72 -0
  38. package/skills/workit-retro/agents/openai.yaml +3 -0
  39. package/skills/workit-retro/references/sources.md +35 -0
  40. package/skills/workit-retro/references/steering.md +39 -0
  41. package/skills/workit-review/SKILL.md +66 -0
  42. package/skills/workit-review/references/impact.md +24 -0
  43. package/skills/workit-shape/SKILL.md +71 -0
  44. package/skills/workit-shape/references/diagrams.md +17 -0
  45. package/skills/workit-shape/references/knowledge.md +61 -0
  46. package/skills/workit-shape/references/mockups.md +15 -0
  47. package/skills/workit-shape/references/slicing.md +44 -0
  48. package/skills/workit-ship/SKILL.md +71 -0
  49. package/skills/workit-ship/references/changelog.md +44 -0
  50. package/skills/workit-ship/references/pr-body.md +32 -0
  51. package/skills/workit-ship/references/release.md +39 -0
  52. package/skills/workit-test-audit/SKILL.md +54 -0
  53. package/skills/workit-verify-app/SKILL.md +68 -0
  54. package/skills/workit-verify-app/references/template.md +51 -0
  55. package/src/autonomy.ts +910 -0
  56. package/src/check-config.ts +613 -0
  57. package/src/checks.ts +534 -0
  58. package/src/ci-waiter.ts +159 -0
  59. package/src/core/boundary.ts +55 -0
  60. package/src/core/branch-policy.ts +112 -0
  61. package/src/core/branch.ts +1192 -0
  62. package/src/core/changelog-gate.ts +349 -0
  63. package/src/core/changelog-merge.ts +214 -0
  64. package/src/core/changelog.ts +392 -0
  65. package/src/core/cleanup.ts +638 -0
  66. package/src/core/cli-locate.ts +173 -0
  67. package/src/core/commit-flavors.ts +65 -0
  68. package/src/core/config-schemas.ts +653 -0
  69. package/src/core/config.ts +351 -0
  70. package/src/core/context-read.ts +144 -0
  71. package/src/core/docs-gate.ts +69 -0
  72. package/src/core/finding-evidence.ts +89 -0
  73. package/src/core/finding-queue.ts +36 -0
  74. package/src/core/git.ts +73 -0
  75. package/src/core/gitignore.ts +52 -0
  76. package/src/core/heavy-eta.ts +170 -0
  77. package/src/core/heavy-lease.ts +290 -0
  78. package/src/core/heavy-rules.ts +373 -0
  79. package/src/core/heavy-scope.ts +131 -0
  80. package/src/core/heavy-words.ts +83 -0
  81. package/src/core/heavy.ts +535 -0
  82. package/src/core/hygiene.ts +149 -0
  83. package/src/core/init.ts +218 -0
  84. package/src/core/live-session.ts +333 -0
  85. package/src/core/logger.ts +310 -0
  86. package/src/core/methods.ts +185 -0
  87. package/src/core/model-fallback.ts +208 -0
  88. package/src/core/node-resolve.mjs +249 -0
  89. package/src/core/operation-input.ts +632 -0
  90. package/src/core/package-root.ts +28 -0
  91. package/src/core/policy/attributes.ts +97 -0
  92. package/src/core/policy/bookkeeping.ts +119 -0
  93. package/src/core/policy/carry.ts +122 -0
  94. package/src/core/policy/check-evidence.ts +115 -0
  95. package/src/core/policy/current-judgment.ts +20 -0
  96. package/src/core/policy/derive.ts +387 -0
  97. package/src/core/policy/judgment.ts +246 -0
  98. package/src/core/policy/lane.ts +562 -0
  99. package/src/core/policy/pr-lane.ts +32 -0
  100. package/src/core/policy/substance.ts +365 -0
  101. package/src/core/policy-resolver.ts +94 -0
  102. package/src/core/pr-create.ts +448 -0
  103. package/src/core/pr-template.ts +162 -0
  104. package/src/core/release-notes.ts +197 -0
  105. package/src/core/release-tracks.ts +779 -0
  106. package/src/core/remotes.ts +436 -0
  107. package/src/core/repo-context.ts +542 -0
  108. package/src/core/repo-paths.ts +29 -0
  109. package/src/core/resources.ts +1299 -0
  110. package/src/core/role-models.ts +603 -0
  111. package/src/core/route-intent.ts +305 -0
  112. package/src/core/rules.ts +65 -0
  113. package/src/core/runtime-identity.ts +63 -0
  114. package/src/core/safe-write.ts +87 -0
  115. package/src/core/scope-glob.ts +56 -0
  116. package/src/core/scripts.ts +6 -0
  117. package/src/core/skill-manifests.ts +173 -0
  118. package/src/core/store-lock.ts +374 -0
  119. package/src/core/support-matrix.ts +24 -0
  120. package/src/core/task-context.ts +247 -0
  121. package/src/core/task-contract.ts +1279 -0
  122. package/src/core/task-delivery.ts +109 -0
  123. package/src/core/task-engine.ts +2248 -0
  124. package/src/core/task-evaluation.ts +1147 -0
  125. package/src/core/task-store.ts +2649 -0
  126. package/src/core/task-view.ts +180 -0
  127. package/src/core/templates.ts +22 -0
  128. package/src/core/tracker-issues.ts +174 -0
  129. package/src/core/vcs-config.ts +540 -0
  130. package/src/core/workers.ts +62 -0
  131. package/src/core/workit-paths.mjs +242 -0
  132. package/src/core/workspace-branch-policy.ts +123 -0
  133. package/src/core/workspaces.ts +1745 -0
  134. package/src/core/youtrack-backend.ts +288 -0
  135. package/src/core/youtrack-tools.ts +260 -0
  136. package/src/core/youtrack-writes.ts +73 -0
  137. package/src/core/youtrack-ytrack.ts +217 -0
  138. package/src/core/youtrack.ts +454 -0
  139. package/src/core/ytrack-skill.ts +297 -0
  140. package/src/core.ts +171 -0
  141. package/src/deslop/lex.ts +271 -0
  142. package/src/deslop/scan.ts +232 -0
  143. package/src/duration.ts +27 -0
  144. package/src/fanout-brief.ts +486 -0
  145. package/src/fanout-check.ts +455 -0
  146. package/src/fanout-landed.ts +370 -0
  147. package/src/fanout-stack.ts +108 -0
  148. package/src/fanout-status.ts +362 -0
  149. package/src/fanout-worktree.ts +893 -0
  150. package/src/fanout.ts +1473 -0
  151. package/src/forge/exec.ts +385 -0
  152. package/src/forge/github.ts +831 -0
  153. package/src/forge/gitlab.ts +906 -0
  154. package/src/forge/issue-forge.ts +209 -0
  155. package/src/forge/issue-link.ts +468 -0
  156. package/src/forge/issue-ops.ts +314 -0
  157. package/src/forge/merge-gate-base-red.ts +72 -0
  158. package/src/forge/pr-body.ts +100 -0
  159. package/src/forge/pr-edit.ts +292 -0
  160. package/src/forge/pr-ops.ts +1109 -0
  161. package/src/forge/pr-status.ts +10 -0
  162. package/src/forge/redact.ts +195 -0
  163. package/src/forge/report.ts +994 -0
  164. package/src/forge/reruns.ts +132 -0
  165. package/src/forge/resolve.ts +509 -0
  166. package/src/forge/types.ts +242 -0
  167. package/src/forge/verify.ts +398 -0
  168. package/src/git/default-base.ts +130 -0
  169. package/src/git/env-redirect.ts +28 -0
  170. package/src/git/facts.ts +122 -0
  171. package/src/git/ops.ts +1823 -0
  172. package/src/git/refs-read.ts +332 -0
  173. package/src/git/release.ts +1020 -0
  174. package/src/git/rev.ts +1175 -0
  175. package/src/git/session-hook.ts +597 -0
  176. package/src/git/stage.ts +63 -0
  177. package/src/hooks/agent-model.ts +149 -0
  178. package/src/hooks/budget-signal.ts +284 -0
  179. package/src/hooks/context.ts +461 -0
  180. package/src/hooks/cwd-normalize.ts +79 -0
  181. package/src/hooks/deadline.ts +228 -0
  182. package/src/hooks/delivery-verbs.ts +182 -0
  183. package/src/hooks/descriptor.ts +124 -0
  184. package/src/hooks/handle.ts +433 -0
  185. package/src/hooks/heavy-gate.ts +99 -0
  186. package/src/hooks/hook-runs.ts +130 -0
  187. package/src/hooks/hosts/claude-code.ts +380 -0
  188. package/src/hooks/hosts/codex.ts +372 -0
  189. package/src/hooks/hosts/cursor.ts +419 -0
  190. package/src/hooks/hosts/fields.ts +98 -0
  191. package/src/hooks/hosts/opencode.ts +79 -0
  192. package/src/hooks/hosts/pi.ts +78 -0
  193. package/src/hooks/index.ts +60 -0
  194. package/src/hooks/model-limit.ts +378 -0
  195. package/src/hooks/model-switch.ts +35 -0
  196. package/src/hooks/policy.ts +29 -0
  197. package/src/hooks/process.ts +48 -0
  198. package/src/hooks/protocol.ts +135 -0
  199. package/src/hooks/raw-git.ts +2289 -0
  200. package/src/hooks/repeat-failure.ts +115 -0
  201. package/src/hooks/run.ts +54 -0
  202. package/src/hooks/session-marker.ts +151 -0
  203. package/src/hooks/shell-words.ts +532 -0
  204. package/src/hooks/skill-nudge.ts +295 -0
  205. package/src/hooks/spawn-gate.ts +72 -0
  206. package/src/hooks/standing-signal.ts +99 -0
  207. package/src/hooks/standing.ts +225 -0
  208. package/src/hooks/steering.ts +74 -0
  209. package/src/hooks/stop.ts +408 -0
  210. package/src/hooks/worktree-confine.ts +415 -0
  211. package/src/hooks/write-gate.ts +1174 -0
  212. package/src/host-session.ts +459 -0
  213. package/src/knowledge.ts +368 -0
  214. package/src/ledger.ts +2913 -0
  215. package/src/stack.ts +2570 -0
  216. package/src/store/event-log.ts +217 -0
  217. package/src/store/notes.ts +29 -0
  218. package/src/store/patch.ts +135 -0
  219. package/src/store/paths.ts +368 -0
  220. package/src/store/reduce.ts +203 -0
  221. package/src/test-audit-mocks.ts +309 -0
  222. package/src/verdict-cover.ts +151 -0
  223. package/src/worktree-deps.ts +142 -0
  224. package/templates/headers.md +3 -0
  225. package/templates/hygiene/.editorconfig +8 -0
  226. package/templates/hygiene/.gitattributes +3 -0
  227. package/templates/hygiene/CHANGELOG.md +14 -0
  228. package/templates/hygiene/CONTRIBUTING.md +3 -0
  229. package/templates/hygiene/LICENSE +21 -0
  230. package/templates/hygiene/README.md +3 -0
  231. package/templates/issue-update.md +5 -0
  232. package/templates/plan-template.md +26 -0
  233. package/templates/spec-template.md +64 -0
  234. package/templates/workit-contract.md +13 -0
@@ -0,0 +1,906 @@
1
+ // GitLab adapter: `glab api` REST only (design §2.0 GitLab endpoints): the MR
2
+ // (`detailed_merge_status`, `has_conflicts`, `head_pipeline`), pipeline jobs,
3
+ // job traces, discussions, and pipeline/job retry.
4
+ import { apiJson, apiText, apiWrite, FORGE_TIMEOUTS, type ForgeRunner } from "./exec";
5
+ import { logTail, redactText, shortBody } from "./redact";
6
+ import {
7
+ failure,
8
+ success,
9
+ type Forge,
10
+ type ForgeCheck,
11
+ type ForgePrStatus,
12
+ type ForgeResult,
13
+ type ForgeThread,
14
+ type PrMeta,
15
+ type PrState,
16
+ } from "./types";
17
+
18
+ /** Page cap for jobs and discussions (100 per page). */
19
+ export const GITLAB_MAX_PAGES = 10;
20
+ const PER_PAGE = 100;
21
+ /** Merge requests per findPr page (paged until a short page). */
22
+ const FIND_PER_PAGE = 20;
23
+
24
+ type GlUser = { username?: string; bot?: boolean } | null;
25
+
26
+ type GlMr = {
27
+ iid: number;
28
+ title?: string;
29
+ reviewers?: Array<{ id: number }>;
30
+ web_url: string;
31
+ state: string;
32
+ draft?: boolean;
33
+ work_in_progress?: boolean;
34
+ detailed_merge_status?: string;
35
+ has_conflicts?: boolean;
36
+ target_branch: string;
37
+ source_branch: string;
38
+ sha: string;
39
+ diff_refs?: { base_sha?: string | null } | null;
40
+ source_project_id?: number;
41
+ target_project_id?: number;
42
+ author?: { username?: string } | null;
43
+ head_pipeline?: {
44
+ id: number;
45
+ sha: string;
46
+ /** Merged-results / merge-train pipelines run on a merge ref; this is the MR head. */
47
+ source_sha?: string | null;
48
+ /** `refs/merge-requests/<iid>/merge` (merged results) or `/train` (merge train). */
49
+ ref?: string;
50
+ project_id: number;
51
+ status: string;
52
+ web_url?: string;
53
+ } | null;
54
+ };
55
+
56
+ type GlBridge = {
57
+ id: number;
58
+ name: string;
59
+ stage: string;
60
+ status: string;
61
+ allow_failure?: boolean;
62
+ web_url?: string;
63
+ downstream_pipeline?: {
64
+ id: number;
65
+ status: string;
66
+ project_id?: number;
67
+ web_url?: string;
68
+ } | null;
69
+ };
70
+
71
+ type GlJob = {
72
+ id: number;
73
+ name: string;
74
+ stage: string;
75
+ status: string;
76
+ allow_failure?: boolean;
77
+ web_url?: string;
78
+ };
79
+
80
+ type GlNote = {
81
+ id: number;
82
+ body: string;
83
+ author: GlUser;
84
+ system?: boolean;
85
+ resolvable?: boolean;
86
+ resolved?: boolean;
87
+ position?: {
88
+ new_path?: string | null;
89
+ new_line?: number | null;
90
+ old_path?: string | null;
91
+ } | null;
92
+ };
93
+
94
+ type GlDiscussion = { id: string; notes: GlNote[] };
95
+
96
+ const mrState = (value: string): PrState =>
97
+ value === "merged" ? "merged" : value === "opened" ? "open" : "closed";
98
+
99
+ const PENDING = new Set([
100
+ "created",
101
+ "pending",
102
+ "running",
103
+ "waiting_for_resource",
104
+ "waiting_for_callback",
105
+ "preparing",
106
+ "scheduled",
107
+ ]);
108
+
109
+ const stateOf = (status: string, allowFailure: boolean): ForgeCheck["state"] => {
110
+ const failed = status === "failed" || status === "canceled";
111
+ return PENDING.has(status)
112
+ ? "pending"
113
+ : failed
114
+ ? allowFailure
115
+ ? "passing"
116
+ : "failing"
117
+ : status === "success"
118
+ ? "passing"
119
+ : "skipped";
120
+ };
121
+
122
+ /** A trigger/bridge job: its state is the downstream pipeline's. */
123
+ export function checkFromBridge(bridge: GlBridge, pipelineId: number, scope: string): ForgeCheck {
124
+ const status = bridge.downstream_pipeline?.status ?? bridge.status;
125
+ const allow = bridge.allow_failure === true;
126
+ const failed = status === "failed" || status === "canceled";
127
+ return {
128
+ name: `${bridge.stage} / ${bridge.name}`,
129
+ context: bridge.name,
130
+ state: stateOf(status, allow),
131
+ conclusion: failed && allow ? "allowed_failure" : `downstream_${status}`,
132
+ url: safeUrl(bridge.downstream_pipeline?.web_url ?? bridge.web_url ?? null),
133
+ runId: pipelineId,
134
+ // Not a log-bearing job; rerun it on the downstream pipeline.
135
+ jobId: null,
136
+ scope,
137
+ required: !allow,
138
+ };
139
+ }
140
+
141
+ export function checkFromJob(job: GlJob, pipelineId: number, scope: string): ForgeCheck {
142
+ const failed = job.status === "failed" || job.status === "canceled";
143
+ return {
144
+ name: `${job.stage} / ${job.name}`,
145
+ context: job.name,
146
+ state: stateOf(job.status, job.allow_failure === true),
147
+ conclusion: failed && job.allow_failure ? "allowed_failure" : job.status,
148
+ url: safeUrl(job.web_url ?? null),
149
+ runId: pipelineId,
150
+ jobId: job.id,
151
+ scope,
152
+ // Every job that may not fail gates the pipeline, which gates the MR.
153
+ required: job.allow_failure !== true,
154
+ };
155
+ }
156
+
157
+ /** A pipeline-level check when no job explains the pipeline's state. */
158
+ const pipelineCheck = (
159
+ state: ForgeCheck["state"],
160
+ conclusion: string,
161
+ pipeline: { id: number; project_id: number; web_url?: string } | null,
162
+ ): ForgeCheck => ({
163
+ name: "pipeline",
164
+ context: "pipeline",
165
+ state,
166
+ conclusion,
167
+ url: safeUrl(pipeline?.web_url ?? null),
168
+ runId: pipeline?.id ?? null,
169
+ jobId: null,
170
+ scope: pipeline ? String(pipeline.project_id) : null,
171
+ required: true,
172
+ });
173
+
174
+ const safeUrl = (value: string | null): string | null => (value ? redactText(value) : null);
175
+
176
+ const isBot = (author: GlUser): boolean =>
177
+ author?.bot === true || /(?:^project_\d+_bot|bot$|\[bot\]$)/iu.test(author?.username ?? "");
178
+
179
+ /** GitLab's draft markers at the start of an MR title. */
180
+ const DRAFT_PREFIX = /^(?:\s*(?:\[draft\]|\(draft\)|draft:)\s*)+/iu;
181
+
182
+ /**
183
+ * `title` with GitLab's draft markers (`Draft:`, `[Draft]`, `(Draft)`,
184
+ * repeated) replaced by one `Draft: ` or removed (GitLab keeps draft in the
185
+ * title). Null when nothing but markers is left.
186
+ */
187
+ const draftTitle = (title: string, draft: boolean): string | null => {
188
+ const bare = title.replace(DRAFT_PREFIX, "").trim();
189
+ if (!bare) return null;
190
+ return draft ? `Draft: ${bare}` : bare;
191
+ };
192
+
193
+ // Statuses where GitLab has not finished computing mergeability.
194
+ const UNSETTLED = new Set(["checking", "unchecked", "preparing", "approvals_syncing"]);
195
+
196
+ export function createGitLabForge(options: {
197
+ apiHost: string;
198
+ repo: string;
199
+ runner: ForgeRunner;
200
+ maxPages?: number;
201
+ }): Forge {
202
+ const { apiHost, repo, runner } = options;
203
+ const maxPages = options.maxPages ?? GITLAB_MAX_PAGES;
204
+ const project = `projects/${encodeURIComponent(repo)}`;
205
+
206
+ const api = (endpoint: string): string[] => ["api", endpoint];
207
+
208
+ /** Every page of a list endpoint, up to the cap. */
209
+ const pages = (
210
+ endpoint: string,
211
+ what: string,
212
+ ): ForgeResult<{ items: unknown[]; truncated: boolean }> => {
213
+ const items: unknown[] = [];
214
+ const sep = endpoint.includes("?") ? "&" : "?";
215
+ for (let page = 1; page <= maxPages; page += 1) {
216
+ const chunk = apiJson<unknown>(
217
+ runner,
218
+ "glab",
219
+ apiHost,
220
+ api(`${endpoint}${sep}per_page=${PER_PAGE}&page=${page}`),
221
+ what,
222
+ );
223
+ if (!chunk.ok) return chunk;
224
+ if (!Array.isArray(chunk.data)) return failure("failed", `${what}: expected a list`);
225
+ items.push(...chunk.data);
226
+ if (chunk.data.length < PER_PAGE) return success({ items, truncated: false });
227
+ }
228
+ return success({ items, truncated: true });
229
+ };
230
+
231
+ /** True when merge commit `sha` (in project `scope`) has `head` as a parent. */
232
+ const mergeCommitTests = (scope: number, sha: string, head: string): boolean => {
233
+ const commit = apiJson<{ parent_ids?: string[] }>(
234
+ runner,
235
+ "glab",
236
+ apiHost,
237
+ api(`projects/${scope}/repository/commits/${sha}`),
238
+ `commit ${sha.slice(0, 12)}`,
239
+ );
240
+ return (
241
+ commit.ok && Array.isArray(commit.data.parent_ids) && commit.data.parent_ids.includes(head)
242
+ );
243
+ };
244
+
245
+ /** The current tip of the target branch (diff_refs.base_sha is the merge base). */
246
+ const baseTip = (branch: string): string | null => {
247
+ const tip = apiJson<{ commit?: { id?: string } }>(
248
+ runner,
249
+ "glab",
250
+ apiHost,
251
+ api(`${project}/repository/branches/${encodeURIComponent(branch)}`),
252
+ `branch ${branch}`,
253
+ );
254
+ return tip.ok && typeof tip.data.commit?.id === "string" ? tip.data.commit.id : null;
255
+ };
256
+
257
+ const getMr = (iid: number): ForgeResult<GlMr> =>
258
+ apiJson<GlMr>(
259
+ runner,
260
+ "glab",
261
+ apiHost,
262
+ api(`${project}/merge_requests/${iid}`),
263
+ `merge request !${iid}`,
264
+ );
265
+
266
+ const putMr = (iid: number, fields: string[], what: string): ForgeResult<void> => {
267
+ const updated = apiWrite<unknown>(
268
+ runner,
269
+ "glab",
270
+ apiHost,
271
+ ["api", "-X", "PUT", `${project}/merge_requests/${iid}`, ...fields],
272
+ `${what} of merge request !${iid}`,
273
+ );
274
+ return updated.ok ? success(undefined) : updated;
275
+ };
276
+
277
+ return {
278
+ kind: "gitlab",
279
+ apiHost,
280
+ repo,
281
+
282
+ identity(expected) {
283
+ const user = apiJson<{ username?: unknown }>(runner, "glab", apiHost, api("user"), "user");
284
+ // CI job tokens cannot read /user; reads still work, so skip the check.
285
+ if (!user.ok && user.code === "failed" && /\b403\b|forbidden/iu.test(user.error))
286
+ return success({
287
+ login: null,
288
+ expected,
289
+ matches: null,
290
+ note: "the credential cannot read /user (job or project token); identity not checked",
291
+ });
292
+ if (!user.ok) return user;
293
+ const login = typeof user.data.username === "string" ? user.data.username : "";
294
+ if (!login) return failure("failed", "glab api user returned no username");
295
+ return success({
296
+ login,
297
+ expected,
298
+ matches: expected ? login.toLowerCase() === expected.toLowerCase() : null,
299
+ });
300
+ },
301
+
302
+ repoInfo(target) {
303
+ const info = apiJson<{ id?: number; forked_from_project?: { path_with_namespace?: string } }>(
304
+ runner,
305
+ "glab",
306
+ apiHost,
307
+ api(`projects/${encodeURIComponent(target)}`),
308
+ `project ${target}`,
309
+ );
310
+ if (!info.ok) return info;
311
+ return success({
312
+ id: typeof info.data.id === "number" ? info.data.id : null,
313
+ parent: info.data.forked_from_project?.path_with_namespace ?? null,
314
+ });
315
+ },
316
+
317
+ findPr(head, match) {
318
+ // Every page (#284 N5): an open MR past the first 20 would read as
319
+ // "none" and pr create would open a duplicate.
320
+ const listed: GlMr[] = [];
321
+ const endpoint = `${project}/merge_requests?source_branch=${encodeURIComponent(head)}&order_by=created_at&sort=desc&per_page=${FIND_PER_PAGE}`;
322
+ for (let page = 1; page <= maxPages; page += 1) {
323
+ const list = apiJson<GlMr[]>(
324
+ runner,
325
+ "glab",
326
+ apiHost,
327
+ api(page === 1 ? endpoint : `${endpoint}&page=${page}`),
328
+ `merge requests for ${head}`,
329
+ );
330
+ if (!list.ok) return list;
331
+ if (!Array.isArray(list.data)) return failure("failed", "merge request list is not a list");
332
+ listed.push(...list.data);
333
+ if (list.data.length < FIND_PER_PAGE) break;
334
+ }
335
+ // Same-named branches in other forks are not ours: match the source
336
+ // project or the exact head sha.
337
+ const matches = listed.filter(
338
+ (mr) =>
339
+ mr.source_branch === head &&
340
+ (!match.base || mr.target_branch === undefined || mr.target_branch === match.base) &&
341
+ ((match.projectId !== null && mr.source_project_id === match.projectId) ||
342
+ (match.sha !== null && mr.sha === match.sha)),
343
+ );
344
+ const open = matches.filter((mr) => mr.state === "opened");
345
+ if (!match.base && new Set(open.map((mr) => mr.target_branch)).size > 1)
346
+ return failure(
347
+ "blocked",
348
+ `ambiguous_pr: ${head} has open merge requests ${open.map((mr) => `!${mr.iid} -> ${mr.target_branch}`).join(", ")}`,
349
+ `pass --pr <n>, e.g. --pr ${open[0].iid}`,
350
+ );
351
+ const pick = open[0] ?? matches[0];
352
+ return success(
353
+ pick
354
+ ? {
355
+ number: pick.iid,
356
+ url: pick.web_url,
357
+ state: mrState(pick.state),
358
+ headBranch: pick.source_branch,
359
+ headSha: pick.sha ?? null,
360
+ }
361
+ : null,
362
+ );
363
+ },
364
+
365
+ prStatus(iid) {
366
+ const mr = apiJson<GlMr>(
367
+ runner,
368
+ "glab",
369
+ apiHost,
370
+ api(`${project}/merge_requests/${iid}`),
371
+ `merge request !${iid}`,
372
+ );
373
+ if (!mr.ok) return mr;
374
+ const value = mr.data;
375
+ let truncated = false;
376
+ let checks: ForgeCheck[] = [];
377
+ const pipeline = value.head_pipeline ?? null;
378
+ const open = value.state === "opened";
379
+ // Merged-results and merge-train pipelines run on a merge ref whose sha
380
+ // is a merge commit, not the MR head. They test the current head when
381
+ // source_sha says so or, when the response has no source_sha, when the
382
+ // merge commit's parents include the head (GET …/repository/commits/:sha).
383
+ const current =
384
+ pipeline !== null &&
385
+ (pipeline.sha === value.sha ||
386
+ pipeline.source_sha === value.sha ||
387
+ (!pipeline.source_sha &&
388
+ new RegExp(`^refs/merge-requests/${value.iid}/(?:merge|train)$`, "u").test(
389
+ pipeline.ref ?? "",
390
+ ) &&
391
+ mergeCommitTests(pipeline.project_id, pipeline.sha, value.sha)));
392
+ if (pipeline && current) {
393
+ const scope = String(pipeline.project_id);
394
+ const jobs = pages(
395
+ `projects/${scope}/pipelines/${pipeline.id}/jobs`,
396
+ `pipeline ${pipeline.id} jobs`,
397
+ );
398
+ if (!jobs.ok) return jobs;
399
+ const bridges = pages(
400
+ `projects/${scope}/pipelines/${pipeline.id}/bridges`,
401
+ `pipeline ${pipeline.id} bridges`,
402
+ );
403
+ if (!bridges.ok) return bridges;
404
+ truncated ||= jobs.data.truncated || bridges.data.truncated;
405
+ checks = [
406
+ ...(jobs.data.items as GlJob[]).map((job) => checkFromJob(job, pipeline.id, scope)),
407
+ ...(bridges.data.items as GlBridge[]).map((bridge) =>
408
+ checkFromBridge(bridge, pipeline.id, scope),
409
+ ),
410
+ ];
411
+ // The pipeline status is a floor: a failed pipeline is never green,
412
+ // and a running one is never done, whatever the visible jobs say.
413
+ const pipelineState = stateOf(pipeline.status, false);
414
+ if (pipelineState === "failing" && !checks.some((check) => check.state === "failing"))
415
+ checks.push(pipelineCheck("failing", pipeline.status, pipeline));
416
+ if (pipelineState === "pending" && !checks.some((check) => check.state === "pending"))
417
+ checks.push(pipelineCheck("pending", pipeline.status, pipeline));
418
+ } else if (open && pipeline) {
419
+ // The head pipeline belongs to an older head: CI for this head has
420
+ // not reported yet. Pending, never "no checks".
421
+ checks = [pipelineCheck("pending", "awaiting_pipeline_for_head", null)];
422
+ }
423
+ // #284 N16: GitLab has no per-job required checks; "Pipelines must
424
+ // succeed" (only_allow_merge_if_pipeline_succeeds) makes every job that
425
+ // may not fail required. Read only when a failing check makes it matter:
426
+ // with the setting off, failing jobs are optional (a waiver may pass
427
+ // them). Unreadable keeps every such job required.
428
+ if (open && checks.some((check) => check.state === "failing")) {
429
+ const settings = apiJson<{ only_allow_merge_if_pipeline_succeeds?: unknown }>(
430
+ runner,
431
+ "glab",
432
+ apiHost,
433
+ api(project),
434
+ `project ${repo}`,
435
+ );
436
+ if (settings.ok && settings.data.only_allow_merge_if_pipeline_succeeds === false)
437
+ checks = checks.map((check) => ({ ...check, required: false }));
438
+ }
439
+ const discussions = pages(
440
+ `${project}/merge_requests/${iid}/discussions`,
441
+ `merge request !${iid} discussions`,
442
+ );
443
+ if (!discussions.ok) return discussions;
444
+ truncated ||= discussions.data.truncated;
445
+ const threads: ForgeThread[] = (discussions.data.items as GlDiscussion[])
446
+ .filter((discussion) =>
447
+ discussion.notes.some((note) => note.resolvable === true && note.resolved !== true),
448
+ )
449
+ .map((discussion) => {
450
+ const note = discussion.notes[0];
451
+ return {
452
+ id: discussion.id,
453
+ path: note.position?.new_path ?? note.position?.old_path ?? null,
454
+ line: note.position?.new_line ?? null,
455
+ author: note.author?.username ?? null,
456
+ isBot: isBot(note.author),
457
+ outdated: false,
458
+ url: safeUrl(`${value.web_url}#note_${note.id}`),
459
+ body: shortBody(note.body ?? ""),
460
+ };
461
+ });
462
+ const detailed = value.detailed_merge_status ?? "unchecked";
463
+ const conflicts = value.has_conflicts === true || detailed === "conflict";
464
+ const status: ForgePrStatus = {
465
+ number: value.iid,
466
+ url: value.web_url,
467
+ state: mrState(value.state),
468
+ draft: value.draft ?? value.work_in_progress ?? false,
469
+ base: value.target_branch,
470
+ baseSha: open ? baseTip(value.target_branch) : null,
471
+ head: { branch: value.source_branch, sha: value.sha },
472
+ mergeable: conflicts ? "no" : UNSETTLED.has(detailed) ? "unknown" : "yes",
473
+ conflicts,
474
+ rebaseRequired: detailed === "need_rebase",
475
+ reviewDecision:
476
+ detailed === "not_approved"
477
+ ? "review_required"
478
+ : detailed === "requested_changes"
479
+ ? "changes_requested"
480
+ : null,
481
+ mergeState: detailed,
482
+ inMergeQueue: false,
483
+ checks,
484
+ // GitLab gates on the whole pipeline: the jobs that may not fail
485
+ // when the project requires a green pipeline, none otherwise.
486
+ requiredContexts: [
487
+ ...new Set(
488
+ checks.filter((check) => check.required === true).map((check) => check.context),
489
+ ),
490
+ ],
491
+ threads,
492
+ truncated,
493
+ };
494
+ return success(status);
495
+ },
496
+
497
+ branchChecks(branch) {
498
+ const tip = baseTip(branch);
499
+ if (!tip) return failure("not_found", `branch ${branch} was not found`);
500
+ const latest = apiJson<
501
+ Array<{ id: number; project_id?: number; status: string; web_url?: string }>
502
+ >(
503
+ runner,
504
+ "glab",
505
+ apiHost,
506
+ api(
507
+ `${project}/pipelines?ref=${encodeURIComponent(branch)}&sha=${tip}&order_by=id&sort=desc&per_page=1`,
508
+ ),
509
+ `pipelines on ${branch}`,
510
+ );
511
+ if (!latest.ok) return latest;
512
+ const pipeline = Array.isArray(latest.data) ? latest.data[0] : undefined;
513
+ if (!pipeline) return success({ sha: tip, checks: [], requiredKnown: true });
514
+ const scope = String(pipeline.project_id ?? encodeURIComponent(repo));
515
+ const jobs = pages(
516
+ `projects/${scope}/pipelines/${pipeline.id}/jobs`,
517
+ `pipeline ${pipeline.id} jobs`,
518
+ );
519
+ if (!jobs.ok) return jobs;
520
+ const bridges = pages(
521
+ `projects/${scope}/pipelines/${pipeline.id}/bridges`,
522
+ `pipeline ${pipeline.id} bridges`,
523
+ );
524
+ if (!bridges.ok) return bridges;
525
+ const checks = [
526
+ ...(jobs.data.items as GlJob[]).map((job) => checkFromJob(job, pipeline.id, scope)),
527
+ ...(bridges.data.items as GlBridge[]).map((bridge) =>
528
+ checkFromBridge(bridge, pipeline.id, scope),
529
+ ),
530
+ ];
531
+ if (
532
+ stateOf(pipeline.status, false) === "failing" &&
533
+ !checks.some((check) => check.state === "failing")
534
+ )
535
+ checks.push(
536
+ pipelineCheck("failing", pipeline.status, {
537
+ id: pipeline.id,
538
+ project_id: pipeline.project_id ?? 0,
539
+ web_url: pipeline.web_url,
540
+ }),
541
+ );
542
+ return success({ sha: tip, checks, requiredKnown: true });
543
+ },
544
+
545
+ jobLogTail(job, lines) {
546
+ const scope = job.scope ?? encodeURIComponent(repo);
547
+ const text = apiText(
548
+ runner,
549
+ "glab",
550
+ apiHost,
551
+ ["api", `projects/${scope}/jobs/${job.jobId}/trace`],
552
+ `job ${job.jobId} trace`,
553
+ FORGE_TIMEOUTS.log,
554
+ );
555
+ return text.ok ? success(logTail(text.data, lines)) : text;
556
+ },
557
+
558
+ rerun(target) {
559
+ const scope = target.scope ?? encodeURIComponent(repo);
560
+ const endpoint =
561
+ target.kind === "failed_in_run"
562
+ ? `projects/${scope}/pipelines/${target.runId}/retry`
563
+ : `projects/${scope}/jobs/${target.jobId}/retry`;
564
+ const what =
565
+ target.kind === "failed_in_run"
566
+ ? `retry of pipeline ${target.runId}`
567
+ : `retry of job ${target.jobId}`;
568
+ const run = apiText(
569
+ runner,
570
+ "glab",
571
+ apiHost,
572
+ ["api", "-X", "POST", endpoint],
573
+ what,
574
+ FORGE_TIMEOUTS.api,
575
+ );
576
+ return run.ok ? success(undefined) : run;
577
+ },
578
+
579
+ createPr(input) {
580
+ // An MR from a fork is created in the source project and targets ours.
581
+ const fromFork = input.headRepo !== repo;
582
+ let source = project;
583
+ const extra: string[] = [];
584
+ if (fromFork) {
585
+ const target = apiJson<{ id?: number }>(
586
+ runner,
587
+ "glab",
588
+ apiHost,
589
+ api(project),
590
+ `project ${repo}`,
591
+ );
592
+ if (!target.ok) return target;
593
+ if (input.headProjectId === null || typeof target.data.id !== "number")
594
+ return failure(
595
+ "failed",
596
+ `cannot resolve the project ids for an MR from ${input.headRepo}`,
597
+ );
598
+ source = `projects/${input.headProjectId}`;
599
+ extra.push("-F", `target_project_id=${target.data.id}`);
600
+ }
601
+ const title =
602
+ input.draft && !/^draft:/iu.test(input.title) ? `Draft: ${input.title}` : input.title;
603
+ const created = apiWrite<GlMr>(
604
+ runner,
605
+ "glab",
606
+ apiHost,
607
+ [
608
+ "api",
609
+ "-X",
610
+ "POST",
611
+ `${source}/merge_requests`,
612
+ "-f",
613
+ `source_branch=${input.head}`,
614
+ "-f",
615
+ `target_branch=${input.base}`,
616
+ "-f",
617
+ `title=${title}`,
618
+ "-f",
619
+ `description=${input.body}`,
620
+ ...extra,
621
+ ],
622
+ `merge request for ${input.head}`,
623
+ );
624
+ if (!created.ok) return created;
625
+ const value = created.data;
626
+ if (typeof value.iid !== "number" || typeof value.web_url !== "string")
627
+ return failure("failed", "glab api merge_requests returned no merge request iid");
628
+ return success({
629
+ number: value.iid,
630
+ url: value.web_url,
631
+ state: mrState(value.state),
632
+ headBranch: value.source_branch ?? input.head,
633
+ headSha: value.sha ?? null,
634
+ });
635
+ },
636
+
637
+ merge(iid, mergeOptions) {
638
+ const squash = mergeOptions.method === "squash" ? mergeOptions.squashMessage : undefined;
639
+ // GitLab's merge method is a project setting; squash is per request.
640
+ if (mergeOptions.method === "rebase")
641
+ return failure(
642
+ "invalid_input",
643
+ "GitLab merges with the project's merge method; pass --method squash or merge",
644
+ );
645
+ const merged = apiWrite<
646
+ GlMr & { merge_commit_sha?: string | null; squash_commit_sha?: string | null }
647
+ >(
648
+ runner,
649
+ "glab",
650
+ apiHost,
651
+ [
652
+ "api",
653
+ "-X",
654
+ "PUT",
655
+ `${project}/merge_requests/${iid}/merge`,
656
+ ...(squash
657
+ ? ["--input", "-"]
658
+ : [
659
+ "-f",
660
+ `sha=${mergeOptions.sha}`,
661
+ "-F",
662
+ `squash=${mergeOptions.method === "squash" ? "true" : "false"}`,
663
+ ]),
664
+ ],
665
+ `merge of merge request !${iid}`,
666
+ undefined,
667
+ // The squash message rides stdin: argv has a size limit (E2BIG).
668
+ squash
669
+ ? JSON.stringify({
670
+ sha: mergeOptions.sha,
671
+ squash: true,
672
+ squash_commit_message: squash.body
673
+ ? `${squash.subject}\n\n${squash.body}`
674
+ : squash.subject,
675
+ })
676
+ : undefined,
677
+ );
678
+ if (!merged.ok) return merged;
679
+ const value = merged.data;
680
+ return success({
681
+ mergeSha: value.merge_commit_sha ?? value.squash_commit_sha ?? null,
682
+ });
683
+ },
684
+
685
+ updateBase(iid, base) {
686
+ const updated = apiWrite<unknown>(
687
+ runner,
688
+ "glab",
689
+ apiHost,
690
+ ["api", "-X", "PUT", `${project}/merge_requests/${iid}`, "-f", `target_branch=${base}`],
691
+ `retarget of merge request !${iid}`,
692
+ );
693
+ return updated.ok ? success(undefined) : updated;
694
+ },
695
+
696
+ prMeta(iid) {
697
+ const mr = getMr(iid);
698
+ if (!mr.ok) return mr;
699
+ const value = mr.data;
700
+ const meta: PrMeta = {
701
+ number: value.iid,
702
+ url: value.web_url,
703
+ state: mrState(value.state),
704
+ draft: value.draft ?? value.work_in_progress ?? false,
705
+ title: value.title ?? "",
706
+ base: value.target_branch,
707
+ headBranch: value.source_branch,
708
+ author: value.author?.username ?? null,
709
+ fork:
710
+ value.source_project_id === undefined || value.target_project_id === undefined
711
+ ? null
712
+ : value.source_project_id !== value.target_project_id,
713
+ };
714
+ return success(meta);
715
+ },
716
+
717
+ openPrsInto(branch) {
718
+ const list = apiJson<GlMr[]>(
719
+ runner,
720
+ "glab",
721
+ apiHost,
722
+ api(
723
+ `${project}/merge_requests?state=opened&target_branch=${encodeURIComponent(branch)}&per_page=100`,
724
+ ),
725
+ `open merge requests into ${branch}`,
726
+ );
727
+ if (!list.ok) return list;
728
+ if (!Array.isArray(list.data)) return failure("failed", "merge request list is not a list");
729
+ return success(list.data.map((mr) => mr.iid));
730
+ },
731
+
732
+ setDraft(iid, draft) {
733
+ const mr = getMr(iid);
734
+ if (!mr.ok) return mr;
735
+ const title = draftTitle(mr.data.title ?? "", draft);
736
+ if (title === null)
737
+ return failure(
738
+ "invalid_input",
739
+ `merge request !${iid} has no title besides its draft marker`,
740
+ );
741
+ return putMr(iid, ["-f", `title=${title}`], `draft state`);
742
+ },
743
+
744
+ editPr(iid, edit) {
745
+ const mr = getMr(iid);
746
+ if (!mr.ok) return mr;
747
+ const fields: string[] = [];
748
+ // A new title keeps the MR's draft state (it lives in the title).
749
+ if (edit.title !== undefined) {
750
+ const title = draftTitle(edit.title, mr.data.draft ?? mr.data.work_in_progress ?? false);
751
+ if (title === null)
752
+ return failure("invalid_input", "--title must have text besides a draft marker");
753
+ fields.push("-f", `title=${title}`);
754
+ }
755
+ if (edit.body !== undefined) fields.push("-f", `description=${edit.body}`);
756
+ if (edit.base !== undefined) fields.push("-f", `target_branch=${edit.base}`);
757
+ if (edit.addLabels.length) fields.push("-f", `add_labels=${edit.addLabels.join(",")}`);
758
+ if (edit.removeLabels.length)
759
+ fields.push("-f", `remove_labels=${edit.removeLabels.join(",")}`);
760
+ if (edit.addReviewers.length) {
761
+ // reviewer_ids replaces the list: keep the current reviewers.
762
+ const ids = new Set((mr.data.reviewers ?? []).map((reviewer) => reviewer.id));
763
+ for (const username of edit.addReviewers) {
764
+ const users = apiJson<Array<{ id?: number; username?: string }>>(
765
+ runner,
766
+ "glab",
767
+ apiHost,
768
+ api(`users?username=${encodeURIComponent(username)}`),
769
+ `user ${username}`,
770
+ );
771
+ if (!users.ok) return users;
772
+ const user = Array.isArray(users.data)
773
+ ? users.data.find((entry) => entry.username?.toLowerCase() === username.toLowerCase())
774
+ : undefined;
775
+ if (typeof user?.id !== "number")
776
+ return failure("not_found", `GitLab user ${username} was not found`);
777
+ ids.add(user.id);
778
+ }
779
+ // One JSON array: glab rejects `reviewer_ids[]=` keys in a JSON body.
780
+ fields.push("-F", `reviewer_ids=${JSON.stringify([...ids])}`);
781
+ }
782
+ return fields.length ? putMr(iid, fields, "edit") : success(undefined);
783
+ },
784
+
785
+ replyThread(iid, thread, body) {
786
+ const note = apiWrite<{ id?: number }>(
787
+ runner,
788
+ "glab",
789
+ apiHost,
790
+ [
791
+ "api",
792
+ "-X",
793
+ "POST",
794
+ `${project}/merge_requests/${iid}/discussions/${encodeURIComponent(thread)}/notes`,
795
+ "-f",
796
+ `body=${body}`,
797
+ ],
798
+ `reply on merge request !${iid}`,
799
+ );
800
+ if (!note.ok) return note;
801
+ return success({ url: null });
802
+ },
803
+
804
+ resolveThread(iid, thread) {
805
+ const resolved = apiWrite<{ notes?: GlNote[] }>(
806
+ runner,
807
+ "glab",
808
+ apiHost,
809
+ [
810
+ "api",
811
+ "-X",
812
+ "PUT",
813
+ `${project}/merge_requests/${iid}/discussions/${encodeURIComponent(thread)}`,
814
+ "-F",
815
+ "resolved=true",
816
+ ],
817
+ `resolve on merge request !${iid}`,
818
+ );
819
+ if (!resolved.ok) return resolved;
820
+ const open = (resolved.data.notes ?? []).some(
821
+ (note) => note.resolvable === true && note.resolved !== true,
822
+ );
823
+ return open
824
+ ? failure("failed", `discussion ${thread} on merge request !${iid} is still unresolved`)
825
+ : success(undefined);
826
+ },
827
+
828
+ threadState(iid, thread) {
829
+ const discussion = apiJson<GlDiscussion>(
830
+ runner,
831
+ "glab",
832
+ apiHost,
833
+ api(`${project}/merge_requests/${iid}/discussions/${encodeURIComponent(thread)}`),
834
+ `discussion ${thread}`,
835
+ );
836
+ if (!discussion.ok) return discussion.code === "not_found" ? success(null) : discussion;
837
+ const notes = Array.isArray(discussion.data.notes) ? discussion.data.notes : [];
838
+ if (!notes.some((note) => note.resolvable === true)) return success(null);
839
+ return success(
840
+ notes.some((note) => note.resolvable === true && note.resolved !== true)
841
+ ? "open"
842
+ : "resolved",
843
+ );
844
+ },
845
+
846
+ findRelease(tag) {
847
+ const found = apiJson<{ tag_name?: string; _links?: { self?: string } }>(
848
+ runner,
849
+ "glab",
850
+ apiHost,
851
+ api(`${project}/releases/${encodeURIComponent(tag)}`),
852
+ `release ${tag}`,
853
+ );
854
+ if (!found.ok) return found.code === "not_found" ? success(null) : found;
855
+ return success({
856
+ tag: found.data.tag_name ?? tag,
857
+ url: safeUrl(found.data["_links"]?.self ?? null),
858
+ });
859
+ },
860
+
861
+ createRelease(input) {
862
+ // The notes ride stdin: argv has a size limit (E2BIG).
863
+ const created = apiWrite<{ tag_name?: string; _links?: { self?: string } }>(
864
+ runner,
865
+ "glab",
866
+ apiHost,
867
+ ["api", "-X", "POST", `${project}/releases`, "--input", "-"],
868
+ `release ${input.tag}`,
869
+ undefined,
870
+ JSON.stringify({ tag_name: input.tag, name: input.name, description: input.body }),
871
+ );
872
+ if (!created.ok) return created;
873
+ return success({
874
+ tag: created.data.tag_name ?? input.tag,
875
+ url: safeUrl(created.data["_links"]?.self ?? null),
876
+ });
877
+ },
878
+
879
+ issueComments(iid) {
880
+ const listed = apiJson<Array<{ body?: unknown }>>(
881
+ runner,
882
+ "glab",
883
+ apiHost,
884
+ api(`${project}/issues/${iid}/notes?per_page=100`),
885
+ `notes on issue #${iid}`,
886
+ );
887
+ if (!listed.ok) return listed;
888
+ if (!Array.isArray(listed.data))
889
+ return failure("failed", `glab api issue #${iid} notes returned no list`);
890
+ return success(
891
+ listed.data.flatMap((note) => (typeof note.body === "string" ? [note.body] : [])),
892
+ );
893
+ },
894
+
895
+ commentIssue(iid, body) {
896
+ const posted = apiWrite<unknown>(
897
+ runner,
898
+ "glab",
899
+ apiHost,
900
+ ["api", "-X", "POST", `${project}/issues/${iid}/notes`, "-f", `body=${body}`],
901
+ `note on issue #${iid}`,
902
+ );
903
+ return posted.ok ? success(undefined) : posted;
904
+ },
905
+ };
906
+ }