From 79c987e107aa772114177e3cb1be4c40aa04f36c Mon Sep 17 00:00:00 2001 From: SJ Lee Date: Tue, 15 Sep 2026 21:59:27 +0000 Subject: [PATCH] feat(relay): GitLab change-request adapter seam M1 Closes #1210 --- .../forge-change-request-adapters.md | 114 ++++ .../relay-dispatch/scripts/forge-adapters.js | 333 +++++++++++ .../scripts/forge-adapters.test.js | 549 ++++++++++++++++++ 3 files changed, 996 insertions(+) create mode 100644 skills/relay-dispatch/references/forge-change-request-adapters.md create mode 100644 skills/relay-dispatch/scripts/forge-adapters.js create mode 100644 tests/relay-dispatch/scripts/forge-adapters.test.js diff --git a/skills/relay-dispatch/references/forge-change-request-adapters.md b/skills/relay-dispatch/references/forge-change-request-adapters.md new file mode 100644 index 00000000..7c8dd00f --- /dev/null +++ b/skills/relay-dispatch/references/forge-change-request-adapters.md @@ -0,0 +1,114 @@ +# Forge change-request adapters (#1210) + +`scripts/forge-adapters.js` holds the native forge seam: the smallest provider +surface demonstrated by both the retained GitHub route and the native GitLab +change-request adapter. The seam observes the forge-owned Change Request only. +Publication remains canonical-recovery-owned, review remains relay-review-owned, +and authorization and Landing remain explicit `relay-merge` steps; no adapter +adds a lifecycle writer, and no GitLab-specific state enters core run lifecycle +records. The core Git `ReviewSubject` is unchanged: Git identity stays the +content authority and a forge supplies transport plus change-request identity. + +The production source gate is unchanged: GitLab remotes still classify as +unsupported delivery. This module is the extracted observation seam, not a +second Relay route. + +## Documented consumer + +The concrete consumer the seam is extracted from is the retained GitHub Change +Request observer in `recover.js` (`observeGithub`, `selectGithubPr`, +`exactPublishedPr`). Review and Landing already bind through that observer's +exact live `pr_head_sha`. The GitLab adapter is the second native +implementation of the same operator workflow. + +Shared workflow (publication, change request, review, authorization, and +landing stay distinct): + +1. Canonical recovery publishes the exact Git revision to a remote ref. +2. The adapter observes the forge-owned PR/MR for + `{ project, source branch, base branch }` and returns provider-specific + identity plus the exact live head SHA. +3. Review binds only when `exactPublishedChangeRequest` is true for that head. +4. Landing uses the same exact-head decision; authorization remains explicit + `relay-merge`. + +### GitHub consumer (extraction source) + +| Trigger | Evidence | +| --- | --- | +| Repository / workflow | Identity-matching `github.com` origin (`owner/repo`). Operator path is recover → review → explicit merge. | +| Credentials | Owned by recover (`GH_TOKEN` / `GITHUB_TOKEN` or `gh auth login`). The seam issues the same `gh pr list` observation (`RELAY_GH_BIN`). | +| Open / update | Push to the head ref. | +| Approve / merge | GitHub repository permissions plus the explicit `relay-merge` actor (`git user.name`; recover does not call GitHub REST `/user`). | +| Fork vs same-project | `headRepository.nameWithOwner` must equal the canonical repo. Cross-repository PRs never identity-match. | +| Crash / retry | `gh` failure leaves observation `available: false`. Recover records retryable `github_unavailable` and keeps the string `error`. The seam types the same failure as `GITHUB_OBSERVATION_FAILED` (`retryable: true`) without rewriting recover. | + +Equivalent GitHub facts stay byte/decision compatible: the seam reuses +`selectGithubPr` from its definition site and mirrors `exactPublishedPr`. + +### GitLab consumer (second implementation) + +| Trigger | Evidence | +| --- | --- | +| Repository / workflow | `gitlab.com` project `namespace/project` (nested groups allowed). Self-managed hosts through `RELAY_GITLAB_HOSTS`. Same observe → exact-head review/landing bind as GitHub. | +| Credentials | `GITLAB_TOKEN` or `GITLAB_API_TOKEN` (personal access token). Fallback `glab auth token` (`RELAY_GLAB_BIN`). Observation is `GET` with `PRIVATE-TOKEN`. Missing credentials fail closed as `GITLAB_AUTH_REQUIRED` before any request. | +| Permission boundaries | Observe requires `read_api` (or `api`) on the target project: 401 `GITLAB_AUTH_INVALID`, 403 `GITLAB_PERMISSION_DENIED`. Opening or updating an MR needs Developer (or above) with push to the source branch; approval follows project rules; merge needs project merge permission plus explicit `relay-merge`. This adapter does not open, approve, or merge. | +| Fork vs same-project | Identity-match only when `source_project_id === target_project_id`. Fork MRs increment `fork_mr_count` and are never selected implicitly. | +| Identity | Project-scoped `iid` (`mr_number`), `head_ref` / `base_ref`, `head_project_id` / `target_project_id`. Live head is `diff_refs.head_sha`, falling back to MR `sha`. | +| Crash / retry | `GET /merge_requests` is side-effect free. 429 / 5xx / network → `GITLAB_OUTAGE` (`retryable: true`). Re-observation is a pure read. | + +## Seam contract + +Every adapter implements exactly these members, with no unused extension +points: + +| Member | Purpose | +| --- | --- | +| `provider` | `"github"` or `"gitlab"`. | +| `classifyRemote(remoteUrl)` | `{ provider, project, ... }` for a supported forge remote, otherwise `null`. | +| `observeChangeRequest(input)` | Fresh provider-specific change-request facts for `{ project, branch, baseBranch, localHeadSha?, recordedCrNumber? }`. | +| `exactPublishedChangeRequest(observation, { branch }, headSha)` | True only for a unique identity-matched change request whose exact live head equals `headSha`. Review and Landing bind through this decision. | + +`classifyForgeRemote(remoteUrl)` and `getForgeAdapter(remoteUrl)` select the +adapter for a remote. GitHub remotes are classified with the exact decision of +the retained production route (`owner/repo` shorthand included); GitLab +classification never claims that shorthand. + +## GitHub adapter + +`observeChangeRequest` runs the same `gh pr list` observation and reuses +`selectGithubPr` from its definition site in `recover.js`, so equivalent facts +produce byte/decision-compatible GitHub observations (`pr_number`, `pr_state`, +`pr_head_sha`, `pr_base_sha`, `head_repo`, counts). Failures return +`available: false` with the typed, retryable `GITHUB_OBSERVATION_FAILED` error. + +## GitLab adapter + +Native GitLab merge-request observation through the GitLab REST API +(`GET /projects/:id/merge_requests?source_branch=...&state=all`): + +- **Credentials**: `GITLAB_TOKEN` or `GITLAB_API_TOKEN`; otherwise + `glab auth token` (`RELAY_GLAB_BIN` override). Missing credentials fail + closed as `GITLAB_AUTH_REQUIRED` before any request. +- **Hosts**: `gitlab.com` by default; self-managed instances through + `RELAY_GITLAB_HOSTS` (comma-separated). `GITLAB_API_URL` overrides the + derived `https:///api/v4` base. +- **Identity mapping**: merge-request identity maps to provider-specific facts + `mr_number` (project-scoped iid), `mr_state`, `head_ref`/`base_ref`, + `head_project_id`/`target_project_id`, `merge_sha`, and `url`. +- **Exact live head**: `mr_head_sha` is `diff_refs.head_sha` (the head GitLab + binds the live diff version to), falling back to the MR head `sha`. A + lagging or diverging head fails `exactPublishedChangeRequest`, so review and + Landing never bind to a stale head. +- **Fork vs same-project**: only same-project MRs + (`source_project_id === target_project_id`) identity-match, mirroring the + GitHub head-repo identity requirement. Fork MRs are counted in + `fork_mr_count` and never selected implicitly. +- **Selection**: the same decision ladder as the GitHub route — identity + matches win, a recorded closed iid is adoptable, exact-head matches beat + looser pools, and only a unique candidate is selected. +- **Typed failures**: `GITLAB_AUTH_INVALID` (401), `GITLAB_PERMISSION_DENIED` + (403), `GITLAB_NOT_FOUND` (404), `GITLAB_REQUEST_REJECTED` (other 4xx), + `GITLAB_OUTAGE` (429/5xx/network, `retryable: true`), and + `GITLAB_RESPONSE_INVALID`. Outages are retry-safe: re-observation is a pure + read with no forge-side effect. diff --git a/skills/relay-dispatch/scripts/forge-adapters.js b/skills/relay-dispatch/scripts/forge-adapters.js new file mode 100644 index 00000000..8cd0c4fd --- /dev/null +++ b/skills/relay-dispatch/scripts/forge-adapters.js @@ -0,0 +1,333 @@ +"use strict"; + +// Native forge change-request seam (#1210). +// +// Concrete consumer: recover.js observeGithub / selectGithubPr / +// exactPublishedPr. That observer is not rewritten, so retained GitHub +// facts and decisions stay byte-compatible. The GitLab adapter is the +// second native implementation of the same observation + exact-head bind. +// +// Seam members (no unused extension points): +// +// provider +// classifyRemote(remoteUrl) -> { provider, project, ... } | null +// observeChangeRequest(input) -> provider-specific observation facts +// exactPublishedChangeRequest(observation, -> boolean binding review/landing +// { branch }, headSha) to the exact live change-request head +// +// Phase separation is preserved: an adapter only observes the forge-owned +// Change Request identity. Publication stays with canonical recovery, review +// stays with relay-review, authorization and Landing stay with explicit +// relay-merge. The core Git ReviewSubject is unchanged; GitLab merge-request +// identity and the exact live head SHA are mapped into provider-specific +// facts (mr_*) and never into core lifecycle records. +// +// See references/forge-change-request-adapters.md for the documented contract. + +const { execFileSync } = require("child_process"); + +function commandFailureLine(error) { + return String(error?.stderr || error?.stdout || error?.message || error).trim().split(/\r?\n/)[0]; +} + +// --------------------------------------------------------------------------- +// GitHub adapter — decision-compatible with the retained production observer +// (recover.js observeGithub / selectGithubPr / exactPublishedPr). The +// selection decision is imported from its definition site so the seam and the +// retained route cannot drift. +// --------------------------------------------------------------------------- + +const GITHUB_PR_JSON_FIELDS = "number,state,url,headRefName,headRefOid,baseRefName,baseRefOid,headRepository,headRepositoryOwner,isCrossRepository,mergedAt,mergeCommit,body"; + +function classifyGithubRemote(remoteUrl) { + const remote = String(remoteUrl || "").trim().replace(/\/$/, ""); + const match = remote.match(/^git@github\.com:([^/]+\/[^/]+?)(?:\.git)?$/i) + || remote.match(/^(?:ssh:\/\/git@|https?:\/\/)github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/i); + if (match) return { provider: "github", project: match[1].replace(/\.git$/i, "").toLowerCase() }; + if (/^[^/:]+\/[^/]+$/.test(remote)) return { provider: "github", project: remote.replace(/\.git$/i, "").toLowerCase() }; + return null; +} + +function observeGithubChangeRequest({ project, repoRoot = null, branch, baseBranch, localHeadSha = null, recordedCrNumber = null }) { + // Deferred require: classification stays lightweight and no module-level + // coupling is added to the retained recovery runtime. + const { execGh } = require("./exec"); + const { selectGithubPr } = require("./recover").__testing; + try { + const rows = JSON.parse(execGh(repoRoot, [ + "pr", "list", "--repo", project, "--head", branch, "--state", "all", "--limit", "100", + "--json", GITHUB_PR_JSON_FIELDS, + ], { timeout: 15_000 })); + const selection = selectGithubPr(rows, { + remote: project, branch, baseBranch, localHeadSha, recordedPrNumber: recordedCrNumber, + }); + const pr = selection.pr; + return { + available: true, + lookup_complete: true, + pr_lookup_complete: true, + matching_pr_count: selection.matchingPrCount, identity_match_count: selection.identityMatchCount, + open_pr_count: selection.openPrCount, merged_pr_count: selection.mergedPrCount, + closed_pr_count: selection.closedPrCount, + repo: project, + pr_number: pr?.number || null, pr_state: pr?.state || null, + head_ref: pr?.headRefName || branch, base_ref: pr?.baseRefName || baseBranch, + head_repo: pr ? selection.headRepo(pr) : project, + pr_head_sha: pr?.headRefOid || null, pr_base_sha: pr?.baseRefOid || null, + merge_sha: pr?.mergeCommit?.oid || null, + url: pr?.url || null, body: pr?.body || null, + }; + } catch (error) { + return { + available: false, lookup_complete: false, pr_lookup_complete: false, matching_pr_count: null, + repo: project, + pr_number: null, pr_state: null, head_ref: branch, base_ref: baseBranch, + pr_head_sha: null, pr_base_sha: null, merge_sha: null, + error: { code: "GITHUB_OBSERVATION_FAILED", message: commandFailureLine(error), retryable: true }, + }; + } +} + +const githubChangeRequestAdapter = { + provider: "github", + classifyRemote: classifyGithubRemote, + observeChangeRequest: observeGithubChangeRequest, + // Mirrors recover.js exactPublishedPr exactly for equivalent facts. + exactPublishedChangeRequest(observation, { branch }, headSha) { + return observation?.available === true + && observation.matching_pr_count === 1 + && Number.isInteger(observation.pr_number) + && observation.head_ref === branch + && observation.pr_head_sha === headSha; + }, +}; + +// --------------------------------------------------------------------------- +// GitLab adapter — native merge-request observation through the GitLab REST +// API. Credentials: GITLAB_TOKEN / GITLAB_API_TOKEN, or `glab auth token` +// (RELAY_GLAB_BIN override). Self-managed hosts: RELAY_GITLAB_HOSTS +// (comma-separated). API base override: GITLAB_API_URL. +// --------------------------------------------------------------------------- + +function gitlabHosts() { + const extra = String(process.env.RELAY_GITLAB_HOSTS || "") + .split(",").map((host) => host.trim().toLowerCase()).filter(Boolean); + return ["gitlab.com", ...extra.filter((host) => host !== "gitlab.com")]; +} + +function escapeHostRegExp(host) { + return host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function classifyGitlabRemote(remoteUrl) { + const remote = String(remoteUrl || "").trim().replace(/\/$/, ""); + for (const host of gitlabHosts()) { + const escaped = escapeHostRegExp(host); + const match = remote.match(new RegExp(`^git@${escaped}:(.+?)(?:\\.git)?$`, "i")) + || remote.match(new RegExp(`^ssh:\/\/git@${escaped}(?::\\d+)?\/(.+?)(?:\\.git)?$`, "i")) + || remote.match(new RegExp(`^https?:\/\/(?:[^/@]+@)?${escaped}\/(.+?)(?:\\.git)?$`, "i")); + if (!match) continue; + const project = match[1].replace(/\.git$/i, "").toLowerCase(); + // A GitLab project path always has at least a namespace and a name. + if (project.split("/").filter(Boolean).length < 2) continue; + return { provider: "gitlab", project, host }; + } + return null; +} + +function resolveGitlabToken() { + const direct = String(process.env.GITLAB_TOKEN || process.env.GITLAB_API_TOKEN || "").trim(); + if (direct) return direct; + try { + const glabBin = process.env.RELAY_GLAB_BIN || "glab"; + const token = String(execFileSync(glabBin, ["auth", "token"], { + encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], timeout: 10_000, + })).trim(); + if (token) return token; + } catch {} + return null; +} + +function gitlabApiBase(host) { + return String(process.env.GITLAB_API_URL || "").trim().replace(/\/+$/, "") || `https://${host}/api/v4`; +} + +async function fetchTransport({ url, headers }) { + const response = await fetch(url, { method: "GET", headers }); + const body = await response.text(); + return { status: response.status, body }; +} + +function gitlabRequestError(status) { + if (status === 401) return { code: "GITLAB_AUTH_INVALID", message: `GitLab rejected the token (HTTP ${status})`, retryable: false }; + if (status === 403) return { code: "GITLAB_PERMISSION_DENIED", message: `GitLab denied access to the project or merge requests (HTTP ${status})`, retryable: false }; + if (status === 404) return { code: "GITLAB_NOT_FOUND", message: `GitLab project or merge-request endpoint not found (HTTP ${status})`, retryable: false }; + if (status === 429 || status >= 500) return { code: "GITLAB_OUTAGE", message: `GitLab API unavailable (HTTP ${status})`, retryable: true }; + return { code: "GITLAB_REQUEST_REJECTED", message: `GitLab API request rejected (HTTP ${status})`, retryable: false }; +} + +function unavailableGitlabObservation({ project, branch, baseBranch }, error) { + return { + available: false, lookup_complete: false, mr_lookup_complete: false, + matching_mr_count: null, identity_match_count: null, + open_mr_count: null, merged_mr_count: null, closed_mr_count: null, fork_mr_count: null, + project, + mr_number: null, mr_state: null, + head_ref: branch, base_ref: baseBranch, + head_project_id: null, target_project_id: null, fork: false, + mr_head_sha: null, mr_base_sha: null, merge_sha: null, url: null, + error, + }; +} + +// The reviewed head GitLab binds diffs to: diff_refs.head_sha is the head of +// the live merge-request diff version; fall back to the MR head sha. A lagging +// or diverging value simply fails exactPublishedChangeRequest, so review and +// landing never bind to a stale head (fail closed, retry-safe). +function gitlabMrHeadSha(row) { + return row?.diff_refs?.head_sha || row?.sha || null; +} + +function sameGitlabProjectMr(row) { + return row?.source_project_id != null && row.source_project_id === row.target_project_id; +} + +// Mirrors the retained selectGithubPr decision ladder for GitLab facts: +// identity matches win, recorded closed MRs are adoptable, exact-head matches +// beat looser pools, and only a unique candidate is ever selected. Fork MRs +// (source_project_id !== target_project_id) never identity-match, matching the +// GitHub head-repo identity requirement. +function selectGitlabMr(rows, { branch, baseBranch, localHeadSha = null, recordedCrNumber = null }) { + const rowsList = Array.isArray(rows) ? rows : []; + const sameHead = (row) => row?.source_branch === branch && sameGitlabProjectMr(row); + const identityMatches = rowsList.filter((row) => sameHead(row) && row.target_branch === baseBranch); + const pool = identityMatches.length ? identityMatches : rowsList.filter(sameHead); + const byState = (state) => pool.filter((row) => row.state === state); + const open = byState("opened"); + const merged = byState("merged"); + const closed = byState("closed"); + const exactHead = (candidates) => localHeadSha ? candidates.filter((row) => gitlabMrHeadSha(row) === localHeadSha) : candidates; + const exactOpen = exactHead(open); + const exactMerged = exactHead(merged); + const recordedClosed = Number.isInteger(recordedCrNumber) ? closed.filter((row) => row.iid === recordedCrNumber) : []; + const reusable = [recordedClosed, exactOpen, exactMerged, open, merged].find((group) => group.length) || []; + return { + mr: reusable.length === 1 ? reusable[0] : null, + matchingMrCount: reusable.length, + identityMatchCount: identityMatches.length, + openMrCount: open.length, + mergedMrCount: merged.length, + closedMrCount: closed.length, + forkMrCount: rowsList.filter((row) => row?.source_branch === branch && !sameGitlabProjectMr(row)).length, + }; +} + +async function observeGitlabChangeRequest({ + project, + host = "gitlab.com", + branch, + baseBranch, + localHeadSha = null, + recordedCrNumber = null, + transport = null, + token = null, +}) { + const resolvedToken = token || resolveGitlabToken(); + if (!resolvedToken) { + return unavailableGitlabObservation({ project, branch, baseBranch }, { + code: "GITLAB_AUTH_REQUIRED", + message: "GitLab merge-request observation requires GITLAB_TOKEN/GITLAB_API_TOKEN or credentials available through `glab auth login`", + retryable: false, + }); + } + const apiBase = gitlabApiBase(host); + const url = `${apiBase}/projects/${encodeURIComponent(project)}/merge_requests` + + `?source_branch=${encodeURIComponent(branch)}&state=all&per_page=100`; + let response; + try { + response = await (transport || fetchTransport)({ + url, + headers: { "private-token": resolvedToken, accept: "application/json" }, + }); + } catch (error) { + return unavailableGitlabObservation({ project, branch, baseBranch }, { + code: "GITLAB_OUTAGE", message: commandFailureLine(error), retryable: true, + }); + } + if (response.status !== 200) { + return unavailableGitlabObservation({ project, branch, baseBranch }, gitlabRequestError(response.status)); + } + let rows; + try { + rows = JSON.parse(response.body); + } catch { + return unavailableGitlabObservation({ project, branch, baseBranch }, { + code: "GITLAB_RESPONSE_INVALID", message: "GitLab API returned a non-JSON merge-request response", retryable: false, + }); + } + if (!Array.isArray(rows)) { + return unavailableGitlabObservation({ project, branch, baseBranch }, { + code: "GITLAB_RESPONSE_INVALID", message: "GitLab API returned a non-list merge-request response", retryable: false, + }); + } + const selection = selectGitlabMr(rows, { branch, baseBranch, localHeadSha, recordedCrNumber }); + const mr = selection.mr; + return { + available: true, + lookup_complete: true, + mr_lookup_complete: true, + matching_mr_count: selection.matchingMrCount, identity_match_count: selection.identityMatchCount, + open_mr_count: selection.openMrCount, merged_mr_count: selection.mergedMrCount, + closed_mr_count: selection.closedMrCount, fork_mr_count: selection.forkMrCount, + project, + mr_number: mr?.iid ?? null, mr_state: mr?.state || null, + head_ref: mr?.source_branch || branch, base_ref: mr?.target_branch || baseBranch, + head_project_id: mr?.source_project_id ?? null, target_project_id: mr?.target_project_id ?? null, + fork: mr ? !sameGitlabProjectMr(mr) : false, + mr_head_sha: gitlabMrHeadSha(mr), + mr_base_sha: mr?.diff_refs?.base_sha || null, + merge_sha: mr?.merge_commit_sha || null, + url: mr?.web_url || null, + }; +} + +const gitlabChangeRequestAdapter = { + provider: "gitlab", + classifyRemote: classifyGitlabRemote, + observeChangeRequest: observeGitlabChangeRequest, + // Same exact-live-head binding decision as the GitHub route: a unique, + // identity-matched change request whose live head equals the expected SHA. + exactPublishedChangeRequest(observation, { branch }, headSha) { + return observation?.available === true + && observation.matching_mr_count === 1 + && Number.isInteger(observation.mr_number) + && observation.head_ref === branch + && observation.mr_head_sha === headSha; + }, +}; + +// --------------------------------------------------------------------------- +// Seam registry +// --------------------------------------------------------------------------- + +const FORGE_ADAPTERS = Object.freeze({ + github: Object.freeze(githubChangeRequestAdapter), + gitlab: Object.freeze(gitlabChangeRequestAdapter), +}); + +function classifyForgeRemote(remoteUrl) { + return classifyGithubRemote(remoteUrl) || classifyGitlabRemote(remoteUrl) || { provider: null }; +} + +function getForgeAdapter(remoteUrl) { + const classification = classifyForgeRemote(remoteUrl); + return classification.provider ? FORGE_ADAPTERS[classification.provider] : null; +} + +module.exports = { + FORGE_ADAPTERS, + classifyForgeRemote, + getForgeAdapter, + githubChangeRequestAdapter: FORGE_ADAPTERS.github, + gitlabChangeRequestAdapter: FORGE_ADAPTERS.gitlab, +}; diff --git a/tests/relay-dispatch/scripts/forge-adapters.test.js b/tests/relay-dispatch/scripts/forge-adapters.test.js new file mode 100644 index 00000000..7d18a140 --- /dev/null +++ b/tests/relay-dispatch/scripts/forge-adapters.test.js @@ -0,0 +1,549 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const { + FORGE_ADAPTERS, + classifyForgeRemote, + getForgeAdapter, + githubChangeRequestAdapter, + gitlabChangeRequestAdapter, +} = require("../../../skills/relay-dispatch/scripts/forge-adapters"); +const recover = require("../../../skills/relay-dispatch/scripts/recover"); + +const RECOVER_SRC = fs.readFileSync( + path.join(__dirname, "../../../skills/relay-dispatch/scripts/recover.js"), + "utf8", +); +const GITHUB_PR_JSON_FIELDS = "number,state,url,headRefName,headRefOid,baseRefName,baseRefOid,headRepository,headRepositoryOwner,isCrossRepository,mergedAt,mergeCommit,body"; + +function retainedExactPublishedPr(github, branch, headSha) { + return github.available === true + && github.matching_pr_count === 1 + && Number.isInteger(github.pr_number) + && github.head_ref === branch + && github.pr_head_sha === headSha; +} + +const HEAD = "a".repeat(40); +const BASE = "b".repeat(40); +const OTHER = "c".repeat(40); + +function tmpDir(prefix) { + return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix))); +} + +function withEnv(overrides, callback) { + const prior = {}; + for (const [key, value] of Object.entries(overrides)) { + prior[key] = process.env[key]; + if (value === null) delete process.env[key]; + else process.env[key] = value; + } + const restore = () => { + for (const [key, value] of Object.entries(prior)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }; + try { + const result = callback(); + if (result && typeof result.then === "function") return Promise.resolve(result).finally(restore); + restore(); + return result; + } catch (error) { + restore(); + throw error; + } +} + +function gitlabMr(overrides = {}) { + return { + iid: 12, + state: "opened", + source_branch: "issue-1210", + target_branch: "main", + source_project_id: 7, + target_project_id: 7, + sha: HEAD, + diff_refs: { head_sha: HEAD, base_sha: BASE }, + merge_commit_sha: null, + web_url: "https://gitlab.com/group/repo/-/merge_requests/12", + ...overrides, + }; +} + +function recordingTransport(responses) { + const calls = []; + return { + calls, + transport: async (request) => { + calls.push(request); + const next = responses.shift(); + if (next instanceof Error) throw next; + return next; + }, + }; +} + +function jsonResponse(status, payload) { + return { status, body: typeof payload === "string" ? payload : JSON.stringify(payload) }; +} + +// --------------------------------------------------------------------------- +// Remote classification +// --------------------------------------------------------------------------- + +test("#1210 classifies GitHub remotes with the retained production decision", () => { + for (const [remote, project] of [ + ["git@github.com:Owner/Repo.git", "owner/repo"], + ["ssh://git@github.com/owner/repo.git", "owner/repo"], + ["https://github.com/owner/repo", "owner/repo"], + ["https://github.com/owner/repo/", "owner/repo"], + ["owner/repo", "owner/repo"], + ]) { + assert.deepEqual(classifyForgeRemote(remote), { provider: "github", project }, remote); + assert.equal(getForgeAdapter(remote).provider, "github", remote); + } +}); + +test("#1210 classifies gitlab.com remotes including nested groups and ssh ports", () => { + for (const [remote, project] of [ + ["git@gitlab.com:group/repo.git", "group/repo"], + ["https://gitlab.com/group/repo", "group/repo"], + ["https://gitlab.com/group/sub/group/repo.git", "group/sub/group/repo"], + ["ssh://git@gitlab.com:2222/group/repo.git", "group/repo"], + ]) { + assert.deepEqual(classifyForgeRemote(remote), { provider: "gitlab", project, host: "gitlab.com" }, remote); + assert.equal(getForgeAdapter(remote).provider, "gitlab", remote); + } +}); + +test("#1210 supports self-managed GitLab hosts only through RELAY_GITLAB_HOSTS", () => { + const remote = "https://git.example.test/group/repo.git"; + assert.deepEqual(classifyForgeRemote(remote), { provider: null }); + assert.equal(getForgeAdapter(remote), null); + withEnv({ RELAY_GITLAB_HOSTS: " git.example.test , other.example.test " }, () => { + assert.deepEqual(classifyForgeRemote(remote), { + provider: "gitlab", project: "group/repo", host: "git.example.test", + }); + }); +}); + +test("#1210 leaves non-forge and local identities unclassified and never claims owner/repo shorthand for GitLab", () => { + assert.deepEqual(classifyForgeRemote("/srv/git/repo"), { provider: null }); + assert.deepEqual(classifyForgeRemote("https://bitbucket.example.test/owner/repo.git"), { provider: null }); + assert.deepEqual(classifyForgeRemote(""), { provider: null }); + // The bare shorthand keeps its retained GitHub decision even with GitLab hosts configured. + withEnv({ RELAY_GITLAB_HOSTS: "gitlab.com" }, () => { + assert.equal(classifyForgeRemote("group/repo").provider, "github"); + }); + // A single-segment GitLab path is not a project identity. + assert.deepEqual(classifyForgeRemote("https://gitlab.com/repo"), { provider: null }); +}); + +test("#1210 the seam registry holds exactly the two demonstrated implementations", () => { + assert.deepEqual(Object.keys(FORGE_ADAPTERS).sort(), ["github", "gitlab"]); + for (const adapter of Object.values(FORGE_ADAPTERS)) { + assert.deepEqual( + Object.keys(adapter).sort(), + ["classifyRemote", "exactPublishedChangeRequest", "observeChangeRequest", "provider"], + "no unused extension points", + ); + } + assert.match( + RECOVER_SRC, + new RegExp(`"--json", "${GITHUB_PR_JSON_FIELDS.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`), + "GitHub observation JSON fields stay byte-identical to recover.js", + ); + assert.match(RECOVER_SRC, /github\.pr_head_sha === headSha/); + assert.match(RECOVER_SRC, /function exactPublishedPr\(github, record, headSha\)/); +}); + +// --------------------------------------------------------------------------- +// GitLab merge-request observation +// --------------------------------------------------------------------------- + +test("#1210 maps GitLab merge-request identity and the exact live head into provider-specific facts", async () => { + const { transport, calls } = recordingTransport([jsonResponse(200, [gitlabMr()])]); + const observation = await gitlabChangeRequestAdapter.observeChangeRequest({ + project: "group/repo", branch: "issue-1210", baseBranch: "main", + localHeadSha: HEAD, transport, token: "test-token", + }); + + assert.equal(calls.length, 1); + assert.equal( + calls[0].url, + "https://gitlab.com/api/v4/projects/group%2Frepo/merge_requests?source_branch=issue-1210&state=all&per_page=100", + ); + assert.deepEqual(calls[0].headers, { "private-token": "test-token", accept: "application/json" }); + + assert.deepEqual(observation, { + available: true, + lookup_complete: true, + mr_lookup_complete: true, + matching_mr_count: 1, identity_match_count: 1, + open_mr_count: 1, merged_mr_count: 0, closed_mr_count: 0, fork_mr_count: 0, + project: "group/repo", + mr_number: 12, mr_state: "opened", + head_ref: "issue-1210", base_ref: "main", + head_project_id: 7, target_project_id: 7, fork: false, + mr_head_sha: HEAD, mr_base_sha: BASE, merge_sha: null, + url: "https://gitlab.com/group/repo/-/merge_requests/12", + }); + assert.equal(gitlabChangeRequestAdapter.exactPublishedChangeRequest(observation, { branch: "issue-1210" }, HEAD), true); +}); + +test("#1210 honors GITLAB_API_URL and RELAY_GITLAB_HOSTS-derived API bases", async () => { + const { transport, calls } = recordingTransport([jsonResponse(200, [])]); + await gitlabChangeRequestAdapter.observeChangeRequest({ + project: "group/repo", host: "git.example.test", branch: "b", baseBranch: "main", + transport, token: "t", + }); + assert.match(calls[0].url, /^https:\/\/git\.example\.test\/api\/v4\/projects\/group%2Frepo\/merge_requests\?/); + + await withEnv({ GITLAB_API_URL: "https://proxy.example.test/api/v4/" }, async () => { + const proxied = recordingTransport([jsonResponse(200, [])]); + await gitlabChangeRequestAdapter.observeChangeRequest({ + project: "group/repo", branch: "b", baseBranch: "main", + transport: proxied.transport, token: "t", + }); + assert.match(proxied.calls[0].url, /^https:\/\/proxy\.example\.test\/api\/v4\/projects\//); + }); +}); + +test("#1210 binds the exact live head through diff_refs and fails closed on a diverging head", async () => { + // The reviewed diff-version head wins over a lagging top-level sha. + const { transport } = recordingTransport([jsonResponse(200, [gitlabMr({ sha: OTHER, diff_refs: { head_sha: HEAD, base_sha: BASE } })])]); + const observation = await gitlabChangeRequestAdapter.observeChangeRequest({ + project: "group/repo", branch: "issue-1210", baseBranch: "main", + localHeadSha: HEAD, transport, token: "t", + }); + assert.equal(observation.mr_head_sha, HEAD); + assert.equal(gitlabChangeRequestAdapter.exactPublishedChangeRequest(observation, { branch: "issue-1210" }, HEAD), true); + + // A live head that moved since publication never binds review or landing. + assert.equal(gitlabChangeRequestAdapter.exactPublishedChangeRequest(observation, { branch: "issue-1210" }, OTHER), false); + + // A head that lags the local expectation is still observed (the retained + // ladder reuses a unique open candidate) but never binds review or landing: + // the exact-head decision fails closed and stays retry-safe. + const lagging = recordingTransport([jsonResponse(200, [gitlabMr({ sha: OTHER, diff_refs: { head_sha: OTHER, base_sha: BASE } })])]); + const laggingObservation = await gitlabChangeRequestAdapter.observeChangeRequest({ + project: "group/repo", branch: "issue-1210", baseBranch: "main", + localHeadSha: HEAD, transport: lagging.transport, token: "t", + }); + assert.equal(laggingObservation.mr_number, 12); + assert.equal(laggingObservation.matching_mr_count, 1); + assert.equal(laggingObservation.mr_head_sha, OTHER, "the observation reports the live head"); + assert.equal(gitlabChangeRequestAdapter.exactPublishedChangeRequest(laggingObservation, { branch: "issue-1210" }, HEAD), false); +}); + +test("#1210 keeps fork merge requests distinct from same-project branches", async () => { + const fork = gitlabMr({ iid: 3, source_project_id: 99, target_project_id: 7 }); + const { transport } = recordingTransport([jsonResponse(200, [fork])]); + const observation = await gitlabChangeRequestAdapter.observeChangeRequest({ + project: "group/repo", branch: "issue-1210", baseBranch: "main", transport, token: "t", + }); + assert.equal(observation.available, true); + assert.equal(observation.fork_mr_count, 1); + assert.equal(observation.mr_number, null, "a fork MR is never identity-matched implicitly"); + assert.equal(observation.matching_mr_count, 0); + assert.equal(gitlabChangeRequestAdapter.exactPublishedChangeRequest(observation, { branch: "issue-1210" }, HEAD), false); +}); + +test("#1210 selects a unique candidate and fails ambiguity closed like the GitHub ladder", async () => { + // Two opened MRs on the same branch: ambiguous, nothing selected. + const ambiguous = recordingTransport([jsonResponse(200, [gitlabMr({ iid: 1 }), gitlabMr({ iid: 2 })])]); + const ambiguousObservation = await gitlabChangeRequestAdapter.observeChangeRequest({ + project: "group/repo", branch: "issue-1210", baseBranch: "main", transport: ambiguous.transport, token: "t", + }); + assert.equal(ambiguousObservation.mr_number, null); + assert.equal(ambiguousObservation.matching_mr_count, 2); + assert.equal(ambiguousObservation.open_mr_count, 2); + + // Identity match (target_branch) wins over a looser same-head candidate. + const identityWins = recordingTransport([jsonResponse(200, [ + gitlabMr({ iid: 1, target_branch: "release" }), + gitlabMr({ iid: 2 }), + ])]); + const identityObservation = await gitlabChangeRequestAdapter.observeChangeRequest({ + project: "group/repo", branch: "issue-1210", baseBranch: "main", transport: identityWins.transport, token: "t", + }); + assert.equal(identityObservation.mr_number, 2); + assert.equal(identityObservation.identity_match_count, 1); + assert.equal(identityObservation.matching_mr_count, 1); + + // A recorded closed iid is adoptable for recovery of a known change request. + const recorded = recordingTransport([jsonResponse(200, [ + gitlabMr({ iid: 5, state: "closed", merge_commit_sha: null }), + gitlabMr({ iid: 6, state: "closed" }), + ])]); + const recordedObservation = await gitlabChangeRequestAdapter.observeChangeRequest({ + project: "group/repo", branch: "issue-1210", baseBranch: "main", + recordedCrNumber: 6, transport: recorded.transport, token: "t", + }); + assert.equal(recordedObservation.mr_number, 6); + assert.equal(recordedObservation.mr_state, "closed"); + + // A merged MR maps its merge commit into provider facts. + const merged = recordingTransport([jsonResponse(200, [gitlabMr({ + iid: 9, state: "merged", merge_commit_sha: OTHER, + })])]); + const mergedObservation = await gitlabChangeRequestAdapter.observeChangeRequest({ + project: "group/repo", branch: "issue-1210", baseBranch: "main", transport: merged.transport, token: "t", + }); + assert.equal(mergedObservation.mr_number, 9); + assert.equal(mergedObservation.merge_sha, OTHER); + assert.equal(mergedObservation.merged_mr_count, 1); +}); + +test("#1210 GitLab outages and permission failures stay typed, observable, and retry-safe", async () => { + const cases = [ + [jsonResponse(401, { message: "401 Unauthorized" }), "GITLAB_AUTH_INVALID", false], + [jsonResponse(403, { message: "403 Forbidden" }), "GITLAB_PERMISSION_DENIED", false], + [jsonResponse(404, { message: "404 Not Found" }), "GITLAB_NOT_FOUND", false], + [jsonResponse(400, { message: "bad request" }), "GITLAB_REQUEST_REJECTED", false], + [jsonResponse(429, { message: "rate limited" }), "GITLAB_OUTAGE", true], + [jsonResponse(503, "unavailable"), "GITLAB_OUTAGE", true], + [new Error("connect ECONNREFUSED"), "GITLAB_OUTAGE", true], + [jsonResponse(200, "not-json"), "GITLAB_RESPONSE_INVALID", false], + [jsonResponse(200, { unexpected: "object" }), "GITLAB_RESPONSE_INVALID", false], + ]; + for (const [response, code, retryable] of cases) { + const { transport } = recordingTransport([response]); + const observation = await gitlabChangeRequestAdapter.observeChangeRequest({ + project: "group/repo", branch: "issue-1210", baseBranch: "main", transport, token: "t", + }); + assert.equal(observation.available, false, code); + assert.equal(observation.lookup_complete, false, code); + assert.equal(observation.error.code, code); + assert.equal(observation.error.retryable, retryable, code); + assert.equal(typeof observation.error.message, "string"); + assert.ok(observation.error.message.length > 0, code); + assert.equal(observation.mr_number, null, code); + assert.equal(observation.mr_head_sha, null, code); + assert.equal(observation.head_ref, "issue-1210", code); + assert.equal(gitlabChangeRequestAdapter.exactPublishedChangeRequest(observation, { branch: "issue-1210" }, HEAD), false, code); + } +}); + +test("#1210 missing GitLab credentials fail closed before any request", async () => { + const dir = tmpDir("forge-adapters-glab-"); + try { + const failingGlab = path.join(dir, "glab"); + fs.writeFileSync(failingGlab, "#!/usr/bin/env node\nprocess.exit(1);\n", { mode: 0o755 }); + const { transport, calls } = recordingTransport([]); + const observation = await withEnv({ + GITLAB_TOKEN: null, GITLAB_API_TOKEN: null, RELAY_GLAB_BIN: failingGlab, + }, () => gitlabChangeRequestAdapter.observeChangeRequest({ + project: "group/repo", branch: "issue-1210", baseBranch: "main", transport, + })); + assert.equal(calls.length, 0, "no forge request without credentials"); + assert.equal(observation.available, false); + assert.equal(observation.error.code, "GITLAB_AUTH_REQUIRED"); + assert.equal(observation.error.retryable, false); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("#1210 resolves GitLab credentials from GITLAB_TOKEN, GITLAB_API_TOKEN, and glab auth token", async () => { + const env = recordingTransport([jsonResponse(200, [])]); + await withEnv({ GITLAB_TOKEN: "env-token", GITLAB_API_TOKEN: null }, () => gitlabChangeRequestAdapter.observeChangeRequest({ + project: "group/repo", branch: "b", baseBranch: "main", transport: env.transport, + })); + assert.equal(env.calls[0].headers["private-token"], "env-token"); + + const apiToken = recordingTransport([jsonResponse(200, [])]); + await withEnv({ GITLAB_TOKEN: null, GITLAB_API_TOKEN: "api-token" }, () => gitlabChangeRequestAdapter.observeChangeRequest({ + project: "group/repo", branch: "b", baseBranch: "main", transport: apiToken.transport, + })); + assert.equal(apiToken.calls[0].headers["private-token"], "api-token"); + + const dir = tmpDir("forge-adapters-glab-token-"); + try { + const glab = path.join(dir, "glab"); + fs.writeFileSync(glab, "#!/usr/bin/env node\nprocess.stdout.write('glab-token\\n');\n", { mode: 0o755 }); + const probed = recordingTransport([jsonResponse(200, [])]); + await withEnv({ GITLAB_TOKEN: null, GITLAB_API_TOKEN: null, RELAY_GLAB_BIN: glab }, () => gitlabChangeRequestAdapter.observeChangeRequest({ + project: "group/repo", branch: "b", baseBranch: "main", transport: probed.transport, + })); + assert.equal(probed.calls[0].headers["private-token"], "glab-token"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +// --------------------------------------------------------------------------- +// GitHub adapter — decision compatibility with the retained route +// --------------------------------------------------------------------------- + +function writeFakeGh(dir, { rows = null, argvLog = null } = {}) { + const script = path.join(dir, "gh"); + fs.writeFileSync(script, [ + "#!/usr/bin/env node", + "const fs = require('fs');", + argvLog ? `fs.writeFileSync(${JSON.stringify(argvLog)}, JSON.stringify(process.argv.slice(2)));` : "", + rows === null + ? "process.stderr.write('gh: could not resolve host\\n'); process.exit(1);" + : `process.stdout.write(JSON.stringify(${JSON.stringify(rows)}));`, + "", + ].filter(Boolean).join("\n"), { mode: 0o755 }); + return script; +} + +function githubRow(overrides = {}) { + return { + number: 42, + state: "OPEN", + url: "https://github.com/owner/repo/pull/42", + headRefName: "issue-1210", + headRefOid: HEAD, + baseRefName: "main", + baseRefOid: BASE, + headRepository: { nameWithOwner: "owner/repo" }, + headRepositoryOwner: { login: "owner" }, + isCrossRepository: false, + mergedAt: null, + mergeCommit: null, + body: "closes #1210", + ...overrides, + }; +} + +test("#1210 GitHub observation is decision-compatible with the retained observeGithub facts", async (t) => { + const dir = tmpDir("forge-adapters-gh-"); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const argvLog = path.join(dir, "argv.json"); + const gh = writeFakeGh(dir, { rows: [githubRow()], argvLog }); + + const observation = await withEnv({ RELAY_GH_BIN: gh }, () => githubChangeRequestAdapter.observeChangeRequest({ + project: "owner/repo", branch: "issue-1210", baseBranch: "main", localHeadSha: HEAD, + })); + + // The seam issues the exact retained gh observation command. + assert.deepEqual(JSON.parse(fs.readFileSync(argvLog, "utf8")), [ + "pr", "list", "--repo", "owner/repo", "--head", "issue-1210", "--state", "all", "--limit", "100", + "--json", GITHUB_PR_JSON_FIELDS, + ]); + + const row = githubRow(); + const selection = recover.__testing.selectGithubPr([row], { + remote: "owner/repo", branch: "issue-1210", baseBranch: "main", localHeadSha: HEAD, + }); + const pr = selection.pr; + // Field-for-field the retained GitHub observation shape (recover.js observeGithub). + assert.deepEqual(observation, { + available: true, + lookup_complete: true, + pr_lookup_complete: true, + matching_pr_count: selection.matchingPrCount, identity_match_count: selection.identityMatchCount, + open_pr_count: selection.openPrCount, merged_pr_count: selection.mergedPrCount, + closed_pr_count: selection.closedPrCount, + repo: "owner/repo", + pr_number: pr.number, pr_state: pr.state, + head_ref: pr.headRefName, base_ref: pr.baseRefName, + head_repo: selection.headRepo(pr), + pr_head_sha: pr.headRefOid, pr_base_sha: pr.baseRefOid, + merge_sha: pr.mergeCommit?.oid || null, + url: pr.url, body: pr.body, + }); + assert.equal( + githubChangeRequestAdapter.exactPublishedChangeRequest(observation, { branch: "issue-1210" }, HEAD), + retainedExactPublishedPr(observation, "issue-1210", HEAD), + ); + assert.equal(githubChangeRequestAdapter.exactPublishedChangeRequest(observation, { branch: "issue-1210" }, HEAD), true); + assert.equal(githubChangeRequestAdapter.exactPublishedChangeRequest(observation, { branch: "issue-1210" }, OTHER), false); + assert.equal("pr_number" in observation && !("mr_number" in observation), true); +}); + +test("#1210 GitHub selection reuses the retained ladder for ambiguous and fork heads", async (t) => { + const dir = tmpDir("forge-adapters-gh-ladder-"); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + + const ambiguous = writeFakeGh(dir, { + rows: [githubRow({ number: 1 }), githubRow({ number: 2 })], + }); + const ambiguousObservation = await withEnv({ RELAY_GH_BIN: ambiguous }, () => githubChangeRequestAdapter.observeChangeRequest({ + project: "owner/repo", branch: "issue-1210", baseBranch: "main", + })); + assert.equal(ambiguousObservation.pr_number, null); + assert.equal(ambiguousObservation.matching_pr_count, 2); + assert.equal(githubChangeRequestAdapter.exactPublishedChangeRequest(ambiguousObservation, { branch: "issue-1210" }, HEAD), false); + + // A fork-head PR (head repo differs from the canonical repo) never identity-matches. + const fork = writeFakeGh(dir, { + rows: [githubRow({ headRepository: { nameWithOwner: "contributor/repo" }, headRepositoryOwner: { login: "contributor" }, isCrossRepository: true })], + }); + const forkObservation = await withEnv({ RELAY_GH_BIN: fork }, () => githubChangeRequestAdapter.observeChangeRequest({ + project: "owner/repo", branch: "issue-1210", baseBranch: "main", localHeadSha: HEAD, + })); + assert.equal(forkObservation.pr_number, null); + assert.equal(forkObservation.matching_pr_count, 0); + + const recorded = writeFakeGh(dir, { + rows: [githubRow({ number: 5, state: "CLOSED" }), githubRow({ number: 6, state: "CLOSED" })], + }); + const recordedObservation = await withEnv({ RELAY_GH_BIN: recorded }, () => githubChangeRequestAdapter.observeChangeRequest({ + project: "owner/repo", branch: "issue-1210", baseBranch: "main", recordedCrNumber: 6, + })); + assert.equal(recordedObservation.pr_number, 6); + assert.equal(recordedObservation.pr_state, "CLOSED"); +}); + +test("#1210 GitHub observation failures stay typed and retry-safe", async (t) => { + const dir = tmpDir("forge-adapters-gh-fail-"); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const failingGh = writeFakeGh(dir, { rows: null }); + + const observation = await withEnv({ RELAY_GH_BIN: failingGh }, () => githubChangeRequestAdapter.observeChangeRequest({ + project: "owner/repo", branch: "issue-1210", baseBranch: "main", + })); + assert.equal(observation.available, false); + assert.equal(observation.lookup_complete, false); + assert.equal(observation.error.code, "GITHUB_OBSERVATION_FAILED"); + assert.equal(observation.error.retryable, true); + assert.match(observation.error.message, /could not resolve host/); + assert.equal(observation.pr_number, null); + assert.equal(observation.pr_head_sha, null); + assert.equal(githubChangeRequestAdapter.exactPublishedChangeRequest(observation, { branch: "issue-1210" }, HEAD), false); +}); + +// --------------------------------------------------------------------------- +// Seam-level guarantees +// --------------------------------------------------------------------------- + +test("#1210 both implementations bind review and landing to the exact live change-request head", async (t) => { + const dir = tmpDir("forge-adapters-exact-head-"); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const gh = writeFakeGh(dir, { rows: [githubRow()] }); + const githubObservation = await withEnv({ RELAY_GH_BIN: gh }, () => githubChangeRequestAdapter.observeChangeRequest({ + project: "owner/repo", branch: "issue-1210", baseBranch: "main", localHeadSha: HEAD, + })); + + const gitlab = recordingTransport([jsonResponse(200, [gitlabMr()])]); + const gitlabObservation = await gitlabChangeRequestAdapter.observeChangeRequest({ + project: "group/repo", branch: "issue-1210", baseBranch: "main", transport: gitlab.transport, token: "t", + }); + assert.equal("mr_number" in gitlabObservation && !("pr_number" in gitlabObservation), true); + + for (const [adapter, observation, matchingKey] of [ + [githubChangeRequestAdapter, githubObservation, "matching_pr_count"], + [gitlabChangeRequestAdapter, gitlabObservation, "matching_mr_count"], + ]) { + assert.equal(adapter.exactPublishedChangeRequest(observation, { branch: "issue-1210" }, HEAD), true); + assert.equal(adapter.exactPublishedChangeRequest(observation, { branch: "issue-1210" }, OTHER), false, "stale head must not bind"); + assert.equal(adapter.exactPublishedChangeRequest({ ...observation, available: false }, { branch: "issue-1210" }, HEAD), false); + assert.equal(adapter.exactPublishedChangeRequest({ ...observation, [matchingKey]: 2 }, { branch: "issue-1210" }, HEAD), false); + assert.equal(adapter.exactPublishedChangeRequest(null, { branch: "issue-1210" }, HEAD), false); + } + assert.equal( + githubChangeRequestAdapter.exactPublishedChangeRequest(githubObservation, { branch: "issue-1210" }, HEAD), + retainedExactPublishedPr(githubObservation, "issue-1210", HEAD), + ); +});