@cat-factory/executor-harness 1.58.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.
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:
@@ -648,6 +603,46 @@ export async function branchAheadOfBase(
648
603
  }
649
604
  }
650
605
 
606
+ /**
607
+ * The files `commitish` changes relative to its merge base with the PR base branch — i.e.
608
+ * everything the work branch has added on top of base, `git diff --name-only <base>...<commitish>`.
609
+ *
610
+ * The BUGFIX REPRODUCTION PROOF uses this to answer the one question that decides whether a GREEN
611
+ * pre-fix tree means anything: does that tree ALREADY carry non-test work committed on this
612
+ * branch? A resumed run's `baseSha` is whatever the branch tip was when this pass started, which
613
+ * in the designed flow is the reproduction step's test commit — but after an eviction it is this
614
+ * same coder step's own interrupted work, fix included. Reporting "the check passed before your
615
+ * change, so it does not demonstrate the defect" in that case is simply false.
616
+ *
617
+ * `undefined` means "could not determine" (a shallow clone with no reachable merge base, a fetch
618
+ * failure, an unknown ref), never an empty list: the caller must degrade to its prior behaviour
619
+ * rather than read a failed probe as "the tree is clean".
620
+ *
621
+ * NUL-delimited so a path containing a newline (legal in git) cannot split into two entries.
622
+ */
623
+ export async function changedFilesSinceBase(
624
+ dir: string,
625
+ baseBranch: string,
626
+ ghToken: string,
627
+ commitish: string,
628
+ signal?: AbortSignal,
629
+ ): Promise<string[] | undefined> {
630
+ try {
631
+ await git(['fetch', 'origin', `+refs/heads/${baseBranch}:refs/cat-factory/base`], {
632
+ cwd: dir,
633
+ signal,
634
+ env: await authEnv(ghToken),
635
+ })
636
+ const out = await git(['diff', '--name-only', '-z', `refs/cat-factory/base...${commitish}`], {
637
+ cwd: dir,
638
+ signal,
639
+ })
640
+ return out.split('\0').filter((p) => p !== '')
641
+ } catch {
642
+ return undefined
643
+ }
644
+ }
645
+
651
646
  /**
652
647
  * Whether the checked-out branch has a real, examinable diff against
653
648
  * `origin/<baseBranch>` — i.e. the base branch's remote-tracking ref exists (so the
@@ -708,6 +703,99 @@ export async function headCommit(dir: string, signal?: AbortSignal): Promise<str
708
703
  return (await git(['rev-parse', 'HEAD'], { cwd: dir, signal })).trim()
709
704
  }
710
705
 
706
+ /**
707
+ * Add a DETACHED worktree of `commitish` at `worktreePath`, sharing `dir`'s object database.
708
+ *
709
+ * The bugfix reproduction proof runs the declared check against two trees of the SAME clone (the
710
+ * pre-fix tree and the final tree), so a worktree is the only mechanism that gets both without a
711
+ * second clone, a second fetch, or disturbing the agent's own checkout — which must stay exactly
712
+ * as the agent left it, since the push and the PR come off it.
713
+ *
714
+ * `--detach` (rather than a branch) is deliberate: a worktree that claimed a branch would collide
715
+ * with the work branch checked out in `dir`, and nothing here ever commits.
716
+ *
717
+ * `worktreePath` is expected to live OUTSIDE the checkout (a per-job temp root), so the worktree's
718
+ * `.git` pointer file can never be swept into the agent's commit by a broad `git add -A`.
719
+ */
720
+ export async function addWorktree(
721
+ dir: string,
722
+ worktreePath: string,
723
+ commitish: string,
724
+ signal?: AbortSignal,
725
+ ): Promise<void> {
726
+ await git(['worktree', 'add', '--detach', worktreePath, commitish], { cwd: dir, signal })
727
+ }
728
+
729
+ /**
730
+ * Remove a worktree previously added by {@link addWorktree} and prune the stale administrative
731
+ * entry, never throwing: teardown is bookkeeping, and a run whose PROOF succeeded must not fail
732
+ * because a temp directory could not be cleaned up. The caller still deletes the temp root, so a
733
+ * failure here leaks only a `.git/worktrees/<name>` record inside a container that is about to be
734
+ * destroyed anyway.
735
+ */
736
+ export async function removeWorktree(
737
+ dir: string,
738
+ worktreePath: string,
739
+ signal?: AbortSignal,
740
+ ): Promise<void> {
741
+ try {
742
+ await git(['worktree', 'remove', '--force', worktreePath], { cwd: dir, signal })
743
+ } catch {
744
+ // Fall through to the prune, which cleans up the record even when the directory is gone.
745
+ }
746
+ try {
747
+ await git(['worktree', 'prune'], { cwd: dir, signal })
748
+ } catch {
749
+ // Best-effort by design (see the doc comment).
750
+ }
751
+ }
752
+
753
+ /**
754
+ * Which of `paths` actually exist in `commitish`'s tree. Used by the reproduction proof to tell a
755
+ * DECLARED test file that was committed from one that only ever existed as an untracked working-
756
+ * tree file: the proof runs against committed trees, so an unadded test is invisible to it — and
757
+ * equally invisible to the push, which is the point worth telling the agent about rather than
758
+ * reporting a verdict computed without the reproduction in it.
759
+ *
760
+ * Returns the input order/spelling of the paths that matched, so the caller can diff against its
761
+ * declared list to name the missing ones verbatim.
762
+ */
763
+ export async function pathsPresentAtCommit(
764
+ dir: string,
765
+ commitish: string,
766
+ paths: readonly string[],
767
+ signal?: AbortSignal,
768
+ ): Promise<string[]> {
769
+ if (paths.length === 0) return []
770
+ const out = await git(['ls-tree', '-r', '--name-only', '-z', commitish, '--', ...paths], {
771
+ cwd: dir,
772
+ signal,
773
+ })
774
+ // NUL-delimited so a path containing a newline (legal in git) can't split into two entries.
775
+ const present = new Set(out.split('\0').filter((p) => p !== ''))
776
+ return paths.filter((p) => present.has(p))
777
+ }
778
+
779
+ /**
780
+ * Check `paths` out of `commitish` into `dir`'s working tree (and index), leaving every other file
781
+ * untouched.
782
+ *
783
+ * This is how the reproduction's declared TEST files are placed onto the pre-fix worktree, and the
784
+ * narrowness is the whole safety property: a whole-tree checkout would drag the FIX across too and
785
+ * green the base, manufacturing a "the test does not capture the defect" verdict out of a
786
+ * perfectly good reproduction. Only the paths the caller has already sanitized are passed, and
787
+ * `--` stops any of them being read as a revision.
788
+ */
789
+ export async function checkoutPathsFrom(
790
+ dir: string,
791
+ commitish: string,
792
+ paths: readonly string[],
793
+ signal?: AbortSignal,
794
+ ): Promise<void> {
795
+ if (paths.length === 0) return
796
+ await git(['checkout', commitish, '--', ...paths], { cwd: dir, signal })
797
+ }
798
+
711
799
  /** Stage everything and commit; returns false when there was nothing to commit. */
