@cat-factory/executor-harness 1.60.0 → 1.64.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.
@@ -24,15 +24,21 @@ import {
24
24
  fetchReferenceBranches,
25
25
  headCommit,
26
26
  listUntrackedFiles,
27
- openPullRequest,
28
27
  prepareExistingCheckout,
29
28
  pushBranch,
30
29
  refreshFromBaseIfClean,
31
30
  remoteBranchExists,
32
31
  } from './git.js'
32
+ import { openPullRequest } from './vcs-api.js'
33
33
  import { FOLLOW_UPS_FILENAME, FollowUpTailer } from './follow-ups.js'
34
34
  import type { HarnessCallMetric, PiRunStats } from './pi.js'
35
35
  import { EFFORT_REPORT_FILE, type EffortReport } from './effort.js'
36
+ import {
37
+ type AgentPrDescription,
38
+ applyPrDescription,
39
+ PR_DESCRIPTION_FILE,
40
+ readPrDescription,
41
+ } from './pr-description.js'
36
42
  import {
37
43
  acquireRepoCheckout,
38
44
  agentNeverActed,
@@ -40,7 +46,7 @@ import {
40
46
  runAgentInWorkspace,
41
47
  withWorkspace,
42
48
  } from './pi-workspace.js'
43
- import type { ProgressGuardLimits } from './pi.js'
49
+ import type { ProgressGuardLimits } from './progress-guard.js'
44
50
  import type { RunOptions } from './runner.js'
45
51
  import { log, type Logger } from './logger.js'
46
52
  import {
@@ -157,6 +163,12 @@ export interface CodingAgentOutcome {
157
163
  callMetrics?: HarnessCallMetric[]
158
164
  /** The agent's effort self-assessment, lifted from its sentinel file (absent when it wrote none). */
159
165
  effortReport?: EffortReport
166
+ /**
167
+ * The agent-authored PR description, lifted from its sentinel file (absent when it wrote none).
168
+ * The PR-opening caller folds it over the dispatch-time title/body via {@link applyPrDescription};
169
+ * absent means the fallback text, unchanged.
170
+ */
171
+ prDescription?: AgentPrDescription
160
172
  /**
161
173
  * Ralph loop: the verdict of the post-commit validation command (whether it exited 0, the
162
174
  * exit code, and a bounded/redacted output tail). Present only when {@link CodingAgentSpec.validation}
@@ -295,6 +307,9 @@ export async function runCodingAgent(
295
307
  // but that cannot un-stage a mid-run commit; the per-clone exclude is what prevents it. A bare
296
308
  // filename pattern matches the file in any subdirectory, so it covers a monorepo `workDir` too.
297
309
  await excludeFromGit(dir, EFFORT_REPORT_FILE, signal)
310
+ // Same treatment for the agent-authored PR-description sentinel: excluded locally so the
311
+ // agent's own `git add` can never stage the briefing into the PR it describes.
312
+ await excludeFromGit(dir, PR_DESCRIPTION_FILE, signal)
298
313
 
299
314
  // Follow-up companion: tail the Coder's sentinel file and stream new items out on the
300
315
  // job view. Locally exclude it from git first so the agent's own `git add` can never
@@ -624,6 +639,14 @@ async function finalizeCodingRun(args: {
624
639
  // untracked scratch files/artifacts — the agent owns committing new files).
625
640
  await commitTrackedEdits(dir, spec.commitMessage, signal)
626
641
 
642
+ // The agent-authored PR description, read AFTER the validation loop (a repair round may have
643
+ // changed what the briefing should say) and removed so it never lingers in the checkout. The
644
+ // prompt asks for it at the top level of the checkout; a monorepo agent working in a service
645
+ // subdirectory may drop it in its cwd instead, so probe the checkout root first, then the cwd.
646
+ const prDescription =
647
+ (await readPrDescription(dir)) ??
648
+ (workDir !== dir ? await readPrDescription(workDir) : undefined)
649
+
627
650
  // Stop periodic checkpoints and let any in-flight one settle BEFORE the final
628
651
  // push, so the two never run a concurrent `git push` to the same branch (the
629
652
  // final push below is then a fresh attempt whose failure is the real signal).
@@ -686,6 +709,7 @@ async function finalizeCodingRun(args: {
686
709
  ...(usage ? { usage } : {}),
687
710
  ...(callMetrics ? { callMetrics } : {}),
688
711
  ...(effortReport ? { effortReport } : {}),
712
+ ...(prDescription ? { prDescription } : {}),
689
713
  }
690
714
  }
691
715
 
@@ -1017,6 +1041,7 @@ export async function runMultiRepoCoding(
1017
1041
  job,
1018
1042
  logger,
1019
1043
  opts,
1044
+ root,
1020
1045
  )
1021
1046
 
1022
1047
  const anyWork = primaryPushed || peerPullRequests.length > 0
@@ -1129,6 +1154,9 @@ async function prepareMultiRepoCheckouts(
1129
1154
  await createBranch(dir, leg.workBranch, signal)
1130
1155
  }
1131
1156
  leg.dir = dir
1157
+ // Exclude the agent-authored PR-description sentinel locally (as the single-repo path does)
1158
+ // so the agent's own `git add` can never stage the briefing into the PR it describes.
1159
+ await excludeFromGit(dir, PR_DESCRIPTION_FILE, signal)
1132
1160
  // The branch tip before the agent runs. Captured BEFORE the resume base refresh below so
1133
1161
  // that refresh's merge commit counts as advancement and is pushed (as in the single-repo
1134
1162
  // path). A fresh leg produced work iff its branch advances past this; a resumed leg already
@@ -1187,6 +1215,8 @@ async function pushMultiRepoLegs(
1187
1215
  job: AgentJob,
1188
1216
  logger: Logger,
1189
1217
  opts: RunOptions,
1218
+ /** The workspace root the agent ran in — the fallback probe for the primary's briefing. */
1219
+ root: string,
1190
1220
  ): Promise<{
1191
1221
  primaryPushed: boolean
1192
1222
  primaryPrUrl: string | undefined
@@ -1201,6 +1231,15 @@ async function pushMultiRepoLegs(
1201
1231
  // A read-only reference leg is never committed or pushed — the third layer of the read-only
1202
1232
  // guarantee (the spec carries no branch/PR, and the clone phase gave it no work branch).
1203
1233
  if (leg.readOnly) continue
1234
+ // Lift (and remove) the agent-authored PR description for THIS repo's PR before anything
1235
+ // else touches the checkout — each sibling checkout carries its own briefing for its own PR.
1236
+ // The agent's cwd here is the WORKSPACE ROOT rather than any one checkout, so an agent that
1237
+ // read the prompt loosely may well have written a single briefing there instead. Fall back
1238
+ // to it for the PRIMARY leg only: at the root there is nothing to say which repo it
1239
+ // describes, and the primary is the one the run is actually about.
1240
+ const agentPrDescription =
1241
+ (await readPrDescription(leg.dir)) ??
1242
+ (leg.primary ? await readPrDescription(root) : undefined)
1204
1243
  await commitTrackedEdits(leg.dir, job.commitMessage ?? leg.pr?.title ?? 'Agent changes', signal)
1205
1244
  const advanced = await branchHasCommitsSince(leg.dir, leg.baseSha, signal)
1206
1245
  let hasWork = advanced || leg.resumed
@@ -1229,7 +1268,10 @@ async function pushMultiRepoLegs(
1229
1268
  ghToken: leg.ghToken,
1230
1269
  head: leg.workBranch,
1231
1270
  base: leg.repo.baseBranch,
1232
- pr: leg.pr,
1271
+ pr: applyPrDescription(leg.pr, agentPrDescription),
1272
+ // See the single-repo call site: refresh a resumed leg's already-open PR, but only
1273
+ // when the text is the agent's own briefing rather than the dispatch-time fallback.
1274
+ ...(agentPrDescription ? { refreshExisting: true } : {}),
1233
1275
  apiBase: job.githubApiBase,
1234
1276
  cloneUrl: leg.repo.cloneUrl,
1235
1277
  ...(leg.repo.provider ? { provider: leg.repo.provider } : {}),
package/src/embed.ts CHANGED
@@ -7,21 +7,23 @@
7
7
 
8
8
  export {
9
9
  PI_MAX_OUTPUT_TOKENS,
10
- DEFAULT_PROGRESS_GUARD_LIMITS,
11
10
  writePiModelsConfig,
12
11
  writeAgentsContext,
13
12
  runPi,
14
13
  summarizePiRun,
15
14
  parsePiOutput,
16
15
  parseTodoProgress,
17
- progressGuardLimitsFromEnv,
18
16
  terminalRunError,
19
17
  type PiRunOutcome,
20
18
  type PiRunStats,
21
- type ProgressGuardLimits,
22
19
  type TodoItem,
23
20
  type TodoProgress,
24
21
  } from './pi.js'
22
+ export {
23
+ DEFAULT_PROGRESS_GUARD_LIMITS,
24
+ progressGuardLimitsFromEnv,
25
+ type ProgressGuardLimits,
26
+ } from './progress-guard.js'
25
27
  export {
26
28
  cloneRepo,
27
29
  createBranch,
package/src/git.ts CHANGED
@@ -3,7 +3,7 @@ import { appendFile, chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
  import { promisify } from 'node:util'
6
- import type { BootstrapTargetSpec, PrSpec, RepoSpec } from './job.js'
6
+ import type { BootstrapTargetSpec, RepoSpec } from './job.js'
7
7
  import { pathExists } from './fs-utils.js'
8
8
  import { redactSecrets } from './redact.js'
9
9
  import { loadRunnerLimits } from './runner.js'
@@ -174,51 +174,6 @@ export function describeGitFailure(stderr: string): string | undefined {
174
174
  return undefined
175
175
  }
176
176
 
177
- /**
178
- * Classify a PR/MR-open REST failure by its HTTP status into an actionable remedy, else
179
- * undefined (an unmapped status keeps just the raw `Failed to open … (HTTP n)` line). Like
180
- * {@link describeGitFailure} this only APPENDS a cause + fix — the raw status line is
181
- * load-bearing detail and stays. `provider` tailors the scope/permission wording (GitHub App
182
- * Pull-requests permission / `repo` PAT scope vs GitLab `api` scope) and the noun (pull
183
- * request vs merge request). Pure, so it is unit-tested per status.
184
- */
185
- export function describePrOpenFailure(
186
- status: number,
187
- provider: 'github' | 'gitlab',
188
- ): string | undefined {
189
- const noun = provider === 'gitlab' ? 'merge request' : 'pull request'
190
- if (status === 401) {
191
- return (
192
- `The credential was rejected while opening the ${noun}. The GitHub App installation token ` +
193
- '(or, in local mode, the GITHUB_PAT) is most likely expired, rotated, or revoked — reconnect ' +
194
- 'the GitHub App for the workspace (or regenerate the PAT), then retry.'
195
- )
196
- }
197
- if (status === 403) {
198
- const scope =
199
- provider === 'gitlab'
200
- ? 'the GitLab token needs the `api` scope and Developer+ access to the project'
201
- : 'the GitHub App needs the "Pull requests: write" permission (or the PAT the `repo` scope) and write access to the repository'
202
- return `The credential lacks permission to open a ${noun}: ${scope}. Grant it, then retry.`
203
- }
204
- if (status === 404) {
205
- return (
206
- `The repository could not be found while opening the ${noun} — it may have been deleted, ` +
207
- 'renamed, or made private, or the credential can no longer see it. Confirm the repo and the ' +
208
- "credential's access to it, then retry."
209
- )
210
- }
211
- if (status === 422 || status === 400) {
212
- return (
213
- `GitHub/GitLab rejected the ${noun} as invalid. Usually the head or base branch does not ` +
214
- 'exist, the two branches are identical (nothing to compare), or the base branch is protected ' +
215
- 'against direct PRs. Check the branch names and that the head has commits ahead of the base, ' +
216
- 'then retry.'
217
- )
218
- }
219
- return undefined
220
- }
221
-
222
177
  /**
223
178
  * Wrap a git failure into a credential-scrubbed {@link HarnessFailure}('git') with an ACCURATE
224
179
  * message. Three cases the old bare "Command failed: git …" collapsed together:
@@ -1096,342 +1051,3 @@ export async function reinitAndPush(opts: {
1096
1051
  env: await authEnv(opts.ghToken),
1097
1052
  })
1098
1053
  }
1099
-
1100
- export interface OpenPullRequestOptions {
1101
- owner: string
1102
- name: string
1103
- ghToken: string
1104
- head: string
1105
- base: string
1106
- pr: PrSpec
1107
- apiBase?: string
1108
- /**
1109
- * The repo's clone URL. Used (when {@link provider} is absent) to detect the provider and,
1110
- * for GitLab, to derive the REST base + project path from its host — so the harness opens a
1111
- * GitLab **merge request** rather than POSTing to GitHub's pulls API. Absent ⇒ GitHub.
1112
- */
1113
- cloneUrl?: string
1114
- /**
1115
- * The VCS provider, when the dispatcher knows it (the server derives it from the configured
1116
- * source-control backend and sets `repo.provider`). AUTHORITATIVE — it overrides host
1117
- * inference — so a self-managed GitLab on an arbitrarily-named host (e.g. `git.acme.com`,
1118
- * which {@link inferVcsProvider} can't recognise) still opens a merge request instead of
1119
- * being misrouted to GitHub's API. Absent ⇒ inferred from {@link cloneUrl}'s host.
1120
- */
1121
- provider?: 'github' | 'gitlab'
1122
- signal?: AbortSignal
1123
- }
1124
-
1125
- /**
1126
- * The VCS host a clone URL points at. The harness is otherwise provider-agnostic (its git
1127
- * auth is a host-neutral GIT_ASKPASS credential), but the "open the PR/MR" REST call is not:
1128
- * GitHub and GitLab have different endpoints, so infer which to call from the host. GitHub is
1129
- * the default; a host of `gitlab.com` or one in the `gitlab.*` / `*.gitlab.*` family (covering
1130
- * self-managed instances named that way) is treated as GitLab.
1131
- */
1132
- export function inferVcsProvider(cloneUrl: string): 'github' | 'gitlab' {
1133
- let host = ''
1134
- try {
1135
- host = new URL(cloneUrl).host.toLowerCase()
1136
- } catch {
1137
- return 'github'
1138
- }
1139
- if (host === 'gitlab.com' || host.startsWith('gitlab.') || host.includes('.gitlab.')) {
1140
- return 'gitlab'
1141
- }
1142
- return 'github'
1143
- }
1144
-
1145
- /** The GitLab REST v4 base for a clone URL's host, e.g. `https://gitlab.com/api/v4`. */
1146
- export function gitlabApiBaseFromCloneUrl(cloneUrl: string): string {
1147
- const u = new URL(cloneUrl)
1148
- return `${u.protocol}//${u.host}/api/v4`
1149
- }
1150
-
1151
- /**
1152
- * The URL-encoded GitLab project path from a clone URL — the full namespace path (so subgroups
1153
- * survive), with the trailing `.git` stripped, e.g.
1154
- * `https://gitlab.com/group/sub/proj.git` → `group%2Fsub%2Fproj`.
1155
- */
1156
- export function gitlabProjectPath(cloneUrl: string): string {
1157
- const path = new URL(cloneUrl).pathname.replace(/^\/+/, '').replace(/\.git$/, '')
1158
- return encodeURIComponent(path)
1159
- }
1160
-
1161
- /** The abort reason as an Error (the watchdog aborts with one), or a generic fallback. */
1162
- function abortError(signal: AbortSignal): Error {
1163
- return signal.reason instanceof Error ? signal.reason : new Error('aborted')
1164
- }
1165
-
1166
- /** Whether a thrown fetch error is an AbortError (caller-initiated, never retried). */
1167
- function isAbortError(err: unknown): boolean {
1168
- return err instanceof Error && err.name === 'AbortError'
1169
- }
1170
-
1171
- /**
1172
- * Parse a `Retry-After` header into ms, bounded so it can't stall the job. Accepts BOTH
1173
- * forms the spec allows: integer delay-seconds (`120`) and an HTTP-date (`Wed, 21 Oct 2026
1174
- * 07:28:00 GMT`); the latter is turned into a delay from now. A past/zero/unparseable value
1175
- * yields undefined so the caller falls back to exponential backoff.
1176
- */
1177
- function retryAfterMs(res: Response): number | undefined {
1178
- const raw = res.headers.get('retry-after')
1179
- if (!raw) return undefined
1180
- const secs = Number(raw)
1181
- if (Number.isFinite(secs)) {
1182
- return secs > 0 ? Math.min(secs * 1000, MAX_RETRY_AFTER_MS) : undefined
1183
- }
1184
- const at = Date.parse(raw)
1185
- if (Number.isNaN(at)) return undefined
1186
- const ms = at - Date.now()
1187
- return ms > 0 ? Math.min(ms, MAX_RETRY_AFTER_MS) : undefined
1188
- }
1189
-
1190
- /** Sleep `ms`, rejecting immediately (with the abort reason) if `signal` aborts meanwhile. */
1191
- function abortableDelay(ms: number, signal?: AbortSignal): Promise<void> {
1192
- return new Promise((resolve, reject) => {
1193
- if (signal?.aborted) return reject(abortError(signal))
1194
- const onAbort = (): void => {
1195
- clearTimeout(timer)
1196
- reject(abortError(signal as AbortSignal))
1197
- }
1198
- const timer = setTimeout(() => {
1199
- signal?.removeEventListener('abort', onAbort)
1200
- resolve()
1201
- }, ms)
1202
- signal?.addEventListener('abort', onAbort, { once: true })
1203
- })
1204
- }
1205
-
1206
- const MAX_RETRY_AFTER_MS = 8_000
1207
- const RETRY_BASE_MS = 500
1208
- const RETRY_MAX_DELAY_MS = 4_000
1209
-
1210
- /**
1211
- * Run a single HTTP request with bounded retry for TRANSIENT failures, so a momentary
1212
- * upstream blip (a 5xx, a 429 rate-limit, or a dropped connection) no longer fails an
1213
- * otherwise-complete run on its very last step (opening the PR/MR). Up to 3 attempts
1214
- * (2 retries) with exponential backoff + jitter (honoring a `Retry-After` on a 429),
1215
- * every wait abort-aware so the inactivity/max-duration watchdog still cancels promptly.
1216
- *
1217
- * ONLY transient failures retry: a `>=500`/`429` response, or a network-level fetch
1218
- * rejection. A 4xx (incl. the 422/409 "already exists" the callers treat as success) is
1219
- * returned to the caller unretried, and a caller abort is rethrown at once. The response
1220
- * body is never read here, so the caller's existing status handling is unchanged.
1221
- */
1222
- async function withApiRetry(
1223
- fn: () => Promise<Response>,
1224
- opts: { signal?: AbortSignal; attempts?: number } = {},
1225
- ): Promise<Response> {
1226
- const maxAttempts = opts.attempts ?? 3
1227
- let lastError: unknown
1228
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1229
- if (opts.signal?.aborted) throw abortError(opts.signal)
1230
- let res: Response | undefined
1231
- try {
1232
- res = await fn()
1233
- } catch (err) {
1234
- // A caller/watchdog abort is terminal; a network error is transient → retry.
1235
- if (isAbortError(err) || opts.signal?.aborted) throw err
1236
- lastError = err
1237
- }
1238
- if (res) {
1239
- const transient = res.status >= 500 || res.status === 429
1240
- if (!transient || attempt >= maxAttempts) return res
1241
- const after = retryAfterMs(res)
1242
- // Discard the unread body before retrying so the connection can be reused.
1243
- await res.body?.cancel().catch(() => {})
1244
- await abortableDelay(after ?? backoffMs(attempt), opts.signal)
1245
- continue
1246
- }
1247
- if (attempt >= maxAttempts) break
1248
- await abortableDelay(backoffMs(attempt), opts.signal)
1249
- }
1250
- // Exhausted on a network-level rejection (no HTTP response): an upstream API failure.
1251
- const message =
1252
- lastError instanceof Error ? lastError.message : 'API request failed after retries'
1253
- throw new HarnessFailure('api', redactSecrets(message))
1254
- }
1255
-
1256
- /** Exponential backoff (base 500ms, capped 4s) with up to 25% positive jitter. */
1257
- function backoffMs(attempt: number): number {
1258
- const base = Math.min(RETRY_MAX_DELAY_MS, RETRY_BASE_MS * 2 ** (attempt - 1))
1259
- return base + Math.floor(base * 0.25 * Math.random())
1260
- }
1261
-
1262
- /**
1263
- * Open a PR (GitHub) or merge request (GitLab) for the pushed branch; returns its web URL.
1264
- * The provider is chosen from the EXPLICIT `opts.provider` when the dispatcher set it,
1265
- * falling back to host inference from the clone URL only when it didn't — so a self-managed
1266
- * GitLab whose host isn't named `gitlab.*` still opens an MR instead of being misrouted to
1267
- * GitHub's API. The GitHub path is unchanged.
1268
- */
1269
- export async function openPullRequest(opts: OpenPullRequestOptions): Promise<string | null> {
1270
- const provider = opts.provider ?? (opts.cloneUrl ? inferVcsProvider(opts.cloneUrl) : 'github')
1271
- if (provider === 'gitlab') {
1272
- if (!opts.cloneUrl) {
1273
- throw new Error('Cannot open a GitLab merge request without the repo clone URL')
1274
- }
1275
- return openGitLabMergeRequest({ ...opts, cloneUrl: opts.cloneUrl })
1276
- }
1277
- const apiBase = opts.apiBase ?? 'https://api.github.com'
1278
- const path = `${encodeURIComponent(opts.owner)}/${encodeURIComponent(opts.name)}`
1279
- const res = await withApiRetry(
1280
- () =>
1281
- fetch(`${apiBase}/repos/${path}/pulls`, {
1282
- method: 'POST',
1283
- headers: {
1284
- authorization: `Bearer ${opts.ghToken}`,
1285
- accept: 'application/vnd.github+json',
1286
- 'user-agent': 'cat-factory-executor',
1287
- 'x-github-api-version': '2022-11-28',
1288
- 'content-type': 'application/json',
1289
- },
1290
- body: JSON.stringify({
1291
- title: opts.pr.title,
1292
- head: opts.head,
1293
- base: opts.base,
1294
- body: opts.pr.body,
1295
- }),
1296
- // Bound on the watchdog so a hung GitHub call can't stall the job.
1297
- ...(opts.signal ? { signal: opts.signal } : {}),
1298
- }),
1299
- { signal: opts.signal },
1300
- )
1301
- if (!res.ok) {
1302
- const detail = await res.text().catch(() => '')
1303
- // A resumed run pushes to a branch that already has an open PR; GitHub answers
1304
- // 422 "A pull request already exists". That's success for us — return the
1305
- // existing PR's url rather than failing the resumed run.
1306
- if (res.status === 422 && /pull request already exists/i.test(detail)) {
1307
- const existing = await findOpenPullRequestUrl(opts)
1308
- if (existing) return existing
1309
- }
1310
- // The head branch has nothing ahead of base ("No commits between <base> and <head>").
1311
- // That is not an API failure — there is simply nothing to open a PR for (e.g. a resumed
1312
- // branch whose earlier PR was merged with a merge commit, leaving the branch reachable
1313
- // from base). Signal it with null so the caller records a clean no-op instead of failing
1314
- // the run with GitHub's opaque 422.
1315
- if (res.status === 422 && /no commits between/i.test(detail)) return null
1316
- const remedy = describePrOpenFailure(res.status, 'github')
1317
- const base = redactSecrets(`Failed to open PR (HTTP ${res.status}): ${detail.slice(0, 300)}`)
1318
- throw new HarnessFailure('api', remedy ? `${base}\n${remedy}` : base)
1319
- }
1320
- const body = (await res.json()) as { html_url?: string }
1321
- if (!body.html_url) throw new HarnessFailure('api', 'GitHub did not return a PR url')
1322
- return body.html_url
1323
- }
1324
-
1325
- /** GitLab API headers for the PAT (the `PRIVATE-TOKEN` auth GitLab uses). */
1326
- function gitlabHeaders(token: string): Record<string, string> {
1327
- return {
1328
- 'private-token': token,
1329
- accept: 'application/json',
1330
- 'user-agent': 'cat-factory-executor',
1331
- 'content-type': 'application/json',
1332
- }
1333
- }
1334
-
1335
- /**
1336
- * Open a GitLab merge request (the analogue of {@link openPullRequest} for GitLab). The REST
1337
- * base + project path are derived from the clone URL's host, so it works for gitlab.com and a
1338
- * self-managed instance alike. `head`→`source_branch`, `base`→`target_branch`. On a duplicate
1339
- * (a resumed run whose branch already has an open MR — GitLab answers 409) the existing MR's
1340
- * web URL is returned instead of failing the run, mirroring the GitHub 422 handling.
1341
- */
1342
- async function openGitLabMergeRequest(
1343
- opts: OpenPullRequestOptions & { cloneUrl: string },
1344
- ): Promise<string> {
1345
- const apiBase = gitlabApiBaseFromCloneUrl(opts.cloneUrl)
1346
- const project = gitlabProjectPath(opts.cloneUrl)
1347
- const res = await withApiRetry(
1348
- () =>
1349
- fetch(`${apiBase}/projects/${project}/merge_requests`, {
1350
- method: 'POST',
1351
- headers: gitlabHeaders(opts.ghToken),
1352
- body: JSON.stringify({
1353
- source_branch: opts.head,
1354
- target_branch: opts.base,
1355
- title: opts.pr.title,
1356
- description: opts.pr.body,
1357
- }),
1358
- ...(opts.signal ? { signal: opts.signal } : {}),
1359
- }),
1360
- { signal: opts.signal },
1361
- )
1362
- if (!res.ok) {
1363
- const detail = await res.text().catch(() => '')
1364
- // GitLab returns 409 (sometimes 400) when an open MR already exists for this source
1365
- // branch; that is success for a resumed run — return the existing MR's url.
1366
- if (
1367
- (res.status === 409 || res.status === 400) &&
1368
- /already exists|open merge request/i.test(detail)
1369
- ) {
1370
- const existing = await findOpenMergeRequestUrl(apiBase, project, opts)
1371
- if (existing) return existing
1372
- }
1373
- const remedy = describePrOpenFailure(res.status, 'gitlab')
1374
- const base = redactSecrets(
1375
- `Failed to open merge request (HTTP ${res.status}): ${detail.slice(0, 300)}`,
1376
- )
1377
- throw new HarnessFailure('api', remedy ? `${base}\n${remedy}` : base)
1378
- }
1379
- const body = (await res.json()) as { web_url?: string }
1380
- if (!body.web_url) throw new HarnessFailure('api', 'GitLab did not return a merge request url')
1381
- return body.web_url
1382
- }
1383
-
1384
- /** Find the open GitLab MR for `opts.head`→`opts.base`, returning its web_url or undefined. */
1385
- async function findOpenMergeRequestUrl(
1386
- apiBase: string,
1387
- project: string,
1388
- opts: { head: string; base: string; ghToken: string; signal?: AbortSignal },
1389
- ): Promise<string | undefined> {
1390
- // Filter by BOTH branches: a source branch can have open MRs to several targets, so the
1391
- // source alone could match an MR against a different base than the one we just tried to open.
1392
- const query = new URLSearchParams({
1393
- source_branch: opts.head,
1394
- target_branch: opts.base,
1395
- state: 'opened',
1396
- })
1397
- const res = await fetch(`${apiBase}/projects/${project}/merge_requests?${query}`, {
1398
- headers: gitlabHeaders(opts.ghToken),
1399
- ...(opts.signal ? { signal: opts.signal } : {}),
1400
- })
1401
- if (!res.ok) return undefined
1402
- const list = (await res.json().catch(() => [])) as Array<{ web_url?: string }>
1403
- return Array.isArray(list) && list[0]?.web_url ? list[0].web_url : undefined
1404
- }
1405
-
1406
- /** Find the open PR for `opts.head` on `opts.base`, returning its html_url or undefined. */
1407
- async function findOpenPullRequestUrl(opts: {
1408
- owner: string
1409
- name: string
1410
- ghToken: string
1411
- head: string
1412
- base: string
1413
- apiBase?: string
1414
- signal?: AbortSignal
1415
- }): Promise<string | undefined> {
1416
- const apiBase = opts.apiBase ?? 'https://api.github.com'
1417
- // Encode the ref-derived query params: a branch/owner containing `&` or `#` would
1418
- // otherwise split the query string or inject an unintended parameter.
1419
- const query = new URLSearchParams({
1420
- head: `${opts.owner}:${opts.head}`,
1421
- base: opts.base,
1422
- state: 'open',
1423
- })
1424
- const path = `${encodeURIComponent(opts.owner)}/${encodeURIComponent(opts.name)}`
1425
- const res = await fetch(`${apiBase}/repos/${path}/pulls?${query}`, {
1426
- headers: {
1427
- authorization: `Bearer ${opts.ghToken}`,
1428
- accept: 'application/vnd.github+json',
1429
- 'user-agent': 'cat-factory-executor',
1430
- 'x-github-api-version': '2022-11-28',
1431
- },
1432
- ...(opts.signal ? { signal: opts.signal } : {}),
1433
- })
1434
- if (!res.ok) return undefined
1435
- const list = (await res.json().catch(() => [])) as Array<{ html_url?: string }>
1436
- return Array.isArray(list) && list[0]?.html_url ? list[0].html_url : undefined
1437
- }