@cat-factory/executor-harness 1.60.0 → 1.62.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.
@@ -0,0 +1,171 @@
1
+ import { readFile, rm } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { inertInline, inertMarkdown, walkFences } from './host-markdown.js'
4
+ import { redactSecrets } from './redact.js'
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // The agent-authored pull-request description side channel. A coding agent whose
8
+ // dispatch opens a PR is asked (via the backend-composed system prompt) to end its
9
+ // run by writing a reviewer briefing — the problem, the decisions made, what to
10
+ // look out for — to a sentinel file at the root of the checkout the PR belongs to.
11
+ // The harness reads it after the agent settles, removes it (so it never lands in a
12
+ // commit), and uses it as the PR body in place of the generic dispatch-time text
13
+ // the job body carries. Absent or unusable ⇒ the dispatch-time fallback, unchanged.
14
+ //
15
+ // The briefing is MODEL-AUTHORED text landing verbatim on a host-parsed surface, so
16
+ // it crosses `host-markdown.ts` (auto-link triggers defused, open code fences closed)
17
+ // on the way out — see that module for why a PR body is not an inert string sink.
18
+ //
19
+ // The filename is kept in sync with `PR_DESCRIPTION_FILE` in `@cat-factory/agents`
20
+ // (the executor-harness has no dependency on that package), exactly like the
21
+ // effort-report and follow-ups sentinels.
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /** The sentinel file the agent writes its PR description to (relative to the checkout root). */
25
+ export const PR_DESCRIPTION_FILE = '.cat-pr-description.md'
26
+
27
+ /**
28
+ * Ceiling on the agent-authored body.
29
+ *
30
+ * The engine appends its verification report to the SAME body later, and that section carries
31
+ * its own 50,000-character ceiling (`MAX_SECTION_CHARS` in kernel's `hostMarkdown`). GitHub
32
+ * rejects a body over 65,536 with a 422, and the report publisher swallows its own failures —
33
+ * so a briefing budget that does not leave the report room would surface as a report that
34
+ * silently never publishes. 15,000 + 50,000 stays under the limit with room to join them.
35
+ */
36
+ const MAX_PR_BODY_CHARS = 15_000
37
+
38
+ /** Ceiling on an agent-supplied title (GitHub truncates around 256; a title should be short). */
39
+ const MAX_PR_TITLE_CHARS = 160
40
+
41
+ /** Opens the engine-managed region of a PR body (kept in sync with `kernel/domain/pr-report.ts`). */
42
+ export const PR_REPORT_MARKER_START = '<!-- cat-factory:verification-report:start -->'
43
+ /** Closes the engine-managed region of a PR body. */
44
+ export const PR_REPORT_MARKER_END = '<!-- cat-factory:verification-report:end -->'
45
+
46
+ /**
47
+ * A marker inside the agent-authored briefing would make the engine's splice treat part of the
48
+ * briefing as its own managed region and rewrite it, so any occurrence is stripped up front.
49
+ * Deliberately laxer than the exact constants above (whitespace-tolerant), so a near-miss the
50
+ * splice itself would not match cannot survive here either.
51
+ */
52
+ const MANAGED_SECTION_MARKER = /<!--\s*cat-factory:verification-report:(?:start|end)\s*-->/g
53
+
54
+ /** An agent-authored PR description: an optional title plus the briefing body. */
55
+ export interface AgentPrDescription {
56
+ title?: string
57
+ body?: string
58
+ }
59
+
60
+ /**
61
+ * Read + parse + REMOVE the agent's PR-description sentinel from `dir`. Lenient: returns
62
+ * undefined when the file is absent (the agent wrote none) or carries nothing usable. Never
63
+ * throws — a bad description must never fail an otherwise-good run; the caller falls back to
64
+ * the dispatch-time text.
65
+ *
66
+ * A SINGLE `# <title>` heading on the first line sets the PR title; everything after it is the
67
+ * body (see {@link splitTitle} for why a LONE heading is required). The whole text is
68
+ * secret-scrubbed, an over-budget body is truncated WITH a visible note (a silent cut would
69
+ * read as the complete briefing), and both halves are made inert for the host.
70
+ *
71
+ * On scrubbing: `redactSecrets`'s credential-assignment rule is deliberately eager, so a
72
+ * briefing sentence like "the token: handling changed" loses its next word. That is the right
73
+ * trade for a surface this public — the rule is shared with every other redaction path, and
74
+ * narrowing it so prose reads better would weaken all of them.
75
+ */
76
+ export async function readPrDescription(dir: string): Promise<AgentPrDescription | undefined> {
77
+ const path = join(dir, PR_DESCRIPTION_FILE)
78
+ let raw: string
79
+ try {
80
+ raw = await readFile(path, 'utf8')
81
+ } catch {
82
+ return undefined // no description written — the fallback body applies
83
+ }
84
+ // Remove it so it never lands in a commit (defence in depth; the checkout also excludes it).
85
+ await rm(path, { force: true }).catch(() => {})
86
+ const text = redactSecrets(raw).replace(MANAGED_SECTION_MARKER, '').trim()
87
+ if (!text) return undefined
88
+
89
+ const split = splitTitle(text)
90
+ // Cap BEFORE the escapes on both halves, so a numeric entity can never be sliced in half.
91
+ const title = split.title ? inertInline(capTitle(split.title)) : undefined
92
+ const body = split.body ? inertMarkdown(capBody(split.body)) : undefined
93
+ if (!title && !body) return undefined
94
+ return { ...(title ? { title } : {}), ...(body ? { body } : {}) }
95
+ }
96
+
97
+ /**
98
+ * Split a leading `# <title>` heading off the briefing.
99
+ *
100
+ * The heading becomes the title ONLY when it is the single level-1 heading in the whole file,
101
+ * which is exactly what the prompt asks for ("a single `# <title>` heading line"). An agent
102
+ * that instead uses `#` for its section headings — `# Problem`, `# Decisions`, entirely
103
+ * idiomatic for the briefing the prompt describes — would otherwise have its first section
104
+ * silently become the pull request's title, replacing `<block> (<pipeline>)` with the word
105
+ * "Problem". Headings inside fenced code are not headings and are skipped, or a briefing
106
+ * quoting a shell snippet (`# rebuild the image`) would lose its title to the snippet.
107
+ */
108
+ function splitTitle(text: string): { title?: string; body: string } {
109
+ const lines = text.split('\n')
110
+ const headings: number[] = []
111
+ let index = 0
112
+ walkFences(lines, (line, insideFence) => {
113
+ if (!insideFence && /^#\s+\S/.test(line)) headings.push(index)
114
+ index += 1
115
+ })
116
+ if (headings.length !== 1 || headings[0] !== 0) return { body: text }
117
+ const title = lines[0]!.replace(/^#\s+/, '').trim()
118
+ if (!title) return { body: text }
119
+ return { title, body: lines.slice(1).join('\n').trim() }
120
+ }
121
+
122
+ /** Cut an over-long title at a word boundary when one is near, marking the cut. */
123
+ function capTitle(value: string): string {
124
+ const collapsed = value.trim()
125
+ if (collapsed.length <= MAX_PR_TITLE_CHARS) return collapsed
126
+ const head = collapsed.slice(0, MAX_PR_TITLE_CHARS - 1)
127
+ const space = head.lastIndexOf(' ')
128
+ const kept = space > MAX_PR_TITLE_CHARS * 0.6 ? head.slice(0, space) : head
129
+ return `${kept.trimEnd()}…`
130
+ }
131
+
132
+ /** Cut an over-budget body, marking the cut so it is never read as the whole briefing. */
133
+ function capBody(value: string): string {
134
+ if (value.length <= MAX_PR_BODY_CHARS) return value
135
+ return (
136
+ value.slice(0, MAX_PR_BODY_CHARS).trimEnd() +
137
+ '\n\n_Truncated by the platform: the description exceeded the size budget._'
138
+ )
139
+ }
140
+
141
+ /**
142
+ * Fold an agent-authored description over the dispatch-time fallback the job body carries.
143
+ * Field-wise: the agent's title/body each win when present, so a body-only briefing keeps the
144
+ * backend-composed title and vice versa.
145
+ */
146
+ export function applyPrDescription(
147
+ fallback: { title: string; body: string },
148
+ agent: AgentPrDescription | undefined,
149
+ ): { title: string; body: string } {
150
+ if (!agent) return fallback
151
+ return { title: agent.title ?? fallback.title, body: agent.body ?? fallback.body }
152
+ }
153
+
154
+ /**
155
+ * The body to PATCH onto an ALREADY-OPEN pull request when a resumed run produced a fresh
156
+ * briefing: the new description followed by whatever the engine's managed verification-report
157
+ * region currently holds.
158
+ *
159
+ * Carrying the region across is what makes the refresh safe. The engine re-publishes the report
160
+ * on every step settlement, so dropping it here would usually self-heal — but "usually" is not
161
+ * a property to rest the one artefact a reviewer reads on, and a run that settles no further
162
+ * step (the work is already merged, the run failed after its push) would never restore it.
163
+ */
164
+ export function preserveManagedSection(currentBody: string | undefined, nextBody: string): string {
165
+ const existing = currentBody ?? ''
166
+ const start = existing.indexOf(PR_REPORT_MARKER_START)
167
+ const end = existing.indexOf(PR_REPORT_MARKER_END)
168
+ if (start === -1 || end <= start) return nextBody
169
+ const region = existing.slice(start, end + PR_REPORT_MARKER_END.length)
170
+ return `${nextBody.trim()}\n\n${region}\n`
171
+ }
package/src/vcs-api.ts ADDED
@@ -0,0 +1,512 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The VCS HOST REST surface: opening a pull request / merge request, finding the one that is
3
+ // already open, and refreshing its title + description.
4
+ //
5
+ // Split out of `git.ts`, which is otherwise entirely the git CLI. The seam is real rather than
6
+ // arithmetic: nothing here shells out to git, nothing in `git.ts` speaks HTTP, and the two
7
+ // halves fail in completely different ways (a rejected credential and a 403 from an App
8
+ // permission want different remedies). `test/git-pr.test.ts` already covered exactly this
9
+ // surface before it had a file of its own.
10
+ //
11
+ // Provider-agnostic by construction: GitHub and GitLab each get their own request shapes behind
12
+ // one `openPullRequest` entry point, and every capability added to one is added to the other.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ import type { PrSpec } from './job.js'
16
+ import { HarnessFailure } from './failure.js'
17
+ import { preserveManagedSection } from './pr-description.js'
18
+ import { redactSecrets } from './redact.js'
19
+
20
+ /**
21
+ * Classify a PR/MR-open REST failure by its HTTP status into an actionable remedy, else
22
+ * undefined (an unmapped status keeps just the raw `Failed to open … (HTTP n)` line). Like
23
+ * {@link describeGitFailure} this only APPENDS a cause + fix — the raw status line is
24
+ * load-bearing detail and stays. `provider` tailors the scope/permission wording (GitHub App
25
+ * Pull-requests permission / `repo` PAT scope vs GitLab `api` scope) and the noun (pull
26
+ * request vs merge request). Pure, so it is unit-tested per status.
27
+ */
28
+ export function describePrOpenFailure(
29
+ status: number,
30
+ provider: 'github' | 'gitlab',
31
+ ): string | undefined {
32
+ const noun = provider === 'gitlab' ? 'merge request' : 'pull request'
33
+ if (status === 401) {
34
+ return (
35
+ `The credential was rejected while opening the ${noun}. The GitHub App installation token ` +
36
+ '(or, in local mode, the GITHUB_PAT) is most likely expired, rotated, or revoked — reconnect ' +
37
+ 'the GitHub App for the workspace (or regenerate the PAT), then retry.'
38
+ )
39
+ }
40
+ if (status === 403) {
41
+ const scope =
42
+ provider === 'gitlab'
43
+ ? 'the GitLab token needs the `api` scope and Developer+ access to the project'
44
+ : 'the GitHub App needs the "Pull requests: write" permission (or the PAT the `repo` scope) and write access to the repository'
45
+ return `The credential lacks permission to open a ${noun}: ${scope}. Grant it, then retry.`
46
+ }
47
+ if (status === 404) {
48
+ return (
49
+ `The repository could not be found while opening the ${noun} — it may have been deleted, ` +
50
+ 'renamed, or made private, or the credential can no longer see it. Confirm the repo and the ' +
51
+ "credential's access to it, then retry."
52
+ )
53
+ }
54
+ if (status === 422 || status === 400) {
55
+ return (
56
+ `GitHub/GitLab rejected the ${noun} as invalid. Usually the head or base branch does not ` +
57
+ 'exist, the two branches are identical (nothing to compare), or the base branch is protected ' +
58
+ 'against direct PRs. Check the branch names and that the head has commits ahead of the base, ' +
59
+ 'then retry.'
60
+ )
61
+ }
62
+ return undefined
63
+ }
64
+ export interface OpenPullRequestOptions {
65
+ owner: string
66
+ name: string
67
+ ghToken: string
68
+ head: string
69
+ base: string
70
+ pr: PrSpec
71
+ apiBase?: string
72
+ /**
73
+ * The repo's clone URL. Used (when {@link provider} is absent) to detect the provider and,
74
+ * for GitLab, to derive the REST base + project path from its host — so the harness opens a
75
+ * GitLab **merge request** rather than POSTing to GitHub's pulls API. Absent ⇒ GitHub.
76
+ */
77
+ cloneUrl?: string
78
+ /**
79
+ * The VCS provider, when the dispatcher knows it (the server derives it from the configured
80
+ * source-control backend and sets `repo.provider`). AUTHORITATIVE — it overrides host
81
+ * inference — so a self-managed GitLab on an arbitrarily-named host (e.g. `git.acme.com`,
82
+ * which {@link inferVcsProvider} can't recognise) still opens a merge request instead of
83
+ * being misrouted to GitHub's API. Absent ⇒ inferred from {@link cloneUrl}'s host.
84
+ */
85
+ provider?: 'github' | 'gitlab'
86
+ /**
87
+ * When the PR/MR for {@link head} ALREADY exists (a resumed run pushing onto a branch whose PR
88
+ * is open), replace its title and description with {@link pr} instead of leaving them alone.
89
+ *
90
+ * Set ONLY when {@link pr} carries the agent's own reviewer briefing. A resumed run is exactly
91
+ * the case that matters — eviction + re-dispatch, a ralph iteration, a retry — and without this
92
+ * the agent writes a briefing the platform reads, scrubs, caps and then silently drops. It must
93
+ * stay opt-in, though: refreshing from the GENERIC dispatch-time fallback would overwrite a
94
+ * description a human (or an earlier, better-informed run) had already written.
95
+ *
96
+ * The engine's managed verification-report region is carried across the rewrite by
97
+ * {@link preserveManagedSection}, and the update is best-effort — a failed refresh keeps the
98
+ * run's real outcome, which is the pushed work.
99
+ */
100
+ refreshExisting?: boolean
101
+ signal?: AbortSignal
102
+ }
103
+
104
+ /**
105
+ * The VCS host a clone URL points at. The harness is otherwise provider-agnostic (its git
106
+ * auth is a host-neutral GIT_ASKPASS credential), but the "open the PR/MR" REST call is not:
107
+ * GitHub and GitLab have different endpoints, so infer which to call from the host. GitHub is
108
+ * the default; a host of `gitlab.com` or one in the `gitlab.*` / `*.gitlab.*` family (covering
109
+ * self-managed instances named that way) is treated as GitLab.
110
+ */
111
+ export function inferVcsProvider(cloneUrl: string): 'github' | 'gitlab' {
112
+ let host = ''
113
+ try {
114
+ host = new URL(cloneUrl).host.toLowerCase()
115
+ } catch {
116
+ return 'github'
117
+ }
118
+ if (host === 'gitlab.com' || host.startsWith('gitlab.') || host.includes('.gitlab.')) {
119
+ return 'gitlab'
120
+ }
121
+ return 'github'
122
+ }
123
+
124
+ /** The GitLab REST v4 base for a clone URL's host, e.g. `https://gitlab.com/api/v4`. */
125
+ export function gitlabApiBaseFromCloneUrl(cloneUrl: string): string {
126
+ const u = new URL(cloneUrl)
127
+ return `${u.protocol}//${u.host}/api/v4`
128
+ }
129
+
130
+ /**
131
+ * The URL-encoded GitLab project path from a clone URL — the full namespace path (so subgroups
132
+ * survive), with the trailing `.git` stripped, e.g.
133
+ * `https://gitlab.com/group/sub/proj.git` → `group%2Fsub%2Fproj`.
134
+ */
135
+ export function gitlabProjectPath(cloneUrl: string): string {
136
+ const path = new URL(cloneUrl).pathname.replace(/^\/+/, '').replace(/\.git$/, '')
137
+ return encodeURIComponent(path)
138
+ }
139
+
140
+ /** The abort reason as an Error (the watchdog aborts with one), or a generic fallback. */
141
+ function abortError(signal: AbortSignal): Error {
142
+ return signal.reason instanceof Error ? signal.reason : new Error('aborted')
143
+ }
144
+
145
+ /** Whether a thrown fetch error is an AbortError (caller-initiated, never retried). */
146
+ function isAbortError(err: unknown): boolean {
147
+ return err instanceof Error && err.name === 'AbortError'
148
+ }
149
+
150
+ /**
151
+ * Parse a `Retry-After` header into ms, bounded so it can't stall the job. Accepts BOTH
152
+ * forms the spec allows: integer delay-seconds (`120`) and an HTTP-date (`Wed, 21 Oct 2026
153
+ * 07:28:00 GMT`); the latter is turned into a delay from now. A past/zero/unparseable value
154
+ * yields undefined so the caller falls back to exponential backoff.
155
+ */
156
+ function retryAfterMs(res: Response): number | undefined {
157
+ const raw = res.headers.get('retry-after')
158
+ if (!raw) return undefined
159
+ const secs = Number(raw)
160
+ if (Number.isFinite(secs)) {
161
+ return secs > 0 ? Math.min(secs * 1000, MAX_RETRY_AFTER_MS) : undefined
162
+ }
163
+ const at = Date.parse(raw)
164
+ if (Number.isNaN(at)) return undefined
165
+ const ms = at - Date.now()
166
+ return ms > 0 ? Math.min(ms, MAX_RETRY_AFTER_MS) : undefined
167
+ }
168
+
169
+ /** Sleep `ms`, rejecting immediately (with the abort reason) if `signal` aborts meanwhile. */
170
+ function abortableDelay(ms: number, signal?: AbortSignal): Promise<void> {
171
+ return new Promise((resolve, reject) => {
172
+ if (signal?.aborted) return reject(abortError(signal))
173
+ const onAbort = (): void => {
174
+ clearTimeout(timer)
175
+ reject(abortError(signal as AbortSignal))
176
+ }
177
+ const timer = setTimeout(() => {
178
+ signal?.removeEventListener('abort', onAbort)
179
+ resolve()
180
+ }, ms)
181
+ signal?.addEventListener('abort', onAbort, { once: true })
182
+ })
183
+ }
184
+
185
+ const MAX_RETRY_AFTER_MS = 8_000
186
+ const RETRY_BASE_MS = 500
187
+ const RETRY_MAX_DELAY_MS = 4_000
188
+
189
+ /**
190
+ * Run a single HTTP request with bounded retry for TRANSIENT failures, so a momentary
191
+ * upstream blip (a 5xx, a 429 rate-limit, or a dropped connection) no longer fails an
192
+ * otherwise-complete run on its very last step (opening the PR/MR). Up to 3 attempts
193
+ * (2 retries) with exponential backoff + jitter (honoring a `Retry-After` on a 429),
194
+ * every wait abort-aware so the inactivity/max-duration watchdog still cancels promptly.
195
+ *
196
+ * ONLY transient failures retry: a `>=500`/`429` response, or a network-level fetch
197
+ * rejection. A 4xx (incl. the 422/409 "already exists" the callers treat as success) is
198
+ * returned to the caller unretried, and a caller abort is rethrown at once. The response
199
+ * body is never read here, so the caller's existing status handling is unchanged.
200
+ */
201
+ async function withApiRetry(
202
+ fn: () => Promise<Response>,
203
+ opts: { signal?: AbortSignal; attempts?: number } = {},
204
+ ): Promise<Response> {
205
+ const maxAttempts = opts.attempts ?? 3
206
+ let lastError: unknown
207
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
208
+ if (opts.signal?.aborted) throw abortError(opts.signal)
209
+ let res: Response | undefined
210
+ try {
211
+ res = await fn()
212
+ } catch (err) {
213
+ // A caller/watchdog abort is terminal; a network error is transient → retry.
214
+ if (isAbortError(err) || opts.signal?.aborted) throw err
215
+ lastError = err
216
+ }
217
+ if (res) {
218
+ const transient = res.status >= 500 || res.status === 429
219
+ if (!transient || attempt >= maxAttempts) return res
220
+ const after = retryAfterMs(res)
221
+ // Discard the unread body before retrying so the connection can be reused.
222
+ await res.body?.cancel().catch(() => {})
223
+ await abortableDelay(after ?? backoffMs(attempt), opts.signal)
224
+ continue
225
+ }
226
+ if (attempt >= maxAttempts) break
227
+ await abortableDelay(backoffMs(attempt), opts.signal)
228
+ }
229
+ // Exhausted on a network-level rejection (no HTTP response): an upstream API failure.
230
+ const message =
231
+ lastError instanceof Error ? lastError.message : 'API request failed after retries'
232
+ throw new HarnessFailure('api', redactSecrets(message))
233
+ }
234
+
235
+ /** Exponential backoff (base 500ms, capped 4s) with up to 25% positive jitter. */
236
+ function backoffMs(attempt: number): number {
237
+ const base = Math.min(RETRY_MAX_DELAY_MS, RETRY_BASE_MS * 2 ** (attempt - 1))
238
+ return base + Math.floor(base * 0.25 * Math.random())
239
+ }
240
+
241
+ /**
242
+ * Open a PR (GitHub) or merge request (GitLab) for the pushed branch; returns its web URL.
243
+ * The provider is chosen from the EXPLICIT `opts.provider` when the dispatcher set it,
244
+ * falling back to host inference from the clone URL only when it didn't — so a self-managed
245
+ * GitLab whose host isn't named `gitlab.*` still opens an MR instead of being misrouted to
246
+ * GitHub's API. The GitHub path is unchanged.
247
+ */
248
+ export async function openPullRequest(opts: OpenPullRequestOptions): Promise<string | null> {
249
+ const provider = opts.provider ?? (opts.cloneUrl ? inferVcsProvider(opts.cloneUrl) : 'github')
250
+ if (provider === 'gitlab') {
251
+ if (!opts.cloneUrl) {
252
+ throw new Error('Cannot open a GitLab merge request without the repo clone URL')
253
+ }
254
+ return openGitLabMergeRequest({ ...opts, cloneUrl: opts.cloneUrl })
255
+ }
256
+ const apiBase = opts.apiBase ?? 'https://api.github.com'
257
+ const path = `${encodeURIComponent(opts.owner)}/${encodeURIComponent(opts.name)}`
258
+ const res = await withApiRetry(
259
+ () =>
260
+ fetch(`${apiBase}/repos/${path}/pulls`, {
261
+ method: 'POST',
262
+ headers: {
263
+ authorization: `Bearer ${opts.ghToken}`,
264
+ accept: 'application/vnd.github+json',
265
+ 'user-agent': 'cat-factory-executor',
266
+ 'x-github-api-version': '2022-11-28',
267
+ 'content-type': 'application/json',
268
+ },
269
+ body: JSON.stringify({
270
+ title: opts.pr.title,
271
+ head: opts.head,
272
+ base: opts.base,
273
+ body: opts.pr.body,
274
+ }),
275
+ // Bound on the watchdog so a hung GitHub call can't stall the job.
276
+ ...(opts.signal ? { signal: opts.signal } : {}),
277
+ }),
278
+ { signal: opts.signal },
279
+ )
280
+ if (!res.ok) {
281
+ const detail = await res.text().catch(() => '')
282
+ // A resumed run pushes to a branch that already has an open PR; GitHub answers
283
+ // 422 "A pull request already exists". That's success for us — return the
284
+ // existing PR's url rather than failing the resumed run.
285
+ if (res.status === 422 && /pull request already exists/i.test(detail)) {
286
+ const existing = await findOpenPullRequest(opts)
287
+ if (existing) {
288
+ if (opts.refreshExisting) await refreshPullRequest(opts, existing)
289
+ return existing.url
290
+ }
291
+ }
292
+ // The head branch has nothing ahead of base ("No commits between <base> and <head>").
293
+ // That is not an API failure — there is simply nothing to open a PR for (e.g. a resumed
294
+ // branch whose earlier PR was merged with a merge commit, leaving the branch reachable
295
+ // from base). Signal it with null so the caller records a clean no-op instead of failing
296
+ // the run with GitHub's opaque 422.
297
+ if (res.status === 422 && /no commits between/i.test(detail)) return null
298
+ const remedy = describePrOpenFailure(res.status, 'github')
299
+ const base = redactSecrets(`Failed to open PR (HTTP ${res.status}): ${detail.slice(0, 300)}`)
300
+ throw new HarnessFailure('api', remedy ? `${base}\n${remedy}` : base)
301
+ }
302
+ const body = (await res.json()) as { html_url?: string }
303
+ if (!body.html_url) throw new HarnessFailure('api', 'GitHub did not return a PR url')
304
+ return body.html_url
305
+ }
306
+
307
+ /** GitLab API headers for the PAT (the `PRIVATE-TOKEN` auth GitLab uses). */
308
+ function gitlabHeaders(token: string): Record<string, string> {
309
+ return {
310
+ 'private-token': token,
311
+ accept: 'application/json',
312
+ 'user-agent': 'cat-factory-executor',
313
+ 'content-type': 'application/json',
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Open a GitLab merge request (the analogue of {@link openPullRequest} for GitLab). The REST
319
+ * base + project path are derived from the clone URL's host, so it works for gitlab.com and a
320
+ * self-managed instance alike. `head`→`source_branch`, `base`→`target_branch`. On a duplicate
321
+ * (a resumed run whose branch already has an open MR — GitLab answers 409) the existing MR's
322
+ * web URL is returned instead of failing the run, mirroring the GitHub 422 handling.
323
+ */
324
+ async function openGitLabMergeRequest(
325
+ opts: OpenPullRequestOptions & { cloneUrl: string },
326
+ ): Promise<string> {
327
+ const apiBase = gitlabApiBaseFromCloneUrl(opts.cloneUrl)
328
+ const project = gitlabProjectPath(opts.cloneUrl)
329
+ const res = await withApiRetry(
330
+ () =>
331
+ fetch(`${apiBase}/projects/${project}/merge_requests`, {
332
+ method: 'POST',
333
+ headers: gitlabHeaders(opts.ghToken),
334
+ body: JSON.stringify({
335
+ source_branch: opts.head,
336
+ target_branch: opts.base,
337
+ title: opts.pr.title,
338
+ description: opts.pr.body,
339
+ }),
340
+ ...(opts.signal ? { signal: opts.signal } : {}),
341
+ }),
342
+ { signal: opts.signal },
343
+ )
344
+ if (!res.ok) {
345
+ const detail = await res.text().catch(() => '')
346
+ // GitLab returns 409 (sometimes 400) when an open MR already exists for this source
347
+ // branch; that is success for a resumed run — return the existing MR's url.
348
+ if (
349
+ (res.status === 409 || res.status === 400) &&
350
+ /already exists|open merge request/i.test(detail)
351
+ ) {
352
+ const existing = await findOpenMergeRequest(apiBase, project, opts)
353
+ if (existing) {
354
+ if (opts.refreshExisting) await refreshMergeRequest(apiBase, project, opts, existing)
355
+ return existing.url
356
+ }
357
+ }
358
+ const remedy = describePrOpenFailure(res.status, 'gitlab')
359
+ const base = redactSecrets(
360
+ `Failed to open merge request (HTTP ${res.status}): ${detail.slice(0, 300)}`,
361
+ )
362
+ throw new HarnessFailure('api', remedy ? `${base}\n${remedy}` : base)
363
+ }
364
+ const body = (await res.json()) as { web_url?: string }
365
+ if (!body.web_url) throw new HarnessFailure('api', 'GitLab did not return a merge request url')
366
+ return body.web_url
367
+ }
368
+
369
+ /**
370
+ * Rewrite an already-open MR's title and description — the GitLab half of
371
+ * {@link refreshPullRequest}, so a resumed run's agent briefing reaches both hosts alike.
372
+ * Best-effort for the same reason.
373
+ */
374
+ async function refreshMergeRequest(
375
+ apiBase: string,
376
+ project: string,
377
+ opts: OpenPullRequestOptions,
378
+ existing: ExistingPullRequest,
379
+ ): Promise<void> {
380
+ if (existing.number === undefined) return
381
+ await fetch(`${apiBase}/projects/${project}/merge_requests/${existing.number}`, {
382
+ method: 'PUT',
383
+ headers: gitlabHeaders(opts.ghToken),
384
+ body: JSON.stringify({
385
+ title: opts.pr.title,
386
+ description: preserveManagedSection(existing.body, opts.pr.body),
387
+ }),
388
+ ...(opts.signal ? { signal: opts.signal } : {}),
389
+ }).catch(() => undefined)
390
+ }
391
+
392
+ /** Find the open GitLab MR for `opts.head`→`opts.base`, or undefined when there is none. */
393
+ async function findOpenMergeRequest(
394
+ apiBase: string,
395
+ project: string,
396
+ opts: { head: string; base: string; ghToken: string; signal?: AbortSignal },
397
+ ): Promise<ExistingPullRequest | undefined> {
398
+ // Filter by BOTH branches: a source branch can have open MRs to several targets, so the
399
+ // source alone could match an MR against a different base than the one we just tried to open.
400
+ const query = new URLSearchParams({
401
+ source_branch: opts.head,
402
+ target_branch: opts.base,
403
+ state: 'opened',
404
+ })
405
+ const res = await fetch(`${apiBase}/projects/${project}/merge_requests?${query}`, {
406
+ headers: gitlabHeaders(opts.ghToken),
407
+ ...(opts.signal ? { signal: opts.signal } : {}),
408
+ })
409
+ if (!res.ok) return undefined
410
+ const list = (await res.json().catch(() => [])) as Array<{
411
+ web_url?: string
412
+ iid?: number
413
+ description?: string | null
414
+ }>
415
+ const found = Array.isArray(list) ? list[0] : undefined
416
+ if (!found?.web_url) return undefined
417
+ return {
418
+ url: found.web_url,
419
+ // `iid` (project-scoped) is what the update endpoint addresses, not the global `id`.
420
+ ...(typeof found.iid === 'number' ? { number: found.iid } : {}),
421
+ body: found.description ?? undefined,
422
+ }
423
+ }
424
+
425
+ /**
426
+ * An already-open PR: enough to return it as the run's PR and, when the host told us its
427
+ * number, to rewrite its description.
428
+ *
429
+ * `number` is OPTIONAL on purpose. Returning the existing PR's url is what keeps a resumed run
430
+ * from failing, and that must not become contingent on a second field parsing: a response we
431
+ * can read a url but not a number out of degrades to "found it, can't refresh it", never to a
432
+ * failed run.
433
+ */
434
+ interface ExistingPullRequest {
435
+ url: string
436
+ number?: number
437
+ body: string | undefined
438
+ }
439
+
440
+ /**
441
+ * Rewrite an already-open PR's title and description from `opts.pr` (a resumed run whose agent
442
+ * wrote a fresh reviewer briefing — see {@link OpenPullRequestOptions.refreshExisting}).
443
+ *
444
+ * Best-effort by construction: the work is already pushed and the PR already exists, so a failed
445
+ * refresh must degrade to the stale description rather than fail the run.
446
+ */
447
+ async function refreshPullRequest(
448
+ opts: OpenPullRequestOptions,
449
+ existing: ExistingPullRequest,
450
+ ): Promise<void> {
451
+ if (existing.number === undefined) return
452
+ const apiBase = opts.apiBase ?? 'https://api.github.com'
453
+ const path = `${encodeURIComponent(opts.owner)}/${encodeURIComponent(opts.name)}`
454
+ await fetch(`${apiBase}/repos/${path}/pulls/${existing.number}`, {
455
+ method: 'PATCH',
456
+ headers: {
457
+ authorization: `Bearer ${opts.ghToken}`,
458
+ accept: 'application/vnd.github+json',
459
+ 'user-agent': 'cat-factory-executor',
460
+ 'x-github-api-version': '2022-11-28',
461
+ 'content-type': 'application/json',
462
+ },
463
+ body: JSON.stringify({
464
+ title: opts.pr.title,
465
+ body: preserveManagedSection(existing.body, opts.pr.body),
466
+ }),
467
+ ...(opts.signal ? { signal: opts.signal } : {}),
468
+ }).catch(() => undefined)
469
+ }
470
+
471
+ /** Find the open PR for `opts.head` on `opts.base`, or undefined when there is none. */
472
+ async function findOpenPullRequest(opts: {
473
+ owner: string
474
+ name: string
475
+ ghToken: string
476
+ head: string
477
+ base: string
478
+ apiBase?: string
479
+ signal?: AbortSignal
480
+ }): Promise<ExistingPullRequest | undefined> {
481
+ const apiBase = opts.apiBase ?? 'https://api.github.com'
482
+ // Encode the ref-derived query params: a branch/owner containing `&` or `#` would
483
+ // otherwise split the query string or inject an unintended parameter.
484
+ const query = new URLSearchParams({
485
+ head: `${opts.owner}:${opts.head}`,
486
+ base: opts.base,
487
+ state: 'open',
488
+ })
489
+ const path = `${encodeURIComponent(opts.owner)}/${encodeURIComponent(opts.name)}`
490
+ const res = await fetch(`${apiBase}/repos/${path}/pulls?${query}`, {
491
+ headers: {
492
+ authorization: `Bearer ${opts.ghToken}`,
493
+ accept: 'application/vnd.github+json',
494
+ 'user-agent': 'cat-factory-executor',
495
+ 'x-github-api-version': '2022-11-28',
496
+ },
497
+ ...(opts.signal ? { signal: opts.signal } : {}),
498
+ })
499
+ if (!res.ok) return undefined
500
+ const list = (await res.json().catch(() => [])) as Array<{
501
+ html_url?: string
502
+ number?: number
503
+ body?: string | null
504
+ }>
505
+ const found = Array.isArray(list) ? list[0] : undefined
506
+ if (!found?.html_url) return undefined
507
+ return {
508
+ url: found.html_url,
509
+ ...(typeof found.number === 'number' ? { number: found.number } : {}),
510
+ body: found.body ?? undefined,
511
+ }
512
+ }