712
800
  export async function commitAll(
713
801
  dir: string,
@@ -963,342 +1051,3 @@ export async function reinitAndPush(opts: {
963
1051
  env: await authEnv(opts.ghToken),
964
1052
  })
965
1053
  }
966
-
967
- export interface OpenPullRequestOptions {
968
- owner: string
969
- name: string
970
- ghToken: string
971
- head: string
972
- base: string
973
- pr: PrSpec
974
- apiBase?: string
975
- /**
976
- * The repo's clone URL. Used (when {@link provider} is absent) to detect the provider and,
977
- * for GitLab, to derive the REST base + project path from its host — so the harness opens a
978
- * GitLab **merge request** rather than POSTing to GitHub's pulls API. Absent ⇒ GitHub.
979
- */
980
- cloneUrl?: string
981
- /**
982
- * The VCS provider, when the dispatcher knows it (the server derives it from the configured
983
- * source-control backend and sets `repo.provider`). AUTHORITATIVE — it overrides host
984
- * inference — so a self-managed GitLab on an arbitrarily-named host (e.g. `git.acme.com`,
985
- * which {@link inferVcsProvider} can't recognise) still opens a merge request instead of
986
- * being misrouted to GitHub's API. Absent ⇒ inferred from {@link cloneUrl}'s host.
987
- */
988
- provider?: 'github' | 'gitlab'
989
- signal?: AbortSignal
990
- }
991
-
992
- /**
993
- * The VCS host a clone URL points at. The harness is otherwise provider-agnostic (its git
994
- * auth is a host-neutral GIT_ASKPASS credential), but the "open the PR/MR" REST call is not:
995
- * GitHub and GitLab have different endpoints, so infer which to call from the host. GitHub is
996
- * the default; a host of `gitlab.com` or one in the `gitlab.*` / `*.gitlab.*` family (covering
997
- * self-managed instances named that way) is treated as GitLab.
998
- */
999
- export function inferVcsProvider(cloneUrl: string): 'github' | 'gitlab' {
1000
- let host = ''
1001
- try {
1002
- host = new URL(cloneUrl).host.toLowerCase()
1003
- } catch {
1004
- return 'github'
1005
- }
1006
- if (host === 'gitlab.com' || host.startsWith('gitlab.') || host.includes('.gitlab.')) {
1007
- return 'gitlab'
1008
- }
1009
- return 'github'
1010
- }
1011
-
1012
- /** The GitLab REST v4 base for a clone URL's host, e.g. `https://gitlab.com/api/v4`. */
1013
- export function gitlabApiBaseFromCloneUrl(cloneUrl: string): string {
1014
- const u = new URL(cloneUrl)
1015
- return `${u.protocol}//${u.host}/api/v4`
1016
- }
1017
-
1018
- /**
1019
- * The URL-encoded GitLab project path from a clone URL — the full namespace path (so subgroups
1020
- * survive), with the trailing `.git` stripped, e.g.
1021
- * `https://gitlab.com/group/sub/proj.git` → `group%2Fsub%2Fproj`.
1022
- */
1023
- export function gitlabProjectPath(cloneUrl: string): string {
1024
- const path = new URL(cloneUrl).pathname.replace(/^\/+/, '').replace(/\.git$/, '')
1025
- return encodeURIComponent(path)
1026
- }
1027
-
1028
- /** The abort reason as an Error (the watchdog aborts with one), or a generic fallback. */
1029
- function abortError(signal: AbortSignal): Error {
1030
- return signal.reason instanceof Error ? signal.reason : new Error('aborted')
1031
- }
1032
-
1033
- /** Whether a thrown fetch error is an AbortError (caller-initiated, never retried). */
1034
- function isAbortError(err: unknown): boolean {
1035
- return err instanceof Error && err.name === 'AbortError'
1036
- }
1037
-
1038
- /**
1039
- * Parse a `Retry-After` header into ms, bounded so it can't stall the job. Accepts BOTH
1040
- * forms the spec allows: integer delay-seconds (`120`) and an HTTP-date (`Wed, 21 Oct 2026
1041
- * 07:28:00 GMT`); the latter is turned into a delay from now. A past/zero/unparseable value
1042
- * yields undefined so the caller falls back to exponential backoff.
1043
- */
1044
- function retryAfterMs(res: Response): number | undefined {
1045
- const raw = res.headers.get('retry-after')
1046
- if (!raw) return undefined
1047
- const secs = Number(raw)
1048
- if (Number.isFinite(secs)) {
1049
- return secs > 0 ? Math.min(secs * 1000, MAX_RETRY_AFTER_MS) : undefined
1050
- }
1051
- const at = Date.parse(raw)
1052
- if (Number.isNaN(at)) return undefined
1053
- const ms = at - Date.now()
1054
- return ms > 0 ? Math.min(ms, MAX_RETRY_AFTER_MS) : undefined
1055
- }
1056
-
1057
- /** Sleep `ms`, rejecting immediately (with the abort reason) if `signal` aborts meanwhile. */
1058
- function abortableDelay(ms: number, signal?: AbortSignal): Promise<void> {
1059
- return new Promise((resolve, reject) => {
1060
- if (signal?.aborted) return reject(abortError(signal))
1061
- const onAbort = (): void => {
1062
- clearTimeout(timer)
1063
- reject(abortError(signal as AbortSignal))
1064
- }
1065
- const timer = setTimeout(() => {
1066
- signal?.removeEventListener('abort', onAbort)
1067
- resolve()
1068
- }, ms)
1069
- signal?.addEventListener('abort', onAbort, { once: true })
1070
- })
1071
- }
1072
-
1073
- const MAX_RETRY_AFTER_MS = 8_000
1074
- const RETRY_BASE_MS = 500
1075
- const RETRY_MAX_DELAY_MS = 4_000
1076
-
1077
- /**
1078
- * Run a single HTTP request with bounded retry for TRANSIENT failures, so a momentary
1079
- * upstream blip (a 5xx, a 429 rate-limit, or a dropped connection) no longer fails an
1080
- * otherwise-complete run on its very last step (opening the PR/MR). Up to 3 attempts
1081
- * (2 retries) with exponential backoff + jitter (honoring a `Retry-After` on a 429),
1082
- * every wait abort-aware so the inactivity/max-duration watchdog still cancels promptly.
1083
- *
1084
- * ONLY transient failures retry: a `>=500`/`429` response, or a network-level fetch
1085
- * rejection. A 4xx (incl. the 422/409 "already exists" the callers treat as success) is
1086
- * returned to the caller unretried, and a caller abort is rethrown at once. The response
1087
- * body is never read here, so the caller's existing status handling is unchanged.
1088
- */
1089
- async function withApiRetry(
1090
- fn: () => Promise<Response>,
1091
- opts: { signal?: AbortSignal; attempts?: number } = {},
1092
- ): Promise<Response> {
1093
- const maxAttempts = opts.attempts ?? 3
1094
- let lastError: unknown
1095
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1096
- if (opts.signal?.aborted) throw abortError(opts.signal)
1097
- let res: Response | undefined
1098
- try {
1099
- res = await fn()
1100
- } catch (err) {
1101
- // A caller/watchdog abort is terminal; a network error is transient → retry.
1102
- if (isAbortError(err) || opts.signal?.aborted) throw err
1103
- lastError = err
1104
- }
1105
- if (res) {
1106
- const transient = res.status >= 500 || res.status === 429
1107
- if (!transient || attempt >= maxAttempts) return res
1108
- const after = retryAfterMs(res)
1109
- // Discard the unread body before retrying so the connection can be reused.
1110
- await res.body?.cancel().catch(() => {})
1111
- await abortableDelay(after ?? backoffMs(attempt), opts.signal)
1112
- continue
1113
- }
1114
- if (attempt >= maxAttempts) break
1115
- await abortableDelay(backoffMs(attempt), opts.signal)
1116
- }
1117
- // Exhausted on a network-level rejection (no HTTP response): an upstream API failure.
1118
- const message =
1119
- lastError instanceof Error ? lastError.message : 'API request failed after retries'
1120
- throw new HarnessFailure('api', redactSecrets(message))
1121
- }
1122
-
1123
- /** Exponential backoff (base 500ms, capped 4s) with up to 25% positive jitter. */
1124
- function backoffMs(attempt: number): number {
1125
- const base = Math.min(RETRY_MAX_DELAY_MS, RETRY_BASE_MS * 2 ** (attempt - 1))
1126
- return base + Math.floor(base * 0.25 * Math.random())
1127
- }
1128
-
1129
- /**
1130
- * Open a PR (GitHub) or merge request (GitLab) for the pushed branch; returns its web URL.
1131
- * The provider is chosen from the EXPLICIT `opts.provider` when the dispatcher set it,
1132
- * falling back to host inference from the clone URL only when it didn't — so a self-managed
1133
- * GitLab whose host isn't named `gitlab.*` still opens an MR instead of being misrouted to
1134
- * GitHub's API. The GitHub path is unchanged.
1135
- */
1136
- export async function openPullRequest(opts: OpenPullRequestOptions): Promise<string | null> {
1137
- const provider = opts.provider ?? (opts.cloneUrl ? inferVcsProvider(opts.cloneUrl) : 'github')
1138
- if (provider === 'gitlab') {
1139
- if (!opts.cloneUrl) {
1140
- throw new Error('Cannot open a GitLab merge request without the repo clone URL')
1141
- }
1142
- return openGitLabMergeRequest({ ...opts, cloneUrl: opts.cloneUrl })
1143
- }
1144
- const apiBase = opts.apiBase ?? 'https://api.github.com'
1145
- const path = `${encodeURIComponent(opts.owner)}/${encodeURIComponent(opts.name)}`
1146
- const res = await withApiRetry(
1147
- () =>
1148
- fetch(`${apiBase}/repos/${path}/pulls`, {
1149
- method: 'POST',
1150
- headers: {
1151
- authorization: `Bearer ${opts.ghToken}`,
1152
- accept: 'application/vnd.github+json',
1153
- 'user-agent': 'cat-factory-executor',
1154
- 'x-github-api-version': '2022-11-28',
1155
- 'content-type': 'application/json',
1156
- },
1157
- body: JSON.stringify({
1158
- title: opts.pr.title,
1159
- head: opts.head,
1160
- base: opts.base,
1161
- body: opts.pr.body,
1162
- }),
1163
- // Bound on the watchdog so a hung GitHub call can't stall the job.
1164
- ...(opts.signal ? { signal: opts.signal } : {}),
1165
- }),
1166
- { signal: opts.signal },
1167
- )
1168
- if (!res.ok) {
1169
- const detail = await res.text().catch(() => '')
1170
- // A resumed run pushes to a branch that already has an open PR; GitHub answers
1171
- // 422 "A pull request already exists". That's success for us — return the
1172
- // existing PR's url rather than failing the resumed run.
1173
- if (res.status === 422 && /pull request already exists/i.test(detail)) {
1174
- const existing = await findOpenPullRequestUrl(opts)
1175
- if (existing) return existing
1176
- }
1177
- // The head branch has nothing ahead of base ("No commits between <base> and <head>").
1178
- // That is not an API failure — there is simply nothing to open a PR for (e.g. a resumed
1179
- // branch whose earlier PR was merged with a merge commit, leaving the branch reachable
1180
- // from base). Signal it with null so the caller records a clean no-op instead of failing
1181
- // the run with GitHub's opaque 422.
1182
- if (res.status === 422 && /no commits between/i.test(detail)) return null
1183
- const remedy = describePrOpenFailure(res.status, 'github')
1184
- const base = redactSecrets(`Failed to open PR (HTTP ${res.status}): ${detail.slice(0, 300)}`)
1185
- throw new HarnessFailure('api', remedy ? `${base}\n${remedy}` : base)
1186
- }
1187
- const body = (await res.json()) as { html_url?: string }
1188
- if (!body.html_url) throw new HarnessFailure('api', 'GitHub did not return a PR url')
1189
- return body.html_url
1190
- }
1191
-
1192
- /** GitLab API headers for the PAT (the `PRIVATE-TOKEN` auth GitLab uses). */
1193
- function gitlabHeaders(token: string): Record<string, string> {
1194
- return {
1195
- 'private-token': token,
1196
- accept: 'application/json',
1197
- 'user-agent': 'cat-factory-executor',
1198
- 'content-type': 'application/json',
1199
- }
1200
- }
1201
-
1202
- /**
1203
- * Open a GitLab merge request (the analogue of {@link openPullRequest} for GitLab). The REST
1204
- * base + project path are derived from the clone URL's host, so it works for gitlab.com and a
1205
- * self-managed instance alike. `head`→`source_branch`, `base`→`target_branch`. On a duplicate
1206
- * (a resumed run whose branch already has an open MR — GitLab answers 409) the existing MR's
1207
- * web URL is returned instead of failing the run, mirroring the GitHub 422 handling.
1208
- */
1209
- async function openGitLabMergeRequest(
1210
- opts: OpenPullRequestOptions & { cloneUrl: string },
1211
- ): Promise<string> {
1212
- const apiBase = gitlabApiBaseFromCloneUrl(opts.cloneUrl)
1213
- const project = gitlabProjectPath(opts.cloneUrl)
1214
- const res = await withApiRetry(
1215
- () =>
1216
- fetch(`${apiBase}/projects/${project}/merge_requests`, {
1217
- method: 'POST',
1218
- headers: gitlabHeaders(opts.ghToken),
1219
- body: JSON.stringify({
1220
- source_branch: opts.head,
1221
- target_branch: opts.base,
1222
- title: opts.pr.title,
1223
- description: opts.pr.body,
1224
- }),
1225
- ...(opts.signal ? { signal: opts.signal } : {}),
1226
- }),
1227
- { signal: opts.signal },
1228
- )
1229
- if (!res.ok) {
1230
- const detail = await res.text().catch(() => '')
1231
- // GitLab returns 409 (sometimes 400) when an open MR already exists for this source
1232
- // branch; that is success for a resumed run — return the existing MR's url.
1233
- if (
1234
- (res.status === 409 || res.status === 400) &&
1235
- /already exists|open merge request/i.test(detail)
1236
- ) {
1237
- const existing = await findOpenMergeRequestUrl(apiBase, project, opts)
1238
- if (existing) return existing
1239
- }
1240
- const remedy = describePrOpenFailure(res.status, 'gitlab')
1241
- const base = redactSecrets(
1242
- `Failed to open merge request (HTTP ${res.status}): ${detail.slice(0, 300)}`,
1243
- )
1244
- throw new HarnessFailure('api', remedy ? `${base}\n${remedy}` : base)
1245
- }
1246
- const body = (await res.json()) as { web_url?: string }
1247
- if (!body.web_url) throw new HarnessFailure('api', 'GitLab did not return a merge request url')
1248
- return body.web_url
1249
- }
1250
-
1251
- /** Find the open GitLab MR for `opts.head`→`opts.base`, returning its web_url or undefined. */
1252
- async function findOpenMergeRequestUrl(
1253
- apiBase: string,
1254
- project: string,
1255
- opts: { head: string; base: string; ghToken: string; signal?: AbortSignal },
1256
- ): Promise<string | undefined> {
1257
- // Filter by BOTH branches: a source branch can have open MRs to several targets, so the
1258
- // source alone could match an MR against a different base than the one we just tried to open.
1259
- const query = new URLSearchParams({
1260
- source_branch: opts.head,
1261
- target_branch: opts.base,
1262
- state: 'opened',
1263
- })
1264
- const res = await fetch(`${apiBase}/projects/${project}/merge_requests?${query}`, {
1265
- headers: gitlabHeaders(opts.ghToken),
1266
- ...(opts.signal ? { signal: opts.signal } : {}),
1267
- })
1268
- if (!res.ok) return undefined
1269
- const list = (await res.json().catch(() => [])) as Array<{ web_url?: string }>
1270
- return Array.isArray(list) && list[0]?.web_url ? list[0].web_url : undefined
1271
- }
1272
-
1273
- /** Find the open PR for `opts.head` on `opts.base`, returning its html_url or undefined. */
1274
- async function findOpenPullRequestUrl(opts: {
1275
- owner: string
1276
- name: string
1277
- ghToken: string
1278
- head: string
1279
- base: string
1280
- apiBase?: string
1281
- signal?: AbortSignal
1282
- }): Promise<string | undefined> {
1283
- const apiBase = opts.apiBase ?? 'https://api.github.com'
1284
- // Encode the ref-derived query params: a branch/owner containing `&` or `#` would
1285
- // otherwise split the query string or inject an unintended parameter.
1286
- const query = new URLSearchParams({
1287
- head: `${opts.owner}:${opts.head}`,
1288
- base: opts.base,
1289
- state: 'open',
1290
- })
1291
- const path = `${encodeURIComponent(opts.owner)}/${encodeURIComponent(opts.name)}`
1292
- const res = await fetch(`${apiBase}/repos/${path}/pulls?${query}`, {
1293
- headers: {
1294
- authorization: `Bearer ${opts.ghToken}`,
1295
- accept: 'application/vnd.github+json',
1296
- 'user-agent': 'cat-factory-executor',
1297
- 'x-github-api-version': '2022-11-28',
1298
- },
1299
- ...(opts.signal ? { signal: opts.signal } : {}),
1300
- })
1301
- if (!res.ok) return undefined
1302
- const list = (await res.json().catch(() => [])) as Array<{ html_url?: string }>
1303
- return Array.isArray(list) && list[0]?.html_url ? list[0].html_url : undefined
1304
- }