diff --git a/.oxlintrc.json b/.oxlintrc.json index ac3d742007..1c8037837e 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -9,7 +9,8 @@ "./plugins/ban-ref-current-assignment.js", "./plugins/ban-cross-tree-imports.js", "./plugins/ban-e2e-app-imports.js", - "./plugins/ban-web-server-imports.js" + "./plugins/ban-web-server-imports.js", + "./plugins/ban-git-provider-reachthrough.js" ], "ignorePatterns": ["apps/docs/*"], "rules": { @@ -26,7 +27,8 @@ "ban-ref-current-assignment/ban-ref-current-assignment": "error", "ban-cross-tree-imports/ban-cross-tree-imports": "warn", "ban-e2e-app-imports/ban-e2e-app-imports": "error", - "ban-web-server-imports/ban-web-server-imports": "warn" + "ban-web-server-imports/ban-web-server-imports": "warn", + "ban-git-provider-reachthrough/ban-git-provider-reachthrough": "error" }, "plugins": ["react"] } diff --git a/apps/api/src/api/routes/admin-prompts.ts b/apps/api/src/api/routes/admin-prompts.ts index 83ddf8ed7f..4a348f84b5 100644 --- a/apps/api/src/api/routes/admin-prompts.ts +++ b/apps/api/src/api/routes/admin-prompts.ts @@ -15,16 +15,17 @@ * * Mounted by `admin.ts`, therefore already behind `requireDeploymentAdmin`. */ -import { repoRefFromOwnerName } from "@decocms/shared/git-providers"; +import { + repoRefFromOwnerName, + repoWebUrl, +} from "@decocms/shared/git-providers"; import { Hono } from "hono"; import type { Env } from "@/api/hono-env"; -import { contentClientWithToken } from "@/git-providers/content"; import { - type RepoContentClient, + contentClientForProjectRepo, requireBranchHead, -} from "@/git-providers/content/types"; -import { githubConnectionAccessToken } from "@/oauth/github-mint"; -import { resolveGithubConnection } from "@/tools/task-board/prs-get"; + type RepoContentClient, +} from "@/git-providers"; import { extractPromptRegion, replacePromptRegion, @@ -32,6 +33,10 @@ import { /** The repo the prompts live in — this one. */ const PROMPT_REPO = { owner: "decocms", repo: "studio" } as const; +const PROMPT_REPO_REF = repoRefFromOwnerName( + PROMPT_REPO.owner, + PROMPT_REPO.repo, +); /** * The editable prompts, each addressed by the marker pair that fences it in its @@ -124,27 +129,24 @@ async function clientForActor( // which is exactly what this route doesn't have — bind the resolved org so // both the lookup and the token agree on one scope. const orgCtx: Ctx = { ...ctx, organization: org }; - const connection = await resolveGithubConnection(orgCtx, org.id, null, { + /** + * The same credential ladder every other repository read takes — a + * first-class `repositories` row for this repo when the org has one, else + * its legacy `mcp-github` connection. + */ + const gh = await contentClientForProjectRepo(orgCtx, org.id, { + url: repoWebUrl(PROMPT_REPO_REF), owner: PROMPT_REPO.owner, name: PROMPT_REPO.repo, - }); - if (!connection) { + }).catch((cause: unknown) => { throw new PromptEditorError( - "Connect GitHub in this organization to edit prompts", + cause instanceof Error + ? cause.message + : "Connect GitHub in this organization to edit prompts", 400, ); - } - const accessToken = await githubConnectionAccessToken(orgCtx, connection); - if (!accessToken) { - throw new PromptEditorError("Reconnect GitHub to edit prompts", 400); - } - return { - gh: contentClientWithToken( - repoRefFromOwnerName(PROMPT_REPO.owner, PROMPT_REPO.repo), - accessToken, - ), - org: { slug: org.slug, name: org.name }, - }; + }); + return { gh, org: { slug: org.slug, name: org.name } }; } /** diff --git a/apps/api/src/api/routes/decofile.ts b/apps/api/src/api/routes/decofile.ts index 3fc63e57e3..873365213d 100644 --- a/apps/api/src/api/routes/decofile.ts +++ b/apps/api/src/api/routes/decofile.ts @@ -32,18 +32,18 @@ import { createMiddleware } from "hono/factory"; import { z } from "zod"; import { coAuthorFromStudioContext } from "@/lib/co-author-identity"; import { parseGithubRepoFromMetadata } from "@/tools/sandbox/sync-git-credentials"; -import { contentClientForProjectRepo } from "@/git-providers/content"; +import { + RepoWriteConflict, + contentClientForProjectRepo, + repoErrorStatus, + type RepoContentClient, +} from "@/git-providers"; import { enqueueDecofilePatch, type DecofilePatch, } from "@/decofile/commit-coalescer"; import { signDraftToken, verifyDraftToken } from "@/decofile/draft-token"; import { repoGitRebase } from "@/decofile/git-compat"; -import { - type RepoContentClient, - repoErrorStatus, - RepoWriteConflict, -} from "@/git-providers/content/types"; import { readDecofileSnapshot } from "@/decofile/read-decofile"; import type { Env } from "../hono-env"; diff --git a/apps/api/src/api/routes/git-providers.ts b/apps/api/src/api/routes/git-providers.ts index fcdb49f1a5..0d7d5aad19 100644 --- a/apps/api/src/api/routes/git-providers.ts +++ b/apps/api/src/api/routes/git-providers.ts @@ -26,11 +26,9 @@ import { Hono } from "hono"; import { ContextFactory } from "@/core/context-factory"; import type { StudioContext } from "@/core/studio-context"; import { getPublicUrl } from "@/core/server-constants"; -import { getGithubAppAuth } from "@/git-providers/credentials"; -import { - readGithubAppConfig, - readGitlabOAuthConfig, -} from "@/git-providers/env"; +import { getGithubAppAuth } from "@/git-providers/github/app-auth"; +import { readGithubAppConfig } from "@/git-providers/github/env"; +import { readGitlabOAuthConfig } from "@/git-providers/gitlab/env"; import { exchangeGithubCode, githubAuthorizeUrl, diff --git a/apps/api/src/api/routes/sandbox-proxy.ts b/apps/api/src/api/routes/sandbox-proxy.ts index d42ca2c6e0..5fde112405 100644 --- a/apps/api/src/api/routes/sandbox-proxy.ts +++ b/apps/api/src/api/routes/sandbox-proxy.ts @@ -52,12 +52,12 @@ import { suggestCommitMessageWithLlm, } from "../../lib/suggest-commit-message"; import { judgeRequiresReviewWithLlm } from "../../lib/judge-requires-review"; -import { contentClientForProjectRepo } from "../../git-providers/content"; import { + RepoWriteConflict, + contentClientForProjectRepo, repoErrorStatus, repoRateLimitRetryAfterMs, - RepoWriteConflict, -} from "../../git-providers/content/types"; +} from "@/git-providers"; import { GitProviderError } from "../../git-providers/types"; import { repoGitDiff, diff --git a/apps/api/src/decofile/commit-coalescer.ts b/apps/api/src/decofile/commit-coalescer.ts index 0bd627f69f..153a474e71 100644 --- a/apps/api/src/decofile/commit-coalescer.ts +++ b/apps/api/src/decofile/commit-coalescer.ts @@ -9,7 +9,7 @@ import { type FileChange, type RepoContentClient, RepoWriteConflict, -} from "@/git-providers/content/types"; +} from "@/git-providers"; import { aliasPathsForKey, blockEntriesInTree, diff --git a/apps/api/src/decofile/git-compat.test.ts b/apps/api/src/decofile/git-compat.test.ts index f814e1f6f9..3ab1b36444 100644 --- a/apps/api/src/decofile/git-compat.test.ts +++ b/apps/api/src/decofile/git-compat.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import type { TreeEntry } from "@/git-providers/content/types"; +import type { TreeEntry } from "@/git-providers"; import { buildDiscardPlan, buildMergeReplayPlan, diff --git a/apps/api/src/decofile/git-compat.ts b/apps/api/src/decofile/git-compat.ts index 26bff32351..b84704619b 100644 --- a/apps/api/src/decofile/git-compat.ts +++ b/apps/api/src/decofile/git-compat.ts @@ -14,14 +14,14 @@ */ import { - type FileChange, - type RepoContentClient, - repoErrorStatus, + GitProviderError, RepoWriteConflict, + repoErrorStatus, requireBranchHead, + type FileChange, + type RepoContentClient, type TreeEntry, -} from "@/git-providers/content/types"; -import { GitProviderError } from "@/git-providers/types"; +} from "@/git-providers"; import { mapBounded, resolveOrCreateHead } from "./read-decofile"; const DIFF_MAX_FILES = 200; diff --git a/apps/api/src/decofile/read-decofile.test.ts b/apps/api/src/decofile/read-decofile.test.ts index b876fc8391..b01559d4eb 100644 --- a/apps/api/src/decofile/read-decofile.test.ts +++ b/apps/api/src/decofile/read-decofile.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import type { TreeEntry } from "@/git-providers/content/types"; +import type { TreeEntry } from "@/git-providers"; import { aliasPathsForKey, blockEntriesInTree, diff --git a/apps/api/src/decofile/read-decofile.ts b/apps/api/src/decofile/read-decofile.ts index 2a55502eb5..b65c82ab78 100644 --- a/apps/api/src/decofile/read-decofile.ts +++ b/apps/api/src/decofile/read-decofile.ts @@ -11,7 +11,7 @@ import { requireBranchHead, RepoWriteConflict, type TreeEntry, -} from "@/git-providers/content/types"; +} from "@/git-providers"; import { meter } from "../observability"; import { getBlob, diff --git a/apps/api/src/file-storage/org-repo-sync.ts b/apps/api/src/file-storage/org-repo-sync.ts index 6948743200..06c32b7265 100644 --- a/apps/api/src/file-storage/org-repo-sync.ts +++ b/apps/api/src/file-storage/org-repo-sync.ts @@ -19,7 +19,7 @@ import type { OrgRepoSync } from "@/storage/types"; import { clientForAccount, repositoryUsesStudioCredentials, -} from "@/git-providers/credentials"; +} from "@/git-providers"; import { repoRefOf } from "@/storage/repositories"; import { ensureGithubCloneToken } from "@/shared/github-clone-info"; import { getValidDownstreamAccessToken } from "@/oauth/token-refresh"; diff --git a/apps/api/src/git-providers/capabilities.ts b/apps/api/src/git-providers/capabilities.ts new file mode 100644 index 0000000000..63a123452a --- /dev/null +++ b/apps/api/src/git-providers/capabilities.ts @@ -0,0 +1,22 @@ +/** + * Which providers this deployment can connect through Studio-owned + * credentials, without the caller reading an environment variable. + * + * The connect flows themselves are provider-specific by construction (a GitHub + * App installation and a GitLab OAuth grant are different dances, and + * `api/routes/git-providers.ts` implements both), but "can we offer this at + * all" is one question with one shape per provider. Each provider answers for + * itself; this is only the assembly, so no host or env var is named here. + */ + +import type { GitProviderKind } from "@decocms/shared/git-providers"; +import { githubCapability } from "./github/app-auth"; +import { gitlabCapability } from "./gitlab/env"; +import type { GitProviderCapability } from "./types"; + +export function providerCapabilities(): Record< + GitProviderKind, + GitProviderCapability +> { + return { github: githubCapability(), gitlab: gitlabCapability() }; +} diff --git a/apps/api/src/git-providers/change-requests.test.ts b/apps/api/src/git-providers/change-requests.test.ts new file mode 100644 index 0000000000..960ab804e4 --- /dev/null +++ b/apps/api/src/git-providers/change-requests.test.ts @@ -0,0 +1,72 @@ +/** + * The one reducer both providers share: a run list to a single CI summary. + * + * It lives in the interface rather than in either implementation precisely so + * these cases hold for both — a red build must not mean different things on + * GitHub and GitLab. + */ +import { describe, expect, it } from "bun:test"; +import type { CheckRun } from "./change-requests"; +import { summarizeChecks } from "./change-requests"; + +const run = (over: Partial = {}): CheckRun => ({ + id: "1", + name: "build", + state: "completed", + conclusion: "success", + url: null, + durationMs: null, + summary: null, + ...over, +}); + +describe("summarizeChecks", () => { + /** A change request without CI is not "pending" — it has nothing to say. */ + it("is null for no runs at all", () => { + expect(summarizeChecks([])).toBeNull(); + }); + + it("is passing when every run finished well", () => { + expect( + summarizeChecks([ + run(), + run({ conclusion: "neutral" }), + run({ conclusion: "skipped" }), + ]), + ).toBe("passing"); + }); + + it("is pending while any run is unfinished", () => { + expect( + summarizeChecks([run(), run({ state: "queued", conclusion: null })]), + ).toBe("pending"); + expect(summarizeChecks([run({ state: "running", conclusion: null })])).toBe( + "pending", + ); + }); + + /** Failing wins over pending: the worst answer is the actionable one. */ + it("is failing as soon as one run failed, even mid-pipeline", () => { + expect( + summarizeChecks([ + run({ state: "running", conclusion: null }), + run({ conclusion: "failure" }), + ]), + ).toBe("failing"); + }); + + /** + * A run that did not finish is not evidence the head is good, so a + * cancellation and a timeout are red — the same reading GitLab's `canceled` + * pipeline gets. + */ + it("treats a cancelled, timed-out or action-required run as red", () => { + for (const conclusion of [ + "cancelled", + "timed_out", + "action_required", + ] as const) { + expect(summarizeChecks([run({ conclusion })])).toBe("failing"); + } + }); +}); diff --git a/apps/api/src/git-providers/change-requests.ts b/apps/api/src/git-providers/change-requests.ts new file mode 100644 index 0000000000..2a075f677a --- /dev/null +++ b/apps/api/src/git-providers/change-requests.ts @@ -0,0 +1,281 @@ +/** + * Proposing, inspecting and landing a change, as an intention rather than as + * one provider's object model. + * + * GitHub calls it a pull request and GitLab a merge request; both are "here is + * a branch, please put it on that one", both are numbered per repository, and + * both carry the same four things a reviewer acts on: a lifecycle state, + * whether it still applies to its base, what CI said, and what people wrote. + * That is the whole interface. Nothing here names a pull request, a check run, + * a pipeline or a job. + * + * The providers disagree on how many calls each answer costs — GitHub folds a + * detailed read into one GraphQL query, GitLab needs a handful of REST hops — + * and deliberately so: the callers are budgeted in reads, not in round-trips, + * which is why the cheap read and the detailed one are separate methods rather + * than one method with a flag. + */ + +import type { RepoRef } from "@decocms/shared/git-providers"; + +/** + * Where the change request is in its life. `merged` is its own state, not a + * flavour of `closed`: closed-unmerged means abandoned, and every gate that + * advances a task apart cares which of the two happened. + */ +export type ChangeRequestState = "open" | "closed" | "merged"; + +/** CI, reduced to what a gate can act on. `null` = nothing said, not "green". */ +export type ChecksSummary = "pending" | "passing" | "failing" | null; + +export type CheckState = "queued" | "running" | "completed"; + +export type CheckConclusion = + | "success" + | "failure" + | "neutral" + | "cancelled" + | "skipped" + | "timed_out" + | "action_required"; + +/** + * One CI run attached to the head commit — a GitHub check run, a GitHub commit + * status, or a GitLab pipeline job. `id` is null for the shapes that have no + * addressable log (a commit status is a link, not a run). + */ +export interface CheckRun { + id: string | null; + name: string; + state: CheckState; + conclusion: CheckConclusion | null; + /** Where a human goes to read this run. */ + url: string | null; + durationMs: number | null; + /** + * The run's own report, when the provider publishes one with the listing + * (GitHub check-run `output.summary`). Null does NOT mean there is none — + * see `readCheckLog`, which fetches it for a single run on demand. + */ + summary: string | null; +} + +/** Conclusions that mean the run failed — the checks gate's definition of red. */ +const FAILED_CONCLUSIONS = new Set([ + "failure", + "cancelled", + "timed_out", + "action_required", +]); + +/** + * Reduce a run list to one summary, worst-first. Lives here rather than in + * either implementation so a red build cannot mean different things on GitHub + * and GitLab. `null` for an empty list: a change request without CI is not + * "pending", it simply has nothing to say. + */ +export function summarizeChecks(runs: CheckRun[]): ChecksSummary { + if (runs.length === 0) return null; + let pending = false; + for (const run of runs) { + if (run.state !== "completed") { + pending = true; + continue; + } + if (run.conclusion && FAILED_CONCLUSIONS.has(run.conclusion)) { + return "failing"; + } + } + return pending ? "pending" : "passing"; +} + +/** A comment on the change request itself, not on a file and line. */ +export interface ChangeRequestComment { + id: string; + author: string; + body: string; + createdAt: string; + /** + * When it was last edited, `createdAt` when never. Load-bearing, not + * bookkeeping: Vercel and Cloudflare EDIT one sticky comment in place on + * every push, so `createdAt` stays frozen at its first post and anything + * ranking by it reads a months-old deploy link as the newest. + */ + updatedAt: string; + url: string; +} + +/** + * A change request as one cheap read answers it — everything that comes off + * the provider's single "get" for it, and nothing that needs a second call. + */ +export interface ChangeRequest { + number: number; + url: string; + title: string; + body: string; + state: ChangeRequestState; + draft: boolean; + mergedAt: string | null; + /** Branch it targets. */ + base: string; + /** Branch it proposes. */ + head: string; + headSha: string; + /** + * `namespace/name` of the repository the head branch lives in — different + * from this repository for a fork, null when the fork is gone. Such a change + * request cannot be checked out as a local branch. + */ + headRepoPath: string | null; + author: string; + /** + * Whether it still applies to its base. `true` = conflicts, `false` = it + * applies, `null` = the provider has not worked it out yet (both compute + * this asynchronously). An unknown must never be read as a conflict. + */ + conflicting: boolean | null; + /** + * The CI signal that rides along on this one read. Deliberately coarse and + * conservative: it exists so a sweep can gate on green without paying for + * {@link ChangeRequestClient.readDetailed}, and it answers `null` whenever + * the provider's summary field is about anything other than CI. + */ + checks: ChecksSummary; + /** Files touched, when the read carries it. */ + changedFiles: number | null; +} + +/** + * Everything a review surface draws. The extra fields over {@link ChangeRequest} + * are the ones that cost the provider real work: the per-run CI list, the + * comments, and how much human review is outstanding. + */ +export interface ChangeRequestDetail extends ChangeRequest { + checkRuns: CheckRun[]; + comments: ChangeRequestComment[]; + /** Review conversations the provider reports as still open. */ + unresolvedConversations: number; + /** + * A person still has to act before this can land — a required approval is + * missing, or someone asked for changes. Distinct from `checks`, which is + * about machines, and from `conflicting`, which is about the branch. + */ + reviewBlocked: boolean; +} + +/** + * How much history to leave behind. `squash` is asked for by name because a + * publish IS one commit on the base branch; `any` means the caller does not + * care and the implementation should use whatever the repository allows. + */ +export type MergeStrategy = "any" | "squash"; + +/** + * Why a merge did not happen, in terms of who can do something about it. + * + * - `conflict` — the branch no longer applies; the author must rebase. + * - `blocked` — a policy refusal (branch protection, a missing approval, an + * unresolved discussion, a draft). Only a person can clear it, and no other + * merge strategy would help. + * - `rate_limited` — the provider is shedding load. Says nothing about + * mergeability, and asking again immediately IS the burst being limited. + * - `not_found` — no such change request, or the credential cannot see it. + * - `error` — anything else, including a transport failure. + */ +export type MergeRefusal = + | "conflict" + | "blocked" + | "rate_limited" + | "not_found" + | "error"; + +export type MergeOutcome = + | { merged: true } + | { merged: false; reason: MergeRefusal; detail: string }; + +export interface OpenChangeRequestParams { + head: string; + base: string; + title: string; + body?: string; +} + +export interface MergeParams { + strategy?: MergeStrategy; + commitTitle?: string; + commitMessage?: string; +} + +/** + * Raised by {@link ChangeRequestClient.open} when the head branch already has + * an open change request. The caller decides whether that is an error or the + * answer it wanted — the publish path reuses the existing one. + */ +export class ChangeRequestExists extends Error { + readonly existing: ChangeRequest | null; + constructor(message: string, existing: ChangeRequest | null) { + super(message); + this.name = "ChangeRequestExists"; + this.existing = existing; + } +} + +export interface ChangeRequestClient { + readonly repo: RepoRef; + + /** One change request by number, or null when there is none. */ + read(number: number): Promise; + + /** + * The newest change request proposing `branch`, open or not — a merged one + * is what a publish surface shows after it lands, so this must not filter to + * open. Null when the branch has never had one. + */ + readForBranch(branch: string): Promise; + + /** + * Everything a review surface draws, by number or by head branch. Costs + * several provider calls; {@link read} is the one to use in a sweep. + */ + readDetailed( + target: { number: number } | { branch: string }, + ): Promise; + + /** Open change requests, newest first, capped at `limit`. */ + listOpen(limit: number): Promise; + + /** + * The most recently merged change request into `base`. Ordered by when it + * MERGED, not by when it was last touched — a comment on an older one must + * not make it look like the latest. + */ + lastMergedInto(base: string): Promise; + + /** Propose `head` onto `base`. Throws {@link ChangeRequestExists} on a duplicate. */ + open(params: OpenChangeRequestParams): Promise; + + /** Replace the description. Used to add a co-author trailer after the fact. */ + describe(number: number, body: string): Promise; + + /** + * Land it. Never throws for a refusal — every outcome the caller branches on + * is a value, so a provider's prose (a 405 here, a 406 there) is classified + * once, inside the implementation that knows its own vocabulary. + */ + merge(number: number, params?: MergeParams): Promise; + + /** + * One CI run's report, for the rows a reader expands: GitHub's check-run + * `output` markdown, GitLab's job trace. Null when the run has none. + */ + readCheckLog(checkId: string): Promise; + + /** + * The URL a deploy for `sha` published, when the provider tracks deployments + * itself (GitHub Deployments, GitLab Environments) rather than only as a + * commit status or a bot comment. Null when there is no deployment with a + * published URL yet. + */ + readDeployedUrl(sha: string): Promise; +} diff --git a/apps/api/src/git-providers/clients.ts b/apps/api/src/git-providers/clients.ts new file mode 100644 index 0000000000..38403c490a --- /dev/null +++ b/apps/api/src/git-providers/clients.ts @@ -0,0 +1,283 @@ +/** + * The composition root: from a repository to a client that can act on it. + * + * This is the ONE module that knows both providers exist. Everything above it + * speaks `RepoRef` and gets back an interface; everything below it is one + * provider's own vocabulary, sealed in `github/` or `gitlab/`. The `switch` + * here is a registry, not knowledge — adding a provider is a case and a + * directory. + * + * Every factory walks the same credential ladder (`resolveRepoTarget`): + * - the `repositories` row named on the caller's record, when its git provider + * account is one Studio can serve; + * - the row matching the repository's identity, for records written before the + * id was captured; + * - the legacy `mcp-github` connection, for orgs not migrated yet. GitHub + * only — GitLab never had one. + * + * The two capability factories differ deliberately in what they do when no + * path works. A content client THROWS: every caller is about to read or write + * a file and has nothing to show without one. A change-request client answers + * NULL: the task board renders a card that has plenty else on it, and a throw + * there would take down the whole read. + */ + +import type { GithubRepo } from "@decocms/shared/sdk/types"; +import { + parseRepoUrl, + type RepoRef, + repoRefFromOwnerName, +} from "@decocms/shared/git-providers"; +import type { GitProviderKind } from "@decocms/shared/git-providers"; +import type { StudioContext } from "@/core/studio-context"; +import { githubConnectionAccessToken } from "@/oauth/github-mint"; +import { RECONNECT_ERROR } from "@/oauth/token-refresh"; +import type { ChangeRequestClient } from "./change-requests"; +import type { RepoContentClient } from "./content"; +import { + type RepoCredential, + repoCredentialForRepository, + type RepoTarget, + resolveRepoTarget, + type ResolvedRepoTarget, + staticRepoCredential, +} from "./credentials"; +import { GithubChangeRequestClient } from "./github/change-requests"; +import { GithubContentClient } from "./github/content"; +import { resolveLegacyGithubConnection } from "./github/legacy-connection"; +import { GitlabChangeRequestClient } from "./gitlab/change-requests"; +import { gitlabCurrentUser } from "./gitlab/client"; +import { GitlabContentClient } from "./gitlab/content"; +import { + GitProviderError, + type GitTokenKind, + type ProviderPrincipal, +} from "./types"; + +function contentClientFor({ + ref, + tokenSource, +}: RepoCredential): RepoContentClient { + switch (ref.provider) { + case "github": + return new GithubContentClient({ repo: ref, tokenSource }); + case "gitlab": + return new GitlabContentClient({ repo: ref, tokenSource }); + } +} + +function changeRequestClientFor({ + ref, + tokenSource, +}: RepoCredential): ChangeRequestClient { + switch (ref.provider) { + case "github": + return new GithubChangeRequestClient({ repo: ref, tokenSource }); + case "gitlab": + return new GitlabChangeRequestClient({ repo: ref, tokenSource }); + } +} + +/** + * Content client for a repository whose token the caller already holds — the + * legacy connection paths, where the credential was minted before Studio knew + * which repository it was for. + */ +function contentClientWithToken( + repo: RepoRef, + token: string, + kind: GitTokenKind = "installation", +): RepoContentClient { + return contentClientFor(staticRepoCredential(repo, token, kind)); +} + +/** + * The legacy `mcp-github` token for `ref`, or null when this org has no + * connection that can reach it. Shared by both factories so the one remaining + * pre-repository credential path cannot behave differently per capability. + */ +async function legacyGithubToken( + ctx: StudioContext, + organizationId: string, + ref: RepoRef, + connectionId: string | null, +): Promise { + const connection = await resolveLegacyGithubConnection( + ctx, + organizationId, + ref, + connectionId, + ); + if (!connection) return null; + return githubConnectionAccessToken(ctx, connection); +} + +/** + * Why this repository cannot be read, in terms of what the reader has to do. + * + * The three cases are genuinely different and used to collapse into one: + * "reconnect the mcp-github integration" was raised even for a repository + * linked anonymously from a public URL, which has no integration to reconnect + * and never had one. A message that names an action the reader cannot take is + * worse than no message. + */ +function noCredential(resolved: ResolvedRepoTarget): GitProviderError { + const { ref, repository } = resolved; + const base = { provider: ref.provider, status: 401 } as const; + if (repository && !repository.accountId) { + return new GitProviderError({ + ...base, + message: `${ref.path} is linked without an account — connect one in Settings → Repositories to read it`, + }); + } + if (ref.provider !== "github") { + return new GitProviderError({ + ...base, + message: `${ref.path} is not connected to this organization — link it in Settings → Repositories`, + }); + } + // A GitHub connection was there to try, and its token could not be renewed. + return new GitProviderError({ ...base, message: RECONNECT_ERROR }); +} + +/** + * Content client for a repository the caller names however it can — a + * repository id, an identity, or a legacy connection. Shared by the decofile + * routes, the sandbox-less `/git/*` compat handlers and the branch search so + * credential resolution cannot drift between them. + */ +export async function contentClientForTarget( + ctx: StudioContext, + organizationId: string, + target: RepoTarget, +): Promise { + const resolved = await resolveRepoTarget(ctx.storage, organizationId, target); + if (!resolved) { + throw new GitProviderError({ + provider: "github", + status: 404, + message: + "No repository for this project — link one in Settings → Repositories", + }); + } + if (resolved.repository && resolved.servable) { + return contentClientFor( + await repoCredentialForRepository(ctx, resolved.repository), + ); + } + const token = await legacyGithubToken( + ctx, + organizationId, + resolved.ref, + target.connectionId ?? null, + ); + if (!token) throw noCredential(resolved); + return contentClientWithToken(resolved.ref, token); +} + +/** + * A legacy `metadata.githubRepo` binding, as a target. + * + * The URL is preferred over the `owner`/`name` pair because it carries the + * host, and therefore the provider; the pair is the fallback for a binding + * written before that was true, which is github.com by construction. + */ +export function repoTargetForBinding(githubRepo: GithubRepo): RepoTarget { + return { + repositoryId: githubRepo.repositoryId, + ref: + parseRepoUrl(githubRepo.url) ?? + repoRefFromOwnerName(githubRepo.owner, githubRepo.name), + connectionId: githubRepo.connectionId, + }; +} + +/** {@link contentClientForTarget} for a project's legacy `githubRepo` binding. */ +export function contentClientForProjectRepo( + ctx: StudioContext, + organizationId: string, + githubRepo: GithubRepo, +): Promise { + return contentClientForTarget( + ctx, + organizationId, + repoTargetForBinding(githubRepo), + ); +} + +/** Where a change request's repository was recorded, however completely. */ +export interface ChangeRequestOrigin { + repo: RepoRef; + repositoryId?: string | null; + connectionId?: string | null; +} + +/** A client for `origin`'s repository, or null — see the module note. */ +export function changeRequestClientForOrigin( + ctx: StudioContext, + organizationId: string, + origin: ChangeRequestOrigin, +): Promise { + return changeRequestClientForTarget(ctx, organizationId, { + repositoryId: origin.repositoryId, + ref: origin.repo, + connectionId: origin.connectionId, + }); +} + +/** + * A client for a repository the caller names however it can. Null when this + * org has none of those paths; a credential that EXISTS but cannot mint (a + * revoked grant) still throws — that is a real failure, and reading it as "no + * repository" is how a broken card looks merely empty. + */ +export async function changeRequestClientForTarget( + ctx: StudioContext, + organizationId: string, + target: RepoTarget, +): Promise { + const resolved = await resolveRepoTarget(ctx.storage, organizationId, target); + if (!resolved) return null; + if (resolved.repository && resolved.servable) { + return changeRequestClientFor( + await repoCredentialForRepository(ctx, resolved.repository), + ); + } + const token = await legacyGithubToken( + ctx, + organizationId, + resolved.ref, + target.connectionId ?? null, + ); + return token + ? changeRequestClientFor(staticRepoCredential(resolved.ref, token)) + : null; +} + +/** + * Who a raw access token authenticates as — the connect-by-token flow, which + * has to name the account before it can store the credential under it. + * + * GitHub is refused rather than merely unimplemented: its accounts connect + * through the App so Studio can mint a token scoped to ONE repository, and a + * user PAT would silently widen every repository to that user's blanket + * access. That is a policy about what each provider offers, so it lives with + * the registry rather than in a tool. + */ +export function principalForToken( + provider: GitProviderKind, + host: string, + token: string, +): Promise { + switch (provider) { + case "gitlab": + return gitlabCurrentUser(host, token); + case "github": + throw new GitProviderError({ + provider, + status: 400, + message: + "GitHub accounts connect through the GitHub App; tokens are accepted for GitLab only", + }); + } +} diff --git a/apps/api/src/git-providers/content/types.test.ts b/apps/api/src/git-providers/content.test.ts similarity index 96% rename from apps/api/src/git-providers/content/types.test.ts rename to apps/api/src/git-providers/content.test.ts index f19bab68a4..dcf2e66836 100644 --- a/apps/api/src/git-providers/content/types.test.ts +++ b/apps/api/src/git-providers/content.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "bun:test"; -import { GitProviderError } from "../types"; import { repoErrorStatus, repoRateLimitRetryAfterMs, RepoWriteConflict, -} from "./types"; +} from "./content"; +import { GitProviderError } from "./types"; describe("repoErrorStatus", () => { it("reads the status off either provider's error", () => { diff --git a/apps/api/src/git-providers/content/types.ts b/apps/api/src/git-providers/content.ts similarity index 85% rename from apps/api/src/git-providers/content/types.ts rename to apps/api/src/git-providers/content.ts index 19e922e6ac..ada0eae38e 100644 --- a/apps/api/src/git-providers/content/types.ts +++ b/apps/api/src/git-providers/content.ts @@ -15,7 +15,7 @@ */ import type { RepoRef } from "@decocms/shared/git-providers"; -import { GitProviderError } from "../types"; +import { GitProviderError } from "./types"; export interface TreeEntry { path: string; @@ -92,6 +92,20 @@ export function repoRateLimitRetryAfterMs( return typeof e.retryAfterMs === "number" ? e.retryAfterMs : null; } +/** A branch, as a picker lists it. `author` is null when the provider cannot + * attribute the head commit to an account. */ +export interface BranchMatch { + name: string; + author: string | null; +} + +export interface BranchPage { + branches: BranchMatch[]; + totalCount: number; + /** Opaque, provider-owned; null when the listing is exhausted. */ + nextCursor: string | null; +} + export interface RepoContentClient { readonly repo: RepoRef; @@ -104,6 +118,29 @@ export interface RepoContentClient { getBranch( branch: string, ): Promise<{ sha: string; committedAt: string } | null>; + /** + * Branches whose name contains `query`, case-insensitively, filtered by the + * PROVIDER rather than locally — a repository with hundreds of branches + * makes a client-side grep over a paged listing useless, since the one you + * typed shows up several "load more" clicks later. An empty `query` browses + * from the start, which is why this one method serves both the picker's + * search and its paging. + * + * `totalCount` is the true number of matches, so a caller can say how many + * it is not showing. `nextCursor` is opaque and provider-owned (a GraphQL + * cursor, a page number) — pass it back verbatim for the next window, and + * null means there is no more. + * + * Alphabetical, not by recency: GitHub silently ignores a commit-date order + * for branch refs and answers alphabetically anyway, so sorting the + * truncated window would look ranked while omitting the actually-newest + * branch. + */ + searchBranches(params: { + query: string; + limit: number; + cursor?: string | null; + }): Promise; /** * Gzipped tar at `ref`, streamed — one request for every file, versus one * blob request per block. The body is never buffered here; the caller pipes diff --git a/apps/api/src/git-providers/content/index.ts b/apps/api/src/git-providers/content/index.ts deleted file mode 100644 index 0b9357d866..0000000000 --- a/apps/api/src/git-providers/content/index.ts +++ /dev/null @@ -1,179 +0,0 @@ -/** - * From a repository (or a legacy `metadata.githubRepo` binding) to a - * `RepoContentClient`. - * - * Two credential paths, the same two the clone path has: - * - a first-class `repositories` row whose git provider account Studio can - * serve — the provider client mints the repo token (`clientForAccount`), and - * the row's `provider` picks the implementation; - * - the legacy `mcp-github` connection recorded on the binding, for orgs that - * have not been migrated to a repository row yet. - * - * A client instance is meant to live for ONE request: its default-branch memo - * and commit→tree memo assume no ref moves under it. - */ - -import type { GithubRepo } from "@decocms/shared/sdk/types"; -import { - repoRefFromOwnerName, - type RepoRef, -} from "@decocms/shared/git-providers"; -import type { StudioContext } from "@/core/studio-context"; -import { githubConnectionAccessToken } from "@/oauth/github-mint"; -import { RECONNECT_ERROR } from "@/oauth/token-refresh"; -import { GitProviderAccountStorage } from "@/storage/git-provider-accounts"; -import { type RepositoryRecord, repoRefOf } from "@/storage/repositories"; -import { - clientForAccount, - findRepositoryForLegacyBinding, - type GitProviderDeps, - repositoryUsesStudioCredentials, -} from "../credentials"; -import { - type GitProviderClient, - GitProviderError, - type GitTokenKind, - type TokenSource, -} from "../types"; -import { GithubContentClient } from "./github"; -import { GitlabContentClient } from "./gitlab"; -import type { RepoContentClient } from "./types"; - -/** The token kind an account's stored credential produces, before minting it. */ -function tokenKindOf(account: { authKind: string }): GitTokenKind { - if (account.authKind === "github_app") return "installation"; - return account.authKind === "token" ? "token" : "oauth"; -} - -/** A token source over one repository's push/read credential. */ -function repoTokenSource( - client: GitProviderClient, - repo: RepoRef, - kind: GitTokenKind, -): TokenSource { - return { kind, get: (opts) => client.tokenForRepo(repo, opts) }; -} - -/** A token source over an already-minted token — the legacy connection path. */ -function staticTokenSource(token: string, kind: GitTokenKind): TokenSource { - return { kind, get: () => Promise.resolve({ token, kind, expiresAt: null }) }; -} - -/** - * Content client for a repository whose token the caller already holds — the - * legacy connection paths, where the credential was minted before Studio knew - * which repository it was for. - */ -export function contentClientWithToken( - repo: RepoRef, - token: string, - kind: GitTokenKind = "installation", -): RepoContentClient { - return contentClientFor(repo, staticTokenSource(token, kind)); -} - -function contentClientFor( - repo: RepoRef, - tokenSource: TokenSource, -): RepoContentClient { - switch (repo.provider) { - case "github": - return new GithubContentClient({ repo, tokenSource }); - case "gitlab": - return new GitlabContentClient({ repo, tokenSource }); - } -} - -/** - * Content client for a first-class repository row, credentialed through its - * git provider account. Throws `GitProviderError` when the row is anonymous or - * its account cannot produce a token — reading a repository's contents is - * always authenticated here, even for a public one. - */ -async function contentClientForRepository( - deps: GitProviderDeps, - repository: RepositoryRecord, -): Promise { - const ref = repoRefOf(repository); - if (!repository.accountId) { - throw new GitProviderError({ - provider: repository.provider, - status: 401, - message: `${repository.path} is linked without an account; link it again to read and write its contents`, - }); - } - const account = await new GitProviderAccountStorage(deps.db).getUnscoped( - repository.accountId, - ); - if (!account || account.organizationId !== repository.organizationId) { - throw new GitProviderError({ - provider: repository.provider, - status: 404, - message: `The account backing ${repository.path} no longer exists. Link the repository again.`, - }); - } - const client = clientForAccount(deps, account); - return contentClientFor( - ref, - repoTokenSource(client, ref, tokenKindOf(account)), - ); -} - -/** - * Content client for a legacy `metadata.githubRepo` binding: the recorded - * `mcp-github` connection (org-scoped) plus the repo-scoped installation token - * it mints. Kept for orgs whose repos have no `repositories` row yet. - */ -async function contentClientForLegacyConnection( - ctx: StudioContext, - organizationId: string, - githubRepo: GithubRepo, -): Promise { - const missing = new GitProviderError({ - provider: "github", - status: 401, - message: "Project's GitHub connection is missing — reconnect GitHub", - }); - if (!githubRepo.connectionId) throw missing; - const connection = await ctx.storage.connections.findById( - githubRepo.connectionId, - organizationId, - ); - if (!connection) throw missing; - const accessToken = await githubConnectionAccessToken(ctx, connection); - if (!accessToken) { - throw new GitProviderError({ - provider: "github", - status: 401, - message: RECONNECT_ERROR, - }); - } - return contentClientWithToken( - repoRefFromOwnerName(githubRepo.owner, githubRepo.name), - accessToken, - ); -} - -/** - * Content client for a project's linked repo, taking whichever credential path - * the org is on. Shared by the decofile routes and the sandbox-less `/git/*` - * compat handlers so credential resolution cannot drift between them. - */ -export async function contentClientForProjectRepo( - ctx: StudioContext, - organizationId: string, - githubRepo: GithubRepo, -): Promise { - const repository = await findRepositoryForLegacyBinding( - ctx.storage, - organizationId, - githubRepo, - ); - if ( - repository && - (await repositoryUsesStudioCredentials(ctx.storage, repository)) - ) { - return contentClientForRepository(ctx, repository); - } - return contentClientForLegacyConnection(ctx, organizationId, githubRepo); -} diff --git a/apps/api/src/git-providers/credentials.ts b/apps/api/src/git-providers/credentials.ts index 7217adeb74..3a01d32f82 100644 --- a/apps/api/src/git-providers/credentials.ts +++ b/apps/api/src/git-providers/credentials.ts @@ -30,8 +30,7 @@ import { repoRefOf, } from "@/storage/repositories"; import type { Database } from "@/storage/types"; -import { readGithubAppConfig } from "./env"; -import { GithubAppAuth } from "./github/app-auth"; +import { getGithubAppAuth } from "./github/app-auth"; import { GithubProviderClient } from "./github/client"; import { GitlabProviderClient } from "./gitlab/client"; import { @@ -47,17 +46,6 @@ export interface GitProviderDeps { vault: CredentialVault; } -let appAuthSingleton: GithubAppAuth | null | undefined; - -/** Process-wide GitHub App signer (its token cache lives inside); null when unconfigured. */ -export function getGithubAppAuth(): GithubAppAuth | null { - if (appAuthSingleton === undefined) { - const config = readGithubAppConfig(); - appAuthSingleton = config ? new GithubAppAuth(config) : null; - } - return appAuthSingleton; -} - /** * A token source over a stored grant. * @@ -157,6 +145,75 @@ export function clientForAccount( } } +/** A repository plus a token source that can read and push it. */ +export interface RepoCredential { + ref: RepoRef; + tokenSource: TokenSource; +} + +/** The token kind an account's stored credential produces, before minting it. */ +function tokenKindOf(account: GitProviderAccountRecord): GitTokenKind { + if (account.authKind === "github_app") return "installation"; + return grantKind(account.authKind); +} + +/** + * A credential over an already-minted token — the legacy connection paths, + * where the token was produced before Studio knew which repository it was for. + */ +export function staticRepoCredential( + ref: RepoRef, + token: string, + kind: GitTokenKind = "installation", +): RepoCredential { + return { + ref, + tokenSource: { + kind, + get: () => Promise.resolve({ token, kind, expiresAt: null }), + }, + }; +} + +/** + * The credential for a first-class repository row, through its git provider + * account. Throws `GitProviderError` when the row is anonymous or its account + * is gone — every provider API call Studio makes for a repository is + * authenticated, even for a public one. + * + * Shared by every client factory (contents, change requests) so credential + * resolution cannot drift between them. + */ +export async function repoCredentialForRepository( + deps: GitProviderDeps, + repository: RepositoryRecord, +): Promise { + const ref = repoRefOf(repository); + if (!repository.accountId) { + throw new GitProviderError({ + provider: repository.provider, + status: 401, + message: `${repository.path} is linked without an account; link it again to read and write it`, + }); + } + const account = await new GitProviderAccountStorage(deps.db).getUnscoped( + repository.accountId, + ); + if (!account || account.organizationId !== repository.organizationId) { + throw new GitProviderError({ + provider: repository.provider, + status: 404, + message: `The account backing ${repository.path} no longer exists. Link the repository again.`, + }); + } + const client = clientForAccount(deps, account); + const kind = tokenKindOf(account); + return { + ref, + tokenSource: { kind, get: (opts) => client.tokenForRepo(ref, opts) }, + }; +} + export interface RepoCloneInfo { cloneUrl: string; gitUserName: string; @@ -208,32 +265,64 @@ function anonymousCloneInfo(ref: RepoRef): RepoCloneInfo { } /** The storage ports these lookups need — satisfied by `ctx.storage`. */ -export interface GitProviderStoragePorts { - repositories: Pick; - gitProviderAccounts: Pick; +/** + * How a caller names the repository it wants a client for. + * + * Three fields because records were written at three different times: the + * repository id is what everything records now, the identity is what older + * rows carry, and the connection is the pre-repository world. Every client + * factory takes this shape so the ladder cannot differ between them. + */ +export interface RepoTarget { + /** The first-class repository row. Wins over `ref`. */ + repositoryId?: string | null; + /** Identity, for records written before the id was captured. */ + ref?: RepoRef | null; + /** The legacy `mcp-github` connection, when the record carries one. */ + connectionId?: string | null; +} + +export interface ResolvedRepoTarget { + ref: RepoRef; + /** The org's repository row for it, when there is one. */ + repository: RepositoryRecord | null; + /** Whether that row's account is one Studio can mint credentials from. */ + servable: boolean; } /** - * The repository row a legacy `metadata.githubRepo` binding refers to, if the - * org has one — by explicit `repositoryId` when the binding carries it, else by - * identity (`github.com/owner/name`). Null keeps the caller on the legacy path. + * Resolve a target to a concrete repository, without minting anything. + * + * Null when the target names nothing this org has: neither an id it owns nor + * an identity — which is the caller's cue to report "connect this repository" + * rather than to guess. A row that exists but whose account Studio cannot + * serve still resolves, with `servable: false`, so the caller can fall back to + * the legacy connection path for it. */ -export async function findRepositoryForLegacyBinding( - storage: Pick, +export async function resolveRepoTarget( + storage: GitProviderStoragePorts, organizationId: string, - binding: { owner: string; name: string; repositoryId?: string | null }, -): Promise { - if (binding.repositoryId) { - const byId = await storage.repositories.get( - binding.repositoryId, - organizationId, - ); - if (byId) return byId; - } - return storage.repositories.findByRef(organizationId, { - host: "github.com", - path: `${binding.owner}/${binding.name}`, - }); + target: RepoTarget, +): Promise { + const repository = target.repositoryId + ? await storage.repositories.get(target.repositoryId, organizationId) + : target.ref + ? await storage.repositories.findByRef(organizationId, target.ref) + : null; + const ref = repository ? repoRefOf(repository) : (target.ref ?? null); + if (!ref) return null; + return { + ref, + repository, + servable: repository + ? await repositoryUsesStudioCredentials(storage, repository) + : false, + }; +} + +export interface GitProviderStoragePorts { + repositories: Pick; + gitProviderAccounts: Pick; } /** diff --git a/apps/api/src/git-providers/env.ts b/apps/api/src/git-providers/env.ts deleted file mode 100644 index 4c3b3b8a8a..0000000000 --- a/apps/api/src/git-providers/env.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Deployment config for Studio-owned git provider credentials. - * - * All optional. With nothing set, the git-providers routes answer 503 and the - * legacy `mcp-github` paths keep working — the feature is dormant until the - * operator registers the apps (see `deploy/` and `selfhost/` docs). - * - * Read through these helpers only; tools never touch `process.env`. - */ - -export interface GithubAppConfig { - appId: string; - /** PEM. `\n` escape sequences are accepted for single-line env values. */ - privateKeyPem: string; - clientId: string; - clientSecret: string; - /** App slug, for `https://github.com/apps//installations/new`. */ - slug: string; -} - -export function readGithubAppConfig( - env: Record = process.env, -): GithubAppConfig | null { - const appId = env.GITHUB_APP_ID?.trim(); - const privateKey = env.GITHUB_APP_PRIVATE_KEY?.trim(); - const clientId = env.GITHUB_APP_CLIENT_ID?.trim(); - const clientSecret = env.GITHUB_APP_CLIENT_SECRET?.trim(); - const slug = env.GITHUB_APP_SLUG?.trim(); - if (!appId || !privateKey || !clientId || !clientSecret || !slug) return null; - return { - appId, - privateKeyPem: privateKey.replace(/\\n/g, "\n"), - clientId, - clientSecret, - slug, - }; -} - -export interface GitlabOAuthConfig { - host: string; - clientId: string; - clientSecret: string; -} - -/** - * OAuth application for gitlab.com. Self-managed instances need their own - * application registered per instance; those connect with a token for now. - */ -export function readGitlabOAuthConfig( - env: Record = process.env, -): GitlabOAuthConfig | null { - const clientId = env.GITLAB_OAUTH_CLIENT_ID?.trim(); - const clientSecret = env.GITLAB_OAUTH_CLIENT_SECRET?.trim(); - if (!clientId || !clientSecret) return null; - return { - host: (env.GITLAB_OAUTH_HOST?.trim() || "gitlab.com").toLowerCase(), - clientId, - clientSecret, - }; -} diff --git a/apps/api/src/git-providers/github/app-auth.test.ts b/apps/api/src/git-providers/github/app-auth.test.ts index 1918693297..6a11b6b387 100644 --- a/apps/api/src/git-providers/github/app-auth.test.ts +++ b/apps/api/src/git-providers/github/app-auth.test.ts @@ -9,6 +9,7 @@ import { installationCacheKey, mapInstallation, nextPermissionSet, + usableAppKey, } from "./app-auth"; /** A throwaway RSA pair. Real crypto, generated per run — nothing is mocked. */ @@ -250,3 +251,33 @@ describe("mapInstallation", () => { expect(mapInstallation({ account: { id: 2, login: "x" } })).toBeNull(); }); }); + +describe("usableAppKey", () => { + const key = generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }).privateKey; + const config = (privateKeyPem: string) => ({ + appId: "1", + privateKeyPem, + clientId: "c", + clientSecret: "s", + slug: "studio", + }); + + test("accepts a key that can actually sign", () => { + expect(usableAppKey(config(key))).toBe(true); + }); + + /** + * Every one of these is a non-empty string, so the env reader is happy with + * it. Without this gate they would make the App look configured, take every + * repository off its legacy connection, and then fail at the first mint. + */ + test("rejects the ways a PEM arrives mangled from a secret store", () => { + expect(usableAppKey(config(key.replace(/\n/g, " ")))).toBe(false); + expect(usableAppKey(config(key.slice(0, 120)))).toBe(false); + expect(usableAppKey(config("not a key"))).toBe(false); + }); +}); diff --git a/apps/api/src/git-providers/github/app-auth.ts b/apps/api/src/git-providers/github/app-auth.ts index 856b9784af..3f194d4017 100644 --- a/apps/api/src/git-providers/github/app-auth.ts +++ b/apps/api/src/git-providers/github/app-auth.ts @@ -16,7 +16,8 @@ import { isPermissionRejected, OPTIONAL_MINT_PERMISSIONS, } from "@decocms/shared/github-repo-scope"; -import type { GithubAppConfig } from "../env"; +import { type GithubAppConfig, readGithubAppConfig } from "./env"; +import type { GitProviderCapability } from "../types"; import { GitProviderError } from "../types"; import { githubErrorMessage, @@ -368,3 +369,74 @@ export class GithubAppAuth { } } } + +let appAuthSingleton: GithubAppAuth | null | undefined; + +/** + * The process-wide App signer, or null when this deployment has no App + * registered. One instance because its installation-token cache lives inside + * it: a second signer would mint a second token per installation and halve + * the cache's usefulness. + * + * Lives here rather than in the credential ladder so that the ladder — which + * is provider-neutral — holds no GitHub state of its own. + */ +export function getGithubAppAuth(): GithubAppAuth | null { + if (appAuthSingleton === undefined) { + const config = readGithubAppConfig(); + appAuthSingleton = + config && usableAppKey(config) ? new GithubAppAuth(config) : null; + } + return appAuthSingleton; +} + +/** + * Whether the configured private key can actually sign — checked HERE, at the + * gate, rather than at the first mint. + * + * `readGithubAppConfig` only proves five environment variables are non-empty, + * and a PEM mangled on the way into a secret manager (the newlines eaten, the + * classic one) is a perfectly good non-empty string. Without this check that + * key still makes `getGithubAppAuth()` non-null, which makes every backfilled + * account `accountIsServable`, which takes every GitHub repository OFF its + * legacy connection and onto an App that cannot sign — turning a bad paste + * into an outage instead of a no-op. + * + * Answering null instead leaves the whole feature dormant and every org on the + * path it is already using, which is what a misconfiguration should cost. + * + * Exported for its test: the singleton above memoizes, so only the predicate + * can be exercised across more than one configuration in a process. + */ +export function usableAppKey(config: GithubAppConfig): boolean { + try { + createPrivateKey(config.privateKeyPem); + return true; + } catch (cause) { + console.error( + "[git-providers] GITHUB_APP_PRIVATE_KEY is not a usable private key — " + + "the GitHub App stays disabled and orgs keep their existing " + + "connections. Check that newlines survived the secret store.", + cause, + ); + return false; + } +} + +/** + * The App serves github.com only — GitHub Enterprise needs its own App + * registered per instance, which this deployment has no config for. So the + * host list is a constant here rather than a setting. + */ +const GITHUB_APP_HOST = "github.com"; + +export function githubCapability(): GitProviderCapability { + /** + * BOTH a readable config and a signer built from it: a malformed PEM parses + * as config and then fails at the first mint, which is a worse answer than + * "not configured". + */ + const configured = + readGithubAppConfig() !== null && getGithubAppAuth() !== null; + return { configured, hosts: configured ? [GITHUB_APP_HOST] : [] }; +} diff --git a/apps/api/src/git-providers/github/change-requests.test.ts b/apps/api/src/git-providers/github/change-requests.test.ts new file mode 100644 index 0000000000..99ff63c06a --- /dev/null +++ b/apps/api/src/git-providers/github/change-requests.test.ts @@ -0,0 +1,443 @@ +/** + * GitHub's vocabulary, mapped into the neutral one. Pure — the round-trips + * themselves are e2e. + * + * Ported from the task board's `checks-status.test.ts`, which is where these + * rules were earned while GitHub was reached through MCP. They are unchanged; + * only their home moved, next to the implementation that owns the vocabulary. + */ +import { describe, expect, it } from "bun:test"; +import type { RepoRef } from "@decocms/shared/git-providers"; +import { + checksFromMergeableState, + conflictFromPullRequest, + isMergeMethodNotAllowed, + mapChecks, + mapDetail, + mapPullRequest, + pickMostRecentlyMerged, + type RawGraphqlChangeRequest, +} from "./change-requests"; + +const REPO: RepoRef = { + provider: "github", + host: "github.com", + path: "acme/site", +}; + +describe("conflictFromPullRequest", () => { + it("maps an open pull request with mergeable === false to a conflict", () => { + expect(conflictFromPullRequest({ state: "open", mergeable: false })).toBe( + true, + ); + }); + + it("maps an open, mergeable one to false", () => { + expect(conflictFromPullRequest({ state: "open", mergeable: true })).toBe( + false, + ); + }); + + it("is null while GitHub has not computed mergeability", () => { + expect( + conflictFromPullRequest({ state: "open", mergeable: null }), + ).toBeNull(); + expect(conflictFromPullRequest({ state: "open" })).toBeNull(); + }); + + /** A merged or closed one reports no mergeability, but is never conflicting. */ + it("treats a non-open pull request as not conflicting", () => { + expect(conflictFromPullRequest({ state: "closed" })).toBe(false); + expect( + conflictFromPullRequest({ state: "closed", mergeable_state: "dirty" }), + ).toBe(false); + }); + + it("is null for nothing at all", () => { + expect(conflictFromPullRequest(null)).toBeNull(); + }); + + /** + * The reason this reads `mergeable_state` at all: github-mcp's + * `MinimalPullRequest` has no `mergeable`, so reading only the boolean + * yielded null for every pull request ever and the conflict auto-resolution + * it gates never fired once in production. + */ + it("falls back to mergeable_state when the boolean is absent", () => { + expect( + conflictFromPullRequest({ state: "open", mergeable_state: "dirty" }), + ).toBe(true); + expect( + conflictFromPullRequest({ state: "open", mergeable_state: "clean" }), + ).toBe(false); + expect( + conflictFromPullRequest({ state: "open", mergeable_state: "blocked" }), + ).toBe(false); + }); + + it("is null when mergeable_state is still being computed", () => { + for (const mergeable_state of ["unknown", ""]) { + expect( + conflictFromPullRequest({ state: "open", mergeable_state }), + ).toBeNull(); + } + }); + + it("prefers the boolean when a full payload carries both", () => { + expect( + conflictFromPullRequest({ + state: "open", + mergeable: true, + mergeable_state: "dirty", + }), + ).toBe(false); + }); +}); + +describe("checksFromMergeableState", () => { + it("maps the two unambiguous values", () => { + expect(checksFromMergeableState("clean")).toBe("passing"); + expect(checksFromMergeableState("unstable")).toBe("failing"); + }); + + it("is null for everything that says nothing about checks", () => { + /** + * `blocked` is the load-bearing one: it also covers a missing required + * review, so reading it as red would hold QA on a healthy deploy. + */ + for (const state of ["blocked", "dirty", "behind", "unknown"]) { + expect(checksFromMergeableState(state)).toBeNull(); + } + expect(checksFromMergeableState(undefined)).toBeNull(); + expect(checksFromMergeableState(null)).toBeNull(); + }); +}); + +describe("mapPullRequest", () => { + it("reads a REST payload into the neutral shape", () => { + expect( + mapPullRequest({ + number: 7, + html_url: "https://github.com/acme/site/pull/7", + title: "feat: x", + body: "why", + state: "open", + draft: false, + mergeable: true, + mergeable_state: "clean", + changed_files: 3, + base: { ref: "main" }, + head: { + ref: "feat/x", + sha: "abc1234", + repo: { full_name: "acme/site" }, + }, + user: { login: "someone" }, + }), + ).toEqual({ + number: 7, + url: "https://github.com/acme/site/pull/7", + title: "feat: x", + body: "why", + state: "open", + draft: false, + mergedAt: null, + base: "main", + head: "feat/x", + headSha: "abc1234", + headRepoPath: "acme/site", + author: "someone", + conflicting: false, + checks: "passing", + changedFiles: 3, + }); + }); + + /** `merged` is its own state, not a flavour of closed. */ + it("reads a merged pull request as merged, not closed", () => { + const cr = mapPullRequest({ + number: 1, + state: "closed", + merged: true, + merged_at: "2026-09-01T00:00:00Z", + }); + expect(cr.state).toBe("merged"); + expect(cr.mergedAt).toBe("2026-09-01T00:00:00Z"); + }); + + /** A payload with only `merged_at` (no boolean) still landed. */ + it("infers a merge from merged_at alone", () => { + expect( + mapPullRequest({ + state: "closed", + merged_at: "2026-09-01T00:00:00Z", + }).state, + ).toBe("merged"); + }); + + it("reads a closed-unmerged pull request as closed", () => { + expect(mapPullRequest({ state: "closed" }).state).toBe("closed"); + }); + + /** A fork's head repo differs; null once the fork is gone. */ + it("keeps the head repository path, or null when absent", () => { + expect( + mapPullRequest({ head: { repo: { full_name: "fork/site" } } }) + .headRepoPath, + ).toBe("fork/site"); + expect(mapPullRequest({ head: { ref: "x" } }).headRepoPath).toBeNull(); + }); +}); + +describe("mapChecks", () => { + const rollup = (nodes: unknown[]): RawGraphqlChangeRequest => ({ + commits: { + nodes: [ + { + commit: { + statusCheckRollup: { + contexts: { nodes: nodes as never }, + }, + }, + }, + ], + }, + }); + + /** + * The rollup carries BOTH check runs and legacy commit statuses, and a + * repository can post to either — a deco site whose combined status is empty + * but whose "Deco / QA" check run failed, and a Cloudflare deploy that is + * only ever a status. Reading both here is what collapsed the two separate + * reads the MCP path made into one. + */ + it("reads check runs and legacy commit statuses as the same thing", () => { + const runs = mapChecks( + rollup([ + { + __typename: "CheckRun", + databaseId: 42, + name: "Deco / QA", + status: "COMPLETED", + conclusion: "FAILURE", + detailsUrl: "https://github.com/acme/site/runs/42", + startedAt: "2026-09-01T00:00:00Z", + completedAt: "2026-09-01T00:00:30Z", + summary: "it broke", + }, + { + __typename: "StatusContext", + context: "deco/deploy", + state: "SUCCESS", + targetUrl: "https://envs-x--a1b2.decocdn.com", + description: "deployed", + }, + ]), + ); + expect(runs).toEqual([ + { + id: "42", + name: "Deco / QA", + state: "completed", + conclusion: "failure", + url: "https://github.com/acme/site/runs/42", + durationMs: 30_000, + summary: "it broke", + }, + { + id: null, + name: "deco/deploy", + state: "completed", + conclusion: "success", + url: "https://envs-x--a1b2.decocdn.com", + durationMs: null, + summary: "deployed", + }, + ]); + }); + + /** + * GraphQL carries two conclusions REST has no word for. `STARTUP_FAILURE` + * is a failure by any reading; `STALE` is a run superseded before it + * concluded, which is informational — neither may leak through unhandled. + */ + it("maps the conclusions REST's vocabulary lacks", () => { + const runs = mapChecks( + rollup([ + { + __typename: "CheckRun", + status: "COMPLETED", + conclusion: "STARTUP_FAILURE", + }, + { __typename: "CheckRun", status: "COMPLETED", conclusion: "STALE" }, + { __typename: "CheckRun", status: "IN_PROGRESS" }, + ]), + ); + expect(runs.map((r) => r.conclusion)).toEqual(["failure", "neutral", null]); + expect(runs.map((r) => r.state)).toEqual([ + "completed", + "completed", + "running", + ]); + }); + + it("is empty with no rollup at all", () => { + expect(mapChecks({})).toEqual([]); + }); +}); + +describe("mapDetail", () => { + const base: RawGraphqlChangeRequest = { + number: 9, + title: "t", + body: "b", + state: "OPEN", + url: "https://github.com/acme/site/pull/9", + baseRefName: "main", + headRefName: "feat/x", + headRefOid: "abc1234", + author: { login: "someone" }, + mergeable: "MERGEABLE", + }; + + /** + * `reviewBlocked` and `unresolvedConversations` are the point of the + * detailed read: REST exposes neither, so the panel used to infer "blocked + * on a human" from `mergeable_state` and count every review comment ever + * left as an open conversation. + */ + it("reports a missing approval as blocked on a person", () => { + expect( + mapDetail({ ...base, reviewDecision: "REVIEW_REQUIRED" }, REPO), + ).toMatchObject({ reviewBlocked: true }); + expect( + mapDetail({ ...base, reviewDecision: "CHANGES_REQUESTED" }, REPO), + ).toMatchObject({ reviewBlocked: true }); + expect( + mapDetail({ ...base, reviewDecision: "APPROVED" }, REPO), + ).toMatchObject({ reviewBlocked: false }); + }); + + it("counts only the review threads GitHub reports as unresolved", () => { + expect( + mapDetail( + { + ...base, + reviewThreads: { + nodes: [ + { isResolved: false }, + { isResolved: true }, + { isResolved: false }, + ], + }, + }, + REPO, + ).unresolvedConversations, + ).toBe(2); + }); + + it("states mergeability directly — there is no mergeable_state here", () => { + expect( + mapDetail({ ...base, mergeable: "CONFLICTING" }, REPO).conflicting, + ).toBe(true); + expect( + mapDetail({ ...base, mergeable: "UNKNOWN" }, REPO).conflicting, + ).toBeNull(); + expect( + mapDetail({ ...base, state: "CLOSED", mergeable: "UNKNOWN" }, REPO) + .conflicting, + ).toBe(false); + }); + + it("splits merged out of closed, which GraphQL reports separately", () => { + expect( + mapDetail({ ...base, state: "CLOSED", merged: true }, REPO).state, + ).toBe("merged"); + expect(mapDetail({ ...base, state: "CLOSED" }, REPO).state).toBe("closed"); + }); + + /** A sticky comment's edit time is what ranks it — see `previewUrlFromComments`. */ + it("carries a comment's edit time, falling back to its creation", () => { + const [edited, never] = mapDetail( + { + ...base, + comments: { + nodes: [ + { + databaseId: 1, + body: "a", + createdAt: "2026-08-01T00:00:00Z", + updatedAt: "2026-08-06T00:00:00Z", + }, + { databaseId: 2, body: "b", createdAt: "2026-08-03T00:00:00Z" }, + ], + }, + }, + REPO, + ).comments; + expect(edited?.updatedAt).toBe("2026-08-06T00:00:00Z"); + expect(never?.updatedAt).toBe("2026-08-03T00:00:00Z"); + }); + + it("falls back to a built URL when GraphQL reports none", () => { + expect(mapDetail({ ...base, url: null }, REPO).url).toBe( + "https://github.com/acme/site/pull/9", + ); + }); +}); + +describe("pickMostRecentlyMerged", () => { + /** + * The page is ordered by `updatedAt`, and the two diverge whenever an older + * merged pull request was touched (a comment, a label) more recently than a + * newer merge landed — so position cannot be trusted. + */ + it("takes the max mergedAt, not the first node", () => { + expect( + pickMostRecentlyMerged([ + { number: 1, mergedAt: "2026-08-01T00:00:00Z" }, + { number: 2, mergedAt: "2026-09-01T00:00:00Z" }, + { number: 3, mergedAt: "2026-07-01T00:00:00Z" }, + ])?.number, + ).toBe(2); + }); + + it("ignores nodes with no usable merge time", () => { + expect( + pickMostRecentlyMerged([{ number: 1 }, { number: 2, mergedAt: "nope" }]), + ).toBeNull(); + expect(pickMostRecentlyMerged([])).toBeNull(); + expect(pickMostRecentlyMerged(null)).toBeNull(); + }); +}); + +describe("isMergeMethodNotAllowed", () => { + /** The refusal a repository gives when it forbids the method just tried. */ + it("is true for the forbidden-method refusal, for each method", () => { + for (const detail of [ + "GitHub change_request_merge failed: 405 Merge commits are not allowed on this repository.", + "405 Squash merges are not allowed on this repository", + "405 Rebase merges are not allowed on this repository", + ]) { + expect(isMergeMethodNotAllowed(detail)).toBe(true); + } + }); + + /** A conflict is also a 405, but no other method fixes it — must not advance. */ + it("is false for a 405 that is not about the method", () => { + expect(isMergeMethodNotAllowed("405 Pull Request is not mergeable")).toBe( + false, + ); + expect(isMergeMethodNotAllowed("405 Method Not Allowed")).toBe(false); + }); + + it("is false for every other refusal", () => { + expect(isMergeMethodNotAllowed("409 Merge conflict")).toBe(false); + expect( + isMergeMethodNotAllowed( + "422 At least 1 approving review is required by reviewers with write access", + ), + ).toBe(false); + expect(isMergeMethodNotAllowed("")).toBe(false); + }); +}); diff --git a/apps/api/src/git-providers/github/change-requests.ts b/apps/api/src/git-providers/github/change-requests.ts new file mode 100644 index 0000000000..b37aa54b86 --- /dev/null +++ b/apps/api/src/git-providers/github/change-requests.ts @@ -0,0 +1,832 @@ +/** + * `ChangeRequestClient` over GitHub — REST for the single reads and the + * writes, GraphQL for the detailed one. + * + * The split is not stylistic. A review surface needs the change request, its + * checks, its review decision and its comments at ONE instant, and REST cannot + * do that in fewer than four calls whose answers describe four different + * moments; GraphQL does it in one, against a quota (points/hour) that REST is + * not competing for. Everything else is a single REST call and stays there. + * + * Ported from `tools/task-board/prs-get.ts` (which reached GitHub through the + * `mcp-github` MCP server) and `tools/github/pr-state.ts`. The mapping rules + * those two had earned — a `mergeable_state` read conservatively, GraphQL's + * two extra check conclusions, picking the newest MERGE rather than the newest + * update — are kept verbatim; only the transport and the vocabulary changed. + */ + +import { + changeRequestUrl, + type RepoRef, + splitOwnerName, +} from "@decocms/shared/git-providers"; +import { githubGraphqlRequest } from "./graphql"; +import { + githubApiBaseUrl, + githubFailure, + githubFetch, + githubJson, +} from "./http"; +import { GitProviderError, type TokenSource } from "../types"; +import { + type ChangeRequest, + type ChangeRequestClient, + type ChangeRequestDetail, + ChangeRequestExists, + type CheckConclusion, + type CheckRun, + type CheckState, + type ChecksSummary, + type MergeOutcome, + type MergeParams, + type MergeRefusal, + type MergeStrategy, + type OpenChangeRequestParams, + summarizeChecks, +} from "../change-requests"; + +/** + * Merge methods to try in order, stopping at the first that succeeds. `merge` + * is first because it is GitHub's own default, so keeping it first changes + * nothing for every repository that allows a merge commit. The fallback is the + * whole point: a repository with "Allow merge commits" off answers `405 Merge + * commits are not allowed on this repository`, which used to strand a card in + * review forever, retried against the same refusal every sweep. + */ +const MERGE_LADDER: Record = { + any: ["merge", "squash", "rebase"], + squash: ["squash"], +}; + +/** Newest deployments inspected when looking for a published URL. */ +const DEPLOYMENTS_SCANNED = 3; + +export interface RawPullRequest { + number?: number | null; + html_url?: string | null; + title?: string | null; + body?: string | null; + state?: string | null; + draft?: boolean | null; + merged?: boolean | null; + merged_at?: string | null; + mergeable?: boolean | null; + mergeable_state?: string | null; + changed_files?: number | null; + base?: { ref?: string | null } | null; + head?: { + ref?: string | null; + sha?: string | null; + repo?: { full_name?: string | null } | null; + } | null; + user?: { login?: string | null } | null; +} + +/** + * Whether the change request still applies to its base. Pure — unit-tested; + * the single home for the polarity, so no two callers can disagree. + * + * `mergeable_state` is the field that actually arrives from some sources: + * github-mcp's `MinimalPullRequest` has no `mergeable` at all, so reading only + * the boolean yielded `null` for every change request ever and the conflict + * auto-resolution it gates never fired once in production. The boolean is + * still read first — a full REST payload carries it and it is the richer + * signal. + * + * Of the `mergeable_state` values only `dirty` means conflicts; `unknown`/`""` + * is GitHub still computing, and the rest (`blocked`, `behind`, `unstable`) + * are for the checks and review gates to judge, not this. + */ +export function conflictFromPullRequest( + pr: Pick | null, +): boolean | null { + if (!pr) return null; + if (pr.state !== "open") return false; + if (typeof pr.mergeable === "boolean") return !pr.mergeable; + const state = pr.mergeable_state; + if (typeof state !== "string" || state === "" || state === "unknown") { + return null; + } + return state === "dirty"; +} + +/** + * GitHub's `mergeable_state`, read as a checks summary. Pure — unit-tested. + * + * It exists so a sweep can tell whether head's checks are green without paying + * for a detailed read: `mergeable_state` rides along on the single read it + * already does, and that per-card multiplier is what held the App's rate limit + * shut for 17 hours once. + * + * Only the two unambiguous values are mapped. `blocked` is deliberately NOT + * `pending`: it also covers a missing required review, which says nothing + * about CI, and reading it as a red check would hold a reviewer back on a + * change request whose deploy is perfectly fine. + */ +export function checksFromMergeableState(state: unknown): ChecksSummary { + if (state === "clean") return "passing"; + if (state === "unstable") return "failing"; + return null; +} + +/** Map a REST pull request payload to the neutral shape. Pure — unit-tested. */ +export function mapPullRequest(pr: RawPullRequest): ChangeRequest { + const merged = pr.merged === true || typeof pr.merged_at === "string"; + return { + number: pr.number ?? 0, + url: pr.html_url ?? "", + title: pr.title ?? "", + body: pr.body ?? "", + state: merged ? "merged" : pr.state === "closed" ? "closed" : "open", + draft: pr.draft === true, + mergedAt: pr.merged_at ?? null, + base: pr.base?.ref ?? "main", + head: pr.head?.ref ?? "", + headSha: pr.head?.sha ?? "", + headRepoPath: pr.head?.repo?.full_name ?? null, + author: pr.user?.login ?? "", + conflicting: conflictFromPullRequest(pr), + checks: checksFromMergeableState(pr.mergeable_state), + changedFiles: + typeof pr.changed_files === "number" ? pr.changed_files : null, + }; +} + +/** GraphQL's status enum is wider than the three states a reader is shown. */ +function mapCheckState(raw: string | null | undefined): CheckState { + if (raw === "IN_PROGRESS") return "running"; + if (raw === "COMPLETED") return "completed"; + return "queued"; +} + +/** + * GraphQL carries two conclusions REST's vocabulary has no word for. + * `STARTUP_FAILURE` is a failure by any reading; `STALE` is a run superseded + * before it concluded, which is informational — neither may leak through as an + * unhandled string. + */ +function mapCheckConclusion( + raw: string | null | undefined, +): CheckConclusion | null { + switch (raw) { + case "SUCCESS": + return "success"; + case "FAILURE": + case "STARTUP_FAILURE": + return "failure"; + case "NEUTRAL": + case "STALE": + return "neutral"; + case "CANCELLED": + return "cancelled"; + case "SKIPPED": + return "skipped"; + case "TIMED_OUT": + return "timed_out"; + case "ACTION_REQUIRED": + return "action_required"; + default: + return null; + } +} + +/** + * The fields the detailed read needs beyond {@link ChangeRequest}. Written as + * a fragment so the by-number and by-branch queries cannot drift. + */ +const DETAIL_FRAGMENT = ` +fragment CrDetail on PullRequest { + number + title + body + state + merged + mergedAt + isDraft + mergeable + reviewDecision + changedFiles + url + baseRefName + headRefName + headRefOid + headRepository { nameWithOwner } + author { login } + reviewThreads(first: 100) { nodes { isResolved } } + comments(last: 50) { + nodes { databaseId author { login } body createdAt updatedAt url } + } + commits(last: 1) { + nodes { + commit { + statusCheckRollup { + contexts(first: 100) { + nodes { + __typename + ... on CheckRun { + databaseId + name + status + conclusion + detailsUrl + startedAt + completedAt + summary + } + ... on StatusContext { + context + state + targetUrl + description + } + } + } + } + } + } + } +}`; + +/** + * Matched by `headRefName`, not by walking `repository.ref(...)`: a ref deleted + * after its merge makes the ref walk answer null, losing a merged change + * request the panel still shows. + */ +const BY_BRANCH_QUERY = ` +query CrByBranch($owner: String!, $repo: String!, $branch: String!) { + repository(owner: $owner, name: $repo) { + pullRequests( + headRefName: $branch + first: 1 + orderBy: { field: UPDATED_AT, direction: DESC } + ) { nodes { ...CrDetail } } + } +}${DETAIL_FRAGMENT}`; + +const BY_NUMBER_QUERY = ` +query CrByNumber($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { ...CrDetail } + } +}${DETAIL_FRAGMENT}`; + +/** + * `states: MERGED` is why this is exact. REST can only filter `state: closed`, + * which interleaves change requests closed WITHOUT merging, so it takes a page + * to answer and still reports "never published" for a base whose whole page + * was abandoned. + * + * `PullRequestOrder` has no `MERGED_AT` field, only `UPDATED_AT` — and a + * comment on an OLDER merged one bumps its `updatedAt` past a more recently + * merged one, so the top node is not reliably the last publish. A window of 20 + * is pulled and {@link pickMostRecentlyMerged} picks the max `mergedAt` within + * it rather than trusting position. + */ +const LAST_MERGED_QUERY = ` +query CrLastMerged($owner: String!, $repo: String!, $base: String!) { + repository(owner: $owner, name: $repo) { + pullRequests( + states: MERGED + baseRefName: $base + first: 20 + orderBy: { field: UPDATED_AT, direction: DESC } + ) { + nodes { + number title body mergedAt url + baseRefName headRefName headRefOid + author { login } + } + } + } +}`; + +interface RawGraphqlCheck { + __typename?: string | null; + databaseId?: number | null; + name?: string | null; + status?: string | null; + conclusion?: string | null; + detailsUrl?: string | null; + startedAt?: string | null; + completedAt?: string | null; + summary?: string | null; + context?: string | null; + state?: string | null; + targetUrl?: string | null; + description?: string | null; +} + +export interface RawGraphqlChangeRequest { + number?: number | null; + title?: string | null; + body?: string | null; + state?: string | null; + merged?: boolean | null; + mergedAt?: string | null; + isDraft?: boolean | null; + mergeable?: string | null; + reviewDecision?: string | null; + changedFiles?: number | null; + url?: string | null; + baseRefName?: string | null; + headRefName?: string | null; + headRefOid?: string | null; + headRepository?: { nameWithOwner?: string | null } | null; + author?: { login?: string | null } | null; + reviewThreads?: { + nodes?: Array<{ isResolved?: boolean | null } | null> | null; + } | null; + comments?: { + nodes?: Array<{ + databaseId?: number | null; + author?: { login?: string | null } | null; + body?: string | null; + createdAt?: string | null; + updatedAt?: string | null; + url?: string | null; + } | null> | null; + } | null; + commits?: { + nodes?: Array<{ + commit?: { + statusCheckRollup?: { + contexts?: { nodes?: Array | null } | null; + } | null; + } | null; + } | null> | null; + } | null; +} + +/** A legacy commit status, mapped to the same two fields a run has. */ +function mapStatusContext(node: RawGraphqlCheck): CheckRun { + const state = node.state; + return { + id: null, + name: node.context ?? "", + state: + state === "PENDING" || state === "EXPECTED" ? "running" : "completed", + conclusion: + state === "SUCCESS" + ? "success" + : state === "FAILURE" || state === "ERROR" + ? "failure" + : state === "PENDING" || state === "EXPECTED" + ? null + : "neutral", + url: node.targetUrl ?? null, + durationMs: null, + summary: node.description ?? null, + }; +} + +/** + * The rollup carries both check runs and legacy commit statuses, and a + * repository can post to either — a deco site whose combined status is empty + * but whose "Deco / QA" check run failed, and a Cloudflare deploy that is only + * ever a status. Both are runs here, which is what collapsed the two separate + * status/check-runs reads the MCP path made into one. + */ +export function mapChecks(pr: RawGraphqlChangeRequest): CheckRun[] { + const contexts = + pr.commits?.nodes?.[0]?.commit?.statusCheckRollup?.contexts?.nodes ?? []; + const runs: CheckRun[] = []; + for (const node of contexts) { + if (!node) continue; + if (node.__typename === "StatusContext") { + runs.push(mapStatusContext(node)); + continue; + } + if (node.__typename !== "CheckRun") continue; + const startedAt = node.startedAt; + const completedAt = node.completedAt; + runs.push({ + id: node.databaseId == null ? null : String(node.databaseId), + name: node.name ?? "", + state: mapCheckState(node.status), + conclusion: mapCheckConclusion(node.conclusion), + url: node.detailsUrl ?? null, + durationMs: + startedAt && completedAt + ? new Date(completedAt).getTime() - new Date(startedAt).getTime() + : null, + summary: node.summary ?? null, + }); + } + return runs; +} + +/** Fold a GraphQL node into the detailed shape. Pure — unit-tested. */ +export function mapDetail( + pr: RawGraphqlChangeRequest, + repo: RepoRef, +): ChangeRequestDetail { + const unresolvedConversations = (pr.reviewThreads?.nodes ?? []).filter( + (thread) => thread?.isResolved === false, + ).length; + const decision = pr.reviewDecision; + const reviewBlocked = + decision === "REVIEW_REQUIRED" || decision === "CHANGES_REQUESTED"; + const checkRuns = mapChecks(pr); + const number = pr.number ?? 0; + const merged = pr.merged === true; + return { + number, + url: pr.url ?? changeRequestUrl(repo, number), + title: pr.title ?? "", + body: pr.body ?? "", + state: merged ? "merged" : pr.state === "OPEN" ? "open" : "closed", + draft: pr.isDraft === true, + mergedAt: pr.mergedAt ?? null, + base: pr.baseRefName ?? "main", + head: pr.headRefName ?? "", + headSha: pr.headRefOid ?? "", + headRepoPath: pr.headRepository?.nameWithOwner ?? null, + author: pr.author?.login ?? "", + // GraphQL states mergeability directly; there is no `mergeable_state` here. + conflicting: + pr.state !== "OPEN" + ? false + : pr.mergeable === "CONFLICTING" + ? true + : pr.mergeable === "MERGEABLE" + ? false + : null, + checks: summarizeChecks(checkRuns), + changedFiles: pr.changedFiles ?? null, + checkRuns, + comments: (pr.comments?.nodes ?? []).flatMap((comment) => + comment + ? [ + { + id: String(comment.databaseId ?? 0), + author: comment.author?.login ?? "", + body: comment.body ?? "", + createdAt: comment.createdAt ?? "", + updatedAt: comment.updatedAt ?? comment.createdAt ?? "", + url: comment.url ?? "", + }, + ] + : [], + ), + unresolvedConversations, + reviewBlocked, + }; +} + +interface RawMergedNode { + number?: number | null; + title?: string | null; + body?: string | null; + mergedAt?: string | null; + url?: string | null; + baseRefName?: string | null; + headRefName?: string | null; + headRefOid?: string | null; + author?: { login?: string | null } | null; +} + +/** + * Pick the actually-most-recently-merged node from a page ordered by + * `updatedAt` — the two diverge whenever an older merged change request was + * touched (a comment, a label) more recently than a newer merge landed. + */ +export function pickMostRecentlyMerged( + nodes: Array | null | undefined, +): RawMergedNode | null { + let best: RawMergedNode | null = null; + let bestMergedAt = -Infinity; + for (const node of nodes ?? []) { + if (!node?.mergedAt) continue; + const mergedAt = Date.parse(node.mergedAt); + if (Number.isNaN(mergedAt) || mergedAt <= bestMergedAt) continue; + best = node; + bestMergedAt = mergedAt; + } + return best; +} + +/** + * True when a refusal is GitHub rejecting THIS merge method — the one refusal + * a different method can fix, so the ladder advances on it. Every other + * refusal (branch protection, a required review, a conflict) is + * method-independent. Pure — unit-tested. + */ +export function isMergeMethodNotAllowed(message: string): boolean { + return /not allowed on this repository/i.test(message); +} + +export class GithubChangeRequestClient implements ChangeRequestClient { + readonly repo: RepoRef; + private readonly tokenSource: TokenSource; + private readonly apiBaseUrl: string; + private readonly owner: string; + private readonly name: string; + + constructor(params: { repo: RepoRef; tokenSource: TokenSource }) { + this.repo = params.repo; + this.tokenSource = params.tokenSource; + this.apiBaseUrl = githubApiBaseUrl(params.repo.host); + const { owner, name } = splitOwnerName(params.repo); + this.owner = owner; + this.name = name; + } + + private async token(force?: boolean): Promise { + const issued = await this.tokenSource.get( + force ? { forceRefresh: true } : undefined, + ); + return issued?.token ?? null; + } + + private async requireToken(): Promise { + const token = await this.token(); + if (token) return token; + throw new GitProviderError({ + provider: "github", + status: 401, + message: `No usable GitHub token for ${this.repo.path}; reconnect the account`, + }); + } + + private get base(): string { + return `${this.apiBaseUrl}/repos/${this.owner}/${this.name}`; + } + + /** One REST call. 404 answers null; every other non-2xx throws. */ + private async rest( + path: string, + init: { + method?: "GET" | "POST" | "PATCH" | "PUT"; + body?: unknown; + operation: string; + }, + ): Promise { + const res = await githubFetch(`${this.base}${path}`, { + method: init.method, + body: init.body, + token: await this.requireToken(), + operation: init.operation, + }); + if (res.status === 404) { + await res.body?.cancel().catch(() => {}); + return null; + } + if (!res.ok) throw await githubFailure(res, init.operation); + if (res.status === 204) return null; + return githubJson(res, init.operation); + } + + private graphql(args: { + query: string; + variables: Record; + label: string; + operation: string; + }): Promise { + return githubGraphqlRequest({ + getToken: (force) => this.token(force), + missingTokenMessage: `No usable GitHub token for ${this.repo.path}; reconnect the account`, + ...args, + }); + } + + async read(number: number): Promise { + const pr = await this.rest(`/pulls/${number}`, { + operation: "change_request_read", + }); + return pr ? mapPullRequest(pr) : null; + } + + async readForBranch(branch: string): Promise { + return await this.readDetailed({ branch }); + } + + async readDetailed( + target: { number: number } | { branch: string }, + ): Promise { + const byNumber = "number" in target; + const payload = await this.graphql<{ + repository?: { + pullRequest?: RawGraphqlChangeRequest | null; + pullRequests?: { + nodes?: Array | null; + } | null; + } | null; + }>({ + query: byNumber ? BY_NUMBER_QUERY : BY_BRANCH_QUERY, + variables: byNumber + ? { owner: this.owner, repo: this.name, number: target.number } + : { owner: this.owner, repo: this.name, branch: target.branch }, + label: `change request for ${this.repo.path}`, + operation: "change_request_detail", + }); + const repository = payload.repository; + if (!repository) { + throw new GitProviderError({ + provider: "github", + status: 404, + message: `${this.repo.path} is not accessible with this credential`, + }); + } + const node = byNumber + ? repository.pullRequest + : repository.pullRequests?.nodes?.[0]; + return node ? mapDetail(node, this.repo) : null; + } + + async listOpen(limit: number): Promise { + const rows = await this.rest( + `/pulls?state=open&sort=updated&direction=desc&per_page=${Math.min(limit, 100)}`, + { operation: "change_request_list" }, + ); + return (rows ?? []).map(mapPullRequest); + } + + async lastMergedInto(base: string): Promise { + const payload = await this.graphql<{ + repository?: { + pullRequests?: { nodes?: Array | null } | null; + } | null; + }>({ + query: LAST_MERGED_QUERY, + variables: { owner: this.owner, repo: this.name, base }, + label: `last merged into ${base} on ${this.repo.path}`, + operation: "change_request_last_merged", + }); + if (!payload.repository) { + throw new GitProviderError({ + provider: "github", + status: 404, + message: `${this.repo.path} is not accessible with this credential`, + }); + } + const node = pickMostRecentlyMerged(payload.repository.pullRequests?.nodes); + if (!node) return null; + const number = node.number ?? 0; + return { + number, + url: node.url ?? changeRequestUrl(this.repo, number), + title: node.title ?? "", + body: node.body ?? "", + state: "merged", + draft: false, + mergedAt: node.mergedAt ?? null, + base: node.baseRefName ?? base, + head: node.headRefName ?? "", + headSha: node.headRefOid ?? "", + headRepoPath: null, + author: node.author?.login ?? "", + conflicting: false, + checks: null, + changedFiles: null, + }; + } + + async open(params: OpenChangeRequestParams): Promise { + const res = await githubFetch(`${this.base}/pulls`, { + method: "POST", + token: await this.requireToken(), + operation: "change_request_open", + body: { + title: params.title, + body: params.body || undefined, + head: params.head, + base: params.base, + }, + }); + if (res.ok) { + return mapPullRequest( + await githubJson(res, "change_request_open"), + ); + } + const failure = await githubFailure(res, "change_request_open"); + if (/already exists|pull request already/i.test(failure.message)) { + throw new ChangeRequestExists( + failure.message, + await this.readForBranch(params.head).catch(() => null), + ); + } + throw failure; + } + + async describe(number: number, body: string): Promise { + await this.rest(`/pulls/${number}`, { + method: "PATCH", + body: { body }, + operation: "change_request_describe", + }); + } + + async merge(number: number, params: MergeParams = {}): Promise { + const methods = MERGE_LADDER[params.strategy ?? "any"]; + let lastRefusal = ""; + for (const method of methods) { + const attempt = await this.attemptMerge(number, method, params); + if (attempt.merged) return attempt; + // The repository forbids THIS method — remember it and try the next. + if ( + attempt.reason === "blocked" && + isMergeMethodNotAllowed(attempt.detail) + ) { + lastRefusal = attempt.detail; + continue; + } + return attempt; + } + return { merged: false, reason: "blocked", detail: lastRefusal }; + } + + /** + * One merge round-trip, classified. A refusal costs one extra read to ask + * whether the branch conflicts, because GitHub answers `405 Pull Request is + * not mergeable` for a conflict and for a policy block alike — and the two + * lead to completely different reactions (hand it back to the author to + * rebase, versus hand it to a person). The read is only ever paid on the + * refusal path, never on a rate limit. + */ + private async attemptMerge( + number: number, + method: string, + params: MergeParams, + ): Promise { + let res: Response; + try { + res = await githubFetch(`${this.base}/pulls/${number}/merge`, { + method: "PUT", + token: await this.requireToken(), + operation: "change_request_merge", + body: { + merge_method: method, + ...(params.commitTitle ? { commit_title: params.commitTitle } : {}), + ...(params.commitMessage + ? { commit_message: params.commitMessage } + : {}), + }, + }); + } catch (cause) { + return { merged: false, ...classifyThrown(cause) }; + } + if (res.ok) { + await res.body?.cancel().catch(() => {}); + return { merged: true }; + } + const failure = await githubFailure(res, "change_request_merge"); + if (res.status === 404) { + return { merged: false, reason: "not_found", detail: failure.message }; + } + if (isMergeMethodNotAllowed(failure.message)) { + return { merged: false, reason: "blocked", detail: failure.message }; + } + const conflicting = await this.read(number) + .then((cr) => cr?.conflicting ?? null) + .catch(() => null); + return { + merged: false, + reason: conflicting === true ? "conflict" : "blocked", + detail: failure.message, + }; + } + + async readCheckLog(checkId: string): Promise { + const run = await this.rest<{ + output?: { summary?: string | null; text?: string | null } | null; + }>(`/check-runs/${encodeURIComponent(checkId)}`, { + operation: "change_request_check_log", + }); + return run?.output?.summary ?? run?.output?.text ?? null; + } + + async readDeployedUrl(sha: string): Promise { + const deployments = await this.rest>( + `/deployments?sha=${encodeURIComponent(sha)}&per_page=${DEPLOYMENTS_SCANNED}`, + { operation: "change_request_deployments" }, + ); + for (const deployment of deployments ?? []) { + if (typeof deployment.id !== "number") continue; + const statuses = await this.rest< + Array<{ state?: string; environment_url?: string | null }> + >(`/deployments/${deployment.id}/statuses?per_page=10`, { + operation: "change_request_deployment_statuses", + }); + const published = (statuses ?? []).find( + (status) => + status.state === "success" && + typeof status.environment_url === "string" && + status.environment_url.length > 0, + ); + if (published?.environment_url) return published.environment_url; + } + return null; + } +} + +/** A thrown transport/rate failure, as a merge outcome. */ +function classifyThrown(cause: unknown): { + reason: MergeRefusal; + detail: string; +} { + const detail = cause instanceof Error ? cause.message : String(cause); + if (cause instanceof GitProviderError && cause.isRateLimited) { + return { reason: "rate_limited", detail }; + } + return { reason: "error", detail }; +} diff --git a/apps/api/src/git-providers/content/github.test.ts b/apps/api/src/git-providers/github/content.test.ts similarity index 99% rename from apps/api/src/git-providers/content/github.test.ts rename to apps/api/src/git-providers/github/content.test.ts index e039630baa..830e9a0354 100644 --- a/apps/api/src/git-providers/content/github.test.ts +++ b/apps/api/src/git-providers/github/content.test.ts @@ -4,8 +4,8 @@ import { mapGithubPull, resolveEntriesAtPaths, treeWriteEntries, -} from "./github"; -import type { TreeEntry } from "./types"; +} from "./content"; +import type { TreeEntry } from "../content"; /** An in-memory repo tree, keyed by directory path ("" for root). */ function fakeOps(dirs: Record) { diff --git a/apps/api/src/git-providers/content/github.ts b/apps/api/src/git-providers/github/content.ts similarity index 89% rename from apps/api/src/git-providers/content/github.ts rename to apps/api/src/git-providers/github/content.ts index aa10c5f12a..f4d2018384 100644 --- a/apps/api/src/git-providers/content/github.ts +++ b/apps/api/src/git-providers/github/content.ts @@ -12,25 +12,24 @@ * local stub — tests must never reach api.github.com. */ -import { - apiBaseUrlFor, - type RepoRef, - splitOwnerName, -} from "@decocms/shared/git-providers"; +import { type RepoRef, splitOwnerName } from "@decocms/shared/git-providers"; import { countGithubRateLimited, githubRetryAfterMs, isGithubRateLimited, recordGithubRateLimit, } from "@/observability/github-rate-limit"; +import { githubGraphqlRequest } from "./graphql"; +import { githubApiBaseUrl } from "./http"; import type { TokenSource } from "../types"; import { + type BranchPage, type ChangeRequestInfo, type FileChange, type RepoContentClient, RepoWriteConflict, type TreeEntry, -} from "./types"; +} from "../content"; /** * A tree entry with the `mode` GitHub reports for it. The neutral `TreeEntry` @@ -45,12 +44,6 @@ const DEFAULT_TIMEOUT_MS = 15_000; /** A whole-repo archive is a download, not a REST call — it needs room to stream. */ const ARCHIVE_TIMEOUT_MS = 60_000; -/** e2e seam: set GITHUB_API_BASE_URL to a local stub. Read per call site so a - * long-lived process (dev server) and the test webServer agree on one value. */ -function githubApiBaseUrl(host: string): string { - return process.env.GITHUB_API_BASE_URL ?? apiBaseUrlFor("github", host); -} - /** * `default_branch` per repo, cached across requests — a client instance lives * for ONE of them (see `content/index.ts`), so its own memo never survives. @@ -139,6 +132,34 @@ function etagCachePut(url: string, etag: string, body: unknown): void { } } +/** + * Note `orderBy` is deliberately ALPHABETICAL — see `searchBranches` for why + * asking GitHub for recency here would be a lie. + */ +const BRANCH_SEARCH_QUERY = ` +query BranchSearch($owner: String!, $repo: String!, $query: String, $limit: Int!, $after: String) { + repository(owner: $owner, name: $repo) { + refs( + refPrefix: "refs/heads/" + query: $query + first: $limit + after: $after + orderBy: { field: ALPHABETICAL, direction: ASC } + ) { + totalCount + pageInfo { hasNextPage endCursor } + nodes { + name + target { + ... on Commit { + author { user { login } } + } + } + } + } + } +}`; + class GitHubApiError extends Error { constructor( readonly status: number, @@ -323,6 +344,7 @@ export class GithubContentClient implements RepoContentClient { private readonly apiBaseUrl: string; private readonly repoBase: string; private readonly owner: string; + private readonly name: string; private defaultBranch: string | null = null; /** commit sha -> its tree sha. A commit is immutable, and one request can * ask for the same commit's tree several times (compare + read + write). */ @@ -338,6 +360,7 @@ export class GithubContentClient implements RepoContentClient { this.apiBaseUrl = githubApiBaseUrl(options.repo.host); const { owner, name } = splitOwnerName(options.repo); this.owner = owner; + this.name = name; this.repoBase = `/repos/${owner}/${name}`; } @@ -524,6 +547,80 @@ export class GithubContentClient implements RepoContentClient { }; } + /** + * GraphQL, not REST: `GET /repos/:o/:r/branches` takes no search, filter or + * sort parameter at all, so a picker on it can only page 100 at a time and + * grep locally. `repository.refs(query:)` filters by a case-insensitive + * substring of the ref name in one round trip ("upstream" finds + * `claude/fastpreview-upstream-authority`), which is what github.com's own + * branch dropdown uses, and reports the true `totalCount`. + */ + async searchBranches(params: { + query: string; + limit: number; + cursor?: string | null; + }): Promise { + const payload = await githubGraphqlRequest<{ + repository?: { + refs?: { + totalCount?: number; + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null }; + nodes?: Array<{ + name?: string | null; + target?: { + author?: { user?: { login?: string | null } | null } | null; + } | null; + } | null> | null; + } | null; + } | null; + }>({ + getToken: async (force) => + (await this.tokenSource.get(force ? { forceRefresh: true } : undefined)) + ?.token ?? null, + missingTokenMessage: `No usable GitHub token for ${this.repo.path}; reconnect the account`, + query: BRANCH_SEARCH_QUERY, + variables: { + owner: this.owner, + repo: this.name, + query: params.query, + limit: params.limit, + after: params.cursor ?? null, + }, + label: `branch search on ${this.repo.path}`, + operation: "branch_search", + }); + const repository = payload.repository; + if (!repository) { + throw new GitHubApiError( + 404, + "GET", + this.repoBase, + `${this.repo.path} not found or not accessible by this credential`, + ); + } + /** + * Every level is optional: a commit authored by an address with no GitHub + * account has `author.user === null`, as does a ref whose target does not + * resolve to a Commit. + */ + const branches = (repository.refs?.nodes ?? []).flatMap((node) => + typeof node?.name === "string" + ? [ + { + name: node.name, + author: node.target?.author?.user?.login ?? null, + }, + ] + : [], + ); + const pageInfo = repository.refs?.pageInfo; + return { + branches, + totalCount: repository.refs?.totalCount ?? branches.length, + nextCursor: pageInfo?.hasNextPage ? (pageInfo.endCursor ?? null) : null, + }; + } + /** The branch head, as a hard requirement — a missing branch is a 404. */ private async requireBranchSha(branch: string): Promise { const head = await this.getBranch(branch); diff --git a/apps/api/src/git-providers/github/env.ts b/apps/api/src/git-providers/github/env.ts new file mode 100644 index 0000000000..71edcedde3 --- /dev/null +++ b/apps/api/src/git-providers/github/env.ts @@ -0,0 +1,36 @@ +/** + * Deployment config for Studio's GitHub App. + * + * All optional. With nothing set, the GitHub half of the git-providers routes + * answers 503 and the legacy `mcp-github` paths keep working — the feature is + * dormant until the operator registers the App (see `deploy/` and `selfhost/` + * docs). Read through this helper only; tools never touch `process.env`. + */ + +export interface GithubAppConfig { + appId: string; + /** PEM. `\n` escape sequences are accepted for single-line env values. */ + privateKeyPem: string; + clientId: string; + clientSecret: string; + /** App slug, for `https://github.com/apps//installations/new`. */ + slug: string; +} + +export function readGithubAppConfig( + env: Record = process.env, +): GithubAppConfig | null { + const appId = env.GITHUB_APP_ID?.trim(); + const privateKey = env.GITHUB_APP_PRIVATE_KEY?.trim(); + const clientId = env.GITHUB_APP_CLIENT_ID?.trim(); + const clientSecret = env.GITHUB_APP_CLIENT_SECRET?.trim(); + const slug = env.GITHUB_APP_SLUG?.trim(); + if (!appId || !privateKey || !clientId || !clientSecret || !slug) return null; + return { + appId, + privateKeyPem: privateKey.replace(/\\n/g, "\n"), + clientId, + clientSecret, + slug, + }; +} diff --git a/apps/api/src/tools/github/graphql.test.ts b/apps/api/src/git-providers/github/graphql.test.ts similarity index 98% rename from apps/api/src/tools/github/graphql.test.ts rename to apps/api/src/git-providers/github/graphql.test.ts index 077a430149..d40e53bee2 100644 --- a/apps/api/src/tools/github/graphql.test.ts +++ b/apps/api/src/git-providers/github/graphql.test.ts @@ -7,7 +7,7 @@ import { unwrapRetryError, } from "./graphql"; -const LABEL = "branch search for acme/site"; +const LABEL = "change request for acme/site"; describe("parseGraphqlBody", () => { it("parses a well-formed JSON body", () => { diff --git a/apps/api/src/tools/github/graphql.ts b/apps/api/src/git-providers/github/graphql.ts similarity index 62% rename from apps/api/src/tools/github/graphql.ts rename to apps/api/src/git-providers/github/graphql.ts index e3933ebcf5..acc58909db 100644 --- a/apps/api/src/tools/github/graphql.ts +++ b/apps/api/src/git-providers/github/graphql.ts @@ -1,21 +1,20 @@ /** - * Shared GitHub GraphQL transport for the app-only GitHub tools. Every one of - * them needs the same five things and gets them exactly once here: the - * org-ownership guard on the connection, the minted token, the single 401 - * refresh-and-retry, the not-JSON-despite-200 guard, and the - * errors-inside-a-200 guard that plain `res.ok` misses. + * GitHub GraphQL transport, over a token rather than a connection. + * + * Every caller needs the same four things and gets them exactly once here: the + * single 401 refresh-and-retry, the retry on a 5xx, the not-JSON-despite-200 + * guard, and the errors-inside-a-200 guard that plain `res.ok` misses. * * GraphQL is metered against a budget SEPARATE from REST's (points/hour vs - * requests/hour) — moving a read here spends a quota REST is not competing for. + * requests/hour), so a read moved here spends a quota REST is not competing + * for. That is the only reason this exists next to `http.ts`. + * + * Nothing here decides WHICH token to send: callers pass a getter, which is + * what lets one transport serve both a git provider account and a legacy + * connection. */ import { retry, RetryError } from "@decocms/shared/std"; -import type { StudioContext } from "@/core/studio-context"; -import { - githubConnectionAccessToken, - isGithubConnection, -} from "@/oauth/github-mint"; -import { RECONNECT_ERROR } from "@/oauth/token-refresh"; import { countGithubRateLimited, githubRetryAfterMs, @@ -29,7 +28,7 @@ function githubGraphqlUrl(): string { return process.env.GITHUB_GRAPHQL_URL ?? "https://api.github.com/graphql"; } -/** Matches the Git Data client's per-attempt timeout. */ +/** Matches the REST client's per-attempt timeout. */ const GITHUB_TIMEOUT_MS = 15_000; /** A GitHub-side outage, not a real answer — worth retrying, unlike a 4xx. */ @@ -86,60 +85,30 @@ export function unwrapGraphqlData( return payload.data; } -/** - * Resolve a GitHub connection the caller's org owns. A connection id is - * caller-supplied, so it is re-read scoped to the authenticated org before its - * credential is used — no cross-org token reads (as GITHUB_LIST_USER_ORGS does). - */ -async function resolveGithubConnection( - ctx: StudioContext, - connectionId: string, -) { - const organizationId = ctx.organization?.id; - if (!organizationId) { - throw new Error("Organization context required"); - } - const connection = await ctx.storage.connections.findById( - connectionId, - organizationId, - ); - if (!connection) { - throw new Error("Connection not found"); - } - if (!isGithubConnection(connection)) { - throw new Error("Connection is not a GitHub connection"); - } - return connection; +export interface GithubGraphqlArgs { + /** Returns a bearer token, or null when the credential is gone. `force` + * re-mints past any freshness check — used for the one 401 retry. */ + getToken: (force?: boolean) => Promise; + /** Thrown when `getToken` answers null. Callers word this for their surface. */ + missingTokenMessage: string; + query: string; + variables: Record; + /** Names the operation in every error message, interpolating owner/repo. */ + label: string; + /** + * The metrics tag, kept separate from `label`: every call site's `label` + * interpolates caller-supplied owner/repo/branch, and feeding that straight + * into an OTel attribute would mint one time series per repo ever queried. + */ + operation: string; } -/** - * POST one GraphQL operation on behalf of a connection and return its `data`. - * - * `label` names the operation in every error message — it is what tells a - * reader whether "not accessible" came from a branch search or a PR read. - * - * `operation` is the metrics tag, kept separate from `label`: every call site's - * `label` interpolates the caller-supplied owner/repo/branch, and feeding that - * straight into an OTel attribute would mint one time series per repo/branch - * ever queried. `operation` is a fixed, small-cardinality name instead - * ("pr_state", not "pull request state for acme/site@my-branch"). - */ -export async function githubGraphql( - ctx: StudioContext, - args: { - connectionId: string; - query: string; - variables: Record; - label: string; - operation: string; - }, +/** POST one GraphQL operation and return its `data`. */ +export async function githubGraphqlRequest( + args: GithubGraphqlArgs, ): Promise { - const connection = await resolveGithubConnection(ctx, args.connectionId); - - const accessToken = await githubConnectionAccessToken(ctx, connection); - if (!accessToken) { - throw new Error(RECONNECT_ERROR); - } + const accessToken = await args.getToken(); + if (!accessToken) throw new Error(args.missingTokenMessage); const post = (token: string) => fetch(githubGraphqlUrl(), { @@ -183,16 +152,10 @@ export async function githubGraphql( } catch { /* ignore */ } - const refreshed = await githubConnectionAccessToken(ctx, connection, { - forceRefresh: true, - }); - if (!refreshed) { - throw new Error(RECONNECT_ERROR); - } + const refreshed = await args.getToken(true); + if (!refreshed) throw new Error(args.missingTokenMessage); res = await postWithRetry(refreshed); - if (res.status === 401) { - throw new Error(RECONNECT_ERROR); - } + if (res.status === 401) throw new Error(args.missingTokenMessage); } recordGithubRateLimit(res.headers, { diff --git a/apps/api/src/git-providers/github/http.ts b/apps/api/src/git-providers/github/http.ts index 0c2c2a0338..9dab97a551 100644 --- a/apps/api/src/git-providers/github/http.ts +++ b/apps/api/src/git-providers/github/http.ts @@ -8,6 +8,7 @@ * presents an installation or user token. */ +import { apiBaseUrlFor } from "@decocms/shared/git-providers"; import { countGithubRateLimited, githubRetryAfterMs, @@ -21,8 +22,19 @@ const GITHUB_JSON_ACCEPT = "application/vnd.github+json"; /** Matches the other GitHub REST callers (`tools/github/list-user-orgs.ts`). */ export const GITHUB_TIMEOUT_MS = 15_000; +/** + * REST base for a GitHub host. `GITHUB_API_BASE_URL` overrides it so the e2e + * suite can point every GitHub caller at a local stub — read per call site so + * a long-lived dev server and a test webServer agree on one value, and never + * reaching api.github.com from a test is a property of the whole module, not + * of one client. + */ +export function githubApiBaseUrl(host: string): string { + return process.env.GITHUB_API_BASE_URL ?? apiBaseUrlFor("github", host); +} + export interface GithubFetchInit { - method?: "GET" | "POST"; + method?: "GET" | "POST" | "PATCH" | "PUT"; /** Sent as `Authorization: Bearer ` — App JWTs and tokens alike. */ token: string; accept?: string; diff --git a/apps/api/src/tools/task-board/pick-github-connection.test.ts b/apps/api/src/git-providers/github/legacy-connection.test.ts similarity index 97% rename from apps/api/src/tools/task-board/pick-github-connection.test.ts rename to apps/api/src/git-providers/github/legacy-connection.test.ts index 5216c22e78..088bdd0c70 100644 --- a/apps/api/src/tools/task-board/pick-github-connection.test.ts +++ b/apps/api/src/git-providers/github/legacy-connection.test.ts @@ -9,7 +9,7 @@ * the honest answer, and the caller logs it. */ import { describe, expect, it } from "bun:test"; -import { pickGithubConnection } from "./prs-get"; +import { pickGithubConnection } from "./legacy-connection"; const scoped = (id: string, owner: string, repo: string) => ({ id, diff --git a/apps/api/src/git-providers/github/legacy-connection.ts b/apps/api/src/git-providers/github/legacy-connection.ts new file mode 100644 index 0000000000..018c8538d9 --- /dev/null +++ b/apps/api/src/git-providers/github/legacy-connection.ts @@ -0,0 +1,197 @@ +/** + * Reaching a repository the OLD way: through an `mcp-github` connection, for + * orgs whose repositories have no `repositories` row yet. + * + * This is the only module left that knows a connection can stand for a + * repository — both for resolving a credential and for listing what an agent + * may clone. It is deliberately quarantined here rather than spread across the + * task board and the repo picker: every caller above now speaks `RepoRef`, so + * when the last org is migrated this file is the whole deletion. + */ + +import { + getRepoScope, + isOrgSharedConnection, +} from "@decocms/shared/github-repo-scope"; +import { + repoRefFromOwnerName, + splitOwnerName, + type RepoRef, +} from "@decocms/shared/git-providers"; +import type { StudioContext } from "@/core/studio-context"; +import { isGithubConnection } from "@/oauth/github-mint"; +import type { ConnectionEntity } from "@/tools/connection/schema"; +import type { RepositoryRecord } from "@/storage/repositories"; +import type { GitProviderStoragePorts } from "../credentials"; + +/** + * Pick the connection that can reach `repo`, pure so the fallback ladder is + * unit-testable — the rule that a repo-scoped connection for a DIFFERENT + * repository is never a substitute is the whole point of this function and is + * invisible from any integration test that does not happen to have two scoped + * connections lying around. + * + * Prefer, in order: a repo-scoped connection matching THIS repository + * (guaranteed access), then the broad org-level connection (no `repoScope`, + * user OAuth over every repository). + * + * There is deliberately NO "any active one" last resort when a repository is + * named. A connection scoped to a DIFFERENT repository cannot reach it — its + * installation token is repo-scoped — so returning it buys nothing and costs + * everything: the caller cannot tell "GitHub said no" from "we asked the wrong + * GitHub", the live state comes back all-null, and the card silently parks in + * review forever. This is not hypothetical: deleting the org's connection for + * the repository its change requests were opened against stranded 40+ approved + * cards, because the resolver kept handing back a connection for an unrelated + * repository. Returning null instead makes the miss loud and points at the + * real fix: connect the repository. + */ +export function pickGithubConnection< + T extends { metadata?: Record | null }, +>(active: T[], repo?: { owner: string; name: string }): T | null { + const broad = active.find((c) => getRepoScope(c) === null) ?? null; + if (!repo) return broad ?? active[0] ?? null; + const matching = active.find((c) => { + const scope = getRepoScope(c); + return scope?.owner === repo.owner && scope?.repo === repo.name; + }); + return matching ?? broad; +} + +/** + * The legacy connection to reach `repo` through: the one recorded on the row + * when there is one, else the org's best `mcp-github` connection for it. + * + * GitLab never had a legacy path — its repositories only ever existed as + * `repositories` rows — so a non-GitHub ref answers null immediately rather + * than borrowing a GitHub installation that cannot see it. + */ +export async function resolveLegacyGithubConnection( + ctx: StudioContext, + organizationId: string, + repo: RepoRef, + connectionId: string | null, +): Promise { + if (repo.provider !== "github") return null; + if (connectionId) { + /** + * Org-scope the lookup: this connection's GitHub installation is used to + * MERGE change requests, so never resolve one from another org (defense in + * depth against a foreign or colliding connection id reaching a write). + * + * And check the slug: a recorded id is caller-supplied data, and the token + * it resolves goes into an `Authorization` header to github.com. Anything + * that is not an `mcp-github` connection cannot legitimately be there, and + * sending an unrelated integration's decrypted token to GitHub is the one + * mistake this path must not make. + */ + const conn = await ctx.storage.connections.findById( + connectionId, + organizationId, + ); + if (conn && conn.status === "active" && isGithubConnection(conn)) { + return conn; + } + } + const { items } = await ctx.storage.connections.list(organizationId, { + slug: "mcp-github", + }); + return pickGithubConnection( + items.filter((c) => c.status === "active"), + splitOwnerName(repo), + ); +} + +/** + * One repository reachable only through a legacy connection, in the neutral + * shape {@link listLegacyRepoChoices}' caller merges. `ref` rather than an + * owner/name pair so the merge never has to rebuild a `github.com` URL. + */ +export interface LegacyRepoChoice { + connectionId: string; + ref: RepoRef; + installationId: number; +} + +/** The connection shape the legacy selection needs. */ +interface RepoConnection { + id: string; + status: string; + metadata: Record | null; +} + +/** + * The org-shared connections first, everything else after, order otherwise + * preserved. + * + * Importing ONE repo routinely leaves two loadable connections behind — the + * org-shared one and a per-agent one — and the merge keeps whichever it sees + * first. The per-agent child is disposable (torn down with its agent), so an + * org-shared sibling is the credential a run should be given; this is what + * makes that the one that survives the dedup instead of whatever order storage + * happened to return. Pure, and exported for its test. + */ +export function orgSharedFirst( + connections: T[], +): T[] { + return [ + ...connections.filter(isOrgSharedConnection), + ...connections.filter((c) => !isOrgSharedConnection(c)), + ]; +} + +/** The repo-scoped connections an org can still clone through. */ +function legacyRepoChoices( + connections: T[], +): LegacyRepoChoice[] { + const out: LegacyRepoChoice[] = []; + for (const connection of orgSharedFirst(connections)) { + if (connection.status !== "active") continue; + const scope = getRepoScope(connection); + if (!scope) continue; + out.push({ + connectionId: connection.id, + ref: repoRefFromOwnerName(scope.owner, scope.repo), + installationId: scope.installationId, + }); + } + return out; +} + +/** {@link legacyRepoChoices} over the org's `mcp-github` connections. */ +export async function listLegacyRepoChoices( + ctx: StudioContext, + organizationId: string, +): Promise { + const { items } = await ctx.storage.connections.list(organizationId, { + slug: "mcp-github", + }); + return legacyRepoChoices(items); +} + +/** + * The repository row a legacy `metadata.githubRepo` binding refers to, if the + * org has one — by explicit `repositoryId` when the binding carries it, else + * by identity. Null keeps the caller on the legacy path. + * + * The identity fallback assumes `github.com`, which is why it lives here: a + * binding written before repositories existed could only ever have been a + * github.com repo. + */ +export async function findRepositoryForLegacyBinding( + storage: Pick, + organizationId: string, + binding: { owner: string; name: string; repositoryId?: string | null }, +): Promise { + if (binding.repositoryId) { + const byId = await storage.repositories.get( + binding.repositoryId, + organizationId, + ); + if (byId) return byId; + } + return storage.repositories.findByRef( + organizationId, + repoRefFromOwnerName(binding.owner, binding.name), + ); +} diff --git a/apps/api/src/git-providers/gitlab/change-requests.test.ts b/apps/api/src/git-providers/gitlab/change-requests.test.ts new file mode 100644 index 0000000000..d66c77f063 --- /dev/null +++ b/apps/api/src/git-providers/gitlab/change-requests.test.ts @@ -0,0 +1,323 @@ +/** + * GitLab's vocabulary, mapped into the neutral one. Pure — the round-trips + * themselves are e2e (and were driven against gitlab.com by hand). + * + * The cases that matter are where GitLab says something GitHub has no word + * for: `detailed_merge_status`, a pipeline status standing in for a whole + * check list, a note that is really an activity entry, and per-note + * resolution. + */ +import { describe, expect, it } from "bun:test"; +import { + checksFromPipelineStatus, + conflictFromMergeRequest, + countUnresolved, + isConflictRefusal, + mapJob, + mapJobStatus, + mapNotes, + mapState, + parseChangesCount, +} from "./change-requests"; + +describe("mapState", () => { + it("maps GitLab's four lifecycle values", () => { + expect(mapState("opened")).toBe("open"); + expect(mapState("closed")).toBe("closed"); + expect(mapState("merged")).toBe("merged"); + }); + + /** `locked` is a transient state of an OPEN merge request being merged. */ + it("reads locked as still open", () => { + expect(mapState("locked")).toBe("open"); + }); + + it("reads anything unexpected as open, never as finished", () => { + expect(mapState(undefined)).toBe("open"); + }); +}); + +describe("conflictFromMergeRequest", () => { + it("takes has_conflicts when GitLab has set it", () => { + expect( + conflictFromMergeRequest({ state: "opened", has_conflicts: true }), + ).toBe(true); + expect( + conflictFromMergeRequest({ + state: "opened", + has_conflicts: false, + merge_status: "cannot_be_merged", + }), + ).toBe(false); + }); + + /** + * `detailed_merge_status` names the blocker precisely, and only one of its + * values is a conflict — the others (a red pipeline, a missing approval, an + * open discussion) are for the checks and review gates to judge. + */ + it("reads detailed_merge_status, and only its conflict values as conflicts", () => { + expect( + conflictFromMergeRequest({ + state: "opened", + detailed_merge_status: "conflict", + }), + ).toBe(true); + expect( + conflictFromMergeRequest({ + state: "opened", + detailed_merge_status: "mergeable", + }), + ).toBe(false); + for (const detailed of ["not_approved", "ci_must_pass", "draft_status"]) { + expect( + conflictFromMergeRequest({ + state: "opened", + detailed_merge_status: detailed, + }), + ).toBeNull(); + } + }); + + it("falls back to the older merge_status", () => { + expect( + conflictFromMergeRequest({ + state: "opened", + merge_status: "can_be_merged", + }), + ).toBe(false); + expect( + conflictFromMergeRequest({ + state: "opened", + merge_status: "cannot_be_merged", + }), + ).toBe(true); + }); + + /** An unknown must never read as a conflict — both providers compute it late. */ + it("is null while GitLab is still checking", () => { + expect( + conflictFromMergeRequest({ state: "opened", merge_status: "checking" }), + ).toBeNull(); + expect( + conflictFromMergeRequest({ state: "opened", merge_status: "unchecked" }), + ).toBeNull(); + expect(conflictFromMergeRequest({ state: "opened" })).toBeNull(); + expect(conflictFromMergeRequest(null)).toBeNull(); + }); + + it("treats a merged or closed merge request as not conflicting", () => { + expect( + conflictFromMergeRequest({ state: "merged", has_conflicts: true }), + ).toBe(false); + expect(conflictFromMergeRequest({ state: "closed" })).toBe(false); + }); +}); + +describe("checksFromPipelineStatus", () => { + /** + * GitLab answers a richer CI signal than GitHub for free: `head_pipeline` + * rides along on the single merge-request read, where GitHub's cheap read + * only has the conservative `mergeable_state`. + */ + it("maps a finished pipeline", () => { + expect(checksFromPipelineStatus("success")).toBe("passing"); + expect(checksFromPipelineStatus("failed")).toBe("failing"); + }); + + /** A run that did not finish is not evidence the head is good. */ + it("reads a cancellation as red, not as nothing", () => { + expect(checksFromPipelineStatus("canceled")).toBe("failing"); + }); + + it("maps every in-flight status to pending", () => { + for (const status of [ + "created", + "waiting_for_resource", + "preparing", + "pending", + "running", + "scheduled", + ]) { + expect(checksFromPipelineStatus(status)).toBe("pending"); + } + }); + + it("is null for the statuses that say nothing at all", () => { + expect(checksFromPipelineStatus("skipped")).toBeNull(); + expect(checksFromPipelineStatus("manual")).toBeNull(); + expect(checksFromPipelineStatus(undefined)).toBeNull(); + }); +}); + +describe("mapJobStatus", () => { + it("splits a job status into the state and conclusion pair", () => { + expect(mapJobStatus("success")).toEqual({ + state: "completed", + conclusion: "success", + }); + expect(mapJobStatus("failed")).toEqual({ + state: "completed", + conclusion: "failure", + }); + expect(mapJobStatus("running")).toEqual({ + state: "running", + conclusion: null, + }); + expect(mapJobStatus("pending")).toEqual({ + state: "queued", + conclusion: null, + }); + }); + + /** A manual job is waiting on a person, which is what action_required means. */ + it("reads a manual job as needing someone to act", () => { + expect(mapJobStatus("manual").conclusion).toBe("action_required"); + }); +}); + +describe("mapJob", () => { + it("reads a job into a run, timing it from its own stamps", () => { + expect( + mapJob({ + id: 55, + name: "build", + status: "success", + web_url: "https://gitlab.com/acme/site/-/jobs/55", + started_at: "2026-09-01T00:00:00Z", + finished_at: "2026-09-01T00:00:30Z", + }), + ).toEqual({ + id: "55", + name: "build", + state: "completed", + conclusion: "success", + url: "https://gitlab.com/acme/site/-/jobs/55", + durationMs: 30_000, + // A job's report is its trace, far too big to carry in a listing. + summary: null, + }); + }); + + it("has no duration for a job that has not finished", () => { + expect( + mapJob({ id: 1, status: "running", started_at: "2026-09-01T00:00:00Z" }) + .durationMs, + ).toBeNull(); + }); +}); + +describe("mapNotes", () => { + const url = "https://gitlab.com/acme/site/-/merge_requests/7"; + + /** + * GitLab records its own activity ("changed the description", "assigned + * to") as notes with `system: true`. They are not comments, and counting + * them as such is what would make a bot's preview link compete with "added + * 1 commit". + */ + it("drops GitLab's own activity entries", () => { + expect( + mapNotes( + [ + { id: 1, body: "added 1 commit", system: true }, + { id: 2, body: "looks good", system: false }, + ], + url, + ), + ).toEqual([ + { + id: "2", + author: "", + body: "looks good", + createdAt: "", + updatedAt: "", + url: `${url}#note_2`, + }, + ]); + }); + + it("carries the edit time, falling back to the creation time", () => { + const [edited, never] = mapNotes( + [ + { + id: 1, + body: "a", + created_at: "2026-08-01T00:00:00Z", + updated_at: "2026-08-06T00:00:00Z", + }, + { id: 2, body: "b", created_at: "2026-08-03T00:00:00Z" }, + ], + url, + ); + expect(edited?.updatedAt).toBe("2026-08-06T00:00:00Z"); + expect(never?.updatedAt).toBe("2026-08-03T00:00:00Z"); + }); +}); + +describe("countUnresolved", () => { + /** + * GitLab marks resolution per NOTE, so a thread is the unit only after + * folding them: one unresolved note leaves the whole discussion open. + */ + it("counts a discussion with any unresolved resolvable note", () => { + expect( + countUnresolved([ + { notes: [{ resolvable: true, resolved: true }] }, + { + notes: [ + { resolvable: true, resolved: true }, + { resolvable: true, resolved: false }, + ], + }, + ]), + ).toBe(1); + }); + + /** A plain comment is not resolvable, so it is not an open conversation. */ + it("ignores discussions with nothing resolvable in them", () => { + expect( + countUnresolved([{ notes: [{ resolvable: false }] }, { notes: [] }, {}]), + ).toBe(0); + expect(countUnresolved([])).toBe(0); + }); +}); + +describe("parseChangesCount", () => { + it("reads GitLab's string count", () => { + expect(parseChangesCount("3")).toBe(3); + expect(parseChangesCount(3)).toBe(3); + }); + + /** GitLab reports "1000+" past its counting limit — the number still helps. */ + it("reads the capped count as its lower bound", () => { + expect(parseChangesCount("1000+")).toBe(1000); + }); + + it("is null when there is no count", () => { + expect(parseChangesCount(undefined)).toBeNull(); + expect(parseChangesCount(null)).toBeNull(); + expect(parseChangesCount("many")).toBeNull(); + }); +}); + +describe("isConflictRefusal", () => { + /** GitLab is explicit where GitHub is not, which is why 406 needs no re-read. */ + it("is true for the status that means the branch does not apply", () => { + expect(isConflictRefusal(406, "Branch cannot be merged")).toBe(true); + }); + + it("is true when the message names a conflict whatever the status", () => { + expect(isConflictRefusal(405, "merge conflict detected")).toBe(true); + expect(isConflictRefusal(409, "cannot be merged")).toBe(true); + }); + + /** + * The bare 405 is the one that needs a re-read: it covers a draft, an + * unresolved discussion and a red required pipeline alike. + */ + it("is false for a refusal that says nothing about the branch", () => { + expect(isConflictRefusal(405, "Method Not Allowed")).toBe(false); + expect(isConflictRefusal(401, "Unauthorized")).toBe(false); + }); +}); diff --git a/apps/api/src/git-providers/gitlab/change-requests.ts b/apps/api/src/git-providers/gitlab/change-requests.ts new file mode 100644 index 0000000000..99dfdd4df1 --- /dev/null +++ b/apps/api/src/git-providers/gitlab/change-requests.ts @@ -0,0 +1,565 @@ +/** + * `ChangeRequestClient` over GitLab's REST v4 API. + * + * A merge request is the same object a pull request is, so the read side maps + * almost field-for-field. The two places GitLab needs more work than GitHub + * are the detailed read — GitLab has no GraphQL-shaped "everything at once", + * so the notes, the discussions and the pipeline's jobs are three more calls — + * and the merge refusal, whose meaning has to be recovered from the merge + * request's own state because GitLab's 405 body says only "Method Not + * Allowed". + * + * GitLab answers a richer CI signal than GitHub for free: `head_pipeline` + * rides along on the single merge-request read, so the cheap `checks` here is + * the real pipeline status rather than the conservative guess GitHub's + * `mergeable_state` forces. + */ + +import { changeRequestUrl, type RepoRef } from "@decocms/shared/git-providers"; +import { encodeProjectPath } from "./client"; +import { gitlabApiBaseUrl, gitlabFailure, gitlabFetch } from "./http"; +import { GitProviderError, type TokenSource } from "../types"; +import { + type ChangeRequest, + type ChangeRequestClient, + type ChangeRequestComment, + type ChangeRequestDetail, + ChangeRequestExists, + type CheckConclusion, + type CheckRun, + type CheckState, + type ChecksSummary, + type MergeOutcome, + type MergeParams, + type MergeRefusal, + type OpenChangeRequestParams, + summarizeChecks, +} from "../change-requests"; + +/** One page is the whole answer here — the same bound GitHub's rollup uses. */ +const PAGE_SIZE = 100; +const COMMENT_PAGE_SIZE = 50; +/** Newest deployments scanned when looking for a published environment URL. */ +const DEPLOYMENTS_SCANNED = 30; +/** + * A job trace is the whole build log and can be megabytes. Only its tail says + * why the job failed, which is what a reader expanding the row is after. + */ +const TRACE_TAIL_BYTES = 8_000; + +export interface RawMergeRequest { + iid?: number | null; + web_url?: string | null; + title?: string | null; + description?: string | null; + state?: string | null; + draft?: boolean | null; + work_in_progress?: boolean | null; + merged_at?: string | null; + target_branch?: string | null; + source_branch?: string | null; + sha?: string | null; + project_id?: number | null; + source_project_id?: number | null; + author?: { username?: string | null } | null; + has_conflicts?: boolean | null; + merge_status?: string | null; + detailed_merge_status?: string | null; + changes_count?: string | number | null; + blocking_discussions_resolved?: boolean | null; + head_pipeline?: { id?: number | null; status?: string | null } | null; + pipeline?: { id?: number | null; status?: string | null } | null; +} + +/** GitLab's four lifecycle values, in the neutral vocabulary. */ +export function mapState(state: unknown): ChangeRequest["state"] { + if (state === "merged") return "merged"; + if (state === "closed") return "closed"; + // `locked` is a transient state of an OPEN merge request being merged. + return "open"; +} + +/** + * Whether the merge request still applies to its target. Pure — unit-tested. + * + * `has_conflicts` is authoritative when GitLab has set it. Otherwise + * `detailed_merge_status` (GitLab 15.6+) names the blocker precisely, and only + * one of its values is a conflict; `merge_status` is the older, coarser field + * kept as the last resort. `checking`/`unchecked` mean GitLab has not worked + * it out yet — and an unknown must never read as a conflict. + */ +export function conflictFromMergeRequest( + mr: Pick< + RawMergeRequest, + "state" | "has_conflicts" | "merge_status" | "detailed_merge_status" + > | null, +): boolean | null { + if (!mr) return null; + if (mapState(mr.state) !== "open") return false; + if (typeof mr.has_conflicts === "boolean") return mr.has_conflicts; + const detailed = mr.detailed_merge_status; + if (detailed === "conflict" || detailed === "broken_status") return true; + if (detailed === "mergeable") return false; + if (mr.merge_status === "can_be_merged") return false; + if (mr.merge_status === "cannot_be_merged") return true; + return null; +} + +/** + * A pipeline status, as a checks summary. Pure — unit-tested. + * + * `canceled` reads as failing on purpose, matching the check-run conclusions + * the GitHub side treats as red: a run that did not finish is not evidence the + * head is good. `skipped` and `manual` say nothing at all. + */ +export function checksFromPipelineStatus(status: unknown): ChecksSummary { + switch (status) { + case "success": + return "passing"; + case "failed": + case "canceled": + return "failing"; + case "created": + case "waiting_for_resource": + case "preparing": + case "pending": + case "running": + case "scheduled": + return "pending"; + default: + return null; + } +} + +/** GitLab's job status, split into the state/conclusion pair a reader sees. */ +export function mapJobStatus(status: unknown): { + state: CheckState; + conclusion: CheckConclusion | null; +} { + switch (status) { + case "success": + return { state: "completed", conclusion: "success" }; + case "failed": + return { state: "completed", conclusion: "failure" }; + case "canceled": + return { state: "completed", conclusion: "cancelled" }; + case "skipped": + return { state: "completed", conclusion: "skipped" }; + case "manual": + return { state: "completed", conclusion: "action_required" }; + case "running": + return { state: "running", conclusion: null }; + default: + return { state: "queued", conclusion: null }; + } +} + +/** `changes_count` is a string, and "1000+" past GitLab's counting limit. */ +export function parseChangesCount(value: unknown): number | null { + if (typeof value === "number") return Number.isFinite(value) ? value : null; + if (typeof value !== "string") return null; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : null; +} + +interface RawJob { + id?: number | null; + name?: string | null; + status?: string | null; + web_url?: string | null; + started_at?: string | null; + finished_at?: string | null; + stage?: string | null; +} + +export function mapJob(job: RawJob): CheckRun { + const { state, conclusion } = mapJobStatus(job.status); + const started = job.started_at ? Date.parse(job.started_at) : NaN; + const finished = job.finished_at ? Date.parse(job.finished_at) : NaN; + return { + id: job.id == null ? null : String(job.id), + name: job.name ?? "", + state, + conclusion, + url: job.web_url ?? null, + durationMs: + Number.isFinite(started) && Number.isFinite(finished) + ? finished - started + : null, + // A job's report is its trace, which is far too big to carry in a listing. + summary: null, + }; +} + +interface RawNote { + id?: number | null; + body?: string | null; + created_at?: string | null; + updated_at?: string | null; + system?: boolean | null; + author?: { username?: string | null } | null; +} + +/** + * GitLab records its own activity ("changed the description", "assigned to") + * as notes with `system: true`. They are not comments, and counting them as + * such is what would make a bot's preview link compete with "added 1 commit". + */ +export function mapNotes( + notes: RawNote[], + crUrl: string, +): ChangeRequestComment[] { + return notes + .filter((note) => note.system !== true && typeof note.body === "string") + .map((note) => ({ + id: String(note.id ?? 0), + author: note.author?.username ?? "", + body: note.body ?? "", + createdAt: note.created_at ?? "", + updatedAt: note.updated_at ?? note.created_at ?? "", + url: note.id == null ? crUrl : `${crUrl}#note_${note.id}`, + })); +} + +interface RawDiscussion { + notes?: Array<{ resolvable?: boolean | null; resolved?: boolean | null }>; +} + +/** + * A discussion is unresolved when it CAN be resolved and no note in it has + * been. GitLab marks resolution per note, so the thread is the unit only after + * folding them. + */ +export function countUnresolved(discussions: RawDiscussion[]): number { + let count = 0; + for (const discussion of discussions) { + const notes = discussion.notes ?? []; + const resolvable = notes.filter((note) => note.resolvable === true); + if (resolvable.length === 0) continue; + if (resolvable.some((note) => note.resolved !== true)) count += 1; + } + return count; +} + +/** + * True when a merge refusal is GitLab saying the branch does not apply. Pure — + * unit-tested. GitLab is explicit here where GitHub is not, which is why the + * GitLab side can classify without a second read in the common case. + */ +export function isConflictRefusal(status: number, message: string): boolean { + if (status === 406) return true; + return /conflict|cannot be merged/i.test(message); +} + +export class GitlabChangeRequestClient implements ChangeRequestClient { + readonly repo: RepoRef; + private readonly tokenSource: TokenSource; + private readonly apiBase: string; + private readonly projectBase: string; + + constructor(params: { repo: RepoRef; tokenSource: TokenSource }) { + this.repo = params.repo; + this.tokenSource = params.tokenSource; + this.apiBase = gitlabApiBaseUrl(params.repo.host); + this.projectBase = `/projects/${encodeProjectPath(params.repo.path)}`; + } + + private async token(): Promise { + const issued = await this.tokenSource.get(); + if (issued) return issued.token; + throw new GitProviderError({ + provider: "gitlab", + status: 401, + message: `No usable GitLab token for ${this.repo.path}; reconnect the account`, + }); + } + + /** One REST call under the project. 404 answers null; other non-2xx throws. */ + private async call( + pathAndQuery: string, + init: { + method?: "GET" | "POST" | "PUT" | "DELETE"; + body?: unknown; + accept?: string; + } = {}, + ): Promise { + const res = await gitlabFetch( + `${this.apiBase}${this.projectBase}${pathAndQuery}`, + await this.token(), + init, + ); + if (res.status === 404) { + await res.body?.cancel().catch(() => {}); + return null; + } + if (!res.ok) throw await gitlabFailure(res); + return res; + } + + private async json( + pathAndQuery: string, + init?: { method?: "GET" | "POST" | "PUT" | "DELETE"; body?: unknown }, + ): Promise { + const res = await this.call(pathAndQuery, init); + return res === null ? null : ((await res.json()) as T); + } + + private mrPath(iid: number): string { + return `/merge_requests/${iid}`; + } + + private map(mr: RawMergeRequest): ChangeRequest { + const iid = mr.iid ?? 0; + const sameProject = + mr.source_project_id == null || + mr.project_id == null || + mr.source_project_id === mr.project_id; + return { + number: iid, + url: mr.web_url ?? changeRequestUrl(this.repo, iid), + title: mr.title ?? "", + body: mr.description ?? "", + state: mapState(mr.state), + draft: mr.draft === true || mr.work_in_progress === true, + mergedAt: mr.merged_at ?? null, + base: mr.target_branch ?? "main", + head: mr.source_branch ?? "", + headSha: mr.sha ?? "", + // A fork's path is not on the payload; null already means "not local". + headRepoPath: sameProject ? this.repo.path : null, + author: mr.author?.username ?? "", + conflicting: conflictFromMergeRequest(mr), + checks: checksFromPipelineStatus( + (mr.head_pipeline ?? mr.pipeline)?.status, + ), + changedFiles: parseChangesCount(mr.changes_count), + }; + } + + async read(number: number): Promise { + const mr = await this.json(this.mrPath(number)); + return mr ? this.map(mr) : null; + } + + async readForBranch(branch: string): Promise { + const rows = await this.json( + `/merge_requests?source_branch=${encodeURIComponent(branch)}` + + `&state=all&order_by=updated_at&sort=desc&per_page=1`, + ); + const newest = rows?.[0]; + return newest ? this.map(newest) : null; + } + + async readDetailed( + target: { number: number } | { branch: string }, + ): Promise { + /** + * The by-branch path costs one extra call: the merge-request LISTING + * carries no `head_pipeline`, so the number it resolves is then read on + * its own. Reading the merge request itself is what makes the CI half of + * this answer possible at all. + */ + const iid = + "number" in target + ? target.number + : ((await this.readForBranch(target.branch))?.number ?? null); + if (iid === null) return null; + const mr = await this.json(this.mrPath(iid)); + if (!mr) return null; + const base = this.map(mr); + const pipelineId = (mr.head_pipeline ?? mr.pipeline)?.id ?? null; + + const [notes, discussions, jobs] = await Promise.all([ + this.json( + `${this.mrPath(iid)}/notes` + + `?sort=desc&order_by=created_at&per_page=${COMMENT_PAGE_SIZE}`, + ), + this.json( + `${this.mrPath(iid)}/discussions?per_page=${PAGE_SIZE}`, + ), + pipelineId === null + ? Promise.resolve(null) + : this.json( + `/pipelines/${pipelineId}/jobs?per_page=${PAGE_SIZE}`, + ), + ]); + + const checkRuns = (jobs ?? []).map(mapJob); + const unresolvedConversations = countUnresolved(discussions ?? []); + return { + ...base, + /** + * The per-job reduction wins over the pipeline's own status when there + * are jobs: they are the same fact at different resolutions, and + * agreeing with the GitHub side's reducer matters more than agreeing + * with GitLab's rollup. + */ + checks: checkRuns.length > 0 ? summarizeChecks(checkRuns) : base.checks, + checkRuns, + comments: mapNotes(notes ?? [], base.url).reverse(), + unresolvedConversations, + /** + * GitLab reports what is left to do rather than a review decision. + * `not_approved` is the approval rule (a paid feature, absent on many + * instances); an unresolved blocking discussion is the free equivalent + * of "someone asked for changes". + */ + reviewBlocked: + mr.detailed_merge_status === "not_approved" || + mr.blocking_discussions_resolved === false, + }; + } + + async listOpen(limit: number): Promise { + const rows = await this.json( + `/merge_requests?state=opened&order_by=updated_at&sort=desc` + + `&per_page=${Math.min(limit, PAGE_SIZE)}`, + ); + return (rows ?? []).map((mr) => this.map(mr)); + } + + async lastMergedInto(base: string): Promise { + const rows = await this.json( + `/merge_requests?state=merged&target_branch=${encodeURIComponent(base)}` + + `&order_by=updated_at&sort=desc&per_page=20`, + ); + // Same reason as the GitHub side: ordered by last touch, not by merge. + let best: RawMergeRequest | null = null; + let bestAt = -Infinity; + for (const mr of rows ?? []) { + const at = mr.merged_at ? Date.parse(mr.merged_at) : NaN; + if (Number.isNaN(at) || at <= bestAt) continue; + best = mr; + bestAt = at; + } + return best ? this.map(best) : null; + } + + async open(params: OpenChangeRequestParams): Promise { + const res = await gitlabFetch( + `${this.apiBase}${this.projectBase}/merge_requests`, + await this.token(), + { + method: "POST", + body: { + source_branch: params.head, + target_branch: params.base, + title: params.title, + description: params.body || undefined, + }, + }, + ); + if (res.ok) return this.map((await res.json()) as RawMergeRequest); + const failure = await gitlabFailure(res); + if (/already exists/i.test(failure.message)) { + throw new ChangeRequestExists( + failure.message, + await this.readForBranch(params.head).catch(() => null), + ); + } + throw failure; + } + + async describe(number: number, body: string): Promise { + await this.call(this.mrPath(number), { + method: "PUT", + body: { description: body }, + }); + } + + async merge(number: number, params: MergeParams = {}): Promise { + const squash = params.strategy === "squash"; + let res: Response; + try { + res = await gitlabFetch( + `${this.apiBase}${this.projectBase}${this.mrPath(number)}/merge`, + await this.token(), + { + method: "PUT", + body: { + squash, + ...(params.commitTitle || params.commitMessage + ? { + [squash ? "squash_commit_message" : "merge_commit_message"]: [ + params.commitTitle, + params.commitMessage, + ] + .filter(Boolean) + .join("\n\n"), + } + : {}), + }, + }, + ); + } catch (cause) { + const detail = cause instanceof Error ? cause.message : String(cause); + const rateLimited = + cause instanceof GitProviderError && cause.isRateLimited; + return { + merged: false, + reason: rateLimited ? "rate_limited" : "error", + detail, + }; + } + if (res.ok) { + await res.body?.cancel().catch(() => {}); + return { merged: true }; + } + const failure = await gitlabFailure(res); + return { + merged: false, + reason: await this.classifyRefusal(number, res.status, failure.message), + detail: failure.message, + }; + } + + /** + * GitLab's 405 body says only "Method Not Allowed" — it covers a draft, a + * conflict, an unresolved discussion and a red required pipeline alike. So + * when the status alone is not conclusive the merge request is re-read, and + * its own `has_conflicts`/`detailed_merge_status` answers the question. One + * extra call, only on the refusal path. + */ + private async classifyRefusal( + number: number, + status: number, + message: string, + ): Promise { + if (status === 404) return "not_found"; + if (status === 429) return "rate_limited"; + if (isConflictRefusal(status, message)) return "conflict"; + const conflicting = await this.read(number) + .then((cr) => cr?.conflicting ?? null) + .catch(() => null); + return conflicting === true ? "conflict" : "blocked"; + } + + async readCheckLog(checkId: string): Promise { + const res = await this.call(`/jobs/${encodeURIComponent(checkId)}/trace`, { + accept: "text/plain", + }); + if (res === null) return null; + const text = await res.text(); + if (!text) return null; + return text.length > TRACE_TAIL_BYTES + ? text.slice(text.length - TRACE_TAIL_BYTES) + : text; + } + + async readDeployedUrl(sha: string): Promise { + const deployments = await this.json< + Array<{ + sha?: string | null; + status?: string | null; + environment?: { external_url?: string | null } | null; + }> + >(`/deployments?order_by=id&sort=desc&per_page=${DEPLOYMENTS_SCANNED}`); + for (const deployment of deployments ?? []) { + if (deployment.sha !== sha) continue; + if (deployment.status !== "success") continue; + const url = deployment.environment?.external_url; + if (typeof url === "string" && url.length > 0) return url; + } + return null; + } +} diff --git a/apps/api/src/git-providers/content/gitlab.test.ts b/apps/api/src/git-providers/gitlab/content.test.ts similarity index 99% rename from apps/api/src/git-providers/content/gitlab.test.ts rename to apps/api/src/git-providers/gitlab/content.test.ts index 8212759a0d..decc5f3739 100644 --- a/apps/api/src/git-providers/content/gitlab.test.ts +++ b/apps/api/src/git-providers/gitlab/content.test.ts @@ -4,7 +4,6 @@ import { buildCommitActions, decoDirFor, directoryOf, - gitlabErrorMessage, groupPathsByDirectory, isBranchExistsConflict, isCommitConflict, @@ -13,7 +12,8 @@ import { mapMergeRequestState, pooledMap, type ResolvedChange, -} from "./gitlab"; +} from "./content"; +import { gitlabErrorMessage } from "./http"; describe("buildCommitActions", () => { test("an existing file is an update guarded by its last commit", () => { diff --git a/apps/api/src/git-providers/content/gitlab.ts b/apps/api/src/git-providers/gitlab/content.ts similarity index 94% rename from apps/api/src/git-providers/content/gitlab.ts rename to apps/api/src/git-providers/gitlab/content.ts index 2cbcbf41ef..e2aaa76d79 100644 --- a/apps/api/src/git-providers/content/gitlab.ts +++ b/apps/api/src/git-providers/gitlab/content.ts @@ -16,31 +16,29 @@ * (`resolveCopies`), merging without a merge request (`mergeBranches`), and * blob sizes in a tree listing (`listDecofileEntries`). * - * The request plumbing mirrors `gitlab/client.ts` (bearer auth, 15s timeout, - * 404 → null, every other non-2xx → `GitProviderError` with a rate-limit - * hint). It is re-stated here rather than imported because that module's - * `gitlabRequest` is module-private and GET-only; the write side needs POST, - * PUT and DELETE. Hoisting both into a `gitlab/http.ts` (as `github/http.ts` - * already does) is the obvious follow-up. + * The request plumbing (bearer auth, 15s timeout, 404 → null, every other + * non-2xx → `GitProviderError` with a rate-limit hint) is `gitlab/http.ts`, + * shared with the provider and change-request clients. */ -import { apiBaseUrlFor, type RepoRef } from "@decocms/shared/git-providers"; +import type { RepoRef } from "@decocms/shared/git-providers"; import { retry, RetryError } from "@decocms/shared/std"; import { encodeFilePath, encodeProjectPath, - gitlabRetryAfterMs, GitlabProviderClient, -} from "../gitlab/client"; +} from "./client"; +import { gitlabApiBaseUrl, gitlabFailure } from "./http"; import { GitProviderError, type TokenSource } from "../types"; import { + type BranchPage, type ChangeRequestInfo, type FileChange, type FileMode, type RepoContentClient, RepoWriteConflict, type TreeEntry, -} from "./types"; +} from "../content"; /** Matches `gitlab/client.ts`: one REST call, not a download. */ const REQUEST_TIMEOUT_MS = 15_000; @@ -308,50 +306,6 @@ export async function pooledMap( return out; } -/** - * The human-readable half of a GitLab error body. GitLab is inconsistent: - * `{"message": "..."}` for most refusals, `{"message": ["..."]}` for merge - * requests, `{"message": {"base": ["..."]}}` for model validation, and - * `{"error": "..."}` for OAuth-ish failures. The conflict classifiers match on - * this text, so flattening every shape is load-bearing, not cosmetic. - */ -export function gitlabErrorMessage(bodyText: string): string { - const flatten = (value: unknown): string[] => { - if (typeof value === "string") return value.length > 0 ? [value] : []; - if (Array.isArray(value)) return value.flatMap(flatten); - if (value !== null && typeof value === "object") { - return Object.values(value).flatMap(flatten); - } - return []; - }; - try { - const parsed: unknown = JSON.parse(bodyText); - if (parsed !== null && typeof parsed === "object") { - const record = parsed as Record; - const parts = [ - ...flatten(record.message), - ...flatten(record.error), - ...flatten(record.error_description), - ]; - if (parts.length > 0) return parts.join("; "); - } - } catch { - // Not JSON: an HTML error page or an empty body. Fall through to the text. - } - return bodyText.slice(0, 300); -} - -async function gitlabFailure(res: Response): Promise { - const text = await res.text().catch(() => ""); - const detail = text ? gitlabErrorMessage(text) : res.statusText; - return new GitProviderError({ - provider: "gitlab", - status: res.status, - message: `GitLab API ${res.status}: ${detail}`, - retryAfterMs: res.status === 429 ? gitlabRetryAfterMs(res.headers) : null, - }); -} - interface CallInit { method?: "GET" | "POST" | "PUT" | "DELETE"; body?: unknown; @@ -368,7 +322,7 @@ export class GitlabContentClient implements RepoContentClient { constructor(params: { repo: RepoRef; tokenSource: TokenSource }) { this.repo = params.repo; - this.apiBase = apiBaseUrlFor("gitlab", params.repo.host); + this.apiBase = gitlabApiBaseUrl(params.repo.host); this.projectBase = `/projects/${encodeProjectPath(params.repo.path)}`; this.provider = new GitlabProviderClient({ host: params.repo.host, @@ -417,6 +371,49 @@ export class GitlabContentClient implements RepoContentClient { return { sha: commit.id, committedAt: commit.committed_date }; } + /** + * GitLab filters branch names server-side with `search`, so this is one + * request. `x-total` carries the true match count for offset pagination; + * when GitLab omits it (a very large project, where it stops counting) the + * returned page's length is the honest lower bound. + * + * `author` is the head commit's author NAME, not an account login — GitLab's + * branch listing carries no user object. The neutral field is nullable and + * only ever displayed, so a name is the right answer rather than null. + */ + async searchBranches(params: { + query: string; + limit: number; + cursor?: string | null; + }): Promise { + // GitLab pages by number, so the opaque cursor IS the next page number. + const page = Number(params.cursor) || 1; + const res = await this.call( + `${this.projectBase}/repository/branches` + + `?search=${encodeURIComponent(params.query)}` + + `&per_page=${Math.min(params.limit, TREE_PAGE_SIZE)}&page=${page}`, + ); + if (res === null) { + return { branches: [], totalCount: 0, nextCursor: null }; + } + const rows = (await res.json()) as Array<{ + name?: string; + commit?: { author_name?: string | null }; + }>; + const branches = rows.flatMap((row) => + typeof row.name === "string" + ? [{ name: row.name, author: row.commit?.author_name ?? null }] + : [], + ); + const total = Number(res.headers.get("x-total")); + const nextPage = res.headers.get("x-next-page"); + return { + branches, + totalCount: Number.isFinite(total) ? total : branches.length, + nextCursor: nextPage ? nextPage : null, + }; + } + getArchive(ref: string): Promise | null> { return this.provider.archiveTarball(this.repo, ref); } diff --git a/apps/api/src/git-providers/gitlab/env.ts b/apps/api/src/git-providers/gitlab/env.ts new file mode 100644 index 0000000000..0ab7817899 --- /dev/null +++ b/apps/api/src/git-providers/gitlab/env.ts @@ -0,0 +1,37 @@ +/** + * Deployment config for Studio's GitLab OAuth application. + * + * Optional, like its GitHub counterpart: unset, the GitLab half of the + * git-providers routes answers 503 and an org connects with a token instead. + * Read through this helper only; tools never touch `process.env`. + */ + +import type { GitProviderCapability } from "../types"; + +export interface GitlabOAuthConfig { + host: string; + clientId: string; + clientSecret: string; +} + +/** + * OAuth application for gitlab.com. Self-managed instances need their own + * application registered per instance; those connect with a token for now. + */ +export function readGitlabOAuthConfig( + env: Record = process.env, +): GitlabOAuthConfig | null { + const clientId = env.GITLAB_OAUTH_CLIENT_ID?.trim(); + const clientSecret = env.GITLAB_OAUTH_CLIENT_SECRET?.trim(); + if (!clientId || !clientSecret) return null; + return { + host: (env.GITLAB_OAUTH_HOST?.trim() || "gitlab.com").toLowerCase(), + clientId, + clientSecret, + }; +} + +export function gitlabCapability(): GitProviderCapability { + const config = readGitlabOAuthConfig(); + return { configured: config !== null, hosts: config ? [config.host] : [] }; +} diff --git a/apps/api/src/git-providers/gitlab/http.ts b/apps/api/src/git-providers/gitlab/http.ts new file mode 100644 index 0000000000..5e87629756 --- /dev/null +++ b/apps/api/src/git-providers/gitlab/http.ts @@ -0,0 +1,110 @@ +/** + * GitLab REST v4 plumbing shared by every GitLab caller: the API base for a + * host, the headers and timeout one call sends, and the conversion of a + * refusal into `GitProviderError`. + * + * It exists because three clients need the same transport and only one of them + * (`gitlab/client.ts`) had it — GET-only and module-private, so the content + * client re-stated it to get POST/PUT/DELETE and the change-request client + * would have made three copies of the same 404-is-null rule. + * + * Nothing here decides which token to send; callers pass one. + */ + +import { apiBaseUrlFor } from "@decocms/shared/git-providers"; +import { GitProviderError } from "../types"; +import { gitlabRetryAfterMs } from "./client"; + +/** Matches every other GitLab caller: one REST call, not a download. */ +const GITLAB_TIMEOUT_MS = 15_000; + +/** REST v4 base for a GitLab host — gitlab.com and self-hosted alike. */ +export function gitlabApiBaseUrl(host: string): string { + return apiBaseUrlFor("gitlab", host); +} + +/** + * The human-readable half of a GitLab error body. GitLab is inconsistent: + * `{"message": "..."}` for most refusals, `{"message": ["..."]}` for merge + * requests, `{"message": {"base": ["..."]}}` for model validation, and + * `{"error": "..."}` for OAuth-ish failures. The conflict classifiers match on + * this text, so flattening every shape is load-bearing, not cosmetic. + */ +export function gitlabErrorMessage(bodyText: string): string { + const flatten = (value: unknown): string[] => { + if (typeof value === "string") return value.length > 0 ? [value] : []; + if (Array.isArray(value)) return value.flatMap(flatten); + if (value !== null && typeof value === "object") { + return Object.values(value).flatMap(flatten); + } + return []; + }; + try { + const parsed: unknown = JSON.parse(bodyText); + if (parsed !== null && typeof parsed === "object") { + const record = parsed as Record; + const parts = [ + ...flatten(record.message), + ...flatten(record.error), + ...flatten(record.error_description), + ]; + if (parts.length > 0) return parts.join("; "); + } + } catch { + // Not JSON: an HTML error page or an empty body. Fall through to the text. + } + return bodyText.slice(0, 300); +} + +/** A `GitProviderError` for a non-2xx response, reading its body for the message. */ +export async function gitlabFailure(res: Response): Promise { + const text = await res.text().catch(() => ""); + const detail = text ? gitlabErrorMessage(text) : res.statusText; + return new GitProviderError({ + provider: "gitlab", + status: res.status, + message: `GitLab API ${res.status}: ${detail}`, + retryAfterMs: res.status === 429 ? gitlabRetryAfterMs(res.headers) : null, + }); +} + +export interface GitlabFetchInit { + method?: "GET" | "POST" | "PUT" | "DELETE"; + body?: unknown; + accept?: string; + timeoutMs?: number; +} + +/** + * One authenticated REST call. A network or timeout failure becomes a + * `GitProviderError` with `status: 0`; every response — including 4xx — is + * returned, so a caller can give 404 and 409 their endpoint-specific meaning. + */ +export async function gitlabFetch( + url: string, + token: string, + init: GitlabFetchInit = {}, +): Promise { + const headers: Record = { + Authorization: `Bearer ${token}`, + Accept: init.accept ?? "application/json", + }; + if (init.body !== undefined) headers["Content-Type"] = "application/json"; + try { + return await fetch(url, { + method: init.method ?? "GET", + headers, + body: init.body === undefined ? undefined : JSON.stringify(init.body), + signal: AbortSignal.timeout(init.timeoutMs ?? GITLAB_TIMEOUT_MS), + }); + } catch (cause) { + throw new GitProviderError({ + provider: "gitlab", + status: 0, + message: `GitLab request failed: ${ + cause instanceof Error ? cause.message : String(cause) + }`, + cause, + }); + } +} diff --git a/apps/api/src/git-providers/index.ts b/apps/api/src/git-providers/index.ts new file mode 100644 index 0000000000..a4929d1774 --- /dev/null +++ b/apps/api/src/git-providers/index.ts @@ -0,0 +1,45 @@ +/** + * Talking to a git repository, whoever hosts it. + * + * This barrel is the ONLY entry point the rest of the API should import. The + * layout under it is deliberate: + * + * - `types.ts`, `content.ts`, `change-requests.ts` — the contracts. Three, + * because the three things Studio does with a repository (hold an account + * for it, read and write its files, propose and land changes) have genuinely + * different shapes and different callers. + * - `credentials.ts` — which credential reaches which repository. Neutral. + * - `clients.ts` — the composition root, and the one module that knows both + * providers exist. + * - `github/`, `gitlab/` — one provider's own vocabulary, and the only place + * its name, hosts, endpoints and error prose appear. + * + * Two rules keep that honest, and both are about imports: + * + * 1. Nothing OUTSIDE this directory imports `github/` or `gitlab/`. If a + * caller needs one provider specifically, it wants a capability this + * interface does not express yet — add it here rather than reaching past + * it. Two standing exceptions, both provider-specific *by construction*: + * `api/routes/git-providers.ts` (a GitHub App installation and a GitLab + * OAuth grant are different redirect dances, so there is no one flow to + * implement) and `tools/github/list-user-orgs.ts` (listing App + * installations has no counterpart to abstract over). + * 2. Nothing INSIDE this directory imports this barrel. Implementations + * import the contract file they implement (`../content`, not `..`), which + * is what keeps the graph acyclic — the barrel pulls in `clients.ts`, which + * pulls in the implementations. + */ + +export * from "./types"; +export * from "./content"; +export * from "./change-requests"; +export * from "./credentials"; +export * from "./clients"; +export * from "./capabilities"; + +/** + * The pre-repository world, re-exported so callers still on it need not reach + * into `github/`. GitHub-only underneath, by construction: a binding written + * before repositories existed could only ever have been a github.com repo. + */ +export { findRepositoryForLegacyBinding } from "./github/legacy-connection"; diff --git a/apps/api/src/git-providers/repo-choices.test.ts b/apps/api/src/git-providers/repo-choices.test.ts index e50236c528..20bc6322b5 100644 --- a/apps/api/src/git-providers/repo-choices.test.ts +++ b/apps/api/src/git-providers/repo-choices.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { mergeRepoChoices, orgSharedFirst } from "./repo-choices"; +import { mergeRepoChoices } from "./repo-choices"; +import { + type LegacyRepoChoice, + orgSharedFirst, +} from "./github/legacy-connection"; import type { RepositoryRecord } from "@/storage/repositories"; function repository( @@ -22,10 +26,10 @@ function repository( }; } -const legacy = (owner: string, repo: string) => ({ +/** A legacy connection's repo, as `github/legacy-connection.ts` hands it over. */ +const legacy = (owner: string, repo: string): LegacyRepoChoice => ({ connectionId: `conn_${owner}_${repo}`, - owner, - repo, + ref: { provider: "github", host: "github.com", path: `${owner}/${repo}` }, installationId: 1, }); diff --git a/apps/api/src/git-providers/repo-choices.ts b/apps/api/src/git-providers/repo-choices.ts index f5e6d83c3d..744c50c7a5 100644 --- a/apps/api/src/git-providers/repo-choices.ts +++ b/apps/api/src/git-providers/repo-choices.ts @@ -9,20 +9,28 @@ * every GitHub one linked through the new model) invisible to an agent even * though Studio could mint for it. * + * Nothing here names a provider: the legacy half arrives already in `RepoRef` + * form from `github/legacy-connection.ts`, which is the only module that knows + * a connection can stand for a repository. + * * The unit every consumer passes around is a `RepoChoice`: an opaque id plus the * `owner`/`name`/`webUrl` the later consumers (thread metadata, PR extraction, * the git sync) already read. */ import type { StudioContext } from "@/core/studio-context"; -import { repositoryUsesStudioCredentials } from "@/git-providers/credentials"; -import { selectLoadableRepos } from "@/harnesses/decopilot/built-in-tools/load-repo"; import type { RepositoryRecord } from "@/storage/repositories"; -import { isOrgSharedConnection } from "@decocms/shared/github-repo-scope"; import { type GitProviderKind, + repoIdentityKey, + repoWebUrl, splitOwnerName, } from "@decocms/shared/git-providers"; +import { repositoryUsesStudioCredentials } from "./credentials"; +import { + type LegacyRepoChoice, + listLegacyRepoChoices, +} from "./github/legacy-connection"; /** * One repository the agent may clone, from either model. @@ -47,33 +55,6 @@ export interface RepoChoice { installationId: number | undefined; } -/** The connection shape the legacy selection needs. */ -type RepoConnection = { - id: string; - status: string; - metadata: Record | null; -}; - -/** - * The org-shared `mcp-github` connections first, everything else after, order - * otherwise preserved. - * - * Importing ONE repo routinely leaves two loadable connections behind — the - * org-shared one and a per-agent one — and `mergeRepoChoices` keeps whichever it - * sees first. The per-agent child is disposable (torn down with its agent), so - * an org-shared sibling is the credential a run should be given; this is what - * makes that the one that survives the dedup instead of whatever order storage - * happened to return. Pure, and exported for its test. - */ -export function orgSharedFirst( - connections: T[], -): T[] { - return [ - ...connections.filter(isOrgSharedConnection), - ...connections.filter((c) => !isOrgSharedConnection(c)), - ]; -} - /** * The clonable set an org is offered, from both models. * @@ -85,19 +66,14 @@ export function orgSharedFirst( */ export function mergeRepoChoices( repositories: RepositoryRecord[], - legacy: { - connectionId: string; - owner: string; - repo: string; - installationId: number; - }[], + legacy: LegacyRepoChoice[], ): RepoChoice[] { const out: RepoChoice[] = []; const seen = new Set(); for (const repository of repositories) { const { owner, name } = splitOwnerName(repository); - seen.add(`${repository.host}/${repository.path}`.toLowerCase()); + seen.add(repoIdentityKey(repository)); out.push({ id: repository.id, owner, @@ -112,16 +88,16 @@ export function mergeRepoChoices( } for (const entry of legacy) { - const key = `github.com/${entry.owner}/${entry.repo}`.toLowerCase(); - if (seen.has(key)) continue; - seen.add(key); + if (seen.has(repoIdentityKey(entry.ref))) continue; + seen.add(repoIdentityKey(entry.ref)); + const { owner, name } = splitOwnerName(entry.ref); out.push({ id: entry.connectionId, - owner: entry.owner, - name: entry.repo, - label: `${entry.owner}/${entry.repo} (github.com)`, - webUrl: `https://github.com/${entry.owner}/${entry.repo}`, - provider: "github", + owner, + name, + label: `${entry.ref.path} (${entry.ref.host})`, + webUrl: repoWebUrl(entry.ref), + provider: entry.ref.provider, repository: null, connectionId: entry.connectionId, installationId: entry.installationId, @@ -143,8 +119,5 @@ export async function listOrgRepoChoices( servable.push(repository); } } - const { items } = await ctx.storage.connections.list(orgId, { - slug: "mcp-github", - }); - return mergeRepoChoices(servable, selectLoadableRepos(orgSharedFirst(items))); + return mergeRepoChoices(servable, await listLegacyRepoChoices(ctx, orgId)); } diff --git a/apps/api/src/git-providers/types.ts b/apps/api/src/git-providers/types.ts index 4b0e4d8f8f..b9cfe7e359 100644 --- a/apps/api/src/git-providers/types.ts +++ b/apps/api/src/git-providers/types.ts @@ -16,6 +16,16 @@ import type { GitProviderKind, RepoRef } from "@decocms/shared/git-providers"; export type RepoVisibility = "public" | "private" | "internal"; +/** Whether this deployment can run a provider's connect flow, and where. */ +export interface GitProviderCapability { + configured: boolean; + /** + * Hosts with a registration. Empty when unconfigured; a host absent from a + * configured provider's list connects with a token instead. + */ + hosts: string[]; +} + export interface RepoSummary { ref: RepoRef; /** Provider repository/project id, as a string (GitLab ids are numeric, GitHub's too). */ diff --git a/apps/api/src/sandbox/lifecycle.ts b/apps/api/src/sandbox/lifecycle.ts index bd7a5c5ab2..e9869a5c02 100644 --- a/apps/api/src/sandbox/lifecycle.ts +++ b/apps/api/src/sandbox/lifecycle.ts @@ -11,7 +11,7 @@ import { meter } from "@/observability"; import type { Database as DatabaseSchema } from "@/storage/types"; import { KyselySandboxProviderStateStore } from "@/storage/sandbox-runner-state"; import { buildCloneInfo } from "@/shared/github-clone-info"; -import { cloneInfoForRepository } from "@/git-providers/credentials"; +import { cloneInfoForRepository } from "@/git-providers"; import { RepositoryStorage } from "@/storage/repositories"; import { CredentialVault } from "@/encryption/credential-vault"; import { getSettings } from "@/settings"; diff --git a/apps/api/src/storage/task-board-advance-review.integration.test.ts b/apps/api/src/storage/task-board-advance-review.integration.test.ts index a744910723..e96918deac 100644 --- a/apps/api/src/storage/task-board-advance-review.integration.test.ts +++ b/apps/api/src/storage/task-board-advance-review.integration.test.ts @@ -223,8 +223,11 @@ describe("advanceToReviewIfInProgress (real Postgres)", () => { organizationId: ORG, url: "https://github.com/acme/site/pull/3", prNumber: 3, - repoOwner: "acme", - repoName: "site", + repo: { + provider: "github", + host: "github.com", + path: "acme/site", + }, }); await taskBoard.advanceLinkedTasksToReviewOnThreadFinish(thread.id, ORG); const after = await taskBoard.getById(task.id, ORG); @@ -297,8 +300,11 @@ describe("advanceToReviewIfInProgress (real Postgres)", () => { organizationId: ORG, url: "https://github.com/acme/site/pull/7", prNumber: 7, - repoOwner: "acme", - repoName: "site", + repo: { + provider: "github", + host: "github.com", + path: "acme/site", + }, }); await taskBoard.advanceLinkedTasksToReviewOnThreadFinish(thread.id, ORG); diff --git a/apps/api/src/storage/task-board.ts b/apps/api/src/storage/task-board.ts index 53bea26a2b..5ebf1f9f2b 100644 --- a/apps/api/src/storage/task-board.ts +++ b/apps/api/src/storage/task-board.ts @@ -6,6 +6,7 @@ */ import { sql, type Kysely } from "kysely"; +import { type RepoRef, splitOwnerName } from "@decocms/shared/git-providers"; // Shared with the quota gate, which charges the same class of task. import { isReportsTask } from "../billing/task-quota"; import type { @@ -1329,19 +1330,32 @@ export class TaskBoardStorage { } /** - * Link a GitHub PR to a task (idempotent per (task, url) — a run replay or a - * repeated PR tool call can't duplicate the row). `prNumber`/`repoOwner`/ - * `repoName` are derived from the PR url at capture time. + * Link a change request to a task (idempotent per (task, url) — a run replay + * or a repeated tool call can't duplicate the row). + * + * Takes the `RepoRef` rather than an owner/name pair so BOTH derivations + * happen here, once: the legacy columns (a GitLab project in subgroups puts + * every namespace level in `repo_owner`) and `repository_id`, resolved from + * the org's repositories by identity. Doing the latter at the write means + * every caller — the provider tool hook, a bash-output scan, a + * human-supplied URL — records the credential without knowing it has to. */ async linkPr(params: { taskBoardItemId: string; organizationId: string; url: string; prNumber: number; - repoOwner: string; - repoName: string; + repo: RepoRef; connectionId?: string | null; }): Promise { + const { owner, name } = splitOwnerName(params.repo); + const repository = await this.db + .selectFrom("repositories") + .select("id") + .where("organization_id", "=", params.organizationId) + .where("host", "=", params.repo.host) + .where(sql`lower(path)`, "=", params.repo.path.toLowerCase()) + .executeTakeFirst(); await this.db .insertInto("task_board_item_prs") .values({ @@ -1349,9 +1363,10 @@ export class TaskBoardStorage { organization_id: params.organizationId, url: params.url, pr_number: params.prNumber, - repo_owner: params.repoOwner, - repo_name: params.repoName, + repo_owner: owner, + repo_name: name, connection_id: params.connectionId ?? null, + repository_id: repository?.id ?? null, }) .onConflict((oc) => oc.columns(["task_board_item_id", "url"]).doNothing()) .execute(); @@ -1370,6 +1385,7 @@ export class TaskBoardStorage { "repo_owner as repoOwner", "repo_name as repoName", "connection_id as connectionId", + "repository_id as repositoryId", "created_at as createdAt", ]) .where("task_board_item_id", "=", taskBoardItemId) @@ -1382,6 +1398,7 @@ export class TaskBoardStorage { repoOwner: r.repoOwner, repoName: r.repoName, connectionId: r.connectionId ?? null, + repositoryId: r.repositoryId ?? null, createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() diff --git a/apps/api/src/storage/types.ts b/apps/api/src/storage/types.ts index 17ccf38bdd..7bfe65f6ac 100644 --- a/apps/api/src/storage/types.ts +++ b/apps/api/src/storage/types.ts @@ -1898,13 +1898,23 @@ export interface TaskBoardItemTagRef { createdAt: string; } -/** A PR linked to a task — identity only. Title/state are fetched live. */ +/** + * A change request linked to a task — identity only. Title/state are fetched + * live through a `ChangeRequestClient`. + * + * `url` is the identity that matters: it names the provider, the host and the + * repository path, which is the only shape a GitLab project nested in + * subgroups fits. `repoOwner`/`repoName` are the pre-provider split, kept for + * the legacy readers; `repositoryId` is the credential, and `connectionId` the + * legacy one it replaces. + */ export interface TaskBoardItemPrRef { url: string; number: number; repoOwner: string; repoName: string; connectionId: string | null; + repositoryId: string | null; createdAt: string; } diff --git a/apps/api/src/tools/change-requests/index.ts b/apps/api/src/tools/change-requests/index.ts new file mode 100644 index 0000000000..8a47756bf3 --- /dev/null +++ b/apps/api/src/tools/change-requests/index.ts @@ -0,0 +1,308 @@ +/** + * App-only tools over a repository's change requests — what GitHub calls pull + * requests and GitLab merge requests. + * + * They replace the browser's direct MCP traffic (`create_pull_request`, + * `merge_pull_request`, `list_pull_requests`, `GET_CHECK_RUN`) and the + * GitHub-named tools that preceded them (`GITHUB_PR_STATE`, + * `GITHUB_LAST_PUBLISHED_PR`). Every one of those was GitHub by construction: + * the browser held a GitHub MCP client and spoke GitHub's tool names, so a + * GitLab project's panel had nothing to call. + * + * The repository is named by whatever the caller has — a repository id, a URL, + * or a legacy connection — and the provider follows from the answer, never + * from the caller. + */ + +import { z } from "zod"; +import { defineTool } from "@/core/define-tool"; +import type { StudioContext } from "@/core/studio-context"; +import { + type ChangeRequestClient, + changeRequestClientForTarget, +} from "@/git-providers"; +import { + NO_REPOSITORY_CREDENTIAL, + repoTargetInput, + type RepoTargetInput, + repoTargetOf, +} from "@/tools/git/repo-target"; + +/** The client for a tool's target, or a throw naming what to do about it. */ +async function clientFor( + ctx: StudioContext, + input: RepoTargetInput, +): Promise { + const organizationId = ctx.organization?.id; + if (!organizationId) throw new Error("Organization context required"); + const client = await changeRequestClientForTarget( + ctx, + organizationId, + repoTargetOf(input), + ); + if (!client) throw new Error(NO_REPOSITORY_CREDENTIAL); + return client; +} + +const checkRunSchema = z.object({ + /** Null for a run with no addressable log (a GitHub commit status). */ + id: z.string().nullable(), + name: z.string(), + state: z.enum(["queued", "running", "completed"]), + conclusion: z + .enum([ + "success", + "failure", + "neutral", + "cancelled", + "skipped", + "timed_out", + "action_required", + ]) + .nullable(), + url: z.string().nullable(), + durationMs: z.number().nullable(), + summary: z.string().nullable(), +}); + +const commentSchema = z.object({ + id: z.string(), + author: z.string(), + body: z.string(), + createdAt: z.string(), + updatedAt: z.string(), + url: z.string(), +}); + +const changeRequestSchema = z.object({ + number: z.number(), + url: z.string(), + title: z.string(), + body: z.string(), + /** `merged` is its own state — closed-unmerged means abandoned. */ + state: z.enum(["open", "closed", "merged"]), + draft: z.boolean(), + mergedAt: z.string().nullable(), + base: z.string(), + head: z.string(), + headSha: z.string(), + headRepoPath: z.string().nullable(), + author: z.string(), + conflicting: z.boolean().nullable(), + checks: z.enum(["pending", "passing", "failing"]).nullable(), + changedFiles: z.number().nullable(), +}); + +const detailSchema = changeRequestSchema.extend({ + checkRuns: z.array(checkRunSchema), + comments: z.array(commentSchema), + unresolvedConversations: z.number(), + reviewBlocked: z.boolean(), +}); + +export const CHANGE_REQUEST_STATE = defineTool({ + name: "CHANGE_REQUEST_STATE", + description: + "Read a branch's change request with its CI runs, review state and comments.", + annotations: { + title: "Read Change Request State", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + _meta: { ui: { visibility: "app" } }, + inputSchema: z.object({ + ...repoTargetInput, + branch: z.string().describe("Head branch of the change request to read"), + }), + outputSchema: z.object({ + /** null when the branch has no change request at all. */ + changeRequest: detailSchema.nullable(), + }), + handler: async (input, ctx) => { + await ctx.access.check(); + const client = await clientFor(ctx, input); + return { + changeRequest: await client.readDetailed({ branch: input.branch }), + }; + }, +}); + +export const CHANGE_REQUEST_LAST_MERGED = defineTool({ + name: "CHANGE_REQUEST_LAST_MERGED", + description: + "Read the most recently merged change request into a base branch — in Fast Preview, the last publish.", + annotations: { + title: "Read Last Merged Change Request", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + _meta: { ui: { visibility: "app" } }, + inputSchema: z.object({ + ...repoTargetInput, + base: z.string().describe("Branch it was merged into"), + }), + outputSchema: z.object({ + /** null when nothing has ever been merged into this base. */ + changeRequest: changeRequestSchema.nullable(), + }), + handler: async (input, ctx) => { + await ctx.access.check(); + const client = await clientFor(ctx, input); + return { changeRequest: await client.lastMergedInto(input.base) }; + }, +}); + +export const CHANGE_REQUEST_LIST_OPEN = defineTool({ + name: "CHANGE_REQUEST_LIST_OPEN", + description: + "List a repository's open change requests, most recently updated first.", + annotations: { + title: "List Open Change Requests", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + _meta: { ui: { visibility: "app" } }, + inputSchema: z.object({ + ...repoTargetInput, + limit: z.number().int().min(1).max(100).default(50), + }), + outputSchema: z.object({ changeRequests: z.array(changeRequestSchema) }), + handler: async (input, ctx) => { + await ctx.access.check(); + const client = await clientFor(ctx, input); + return { changeRequests: await client.listOpen(input.limit) }; + }, +}); + +export const CHANGE_REQUEST_CHECK_LOG = defineTool({ + name: "CHANGE_REQUEST_CHECK_LOG", + description: + "Read one CI run's report — a GitHub check run's output markdown, or the tail of a GitLab job's trace.", + annotations: { + title: "Read CI Run Report", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + _meta: { ui: { visibility: "app" } }, + inputSchema: z.object({ + ...repoTargetInput, + checkId: z.string().describe("Provider id of the run, from its listing"), + }), + outputSchema: z.object({ report: z.string().nullable() }), + handler: async (input, ctx) => { + await ctx.access.check(); + const client = await clientFor(ctx, input); + return { report: await client.readCheckLog(input.checkId) }; + }, +}); + +export const CHANGE_REQUEST_OPEN = defineTool({ + name: "CHANGE_REQUEST_OPEN", + description: + "Propose a branch onto another. Returns the branch's existing change request instead of failing when it already has one.", + annotations: { + title: "Open Change Request", + readOnlyHint: false, + destructiveHint: false, + // Re-calling it yields the branch's one change request, never a second. + idempotentHint: true, + openWorldHint: true, + }, + _meta: { ui: { visibility: "app" } }, + inputSchema: z.object({ + ...repoTargetInput, + head: z.string().describe("Branch being proposed"), + base: z.string().describe("Branch it should land on"), + title: z.string(), + body: z.string().optional(), + }), + outputSchema: z.object({ + changeRequest: changeRequestSchema, + /** True when this call found an existing one rather than opening it. */ + existed: z.boolean(), + }), + handler: async (input, ctx) => { + await ctx.access.check(); + const client = await clientFor(ctx, input); + /** + * Reuse before create, deliberately in that order: a branch has at most + * one open change request, so the caller's intent ("this branch should be + * proposed") is already satisfied by an existing one — and asking first is + * one read, where creating and recovering from the duplicate refusal is a + * write, a parse of the provider's prose, and then the read anyway. + */ + const existing = await client.readForBranch(input.head); + if (existing && existing.state === "open") { + if (input.body && input.body !== existing.body) { + await client.describe(existing.number, input.body); + } + return { + changeRequest: input.body + ? { ...existing, body: input.body } + : existing, + existed: true, + }; + } + const opened = await client.open({ + head: input.head, + base: input.base, + title: input.title, + body: input.body, + }); + return { changeRequest: opened, existed: false }; + }, +}); + +export const CHANGE_REQUEST_MERGE = defineTool({ + name: "CHANGE_REQUEST_MERGE", + description: + "Land a change request. Reports why it could not merge rather than throwing.", + annotations: { + title: "Merge Change Request", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + _meta: { ui: { visibility: "app" } }, + inputSchema: z.object({ + ...repoTargetInput, + number: z.number().int().positive(), + strategy: z + .enum(["any", "squash"]) + .default("any") + .describe( + "`squash` when the result must be one commit; `any` uses whatever the repository allows", + ), + commitTitle: z.string().optional(), + commitMessage: z.string().optional(), + }), + outputSchema: z.object({ + merged: z.boolean(), + /** Absent on success. Who has to act, not the provider's status code. */ + reason: z + .enum(["conflict", "blocked", "rate_limited", "not_found", "error"]) + .optional(), + detail: z.string().optional(), + }), + handler: async (input, ctx) => { + await ctx.access.check(); + const client = await clientFor(ctx, input); + const outcome = await client.merge(input.number, { + strategy: input.strategy, + commitTitle: input.commitTitle, + commitMessage: input.commitMessage, + }); + return outcome.merged + ? { merged: true } + : { merged: false, reason: outcome.reason, detail: outcome.detail }; + }, +}); diff --git a/apps/api/src/tools/git/branches.ts b/apps/api/src/tools/git/branches.ts new file mode 100644 index 0000000000..fc7ff577a1 --- /dev/null +++ b/apps/api/src/tools/git/branches.ts @@ -0,0 +1,71 @@ +/** + * Branch search, server-side and provider-neutral. + * + * Replaces `GITHUB_SEARCH_BRANCHES`, which was GitHub by construction: it + * posted GitHub's own GraphQL query. The filtering still happens at the + * provider — a repository with hundreds of branches makes a client-side grep + * over a paged listing useless — but which provider is now the repository's + * answer, not the tool's. + */ + +import { z } from "zod"; +import { defineTool } from "@/core/define-tool"; +import { contentClientForTarget } from "@/git-providers"; +import { repoTargetInput, repoTargetOf } from "./repo-target"; + +export const REPOSITORY_SEARCH_BRANCHES = defineTool({ + name: "REPOSITORY_SEARCH_BRANCHES", + description: + "Search a repository's branches by a case-insensitive substring of the branch name, filtered server-side by the provider.", + annotations: { + title: "Search Repository Branches", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + _meta: { ui: { visibility: "app" } }, + inputSchema: z.object({ + ...repoTargetInput, + query: z + .string() + .describe( + "Substring to match against branch names. Empty returns the first branches alphabetically.", + ), + limit: z.number().int().min(1).max(100).default(30), + cursor: z + .string() + .nullish() + .describe( + "Opaque cursor from a previous call's `nextCursor`, to read the next window", + ), + }), + outputSchema: z.object({ + branches: z.array( + z.object({ + name: z.string(), + /** Null when the provider cannot attribute the head commit. */ + author: z.string().nullable(), + }), + ), + /** Total branches matching `query`, which may exceed `branches.length`. */ + totalCount: z.number(), + /** Pass back verbatim for the next window; null when exhausted. */ + nextCursor: z.string().nullable(), + }), + handler: async (input, ctx) => { + await ctx.access.check(); + const organizationId = ctx.organization?.id; + if (!organizationId) throw new Error("Organization context required"); + const client = await contentClientForTarget( + ctx, + organizationId, + repoTargetOf(input), + ); + return client.searchBranches({ + query: input.query, + limit: input.limit, + cursor: input.cursor, + }); + }, +}); diff --git a/apps/api/src/tools/git/index.ts b/apps/api/src/tools/git/index.ts index f0522237cd..a837018e57 100644 --- a/apps/api/src/tools/git/index.ts +++ b/apps/api/src/tools/git/index.ts @@ -26,13 +26,9 @@ import { import { accountIsServable, clientForAccount, - getGithubAppAuth, -} from "@/git-providers/credentials"; -import { - readGithubAppConfig, - readGitlabOAuthConfig, -} from "@/git-providers/env"; -import { gitlabCurrentUser } from "@/git-providers/gitlab/client"; + principalForToken, + providerCapabilities, +} from "@/git-providers"; import type { GitProviderAccountRecord } from "@/storage/git-provider-accounts"; import type { RepositoryRecord } from "@/storage/repositories"; @@ -109,9 +105,8 @@ export const GIT_PROVIDER_CAPABILITIES = defineTool({ requireAuth(ctx); const organization = requireOrganization(ctx); const base = `/api/${organization.slug ?? organization.id}/git-providers`; - const github = - readGithubAppConfig() !== null && getGithubAppAuth() !== null; - const gitlab = readGitlabOAuthConfig(); + const capabilities = providerCapabilities(); + const github = capabilities.github.configured; return { github: { configured: github, @@ -119,8 +114,10 @@ export const GIT_PROVIDER_CAPABILITIES = defineTool({ installPath: github ? `${base}/github/install` : null, }, gitlab: { - oauthHosts: gitlab ? [gitlab.host] : [], - connectPath: gitlab ? `${base}/gitlab/connect` : null, + oauthHosts: capabilities.gitlab.hosts, + connectPath: capabilities.gitlab.configured + ? `${base}/gitlab/connect` + : null, }, }; }, @@ -177,19 +174,15 @@ export const GIT_ACCOUNT_CONNECT_TOKEN = defineTool({ requireAuth(ctx); await ctx.access.check(); const organization = requireOrganization(ctx); - if (input.type !== "gitlab") { - throw new Error( - "GitHub accounts connect through the GitHub App; tokens are accepted for GitLab only", - ); - } const host = input.host.trim().toLowerCase(); if (!/^[a-z0-9.-]+(:[0-9]+)?$/.test(host)) { throw new Error("host must be a bare hostname, optionally with a port"); } - const principal = await gitlabCurrentUser(host, input.token); + // Refuses GitHub by policy — see `principalForToken`. + const principal = await principalForToken(input.type, host, input.token); const account = await ctx.storage.gitProviderAccounts.upsert({ organizationId: organization.id, - type: "gitlab", + type: input.type, host, authKind: "token", externalAccountId: principal.externalAccountId, @@ -403,3 +396,5 @@ export const REPOSITORY_DELETE = defineTool({ return { deleted }; }, }); + +export { REPOSITORY_SEARCH_BRANCHES } from "./branches"; diff --git a/apps/api/src/tools/git/repo-target.ts b/apps/api/src/tools/git/repo-target.ts new file mode 100644 index 0000000000..0ba60e6644 --- /dev/null +++ b/apps/api/src/tools/git/repo-target.ts @@ -0,0 +1,53 @@ +/** + * How an app-only tool names the repository it acts on. + * + * All three fields are optional because the three eras of records carry + * different ones — a repository id is what everything writes now, an identity + * is what older rows have, and a connection is the pre-repository world. At + * least one that RESOLVES is required, which the resolver enforces rather than + * the schema: which of them is present is not the caller's fault to explain. + */ + +import { z } from "zod"; +import { parseRepoUrl } from "@decocms/shared/git-providers"; +import type { RepoTarget } from "@/git-providers"; + +export const repoTargetInput = { + repositoryId: z + .string() + .optional() + .describe("Connected repository (REPOSITORY_LINK) to act on"), + repoUrl: z + .string() + .optional() + .describe( + "Any URL of the repository — its provider and host are read from it", + ), + connectionId: z + .string() + .optional() + .describe("Legacy: a repo-scoped mcp-github connection in this org"), +}; + +export interface RepoTargetInput { + repositoryId?: string; + repoUrl?: string; + connectionId?: string; +} + +export function repoTargetOf(input: RepoTargetInput): RepoTarget { + return { + repositoryId: input.repositoryId, + ref: input.repoUrl ? parseRepoUrl(input.repoUrl) : null, + connectionId: input.connectionId, + }; +} + +/** + * What a tool says when no credential path resolves. A tool throws where the + * task board returns null: a panel that silently renders empty is + * indistinguishable from a repository with no change requests, and the + * difference is exactly what a reader needs to know. + */ +export const NO_REPOSITORY_CREDENTIAL = + "No credential for this repository — link it in Settings → Repositories"; diff --git a/apps/api/src/tools/github/index.ts b/apps/api/src/tools/github/index.ts index a4dca4a302..cc5d91e817 100644 --- a/apps/api/src/tools/github/index.ts +++ b/apps/api/src/tools/github/index.ts @@ -1,11 +1,11 @@ /** - * GitHub Tools + * GitHub App installation listing — the one GitHub-shaped tool left. * - * App-only tools for GitHub REST API integration (not visible to AI models). - * Uses downstream OAuth tokens from mcp-github connections. + * Everything else that used to live here (a branch search, a pull request's + * state, the last publish) is now provider-neutral: `REPOSITORY_*` and + * `CHANGE_REQUEST_*`, one interface with a GitHub and a GitLab + * implementation. What remains is genuinely about a GitHub App installation, + * which has no counterpart on another provider. */ export { GITHUB_LIST_USER_ORGS } from "./list-user-orgs"; -export { GITHUB_SEARCH_BRANCHES } from "./search-branches"; -export { GITHUB_PR_STATE } from "./pr-state"; -export { GITHUB_LAST_PUBLISHED_PR } from "./last-published-pr"; diff --git a/apps/api/src/tools/github/last-published-pr.test.ts b/apps/api/src/tools/github/last-published-pr.test.ts deleted file mode 100644 index 1707dad303..0000000000 --- a/apps/api/src/tools/github/last-published-pr.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { parseLastPublishedPr } from "./last-published-pr"; - -describe("parseLastPublishedPr", () => { - it("maps the merged pull request", () => { - const { pullRequest } = parseLastPublishedPr({ - repository: { - pullRequests: { - nodes: [ - { - number: 12, - title: "Publish homepage copy", - body: "", - mergedAt: "2026-08-19T10:00:00Z", - url: "https://github.com/acme/site/pull/12", - baseRefName: "main", - headRefName: "claude/copy", - headRefOid: "def456", - author: { login: "gimenes" }, - }, - ], - }, - }, - }); - - expect(pullRequest).toEqual({ - number: 12, - title: "Publish homepage copy", - body: "", - mergedAt: "2026-08-19T10:00:00Z", - base: "main", - head: "claude/copy", - headSha: "def456", - htmlUrl: "https://github.com/acme/site/pull/12", - author: "gimenes", - }); - }); - - /** - * A page ordered by `updatedAt` can list a stale merged PR first (a - * post-merge comment bumped its `updatedAt` past a newer merge) — the - * result must still be the one with the latest `mergedAt`. - */ - it("picks the max mergedAt, not the first node in the updatedAt-ordered page", () => { - const { pullRequest } = parseLastPublishedPr({ - repository: { - pullRequests: { - nodes: [ - { - number: 10, - title: "Older publish, commented on recently", - body: "", - mergedAt: "2026-08-10T10:00:00Z", - url: "https://github.com/acme/site/pull/10", - baseRefName: "main", - headRefName: "claude/old", - headRefOid: "aaa111", - author: { login: "gimenes" }, - }, - { - number: 12, - title: "Actually the last publish", - body: "", - mergedAt: "2026-08-19T10:00:00Z", - url: "https://github.com/acme/site/pull/12", - baseRefName: "main", - headRefName: "claude/copy", - headRefOid: "def456", - author: { login: "gimenes" }, - }, - ], - }, - }, - }); - - expect(pullRequest?.number).toBe(12); - }); - - it("returns null when nothing was ever merged into the base", () => { - expect( - parseLastPublishedPr({ repository: { pullRequests: { nodes: [] } } }), - ).toEqual({ pullRequest: null }); - }); - - /** "Never published" and "not allowed to look" must not read the same. */ - it("throws when the repository is hidden", () => { - expect(() => parseLastPublishedPr({ repository: null })).toThrow( - /not found or not accessible/, - ); - }); -}); diff --git a/apps/api/src/tools/github/last-published-pr.ts b/apps/api/src/tools/github/last-published-pr.ts deleted file mode 100644 index 2de309b49e..0000000000 --- a/apps/api/src/tools/github/last-published-pr.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { z } from "zod"; -import { defineTool } from "../../core/define-tool"; -import { githubGraphql } from "./graphql"; - -/** - * The most recent merged pull request into a base branch — in Fast Preview - * every publish is a squash-merged PR, so this IS the last publish. - * - * `states: MERGED` is why this is exact. REST can only filter `state: closed`, - * which interleaves PRs closed WITHOUT merging, so it takes a page of PRs to - * answer and still reports "never published" for a base whose whole page was - * abandoned. - * - * GraphQL's `PullRequestOrder` has no `MERGED_AT` field, only `UPDATED_AT` — - * and a comment or label change on an OLDER merged PR bumps its `updatedAt` - * past a more-recently-merged one, so the top-1 node isn't reliably the last - * publish. `first: 20` pulls a window of recent activity and - * {@link parseLastPublishedPr} picks the max `mergedAt` within it instead of - * trusting position. - */ -const LAST_PUBLISHED_QUERY = ` -query LastPublishedPr($owner: String!, $repo: String!, $base: String!) { - repository(owner: $owner, name: $repo) { - pullRequests( - states: MERGED - baseRefName: $base - first: 20 - orderBy: { field: UPDATED_AT, direction: DESC } - ) { - nodes { - number - title - body - mergedAt - url - baseRefName - headRefName - headRefOid - author { login } - } - } - } -}`; - -const lastPublishedOutput = z.object({ - /** null when nothing has ever been merged into this base. */ - pullRequest: z - .object({ - number: z.number(), - title: z.string(), - body: z.string(), - mergedAt: z.string().nullable(), - base: z.string(), - head: z.string(), - headSha: z.string(), - htmlUrl: z.string(), - author: z.string(), - }) - .nullable(), -}); - -export type LastPublishedPrResult = z.infer; - -export interface LastPublishedPrResponse { - repository?: { - pullRequests?: { - nodes?: Array<{ - number?: number | null; - title?: string | null; - body?: string | null; - mergedAt?: string | null; - url?: string | null; - baseRefName?: string | null; - headRefName?: string | null; - headRefOid?: string | null; - author?: { login?: string | null } | null; - } | null> | null; - } | null; - } | null; -} - -/** - * Pick the actually-most-recently-merged node from a page ordered by - * `updatedAt` — the two diverge whenever an older merged PR was touched - * (comment, label) more recently than a newer merge landed. - */ -function pickMostRecentlyMerged( - nodes: NonNullable< - NonNullable["pullRequests"] - >["nodes"], -) { - let best: NonNullable[number] | null = null; - let bestMergedAt = -Infinity; - for (const node of nodes ?? []) { - if (!node?.mergedAt) continue; - const mergedAt = Date.parse(node.mergedAt); - if (Number.isNaN(mergedAt) || mergedAt <= bestMergedAt) continue; - best = node; - bestMergedAt = mergedAt; - } - return best; -} - -/** Throws rather than reporting "never published" when the repo was hidden. */ -export function parseLastPublishedPr( - payload: LastPublishedPrResponse, -): LastPublishedPrResult { - const repository = payload.repository; - if (!repository) { - throw new Error( - "Repository not found or not accessible by this connection", - ); - } - const pr = pickMostRecentlyMerged(repository.pullRequests?.nodes); - if (!pr) return { pullRequest: null }; - - return { - pullRequest: { - number: pr.number ?? 0, - title: pr.title ?? "", - body: pr.body ?? "", - mergedAt: pr.mergedAt ?? null, - base: pr.baseRefName ?? "main", - head: pr.headRefName ?? "", - headSha: pr.headRefOid ?? "", - htmlUrl: pr.url ?? "", - author: pr.author?.login ?? "", - }, - }; -} - -export const GITHUB_LAST_PUBLISHED_PR = defineTool({ - name: "GITHUB_LAST_PUBLISHED_PR", - description: - "Read the most recently merged pull request into a base branch — in Fast Preview, the last publish.", - annotations: { - title: "Read Last Published Pull Request", - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: true, - }, - _meta: { ui: { visibility: "app" } }, - inputSchema: z.object({ - connectionId: z.string().describe("ID of the mcp-github connection to use"), - owner: z.string().describe("Repository owner (user or org login)"), - repo: z.string().describe("Repository name"), - base: z.string().describe("Base branch publishes merge into"), - }), - outputSchema: lastPublishedOutput, - handler: async (input, ctx) => { - await ctx.access.check(); - - const data = await githubGraphql(ctx, { - connectionId: input.connectionId, - query: LAST_PUBLISHED_QUERY, - variables: { owner: input.owner, repo: input.repo, base: input.base }, - label: `last published pull request for ${input.owner}/${input.repo}@${input.base}`, - operation: "last_published_pr", - }); - - return parseLastPublishedPr(data); - }, -}); diff --git a/apps/api/src/tools/github/pr-state.test.ts b/apps/api/src/tools/github/pr-state.test.ts deleted file mode 100644 index 5b14e3bff3..0000000000 --- a/apps/api/src/tools/github/pr-state.test.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { parsePrState, type PrStateResponse } from "./pr-state"; - -function payload(over: Record = {}): PrStateResponse { - return { - repository: { - pullRequests: { - nodes: [ - { - number: 42, - title: "Add hero section", - body: "", - state: "OPEN", - merged: false, - mergedAt: null, - isDraft: false, - mergeable: "MERGEABLE", - reviewDecision: null, - changedFiles: 3, - url: "https://github.com/acme/site/pull/42", - baseRefName: "main", - headRefName: "claude/hero", - headRefOid: "abc123", - headRepository: { nameWithOwner: "acme/site" }, - author: { login: "gimenes" }, - reviewThreads: { nodes: [] }, - comments: { nodes: [] }, - commits: { nodes: [] }, - ...over, - }, - ], - }, - }, - }; -} - -describe("parsePrState", () => { - it("maps a clean open pull request", () => { - const { pullRequest } = parsePrState(payload()); - - expect(pullRequest).toMatchObject({ - number: 42, - state: "open", - merged: false, - base: "main", - head: "claude/hero", - headSha: "abc123", - headRepoFullName: "acme/site", - author: "gimenes", - draft: false, - mergeableState: "clean", - unresolvedConversations: 0, - missingRequiredApprovals: false, - changedFiles: 3, - }); - }); - - it("returns null when the branch has no pull request", () => { - expect( - parsePrState({ repository: { pullRequests: { nodes: [] } } }), - ).toEqual({ pullRequest: null }); - }); - - it("throws when the repository is hidden rather than reporting no PR", () => { - expect(() => parsePrState({ repository: null })).toThrow( - /not found or not accessible/, - ); - }); - - /** GraphQL splits MERGED out of CLOSED; the panel's vocabulary does not. */ - it("reports a merged pull request as closed and merged", () => { - const { pullRequest } = parsePrState( - payload({ - state: "MERGED", - merged: true, - mergedAt: "2026-08-19T10:00:00Z", - }), - ); - - expect(pullRequest).toMatchObject({ - state: "closed", - merged: true, - mergedAt: "2026-08-19T10:00:00Z", - }); - }); - - it("reads conflicts off `mergeable`", () => { - expect( - parsePrState(payload({ mergeable: "CONFLICTING" })).pullRequest, - ).toMatchObject({ mergeableState: "dirty" }); - }); - - it("reports UNKNOWN mergeability rather than guessing clean", () => { - expect( - parsePrState(payload({ mergeable: "UNKNOWN" })).pullRequest, - ).toMatchObject({ mergeableState: "unknown" }); - }); - - /** - * The REST path inferred this from `mergeable_state === "blocked"` with no - * unresolved conversations — a guess that was wrong whenever branch - * protection blocked for any other reason. `reviewDecision` states it. - */ - it("reads missing approvals off reviewDecision, not a mergeable_state guess", () => { - expect( - parsePrState(payload({ reviewDecision: "REVIEW_REQUIRED" })).pullRequest, - ).toMatchObject({ - missingRequiredApprovals: true, - mergeableState: "blocked", - }); - - expect( - parsePrState(payload({ reviewDecision: "CHANGES_REQUESTED" })) - .pullRequest, - ).toMatchObject({ - missingRequiredApprovals: true, - mergeableState: "blocked", - }); - - expect( - parsePrState(payload({ reviewDecision: "APPROVED" })).pullRequest, - ).toMatchObject({ - missingRequiredApprovals: false, - mergeableState: "clean", - }); - }); - - /** - * The REST path used the raw `review_comments` COUNT, which counts every - * review comment ever left — resolved ones included. Only unresolved threads - * are something a person still has to act on. - */ - it("counts unresolved review threads, not every review comment", () => { - const { pullRequest } = parsePrState( - payload({ - reviewThreads: { - nodes: [ - { isResolved: true }, - { isResolved: false }, - { isResolved: true }, - { isResolved: false }, - ], - }, - }), - ); - - expect(pullRequest).toMatchObject({ - unresolvedConversations: 2, - mergeableState: "blocked", - }); - }); - - it("maps check runs, keeping the REST id GET_CHECK_RUN needs", () => { - const { pullRequest } = parsePrState( - payload({ - commits: { - nodes: [ - { - commit: { - statusCheckRollup: { - contexts: { - nodes: [ - { - __typename: "CheckRun", - databaseId: 987, - name: "build", - status: "COMPLETED", - conclusion: "SUCCESS", - detailsUrl: "https://github.com/acme/site/runs/987", - startedAt: "2026-08-19T10:00:00Z", - completedAt: "2026-08-19T10:01:30Z", - }, - ], - }, - }, - }, - }, - ], - }, - }), - ); - - expect(pullRequest?.checks).toEqual([ - { - id: "987", - name: "build", - status: "completed", - conclusion: "success", - htmlUrl: "https://github.com/acme/site/runs/987", - durationMs: 90_000, - }, - ]); - }); - - it("drops legacy status contexts, which the panel does not draw", () => { - const { pullRequest } = parsePrState( - payload({ - commits: { - nodes: [ - { - commit: { - statusCheckRollup: { - contexts: { - nodes: [ - { __typename: "StatusContext", context: "ci/legacy" }, - { - __typename: "CheckRun", - databaseId: 1, - name: "test", - status: "IN_PROGRESS", - conclusion: null, - }, - ], - }, - }, - }, - }, - ], - }, - }), - ); - - expect(pullRequest?.checks).toHaveLength(1); - expect(pullRequest?.checks[0]).toMatchObject({ - name: "test", - status: "in_progress", - conclusion: null, - durationMs: null, - }); - }); - - it("folds the conclusions REST has no word for onto ones the panel draws", () => { - const rollup = (conclusion: string) => ({ - commits: { - nodes: [ - { - commit: { - statusCheckRollup: { - contexts: { - nodes: [ - { - __typename: "CheckRun", - databaseId: 1, - name: "n", - status: "COMPLETED", - conclusion, - }, - ], - }, - }, - }, - }, - ], - }, - }); - - expect( - parsePrState(payload(rollup("STARTUP_FAILURE"))).pullRequest?.checks[0] - ?.conclusion, - ).toBe("failure"); - expect( - parsePrState(payload(rollup("STALE"))).pullRequest?.checks[0]?.conclusion, - ).toBe("neutral"); - }); - - it("survives a deleted fork and a commit with no GitHub account", () => { - const { pullRequest } = parsePrState( - payload({ headRepository: null, author: null }), - ); - - expect(pullRequest).toMatchObject({ - headRepoFullName: null, - author: "", - }); - }); - - it("maps issue comments", () => { - const { pullRequest } = parsePrState( - payload({ - comments: { - nodes: [ - { - databaseId: 7, - author: { login: "octocat" }, - body: "ship it", - createdAt: "2026-08-19T09:00:00Z", - url: "https://github.com/acme/site/pull/42#issuecomment-7", - }, - null, - ], - }, - }), - ); - - expect(pullRequest?.comments).toEqual([ - { - id: 7, - author: "octocat", - body: "ship it", - createdAt: "2026-08-19T09:00:00Z", - htmlUrl: "https://github.com/acme/site/pull/42#issuecomment-7", - }, - ]); - }); -}); diff --git a/apps/api/src/tools/github/pr-state.ts b/apps/api/src/tools/github/pr-state.ts deleted file mode 100644 index 7e3ff14fb8..0000000000 --- a/apps/api/src/tools/github/pr-state.ts +++ /dev/null @@ -1,365 +0,0 @@ -import { z } from "zod"; -import { defineTool } from "../../core/define-tool"; -import { githubGraphql } from "./graphql"; - -/** - * One read for everything the PR panel shows: the branch's pull request, its - * check runs, its review state and its comments, at ONE instant — four separate - * polls could render a PR's checks beside a different poll's mergeability. - * - * `reviewDecision` and `reviewThreads.isResolved` are the point. REST exposes - * neither, so the panel used to infer "blocked on a human" from - * `mergeable_state` and count unresolved conversations as every review comment - * ever left. - * - * Matched by `headRefName`, not by walking `repository.ref(...)`: a ref deleted - * after its merge makes the ref walk answer null, losing a merged PR the panel - * still shows. This matches the head ref the PR RECORDS, as REST's `head:` did. - */ -const PR_STATE_QUERY = ` -query PrState($owner: String!, $repo: String!, $branch: String!) { - repository(owner: $owner, name: $repo) { - pullRequests( - headRefName: $branch - first: 1 - orderBy: { field: UPDATED_AT, direction: DESC } - ) { - nodes { - number - title - body - state - merged - mergedAt - isDraft - mergeable - reviewDecision - changedFiles - url - baseRefName - headRefName - headRefOid - headRepository { nameWithOwner } - author { login } - reviewThreads(first: 100) { nodes { isResolved } } - comments(last: 50) { - nodes { databaseId author { login } body createdAt url } - } - commits(last: 1) { - nodes { - commit { - statusCheckRollup { - contexts(first: 100) { - nodes { - __typename - ... on CheckRun { - databaseId - name - status - conclusion - detailsUrl - startedAt - completedAt - } - } - } - } - } - } - } - } - } - } -}`; - -const checkRunSchema = z.object({ - /** REST check-run id — what GET_CHECK_RUN takes to load the run's output. */ - id: z.string(), - name: z.string(), - status: z.enum(["queued", "in_progress", "completed"]), - conclusion: z - .enum([ - "success", - "failure", - "neutral", - "cancelled", - "skipped", - "timed_out", - "action_required", - ]) - .nullable(), - htmlUrl: z.string(), - durationMs: z.number().nullable(), -}); - -const commentSchema = z.object({ - id: z.number(), - author: z.string(), - body: z.string(), - createdAt: z.string(), - htmlUrl: z.string(), -}); - -const pullRequestSchema = z.object({ - number: z.number(), - title: z.string(), - body: z.string(), - state: z.enum(["open", "closed"]), - merged: z.boolean(), - mergedAt: z.string().nullable(), - base: z.string(), - head: z.string(), - headSha: z.string(), - /** `owner/name` of the head repo; null when a fork was deleted. */ - headRepoFullName: z.string().nullable(), - htmlUrl: z.string(), - author: z.string(), - draft: z.boolean(), - /** Kept in the app's REST vocabulary so the panel state machine is unchanged. */ - mergeableState: z.enum(["clean", "dirty", "blocked", "unknown"]), - /** Review threads GitHub reports as unresolved — a count, not a guess. */ - unresolvedConversations: z.number(), - missingRequiredApprovals: z.boolean(), - /** Files the PR touches — the Changes tab's badge, without reading bodies. */ - changedFiles: z.number(), - checks: z.array(checkRunSchema), - comments: z.array(commentSchema), -}); - -const prStateOutput = z.object({ - /** null when the branch has no pull request at all. */ - pullRequest: pullRequestSchema.nullable(), -}); - -export type PrStateResult = z.infer; -type PullRequestState = z.infer; -type CheckRunState = z.infer; - -interface RawCheckRun { - __typename?: string | null; - databaseId?: number | null; - name?: string | null; - status?: string | null; - conclusion?: string | null; - detailsUrl?: string | null; - startedAt?: string | null; - completedAt?: string | null; -} - -interface RawPullRequest { - number?: number | null; - title?: string | null; - body?: string | null; - state?: string | null; - merged?: boolean | null; - mergedAt?: string | null; - isDraft?: boolean | null; - mergeable?: string | null; - reviewDecision?: string | null; - changedFiles?: number | null; - url?: string | null; - baseRefName?: string | null; - headRefName?: string | null; - headRefOid?: string | null; - headRepository?: { nameWithOwner?: string | null } | null; - author?: { login?: string | null } | null; - reviewThreads?: { - nodes?: Array<{ isResolved?: boolean | null } | null> | null; - } | null; - comments?: { - nodes?: Array<{ - databaseId?: number | null; - author?: { login?: string | null } | null; - body?: string | null; - createdAt?: string | null; - url?: string | null; - } | null> | null; - } | null; - commits?: { - nodes?: Array<{ - commit?: { - statusCheckRollup?: { - contexts?: { nodes?: Array | null } | null; - } | null; - } | null; - } | null> | null; - } | null; -} - -export interface PrStateResponse { - repository?: { - pullRequests?: { nodes?: Array | null } | null; - } | null; -} - -/** GraphQL's status enum is wider than the three states the panel draws. */ -function mapCheckStatus( - raw: string | null | undefined, -): CheckRunState["status"] { - if (raw === "IN_PROGRESS") return "in_progress"; - if (raw === "COMPLETED") return "completed"; - return "queued"; -} - -/** - * GraphQL carries two conclusions REST's vocabulary has no word for. - * `STARTUP_FAILURE` is a failure by any reading; `STALE` is a run superseded - * before it concluded, which is informational — neither may leak through as an - * unhandled string. - */ -function mapCheckConclusion( - raw: string | null | undefined, -): CheckRunState["conclusion"] { - switch (raw) { - case "SUCCESS": - return "success"; - case "FAILURE": - case "STARTUP_FAILURE": - return "failure"; - case "NEUTRAL": - case "STALE": - return "neutral"; - case "CANCELLED": - return "cancelled"; - case "SKIPPED": - return "skipped"; - case "TIMED_OUT": - return "timed_out"; - case "ACTION_REQUIRED": - return "action_required"; - default: - return null; - } -} - -/** The rollup also carries legacy StatusContexts; the panel draws check runs. */ -function mapChecks(pr: RawPullRequest): CheckRunState[] { - const contexts = - pr.commits?.nodes?.[0]?.commit?.statusCheckRollup?.contexts?.nodes ?? []; - const runs: CheckRunState[] = []; - for (const node of contexts) { - if (!node || node.__typename !== "CheckRun") continue; - const startedAt = node.startedAt; - const completedAt = node.completedAt; - runs.push({ - id: node.databaseId == null ? "" : String(node.databaseId), - name: node.name ?? "", - status: mapCheckStatus(node.status), - conclusion: mapCheckConclusion(node.conclusion), - htmlUrl: node.detailsUrl ?? "", - durationMs: - startedAt && completedAt - ? new Date(completedAt).getTime() - new Date(startedAt).getTime() - : null, - }); - } - return runs; -} - -/** - * Fold the GraphQL payload into the panel's shape. `mergeableState` keeps REST's - * vocabulary (the panel state machine is written against it) but is derived, not - * reported: "blocked" means blocked on a PERSON. Required status checks belong - * to `checks`, which the panel draws precisely rather than as a generic block. - */ -export function parsePrState(payload: PrStateResponse): PrStateResult { - const repository = payload.repository; - if (!repository) { - throw new Error( - "Repository not found or not accessible by this connection", - ); - } - const pr = repository.pullRequests?.nodes?.[0]; - if (!pr) return { pullRequest: null }; - - const unresolvedConversations = (pr.reviewThreads?.nodes ?? []).filter( - (thread) => thread?.isResolved === false, - ).length; - const decision = pr.reviewDecision; - const missingRequiredApprovals = - decision === "REVIEW_REQUIRED" || decision === "CHANGES_REQUESTED"; - - const mergeableState: PullRequestState["mergeableState"] = - pr.mergeable === "CONFLICTING" - ? "dirty" - : pr.mergeable !== "MERGEABLE" - ? "unknown" - : missingRequiredApprovals || unresolvedConversations > 0 - ? "blocked" - : "clean"; - - return { - pullRequest: { - number: pr.number ?? 0, - title: pr.title ?? "", - body: pr.body ?? "", - // GraphQL splits merged out of closed; the panel's vocabulary does not. - state: pr.state === "OPEN" ? "open" : "closed", - merged: pr.merged === true, - mergedAt: pr.mergedAt ?? null, - base: pr.baseRefName ?? "main", - head: pr.headRefName ?? "", - headSha: pr.headRefOid ?? "", - headRepoFullName: pr.headRepository?.nameWithOwner ?? null, - htmlUrl: pr.url ?? "", - author: pr.author?.login ?? "", - draft: pr.isDraft === true, - mergeableState, - unresolvedConversations, - missingRequiredApprovals, - changedFiles: pr.changedFiles ?? 0, - checks: mapChecks(pr), - comments: (pr.comments?.nodes ?? []).flatMap((comment) => - comment - ? [ - { - id: comment.databaseId ?? 0, - author: comment.author?.login ?? "", - body: comment.body ?? "", - createdAt: comment.createdAt ?? "", - htmlUrl: comment.url ?? "", - }, - ] - : [], - ), - }, - }; -} - -export const GITHUB_PR_STATE = defineTool({ - name: "GITHUB_PR_STATE", - description: - "Read a branch's pull request with its check runs, review state and comments in a single GitHub GraphQL query.", - annotations: { - title: "Read GitHub Pull Request State", - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: true, - }, - _meta: { ui: { visibility: "app" } }, - inputSchema: z.object({ - connectionId: z.string().describe("ID of the mcp-github connection to use"), - owner: z.string().describe("Repository owner (user or org login)"), - repo: z.string().describe("Repository name"), - branch: z.string().describe("Head branch of the pull request to read"), - }), - outputSchema: prStateOutput, - handler: async (input, ctx) => { - await ctx.access.check(); - - const data = await githubGraphql(ctx, { - connectionId: input.connectionId, - query: PR_STATE_QUERY, - variables: { - owner: input.owner, - repo: input.repo, - branch: input.branch, - }, - label: `pull request state for ${input.owner}/${input.repo}@${input.branch}`, - operation: "pr_state", - }); - - return parsePrState(data); - }, -}); diff --git a/apps/api/src/tools/github/search-branches.test.ts b/apps/api/src/tools/github/search-branches.test.ts deleted file mode 100644 index 140df8b0c5..0000000000 --- a/apps/api/src/tools/github/search-branches.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { isGithubConnection } from "@/oauth/github-mint"; -import { parseBranchSearchResponse } from "./search-branches"; - -const REPO = "acme/site"; - -describe("parseBranchSearchResponse", () => { - it("maps refs to branches with their author", () => { - const result = parseBranchSearchResponse( - { - repository: { - refs: { - totalCount: 2, - nodes: [ - { - name: "feat/search", - target: { author: { user: { login: "gimenes" } } }, - }, - { - name: "main", - target: { author: { user: { login: "octocat" } } }, - }, - ], - }, - }, - }, - REPO, - ); - - expect(result).toEqual({ - branches: [ - { name: "feat/search", author: "gimenes" }, - { name: "main", author: "octocat" }, - ], - totalCount: 2, - }); - }); - - it("nulls the author when the committer has no linked GitHub account", () => { - const result = parseBranchSearchResponse( - { - repository: { - refs: { - totalCount: 1, - nodes: [{ name: "fix-workspace-wallet", target: { author: {} } }], - }, - }, - }, - REPO, - ); - - expect(result.branches).toEqual([ - { name: "fix-workspace-wallet", author: null }, - ]); - }); - - it("nulls the author when the ref target is not a Commit", () => { - const result = parseBranchSearchResponse( - { repository: { refs: { totalCount: 1, nodes: [{ name: "odd" }] } } }, - REPO, - ); - - expect(result.branches).toEqual([{ name: "odd", author: null }]); - }); - - it("keeps totalCount above the returned page so callers can report the rest", () => { - const result = parseBranchSearchResponse( - { - repository: { refs: { totalCount: 195, nodes: [{ name: "fix-ui" }] } }, - }, - REPO, - ); - - expect(result.totalCount).toBe(195); - expect(result.branches).toHaveLength(1); - }); - - it("returns an empty result when nothing matched", () => { - const result = parseBranchSearchResponse( - { repository: { refs: { totalCount: 0, nodes: [] } } }, - REPO, - ); - - expect(result).toEqual({ branches: [], totalCount: 0 }); - }); - - it("throws when the repository is hidden, naming the repo", () => { - expect(() => parseBranchSearchResponse({ repository: null }, REPO)).toThrow( - /acme\/site not found or not accessible/, - ); - }); -}); - -/** - * The handler will not hand a connection's decrypted token to github.com - * unless it is a GitHub connection — otherwise any OAuth connection id in the - * caller's org would ship that provider's credential to an unrelated vendor. - */ -describe("isGithubConnection", () => { - it("accepts the mcp-github slug every GitHub flow already filters on", () => { - expect(isGithubConnection({ slug: "mcp-github" })).toBe(true); - }); - - it("rejects another provider's connection", () => { - expect(isGithubConnection({ slug: "mcp-slack" })).toBe(false); - expect(isGithubConnection({ slug: "mcp-jira" })).toBe(false); - }); - - it("rejects a connection with no slug", () => { - expect(isGithubConnection({})).toBe(false); - expect(isGithubConnection({ slug: null })).toBe(false); - }); -}); diff --git a/apps/api/src/tools/github/search-branches.ts b/apps/api/src/tools/github/search-branches.ts deleted file mode 100644 index 7379dfb713..0000000000 --- a/apps/api/src/tools/github/search-branches.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { z } from "zod"; -import { defineTool } from "../../core/define-tool"; -import { githubGraphql } from "./graphql"; - -/** - * Server-side branch search. - * - * REST `GET /repos/{owner}/{repo}/branches` (github-mcp-server's - * `list_branches`) takes no search, filter or sort parameter, so a picker built - * on it can only page 100-at-a-time and grep locally — on a repo with hundreds - * of branches the one you typed shows up several "load more" clicks later. - * - * GraphQL `repository.refs(query:)` filters by a case-insensitive SUBSTRING of - * the full ref name in one round trip ("upstream" finds - * `claude/fastpreview-upstream-authority`), which is what github.com's own - * branch dropdown uses. `totalCount` is the true match count, so the caller can - * say how many matches it is not showing. - * - * Note `orderBy` is deliberately ALPHABETICAL: GitHub silently ignores - * TAG_COMMIT_DATE for branch refs and returns alphabetical order anyway, so - * asking for recency here would be a lie. Ranking by recency would need a - * server-side answer — sorting this alphabetically-truncated window on the - * client would look ranked while omitting the actually-newest branch. - */ -const BRANCH_SEARCH_QUERY = ` -query BranchSearch($owner: String!, $repo: String!, $query: String, $limit: Int!) { - repository(owner: $owner, name: $repo) { - refs( - refPrefix: "refs/heads/" - query: $query - first: $limit - orderBy: { field: ALPHABETICAL, direction: ASC } - ) { - totalCount - nodes { - name - target { - ... on Commit { - author { user { login } } - } - } - } - } - } -}`; - -const branchSearchOutput = z.object({ - branches: z.array( - z.object({ - name: z.string(), - author: z.string().nullable(), - }), - ), - /** Total branches matching `query`, which may exceed `branches.length`. */ - totalCount: z.number(), -}); - -export type BranchSearchResult = z.infer; - -export interface BranchSearchData { - repository?: { - refs?: { - totalCount?: number; - nodes?: Array<{ - name?: string | null; - target?: { - author?: { user?: { login?: string | null } | null } | null; - } | null; - } | null> | null; - } | null; - } | null; -} - -/** - * Narrow the GraphQL payload to the tool's output. Every level is optional: a - * commit authored by an address with no GitHub account has `author.user === - * null`, as does a ref whose target does not resolve to a Commit. A hidden - * repository throws, so "no matches" never masks "not allowed to look". - */ -export function parseBranchSearchResponse( - payload: BranchSearchData, - repoLabel: string, -): BranchSearchResult { - const repository = payload.repository; - if (!repository) { - throw new Error( - `Repository ${repoLabel} not found or not accessible by this connection`, - ); - } - - const branches = (repository.refs?.nodes ?? []) - .filter( - (node): node is NonNullable & { name: string } => - typeof node?.name === "string", - ) - .map((node) => ({ - name: node.name, - author: node.target?.author?.user?.login ?? null, - })); - - return { - branches, - totalCount: repository.refs?.totalCount ?? branches.length, - }; -} - -export const GITHUB_SEARCH_BRANCHES = defineTool({ - name: "GITHUB_SEARCH_BRANCHES", - description: - "Search a repository's branches by a case-insensitive substring of the branch name, filtered server-side by GitHub.", - annotations: { - title: "Search GitHub Branches", - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: true, - }, - _meta: { ui: { visibility: "app" } }, - inputSchema: z.object({ - connectionId: z.string().describe("ID of the mcp-github connection to use"), - owner: z.string().describe("Repository owner (user or org login)"), - repo: z.string().describe("Repository name"), - query: z - .string() - .describe( - "Substring to match against branch names. Empty returns the first branches alphabetically.", - ), - limit: z.number().int().min(1).max(100).default(30), - }), - outputSchema: branchSearchOutput, - handler: async (input, ctx) => { - await ctx.access.check(); - - const repoLabel = `${input.owner}/${input.repo}`; - const search = input.query.trim(); - const data = await githubGraphql(ctx, { - connectionId: input.connectionId, - query: BRANCH_SEARCH_QUERY, - variables: { - owner: input.owner, - repo: input.repo, - // null is GraphQL's "no filter". - query: search === "" ? null : search, - limit: input.limit, - }, - label: `branch search for ${repoLabel}`, - operation: "branch_search", - }); - - return parseBranchSearchResponse(data, repoLabel); - }, -}); diff --git a/apps/api/src/tools/index.ts b/apps/api/src/tools/index.ts index aaffd4b8d9..e6eb959dce 100644 --- a/apps/api/src/tools/index.ts +++ b/apps/api/src/tools/index.ts @@ -44,6 +44,7 @@ import * as RegistryTools from "./registry/index"; import * as SandboxTools from "./sandbox"; import * as GitHubTools from "./github"; import * as GitTools from "./git"; +import * as ChangeRequestTools from "./change-requests"; import * as SearchTools from "./search"; import type { ToolName } from "@decocms/shared/tools/registry-metadata"; // Core tools - always available @@ -255,11 +256,8 @@ export const CORE_TOOLS = [ SandboxTools.SANDBOX_START, SandboxTools.SANDBOX_DELETE, - // GitHub tools (app-only) + // GitHub App installations (app-only) — the one surface that is GitHub's own GitHubTools.GITHUB_LIST_USER_ORGS, - GitHubTools.GITHUB_SEARCH_BRANCHES, - GitHubTools.GITHUB_PR_STATE, - GitHubTools.GITHUB_LAST_PUBLISHED_PR, // Git provider accounts + first-class repositories (app-only) GitTools.GIT_PROVIDER_CAPABILITIES, @@ -270,6 +268,15 @@ export const CORE_TOOLS = [ GitTools.REPOSITORY_SEARCH, GitTools.REPOSITORY_LINK, GitTools.REPOSITORY_DELETE, + GitTools.REPOSITORY_SEARCH_BRANCHES, + + // Change requests — pull requests on GitHub, merge requests on GitLab (app-only) + ChangeRequestTools.CHANGE_REQUEST_STATE, + ChangeRequestTools.CHANGE_REQUEST_LAST_MERGED, + ChangeRequestTools.CHANGE_REQUEST_LIST_OPEN, + ChangeRequestTools.CHANGE_REQUEST_CHECK_LOG, + ChangeRequestTools.CHANGE_REQUEST_OPEN, + ChangeRequestTools.CHANGE_REQUEST_MERGE, // Link tools diff --git a/apps/api/src/tools/sandbox/start.ts b/apps/api/src/tools/sandbox/start.ts index e4d4ba7c9f..63e033f750 100644 --- a/apps/api/src/tools/sandbox/start.ts +++ b/apps/api/src/tools/sandbox/start.ts @@ -80,7 +80,7 @@ import { cloneInfoForRepository, findRepositoryForLegacyBinding, repositoryUsesStudioCredentials, -} from "../../git-providers/credentials"; +} from "@/git-providers"; import { GitProviderError } from "../../git-providers/types"; import type { RepositoryRecord } from "../../storage/repositories"; import { diff --git a/apps/api/src/tools/sandbox/sync-git-credentials.ts b/apps/api/src/tools/sandbox/sync-git-credentials.ts index 6ef6343d48..bc298ab415 100644 --- a/apps/api/src/tools/sandbox/sync-git-credentials.ts +++ b/apps/api/src/tools/sandbox/sync-git-credentials.ts @@ -13,7 +13,7 @@ import { cloneInfoForRepository, findRepositoryForLegacyBinding, repositoryUsesStudioCredentials, -} from "../../git-providers/credentials"; +} from "@/git-providers"; /** Matches the cap `sandbox-proxy.ts` applies to `/_sandbox/config` responses. */ const CONFIG_RESPONSE_MAX_BYTES = 10 * 1024 * 1024; diff --git a/apps/api/src/tools/task-board/add-repo.ts b/apps/api/src/tools/task-board/add-repo.ts index 365169ef8b..2752fc6f79 100644 --- a/apps/api/src/tools/task-board/add-repo.ts +++ b/apps/api/src/tools/task-board/add-repo.ts @@ -32,7 +32,7 @@ import { cloneInfoForRepository, findRepositoryForLegacyBinding, repositoryUsesStudioCredentials, -} from "@/git-providers/credentials"; +} from "@/git-providers"; import { listOrgRepoChoices, type RepoChoice, diff --git a/apps/api/src/tools/task-board/archive-merged.integration.test.ts b/apps/api/src/tools/task-board/archive-merged.integration.test.ts index 372e4c77d4..2cadc1584a 100644 --- a/apps/api/src/tools/task-board/archive-merged.integration.test.ts +++ b/apps/api/src/tools/task-board/archive-merged.integration.test.ts @@ -59,8 +59,11 @@ describe("auto-archive sweep", () => { organizationId: ORG, url: `https://github.com/acme/repo/pull/${item.id}`, prNumber: 1, - repoOwner: "acme", - repoName: "repo", + repo: { + provider: "github", + host: "github.com", + path: "acme/repo", + }, }); } return item.id; @@ -189,8 +192,11 @@ describe("auto-archive sweep", () => { organizationId: ORG, url: `https://github.com/acme/${repoName}/pull/${prNumber}`, prNumber, - repoOwner: "acme", - repoName, + repo: { + provider: "github", + host: "github.com", + path: `acme/${repoName}`, + }, }); const bounced = await seed("done", settled, false); diff --git a/apps/api/src/tools/task-board/change-request-extract.ts b/apps/api/src/tools/task-board/change-request-extract.ts new file mode 100644 index 0000000000..875ebde8c2 --- /dev/null +++ b/apps/api/src/tools/task-board/change-request-extract.ts @@ -0,0 +1,52 @@ +/** + * Finding a change request's identity in whatever a run happened to produce. + * + * Every "the agent opened one" scenario collapses to the same primitive: find + * a change request URL in a string. A provider MCP tool result (a minimal + * `{ id, url }`, or a `{ number, html_url }`, or plain text), `gh pr create` / + * `glab mr create` stdout (both print the URL), and a raw + * `curl -X POST …/pulls` or `…/merge_requests` response body all embed one. So + * we stringify whatever we have and scan it — no per-shape branching, and no + * per-provider branching either: the URL says which provider it is. + */ + +import { + type ChangeRequestRef, + findChangeRequestUrl, + parseRepoUrl, + repoRefFromOwnerName, +} from "@decocms/shared/git-providers"; +import type { ChangeRequestOrigin } from "@/git-providers"; +import type { TaskBoardItemPrRef } from "@/storage/types"; + +export type { ChangeRequestRef }; + +/** Stringify any tool result / bash output and scan it for a change request. */ +export function findChangeRequestIn(value: unknown): ChangeRequestRef | null { + if (value == null) return null; + if (typeof value === "string") return findChangeRequestUrl(value); + let text: string; + try { + text = JSON.stringify(value); + } catch { + return null; + } + return text ? findChangeRequestUrl(text) : null; +} + +/** + * Which repository a linked row points at, and which credential reads it. + * + * The url is the source of truth for identity, because it carries the host and + * therefore the provider. `repoOwner`/`repoName` are the fallback for rows + * written before the url was canonicalised — those are all GitHub, which is + * why assuming that provider there is safe. + */ +export function originOf(pr: TaskBoardItemPrRef): ChangeRequestOrigin { + return { + repo: + parseRepoUrl(pr.url) ?? repoRefFromOwnerName(pr.repoOwner, pr.repoName), + repositoryId: pr.repositoryId, + connectionId: pr.connectionId, + }; +} diff --git a/apps/api/src/tools/task-board/checks-status.test.ts b/apps/api/src/tools/task-board/checks-status.test.ts index 2882fc10a5..e05b34c776 100644 --- a/apps/api/src/tools/task-board/checks-status.test.ts +++ b/apps/api/src/tools/task-board/checks-status.test.ts @@ -1,267 +1,253 @@ /** - * Pure mapping from a GitHub combined-status response to the three-value - * `checksStatus` the board renders. No I/O — the live fetch and its failure - * modes belong to e2e; this pins only the state translation. + * The card's own pure logic: which preview URL to trust, where to find it in + * what a provider reported, and when a provider's refusal is a rate limit. + * + * Provider-shaped mapping (a pull request's `mergeable_state`, a pipeline's + * status, a job's conclusion) is NOT here — it lives next to the + * implementation that owns that vocabulary, in + * `git-providers/change-requests/`. What is left is the part that is the same + * whoever answered. */ import { describe, expect, it } from "bun:test"; +import { GitProviderError } from "@/git-providers"; import { - checksFromMergeableState, - conflictFromPrGet, - previewMatchesHead, - extractPreviewUrl, - extractPreviewUrlFromDeployment, - headShaFromPrGet, - headShaFromStatus, + asHeadSha, + cardLifecycle, isAwaitingCi, isRateLimitError, - extractPreviewUrlFromCheckRuns, - extractPreviewUrlFromComments, isTrustedPreviewHost, - mergeChecksStatus, - parseCheckRuns, - toCheckRunsStatus, - toChecksStatus, + previewMatchesHead, + previewUrlFromChecks, + previewUrlFromComments, } from "./prs-get"; -describe("toChecksStatus", () => { - it("maps success → passing", () => { - expect(toChecksStatus({ state: "success", total_count: 3 })).toBe( - "passing", - ); +/** A completed, successful CI run — the shape both providers map into. */ +const run = ( + over: Partial<{ + name: string; + summary: string | null; + url: string | null; + conclusion: string | null; + state: string; + }> = {}, +) => + ({ + id: "1", + name: "build", + state: "completed" as const, + conclusion: "success" as const, + url: null, + durationMs: null, + summary: null, + ...over, + }) as Parameters[0][number]; + +describe("previewUrlFromChecks", () => { + /** + * Real Workers Builds run (deco-sites/demo-storefront#93) — the bot's + * comment for this Worker carries NO preview link, only the run's own + * report does. + */ + const workersRun = run({ + name: "Workers Builds: demo-storefront", + summary: + "\nBuild ID: [85b63568-6bf1-45d6-9d3b-57a558dee041](https://dash.cloudflare.com/x)\n" + + "Script: [demo-storefront](https://dash.cloudflare.com/y)\n" + + "Version ID: 1fe1ee18-b8d0-46ea-bdf1-45cef0cd6e4d\n", }); - it("maps failure and error → failing", () => { - expect(toChecksStatus({ state: "failure", total_count: 2 })).toBe( - "failing", + it("derives the version preview url from the Workers Builds report", () => { + expect(previewUrlFromChecks([workersRun])).toBe( + "https://1fe1ee18-demo-storefront.deco-cx.workers.dev", ); - expect(toChecksStatus({ state: "error", total_count: 1 })).toBe("failing"); }); - it("maps pending → pending", () => { - expect(toChecksStatus({ state: "pending", total_count: 1 })).toBe( - "pending", - ); + it("ignores runs that are not Workers Builds, or have no version yet", () => { + expect( + previewUrlFromChecks([ + run({ + name: "cubic · AI code reviewer", + summary: "Version ID: deadbeef-1", + }), + run({ + name: "Workers Builds: demo-storefront", + summary: "Build queued", + }), + run({ name: "Workers Builds: demo-storefront" }), + ]), + ).toBeNull(); + expect(previewUrlFromChecks([])).toBeNull(); }); - it("treats a PR with no checks (total_count 0) as null, not pending", () => { - expect(toChecksStatus({ state: "pending", total_count: 0 })).toBeNull(); + it("rejects a worker name that would forge a host outside workers.dev", () => { + expect( + previewUrlFromChecks([ + run({ + name: "Workers Builds: evil.example.com/x", + summary: "Version ID: 1fe1ee18-b8d0", + }), + ]), + ).toBeNull(); }); - it("is null for a missing response or unknown state", () => { - expect(toChecksStatus(null)).toBeNull(); - expect(toChecksStatus({ state: "weird", total_count: 1 })).toBeNull(); + /** + * The other shape: the run just LINKS the deploy. This is where a legacy + * commit status's target URL now arrives — both providers map a status and a + * check run into the same thing, which is what collapsed two separate reads + * into one. + */ + it("lifts a trusted preview URL a run links to", () => { + expect( + previewUrlFromChecks([ + run({ name: "ci", url: "https://github.com/acme/site/runs/1" }), + run({ name: "deploy", url: "https://envs-x--a1b2.decocdn.com" }), + ]), + ).toBe("https://envs-x--a1b2.decocdn.com"); }); -}); -describe("conflictFromPrGet", () => { - it("maps an open PR with mergeable === false → conflict (true)", () => { - expect(conflictFromPrGet({ state: "open", mergeable: false })).toBe(true); + it("prefers a succeeded run's link over an in-flight one", () => { + expect( + previewUrlFromChecks([ + run({ + name: "deploy", + state: "running", + conclusion: null, + url: "https://pending--a1b2.decocdn.com", + }), + run({ name: "deploy", url: "https://ready--c3d4.decocdn.com" }), + ]), + ).toBe("https://ready--c3d4.decocdn.com"); }); - it("maps an open, mergeable PR → false", () => { - expect(conflictFromPrGet({ state: "open", mergeable: true })).toBe(false); + it("is null when no run points at a trusted preview host", () => { + expect( + previewUrlFromChecks([ + run({ name: "ci", url: "https://evil.example.com/?x=.decocdn.com" }), + ]), + ).toBeNull(); }); +}); - it("is null when GitHub hasn't computed mergeability yet (mergeable null/absent)", () => { - // GitHub computes `mergeable` asynchronously — it's null right after a push. - // An unknown must NEVER read as a conflict (the caller only acts on `true`). - expect(conflictFromPrGet({ state: "open", mergeable: null })).toBeNull(); - expect(conflictFromPrGet({ state: "open" })).toBeNull(); - }); +describe("previewUrlFromComments", () => { + /** + * Real Cloudflare Workers bot comment (trimmed): both a commit and a branch + * preview — the branch one is what stays valid as the branch gets commits. + */ + const cloudflareBody = + "## Deploying with Cloudflare Workers\n| Status | Preview URL |\n| - | - |\n" + + "| ✅ | Commit Preview URL" + + "

Branch Preview URL |"; + const decobotBody = + "**deco Deployment** · commit `1059e10`\n\n| Name | Preview |\n| - | - |\n" + + "| example | [Visit Preview](https://envs-example--a1b2c3.decocdn.com) |"; + const vercelBody = + "| Project | Deployment | Actions | Updated (UTC) |\n| :--- | :----- | :------ | :------ |\n" + + "| [electrolux](https://vercel.com/deco13/electrolux) | [Ready](https://vercel.com/deco13/electrolux/GoTyhNUVcQSf7yKLG2yFtWjJ4sZA) | " + + "[Preview](https://electrolux-git-fix-pdp-focus-order-mobile-menu-deco13.vercel.app) | Aug 6, 2026 10:47pm |"; - it("treats a non-open PR as not-conflicting, never a conflict", () => { - // A merged/closed PR reports `mergeable: null` but must not read as a - // conflict (guards a just-merged PR from a spurious resolution run). - expect(conflictFromPrGet({ state: "closed", mergeable: null })).toBe(false); - expect(conflictFromPrGet({ state: "merged", mergeable: false })).toBe( - false, + it("prefers the Cloudflare Branch Preview URL over the commit one", () => { + expect(previewUrlFromComments([{ body: cloudflareBody }])).toBe( + "https://fix-home-title-agents-247-1785513527-decocms-tanstack.deco-cx.workers.dev", ); }); - it("is null for a missing response", () => { - expect(conflictFromPrGet(null)).toBeNull(); + it("lifts the deco.cx 'Visit Preview' markdown link", () => { + expect(previewUrlFromComments([{ body: decobotBody }])).toBe( + "https://envs-example--a1b2c3.decocdn.com", + ); }); - it("reads mergeable_state — github-mcp's MinimalPullRequest has no `mergeable`", () => { - expect(conflictFromPrGet({ state: "open", mergeable_state: "dirty" })).toBe( - true, - ); - expect(conflictFromPrGet({ state: "open", mergeable_state: "clean" })).toBe( - false, + it("lifts the Vercel bot's Preview link", () => { + expect(previewUrlFromComments([{ body: vercelBody }])).toBe( + "https://electrolux-git-fix-pdp-focus-order-mobile-menu-deco13.vercel.app", ); - expect( - conflictFromPrGet({ state: "open", mergeable_state: "blocked" }), - ).toBe(false); }); - it("is null when mergeable_state is unknown or empty — still computing", () => { - expect( - conflictFromPrGet({ state: "open", mergeable_state: "unknown" }), - ).toBeNull(); - expect( - conflictFromPrGet({ state: "open", mergeable_state: "" }), - ).toBeNull(); + it("is null with no preview comment", () => { + expect(previewUrlFromComments([])).toBeNull(); + expect(previewUrlFromComments([{ body: "LGTM, nice work!" }])).toBeNull(); }); - it("prefers the boolean when a non-minimal response carries both", () => { + it("prefers the newest comment, so a stale re-deploy comment doesn't win", () => { expect( - conflictFromPrGet({ - state: "open", - mergeable: true, - mergeable_state: "dirty", - }), - ).toBe(false); - }); -}); - -describe("extractPreviewUrl", () => { - it("lifts a deco preview URL from a status target_url (Deno + Tanstack hosts)", () => { - expect( - extractPreviewUrl({ - statuses: [ - { - context: "ci/lint", - state: "success", - target_url: "https://ci.example.com/1", - }, - { - context: "deco/preview", - state: "success", - target_url: "https://envs-example--a1b2c3.decocdn.com/", - }, - ], - }), - ).toBe("https://envs-example--a1b2c3.decocdn.com/"); - - expect( - extractPreviewUrl({ - statuses: [ - { - state: "success", - target_url: - "https://fix-home-title-agents-247-1785513527-decocms-tanstack.deco-cx.workers.dev/", - }, - ], - }), + previewUrlFromComments([ + { + body: "[Preview](https://electrolux-git-old-stale-deploy-deco13.vercel.app)", + createdAt: "2026-08-01T00:00:00Z", + updatedAt: "2026-08-01T00:00:00Z", + }, + { + body: vercelBody, + createdAt: "2026-08-06T22:32:55Z", + updatedAt: "2026-08-06T22:32:55Z", + }, + ]), ).toBe( - "https://fix-home-title-agents-247-1785513527-decocms-tanstack.deco-cx.workers.dev/", + "https://electrolux-git-fix-pdp-focus-order-mobile-menu-deco13.vercel.app", ); }); - it("prefers a succeeded preview status over a pending one", () => { + /** + * Vercel and Cloudflare edit ONE sticky comment on each push, so + * `createdAt` stays frozen at the first post and only `updatedAt` moves. A + * human comment posted in between (with a coincidentally matching host) + * must not outrank the bot's freshly edited one. + */ + it("ranks by updatedAt, so a sticky comment beats a later unrelated one", () => { expect( - extractPreviewUrl({ - statuses: [ - { state: "pending", target_url: "https://old--a.decocdn.com/" }, - { state: "success", target_url: "https://new--b.decocdn.com/" }, - ], - }), - ).toBe("https://new--b.decocdn.com/"); + previewUrlFromComments([ + { + body: vercelBody, + createdAt: "2026-08-01T00:00:00Z", + updatedAt: "2026-08-06T22:32:55Z", + }, + { + body: "unrelated note, see https://some-other.vercel.app", + createdAt: "2026-08-03T00:00:00Z", + updatedAt: "2026-08-03T00:00:00Z", + }, + ]), + ).toBe( + "https://electrolux-git-fix-pdp-focus-order-mobile-menu-deco13.vercel.app", + ); }); - it("is null when no status points at a deco preview host", () => { - expect(extractPreviewUrl(null)).toBeNull(); + it("falls back to array order when no timestamp is usable", () => { expect( - extractPreviewUrl({ - statuses: [ - { state: "success", target_url: "https://ci.example.com/x" }, - ], - }), - ).toBeNull(); - expect(extractPreviewUrl({})).toBeNull(); + previewUrlFromComments([{ body: decobotBody }, { body: vercelBody }]), + ).toBe("https://envs-example--a1b2c3.decocdn.com"); }); }); -describe("toCheckRunsStatus", () => { - it("maps GitHub Actions check-runs (a real failing 'Deco / QA' run)", () => { - expect( - toCheckRunsStatus({ - check_runs: [ - { - name: "Deco / QA / Purchase journey", - status: "completed", - conclusion: "failure", - }, - ], - }), - ).toBe("failing"); - }); - - it("is passing when all runs completed successfully (incl. neutral/skipped)", () => { - expect( - toCheckRunsStatus({ - check_runs: [ - { status: "completed", conclusion: "success" }, - { status: "completed", conclusion: "skipped" }, - ], - }), - ).toBe("passing"); - }); - - it("is pending while any run is not completed", () => { - expect( - toCheckRunsStatus([ - { status: "completed", conclusion: "success" }, - { status: "in_progress", conclusion: null }, - ]), - ).toBe("pending"); +describe("asHeadSha", () => { + it("accepts a 7-to-40 char hex sha", () => { + expect(asHeadSha("1059e10")).toBe("1059e10"); + expect(asHeadSha("a".repeat(40))).toBe("a".repeat(40)); }); - it("is null with no check-runs", () => { - expect(toCheckRunsStatus({ check_runs: [] })).toBeNull(); - expect(toCheckRunsStatus(null)).toBeNull(); + /** Also what keeps a malformed value out of the deployment lookup. */ + it("rejects anything else", () => { + expect(asHeadSha("main")).toBeNull(); + expect(asHeadSha("12345")).toBeNull(); + expect(asHeadSha("a".repeat(41))).toBeNull(); + expect(asHeadSha(undefined)).toBeNull(); + expect(asHeadSha(123)).toBeNull(); }); }); -describe("parseCheckRuns", () => { - it("flattens a get_check_runs result to name/status/conclusion/detailsUrl", () => { - expect( - parseCheckRuns({ - check_runs: [ - { - id: 42, - name: "Deco / QA", - status: "completed", - conclusion: "failure", - html_url: "https://github.com/x/y/runs/42", - }, - ], - }), - ).toEqual([ - { - id: 42, - name: "Deco / QA", - status: "completed", - conclusion: "failure", - detailsUrl: "https://github.com/x/y/runs/42", - }, - ]); - }); - - it("accepts a raw array and tolerates missing fields", () => { - expect(parseCheckRuns([{ name: "lint" }])).toEqual([ - { - id: null, - name: "lint", - status: "completed", - conclusion: null, - detailsUrl: null, - }, - ]); - expect(parseCheckRuns(null)).toEqual([]); - }); -}); - -describe("mergeChecksStatus", () => { - it("takes the worst of the two (failing > pending > passing > null)", () => { - expect(mergeChecksStatus(null, "failing")).toBe("failing"); - expect(mergeChecksStatus("passing", "pending")).toBe("pending"); - expect(mergeChecksStatus("passing", null)).toBe("passing"); - expect(mergeChecksStatus(null, null)).toBeNull(); - // Seen in production: empty combined status (null) + failing check-run → failing. - expect( - mergeChecksStatus(toChecksStatus({ total_count: 0 }), "failing"), - ).toBe("failing"); +describe("cardLifecycle", () => { + /** + * `merged` alone cannot answer whether the work landed: a closed-unmerged + * change request and an open one both report false, and only the first is + * settled. So the card keeps both fields, derived from the one state. + */ + it("splits the three provider states into the card's two fields", () => { + expect(cardLifecycle("open")).toEqual({ state: "open", merged: false }); + expect(cardLifecycle("closed")).toEqual({ + state: "closed", + merged: false, + }); + expect(cardLifecycle("merged")).toEqual({ state: "closed", merged: true }); }); }); @@ -310,239 +296,6 @@ describe("isTrustedPreviewHost", () => { }); }); -describe("extractPreviewUrlFromCheckRuns", () => { - // Real Workers Builds check-run shape (deco-sites/demo-storefront#93) — the - // bot's PR comment for this Worker carries NO preview link, only this does. - const workersRun = { - name: "Workers Builds: demo-storefront", - output: { - title: "Workers Builds: demo-storefront", - summary: - "\nBuild ID: [85b63568-6bf1-45d6-9d3b-57a558dee041](https://dash.cloudflare.com/x)\n" + - "Script: [demo-storefront](https://dash.cloudflare.com/y)\n" + - "Version ID: 1fe1ee18-b8d0-46ea-bdf1-45cef0cd6e4d\n", - }, - }; - - it("derives the version preview url from the Workers Builds summary", () => { - expect(extractPreviewUrlFromCheckRuns({ check_runs: [workersRun] })).toBe( - "https://1fe1ee18-demo-storefront.deco-cx.workers.dev", - ); - expect(extractPreviewUrlFromCheckRuns([workersRun])).toBe( - "https://1fe1ee18-demo-storefront.deco-cx.workers.dev", - ); - }); - - it("ignores runs that are not Workers Builds, or have no version yet", () => { - expect( - extractPreviewUrlFromCheckRuns([ - { - name: "cubic · AI code reviewer", - output: { summary: "Version ID: deadbeef-1" }, - }, - { - name: "Workers Builds: demo-storefront", - output: { summary: "Build queued" }, - }, - { name: "Workers Builds: demo-storefront" }, - ]), - ).toBeNull(); - expect(extractPreviewUrlFromCheckRuns(null)).toBeNull(); - expect(extractPreviewUrlFromCheckRuns({})).toBeNull(); - }); - - it("rejects a worker name that would forge a host outside workers.dev", () => { - expect( - extractPreviewUrlFromCheckRuns([ - { - name: "Workers Builds: evil.example.com/x", - output: { summary: "Version ID: 1fe1ee18-b8d0" }, - }, - ]), - ).toBeNull(); - }); -}); - -describe("extractPreviewUrlFromComments", () => { - // Real Cloudflare Workers bot comment shape (trimmed): both a commit and a - // branch preview — the branch one is what we want. - const cloudflareBody = - "## Deploying with Cloudflare Workers\n| Status | Preview URL |\n| - | - |\n" + - "| ✅ | Commit Preview URL" + - "

Branch Preview URL |"; - // Real deco.cx `decobot` comment shape (trimmed). - const decobotBody = - "**deco Deployment** · commit `1059e10`\n\n| Name | Preview |\n| - | - |\n" + - "| example | [Visit Preview](https://envs-example--a1b2c3.decocdn.com) |"; - // Real Vercel bot comment shape (trimmed, from deco-sites/electrolux#10). - const vercelBody = - "| Project | Deployment | Actions | Updated (UTC) |\n| :--- | :----- | :------ | :------ |\n" + - "| [electrolux](https://vercel.com/deco13/electrolux) | ![Ready](https://vercel.com/static/status/ready.svg) [Ready](https://vercel.com/deco13/electrolux/GoTyhNUVcQSf7yKLG2yFtWjJ4sZA) | " + - "[Preview](https://electrolux-git-fix-pdp-focus-order-mobile-menu-deco13.vercel.app) | Aug 6, 2026 10:47pm |"; - - it("prefers the Cloudflare Branch Preview URL over the commit one", () => { - expect(extractPreviewUrlFromComments([{ body: cloudflareBody }])).toBe( - "https://fix-home-title-agents-247-1785513527-decocms-tanstack.deco-cx.workers.dev", - ); - }); - - it("lifts the deco.cx 'Visit Preview' markdown link", () => { - expect(extractPreviewUrlFromComments([{ body: decobotBody }])).toBe( - "https://envs-example--a1b2c3.decocdn.com", - ); - }); - - it("lifts the Vercel bot's Preview link", () => { - expect(extractPreviewUrlFromComments([{ body: vercelBody }])).toBe( - "https://electrolux-git-fix-pdp-focus-order-mobile-menu-deco13.vercel.app", - ); - }); - - it("accepts the { comments } and { items } wrapper shapes", () => { - expect( - extractPreviewUrlFromComments({ comments: [{ body: decobotBody }] }), - ).toBe("https://envs-example--a1b2c3.decocdn.com"); - expect( - extractPreviewUrlFromComments({ items: [{ body: decobotBody }] }), - ).toBe("https://envs-example--a1b2c3.decocdn.com"); - }); - - it("is null with no deco preview comment", () => { - expect(extractPreviewUrlFromComments([])).toBeNull(); - expect(extractPreviewUrlFromComments(null)).toBeNull(); - expect( - extractPreviewUrlFromComments([{ body: "LGTM, nice work!" }]), - ).toBeNull(); - }); - - it("prefers the newest comment by updated_at over array order, so a stale re-deploy comment doesn't win", () => { - const staleVercelBody = - "[Preview](https://electrolux-git-old-stale-deploy-deco13.vercel.app)"; - const fresh = extractPreviewUrlFromComments([ - { - body: staleVercelBody, - created_at: "2026-08-01T00:00:00Z", - updated_at: "2026-08-01T00:00:00Z", - }, - { - body: vercelBody, - created_at: "2026-08-06T22:32:55Z", - updated_at: "2026-08-06T22:32:55Z", - }, - ]); - expect(fresh).toBe( - "https://electrolux-git-fix-pdp-focus-order-mobile-menu-deco13.vercel.app", - ); - }); - - it("prefers a comment's updated_at over its created_at, so a sticky comment edited in place beats an unrelated later comment", () => { - // Vercel/Cloudflare edit a single sticky comment on each push — created_at - // stays frozen at the FIRST post, only updated_at moves forward. A human - // comment posted in between (with a coincidentally-matching host) must - // NOT outrank the bot's freshly-edited comment just because its - // created_at is later. - const stickyBotComment = { - body: vercelBody, - created_at: "2026-08-01T00:00:00Z", // first posted early in the PR - updated_at: "2026-08-06T22:32:55Z", // edited in place on the latest push - }; - const laterUnrelatedComment = { - body: "unrelated review note, check https://some-other.vercel.app for reference", - created_at: "2026-08-03T00:00:00Z", // posted after the bot's first post... - updated_at: "2026-08-03T00:00:00Z", // ...but never edited again - }; - expect( - extractPreviewUrlFromComments([stickyBotComment, laterUnrelatedComment]), - ).toBe( - "https://electrolux-git-fix-pdp-focus-order-mobile-menu-deco13.vercel.app", - ); - }); - - it("falls back to created_at, then array order, when updated_at is missing", () => { - expect( - extractPreviewUrlFromComments([ - { body: decobotBody }, - { body: vercelBody }, - ]), - ).toBe("https://envs-example--a1b2c3.decocdn.com"); - }); -}); - -describe("extractPreviewUrlFromDeployment", () => { - it("lifts a trusted environmentUrl from a GET_PREVIEW_DEPLOYMENT result", () => { - expect( - extractPreviewUrlFromDeployment({ - environmentUrl: "https://sfj-b212cf4--torrafaststore.preview.vtex.app", - environment: "staging", - state: "success", - deploymentId: 42, - }), - ).toBe("https://sfj-b212cf4--torrafaststore.preview.vtex.app"); - }); - - it("is null when no deployment has published a url yet (in-flight)", () => { - expect( - extractPreviewUrlFromDeployment({ - environmentUrl: null, - environment: null, - state: null, - deploymentId: null, - }), - ).toBeNull(); - expect(extractPreviewUrlFromDeployment(null)).toBeNull(); - expect(extractPreviewUrlFromDeployment({})).toBeNull(); - }); - - it("rejects an untrusted environmentUrl host", () => { - expect( - extractPreviewUrlFromDeployment({ - environmentUrl: - "https://evil.example.com/torrafaststore.preview.vtex.app", - }), - ).toBeNull(); - }); -}); - -describe("headShaFromPrGet", () => { - it("reads head.sha from a pull_request_read get response", () => { - expect( - headShaFromPrGet({ - state: "open", - head: { ref: "fix/x", sha: "f9f522ce9642cf7f2024e45b9ddc618a6f78bf8c" }, - }), - ).toBe("f9f522ce9642cf7f2024e45b9ddc618a6f78bf8c"); - }); - - it("is null when head/sha is absent or not a hex sha", () => { - expect(headShaFromPrGet(null)).toBeNull(); - expect(headShaFromPrGet({})).toBeNull(); - expect(headShaFromPrGet({ head: {} })).toBeNull(); - expect(headShaFromPrGet({ head: { sha: 123 } })).toBeNull(); - expect(headShaFromPrGet({ head: { sha: "not-a-sha" } })).toBeNull(); - expect(headShaFromPrGet({ head: "nope" })).toBeNull(); - }); -}); - -describe("headShaFromStatus", () => { - it("reads the head sha from a combined-status response", () => { - expect( - headShaFromStatus({ - state: "success", - sha: "f9f522ce9642cf7f2024e45b9ddc618a6f78bf8c", - total_count: 1, - }), - ).toBe("f9f522ce9642cf7f2024e45b9ddc618a6f78bf8c"); - }); - - it("is null when the sha is absent or not a hex sha", () => { - expect(headShaFromStatus(null)).toBeNull(); - expect(headShaFromStatus({})).toBeNull(); - expect(headShaFromStatus({ sha: 123 })).toBeNull(); - expect(headShaFromStatus({ sha: "not-a-sha" })).toBeNull(); - expect(headShaFromStatus({ sha: "abc/../def" })).toBeNull(); - }); -}); - describe("isRateLimitError", () => { // These are the exact strings the GitHub MCP surfaced in prod while the board // hammered it; retrying any of them is what kept the limit shut. @@ -586,23 +339,26 @@ describe("isRateLimitError", () => { ), ).toBe(true); }); -}); - -describe("checksFromMergeableState", () => { - it("maps the two unambiguous values", () => { - expect(checksFromMergeableState("clean")).toBe("passing"); - expect(checksFromMergeableState("unstable")).toBe("failing"); - }); - - it("is null for everything that says nothing about checks", () => { - // `blocked` is the load-bearing one: it also covers a missing required - // review, so reading it as red would hold QA on a healthy deploy. - expect(checksFromMergeableState("blocked")).toBeNull(); - expect(checksFromMergeableState("dirty")).toBeNull(); - expect(checksFromMergeableState("behind")).toBeNull(); - expect(checksFromMergeableState("unknown")).toBeNull(); - expect(checksFromMergeableState(undefined)).toBeNull(); - expect(checksFromMergeableState(null)).toBeNull(); + /** A provider client says so outright, so no phrase matching is needed. */ + it("recognises a typed provider rate-limit refusal", () => { + expect( + isRateLimitError( + new GitProviderError({ + provider: "gitlab", + status: 429, + message: "GitLab API 429: Retry later", + }), + ), + ).toBe(true); + expect( + isRateLimitError( + new GitProviderError({ + provider: "gitlab", + status: 404, + message: "GitLab API 404: Not Found", + }), + ), + ).toBe(false); }); }); @@ -657,3 +413,17 @@ describe("isAwaitingCi", () => { expect(isAwaitingCi({ checksStatus: null })).toBe(false); }); }); + +describe("isAwaitingCi", () => { + it("keeps refreshing while CI runs", () => { + // Was false whenever a preview URL had already been found, so that card got + // the full hit window and showed "Checks pending" long after they passed. + expect(isAwaitingCi({ checksStatus: "pending" })).toBe(true); + }); + + it("caches normally once CI settles", () => { + expect(isAwaitingCi({ checksStatus: "passing" })).toBe(false); + expect(isAwaitingCi({ checksStatus: "failing" })).toBe(false); + expect(isAwaitingCi({ checksStatus: null })).toBe(false); + }); +}); diff --git a/apps/api/src/tools/task-board/create.ts b/apps/api/src/tools/task-board/create.ts index 2b016ab840..f50797c04a 100644 --- a/apps/api/src/tools/task-board/create.ts +++ b/apps/api/src/tools/task-board/create.ts @@ -17,7 +17,7 @@ import { assertValidAssignee } from "./validate-assignee"; import { reactToSuperAgentDelegation } from "./enqueue-super-agent"; import { recordTaskActivity } from "./activity"; import { emitTaskBoardUpdated } from "./run-reactions"; -import { extractPrFromText } from "./pr-extract"; +import { findChangeRequestIn } from "./change-request-extract"; import { invalidatePrCards } from "./prs-get"; import { rejectsUngatedDeliveryLane } from "./update"; @@ -69,11 +69,12 @@ export const TASK_BOARD_ITEM_CREATE = defineTool({ } // Parse the PR link before any write, so a bad URL fails without orphaning a card. - const pr = input.prUrl ? extractPrFromText(input.prUrl) : null; + const pr = input.prUrl ? findChangeRequestIn(input.prUrl) : null; if (input.prUrl && !pr) { throw new Error( - `Not a GitHub pull request URL: ${input.prUrl} (expected ` + - "https://github.com///pull/)", + `Not a change request URL: ${input.prUrl} (expected ` + + "https://github.com///pull/ or " + + "https://gitlab.com///-/merge_requests/)", ); } @@ -142,8 +143,7 @@ export const TASK_BOARD_ITEM_CREATE = defineTool({ organizationId, url: pr.url, prNumber: pr.number, - repoOwner: pr.owner, - repoName: pr.repo, + repo: pr.repo, connectionId: null, }); // Drop the cached card so a viewer's next poll shows the new PR, not a stale "no PR" placeholder. diff --git a/apps/api/src/tools/task-board/list.ts b/apps/api/src/tools/task-board/list.ts index cb6c15ab3f..840b3fafd5 100644 --- a/apps/api/src/tools/task-board/list.ts +++ b/apps/api/src/tools/task-board/list.ts @@ -45,11 +45,23 @@ export const TASK_BOARD_ITEM_LIST = defineTool({ ); } - const [items, { items: githubConnections }] = await Promise.all([ - ctx.storage.taskBoard.list(organizationId), - ctx.storage.connections.list(organizationId, { slug: "mcp-github" }), - ]); - const repos = listRepoScopeLabels(githubConnections); + const [items, { items: githubConnections }, repositories] = + await Promise.all([ + ctx.storage.taskBoard.list(organizationId), + ctx.storage.connections.list(organizationId, { slug: "mcp-github" }), + ctx.storage.repositories.listByOrg(organizationId), + ]); + /** + * Both eras, deduped: the org's first-class repositories and the legacy + * repo-scoped connections. A GitLab-only org has no connection at all, so + * reading only those was what left its board with no repository filter. + */ + const repos = [ + ...new Set([ + ...repositories.map((repository) => repository.path), + ...listRepoScopeLabels(githubConnections), + ]), + ]; // Opening the board is the stall-recovery trigger: re-run the thread-finish // decision over the list we just loaded, for the cards whose finish hook diff --git a/apps/api/src/tools/task-board/merge-pr.test.ts b/apps/api/src/tools/task-board/merge-pr.test.ts index 3dbfaba97d..74ac0a02bf 100644 --- a/apps/api/src/tools/task-board/merge-pr.test.ts +++ b/apps/api/src/tools/task-board/merge-pr.test.ts @@ -9,22 +9,7 @@ import { describe, expect, it } from "bun:test"; import type { ReviewCycleActivity } from "@decocms/shared/task-board"; import { approvedButUnverified } from "@decocms/shared/task-board"; -import { - checksBlockMerge, - classifyMergeResult, - conflictSignal, - isMergeMethodNotAllowed, - mayBeConflict, -} from "./merge-pr"; - -/** The MCP tool-result shape `classifyMergeResult` reads: an error with a text - * content array (what GitHub's refusal actually arrives as). */ -const errorResult = (text: string) => ({ - isError: true, - content: [{ type: "text", text }], -}); -const notAllowed = (method: string, pr = 71) => - `failed to merge pull request: PUT https://api.github.com/repos/o/r/pulls/${pr}/merge: 405 ${method} are not allowed on this repository. []`; +import { checksBlockMerge, conflictFromOutcome, reasonFor } from "./merge-pr"; const ENABLED = ["reviewer"] as const; const at = "2026-08-12T00:00:00.000Z"; @@ -37,121 +22,62 @@ const approved = ( occurredAt: at, }); -describe("mayBeConflict", () => { - it("is true for a plain refusal — the one outcome a conflict looks like", () => { - expect(mayBeConflict({ merged: false, reason: "refused" })).toBe(true); - expect( - mayBeConflict({ - merged: false, - reason: "refused", - detail: "405 Pull Request has merge conflicts", - }), - ).toBe(true); +describe("reasonFor", () => { + /** The one refusal with an automatic answer: the agent can rebase. */ + it("keeps a conflict distinguishable from every other refusal", () => { + expect(reasonFor({ merged: false, reason: "conflict", detail: "" })).toBe( + "conflict", + ); + expect(reasonFor({ merged: false, reason: "blocked", detail: "" })).toBe( + "refused", + ); }); - // A 429 says nothing about mergeability, and re-asking IS the burst. - it("is false for a rate limit however it is detailed", () => { + /** A 429 says nothing about mergeability, and re-asking IS the burst. */ + it("reports a rate limit as its own reason, never as a refusal", () => { expect( - mayBeConflict({ - merged: false, - reason: "rate_limited", - detail: "Streamable HTTP error: too many requests", - }), - ).toBe(false); - expect(mayBeConflict({ merged: false, reason: "rate_limited" })).toBe( - false, - ); + reasonFor({ merged: false, reason: "rate_limited", detail: "" }), + ).toBe("rate_limited"); }); - it("is false for every non-refusal outcome", () => { - expect(mayBeConflict({ merged: true })).toBe(false); - for (const reason of [ + it("reads 'it is not there' as the card's no-PR case", () => { + expect(reasonFor({ merged: false, reason: "not_found", detail: "" })).toBe( "no_pr", - "checks_pending", - "checks_failing", - "no_connection", - "rate_limited", - "error", - ] as const) { - expect(mayBeConflict({ merged: false, reason })).toBe(false); - } - }); -}); - -describe("isMergeMethodNotAllowed", () => { - // The 405 a repo returns when it forbids the merge method just tried. - it("is true for the 405 that means the repo forbids this method", () => { - for (const detail of [ - "PUT https://api.github.com/repos/o/r/pulls/71/merge: 405 Merge commits are not allowed on this repository. []", - "405 Squash merges are not allowed on this repository", - "405 Rebase merges are not allowed on this repository", - ]) { - expect(isMergeMethodNotAllowed(detail)).toBe(true); - } - }); - - // A conflict is also a 405, but no other method fixes it — must NOT advance. - it("is false for a 405 that is not a forbidden-method refusal", () => { - expect(isMergeMethodNotAllowed("405 Pull Request is not mergeable")).toBe( - false, ); - expect(isMergeMethodNotAllowed("405 Method Not Allowed")).toBe(false); }); - // Every other refusal shape is method-independent and reported as-is. - it("is false for non-405 refusals", () => { - expect(isMergeMethodNotAllowed("409 Merge conflict")).toBe(false); - expect( - isMergeMethodNotAllowed( - "422 At least 1 approving review is required by reviewers with write access", - ), - ).toBe(false); - expect(isMergeMethodNotAllowed("")).toBe(false); + it("falls back to error for a transport failure", () => { + expect(reasonFor({ merged: false, reason: "error", detail: "boom" })).toBe( + "error", + ); }); }); -describe("classifyMergeResult", () => { - it("is 'merged' when the tool call did not error", () => { - expect(classifyMergeResult({}).kind).toBe("merged"); - expect(classifyMergeResult({ isError: false }).kind).toBe("merged"); - }); - - it("reads the real serialized content-array wire shape", () => { - const a = classifyMergeResult(errorResult(notAllowed("Merge commits"))); - expect(a.kind).toBe("method_not_allowed"); - if (a.kind === "method_not_allowed") { - expect(a.detail).toContain("not allowed on this repository"); - } - }); - - // PR #429 puts a bare 429 in the error URL — method-not-allowed must still win. - it("classifies a forbidden method on PR #429 as method_not_allowed, not rate-limited", () => { - const a = classifyMergeResult( - errorResult(notAllowed("Merge commits", 429)), +describe("conflictFromOutcome", () => { + it("is true only for a classified conflict", () => { + expect(conflictFromOutcome({ merged: false, reason: "conflict" })).toBe( + true, ); - expect(a.kind).toBe("method_not_allowed"); - }); - - it("is 'rate_limited' for a genuine too-many-requests refusal", () => { - expect( - classifyMergeResult( - errorResult("Streamable HTTP error: too many requests"), - ).kind, - ).toBe("rate_limited"); }); - // A conflict is a 405 without the forbidden-method phrase → stays 'refused'. - it("is 'refused' for a merge conflict", () => { - expect( - classifyMergeResult(errorResult("405 Pull Request is not mergeable")) - .kind, - ).toBe("refused"); - }); - - it("is 'refused' with an empty detail when the error has no content", () => { - const a = classifyMergeResult({ isError: true }); - expect(a.kind).toBe("refused"); - if (a.kind === "refused") expect(a.detail).toBe(""); + /** + * Never false: a policy refusal is not evidence the branch is clean, and the + * conflict reaction may only act on an explicit true. + */ + it("is null for every other outcome, never false", () => { + for (const reason of [ + "no_pr", + "checks_pending", + "checks_failing", + "no_connection", + "rate_limited", + "refused", + "error", + ] as const) { + expect(conflictFromOutcome({ merged: false, reason })).toBeNull(); + } + expect(conflictFromOutcome({ merged: true })).toBeNull(); + expect(conflictFromOutcome(null)).toBeNull(); }); }); @@ -218,37 +144,3 @@ describe("approvedButUnverified", () => { ).toBe(false); }); }); - -describe("conflictSignal", () => { - const refused = (detail: string) => - ({ merged: false, reason: "refused", detail }) as const; - - it("takes the read whenever it has an answer", () => { - expect(conflictSignal(true, refused("anything"))).toBe(true); - expect(conflictSignal(false, refused("has merge conflicts"))).toBe(false); - }); - - it("falls back to GitHub's own refusal when the read is unknown", () => { - expect( - conflictSignal( - null, - refused( - '[{"type":"text","text":"failed to merge pull request: PUT ' + - "https://api.github.com/repos/o/r/pulls/336/merge: 405 Pull " + - 'Request has merge conflicts []"}]', - ), - ), - ).toBe(true); - }); - - it("never answers `false` from a refusal — absence is not evidence", () => { - expect( - conflictSignal(null, refused("405 Merge commits are not allowed")), - ).toBeNull(); - expect( - conflictSignal(null, { merged: false, reason: "rate_limited" }), - ).toBeNull(); - expect(conflictSignal(null, { merged: true })).toBeNull(); - expect(conflictSignal(null, null)).toBeNull(); - }); -}); diff --git a/apps/api/src/tools/task-board/merge-pr.ts b/apps/api/src/tools/task-board/merge-pr.ts index 34bdb292e1..70779d2dc1 100644 --- a/apps/api/src/tools/task-board/merge-pr.ts +++ b/apps/api/src/tools/task-board/merge-pr.ts @@ -1,6 +1,9 @@ import type { StudioContext } from "@/core/studio-context"; import type { TaskBoardItem } from "@/storage/types"; -import { clientFromConnection } from "@/mcp-clients"; +import { + changeRequestClientForOrigin, + type MergeOutcome as ProviderMergeOutcome, +} from "@/git-providers"; import { allReviewersApproved, approvedButUnverified, @@ -9,52 +12,27 @@ import { } from "@decocms/shared/task-board"; import { recordTaskActivity } from "./activity"; import { reactToApprovedPrConflict } from "./conflict-reaction"; +import { originOf } from "./change-request-extract"; import { type ChecksStatus, fetchPrChecksStatus, - fetchPrConflict, invalidatePrCards, invalidatePrReads, - isRateLimitError, pickActivePr, - resolveGithubConnection, + readNamespace, } from "./prs-get"; import { inReviewPhase } from "./lanes"; import { emitTaskBoardUpdated, handTaskToHuman } from "./run-reactions"; -/** Cap the merge round-trip so a slow GitHub can't hang the caller. */ -const MERGE_TIMEOUT_MS = 15000; - -/** - * Merge methods to try in order, stopping at the first that succeeds. `merge` is - * first because it is GitHub's own default — a bare `merge_pull_request` asks for - * a merge commit — so keeping it first changes nothing for every repo that - * allows one. The fallback is the whole point: a repo with "Allow merge commits" - * off answers `405 Merge commits are not allowed on this repository`, which used - * to strand the card In Review forever, retried every sweep against the same - * refusal; now it advances to `squash`, then `rebase`. - */ -const MERGE_METHODS = ["merge", "squash", "rebase"] as const; - -/** - * True when a merge refusal is GitHub rejecting THIS merge method (a `405 … are - * not allowed on this repository`) — the one refusal a different method can fix, - * so the ladder advances on it. Every other refusal (branch protection, a - * required review, a conflict) is method-independent: no method fixes it, so the - * caller reports it as-is instead of burning through the ladder. Pure — - * unit-tested. - */ -export function isMergeMethodNotAllowed(content: string): boolean { - return ( - /\b405\b/.test(content) && /not allowed on this repository/i.test(content) - ); -} - /** * Why a merge didn't happen. `checks_pending` is the one ROUTINE outcome — CI * is still running and the next attempt will simply succeed — so it is the one * reason that never reaches the card's timeline. Everything else means a human * has to do something, and until now they had no way to know. + * + * `conflict` is split out from `refused` because it is the one refusal with an + * automatic answer: the Super Agent can rebase. Every other refusal (branch + * protection, a required review) needs a person. */ export type MergeFailureReason = | "no_pr" @@ -62,6 +40,7 @@ export type MergeFailureReason = | "checks_failing" | "no_connection" | "rate_limited" + | "conflict" | "refused" | "error"; @@ -126,65 +105,18 @@ async function recordMergeFailure( }); } -/** The outcome of one `merge_pull_request` round-trip, classified. */ -type MergeAttempt = - | { kind: "merged" } - | { kind: "rate_limited"; detail: string } - | { kind: "method_not_allowed"; detail: string } - | { kind: "refused"; detail: string }; - /** - * Classify a raw `merge_pull_request` tool result. Pure — unit-tested. + * Merge the task's open change request. Shared by the reviewer decision + * (auto-merge on all-approved) and the manual "promote to production" action. + * Never throws: returns a `{merged: false, reason}` on any failure (no + * credential, it's gone, a conflict) so the caller can leave it for a human. + * Merges the newest linked one — the one under review. * - * GitHub refuses a merge (branch protection, a required review, a forbidden - * method, a lost race) via `isError` on an otherwise-resolved tool call — NOT a - * throw — so the payload is parsed here. Method-not-allowed is tested BEFORE the - * rate-limit heuristic on purpose: that heuristic matches a bare `429` anywhere - * in the payload, which the error's own PR URL can carry (`pulls/429/merge`), - * while a forbidden-method refusal names itself unambiguously — so it wins the - * tie and the ladder still advances for PR #429. `detail` defaults to `""` so a - * refusal with no `content` (which `JSON.stringify` renders as `undefined`, not - * a string) can't throw downstream. - */ -export function classifyMergeResult(result: unknown): MergeAttempt { - if (!(result as { isError?: boolean })?.isError) return { kind: "merged" }; - const detail = - JSON.stringify((result as { content?: unknown })?.content) ?? ""; - if (isMergeMethodNotAllowed(detail)) { - return { kind: "method_not_allowed", detail }; - } - if (isRateLimitError(detail)) return { kind: "rate_limited", detail }; - return { kind: "refused", detail }; -} - -/** One `merge_pull_request` round-trip with a specific `merge_method`. */ -async function attemptMerge( - client: Awaited>, - pr: { repoOwner: string; repoName: string; number: number }, - mergeMethod: string, -): Promise { - const result = await client.callTool( - { - name: "merge_pull_request", - arguments: { - owner: pr.repoOwner, - repo: pr.repoName, - pullNumber: pr.number, - merge_method: mergeMethod, - }, - }, - undefined, - { timeout: MERGE_TIMEOUT_MS }, - ); - return classifyMergeResult(result); -} - -/** - * Merge the task's open PR via the GitHub MCP `merge_pull_request` tool. Shared - * by the reviewer decision (auto-merge on all-approved) and the manual "promote - * to production" action. Never throws: returns a `{merged: false, reason}` on - * any failure (no connection, PR gone, merge conflict) so the caller can leave - * the PR for a human. Merges the newest linked PR — the one under review. + * The strategy ladder and the vocabulary of a refusal belong to the provider + * implementation, not here: a repository that forbids merge commits and one + * that forbids a fast-forward answer with different prose and different status + * codes, and only the implementation knows which of its own strategies is + * worth trying next. * * Every failure is BOTH logged and written to the card's timeline. The card * staying In Review with no explanation is the exact shape of the outage this @@ -221,71 +153,64 @@ export async function mergeLinkedPr( ); return fail(checks === "failing" ? "checks_failing" : "checks_pending"); } - const repo = `${pr.repoOwner}/${pr.repoName}`; - const conn = await resolveGithubConnection(ctx, orgId, pr.connectionId, { - owner: pr.repoOwner, - name: pr.repoName, - }); - if (!conn) { + const origin = originOf(pr); + const repo = origin.repo.path; + const client = await changeRequestClientForOrigin(ctx, orgId, origin).catch( + (err: unknown) => { + console.error(`[task-board] merge blocked — ${repo}:`, err); + return null; + }, + ); + if (!client) { console.warn( - `[task-board] merge blocked — no active GitHub connection for ` + - `${repo} (PR #${pr.number})`, + `[task-board] merge blocked — no credential for ${repo} (#${pr.number})`, ); return fail("no_connection", repo); } - const client = await clientFromConnection(conn, ctx, true); - try { - // Try each allowed merge method in turn — see {@link MERGE_METHODS}. - let lastRefusal: string | null = null; - for (const mergeMethod of MERGE_METHODS) { - const attempt = await attemptMerge(client, pr, mergeMethod); - if (attempt.kind === "merged") { - // Drop the polled read cache so the next poll sees `merged` → Done. - // Awaited: the UI refetches as soon as this responds, and the KV delete - // is a round-trip — firing it and returning races the very poll it is - // meant to fix. - await Promise.all([ - invalidatePrReads(conn.id), - invalidatePrCards(orgId), - ]); - return { merged: true }; - } - // A 429 says nothing about the method; re-asking IS the burst — stop. - if (attempt.kind === "rate_limited") { - console.warn( - `[task-board] merge rate-limited on PR #${pr.number} — sweep retries`, - ); - return fail("rate_limited", attempt.detail.slice(0, 500)); - } - // The repo forbids THIS method — remember it and try the next. - if (attempt.kind === "method_not_allowed") { - console.warn( - `[task-board] merge method '${mergeMethod}' not allowed on ${repo} — trying next`, - ); - lastRefusal = attempt.detail; - continue; - } - // Method-independent refusal (branch protection, conflict) — no method fixes it. - console.error( - `[task-board] merge refused by GitHub on PR #${pr.number}:`, - attempt.detail, - ); - return fail("refused", attempt.detail.slice(0, 500)); - } - // The repo allows none of squash/merge/rebase — report the last refusal. + + const outcome = await client.merge(pr.number); + if (outcome.merged) { + /** + * Drop the polled read cache so the next poll sees the merge → Done. + * Awaited: the UI refetches as soon as this responds, and the KV delete is + * a round-trip — firing it and returning races the very poll it is meant + * to fix. + */ + await Promise.all([ + invalidatePrReads(readNamespace(pr)), + invalidatePrCards(orgId), + ]); + return { merged: true }; + } + if (outcome.reason === "rate_limited") { + console.warn( + `[task-board] merge rate-limited on #${pr.number} — sweep retries`, + ); + } else { console.error( - `[task-board] no merge method allowed on ${repo} (PR #${pr.number})`, - lastRefusal, + `[task-board] merge refused (${outcome.reason}) on ${repo}#${pr.number}:`, + outcome.detail, ); - return fail("refused", lastRefusal?.slice(0, 500)); - } catch (err) { - const detail = err instanceof Error ? err.message : String(err); - // Same 429, thrown rather than returned — the transport decides which. - if (isRateLimitError(err)) return fail("rate_limited", detail); - console.error("[task-board] merge PR failed", err); - return fail("error", detail); - } finally { - await client.close().catch(() => {}); + } + return fail(reasonFor(outcome), outcome.detail.slice(0, 500)); +} + +/** The card's vocabulary for a provider refusal. Pure — unit-tested. */ +export function reasonFor( + outcome: Extract, +): MergeFailureReason { + switch (outcome.reason) { + case "conflict": + return "conflict"; + case "rate_limited": + return "rate_limited"; + // Nothing to merge: the same thing the card means by "no PR". + case "not_found": + return "no_pr"; + case "blocked": + return "refused"; + default: + return "error"; } } @@ -358,44 +283,23 @@ async function handUnverifiedApprovalToHuman( } /** - * True when a merge outcome is worth a `pull_request_read` to ask GitHub - * whether the PR conflicts. Pure — unit-tested. - * - * Only a refusal can be a conflict; every other reason (no PR, no connection, - * red or pending checks) is definitely not one. `rate_limited` is a separate - * reason precisely so it lands here: it says nothing about mergeability, and - * paying another GitHub call into a 429 is the burst that keeps the limit shut. - */ -export function mayBeConflict( - outcome: MergeOutcome, -): outcome is { merged: false; reason: MergeFailureReason; detail?: string } { - return !outcome.merged && outcome.reason === "refused"; -} - -/** - * Whether the PR conflicts, given the `pull_request_read` answer and the merge - * outcome it followed. Pure — unit-tested. + * Whether a failed merge failed because the branch no longer applies. Pure — + * unit-tested. * - * The read wins when it HAS an answer. When it doesn't — GitHub computes - * mergeability asynchronously, so `null` is routine — fall back to what the - * refusal said (`405 Pull Request has merge conflicts`). That is the - * definitive answer and we already paid for it: the merge just asked GitHub to - * do the thing and was told exactly why it couldn't. Without the fallback a - * null read silently skips the resolution run, and the card sits approved and - * unmergeable. + * This used to be a phrase match over the refusal text, plus a second provider + * read to ask about mergeability. Neither is needed now: the provider + * implementation classifies its own refusal, and it is the one that knows + * whether `405 Method Not Allowed` meant a conflict or a policy — so the merge + * attempt already paid for the answer. * - * Never returns `false` from the refusal alone: the phrase being absent is not - * evidence the PR is mergeable, and only an explicit `true` may act. + * Never returns `false`: only `conflict` is evidence, and a `blocked` refusal + * is not evidence the branch is clean. Callers act on an explicit `true` only. */ -export function conflictSignal( - read: boolean | null, +export function conflictFromOutcome( outcome: MergeOutcome | null, ): boolean | null { - if (read !== null) return read; - if (outcome === null || !mayBeConflict(outcome)) return null; - return (outcome.detail ?? "").toLowerCase().includes("merge conflict") - ? true - : null; + if (outcome === null || outcome.merged) return null; + return outcome.reason === "conflict" ? true : null; } /** @@ -408,23 +312,23 @@ export function conflictSignal( * dispatched to fix it. Two cards in one org, `merge_conflict_resolution` count * zero across the whole board. * - * `mayBeConflict` decides which outcomes are worth the extra GitHub read; the - * reaction itself re-checks approval, the org flag and its own dispatch cap. + * The reaction itself re-checks approval, the org flag and its own dispatch cap. */ async function resolveConflictAfterRefusedMerge( ctx: StudioContext, item: TaskBoardItem, outcome: MergeOutcome, ): Promise { - if (!mayBeConflict(outcome)) return; + const conflict = conflictFromOutcome(outcome); + if (conflict !== true) return; const orgId = item.organizationId; const prs = await ctx.storage.taskBoard.listPrs(item.id, orgId); - // The same PR `mergeLinkedPr` just tried to merge. + // The same one `mergeLinkedPr` just tried to merge. const pr = await pickActivePr(ctx, orgId, prs); if (!pr) return; await reactToApprovedPrConflict(ctx, orgId, item, { pr: { number: pr.number, url: pr.url }, - conflict: conflictSignal(await fetchPrConflict(ctx, orgId, pr), outcome), + conflict, }).catch((err) => { console.error("[task-board] sweep conflict auto-resolve failed", err); }); diff --git a/apps/api/src/tools/task-board/pick-active-pr.test.ts b/apps/api/src/tools/task-board/pick-active-pr.test.ts index db66e765a1..a6025508d6 100644 --- a/apps/api/src/tools/task-board/pick-active-pr.test.ts +++ b/apps/api/src/tools/task-board/pick-active-pr.test.ts @@ -28,6 +28,7 @@ const pr = (number: number): TaskBoardItemPrRef => ({ number, url: `https://github.com/deco/studio/pull/${number}`, connectionId: "conn_1", + repositoryId: null, createdAt: new Date(0).toISOString(), }); diff --git a/apps/api/src/tools/task-board/pr-by-branch.test.ts b/apps/api/src/tools/task-board/pr-by-branch.test.ts index 79c6b09aef..193ffb4ef1 100644 --- a/apps/api/src/tools/task-board/pr-by-branch.test.ts +++ b/apps/api/src/tools/task-board/pr-by-branch.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { candidateHeadRefs, firstPrFromListResult } from "./pr-by-branch"; +import { candidateHeadRefs } from "./pr-by-branch"; describe("candidateHeadRefs", () => { test("derives the sandbox ref from a synthetic branch", () => { @@ -25,38 +25,3 @@ describe("candidateHeadRefs", () => { ]); }); }); - -describe("firstPrFromListResult", () => { - test("reads a bare array of REST pull requests", () => { - expect( - firstPrFromListResult([ - { html_url: "https://github.com/o/r/pull/7", number: 7 }, - ]), - ).toMatchObject({ owner: "o", repo: "r", number: 7 }); - }); - - test("reads the wrapped shapes", () => { - for (const key of ["pull_requests", "items", "data"]) { - expect( - firstPrFromListResult({ - [key]: [{ url: "https://github.com/o/r/pull/9" }], - }), - ).toMatchObject({ number: 9 }); - } - }); - - test("skips rows whose url is not a pull request", () => { - expect( - firstPrFromListResult([ - { url: "https://api.github.com/repos/o/r/issues/1" }, - { html_url: "https://github.com/o/r/pull/12" }, - ]), - ).toMatchObject({ number: 12 }); - }); - - test("null on an empty or unrecognized result", () => { - expect(firstPrFromListResult([])).toBeNull(); - expect(firstPrFromListResult({ nope: 1 })).toBeNull(); - expect(firstPrFromListResult(null)).toBeNull(); - }); -}); diff --git a/apps/api/src/tools/task-board/pr-by-branch.ts b/apps/api/src/tools/task-board/pr-by-branch.ts index bd5469221b..466a88fe79 100644 --- a/apps/api/src/tools/task-board/pr-by-branch.ts +++ b/apps/api/src/tools/task-board/pr-by-branch.ts @@ -14,17 +14,26 @@ * to the thread (`metadata.githubRepo`, written at dispatch or by * `TASK_ADD_REPO`) and the branch is derived, not chosen — the daemon checks out * `syntheticBranchToGitRef()`, and a live daemon's actual HEAD is - * recorded on `metadata.headRef`. So one `list_pull_requests?head=owner:ref` - * answers it, from GitHub, with no model in the loop. + * recorded on `metadata.headRef`. So one `readForBranch` answers it, from the + * repository's own provider, with no model in the loop. * - * This is a FLOOR, not the fast path: the MCP `create_pull_request` hook - * (`capturePrForRun`) still links instantly when the run opens its PR that way. + * This is a FLOOR, not the fast path: the provider tool hook + * (`capturePrForRun`) still links instantly when the run opens it that way. * This runs from the review sweeper, for a card that reached its review cycle * with nothing linked — which before this was the definition of a stranded card. + * + * It reads through `ChangeRequestClient`, so a GitLab project is looked up the + * same way: the shape-sniffing this used to need (a bare array, or one wrapped + * in `pull_requests`/`items`/`data`, with the URL on `html_url` or `url` + * depending on the MCP server's version) is gone with the MCP call it existed + * to parse. */ import type { StudioContext } from "@/core/studio-context"; -import { clientFromConnection } from "@/mcp-clients"; +import { + changeRequestClientForTarget, + repoTargetForBinding, +} from "@/git-providers"; import type { TaskBoardItem } from "@/storage/types"; import { getThreadGithubRepo, @@ -32,11 +41,7 @@ import { resolveSandboxBranchForThread, syntheticBranchToGitRef, } from "@/tools/sandbox/thread-repo"; -import { extractPrFromText, type ExtractedPr } from "./pr-extract"; -import { invalidatePrCards, resolveGithubConnection } from "./prs-get"; - -/** Cap one lookup, so a slow GitHub can't hold up the sweep tick. */ -const LIST_TIMEOUT_MS = 8000; +import { invalidatePrCards } from "./prs-get"; /** * The refs a run's PR could be open on, most-likely first. @@ -60,57 +65,6 @@ export function candidateHeadRefs( ]; } -/** - * The first pull request in a `list_pull_requests` result, as a PR identity. - * - * Shapes vary by MCP server version (a bare array, or one wrapped in - * `pull_requests`/`items`/`data`), and the URL field varies with it - * (`html_url` on the REST shape, `url` on the minimal one). Everything is run - * through `extractPrFromText`, so a non-PR URL — an API url, an issue — is - * rejected here rather than linked as a PR. Pure, so the shapes are - * unit-tested. Null when the result holds no pull request. - */ -export function firstPrFromListResult(json: unknown): ExtractedPr | null { - const rows = Array.isArray(json) - ? json - : json && typeof json === "object" - ? (["pull_requests", "items", "data"] - .map((k) => (json as Record)[k]) - .find(Array.isArray) as unknown[] | undefined) - : undefined; - for (const row of rows ?? []) { - if (!row || typeof row !== "object") continue; - const r = row as Record; - for (const key of ["html_url", "url"]) { - const value = r[key]; - const pr = typeof value === "string" ? extractPrFromText(value) : null; - if (pr) return pr; - } - } - return null; -} - -/** Normalize a CallToolResult to its JSON payload. Null on an upstream error. */ -function toolJson(result: unknown): unknown { - if (!result || typeof result !== "object") return null; - const r = result as { - isError?: boolean; - structuredContent?: unknown; - content?: Array<{ type?: string; text?: string }>; - }; - if (r.isError) return null; - if (r.structuredContent && typeof r.structuredContent === "object") { - return r.structuredContent; - } - const text = r.content?.find((c) => c.type === "text")?.text; - if (!text) return null; - try { - return JSON.parse(text); - } catch { - return null; - } -} - /** * Link the PR a task's run opened, found by the run's branch. Returns true when * something was linked. @@ -149,76 +103,54 @@ export async function linkPrFromRunBranch( branch, await getThreadHeadRef(ctx, threadId), ); - const conn = await resolveGithubConnection( + const client = await changeRequestClientForTarget( ctx, orgId, - repo.connectionId ?? null, - { owner: repo.owner, name: repo.name }, - ); - if (!conn) { + repoTargetForBinding(repo), + ).catch(() => null); + if (!client) { console.warn( - `[task-board] no GitHub connection for ${repo.owner}/${repo.name} — ` + - `cannot look up ${item.id}'s pull request by branch`, + `[task-board] no credential for ${repo.owner}/${repo.name} — ` + + `cannot look up ${item.id}'s change request by branch`, ); continue; } - const client = await clientFromConnection(conn, ctx, true); - try { - for (const ref of refs) { - const result = await client.callTool( - { - name: "list_pull_requests", - arguments: { - owner: repo.owner, - repo: repo.name, - // `state: all`, not `open`: a PR the agent opened and a human - // closed is still the answer to "what did this run produce", - // and linking it is what lets the card leave In Review. - state: "all", - head: `${repo.owner}:${ref}`, - perPage: 5, - }, - }, - undefined, - { timeout: LIST_TIMEOUT_MS }, - ); - const pr = firstPrFromListResult(toolJson(result)); - if (!pr) continue; - await ctx.storage.taskBoard.linkPr({ - taskBoardItemId: item.id, - organizationId: orgId, - url: pr.url, - prNumber: pr.number, - repoOwner: pr.owner, - repoName: pr.repo, - connectionId: conn.id, - }); - // The one piece of bookkeeping the deleted PR-link tool did - // alongside its link that nothing else on this path does. - // Idempotent, and a no-op for the case this file exists for — a run - // that opened its PR and then died is already In Review by the time - // we get here, and keeps a null cycle. It matters for a run still - // going: without the stamp, `reviewCycleStart` and the reviewer fence - // fall back to scanning activity, and a re-dispatch cannot tell one - // cycle from the next. - // - // ponytail: no `clearSweepBudget` here. The only caller is the sweeper - // itself, which continues straight into the reviewer dispatch in this - // same pass — clearing the interval it just claimed would buy nothing - // but an extra tick. Add it if a non-sweeper caller appears. - await ctx.storage.taskBoard.openReviewCycleIfInProgress( - item.id, - orgId, - ); - await invalidatePrCards(orgId).catch(() => {}); - console.log( - `[task-board] ${item.id}: linked ${pr.url} found on branch ${ref}`, - ); - return true; - } - } finally { - await client.close().catch(() => {}); + for (const ref of refs) { + /** + * Newest regardless of state, not just open: one the agent opened and + * a human closed is still the answer to "what did this run produce", + * and linking it is what lets the card leave In Review. Both + * implementations of `readForBranch` answer that way. + */ + const found = await client.readForBranch(ref).catch(() => null); + if (!found) continue; + await ctx.storage.taskBoard.linkPr({ + taskBoardItemId: item.id, + organizationId: orgId, + url: found.url, + prNumber: found.number, + repo: client.repo, + }); + // The one piece of bookkeeping the deleted PR-link tool did + // alongside its link that nothing else on this path does. + // Idempotent, and a no-op for the case this file exists for — a run + // that opened its change request and then died is already In Review by + // the time we get here, and keeps a null cycle. It matters for a run + // still going: without the stamp, `reviewCycleStart` and the reviewer + // fence fall back to scanning activity, and a re-dispatch cannot tell + // one cycle from the next. + // + // ponytail: no `clearSweepBudget` here. The only caller is the sweeper + // itself, which continues straight into the reviewer dispatch in this + // same pass — clearing the interval it just claimed would buy nothing + // but an extra tick. Add it if a non-sweeper caller appears. + await ctx.storage.taskBoard.openReviewCycleIfInProgress(item.id, orgId); + await invalidatePrCards(orgId).catch(() => {}); + console.log( + `[task-board] ${item.id}: linked ${found.url} found on branch ${ref}`, + ); + return true; } } } catch (err) { diff --git a/apps/api/src/tools/task-board/pr-extract.test.ts b/apps/api/src/tools/task-board/pr-extract.test.ts deleted file mode 100644 index 1eefff44c2..0000000000 --- a/apps/api/src/tools/task-board/pr-extract.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { extractPrFromText, extractPrFromValue } from "./pr-extract"; - -describe("extractPrFromText", () => { - test("gh pr create stdout (bare html URL)", () => { - const out = "https://github.com/deco-sites/example-store/pull/1006\n"; - expect(extractPrFromText(out)).toEqual({ - url: "https://github.com/deco-sites/example-store/pull/1006", - number: 1006, - owner: "deco-sites", - repo: "example-store", - }); - }); - - test("gh pr create with surrounding chatter", () => { - const out = - "Creating pull request for feat/x into main\nremote: ...\nhttps://github.com/acme/site/pull/42"; - expect(extractPrFromText(out)?.number).toBe(42); - expect(extractPrFromText(out)?.repo).toBe("site"); - }); - - test("api URL form (REST response with only api url)", () => { - const body = '{"url":"https://api.github.com/repos/acme/site/pulls/7"}'; - expect(extractPrFromText(body)).toEqual({ - url: "https://github.com/acme/site/pull/7", - number: 7, - owner: "acme", - repo: "site", - }); - }); - - test("curl POST response with both html_url and api url → html wins", () => { - const body = JSON.stringify({ - url: "https://api.github.com/repos/acme/site/pulls/9", - html_url: "https://github.com/acme/site/pull/9", - number: 9, - }); - const pr = extractPrFromText(body)!; - expect(pr.url).toBe("https://github.com/acme/site/pull/9"); - expect(pr.number).toBe(9); - }); - - test("markdown-wrapped URL with trailing paren", () => { - const md = "Opened PR ([#3](https://github.com/acme/site/pull/3))."; - expect(extractPrFromText(md)).toEqual({ - url: "https://github.com/acme/site/pull/3", - number: 3, - owner: "acme", - repo: "site", - }); - }); - - test("owner/repo with dots, dashes, underscores", () => { - const pr = extractPrFromText( - "https://github.com/deco.cx/my_repo-2/pull/5", - )!; - expect(pr.owner).toBe("deco.cx"); - expect(pr.repo).toBe("my_repo-2"); - expect(pr.number).toBe(5); - }); - - test("http (not https) still matches", () => { - expect(extractPrFromText("http://github.com/a/b/pull/1")?.number).toBe(1); - }); - - test("no PR URL → null", () => { - expect(extractPrFromText("nothing here")).toBeNull(); - expect(extractPrFromText("https://github.com/acme/site")).toBeNull(); - expect( - extractPrFromText("https://github.com/acme/site/issues/4"), - ).toBeNull(); - }); - - test("pull/0 is rejected", () => { - expect(extractPrFromText("https://github.com/a/b/pull/0")).toBeNull(); - }); - - test("finds the URL even when preceded by a large blob", () => { - const noise = "x".repeat(50_000); - const out = `${noise}\nhttps://github.com/acme/site/pull/88`; - expect(extractPrFromText(out)?.number).toBe(88); - }); -}); - -describe("extractPrFromValue", () => { - test("MCP structuredContent minimal { id, url }", () => { - const result = { - structuredContent: { - id: 123, - url: "https://github.com/acme/site/pull/11", - }, - }; - expect(extractPrFromValue(result)?.number).toBe(11); - }); - - test("MCP content text carrying JSON string", () => { - const result = { - content: [ - { - type: "text", - text: '{"number":12,"html_url":"https://github.com/acme/site/pull/12"}', - }, - ], - }; - expect(extractPrFromValue(result)?.number).toBe(12); - }); - - test("bash daemon output object { stdout, exitCode }", () => { - const output = { - status: "ok", - exitCode: 0, - stdout: "https://github.com/acme/site/pull/13\n", - stderr: "", - }; - expect(extractPrFromValue(output)?.number).toBe(13); - }); - - test("plain string passes through", () => { - expect( - extractPrFromValue("https://github.com/acme/site/pull/14")?.number, - ).toBe(14); - }); - - test("null / undefined / no-match → null", () => { - expect(extractPrFromValue(null)).toBeNull(); - expect(extractPrFromValue(undefined)).toBeNull(); - expect(extractPrFromValue({ foo: "bar" })).toBeNull(); - }); -}); diff --git a/apps/api/src/tools/task-board/pr-extract.ts b/apps/api/src/tools/task-board/pr-extract.ts deleted file mode 100644 index 8b0b8d27cd..0000000000 --- a/apps/api/src/tools/task-board/pr-extract.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Pull-request identity extraction. - * - * Every PR-open scenario collapses to the same primitive: find a GitHub PR URL - * in a string. The GitHub MCP `create_pull_request` result (a minimal - * `{ id, url }`, or a `{ number, html_url }`, or plain text), `gh pr create` - * stdout (prints the html URL), and a raw `curl -X POST …/pulls` response body - * (JSON with `html_url` + api `url`) all embed one. So we stringify whatever we - * have and scan it — no per-shape branching. - */ - -export interface ExtractedPr { - /** Canonical html URL — the display + dedup key. */ - url: string; - number: number; - owner: string; - repo: string; -} - -// Two forms, tried html-first so the human-facing URL wins over the API URL -// when a curl response carries both. `[^/\s"']+` stops each segment at the next -// slash/quote/space, so a trailing `)`/`.` in prose or a JSON quote never leaks -// in; the number's `\d+` stops at any non-digit. Both are linear (no nested -// quantifiers) — safe to run on large stdout. -const HTML_PR = /https?:\/\/github\.com\/([^/\s"']+)\/([^/\s"']+)\/pull\/(\d+)/; -const API_PR = - /https?:\/\/api\.github\.com\/repos\/([^/\s"']+)\/([^/\s"']+)\/pulls\/(\d+)/; - -// ponytail: cap the scan. A single linear regex is fine on big input, but a -// verbose `curl -v` dump can be megabytes — the PR URL, when present, is near -// the top (gh) or in the response body. 200KB covers both without scanning a -// whole log. Raise if a real payload ever buries the URL deeper. -const MAX_SCAN = 200_000; - -function build(m: RegExpExecArray): ExtractedPr { - const owner = m[1]!; - const repo = m[2]!; - const number = Number(m[3]); - return { - owner, - repo, - number, - url: `https://github.com/${owner}/${repo}/pull/${number}`, - }; -} - -/** Find the first GitHub PR URL in a string, or null. */ -export function extractPrFromText(text: string): ExtractedPr | null { - const s = text.length > MAX_SCAN ? text.slice(0, MAX_SCAN) : text; - const html = HTML_PR.exec(s); - if (html && Number(html[3]) > 0) return build(html); - const api = API_PR.exec(s); - if (api && Number(api[3]) > 0) return build(api); - return null; -} - -/** Stringify any tool result / bash output and scan it for a PR URL. */ -export function extractPrFromValue(value: unknown): ExtractedPr | null { - if (value == null) return null; - if (typeof value === "string") return extractPrFromText(value); - let s: string; - try { - s = JSON.stringify(value); - } catch { - return null; - } - return s ? extractPrFromText(s) : null; -} diff --git a/apps/api/src/tools/task-board/pr-open-board-reaction.integration.test.ts b/apps/api/src/tools/task-board/pr-open-board-reaction.integration.test.ts index 9a42c90b3f..83ab82f7a3 100644 --- a/apps/api/src/tools/task-board/pr-open-board-reaction.integration.test.ts +++ b/apps/api/src/tools/task-board/pr-open-board-reaction.integration.test.ts @@ -13,7 +13,7 @@ import { } from "../../database/test-db-pg"; import { TaskBoardStorage } from "../../storage/task-board"; import type { TaskBoardItem } from "../../storage/types"; -import type { ExtractedPr } from "./pr-extract"; +import type { ChangeRequestRef } from "./change-request-extract"; import { applyBoardDecision, type BoardDecision, @@ -21,11 +21,10 @@ import { const ORG = "org_propen_1"; const USER = "user_propen_1"; -const PR: ExtractedPr = { +const PR: ChangeRequestRef = { url: "https://github.com/acme/widget/pull/7", number: 7, - owner: "acme", - repo: "widget", + repo: { provider: "github", host: "github.com", path: "acme/widget" }, }; describe("applyBoardDecision", () => { diff --git a/apps/api/src/tools/task-board/pr-open-board-reaction.ts b/apps/api/src/tools/task-board/pr-open-board-reaction.ts index c9b2c745d0..0b32918a23 100644 --- a/apps/api/src/tools/task-board/pr-open-board-reaction.ts +++ b/apps/api/src/tools/task-board/pr-open-board-reaction.ts @@ -21,7 +21,11 @@ import type { StudioContext } from "@/core/studio-context"; import { resolveTier } from "@/core/resolve-tier"; import type { TaskBoardStorage } from "@/storage/task-board"; import type { TaskBoardItem } from "@/storage/types"; -import { extractPrFromValue, type ExtractedPr } from "./pr-extract"; +import { changeRequestLabel } from "@decocms/shared/git-providers"; +import { + type ChangeRequestRef, + findChangeRequestIn, +} from "./change-request-extract"; import { invalidatePrCards } from "./prs-get"; import { resolveRunTaskTargets, emitTaskBoardUpdated } from "./run-reactions"; @@ -86,7 +90,7 @@ async function decideBoardActionForPr( orgId: string, openCards: TaskBoardItem[], threadTitle: string | null, - pr: ExtractedPr, + pr: ChangeRequestRef, ): Promise { try { const tier = await resolveTier(ctx, "fast"); @@ -97,7 +101,7 @@ async function decideBoardActionForPr( ? openCards.map((t) => `- [${t.id}] (${t.status}) ${t.title}`).join("\n") : "(none)"; const prompt = `Work summary (chat title): ${threadTitle ?? "(untitled)"} -Pull request: ${pr.url} (${pr.owner}/${pr.repo}#${pr.number}) +Change request: ${pr.url} (${pr.repo.path}#${pr.number}) Existing open cards: ${cardList}`; @@ -128,7 +132,7 @@ export async function applyBoardDecision( orgId: string; userId: string; threadId: string; - pr: ExtractedPr; + pr: ChangeRequestRef; decision: BoardDecision; /** The card set the decision was made against (this org's), for taskId validation. */ openCards: TaskBoardItem[]; @@ -142,8 +146,7 @@ export async function applyBoardDecision( organizationId: orgId, url: pr.url, prNumber: pr.number, - repoOwner: pr.owner, - repoName: pr.repo, + repo: pr.repo, connectionId: null, }); @@ -185,7 +188,9 @@ export async function applyBoardDecision( // Create (also the unknown-taskId fallback), owned by the Super Agent so reviewers pick it up. item = await storage.create({ organizationId: orgId, - title: decision.title?.trim() || `PR #${pr.number}`, + title: + decision.title?.trim() || + changeRequestLabel(pr.repo.provider, pr.number), status: LANES.progress, assigneeId: SUPER_AGENT_ASSIGNEE_ID, assignedBy: userId, @@ -233,7 +238,7 @@ export async function reactToPrOpenedForBoard( const alreadyLinked = await resolveRunTaskTargets(ctx, orgId, threadId); if (alreadyLinked.length > 0) return; - const pr = extractPrFromValue(source); + const pr = findChangeRequestIn(source); if (!pr) return; const cards = await ctx.storage.taskBoard.list(orgId); diff --git a/apps/api/src/tools/task-board/prs-get.test.ts b/apps/api/src/tools/task-board/prs-get.test.ts deleted file mode 100644 index 3d5ef0a961..0000000000 --- a/apps/api/src/tools/task-board/prs-get.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { lazyOnce } from "./prs-get"; - -describe("lazyOnce", () => { - it("shares one in-flight attempt across concurrent callers", async () => { - let calls = 0; - const { get } = lazyOnce(async () => { - calls++; - return "ok"; - }); - const [a, b] = await Promise.all([get(), get()]); - expect(a).toBe("ok"); - expect(b).toBe("ok"); - expect(calls).toBe(1); - }); - - it("clears the memo on failure so the next get() retries open()", async () => { - let calls = 0; - const { get } = lazyOnce(async () => { - calls++; - if (calls === 1) throw new Error("transient"); - return "ok"; - }); - await expect(get()).rejects.toThrow("transient"); - expect(await get()).toBe("ok"); - expect(calls).toBe(2); - }); - - it("never re-opens once a get() succeeds", async () => { - let calls = 0; - const { get, current } = lazyOnce(async () => { - calls++; - return calls; - }); - expect(await get()).toBe(1); - expect(await get()).toBe(1); - expect(calls).toBe(1); - expect(current()).toBe(1); - }); -}); diff --git a/apps/api/src/tools/task-board/prs-get.ts b/apps/api/src/tools/task-board/prs-get.ts index 92654ba737..bd12e70233 100644 --- a/apps/api/src/tools/task-board/prs-get.ts +++ b/apps/api/src/tools/task-board/prs-get.ts @@ -2,40 +2,55 @@ import { z } from "zod"; import { defineTool } from "@/core/define-tool"; import { requireAuth } from "@/core/studio-context"; import type { StudioContext } from "@/core/studio-context"; -import type { ConnectionEntity } from "@/tools/connection/schema"; -import { clientFromConnection } from "@/mcp-clients"; +import { + GitProviderError, + changeRequestClientForOrigin, + type ChangeRequest, + type ChangeRequestClient, + type ChangeRequestDetail, + type CheckRun, + type ChecksSummary, +} from "@/git-providers"; import type { TaskBoardItemPrRef } from "@/storage/types"; -import { getRepoScope } from "@decocms/shared/github-repo-scope"; import { LANES, shippedLane, SUPER_AGENT_ASSIGNEE_ID, } from "@decocms/shared/task-board"; +import { repoIdentityKey } from "@decocms/shared/git-providers"; import { retry, RetryError } from "@decocms/shared/std"; import { TaskBoardItemPrSchema } from "./schema"; import { cardWorkLanded } from "./archive-merged"; import { recordTaskActivity } from "./activity"; +import { originOf } from "./change-request-extract"; import { inReviewPhase, movesForward } from "./lanes"; import { emitTaskBoardUpdated } from "./run-reactions"; import { enqueueEnabledReviewers } from "./enqueue-reviewer"; import { reactToApprovedPrConflict } from "./conflict-reaction"; import { readPrStateThrottled } from "./dbos-github-read"; +import { + getPrCardCache, + getPrReadCache, + PR_CARDS_CACHE, + PR_READS_CACHE, +} from "./pr-cache"; -/** Cap a single live PR fetch — the modal shouldn't hang on a slow GitHub. */ -const PR_FETCH_TIMEOUT_MS = 8000; +export type { ChecksSummary as ChecksStatus }; /** - * GitHub answers "too many requests" — the primary or (more often here) the - * SECONDARY rate limit, which punishes bursts of concurrent calls rather than a - * raw hourly count. Retrying that is not a retry, it's the burst: it triples the - * very thing being limited, and the retried calls are what keep the limit shut. - * So a rate-limit answer ends the attempt immediately and the cache below serves - * the last good value instead. + * The provider answered "too many requests" — the primary or (more often here) + * the SECONDARY rate limit, which punishes bursts of concurrent calls rather + * than a raw hourly count. Retrying that is not a retry, it's the burst: it + * triples the very thing being limited, and the retried calls are what keep + * the limit shut. So a rate-limit answer ends the attempt immediately and the + * cache below serves the last good value instead. * - * Matched on the message because the answer arrives as an MCP tool result - * (`isError` + text), not an HTTP status we can read. Exported for the unit test. + * A provider client raises `GitProviderError`, which says so outright. The + * message match is the fallback for the paths that don't (GraphQL's transport + * throws a plain `Error`). Exported for the unit test. */ export function isRateLimitError(err: unknown): boolean { + if (err instanceof GitProviderError && err.isRateLimited) return true; const message = err instanceof Error ? err.message : String(err); // Scrub the request URL first: a `429` in `…/pulls/429/merge` is not a status. const withoutUrls = message.replace(/https?:\/\/\S+/gi, " "); @@ -43,75 +58,20 @@ export function isRateLimitError(err: unknown): boolean { } /** - * Drop this connection's cached PR reads. Call it after WRITING to GitHub - * through the connection (a merge), so the next poll sees the new state instead - * of serving the pre-merge one for the rest of the revalidate window — the card - * only moves to Done once a poll observes `merged`, and a minute of "did my ship - * button work?" is exactly the confusion this cache must not introduce. - */ -import { - getPrCardCache, - getPrReadCache, - PR_CARDS_CACHE, - PR_READS_CACHE, -} from "./pr-cache"; - -/** Hit window for a cached value that is still waiting on something — a deploy - * with no published url, or CI that hasn't finished. Zero, so the next poll - * always revalidates (detached, off the request path) instead of serving the - * not-ready answer for the full window. */ -const NOT_READY_REVALIDATE_MS = 0; - -/** - * A card that should keep refreshing: CI is still running, so both what it - * reports and the preview URL a deploy check publishes are still moving. - * - * Pending CI counts on its own — it used to additionally require a missing - * preview URL, which is what left a card sitting on "Checks pending" minutes - * after the checks went green. Nothing about the PR had changed; the checks - * simply finished, and with a preview already found the card was a cache HIT - * for the full window, rebuilt from reads that were themselves a window stale. - * Waiting is the one state whose whole point is that it ends, so it never gets - * a hit window. - * - * Bounded: a settled card (passing/failing/no CI) caches normally, so the - * per-poll rebuild this costs only runs while CI is actually running. + * Drop this repository's cached reads. Call it after WRITING to the provider + * (a merge), so the next poll sees the new state instead of serving the + * pre-merge one for the rest of the revalidate window — the card only moves to + * Done once a poll observes a merge, and a minute of "did my ship button + * work?" is exactly the confusion this cache must not introduce. */ -export function isAwaitingCi(card: { checksStatus: ChecksStatus }): boolean { - return card.checksStatus === "pending"; -} - -/** Hit window for one raw GitHub read: zero while it says CI is still running, - * for the same reason as {@link isAwaitingCi}. Without this the card - * cache revalidates on every poll only to rebuild from a read that is itself - * up to a full read-window stale, and the two lags add up. */ -function ciRevalidateAfterMs(status: ChecksStatus): number { - return status === "pending" - ? NOT_READY_REVALIDATE_MS - : PR_READS_CACHE.revalidateAfterMs; -} - -/** Stale ceiling for one raw GitHub read: zero while it says CI is running, so - * the read is a MISS and asks GitHub synchronously instead of serving the - * pending answer one more time. A zero HIT window alone wasn't enough — it - * only starts a background refresh, so the assembled card was still built from - * the previous read and a fresh result needed a second poll to appear (and - * never appeared at all if that one background write failed). Bounded to a - * pending PR someone has the dialog open on. */ -function ciMaxStaleMs(status: ChecksStatus): number { - return status === "pending" ? 0 : PR_READS_CACHE.maxStaleMs; -} - -export function invalidatePrReads(connectionId: string): Promise { - return getPrReadCache().invalidate(connectionId); +export function invalidatePrReads(namespace: string): Promise { + return getPrReadCache().invalidate(namespace); } /** * Drop an org's cached PR CARDS. Called after a write that changes what a card * shows (a merge), so the next poll rebuilds it instead of serving the - * pre-merge card for the rest of the revalidate window — the task only moves to - * Done once a poll observes `merged`, and "did my ship button work?" is exactly - * the confusion this cache must not introduce. + * pre-merge card for the rest of the revalidate window. * * Org-wide rather than per task: a merge is rare, the rebuild is one poll's * work, and a card keyed to the wrong task is worse than a cold one. @@ -125,288 +85,131 @@ const prLabel = (pr: TaskBoardItemPrRef) => `${pr.repoOwner}/${pr.repoName}#${pr.number}`; /** - * One cached, best-effort GitHub read for a PR. Best-effort in the same sense as - * the rest of this file: `null` on any failure, so the card still renders. - * - * `callTool` RESOLVES (doesn't reject) on an upstream MCP error, so an `isError` - * result is rethrown here — otherwise the cache would happily store GitHub's - * "too many requests" as the PR's state and serve it for half an hour. - * - * Takes a `getClient` THUNK, not a client. The cache only calls `fetchLive` on a - * miss or a background revalidation, so on a warm card the client is never - * built — and building one is not free: the per-request pool bakes a fresh - * Studio token, which costs a `downstream_tokens` read plus an eager MCP - * `initialize` handshake (~80-120ms) before any tool call can go out. Handed a - * ready client instead, every poll paid that round-trip to then answer entirely - * from cache. + * The cache namespace for one repository's reads. The repository, not the + * credential: two agents in an org reading the same change request must share + * one entry, and the answer does not depend on which of the org's credentials + * asked. */ -type GetPrClient = () => Promise< - Awaited> ->; +export const readNamespace = (pr: TaskBoardItemPrRef) => + repoIdentityKey(originOf(pr).repo); /** - * Memoizes `open`'s in-flight/resolved promise so concurrent callers share one - * attempt — but clears the memo on failure, so the NEXT `get()` actually - * retries `open()` instead of replaying the same rejection forever. Without - * this, `retry()`'s attempts around a `get()` call are hollow once the first - * attempt fails: each "retry" just re-awaits the already-rejected promise, so - * a transient failure never gets a real second try. Exported for the unit - * test. + * A card whose CI is still running never gets a cache hit window. + * + * Waiting is the one state whose whole point is that it ends: served from + * cache it left a card on "Checks pending" minutes after the checks went + * green, because nothing about the change request had changed and the entry + * was a hit for the full window. + * + * Bounded — a settled card (passing, failing, no CI at all) caches normally, + * so the per-poll rebuild only costs anything while CI is actually running. */ -export function lazyOnce(open: () => Promise): { - get: () => Promise; - current: () => T | null; -} { - let value: T | null = null; - let promise: Promise | null = null; - return { - get: () => { - promise ??= open() - .then((v) => { - value = v; - return v; - }) - .catch((err) => { - promise = null; - throw err; - }); - return promise; - }, - current: () => value, - }; +export function isAwaitingCi(card: { checksStatus: ChecksSummary }): boolean { + return card.checksStatus === "pending"; } +/** Zero. Named because it appears as both a hit window and a stale ceiling. */ +const NOT_READY_MS = 0; + /** - * A GitHub MCP client for `conn`, opened AT MOST ONCE and only if a read - * actually misses the SWR cache. + * The two cache windows for a read whose answer carries a CI verdict. * - * Opening one is not free: the per-request pool bakes a fresh Studio token, so - * it costs a `downstream_tokens` read plus an eager MCP `initialize` handshake - * (~80-120ms) before any tool call can go out. Every read below is cached, so a - * warm card would otherwise pay that round-trip only to answer from cache. + * A zero HIT window alone is not enough: it only starts a background refresh, + * so the poll still serves the previous read and a fresh result needs a second + * poll to appear — and never appears at all if that one background write + * fails. The zero STALE CEILING is what makes a pending read a MISS that asks + * the provider on the spot. * - * `closeWhenIdle` closes once the cache's background revalidations settle — and - * does nothing at all when nothing was ever opened. + * One predicate pair, where the MCP path needed two: its CI verdict was split + * across a combined-status read and a check-runs read, and this interface + * answers both in one. */ -function lazyPrClient( - conn: ConnectionEntity, - ctx: StudioContext, -): { get: GetPrClient; closeWhenIdle: (pending: Promise[]) => void } { - const { get, current } = lazyOnce(() => - clientFromConnection(conn, ctx, true), - ); - return { - get, - // Do NOT await this — the whole point of serving a stale value is to return - // without waiting on GitHub. Closing eagerly (which this did) killed every - // revalidation the moment it started, so a cached entry could never - // refresh: a PR read before its deploy comment existed showed no preview - // and no checks until the entry aged out half an hour later. - closeWhenIdle: (pending) => { - void Promise.allSettled(pending).then(() => - current() - ?.close() - .catch(() => {}), - ); - }, - }; +function ciWindows(checks: ChecksSummary): { + revalidateAfterMs: number; + maxStaleMs: number; +} { + return checks === "pending" + ? { revalidateAfterMs: NOT_READY_MS, maxStaleMs: NOT_READY_MS } + : { + revalidateAfterMs: PR_READS_CACHE.revalidateAfterMs, + maxStaleMs: PR_READS_CACHE.maxStaleMs, + }; } -async function cachedPrRead( - getClient: GetPrClient, - connectionId: string, - name: string, - args: Record, +/** + * One cached, best-effort provider read. Best-effort in the same sense as the + * rest of this file: `null` on any failure, so the card still renders. + * + * The value stored is the NEUTRAL shape, not the provider's payload — which is + * both smaller (a provider's change-request JSON runs past the cache's value + * cap on a busy repository, and a rejected put means that change request + * misses forever) and the only thing that could be shared between two + * providers' answers. + */ +async function cachedRead( + namespace: string, + key: string, describe: string, - pending: Promise[], - revalidateAfterMs?: (stored: unknown) => number, - maxStaleMs?: (stored: unknown) => number, -): Promise | null> { + fetchLive: () => Promise, + /** Per-entry windows, computed from the stored value — see {@link ciWindows}. */ + windows?: (stored: T) => { revalidateAfterMs: number; maxStaleMs: number }, +): Promise { try { const raw = await getPrReadCache().fetch({ - namespace: connectionId, - key: JSON.stringify({ name, args }), - revalidateAfterMs, - maxStaleMs, + namespace, + key, + revalidateAfterMs: windows + ? (stored) => windows(stored as T).revalidateAfterMs + : undefined, + maxStaleMs: windows + ? (stored) => windows(stored as T).maxStaleMs + : undefined, fetchLive: () => - retry( - async () => { - const result = await (await getClient()).callTool( - { name, arguments: args }, - undefined, - { - timeout: PR_FETCH_TIMEOUT_MS, - }, - ); - if ( - result && - typeof result === "object" && - (result as { isError?: boolean }).isError - ) { - throw new Error( - (result as { content?: Array<{ text?: string }> }).content?.[0] - ?.text ?? `${name} returned isError`, - ); - } - return result; - }, - { - maxAttempts: 3, - minTimeout: 300, - maxTimeout: 3000, - jitter: 1, - isRetriable: (err) => !isRateLimitError(err), - }, - ), - // A background revalidation calls `getClient` AFTER this returns the stale - // value — so the caller must keep the client open until it settles. - onRevalidation: (promise) => pending.push(promise), + retry(fetchLive, { + maxAttempts: 3, + minTimeout: 300, + maxTimeout: 3000, + jitter: 1, + isRetriable: (err) => !isRateLimitError(err), + }), + /** + * Nothing to hold open: the provider clients are stateless HTTP, so a + * background revalidation needs no resource kept alive for it — which is + * what the MCP path had to arrange, and got wrong twice. + */ + onRevalidation: () => {}, }); - return toolResultJson(raw); + return (raw ?? null) as T | null; } catch (err) { const cause = err instanceof RetryError ? err.cause : err; - console.error(`[task-board] ${name} failed for ${describe}:`, cause); + console.error(`[task-board] ${key} failed for ${describe}:`, cause); return null; } } /** - * The GitHub MCP connection to fetch/merge a PR through: the one that opened it - * when known (MCP path), else pick from the org's `mcp-github` connections - * (bash path, where no connection was recorded). - * - * The pick matters once an org has repo-scoped connections (imported repos / - * Code Agents): a repo-scoped child's installation only reaches ITS repo, so - * using it for a different repo's PR fails (empty live state → the card loses - * its open/checks/preview and the ship button). So prefer, in order: a - * repo-scoped connection matching THIS PR's repo (guaranteed access), then the - * broad org-level connection (no `repoScope`, user OAuth over every repo). - * - * There is deliberately NO "any active one" last resort when a repo is named. A - * connection scoped to a DIFFERENT repo cannot reach this PR — its installation - * token is repo-scoped — so returning it buys nothing and costs everything: the - * caller can't tell "GitHub said no" from "we asked the wrong GitHub", the live - * state comes back all-null, and the card silently parks In Review forever. This - * is not hypothetical — deleting the org's connection for the repo its PRs were - * opened against stranded 40+ approved cards, because the resolver kept handing - * back a connection for an unrelated repo. Returning null instead makes the - * miss loud (`resolveGithubConnection` returning null is logged at each call - * site) and points at the real fix: connect the repo. + * The client that can read `pr`, or null when this org has no credential for + * its repository. Logged at each call site, because "we asked the wrong + * provider" and "the provider said no" have to stay distinguishable — a card + * whose repository lost its credential must look broken, not empty. */ -export async function resolveGithubConnection( +function clientFor( ctx: StudioContext, orgId: string, - connectionId: string | null, - repo?: { owner: string; name: string }, -): Promise { - if (connectionId) { - // Org-scope the lookup: this connection's GitHub installation is used to - // MERGE PRs, so never resolve one from another org (defense-in-depth against - // a foreign/colliding connectionId reaching a write path). - const conn = await ctx.storage.connections.findById(connectionId, orgId); - if (conn && conn.status === "active") return conn; - } - const { items } = await ctx.storage.connections.list(orgId, { - slug: "mcp-github", - }); - return pickGithubConnection( - items.filter((c) => c.status === "active"), - repo, + pr: TaskBoardItemPrRef, +): Promise { + return changeRequestClientForOrigin(ctx, orgId, originOf(pr)).catch( + (err: unknown) => { + console.error(`[task-board] no client for ${prLabel(pr)}:`, err); + return null; + }, ); } -/** - * The pick itself, pure so the fallback ladder is unit-testable — the rule that - * a repo-scoped connection for a DIFFERENT repo is never a substitute is the - * whole point of this function and is invisible from any integration test that - * doesn't happen to have two scoped connections lying around. - */ -export function pickGithubConnection< - T extends { metadata?: Record | null }, ->(active: T[], repo?: { owner: string; name: string }): T | null { - const broad = active.find((c) => getRepoScope(c) === null) ?? null; - if (!repo) return broad ?? active[0] ?? null; - const matching = active.find((c) => { - const scope = getRepoScope(c); - return scope?.owner === repo.owner && scope?.repo === repo.name; - }); - // The broad org-level connection (user OAuth, every repo the user can see) is - // the only legitimate stand-in for a repo we have no scoped connection to. - return matching ?? broad; -} - -/** Normalize a CallToolResult to its JSON object (structuredContent, else the - * text content parsed as JSON). Null on error/empty — inlined so a tool never - * imports web helpers. */ -function toolResultJson(result: unknown): Record | null { - if (!result || typeof result !== "object") return null; - const r = result as { - isError?: boolean; - structuredContent?: unknown; - content?: Array<{ type?: string; text?: string }>; - }; - if (r.isError) return null; - if ( - r.structuredContent && - typeof r.structuredContent === "object" && - Object.keys(r.structuredContent).length > 0 - ) { - return r.structuredContent as Record; - } - const text = r.content?.find((c) => c.type === "text")?.text; - if (!text) return null; - try { - return JSON.parse(text) as Record; - } catch { - return null; - } -} - -export type ChecksStatus = "pending" | "passing" | "failing" | null; - -/** One CI check on the PR, for the card's expandable checks footer. `summary` - * is the check-run's output markdown, fetched only for FAILING runs (the ones - * worth reading) via `GET_CHECK_RUN`; null otherwise. */ -export type PrCheck = { - name: string; - status: string; - conclusion: string | null; - detailsUrl: string | null; - summary: string | null; -}; - -type PrLiveState = { - title: string | null; - body: string | null; - state: "open" | "closed" | null; - draft: boolean | null; - merged: boolean | null; - /** GitHub's `mergeable`: false = conflicts with base, true = clean, null = - * not computed yet / unknown. */ - mergeable: boolean | null; - checksStatus: ChecksStatus; - checks: PrCheck[]; - previewUrl: string | null; -}; - -/** The live fields before GitHub has answered — also what the card cache serves - * as its placeholder while the real read runs. */ -const NO_LIVE_STATE: PrLiveState = { - title: null, - body: null, - state: null, - draft: null, - merged: null, - mergeable: null, - checksStatus: null, - checks: [], - previewUrl: null, -}; - /** Whether `url`'s HOST is a known preview-deploy host — a strict hostname - * check, NOT a substring match. The preview is lifted from PR comments, - * which external contributors can write, and the result is shown as a - * trusted "Open preview" button AND handed to the autonomous QA reviewer to + * check, NOT a substring match. The preview is lifted from comments, which + * external contributors can write, and the result is shown as a trusted + * "Open preview" button AND handed to the autonomous QA reviewer to * navigate. So a decoy like `https://evil.example.com/x?y=.decocdn.com` must * be rejected. Exported for the unit test. */ export function isTrustedPreviewHost(url: string): boolean { @@ -426,95 +229,64 @@ export function isTrustedPreviewHost(url: string): boolean { ); } -/** Pull the preview URL out of a combined-status response's `statuses[]` (a - * status whose `target_url` is a trusted preview host). Kept as a cheap - * fallback — most providers post the preview in a comment, not a status, so - * this usually finds nothing. `null` when none is posted. */ -export function extractPreviewUrl( - obj: Record | null, -): string | null { - if (!obj || !Array.isArray(obj.statuses)) return null; - const previews = obj.statuses - .map((s) => - s && typeof s === "object" - ? (s as { target_url?: unknown; state?: unknown }) - : null, - ) - .filter( - (s): s is { target_url: string; state?: unknown } => - !!s && - typeof s.target_url === "string" && - isTrustedPreviewHost(s.target_url), - ); - if (previews.length === 0) return null; - return ( - previews.find((s) => s.state === "success")?.target_url ?? - previews[0]!.target_url - ); -} - /** deco's Cloudflare account subdomain — the middle label of every * `*.workers.dev` preview host these sites deploy to. * ponytail: hardcoded; make it configurable if sites land in another account. */ const WORKERS_DEV_SUBDOMAIN = "deco-cx"; -export function extractPreviewUrlFromCheckRuns(raw: unknown): string | null { - const runs = Array.isArray(raw) - ? raw - : Array.isArray((raw as { check_runs?: unknown })?.check_runs) - ? (raw as { check_runs: unknown[] }).check_runs - : []; - for (const r of runs) { - if (!r || typeof r !== "object") continue; - const o = r as { name?: unknown; output?: { summary?: unknown } }; - const worker = - typeof o.name === "string" - ? /^Workers Builds:\s*([a-z0-9][a-z0-9-]*)\s*$/i.exec(o.name)?.[1] - : null; - if (!worker) continue; - const summary = o.output?.summary; - if (typeof summary !== "string") continue; - const version = /Version ID:\s*([0-9a-f]{8})/i.exec(summary)?.[1]; - if (!version) continue; +/** + * The preview URL a CI run points at. Two shapes, both real: + * + * - Cloudflare's "Workers Builds: " run reports the deployed version + * in its own summary, which is exact and present even when its comment omits + * the preview column; + * - every other provider just links the deploy from the run (a check run's + * details URL, a commit status's target URL). + * + * Exported for the pure-logic unit test. + */ +export function previewUrlFromChecks(runs: CheckRun[]): string | null { + for (const run of runs) { + const worker = /^Workers Builds:\s*([a-z0-9][a-z0-9-]*)\s*$/i.exec( + run.name, + )?.[1]; + const version = run.summary + ? /Version ID:\s*([0-9a-f]{8})/i.exec(run.summary)?.[1] + : null; + if (!worker || !version) continue; const url = `https://${version}-${worker}.${WORKERS_DEV_SUBDOMAIN}.workers.dev`; if (isTrustedPreviewHost(url)) return url; } - return null; + // A successful run's own link, preferred over an in-flight one's. + const linked = runs.filter( + (run) => run.url !== null && isTrustedPreviewHost(run.url), + ); + const success = linked.find((run) => run.conclusion === "success"); + return success?.url ?? linked[0]?.url ?? null; } /** - * Pull the preview URL out of a PR's comments — where the deploy bot - * actually posts it. Known shapes: Cloudflare Workers' - * `cloudflare-workers-and-pages[bot]` (a table with a "Commit Preview URL" AND a - * "Branch Preview URL" — prefer the branch one, stable across the PR's commits), - * deco.cx's `decobot` (a single "Visit Preview" markdown link), and Vercel's - * `[Preview](url)` markdown link. Accepts the raw `get_comments` result (an - * array, or `{ comments }`/`{ items }`), sorts newest-first by `updated_at` - * (falling back to `created_at`, then array order, when absent) so a stale - * early comment can't win over a later re-deploy. Sorting by `updated_at` - * rather than `created_at` matters because Vercel/Cloudflare edit a single - * sticky comment in place on each push — `created_at` stays frozen at the - * comment's first post, so ranking by it could let an unrelated LATER human - * comment (that happens to mention a matching host) outrank the bot's - * freshly-edited one. Scans each body; `null` when none is found. Exported - * for the pure-logic unit test. + * Pull the preview URL out of the change request's comments — where the deploy + * bot actually posts it. Known shapes: Cloudflare Workers' + * `cloudflare-workers-and-pages[bot]` (a table with a "Commit Preview URL" AND + * a "Branch Preview URL" — prefer the branch one, stable across the change + * request's commits), deco.cx's `decobot` (a single "Visit Preview" markdown + * link), and Vercel's `[Preview](url)` markdown link. + * + * Sorted newest-first by `updatedAt` (falling back to `createdAt`, then array + * order), so a stale early comment can't win over a later re-deploy. By + * `updatedAt` rather than `createdAt` because Vercel and Cloudflare edit ONE + * sticky comment in place on each push: `createdAt` stays frozen at its first + * post, so ranking by it could let an unrelated later human comment (that + * happens to mention a matching host) outrank the bot's freshly edited one. + * Exported for the pure-logic unit test. */ -export function extractPreviewUrlFromComments(raw: unknown): string | null { - const list = Array.isArray(raw) - ? raw - : Array.isArray((raw as { comments?: unknown })?.comments) - ? (raw as { comments: unknown[] }).comments - : Array.isArray((raw as { items?: unknown })?.items) - ? (raw as { items: unknown[] }).items - : []; - const recency = (c: unknown): number => { - const obj = c as { updated_at?: unknown; created_at?: unknown }; - return ( - Date.parse(obj?.updated_at as string) || - Date.parse(obj?.created_at as string) - ); - }; - const sorted = list +export function previewUrlFromComments( + comments: { body: string; createdAt?: string; updatedAt?: string }[], +): string | null { + const recency = (c: { createdAt?: string; updatedAt?: string }): number => + Date.parse(c.updatedAt ?? "") || Date.parse(c.createdAt ?? ""); + const sorted = comments .map((c, index) => ({ c, index })) .sort((a, b) => { const aTime = recency(a.c); @@ -523,14 +295,11 @@ export function extractPreviewUrlFromComments(raw: unknown): string | null { return bTime - aTime; }) .map(({ c }) => c); - for (const c of sorted) { - const body = - c && typeof (c as { body?: unknown }).body === "string" - ? (c as { body: string }).body - : ""; + for (const comment of sorted) { + const body = comment.body; if (!body) continue; // Cloudflare's comment carries both a per-commit and a per-branch preview — - // prefer the branch URL, which stays valid as the PR gets new commits. + // prefer the branch URL, which stays valid as the branch gets new commits. const branch = body.match(/href=['"]([^'"]+)['"][^>]*>\s*Branch Preview/i); if (branch?.[1] && isTrustedPreviewHost(branch[1])) return branch[1]; const url = (body.match(/https?:\/\/[^\s"'<>)\]]+/g) ?? []).find((u) => @@ -541,459 +310,320 @@ export function extractPreviewUrlFromComments(raw: unknown): string | null { return null; } -/** Pull the preview URL out of a `GET_PREVIEW_DEPLOYMENT` result — the newest - * successful GitHub Deployment status's `environmentUrl`, gated through the - * same trusted-host check as the other two sources. Some hosts (VTEX FastStore - * WebOps) publish the preview ONLY as a deployment — not a status `target_url` - * and not a bot comment — so this is the third and last source tried. `null` - * when the commit has no deployment with a published url yet (an in-flight - * deploy) or the url isn't a trusted host. Exported for the pure-logic unit - * test. */ -export function extractPreviewUrlFromDeployment( - obj: Record | null, -): string | null { - const url = - obj && typeof obj.environmentUrl === "string" ? obj.environmentUrl : null; - return url && isTrustedPreviewHost(url) ? url : null; -} - /** A git commit sha, validated 7–40 hex — also what keeps a malformed value - * from reaching the GitHub query the deployment lookup builds from it. */ -function asHeadSha(sha: unknown): string | null { + * from reaching the deployment lookup built from it. */ +export function asHeadSha(sha: unknown): string | null { return typeof sha === "string" && /^[0-9a-fA-F]{7,40}$/.test(sha) ? sha : null; } -/** The PR head commit sha from a `pull_request_read get` response's `head.sha` - * — the documented, stable source (present regardless of CI), preferred over - * {@link headShaFromStatus}. `null` when absent or not a hex sha. Exported for - * the pure-logic unit test. */ -export function headShaFromPrGet( - obj: Record | null, -): string | null { - const head = obj?.head as { sha?: unknown } | undefined; - return asHeadSha(head?.sha); -} - -/** The PR head commit sha from a combined-status response (`get_status` returns - * the head commit's status, which carries its `sha`). A fallback for - * {@link headShaFromPrGet} when the `get` read is the one that flaked. `null` - * when absent or not a hex sha. Exported for the pure-logic unit test. */ -export function headShaFromStatus( - statusObj: Record | null, -): string | null { - return asHeadSha(statusObj?.sha); -} - -/** Map a GitHub combined-status `state` to our three-value checks summary. - * A response with no statuses (`total_count === 0`) reads as "no checks", - * not "pending" — a PR without CI shouldn't look stuck. Exported for the - * pure-logic unit test. */ -export function toChecksStatus( - obj: Record | null, -): ChecksStatus { - if (!obj) return null; - const total = typeof obj.total_count === "number" ? obj.total_count : null; - if (total === 0) return null; - switch (obj.state) { - case "success": - return "passing"; - case "failure": - case "error": - return "failing"; - case "pending": - return "pending"; - default: - return null; - } -} - -/** Check-run conclusions that mean the run failed. */ -const FAILED_CHECK_CONCLUSIONS = new Set([ - "failure", - "cancelled", - "timed_out", - "action_required", - "startup_failure", - "stale", -]); - -/** Map GitHub check-runs (Checks API) to our three-value summary. Repos on - * GitHub Actions post check-runs, NOT legacy commit statuses (which - * `toChecksStatus` reads), so this is often the only signal — e.g. a deco site - * whose combined status is empty but whose "Deco / QA" check-run failed. - * Accepts the raw `get_check_runs` result (`{ check_runs }` or an array). - * `null` when there are no check-runs. Exported for the unit test. */ -export function toCheckRunsStatus(raw: unknown): ChecksStatus { - const runs = Array.isArray(raw) - ? raw - : Array.isArray((raw as { check_runs?: unknown })?.check_runs) - ? (raw as { check_runs: unknown[] }).check_runs - : []; - if (runs.length === 0) return null; - let failing = false; - let pending = false; - for (const r of runs) { - if (!r || typeof r !== "object") continue; - const status = (r as { status?: unknown }).status; - const conclusion = (r as { conclusion?: unknown }).conclusion; - if (status !== "completed") { - pending = true; - } else if ( - typeof conclusion === "string" && - FAILED_CHECK_CONCLUSIONS.has(conclusion) - ) { - failing = true; - } - } - if (failing) return "failing"; - if (pending) return "pending"; - return "passing"; -} - -/** Combine two checks summaries, worst-first: failing > pending > passing > - * null. A PR's CI can live in BOTH the legacy Status API and the Checks API - * (e.g. a Cloudflare deploy status + a GitHub Actions check-run), so we read - * both and merge. Exported for the unit test. */ -export function mergeChecksStatus( - a: ChecksStatus, - b: ChecksStatus, -): ChecksStatus { - if (a === "failing" || b === "failing") return "failing"; - if (a === "pending" || b === "pending") return "pending"; - if (a === "passing" || b === "passing") return "passing"; - return null; -} +/** One linked change request, as the card renders it. */ +type PrLiveState = { + title: string | null; + body: string | null; + state: "open" | "closed" | null; + draft: boolean | null; + merged: boolean | null; + /** false = conflicts with base, true = clean, null = not computed yet. */ + mergeable: boolean | null; + checksStatus: ChecksSummary; + checks: PrCheck[]; + previewUrl: string | null; +}; -type RawCheckRun = { - id: number | null; +/** One CI check on the change request, for the card's expandable footer. + * `summary` is the run's own report, fetched only for FAILING runs (the ones + * worth reading); null otherwise. */ +export type PrCheck = { name: string; status: string; conclusion: string | null; detailsUrl: string | null; + summary: string | null; }; -/** Parse a `get_check_runs` result into a flat list. Exported for the test. */ -export function parseCheckRuns(raw: unknown): RawCheckRun[] { - const runs = Array.isArray(raw) - ? raw - : Array.isArray((raw as { check_runs?: unknown })?.check_runs) - ? (raw as { check_runs: unknown[] }).check_runs - : []; - const out: RawCheckRun[] = []; - for (const r of runs) { - if (!r || typeof r !== "object") continue; - const o = r as Record; - out.push({ - id: typeof o.id === "number" ? o.id : null, - name: typeof o.name === "string" ? o.name : "", - status: typeof o.status === "string" ? o.status : "completed", - conclusion: typeof o.conclusion === "string" ? o.conclusion : null, - detailsUrl: - typeof o.html_url === "string" - ? o.html_url - : typeof o.details_url === "string" - ? o.details_url - : null, - }); - } - return out; -} +/** The live fields before the provider has answered — also what the card cache + * serves as its placeholder while the real read runs. */ +const NO_LIVE_STATE: PrLiveState = { + title: null, + body: null, + state: null, + draft: null, + merged: null, + mergeable: null, + checksStatus: null, + checks: [], + previewUrl: null, +}; -/** True when a check-run finished in a failing state. */ -function isFailingRun(r: RawCheckRun): boolean { - return ( - r.status === "completed" && - r.conclusion != null && - FAILED_CHECK_CONCLUSIONS.has(r.conclusion) - ); +/** + * The card's `state`/`merged` pair, from the neutral three-value state. + * + * The wire shape keeps them separate because `merged` alone cannot answer + * `cardWorkLanded`: a closed-unmerged change request and an open one both + * report false, and only the first is settled. + */ +export function cardLifecycle(state: ChangeRequest["state"]): { + state: "open" | "closed" | null; + merged: boolean; +} { + if (state === "merged") return { state: "closed", merged: true }; + return { state, merged: false }; } -/** Fetch a check-run's output markdown via the github MCP `GET_CHECK_RUN` tool - * (the list tool omits `output`). Best-effort: null on any failure. */ -async function fetchCheckRunSummary( - getClient: GetPrClient, - connectionId: string, +/** The card's per-check rows, with a report only where one is worth reading. */ +async function checkRows( + client: ChangeRequestClient, + namespace: string, pr: TaskBoardItemPrRef, - checkRunId: number, - pending: Promise[], -): Promise { - const obj = await cachedPrRead( - getClient, - connectionId, - "GET_CHECK_RUN", - { owner: pr.repoOwner, repo: pr.repoName, checkRunId }, - `${prLabel(pr)} check-run ${checkRunId}`, - pending, + runs: CheckRun[], +): Promise { + const failing = (run: CheckRun) => + run.state === "completed" && + run.conclusion !== null && + run.conclusion !== "success" && + run.conclusion !== "skipped" && + run.conclusion !== "neutral"; + return await Promise.all( + runs.map( + async (run): Promise => ({ + name: run.name, + status: run.state, + conclusion: run.conclusion, + detailsUrl: run.url, + summary: + run.summary ?? + (failing(run) && run.id !== null + ? await cachedRead( + namespace, + `check-log:${run.id}`, + `${prLabel(pr)} check ${run.id}`, + () => client.readCheckLog(run.id as string), + ) + : null), + }), + ), ); - const output = obj?.output as - | { summary?: unknown; text?: unknown } - | undefined; - const summary = typeof output?.summary === "string" ? output.summary : null; - const text = typeof output?.text === "string" ? output.text : null; - return summary ?? text ?? null; } -/** Just the PR's checks summary (combined status ∪ check-runs) — used to gate - * the merge (don't ship on red/pending CI). Best-effort: null when it can't be - * determined (which does NOT block the merge — only a definite failing/pending - * does). */ -export async function fetchPrChecksStatus( +/** + * Everything the card shows for one linked change request. Best-effort: any + * failure yields nulls so the modal still shows the link. + * + * ONE detailed read covers the lifecycle, the mergeability, every CI run and + * every comment — where the MCP path made four to six calls whose answers + * described four to six different moments. The deployment lookup is the only + * extra, and only on the miss path. + */ +async function fetchPrLiveState( ctx: StudioContext, orgId: string, pr: TaskBoardItemPrRef, -): Promise { - const conn = await resolveGithubConnection(ctx, orgId, pr.connectionId, { - owner: pr.repoOwner, - name: pr.repoName, - }); - if (!conn) return null; - const client = await clientFromConnection(conn, ctx, true); - const read = async (method: "get_status" | "get_check_runs") => - toolResultJson( - await client.callTool( - { - name: "pull_request_read", - arguments: { - method, - owner: pr.repoOwner, - repo: pr.repoName, - pullNumber: pr.number, - }, - }, - undefined, - { timeout: PR_FETCH_TIMEOUT_MS }, - ), - ); - try { - let status: ChecksStatus = null; - try { - status = toChecksStatus(await read("get_status")); - } catch { - // best-effort - } - try { - status = mergeChecksStatus( - status, - toCheckRunsStatus(await read("get_check_runs")), - ); - } catch { - // best-effort - } - return status; - } finally { - await client.close().catch(() => {}); +): Promise { + const client = await clientFor(ctx, orgId, pr); + if (!client) return NO_LIVE_STATE; + const namespace = readNamespace(pr); + const detail = await cachedRead( + namespace, + `detail:${pr.number}`, + prLabel(pr), + () => client.readDetailed({ number: pr.number }), + (stored) => ciWindows(stored?.checks ?? null), + ); + if (!detail) return NO_LIVE_STATE; + + const { state, merged } = cardLifecycle(detail.state); + const isOpen = state === "open"; + if (!isOpen) { + return { + ...NO_LIVE_STATE, + title: detail.title, + body: detail.body, + state, + draft: detail.draft, + merged, + }; + } + + let previewUrl = + previewUrlFromChecks(detail.checkRuns) ?? + previewUrlFromComments(detail.comments); + if (!previewUrl) { + /** + * Last resort: a deployment's own environment URL. Some hosts (VTEX + * FastStore WebOps) publish the preview ONLY there — not as a run's link + * and not as a bot comment — so it is the third and last source tried, + * and only once the head sha is known. + */ + const headSha = asHeadSha(detail.headSha); + const deployed = headSha + ? await cachedRead( + namespace, + `deployed:${headSha}`, + `${prLabel(pr)} (deployment preview)`, + () => client.readDeployedUrl(headSha), + ) + : null; + previewUrl = deployed && isTrustedPreviewHost(deployed) ? deployed : null; } + + return { + title: detail.title, + body: detail.body, + state, + draft: detail.draft, + merged, + // Same field the conflict gate reads, so the two can't drift. + mergeable: detail.conflicting === null ? null : !detail.conflicting, + checksStatus: detail.checks, + checks: await checkRows(client, namespace, pr, detail.checkRuns), + previewUrl, + }; } -/** Map a `pull_request_read get` response to whether the PR conflicts with its - * base branch. `true` = conflicts; `false` = mergeable, OR the PR is not open - * (a merged/closed PR reports no mergeability but is never "conflicting"); - * `null` = GitHub hasn't computed mergeability yet (it's async) or the read - * gave nothing — an unknown must NEVER read as a conflict, so the caller only - * acts on an explicit `true`. Pure — unit-tested; the single home for the - * mergeability polarity, so the two callers can't drift. - * - * `mergeable_state` is the field that actually arrives: `pull_request_read` - * returns github-mcp's `MinimalPullRequest`, which has no `mergeable`. Reading - * only that boolean yielded `null` for every PR ever, so the conflict - * auto-resolution it gates never fired once in production — zero - * `merge_conflict_resolution` rows across every org, while an approved, - * conflicting PR retried the same 405 every five minutes for two days. The - * boolean is still read first: a non-minimal response (a direct GitHub - * payload, a different MCP server) carries it and it is the richer signal. +/** + * ONE cheap read, the whole call. Deliberately NOT the detailed one, which + * also fetches every CI run and comment: the sweeps read every candidate in + * one go, and that multiplier is exactly what took out the GitHub App's rate + * limit once already (see `review-sweeper.ts`). * - * Of the `mergeable_state` values only `dirty` means conflicts; `unknown`/`""` - * is GitHub still computing, and the rest (`blocked`, `behind`, `unstable`, …) - * are for the checks gate to judge, not this. */ -export function conflictFromPrGet( - obj: Record | null, -): boolean | null { - if (!obj) return null; - if (obj.state !== "open") return false; - if (typeof obj.mergeable === "boolean") return !obj.mergeable; - const state = obj.mergeable_state; - if (typeof state !== "string" || state === "" || state === "unknown") { - return null; - } - return state === "dirty"; + * Null on any failure, and never read as an answer by callers — an unreachable + * provider means "we could not ask", not "no". + */ +async function readChangeRequest( + ctx: StudioContext, + orgId: string, + pr: TaskBoardItemPrRef, + label: string, +): Promise { + const client = await clientFor(ctx, orgId, pr); + if (!client) return null; + return cachedRead( + readNamespace(pr), + `read:${pr.number}`, + `${prLabel(pr)} (${label})`, + () => client.read(pr.number), + (stored) => ciWindows(stored?.checks ?? null), + ); +} + +/** Just the checks summary — used to gate the merge (don't ship on red or + * pending CI). Best-effort: null when it can't be determined, which does NOT + * block the merge — only a definite failing/pending does. */ +export async function fetchPrChecksStatus( + ctx: StudioContext, + orgId: string, + pr: TaskBoardItemPrRef, +): Promise { + const client = await clientFor(ctx, orgId, pr); + if (!client) return null; + const detail = await cachedRead( + readNamespace(pr), + `detail:${pr.number}`, + prLabel(pr), + () => client.readDetailed({ number: pr.number }), + (stored) => ciWindows(stored?.checks ?? null), + ); + return detail?.checks ?? null; } -/** Whether the PR conflicts with its base branch — the definite "can't merge" - * signal used to gate conflict auto-resolution. Reads the same `get` the - * live-state fetch uses; best-effort (null on any failure). */ +/** Whether the change request conflicts with its base branch — the definite + * "can't merge" signal used to gate conflict auto-resolution. Best-effort + * (null on any failure). */ export async function fetchPrConflict( ctx: StudioContext, orgId: string, pr: TaskBoardItemPrRef, ): Promise { - const conn = await resolveGithubConnection(ctx, orgId, pr.connectionId, { - owner: pr.repoOwner, - name: pr.repoName, - }); - if (!conn) return null; - const client = await clientFromConnection(conn, ctx, true); - try { - return conflictFromPrGet( - toolResultJson( - await client.callTool( - { - name: "pull_request_read", - arguments: { - method: "get", - owner: pr.repoOwner, - repo: pr.repoName, - pullNumber: pr.number, - }, - }, - undefined, - { timeout: PR_FETCH_TIMEOUT_MS }, - ), - ), - ); - } catch { - return null; - } finally { - await client.close().catch(() => {}); - } + const cr = await readChangeRequest(ctx, orgId, pr, "conflict"); + return cr?.conflicting ?? null; +} + +/** Just "is this merged?", for the archive sweep. */ +export async function fetchPrLanding( + ctx: StudioContext, + orgId: string, + pr: TaskBoardItemPrRef, +): Promise<{ state: "open" | "closed" | null; merged: boolean | null }> { + const cr = await readChangeRequest(ctx, orgId, pr, "landing"); + if (!cr) return { state: null, merged: null }; + return cardLifecycle(cr.state); } -/** Fetch the PR's live CI + preview extras via the GitHub MCP - * `pull_request_read` tool: the combined Status API AND the Checks API (repos - * differ in which they post to), merged into one checks summary, plus the deco - * deploy preview URL. Best-effort: any failure yields nulls. */ -async function fetchPrStatusExtras( - getClient: GetPrClient, - connectionId: string, +/** + * Exactly what {@link prReadyForReview} reads, and nothing else. + * + * The review sweep used to call the detailed read here — four provider calls + * per change request plus one per failing check — for two booleans. It paid + * that since before the check gate was dropped from the dispatch decision, and + * a red one parked In Review is swept forever, so it paid the per-failure + * reports on every pass in perpetuity. + */ +export async function fetchPrCandidateState( + ctx: StudioContext, + orgId: string, pr: TaskBoardItemPrRef, - pending: Promise[], - prGet: Promise | null>, ): Promise<{ - checksStatus: ChecksStatus; - checks: PrCheck[]; - previewUrl: string | null; + state: "open" | "closed" | null; + merged: boolean | null; + checksStatus: ChecksSummary; + conflict: boolean | null; }> { - const read = ( - method: "get_status" | "get_check_runs" | "get_comments", - revalidateAfterMs?: (stored: unknown) => number, - maxStaleMs?: (stored: unknown) => number, - ) => - cachedPrRead( - getClient, - connectionId, - "pull_request_read", - { - method, - owner: pr.repoOwner, - repo: pr.repoName, - pullNumber: pr.number, - }, - `${prLabel(pr)} (${method})`, - pending, - revalidateAfterMs, - maxStaleMs, - ); - // The three reads are independent — run them CONCURRENTLY. Serial was the - // slowness (each is a remote MCP → GitHub round-trip, ~1.5-2s; the card made - // 4-5 of them in a row). - const [statusObj, runsRaw, commentsRaw] = await Promise.all([ - read( - "get_status", - (stored) => ciRevalidateAfterMs(toChecksStatus(toolResultJson(stored))), - (stored) => ciMaxStaleMs(toChecksStatus(toolResultJson(stored))), - ), - read( - "get_check_runs", - (stored) => - ciRevalidateAfterMs(toCheckRunsStatus(toolResultJson(stored))), - (stored) => ciMaxStaleMs(toCheckRunsStatus(toolResultJson(stored))), - ), - read("get_comments"), - ]); - // Combined Status API ∪ Checks API → one summary. - const checksStatus = mergeChecksStatus( - toChecksStatus(statusObj), - toCheckRunsStatus(runsRaw), - ); - // Preview: the Workers Builds version (exact, and present even when - // Cloudflare's PR comment omits its Preview URL column), else a status - // `target_url` (rare), else the deploy bot's PR comment. - let previewUrl = - extractPreviewUrlFromCheckRuns(runsRaw) ?? - extractPreviewUrl(statusObj) ?? - extractPreviewUrlFromComments(commentsRaw); - // TODO(e2e): cover the miss-path gate + head-sha threading below (only the pure extractors are unit-tested). - if (!previewUrl) { - // Last resort: a GitHub Deployment env url (VTEX FastStore posts it only there), scanned only on the miss path once the head sha is known. - const headSha = - headShaFromPrGet(await prGet) ?? headShaFromStatus(statusObj); - if (headSha) { - previewUrl = extractPreviewUrlFromDeployment( - await cachedPrRead( - getClient, - connectionId, - "GET_PREVIEW_DEPLOYMENT", - { owner: pr.repoOwner, repo: pr.repoName, sha: headSha }, - `${prLabel(pr)} (deployment preview)`, - pending, - // An in-flight deploy answers "no environment url yet". Holding that - // for the full read window means the card keeps rebuilding from a - // stale not-ready answer even once GitHub has the url, so the preview - // shows up minutes after the deploy finished. - (stored) => - extractPreviewUrlFromDeployment( - stored as Record | null, - ) === null - ? NOT_READY_REVALIDATE_MS - : PR_READS_CACHE.revalidateAfterMs, - ), - ); - } + const cr = await readChangeRequest(ctx, orgId, pr, "candidate"); + if (!cr) { + return { state: null, merged: null, checksStatus: null, conflict: null }; } - // Per-check list for the footer; pull the output markdown only for failing - // runs (bounded, in parallel). - const checks = await Promise.all( - parseCheckRuns(runsRaw).map( - async (r): Promise => ({ - name: r.name, - status: r.status, - conclusion: r.conclusion, - detailsUrl: r.detailsUrl, - summary: - isFailingRun(r) && r.id != null - ? await fetchCheckRunSummary( - getClient, - connectionId, - pr, - r.id, - pending, - ) - : null, - }), - ), - ); - return { checksStatus, checks, previewUrl }; + return { + ...cardLifecycle(cr.state), + // Both ride the same read — the sweep's CI and conflict signals cost nothing extra. + checksStatus: cr.checks, + conflict: cr.conflicting, + }; +} + +/** + * The head branch name, or null when the provider can't be reached. + * + * Read live rather than stored on the row deliberately: `task_board_item_prs` + * is written from three places (the provider tool hook, a bash-output scan, + * and the sweeper's closing-message scan) and only one of them ever knows the + * branch. The provider always does. Null is a first-class answer — the caller + * falls back to today's fresh-branch behaviour rather than guessing a ref and + * pushing work somewhere nobody is looking. + */ +export async function fetchPrHeadRef( + ctx: StudioContext, + orgId: string, + pr: TaskBoardItemPrRef, +): Promise { + const cr = await readChangeRequest(ctx, orgId, pr, "head ref"); + /** + * Only an OPEN change request's branch is worth reusing: pushing to a merged + * or closed one's branch updates nothing anyone will look at, and the + * re-run's work would be invisible. + */ + if (!cr || cr.state !== "open") return null; + return cr.head.length > 0 ? cr.head : null; } -/** Fetch a PR's live state via the GitHub MCP `pull_request_read` tool. - * Best-effort: any failure yields nulls so the modal still shows the link. */ /** - * Is there a PR to dispatch a reviewer at? A PR we positively know is closed or - * merged is done with; anything else — open, or unknown because GitHub was quiet - * — is a candidate. + * Is there a change request to dispatch a reviewer at? One we positively know + * is closed or merged is done with; anything else — open, or unknown because + * the provider was quiet — is a candidate. * - * Check status does NOT gate this: the reviewer runs WITHOUT waiting for CI. It - * reads the diff and exercises the deploy preview, so we start it as soon as - * there's a PR rather than sitting on a slow or stuck check. Shipping stays safe — the MERGE is gated on green checks - * separately (`mergeLinkedPr` → `fetchPrChecksStatus`), so nothing merges on red - * no matter what a reviewer decided. + * Check status does NOT gate this: the reviewer runs WITHOUT waiting for CI. + * It reads the diff and exercises the deploy preview, so we start it as soon + * as there is something to review rather than sitting on a slow or stuck + * check. Shipping stays safe — the MERGE is gated on green checks separately + * (`mergeLinkedPr` → `fetchPrChecksStatus`), so nothing merges on red no + * matter what a reviewer decided. * - * `fetchPrLiveState` is best-effort: every field comes back `null` when the - * GitHub call fails, which must read as "we could not ask", not "no PR" — a - * version that required `state === "open"` answered "not ready" for EVERY card - * the moment GitHub went quiet and silently froze dispatch. Shared with - * `review-sweeper.ts` so both dispatch paths agree on when a PR is reviewable. + * The live read is best-effort: every field comes back `null` when the + * provider call fails, which must read as "we could not ask", not "nothing + * there" — a version that required `state === "open"` answered "not ready" for + * EVERY card the moment GitHub went quiet and silently froze dispatch. Shared + * with `review-sweeper.ts` so both dispatch paths agree. */ export const prReadyForReview = ( prs: { @@ -1002,9 +632,10 @@ export const prReadyForReview = ( }[], ): boolean => reviewCandidates(prs).length > 0; -/** The PRs a reviewer would be dispatched at: a PR is a candidate unless we - * positively know it's finished with. One definition, so the readiness gate and - * the preview-freshness gate below can't disagree about which PR is in play. */ +/** The change requests a reviewer would be dispatched at: one is a candidate + * unless we positively know it's finished with. One definition, so the + * readiness gate and the preview-freshness gate below can't disagree about + * which one is in play. */ const reviewCandidates = < T extends { state: string | null; merged: boolean | null }, >( @@ -1012,33 +643,34 @@ const reviewCandidates = < ): T[] => prs.filter((p) => p.state !== "closed" && p.merged !== true); /** - * Does the deploy preview show the PR's HEAD commit? Pure — unit-tested. + * Does the deploy preview show the head commit? Pure — unit-tested. * * Only a definite `failing`/`pending` holds QA back. Everything else — no CI - * configured, GitHub unreadable, or a field a mid-deploy DBOS replay recorded - * before it existed — is trusted, the same way every other read here treats "we - * could not ask" as "do not block". + * configured, the provider unreadable, or a field a mid-deploy DBOS replay + * recorded before it existed — is trusted, the same way every other read here + * treats "we could not ask" as "do not block". * - * It matters because a preview URL outlives the build that produced it. The URL - * is lifted from a commit status or the deploy bot's PR comment, and the - * hostname is per-PR (`pr336-.workers.dev`), not per-commit — so when a + * It matters because a preview URL outlives the build that produced it. The + * URL is lifted from a CI run or the deploy bot's comment, and the hostname is + * per-change-request (`pr336-.workers.dev`), not per-commit — so when a * deploy fails, that URL keeps serving the last build that SUCCEEDED, with a * cheerful 200. QA then exercises code the author didn't write, finds the - * behaviour absent, and requests changes. The verdict looks exactly like a real - * one, and the Super Agent cannot fix it by changing the code. + * behaviour absent, and requests changes. The verdict looks exactly like a + * real one, and the Super Agent cannot fix it by changing the code. * * That is not hypothetical: a site's Workers build broke account-wide for - * everything after 16:30 one afternoon, and six cards were on course to spend a - * second five-bounce budget being rejected against the previous night's bytes. + * everything after 16:30 one afternoon, and six cards were on course to spend + * a second five-bounce budget being rejected against the previous night's + * bytes. * - * Checks are the signal because GitHub attaches them to a COMMIT: head's deploy - * being green is what makes the preview head's. + * Checks are the signal because both providers attach them to a COMMIT: head's + * deploy being green is what makes the preview head's. */ export function previewMatchesHead( prs: { state: string | null; merged: boolean | null; - checksStatus: ChecksStatus; + checksStatus: ChecksSummary; }[], ): boolean { return reviewCandidates(prs).every( @@ -1046,220 +678,39 @@ export function previewMatchesHead( ); } -async function fetchPrLiveState( - ctx: StudioContext, - orgId: string, - pr: TaskBoardItemPrRef, -): Promise { - const conn = await resolveGithubConnection(ctx, orgId, pr.connectionId, { - owner: pr.repoOwner, - name: pr.repoName, - }); - if (!conn) return NO_LIVE_STATE; - const { get: getClient, closeWhenIdle } = lazyPrClient(conn, ctx); - // Background revalidations started by the read cache below run on the client, - // so it may not be closed until they settle — see the `finally`. - const pending: Promise[] = []; - try { - // Fetch the PR's basic state AND its checks/preview extras CONCURRENTLY. - // An open PR (the common case in the review dialog) is fully populated in a - // single round-trip window instead of ~5 serial hops; a merged/closed PR - // wastes the extras, but that's rare here and best-effort. - // One `get`, shared: it populates the PR fields and feeds the deployment preview its head sha (stable, unlike the flakier `get_status`). - const prGet = cachedPrRead( - getClient, - conn.id, - "pull_request_read", - { - method: "get", - owner: pr.repoOwner, - repo: pr.repoName, - pullNumber: pr.number, - }, - `${prLabel(pr)} (get)`, - pending, - ); - const [obj, extras] = await Promise.all([ - prGet, - fetchPrStatusExtras(getClient, conn.id, pr, pending, prGet), - ]); - if (!obj) return NO_LIVE_STATE; - const rawState = obj.state; - const isOpen = rawState === "open"; - const conflict = conflictFromPrGet(obj); - return { - title: typeof obj.title === "string" ? obj.title : null, - body: typeof obj.body === "string" ? obj.body : null, - state: rawState === "closed" ? "closed" : isOpen ? "open" : null, - draft: typeof obj.draft === "boolean" ? obj.draft : null, - merged: typeof obj.merged === "boolean" ? obj.merged : null, - // Same reducer as the auto-resolution gate, so the two can't drift. - mergeable: isOpen && conflict !== null ? !conflict : null, - // Checks/preview only mean something for an open PR. - checksStatus: isOpen ? extras.checksStatus : null, - checks: isOpen ? extras.checks : [], - previewUrl: isOpen ? extras.previewUrl : null, - }; - } catch { - return NO_LIVE_STATE; - } finally { - closeWhenIdle(pending); - } -} - -/** - * ONE `pull_request_read get`, the whole call. Deliberately NOT - * `fetchPrLiveState`, which also fetches checks/preview extras (~5 calls per - * PR): the sweeps read every candidate PR in one go, and that multiplier is - * exactly what took out the GitHub App's rate limit once already (see - * `review-sweeper.ts`). - * - * Null on any failure, and never read as an answer by callers — an unreachable - * GitHub means "we could not ask", not "no". - */ -async function fetchPrGet( - ctx: StudioContext, - orgId: string, - pr: TaskBoardItemPrRef, - label: string, -): Promise | null> { - const conn = await resolveGithubConnection(ctx, orgId, pr.connectionId, { - owner: pr.repoOwner, - name: pr.repoName, - }); - if (!conn) return null; - const { get: getClient, closeWhenIdle } = lazyPrClient(conn, ctx); - const pending: Promise[] = []; - try { - return await cachedPrRead( - getClient, - conn.id, - "pull_request_read", - { - method: "get", - owner: pr.repoOwner, - repo: pr.repoName, - pullNumber: pr.number, - }, - `${prLabel(pr)} (${label})`, - pending, - ); - } catch { - return null; - } finally { - closeWhenIdle(pending); - } -} - -/** Just "is this PR merged?", for the archive sweep. */ -export async function fetchPrLanding( - ctx: StudioContext, - orgId: string, - pr: TaskBoardItemPrRef, -): Promise<{ state: "open" | "closed" | null; merged: boolean | null }> { - const obj = await fetchPrGet(ctx, orgId, pr, "landing"); - return { - // `merged` alone cannot answer `cardWorkLanded`: a closed-unmerged PR and an - // open one both report false, and only the first is settled. Both fields - // come off the one `get` this already pays for. - state: - obj?.state === "closed" - ? "closed" - : obj?.state === "open" - ? "open" - : null, - merged: typeof obj?.merged === "boolean" ? obj.merged : null, - }; -} - -/** - * Exactly what `prReadyForReview` reads, and nothing else. - * - * The review sweep used to call `fetchPrLiveState` here — four GitHub calls per - * PR plus one per failing check — for two booleans. It has cost that since - * before the check gate was dropped from the dispatch decision, and a red PR - * parked In Review is swept forever, so it paid the per-failure summaries on - * every pass in perpetuity. - */ -export async function fetchPrCandidateState( - ctx: StudioContext, - orgId: string, - pr: TaskBoardItemPrRef, -): Promise<{ - state: "open" | "closed" | null; - merged: boolean | null; - checksStatus: ChecksStatus; - conflict: boolean | null; -}> { - const obj = await fetchPrGet(ctx, orgId, pr, "candidate"); - return { - state: - obj?.state === "closed" - ? "closed" - : obj?.state === "open" - ? "open" - : null, - merged: typeof obj?.merged === "boolean" ? obj.merged : null, - checksStatus: checksFromMergeableState(obj?.mergeable_state), - // Rides the same `get` — the sweep's conflict signal costs nothing extra. - conflict: conflictFromPrGet(obj), - }; -} - /** - * GitHub's `mergeable_state`, read as a checks summary. Pure — unit-tested. - * - * It exists so the SWEEP can tell whether head's checks are green without - * paying for the two check reads `fetchPrStatusExtras` makes. `mergeable_state` - * rides along on the `get` the sweep already does, and this sweep's GitHub - * budget is not notional — a per-card multiplier here is what held the App's - * rate limit shut for 17 hours once (see `review-sweeper.ts`). + * Index of the change request the automation should act on, given each linked + * one's live state in `listPrs` order (newest first): the newest not + * definitively closed, falling back to the newest. * - * Only the two unambiguous values are mapped. `blocked` is deliberately NOT - * `pending`: it also covers a missing required review, which says nothing about - * CI, and reading it as a red check would hold QA back on a PR whose deploy is - * perfectly fine. Everything else — `dirty` (a conflict), `behind`, `unknown`, - * absent — says nothing about checks and answers `null`. - */ -export function checksFromMergeableState(state: unknown): ChecksStatus { - if (state === "clean") return "passing"; - if (state === "unstable") return "failing"; - return null; -} - -/** - * Index of the PR the automation should act on, given each linked PR's live - * state in `listPrs` order (newest first): the newest one not definitively - * closed, falling back to the newest. - * - * `null` (GitHub unreadable) counts as usable — a blip must not silently - * redirect a merge to an older PR. `states` may be shorter than the PR list, + * `null` (the provider unreadable) counts as usable — a blip must not silently + * redirect a merge to an older one. `states` may be shorter than the list, * since {@link pickActivePr} stops reading at the first usable one. */ export function pickActivePrIndex( states: readonly ("open" | "closed" | null)[], ): number { const i = states.findIndex((state) => state !== "closed"); - // Every PR read closed — the newest is still the best guess, and the merged + // Every one read closed — the newest is still the best guess, and the merged // ones are handled by the reconcile-to-Done path. return i === -1 ? 0 : i; } /** - * Which of a task's linked PRs the automation should act on. + * Which of a task's linked change requests the automation should act on. * * `listPrs` is newest-first, and taking `[0]` blindly is wrong once a task has - * more than one PR — a bounce that opens a fresh PR instead of pushing to the + * more than one — a bounce that opens a fresh one instead of pushing to the * reviewed one leaves the newest link pointing at an abandoned branch, so the * merge gate reads ITS red checks and reports `checks_failing` forever while - * the approved, green PR sits unmerged. + * the approved, green one sits unmerged. * * `ctx` is unused here on purpose — kept so callers don't need a special case * — the read goes through `readPrStateThrottled`, the same rate-limited DBOS * queue the review sweep's own candidate pass uses. This runs on every merge * attempt and every review decision, not just the sweep's own timer tick, so - * calling `fetchPrCandidateState` straight at GitHub here would reopen the - * exact unbounded-reads problem the queue exists to cap. + * calling `fetchPrCandidateState` straight at the provider here would reopen + * the exact unbounded-reads problem the queue exists to cap. */ export async function pickActivePr( _ctx: StudioContext, @@ -1271,8 +722,10 @@ export async function pickActivePr( for (const pr of prs) { const { state } = await readPrStateThrottled(orgId, pr); states.push(state); - // Stop at the first usable PR: the common case is one extra read, not one - // per link, which is what the GitHub rate limit cares about. + /** + * Stop at the first usable one: the common case is one extra read, not one + * per link, which is what a provider's rate limit cares about. + */ if (state !== "closed") break; } return prs[pickActivePrIndex(states)]; @@ -1281,16 +734,17 @@ export async function pickActivePr( export const TASK_BOARD_ITEM_PRS_GET = defineTool({ name: "TASK_BOARD_ITEM_PRS_GET", description: - "Get the GitHub pull requests linked to a task board item, each enriched " + - "with live state (title, open/closed, draft, merged) fetched from GitHub.", + "Get the change requests (GitHub pull requests, GitLab merge requests) " + + "linked to a task board item, each enriched with live state (title, " + + "open/closed, draft, merged) fetched from its provider.", annotations: { title: "Get Task Board Item Pull Requests", // Not read-only: as a side effect it moves a task to Done when it observes a - // merged PR (see the reconcile below). Idempotent — converges to Done. + // merge (see the reconcile below). Idempotent — converges to Done. readOnlyHint: false, destructiveHint: false, idempotentHint: true, - // Reaches out to GitHub for live PR state. + // Reaches out to the provider for live state. openWorldHint: true, }, inputSchema: z.object({ taskBoardItemId: z.string() }), @@ -1314,7 +768,7 @@ export const TASK_BOARD_ITEM_PRS_GET = defineTool({ taskBoardItemId, organizationId, ); - // One GitHub round-trip per linked PR, in parallel, each best-effort. + // One provider round-trip per linked change request, in parallel, each best-effort. const assemble = () => Promise.all( linked.map(async (pr) => { @@ -1330,29 +784,27 @@ export const TASK_BOARD_ITEM_PRS_GET = defineTool({ }), ); - // Serve the assembled card, not the raw reads it was built from. The read - // cache underneath still helps the sweeps, but it could not make THIS fast: - // it stores raw GitHub payloads, and a busy PR's `get_comments` runs past - // the value cap, so its put is rejected and that PR misses on every read - // forever. A card is a few hundred bytes, so it always stores — and one KV - // get replaces the four-to-six this made per PR. - // - // And on a cold card it does NOT block: the database already holds the - // repo, the number and the link, so that goes back immediately and GitHub's - // half (title, checks, preview) lands in KV for the next poll. Waiting on - // GitHub for what we already have was the ~2s. + /** + * Serve the assembled card, not the reads it was built from. And on a cold + * card it does NOT block: the database already holds the repo, the number + * and the link, so that goes back immediately and the provider's half + * (title, checks, preview) lands in KV for the next poll. Waiting on the + * provider for what we already have was the ~2s. + */ const { value: prs, live } = await getPrCardCache().fetchOrPlaceholder({ namespace: organizationId, key: taskBoardItemId, fetchLive: assemble, - // A card whose deploy is still running has no preview yet. At the default - // window that card is a cache HIT for 30s, so the url can be a poll or - // two late even after the read above refreshes. Go stale immediately - // instead: the revalidation is detached, so this costs a background - // rebuild per poll on exactly the cards that are still missing something. + /** + * A card whose deploy is still running has no preview yet. At the default + * window it is a hit for 30s, so the URL can be a poll or two late even + * after the read above refreshes. Go stale immediately instead: the + * revalidation is detached, so this costs a background rebuild per poll + * on exactly the cards that are still missing something. + */ revalidateAfterMs: (cards) => cards.some(isAwaitingCi) - ? NOT_READY_REVALIDATE_MS + ? NOT_READY_MS : PR_CARDS_CACHE.revalidateAfterMs, placeholder: linked.map((pr) => ({ url: pr.url, @@ -1364,22 +816,25 @@ export const TASK_BOARD_ITEM_PRS_GET = defineTool({ })), }); - // Everything below reconciles the TASK from what GitHub said about its PRs. - // On the placeholder we have not asked yet, and all-null does not mean - // "open, unmerged, no checks" — it means "unknown". Acting on it would hand - // a card to the reviewer, or move it to Done, on the strength of a database - // row. The next poll (seconds away) runs them on the real thing. + /** + * Everything below reconciles the TASK from what the provider said. On the + * placeholder we have not asked yet, and all-null does not mean "open, + * unmerged, no checks" — it means "unknown". Acting on it would hand a + * card to the reviewer, or move it to Done, on the strength of a database + * row. The next poll (seconds away) runs them on the real thing. + */ if (!live) return { prs }; - // Auto-hand-off to the reviewer: once the Super Agent's PR is In Review and - // its checks are green — OR it has no checks at all — delegate to the - // reviewer, if the org has it turned on. Only a pending - // or failing run blocks the hand-off; a PR without CI (`checksStatus === - // null`) shouldn't sit in review forever. Like the merge→done reconcile - // below this is reconcile-on-view (no PR webhook), driven by the modal's 10s - // poll. Gated on assignee === Super Agent so it never fires for a human's - // manual review; `enqueueEnabledReviewers` is itself idempotent per reviewer - // per review cycle, so re-polling won't spawn duplicate reviewer runs. + /** + * Auto-hand-off to the reviewer: once the Super Agent's change request is + * In Review and its checks are green — OR it has none at all — delegate to + * the reviewer, if the org has it turned on. Only a pending or failing run + * blocks the hand-off. Like the merge→done reconcile below this is + * reconcile-on-view (no provider webhook), driven by the modal's 10s poll. + * Gated on assignee === Super Agent so it never fires for a human's manual + * review; `enqueueEnabledReviewers` is itself idempotent per reviewer per + * review cycle, so re-polling won't spawn duplicate reviewer runs. + */ const reviewLane = LANES.review; const openPr = prs.find((p) => p.state === "open" && !p.merged); if ( @@ -1395,27 +850,28 @@ export const TASK_BOARD_ITEM_PRS_GET = defineTool({ }); } - // Auto-resolve a merge conflict on an approved PR: once every enabled - // reviewer approved but the PR can't merge because it conflicts with its - // base branch, hand it back to the Super Agent to resolve (gated on the - // org's `auto_merge` flag, checked inside the reaction). This is the - // poll-driven safety net — a conflict often appears AFTER approval (the base - // branch moved on), which the merge attempt at approval time can't see. Run - // the same `conflictFromPrGet` mapping the review-decision path uses (only an - // explicit conflict triggers; null/unknown never does). The reaction is - // idempotent (it bounces the task to In Progress, so the next poll skips). - const openPrConflict = openPr - ? conflictFromPrGet({ state: openPr.state, mergeable: openPr.mergeable }) - : null; + /** + * Auto-resolve a merge conflict on an approved change request: once every + * enabled reviewer approved but it can't merge because it conflicts with + * its base branch, hand it back to the Super Agent to resolve (gated on + * the org's `auto_merge` flag, checked inside the reaction). This is the + * poll-driven safety net — a conflict often appears AFTER approval (the + * base branch moved on), which the merge attempt at approval time can't + * see. Only an explicit conflict triggers; null/unknown never does. The + * reaction is idempotent (it bounces the task to In Progress, so the next + * poll skips). + */ if ( item && item.status === reviewLane && item.assigneeId === SUPER_AGENT_ASSIGNEE_ID && openPr && - openPrConflict === true + openPr.mergeable === false ) { - // Act on the PR the conflict was detected on (`openPr`), not a re-derived - // "newest" — a task can have more than one linked PR. + /** + * Act on the one the conflict was detected on (`openPr`), not a + * re-derived "newest" — a task can have more than one linked. + */ await reactToApprovedPrConflict(ctx, organizationId, item, { pr: { number: openPr.number, url: openPr.url }, conflict: true, @@ -1424,10 +880,13 @@ export const TASK_BOARD_ITEM_PRS_GET = defineTool({ }); } - // ponytail: reconcile-on-view — there's no GitHub PR webhook, so a merged PR - // only advances the card when someone opens this modal. Upgrade path: - // a `pull_request` webhook calling the same forward move. Best-effort; a - // failure must never break the read. Forward-only via `movesForward`. + /** + * ponytail: reconcile-on-view — there's no provider webhook, so a merged + * change request only advances the card when someone opens this modal. + * Upgrade path: a `pull_request`/`merge_request` webhook calling the same + * forward move. Best-effort; a failure must never break the read. + * Forward-only via `movesForward`. + */ if (cardWorkLanded(prs)) { try { // Inside the try: this block is best-effort and must not fail the read. @@ -1451,7 +910,7 @@ export const TASK_BOARD_ITEM_PRS_GET = defineTool({ // Every other path that moves a card to Done (the review-decision // auto-merge, "Ship to production") logs a `status_changed` timeline // entry — this reconcile silently skipped it, so a task auto-completed - // by a human merging its PR directly on GitHub left no trace of the + // by a human merging directly on the provider left no trace of the // move in the Activity feed. await recordTaskActivity(ctx, { taskBoardItemId, @@ -1469,53 +928,3 @@ export const TASK_BOARD_ITEM_PRS_GET = defineTool({ return { prs }; }, }); - -/** - * The PR's head branch name (`head.ref`), or null when GitHub can't be reached. - * - * Read live rather than stored on the row deliberately: `task_board_item_prs` - * is written from three places (the MCP `onPrOpened` hook, a bash-output scan, - * and the sweeper's closing-message scan) and only one of them ever knows the - * branch. GitHub always does. Null is a first-class answer — the caller falls - * back to today's fresh-branch behavior rather than guessing a ref and pushing - * work somewhere nobody is looking. - */ -export async function fetchPrHeadRef( - ctx: StudioContext, - orgId: string, - pr: TaskBoardItemPrRef, -): Promise { - const conn = await resolveGithubConnection(ctx, orgId, pr.connectionId, { - owner: pr.repoOwner, - name: pr.repoName, - }); - if (!conn) return null; - const client = await clientFromConnection(conn, ctx, true); - try { - const obj = toolResultJson( - await client.callTool( - { - name: "pull_request_read", - arguments: { - method: "get", - owner: pr.repoOwner, - repo: pr.repoName, - pullNumber: pr.number, - }, - }, - undefined, - { timeout: PR_FETCH_TIMEOUT_MS }, - ), - ); - // Only an OPEN PR's branch is worth reusing: pushing to a merged or closed - // PR's branch updates nothing anyone will look at, and the re-run's work - // would be invisible. - if (!obj || obj.state !== "open") return null; - const ref = (obj.head as { ref?: unknown } | undefined)?.ref; - return typeof ref === "string" && ref.length > 0 ? ref : null; - } catch { - return null; - } finally { - await client.close().catch(() => {}); - } -} diff --git a/apps/api/src/tools/task-board/quota-refund.integration.test.ts b/apps/api/src/tools/task-board/quota-refund.integration.test.ts index b06d96dcd6..c8b83271be 100644 --- a/apps/api/src/tools/task-board/quota-refund.integration.test.ts +++ b/apps/api/src/tools/task-board/quota-refund.integration.test.ts @@ -169,8 +169,11 @@ describe("quota refund on thread finish (wiring)", () => { organizationId: ORG, url: "https://github.com/acme/repo/pull/7", prNumber: 7, - repoOwner: "acme", - repoName: "repo", + repo: { + provider: "github", + host: "github.com", + path: "acme/repo", + }, connectionId: null, }); // Even dragged backwards, a delivered PR keeps the charge. diff --git a/apps/api/src/tools/task-board/rerun-merge-pending.integration.test.ts b/apps/api/src/tools/task-board/rerun-merge-pending.integration.test.ts index 2b2d02a6c8..bfda413e53 100644 --- a/apps/api/src/tools/task-board/rerun-merge-pending.integration.test.ts +++ b/apps/api/src/tools/task-board/rerun-merge-pending.integration.test.ts @@ -169,8 +169,11 @@ describe("refuseIfMergePending", () => { organizationId: ORG, url: "https://github.com/acme/widgets/pull/7", prNumber: 7, - repoOwner: "acme", - repoName: "widgets", + repo: { + provider: "github", + host: "github.com", + path: "acme/widgets", + }, }); // Cap already spent — so ONLY the unknown conflict signal keeps the refusal. for (let i = 0; i < 3; i++) { diff --git a/apps/api/src/tools/task-board/review-decision.ts b/apps/api/src/tools/task-board/review-decision.ts index e73e929d58..fa6dad4988 100644 --- a/apps/api/src/tools/task-board/review-decision.ts +++ b/apps/api/src/tools/task-board/review-decision.ts @@ -19,10 +19,10 @@ import { } from "./run-reactions"; import { allEnabledReviewersVerifiedApproved, - conflictSignal, + conflictFromOutcome, mergeLinkedPr, } from "./merge-pr"; -import { fetchPrConflict, pickActivePr } from "./prs-get"; +import { pickActivePr } from "./prs-get"; import { verifyReviewToken } from "./review-token"; import { reactToApprovedPrConflict } from "./conflict-reaction"; import { TaskQuotaError } from "@/billing/task-quota"; @@ -360,10 +360,12 @@ export const TASK_BOARD_REVIEW_DECISION = defineTool({ const resolving = pr ? await reactToApprovedPrConflict(ctx, organizationId, current, { pr: { number: pr.number, url: pr.url }, - conflict: conflictSignal( - await fetchPrConflict(ctx, organizationId, pr), - outcome, - ), + /** + * The merge attempt already classified its own refusal, so this + * costs no extra provider read — where it used to pay a + * mergeability call on every approval. + */ + conflict: conflictFromOutcome(outcome), }).catch((err) => { // Same paywall exception as above — a TaskQuotaError must // surface, not be swallowed as a routine auto-resolve failure. diff --git a/apps/api/src/tools/task-board/run-reactions.test.ts b/apps/api/src/tools/task-board/run-reactions.test.ts index 489b1edeb9..572991adb4 100644 --- a/apps/api/src/tools/task-board/run-reactions.test.ts +++ b/apps/api/src/tools/task-board/run-reactions.test.ts @@ -16,8 +16,7 @@ type LinkPrCall = { organizationId: string; url: string; prNumber: number; - repoOwner: string; - repoName: string; + repo: { provider: string; host: string; path: string }; connectionId?: string | null; }; @@ -66,8 +65,7 @@ describe("capturePrForRun", () => { organizationId: "org-1", url: "https://github.com/acme/site/pull/42", prNumber: 42, - repoOwner: "acme", - repoName: "site", + repo: { provider: "github", host: "github.com", path: "acme/site" }, connectionId: "conn-9", }, ]); @@ -145,9 +143,11 @@ describe("resolveAdvanceTargets", () => { }); describe("isPrCreateMcpTool", () => { - it("matches the GitHub MCP PR-create tools", () => { + it("matches each provider's create tool", () => { expect(isPrCreateMcpTool("create_pull_request")).toBe(true); expect(isPrCreateMcpTool("createPullRequest")).toBe(true); + expect(isPrCreateMcpTool("create_merge_request")).toBe(true); + expect(isPrCreateMcpTool("createMergeRequest")).toBe(true); }); it("matches a gateway-prefixed tool name", () => { @@ -160,18 +160,23 @@ describe("isPrCreateMcpTool", () => { it("ignores other tools", () => { expect(isPrCreateMcpTool("list_pull_requests")).toBe(false); expect(isPrCreateMcpTool("pull_request_read")).toBe(false); + expect(isPrCreateMcpTool("list_merge_requests")).toBe(false); expect(isPrCreateMcpTool("bash")).toBe(false); }); }); describe("isPrCreateBashCommand", () => { - it("matches `gh pr create` with flags and extra whitespace", () => { + it("matches either CLI's create, with flags and extra whitespace", () => { expect(isPrCreateBashCommand("gh pr create")).toBe(true); expect(isPrCreateBashCommand("gh pr create --fill --base main")).toBe(true); expect(isPrCreateBashCommand("cd repo && gh pr create")).toBe(true); + expect(isPrCreateBashCommand("glab mr create")).toBe(true); + expect( + isPrCreateBashCommand("cd repo && glab mr create --fill --yes"), + ).toBe(true); }); - it("matches a curl REST POST to the GitHub pulls endpoint", () => { + it("matches a curl REST POST to either provider's endpoint", () => { // The real prod case: agent fell back to curl when the MCP tool 404'd. const cmd = 'cd /app/repo && curl -s -X POST -H "Authorization: token $TOKEN" ' + @@ -182,17 +187,28 @@ describe("isPrCreateBashCommand", () => { "curl --request POST https://api.github.com/repos/o/r/pulls -d '{}'", ), ).toBe(true); + expect( + isPrCreateBashCommand( + "curl -X POST https://gitlab.com/api/v4/projects/group%2Fstore/merge_requests -d '{}'", + ), + ).toBe(true); }); it("does not match unrelated gh / git / curl commands", () => { expect(isPrCreateBashCommand("gh pr list")).toBe(false); expect(isPrCreateBashCommand("gh pr view 12")).toBe(false); + expect(isPrCreateBashCommand("glab mr list")).toBe(false); expect(isPrCreateBashCommand("git push origin feature")).toBe(false); expect(isPrCreateBashCommand("echo create pull request")).toBe(false); - // GET to /pulls (listing) must not count — only a POST opens a PR. + // A GET to the listing endpoint must not count — only a POST opens one. expect( isPrCreateBashCommand("curl https://api.github.com/repos/o/r/pulls"), ).toBe(false); + expect( + isPrCreateBashCommand( + "curl https://gitlab.com/api/v4/projects/1/merge_requests", + ), + ).toBe(false); }); }); diff --git a/apps/api/src/tools/task-board/run-reactions.ts b/apps/api/src/tools/task-board/run-reactions.ts index fc6709c3cf..8c96315c6e 100644 --- a/apps/api/src/tools/task-board/run-reactions.ts +++ b/apps/api/src/tools/task-board/run-reactions.ts @@ -4,7 +4,8 @@ * A task delegated to the Super Agent rides its run's lifecycle: * enqueued → todo (create/update tool, synchronous) * loop starts → in_progress (runHostedHarness) - * agent opens a PR → review cycle (github MCP tool call OR bash `gh pr create`) + * agent opens one → review cycle (a provider MCP tool call OR bash + * `gh pr create` / `glab mr create`) * opens, lane * stays in_progress * thread finishes, → in_review (projector terminal → thread-finish hook) @@ -31,7 +32,7 @@ import { captureOrgEvent } from "@/posthog"; import type { OrganizationBillingStorage } from "@/storage/organization-billing"; import { TERMINAL_THREAD_STATUSES } from "@/storage/task-board"; import type { StudioContext } from "@/core/studio-context"; -import { extractPrFromValue } from "./pr-extract"; +import { findChangeRequestIn } from "./change-request-extract"; import { invalidatePrCards } from "./prs-get"; import { retryBudgetFor } from "./transient-failure"; import { exponentialBackoffWithJitter } from "@decocms/shared/std"; @@ -228,8 +229,8 @@ export async function openReviewCycleForRun( } /** - * A run opened a GitHub PR — extract its identity from `source` (an MCP - * `create_pull_request` result or a `bash` tool's output) and link it to the + * A run opened a change request — extract its identity from `source` (a + * provider tool result or a `bash` tool's output) and link it to the * run's task board item(s). Resolves the target(s) the same way as the status * advance (runMetadata first, else the thread link), so a PR opened inside a * subtask (which carries no metadata but shares the thread) still links. @@ -245,7 +246,7 @@ export async function capturePrForRun( const orgId = ctx.organization?.id; if (!orgId) return; try { - const pr = extractPrFromValue(source); + const pr = findChangeRequestIn(source); if (!pr) return; const targets = await resolveRunTaskTargets(ctx, orgId, threadId); for (const itemId of targets) { @@ -254,8 +255,7 @@ export async function capturePrForRun( organizationId: orgId, url: pr.url, prNumber: pr.number, - repoOwner: pr.owner, - repoName: pr.repo, + repo: pr.repo, connectionId: connectionId ?? null, }); } @@ -661,27 +661,37 @@ export async function reopenTasksOnThreadRun( } /** - * True when an MCP tool call opens a GitHub PR. Matched by substring so a - * gateway-prefixed name (`conn-6-..._create_pull_request`) still counts. + * True when an MCP tool call opens a change request. Matched by substring so a + * gateway-prefixed name (`conn-6-..._create_pull_request`) still counts, and + * per provider because each names its own object. */ export function isPrCreateMcpTool(toolName: string): boolean { return ( toolName.includes("create_pull_request") || - toolName.includes("createPullRequest") + toolName.includes("createPullRequest") || + toolName.includes("create_merge_request") || + toolName.includes("createMergeRequest") ); } -// ponytail: heuristics for the bash escape hatch. Agents open PRs from bash two -// ways — `gh pr create`, or a raw `curl -X POST …/repos/…/pulls` when gh / the -// GitHub-MCP tool is unavailable (observed in prod: the MCP connection was scoped -// to the wrong repo → the agent fell back to curl). Known ceiling: misses shell -// aliases and script wrappers. The reliable path is the MCP tool above. -const GH_PR_CREATE = /\bgh\s+pr\s+create\b/; -const GITHUB_API_PULLS = /api\.github\.com\/repos\/[^\s"']+\/pulls\b/; +/** + * ponytail: heuristics for the bash escape hatch. Agents open one from bash + * two ways — the provider's CLI (`gh pr create`, `glab mr create`), or a raw + * `curl -X POST` at the REST endpoint when the CLI or the MCP tool is + * unavailable (observed in prod: the MCP connection was scoped to the wrong + * repository, so the agent fell back to curl). Known ceiling: misses shell + * aliases and script wrappers. The reliable path is the tool above. + * + * Both CLIs are in the sandbox image and the checkout's own credential decides + * which one is authenticated, so a run can genuinely use either. + */ +const CLI_CREATE = /\b(?:gh\s+pr|glab\s+mr)\s+create\b/; +const REST_CHANGE_REQUESTS = + /\/(?:repos\/[^\s"']+\/pulls|projects\/[^\s"']+\/merge_requests)\b/; const HTTP_POST = /(?:-X|--request)\s+POST/; -/** True when a bash command opens a GitHub PR (gh CLI or a REST POST to /pulls). */ +/** True when a bash command opens a change request (either CLI, or a REST POST). */ export function isPrCreateBashCommand(command: string): boolean { - if (GH_PR_CREATE.test(command)) return true; - return GITHUB_API_PULLS.test(command) && HTTP_POST.test(command); + if (CLI_CREATE.test(command)) return true; + return REST_CHANGE_REQUESTS.test(command) && HTTP_POST.test(command); } diff --git a/apps/api/src/tools/task-board/tag-merged.integration.test.ts b/apps/api/src/tools/task-board/tag-merged.integration.test.ts index 81420489ed..9d45e7e290 100644 --- a/apps/api/src/tools/task-board/tag-merged.integration.test.ts +++ b/apps/api/src/tools/task-board/tag-merged.integration.test.ts @@ -47,8 +47,11 @@ describe("merged-tag sweep", () => { organizationId: ORG, url: `https://github.com/acme/repo/pull/${item.id}`, prNumber: 1, - repoOwner: "acme", - repoName: "repo", + repo: { + provider: "github", + host: "github.com", + path: "acme/repo", + }, }); } return item.id; diff --git a/apps/api/src/tools/task-board/update.ts b/apps/api/src/tools/task-board/update.ts index 8f3f2d98b8..b1ae4a3fae 100644 --- a/apps/api/src/tools/task-board/update.ts +++ b/apps/api/src/tools/task-board/update.ts @@ -21,7 +21,7 @@ import { recordTaskActivities } from "./activity"; import { taskRunContextStore } from "./task-run-context"; import { emitTaskBoardUpdated } from "./run-reactions"; import { runColumnAutomation } from "./run-column-automation"; -import { extractPrFromText } from "./pr-extract"; +import { findChangeRequestIn } from "./change-request-extract"; import { normalizePreviewRoutes } from "./preview-routes"; import { invalidatePrCards } from "./prs-get"; import { @@ -257,11 +257,12 @@ export const TASK_BOARD_ITEM_UPDATE = defineTool({ } // Parse the PR link before any write, so a bad URL fails without a partial edit. - const pr = input.prUrl ? extractPrFromText(input.prUrl) : null; + const pr = input.prUrl ? findChangeRequestIn(input.prUrl) : null; if (input.prUrl && !pr) { throw new Error( - `Not a GitHub pull request URL: ${input.prUrl} (expected ` + - "https://github.com///pull/)", + `Not a change request URL: ${input.prUrl} (expected ` + + "https://github.com///pull/ or " + + "https://gitlab.com///-/merge_requests/)", ); } @@ -429,8 +430,7 @@ export const TASK_BOARD_ITEM_UPDATE = defineTool({ organizationId, url: pr.url, prNumber: pr.number, - repoOwner: pr.owner, - repoName: pr.repo, + repo: pr.repo, connectionId: null, }); // Drop the cached card so a viewer's next poll shows the new PR, not a stale "no PR" placeholder. diff --git a/apps/web/src/components/chat/pills/branch-pill.tsx b/apps/web/src/components/chat/pills/branch-pill.tsx index 7948053745..c1ecaac308 100644 --- a/apps/web/src/components/chat/pills/branch-pill.tsx +++ b/apps/web/src/components/chat/pills/branch-pill.tsx @@ -1,4 +1,5 @@ import type { SandboxMap } from "@/sdk"; +import type { RepoToolTarget } from "@/lib/github-repo.ts"; import { BranchPicker } from "../../thread/github/branch-picker"; import { BranchPickerLegacy } from "../../thread/github/branch-picker-legacy"; @@ -19,7 +20,7 @@ interface Props { orgId: string; orgSlug: string; userId: string; - connectionId: string | null; + target: RepoToolTarget; owner: string; repo: string; sandboxMap: SandboxMap | undefined; @@ -41,7 +42,7 @@ export function BranchPill({ orgId, orgSlug, userId, - connectionId, + target, owner, repo, sandboxMap, @@ -56,7 +57,7 @@ export function BranchPill({ orgId={orgId} orgSlug={orgSlug} userId={userId} - connectionId={connectionId} + target={target} owner={owner} repo={repo} sandboxMap={sandboxMap} @@ -74,7 +75,7 @@ export function BranchPill({ userId={userId} userLabel={userLabel} virtualMcpId={virtualMcpId} - connectionId={connectionId} + target={target} owner={owner} repo={repo} sandboxMap={sandboxMap} diff --git a/apps/web/src/components/chat/pills/chat-mode-row.test.tsx b/apps/web/src/components/chat/pills/chat-mode-row.test.tsx index 5b6f33c3ac..b77070a067 100644 --- a/apps/web/src/components/chat/pills/chat-mode-row.test.tsx +++ b/apps/web/src/components/chat/pills/chat-mode-row.test.tsx @@ -69,7 +69,7 @@ const BRANCH_PILL_PROPS = { userId: "user-1", userLabel: "Test User", virtualMcpId: "vmcp-1", - connectionId: "conn-1", + target: { connectionId: "conn-1" }, owner: "acme", repo: "monorepo", sandboxMap: undefined, diff --git a/apps/web/src/components/chat/pills/chat-mode-row.tsx b/apps/web/src/components/chat/pills/chat-mode-row.tsx index 4ebc804a49..4807196d39 100644 --- a/apps/web/src/components/chat/pills/chat-mode-row.tsx +++ b/apps/web/src/components/chat/pills/chat-mode-row.tsx @@ -6,7 +6,12 @@ import { draftsModeEnabled, useBaseBranch, } from "../../thread/github/use-version-gate"; -import { getActiveGithubRepo } from "@/lib/github-repo"; +import { + getActiveGithubRepo, + hasRepoCredential, + repoTargetKey, + repoToolTarget, +} from "@/lib/github-repo"; import { useProjectContext } from "@/sdk"; import { defaultThreadRuntime, @@ -63,7 +68,7 @@ export function ChatModeRow({ virtualMcp, currentBranch }: SmartProps) { "sandbox"; const githubRepo = getActiveGithubRepo(virtualMcp); - const connectionId = githubRepo?.connectionId; + const repoTarget = repoToolTarget(githubRepo); const { data: session } = authClient.useSession(); const userLabel = branchUserLabel(session?.user); @@ -87,10 +92,10 @@ export function ChatModeRow({ virtualMcp, currentBranch }: SmartProps) { }; const branchPill = - githubRepo && connectionId ? ( + githubRepo && hasRepoCredential(repoTarget) ? ( { + /** The regression: present and empty, not absent. */ + it("always carries connections, even with none to attach", () => { + const payload = agentPayload({ id: "rep_1" }, repo, { description: "d" }); + expect(payload.connections).toEqual([]); + expect(Object.hasOwn(payload, "connections")).toBe(true); + }); + + /** What every provider client resolves the credential from. */ + it("records the repository id on the binding", () => { + expect( + agentPayload({ id: "rep_1" }, repo, { description: "d" }).metadata + .githubRepo.repositoryId, + ).toBe("rep_1"); + }); + + /** + * A GitLab project in subgroups keeps every namespace level in `owner`, and + * the URL is what names the provider — neither may be flattened away. + */ + it("keeps a nested namespace and the provider-bearing URL", () => { + const { githubRepo } = agentPayload({ id: "rep_1" }, repo, { + description: "d", + }).metadata; + expect(githubRepo.owner).toBe("group/team"); + expect(githubRepo.name).toBe("storefront"); + expect(githubRepo.url).toBe("https://gitlab.com/group/team/storefront"); + }); + + /** Both providers open on the editor; the provider-gated default is gone. */ + it("opens on the site editor regardless of provider", () => { + expect( + agentPayload({ id: "rep_1" }, repo, { description: "d" }).metadata.ui + .layout.defaultMainView.type, + ).toBe("site-editor"); + }); +}); diff --git a/apps/web/src/components/repository-picker-bridge.tsx b/apps/web/src/components/repository-picker-bridge.tsx index 67ef85c902..aa41125f83 100644 --- a/apps/web/src/components/repository-picker-bridge.tsx +++ b/apps/web/src/components/repository-picker-bridge.tsx @@ -9,6 +9,11 @@ * The created agent records `metadata.githubRepo.repositoryId`, which is what * `SANDBOX_START` resolves credentials from; `owner`/`name`/`url` stay for the * legacy readers and for display. + * + * Every project opens on Site Editor regardless of provider: reading the + * decofile and opening a change request both go through the provider + * interface now, so there is nothing left for the editor to be unable to do on + * a GitLab repository. */ import { useQueryClient } from "@tanstack/react-query"; @@ -43,6 +48,53 @@ function toRepo(repository: Repository): Repo { }; } +/** + * The project a repository is imported as. + * + * Pure, and separate from the call, because it is a WIRE CONTRACT with + * `COLLECTION_VIRTUAL_MCP_CREATE` — and one this file already got wrong once: + * `connections` is required by the tool's schema, so omitting it made every + * import fail with a 400 and create nothing, while the picker closed as if it + * had worked. Empty is the correct value here (see the field's note), but the + * key has to be present, and a test can say so. + */ +export function agentPayload( + repository: Pick, + repo: Pick, + opts: { description: string }, +) { + return { + title: repo.name, + description: opts.description, + pinned: false, + icon: null, + metadata: { + githubRepo: { + owner: repo.owner, + name: repo.name, + url: repo.url, + /** What `SANDBOX_START` and every provider client resolve from. */ + repositoryId: repository.id, + }, + instructions: null, + ui: { + pinnedViews: null, + layout: { + defaultMainView: { type: "site-editor" as const }, + chatDefaultOpen: true, + }, + }, + }, + /** + * Empty, and required: a repository-backed project has no per-repo + * connection to attach — its credential comes from the repository's git + * provider account. The legacy picker passes the `mcp-github` child it + * provisions, which is the only reason this field ever carried anything. + */ + connections: [], + }; +} + export function RepositoryPickerBridge({ open, onOpenChange, @@ -76,39 +128,11 @@ export function RepositoryPickerBridge({ const result = (await selfClient.callTool({ name: "COLLECTION_VIRTUAL_MCP_CREATE", arguments: { - data: { - title: repo.name, + data: agentPayload(repository, repo, { description: t("common.repositoryPicker.agentDescription", { path: repository.path, }), - pinned: false, - icon: null, - metadata: { - githubRepo: { - owner: repo.owner, - name: repo.name, - url: repo.url, - repositoryId: repository.id, - }, - instructions: null, - ui: { - pinnedViews: null, - layout: { - /** - * Site Editor reads the decofile over GitHub's Git Data API, - * so a GitLab repository would land on a view that cannot - * load. Those projects open on Chat — the coding agent, which - * does work — until the editor speaks both providers. - */ - defaultMainView: { - type: - repository.provider === "github" ? "site-editor" : "chat", - }, - chatDefaultOpen: true, - }, - }, - }, - }, + }), }, })) as { structuredContent?: unknown }; const payload = (result.structuredContent ?? result) as { diff --git a/apps/web/src/components/thread/github/branch-picker-legacy.tsx b/apps/web/src/components/thread/github/branch-picker-legacy.tsx index 887035621f..83a0816e46 100644 --- a/apps/web/src/components/thread/github/branch-picker-legacy.tsx +++ b/apps/web/src/components/thread/github/branch-picker-legacy.tsx @@ -1,4 +1,5 @@ import { type UIEvent, useState } from "react"; +import type { RepoToolTarget } from "@/lib/github-repo.ts"; import type { SandboxMap } from "@/sdk"; import { useMembersQuery } from "@/hooks/use-members"; import { getInitials } from "@/lib/get-initials"; @@ -46,7 +47,7 @@ interface Props { * seed generated branch names. */ userLabel: string | null | undefined; virtualMcpId: string; - connectionId: string | null; + target: RepoToolTarget; owner: string; repo: string; sandboxMap: SandboxMap | undefined; @@ -80,7 +81,7 @@ export function BranchPickerLegacy({ userLabel, // Kept on Props for a uniform pill contract; unused here directly. virtualMcpId: _virtualMcpId, - connectionId, + target, owner, repo, sandboxMap, @@ -114,7 +115,7 @@ export function BranchPickerLegacy({ orgId, orgSlug, userId, - connectionId, + target, sandboxMap, owner, repo, @@ -138,7 +139,7 @@ export function BranchPickerLegacy({ } = useOpenPrs({ orgId, orgSlug, - connectionId: connectionId ?? "", + target, owner, repo, enabled: open && tab === "prs", diff --git a/apps/web/src/components/thread/github/branch-picker.tsx b/apps/web/src/components/thread/github/branch-picker.tsx index 59f9b77586..a00e527781 100644 --- a/apps/web/src/components/thread/github/branch-picker.tsx +++ b/apps/web/src/components/thread/github/branch-picker.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import type { RepoToolTarget } from "@/lib/github-repo.ts"; import { LAYOUT_TOUR_ANCHORS } from "@/components/layout-tour/anchors"; import { Button } from "@decocms/ui/components/button.tsx"; import { cn } from "@decocms/ui/lib/utils.ts"; @@ -77,7 +78,7 @@ interface Props { orgId: string; orgSlug: string; userId: string; - connectionId: string | null; + target: RepoToolTarget; owner: string; repo: string; sandboxMap: SandboxMap | undefined; @@ -108,7 +109,7 @@ export function BranchPicker({ orgId, orgSlug, userId, - connectionId, + target, owner, repo, sandboxMap, @@ -304,7 +305,7 @@ export function BranchPicker({ orgId={orgId} orgSlug={orgSlug} userId={userId} - connectionId={connectionId} + target={target} owner={owner} repo={repo} sandboxMap={sandboxMap} @@ -547,7 +548,7 @@ function AdvancedPicker({ orgId, orgSlug, userId, - connectionId, + target, owner, repo, sandboxMap, @@ -558,7 +559,7 @@ function AdvancedPicker({ orgId: string; orgSlug: string; userId: string; - connectionId: string | null; + target: RepoToolTarget; owner: string; repo: string; sandboxMap: SandboxMap | undefined; @@ -581,7 +582,7 @@ function AdvancedPicker({ orgId, orgSlug, userId, - connectionId, + target, sandboxMap, owner, repo, @@ -591,7 +592,7 @@ function AdvancedPicker({ const { data: prs = [], isLoading: prsLoading } = useOpenPrs({ orgId, orgSlug, - connectionId: connectionId ?? "", + target, owner, repo, enabled: enabled && tab === "prs", diff --git a/apps/web/src/components/thread/github/checks-tab.tsx b/apps/web/src/components/thread/github/checks-tab.tsx index 27351524e1..316a0a66b6 100644 --- a/apps/web/src/components/thread/github/checks-tab.tsx +++ b/apps/web/src/components/thread/github/checks-tab.tsx @@ -1,4 +1,5 @@ import { useProjectContext } from "@/sdk"; +import type { RepoToolTarget } from "@/lib/github-repo.ts"; import { Button } from "@decocms/ui/components/button.tsx"; import { Markdown } from "@decocms/ui/components/markdown.tsx"; import { cn } from "@decocms/ui/lib/utils.ts"; @@ -17,7 +18,7 @@ import { interface Props { pr: PrSummary; - connectionId: string; + target: RepoToolTarget; owner: string; repo: string; } @@ -26,10 +27,10 @@ interface Props { * Checks sub-tab: list of CI runs for the PR head SHA. Each row shows * the run name, status/conclusion, duration, a link to the provider's * run page, and a Re-run button that sends a templated chat message. - * Rows expand to render the check run's `output` (e.g. the QA journey's - * step-by-step table) inline, lazily fetched via GET_CHECK_RUN. + * Rows expand to render the run's own report (e.g. the QA journey's + * step-by-step table) inline, lazily fetched per row. */ -export function ChecksTab({ pr, connectionId, owner, repo }: Props) { +export function ChecksTab({ pr, target, owner, repo }: Props) { const { org } = useProjectContext(); const chat = useChatStream(); const t = useT(); @@ -38,7 +39,7 @@ export function ChecksTab({ pr, connectionId, owner, repo }: Props) { const checksQuery = useChecks({ orgId: org.id, orgSlug: org.slug, - connectionId, + target, owner, repo, branch: pr.head, @@ -96,14 +97,20 @@ export function ChecksTab({ pr, connectionId, owner, repo }: Props) { return (
    {checks.map((c) => { - const isOpen = expanded.has(c.id); - const checkRunId = Number(c.id); + /** + * A run with no id has no report to expand — a GitHub commit status + * is a link, not a run, and GitLab's job trace is addressed by job id. + * Those rows still render; they just do not open. + */ + const rowKey = c.id ?? c.name; + const isOpen = c.id !== null && expanded.has(c.id); return ( -
  • +
  • @@ -165,10 +172,10 @@ export function ChecksTab({ pr, connectionId, owner, repo }: Props) { interface CheckRunDetailProps { orgId: string; orgSlug: string; - connectionId: string; + target: RepoToolTarget; owner: string; repo: string; - checkRunId: number | null; + checkRunId: string | null; } /** Lazily-loaded detail rendered under an expanded check row. */ diff --git a/apps/web/src/components/thread/github/cms-header-actions.tsx b/apps/web/src/components/thread/github/cms-header-actions.tsx index 3f7ddeb146..161b72192d 100644 --- a/apps/web/src/components/thread/github/cms-header-actions.tsx +++ b/apps/web/src/components/thread/github/cms-header-actions.tsx @@ -31,7 +31,7 @@ import { GitPullRequest, RefreshCw01, Rocket02 } from "@untitledui/icons"; import { GitHubIcon } from "@/components/icons/github-icon.tsx"; import { useT } from "@/i18n/use-t"; import { track } from "@/lib/posthog-client"; -import { resolveGithubAttachment } from "@/lib/github-repo.ts"; +import { repoToolTarget, resolveGithubAttachment } from "@/lib/github-repo.ts"; import { KEYS } from "@/lib/query-keys"; import { useProjectContext, useVirtualMCP } from "@/sdk"; import { useSessionRuntime } from "@/hooks/use-session-runtime"; @@ -184,7 +184,7 @@ export function CmsHeaderActions({ virtualMcpId }: Props) { const prQuery = usePrByBranch({ orgId: org.id, orgSlug: org.slug, - connectionId: githubRepo?.connectionId ?? "", + target: repoToolTarget(githubRepo), owner: githubRepo?.owner ?? "", repo: githubRepo?.name ?? "", branch: githubHeadBranch, @@ -196,7 +196,7 @@ export function CmsHeaderActions({ virtualMcpId }: Props) { const lastPublishedQuery = useLastPublishedPr({ orgId: org.id, orgSlug: org.slug, - connectionId: githubRepo?.connectionId ?? "", + target: repoToolTarget(githubRepo), owner: githubRepo?.owner ?? "", repo: githubRepo?.name ?? "", base: baseBranch, @@ -205,7 +205,7 @@ export function CmsHeaderActions({ virtualMcpId }: Props) { const checksQuery = useChecks({ orgId: org.id, orgSlug: org.slug, - connectionId: githubRepo?.connectionId ?? "", + target: repoToolTarget(githubRepo), owner: githubRepo?.owner ?? "", repo: githubRepo?.name ?? "", branch: githubHeadBranch, @@ -214,7 +214,7 @@ export function CmsHeaderActions({ virtualMcpId }: Props) { const reviewsQuery = usePrReviews({ orgId: org.id, orgSlug: org.slug, - connectionId: githubRepo?.connectionId ?? "", + target: repoToolTarget(githubRepo), owner: githubRepo?.owner ?? "", repo: githubRepo?.name ?? "", branch: githubHeadBranch, @@ -443,7 +443,7 @@ export function CmsHeaderActions({ virtualMcpId }: Props) { virtualMcpId={virtualMcpId} branch={branch} baseBranch={baseBranch} - githubConnectionId={githubRepo.connectionId ?? ""} + repoTarget={repoToolTarget(githubRepo)} owner={githubRepo.owner} repo={githubRepo.name} publishPolicy={normalizePublishPolicy(vm?.metadata?.publishPolicy)} diff --git a/apps/web/src/components/thread/github/cms-publish-popover.tsx b/apps/web/src/components/thread/github/cms-publish-popover.tsx index ea76e61055..a7c9c65bd4 100644 --- a/apps/web/src/components/thread/github/cms-publish-popover.tsx +++ b/apps/web/src/components/thread/github/cms-publish-popover.tsx @@ -4,7 +4,7 @@ * Loads in two beats — see {@link useCmsPublishState} for what each decides. */ -import { useMCPClient } from "@/sdk"; +import type { RepoToolTarget } from "@/lib/github-repo.ts"; import { Spinner } from "@decocms/ui/components/spinner.tsx"; import { Button } from "@decocms/ui/components/button.tsx"; import { Dialog, DialogContent } from "@decocms/ui/components/dialog.tsx"; @@ -88,7 +88,8 @@ export interface CmsPublishPopoverProps { virtualMcpId: string; branch: string; baseBranch: string; - githubConnectionId: string; + /** Which repository this publishes to, and which credential writes it. */ + repoTarget: RepoToolTarget; owner: string; repo: string; publishPolicy: PublishPolicy; @@ -334,11 +335,10 @@ function CmsPublishContent({ onOpenChange, mode = "publish", orgSlug, - orgId, virtualMcpId, branch, baseBranch, - githubConnectionId, + repoTarget, owner, repo, publishPolicy, @@ -355,11 +355,6 @@ function CmsPublishContent({ const t = useT(); /** The session publishing — the git routes resolve their runtime from it. */ const threadId = useOptionalChatTask()?.taskId ?? null; - const githubClient = useMCPClient({ - connectionId: githubConnectionId, - orgId, - orgSlug, - }); const { data: session } = authClient.useSession(); const { @@ -420,15 +415,12 @@ function CmsPublishContent({ threadId, fastPreview: true, baseBranch, - githubClient, + target: repoTarget, owner, repo, headBranch: readGitHeadBranch(gitStatus) ?? branch, coAuthor: coAuthorFromSessionUser(session?.user), expectedHeadSha: headSha ?? undefined, - existingOpenPr: commitToOpenPr - ? { number: openPullRequest.number, htmlUrl: openPullRequest.htmlUrl } - : undefined, }; const { diff --git a/apps/web/src/components/thread/github/dedupe-paged-branches.test.ts b/apps/web/src/components/thread/github/dedupe-paged-branches.test.ts deleted file mode 100644 index eda0aebc08..0000000000 --- a/apps/web/src/components/thread/github/dedupe-paged-branches.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { dedupePagedBranches } from "./use-branches"; - -describe("dedupePagedBranches", () => { - it("flattens pages in order", () => { - expect( - dedupePagedBranches([ - { branches: [{ name: "main" }] }, - { branches: [{ name: "feat/a" }] }, - ]), - ).toEqual([ - { name: "main", author: null }, - { name: "feat/a", author: null }, - ]); - }); - - it("lets the last page win when a branch repeats across pages", () => { - expect( - dedupePagedBranches([ - { branches: [{ name: "main", commit: { author: { login: "old" } } }] }, - { branches: [{ name: "main", commit: { author: { login: "new" } } }] }, - ]), - ).toEqual([{ name: "main", author: "new" }]); - }); - - it("reads an author given as a bare string", () => { - expect( - dedupePagedBranches([ - { branches: [{ name: "main", commit: { author: "octocat" } }] }, - ]), - ).toEqual([{ name: "main", author: "octocat" }]); - }); - - it("nulls a missing or malformed author", () => { - expect( - dedupePagedBranches([ - { branches: [{ name: "a", commit: null }, { name: "b" }] }, - ]), - ).toEqual([ - { name: "a", author: null }, - { name: "b", author: null }, - ]); - }); - - it("drops entries with no name", () => { - expect(dedupePagedBranches([{ branches: [{}, { name: "main" }] }])).toEqual( - [{ name: "main", author: null }], - ); - }); - - it("returns empty for undefined pages", () => { - expect(dedupePagedBranches(undefined)).toEqual([]); - }); -}); diff --git a/apps/web/src/components/thread/github/description-tab.tsx b/apps/web/src/components/thread/github/description-tab.tsx index 00d8ef4745..471ab67c51 100644 --- a/apps/web/src/components/thread/github/description-tab.tsx +++ b/apps/web/src/components/thread/github/description-tab.tsx @@ -1,4 +1,5 @@ import { useProjectContext } from "@/sdk"; +import type { RepoToolTarget } from "@/lib/github-repo.ts"; import { MemoizedMarkdown } from "../../chat/markdown.tsx"; import { CommentsAccordion } from "./comments-accordion.tsx"; import { decodeHtmlEntities } from "./decode-html-entities.ts"; @@ -6,7 +7,7 @@ import { usePrComments, type PrSummary } from "./use-pr-data.ts"; interface Props { pr: PrSummary; - connectionId: string; + target: RepoToolTarget; owner: string; repo: string; } @@ -15,12 +16,12 @@ interface Props { * Description sub-tab: PR title + body (markdown with entity decode) + * collapsible comments accordion. */ -export function DescriptionTab({ pr, connectionId, owner, repo }: Props) { +export function DescriptionTab({ pr, target, owner, repo }: Props) { const { org } = useProjectContext(); const commentsQuery = usePrComments({ orgId: org.id, orgSlug: org.slug, - connectionId, + target, owner, repo, branch: pr.head, diff --git a/apps/web/src/components/thread/github/extract-tool-json.ts b/apps/web/src/components/thread/github/extract-tool-json.ts index 48492ee20f..e1c6f012e0 100644 --- a/apps/web/src/components/thread/github/extract-tool-json.ts +++ b/apps/web/src/components/thread/github/extract-tool-json.ts @@ -69,28 +69,3 @@ export function extractToolJson(r: unknown): T | null { const parsed = payloadFromToolResult(r); return parsed === null ? null : (parsed as T); } - -/** Pull request number from a GitHub PR URL, if present. */ -export function pullNumberFromUrl(url: string | undefined): number | null { - if (!url) return null; - const match = url.match(/\/pull\/(\d+)\b/); - if (!match) return null; - const n = Number(match[1]); - return Number.isFinite(n) && n > 0 ? n : null; -} - -/** Plain-text tool payloads that embed a PR link. */ -export function pullRequestFromToolText(r: unknown): { - number: number; - htmlUrl: string; -} | null { - if (!r || typeof r !== "object") return null; - const text = (r as ToolResultLike).content?.find( - (c) => c.type === "text", - )?.text; - if (!text) return null; - const htmlUrl = text.match(/https:\/\/github\.com\/[^\s"]+\/pull\/\d+/)?.[0]; - const number = pullNumberFromUrl(htmlUrl); - if (!number || !htmlUrl) return null; - return { number, htmlUrl }; -} diff --git a/apps/web/src/components/thread/github/git-tab.tsx b/apps/web/src/components/thread/github/git-tab.tsx index cbbf3a66ae..0ac4348606 100644 --- a/apps/web/src/components/thread/github/git-tab.tsx +++ b/apps/web/src/components/thread/github/git-tab.tsx @@ -13,6 +13,11 @@ */ import { useProjectContext, useVirtualMCP } from "@/sdk"; +import { + hasRepoCredential, + type RepoToolTarget, + repoToolTarget, +} from "@/lib/github-repo.ts"; import { Button } from "@decocms/ui/components/button.tsx"; import { GitBranch01, LinkExternal01 } from "@untitledui/icons"; import { useT } from "@/i18n/use-t.ts"; @@ -53,8 +58,13 @@ export function GitTab({ virtualMcpId }: { virtualMcpId: string }) { const { currentBranch: branch } = useChatTask(); const githubRepo = vm?.metadata?.githubRepo ?? null; + const target = repoToolTarget(githubRepo); - if (!githubRepo?.connectionId) { + /** + * A repository row is enough on its own — a GitLab project never has a + * connection, so gating on one here is what kept this tab dark for it. + */ + if (!githubRepo || !hasRepoCredential(target)) { return (
    {t("thread.gitTab.notLinkedToGithub")} @@ -81,7 +91,7 @@ export function GitTab({ virtualMcpId }: { virtualMcpId: string }) { orgId={org.id} orgSlug={org.slug} virtualMcpId={virtualMcpId} - connectionId={githubRepo.connectionId} + target={target} owner={githubRepo.owner} repo={githubRepo.name} branch={branch} @@ -93,7 +103,7 @@ interface ContentProps { orgId: string; orgSlug: string; virtualMcpId: string; - connectionId: string; + target: RepoToolTarget; owner: string; repo: string; branch: string; @@ -101,8 +111,7 @@ interface ContentProps { function GitTabContent(props: ContentProps) { const t = useT(); - const { orgId, orgSlug, virtualMcpId, connectionId, owner, repo, branch } = - props; + const { orgId, orgSlug, virtualMcpId, target, owner, repo, branch } = props; const { data: pr, @@ -111,7 +120,7 @@ function GitTabContent(props: ContentProps) { } = usePrByBranch({ orgId, orgSlug, - connectionId, + target, owner, repo, branch, @@ -180,7 +189,7 @@ function GitTabContent(props: ContentProps) { pr={pr} virtualMcpId={virtualMcpId} branch={branch} - connectionId={connectionId} + target={target} owner={owner} repo={repo} /> diff --git a/apps/web/src/components/thread/github/github-pr-api.test.ts b/apps/web/src/components/thread/github/github-pr-api.test.ts deleted file mode 100644 index 9703dc4b74..0000000000 --- a/apps/web/src/components/thread/github/github-pr-api.test.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - extractToolJson, - pullNumberFromUrl, - pullRequestFromToolText, -} from "./extract-tool-json.ts"; -import { - openPullRequestForBranch, - parseCreatedPullRequestResult, - PULL_REQUEST_ALREADY_EXISTS_MESSAGE, - squashMergePullRequest, -} from "./github-pr-api.ts"; - -describe("extract-tool-json", () => { - test("prefers content text when structuredContent is an empty object", () => { - const payload = [{ number: 9, html_url: "https://github.com/o/r/pull/9" }]; - const parsed = extractToolJson({ - structuredContent: {}, - content: [{ type: "text", text: JSON.stringify(payload) }], - }); - expect(parsed).toEqual(payload); - }); - - test("pullNumberFromUrl parses GitHub PR links", () => { - expect( - pullNumberFromUrl("https://github.com/deco-cx/deco/pull/12345"), - ).toBe(12345); - }); - - test("pullRequestFromToolText parses plain-text tool payloads", () => { - expect( - pullRequestFromToolText({ - content: [ - { - type: "text", - text: "Pull request opened: https://github.com/o/r/pull/7", - }, - ], - }), - ).toEqual({ - number: 7, - htmlUrl: "https://github.com/o/r/pull/7", - }); - }); -}); - -describe("parseCreatedPullRequestResult", () => { - test("parses github-mcp-server MinimalResponse { id, url }", () => { - const pr = parseCreatedPullRequestResult({ - content: [ - { - type: "text", - text: JSON.stringify({ - id: "2847461234", - url: "https://github.com/deco-cx/deco/pull/42", - }), - }, - ], - }); - - expect(pr).toEqual({ - number: 42, - htmlUrl: "https://github.com/deco-cx/deco/pull/42", - }); - }); - - test("parses legacy { number, html_url } payloads", () => { - const pr = parseCreatedPullRequestResult({ - content: [ - { - type: "text", - text: JSON.stringify({ - number: 3, - html_url: "https://github.com/o/r/pull/3", - }), - }, - ], - }); - - expect(pr).toEqual({ - number: 3, - htmlUrl: "https://github.com/o/r/pull/3", - }); - }); -}); - -describe("squashMergePullRequest", () => { - test("requires merged === true in response", async () => { - const client = { - callTool: async () => ({ - content: [{ type: "text", text: JSON.stringify({ merged: false }) }], - }), - }; - - await expect( - squashMergePullRequest(client, { - owner: "o", - repo: "r", - pullNumber: 1, - }), - ).rejects.toThrow("Failed to merge pull request"); - }); - - test("appends co-author to squash commit message", async () => { - let args: Record | undefined; - const client = { - callTool: async (req: { - name: string; - arguments: Record; - }) => { - args = req.arguments; - return { - content: [{ type: "text", text: JSON.stringify({ merged: true }) }], - }; - }, - }; - - await squashMergePullRequest(client, { - owner: "o", - repo: "r", - pullNumber: 1, - commitMessage: "feat: ship it", - coAuthor: { userName: "Jane Doe", userEmail: "jane@example.com" }, - }); - - expect(args?.commit_message).toBe( - "feat: ship it\n\nCo-authored-by: Jane Doe ", - ); - }); -}); - -describe("openPullRequestForBranch", () => { - test("creates a PR and never calls list_pull_requests", async () => { - const names: string[] = []; - let args: Record | undefined; - const client = { - callTool: async (req: { - name: string; - arguments: Record; - }) => { - names.push(req.name); - args = req.arguments; - return { - content: [ - { - type: "text", - text: JSON.stringify({ - url: "https://github.com/o/r/pull/2", - }), - }, - ], - }; - }, - }; - - const pr = await openPullRequestForBranch(client, { - owner: "o", - repo: "r", - branch: "feat/x", - title: "feat: x", - base: "main", - coAuthor: { userName: "Jane Doe", userEmail: "jane@example.com" }, - }); - - expect(pr).toEqual({ number: 2, htmlUrl: "https://github.com/o/r/pull/2" }); - // No co-author-only body should be created. - expect(args?.body).toBeUndefined(); - expect(names).toEqual(["create_pull_request"]); - expect(names).not.toContain("list_pull_requests"); - }); - - test("reuses an existing PR without listing or creating", async () => { - const names: string[] = []; - const client = { - callTool: async (req: { - name: string; - arguments: Record; - }) => { - names.push(req.name); - return { content: [{ type: "text", text: "{}" }] }; - }, - }; - - const pr = await openPullRequestForBranch(client, { - owner: "o", - repo: "r", - branch: "feat/x", - title: "feat: x", - body: "already open", - base: "main", - existing: { number: 9, htmlUrl: "https://github.com/o/r/pull/9" }, - coAuthor: { userName: "Jane Doe", userEmail: "jane@example.com" }, - }); - - expect(pr).toEqual({ number: 9, htmlUrl: "https://github.com/o/r/pull/9" }); - // Only a best-effort co-author body update — never list/create. - expect(names).not.toContain("list_pull_requests"); - expect(names).not.toContain("create_pull_request"); - }); - - test("surfaces a retry message when create reports a duplicate PR", async () => { - const names: string[] = []; - const client = { - callTool: async (req: { - name: string; - arguments: Record; - }) => { - names.push(req.name); - return { - isError: true, - content: [ - { - type: "text", - text: "A pull request already exists for o:feat/x.", - }, - ], - }; - }, - }; - - await expect( - openPullRequestForBranch(client, { - owner: "o", - repo: "r", - branch: "feat/x", - title: "feat: x", - base: "main", - }), - ).rejects.toThrow(PULL_REQUEST_ALREADY_EXISTS_MESSAGE); - // We must NOT fall back to list_pull_requests to recover the PR. - expect(names).not.toContain("list_pull_requests"); - }); -}); diff --git a/apps/web/src/components/thread/github/github-pr-api.ts b/apps/web/src/components/thread/github/github-pr-api.ts index 422850ef84..b8eecbda21 100644 --- a/apps/web/src/components/thread/github/github-pr-api.ts +++ b/apps/web/src/components/thread/github/github-pr-api.ts @@ -1,9 +1,19 @@ -import { - extractToolJson, - pullNumberFromUrl, - pullRequestFromToolText, - toolErrorMessage, -} from "./extract-tool-json.ts"; +/** + * Opening and landing a change request from the browser. + * + * It used to hold a GitHub MCP client and call `create_pull_request`, + * `update_pull_request` and `merge_pull_request` by name — which is why a + * GitLab project's publish had nothing to call. Both operations are now Studio + * tools over the provider interface, so the browser names an intention and the + * repository decides the provider. + * + * The co-author trailer stays here: it is deco's convention about what a + * publish credits, not something a provider knows. + */ + +import { callStudioTool } from "@/lib/studio-tools"; +import type { RepoToolTarget } from "@/lib/github-repo"; +import type { TFunction } from "@/i18n/use-t.ts"; import { appendCoAuthorToPullRequestBody, appendCoAuthorTrailer, @@ -11,273 +21,131 @@ import { type CoAuthorIdentity, } from "@decocms/sandbox/shared"; -export type GithubMcpClient = { - callTool: (req: { - name: string; - arguments: Record; - }) => Promise; -}; - export interface CreatedPullRequest { number: number; htmlUrl: string; } -export interface MergedPullRequest { - merged: boolean; - message: string; -} - -export interface OpenPullRequestArgs { - owner: string; - repo: string; +export interface OpenChangeRequestArgs { branch: string; title: string; body?: string; base: string; coAuthor?: CoAuthorIdentity; - /** - * The branch's already-known open PR, supplied by the caller from its polled - * PR state. When present we reuse it directly instead of calling - * `list_pull_requests` — keeping that rate-limit-heavy call out of the - * publish/submit path. - */ - existing?: CreatedPullRequest; } /** - * Thrown when `create_pull_request` reports a PR already exists for the branch - * but the caller had no `existing` PR to reuse (its polled state was stale). - * Retrying once the PR panel refreshes resolves it — see - * {@link openPullRequestForBranch}. + * Propose `branch` onto `base`, or return the change request it already has. + * + * There is no duplicate to recover from any more: the server reuses the + * branch's open one and reports `existed`, which is what the caller wanted + * either way. That also retires the "refresh and try again" error this used to + * raise when the caller's polled state was stale — the answer no longer + * depends on what the browser happened to know. */ -export const PULL_REQUEST_ALREADY_EXISTS_MESSAGE = - "A pull request already exists for this branch. Refresh and try again."; - -function assertGithubToolSuccess(result: unknown): void { - const message = toolErrorMessage(result); - if (message) throw new Error(message); -} - -function pickString( - record: Record, - keys: string[], -): string | undefined { - for (const key of keys) { - const value = record[key]; - if (typeof value === "string" && value.length > 0) return value; - } - return undefined; -} - -function pickNumber( - record: Record, - keys: string[], -): number | undefined { - for (const key of keys) { - const value = record[key]; - if (typeof value === "number" && Number.isFinite(value) && value > 0) { - return value; - } - if (typeof value === "string" && /^\d+$/.test(value)) { - const n = Number(value); - if (n > 0) return n; - } - } - return undefined; -} - -export function extractPullRequestList( - result: unknown, -): Record[] { - const raw = extractToolJson(result); - if (Array.isArray(raw)) { - return raw.filter( - (item): item is Record => - item != null && typeof item === "object", - ); - } - if (raw && typeof raw === "object") { - const record = raw as Record; - for (const key of ["pull_requests", "items", "data"]) { - const value = record[key]; - if (Array.isArray(value)) { - return value.filter( - (item): item is Record => - item != null && typeof item === "object", - ); - } - } - } - return []; -} - -/** github-mcp-server create_pull_request returns MinimalResponse `{ id, url }`. */ -export function parseCreatedPullRequestResult( - result: unknown, -): CreatedPullRequest { - assertGithubToolSuccess(result); - - const fromText = pullRequestFromToolText(result); - if (fromText) return fromText; - - const data = extractToolJson>(result); - if (data && typeof data === "object") { - const htmlUrl = pickString(data, ["url", "URL", "html_url", "htmlUrl"]); - const number = - pickNumber(data, ["number", "pullNumber"]) ?? pullNumberFromUrl(htmlUrl); - if (number && htmlUrl) return { number, htmlUrl }; - } - - throw new Error("Failed to open pull request"); -} - -function pullRequestAlreadyExists(message: string): boolean { - return /already exists|pull request already/i.test(message); -} - -async function ensureExistingPullRequestCoAuthor( - client: GithubMcpClient, - args: { - owner: string; - repo: string; - pullNumber: number; - body?: string; - coAuthor?: CoAuthorIdentity; - }, -): Promise { - const body = appendCoAuthorToPullRequestBody( - args.body, - normalizeCoAuthorIdentity(args.coAuthor), - ); - if (!body) return; - - try { - await client.callTool({ - name: "update_pull_request", - arguments: { - owner: args.owner, - repo: args.repo, - pullNumber: args.pullNumber, - body, - }, - }); - } catch { - // Best-effort when the GitHub MCP tool is unavailable or rejects the update. - } -} - -async function createPullRequest( - client: GithubMcpClient, - args: { - owner: string; - repo: string; - title: string; - body?: string; - head: string; - base: string; - coAuthor?: CoAuthorIdentity; - }, +export async function openChangeRequestForBranch( + orgSlug: string, + target: RepoToolTarget, + args: OpenChangeRequestArgs, ): Promise { - const body = appendCoAuthorToPullRequestBody( - args.body, - normalizeCoAuthorIdentity(args.coAuthor), - ); - const result = await client.callTool({ - name: "create_pull_request", - arguments: { - owner: args.owner, - repo: args.repo, - title: args.title, - body: body || undefined, - head: args.head, + const coAuthor = normalizeCoAuthorIdentity(args.coAuthor); + const { changeRequest } = await callStudioTool( + orgSlug, + "CHANGE_REQUEST_OPEN", + { + ...target, + head: args.branch, base: args.base, + title: args.title, + body: appendCoAuthorToPullRequestBody(args.body, coAuthor) || undefined, }, - }); + ); + return { number: changeRequest.number, htmlUrl: changeRequest.url }; +} - return parseCreatedPullRequestResult(result); +export interface SquashMergeArgs { + number: number; + commitTitle?: string; + commitMessage?: string; + coAuthor?: CoAuthorIdentity; } +export type MergeRefusalReason = + | "conflict" + | "blocked" + | "rate_limited" + | "not_found" + | "error"; + /** - * Reuses the branch's open PR when the caller already knows it (`args.existing`, - * from its polled PR state), otherwise creates one. This path deliberately does - * NOT call `list_pull_requests`: on GitHub's hosted MCP that list is a top - * rate-limit contributor, and the publish/submit flows run inside it. If no - * `existing` PR is supplied and `create_pull_request` reports a duplicate, we - * surface {@link PULL_REQUEST_ALREADY_EXISTS_MESSAGE} rather than listing to - * recover it — the PR panel repopulates `existing` on its next poll, so a retry - * succeeds without us adding another list call here. + * A merge the provider refused. Carries the classified `reason` rather than a + * finished sentence, because the sentence has to be translated where it is + * shown; `detail` is the provider's own words, which stay untranslated the way + * every other server-originated message does. */ -export async function openPullRequestForBranch( - client: GithubMcpClient, - args: OpenPullRequestArgs, -): Promise { - if (args.existing) { - await ensureExistingPullRequestCoAuthor(client, { - owner: args.owner, - repo: args.repo, - pullNumber: args.existing.number, - body: args.body, - coAuthor: args.coAuthor, - }); - return args.existing; - } - - try { - return await createPullRequest(client, { - owner: args.owner, - repo: args.repo, - title: args.title, - body: args.body, - head: args.branch, - base: args.base, - coAuthor: args.coAuthor, - }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - if (pullRequestAlreadyExists(message)) { - throw new Error(PULL_REQUEST_ALREADY_EXISTS_MESSAGE); - } - throw err; +export class ChangeRequestMergeRefused extends Error { + readonly reason: MergeRefusalReason; + readonly detail: string | undefined; + constructor(reason: MergeRefusalReason, detail: string | undefined) { + super(detail || `Merge refused: ${reason}`); + this.name = "ChangeRequestMergeRefused"; + this.reason = reason; + this.detail = detail; } } -export async function squashMergePullRequest( - client: GithubMcpClient, - args: { - owner: string; - repo: string; - pullNumber: number; - commitTitle?: string; - commitMessage?: string; - coAuthor?: CoAuthorIdentity; - }, -): Promise { - const normalized = normalizeCoAuthorIdentity(args.coAuthor); - const commitMessage = normalized - ? appendCoAuthorTrailer(args.commitMessage?.trim() ?? "", normalized) +/** + * Land it as ONE commit — in Fast Preview a publish IS one commit on the base + * branch, so the strategy is asked for by name rather than left to whatever + * the repository allows. + * + * Throws {@link ChangeRequestMergeRefused} on a refusal: every caller here is + * a person who just pressed Publish, and why it did not land is what they need + * to read. + */ +export async function squashMergeChangeRequest( + orgSlug: string, + target: RepoToolTarget, + args: SquashMergeArgs, +): Promise { + const coAuthor = normalizeCoAuthorIdentity(args.coAuthor); + const commitMessage = coAuthor + ? appendCoAuthorTrailer(args.commitMessage?.trim() ?? "", coAuthor) : args.commitMessage?.trim() || undefined; - const result = await client.callTool({ - name: "merge_pull_request", - arguments: { - owner: args.owner, - repo: args.repo, - pullNumber: args.pullNumber, - merge_method: "squash", - ...(args.commitTitle ? { commit_title: args.commitTitle } : {}), - ...(commitMessage ? { commit_message: commitMessage } : {}), - }, + const outcome = await callStudioTool(orgSlug, "CHANGE_REQUEST_MERGE", { + ...target, + number: args.number, + strategy: "squash", + commitTitle: args.commitTitle, + commitMessage, }); - - assertGithubToolSuccess(result); - - const data = extractToolJson<{ merged?: boolean; message?: string }>(result); - if (!data || data.merged !== true) { - throw new Error(data?.message ?? "Failed to merge pull request"); + if (!outcome.merged) { + throw new ChangeRequestMergeRefused( + outcome.reason ?? "error", + outcome.detail, + ); } +} - return { - merged: true, - message: data.message ?? "Pull request merged", - }; +/** + * What a refusal reads as when the provider gave no detail of its own. The + * `t` function comes from the calling surface — the reason travels as data + * precisely so the sentence can be translated where it is shown. + */ +export function mergeRefusalText( + reason: MergeRefusalReason, + t: TFunction, +): string { + switch (reason) { + case "conflict": + return t("thread.mergeRefused.conflict"); + case "blocked": + return t("thread.mergeRefused.blocked"); + case "rate_limited": + return t("thread.mergeRefused.rateLimited"); + case "not_found": + return t("thread.mergeRefused.notFound"); + default: + return t("thread.mergeRefused.error"); + } } diff --git a/apps/web/src/components/thread/github/header-actions.tsx b/apps/web/src/components/thread/github/header-actions.tsx index a6ea5718f4..086f4c2c5f 100644 --- a/apps/web/src/components/thread/github/header-actions.tsx +++ b/apps/web/src/components/thread/github/header-actions.tsx @@ -1,4 +1,4 @@ -import { useMCPClient, useProjectContext, useVirtualMCP } from "@/sdk"; +import { useProjectContext, useVirtualMCP } from "@/sdk"; import { useQuery } from "@tanstack/react-query"; import { useDecofileWriting } from "@/components/sections-editor/use-decofile-writing"; import { Button } from "@decocms/ui/components/button.tsx"; @@ -16,7 +16,7 @@ import { useState, useRef } from "react"; import { toast } from "sonner"; import { authClient } from "@/lib/auth-client.ts"; import { coAuthorFromSessionUser } from "@/lib/co-author-identity.ts"; -import { resolveGithubAttachment } from "@/lib/github-repo.ts"; +import { repoToolTarget, resolveGithubAttachment } from "@/lib/github-repo.ts"; import { branchUserLabel, generateBranchName, @@ -24,7 +24,11 @@ import { import { useChatStream } from "../../chat/chat-context.tsx"; import { useChatTask } from "../../chat/index"; import { usePanelActions } from "@/layouts/shell-layout"; -import { squashMergePullRequest } from "./github-pr-api.ts"; +import { + ChangeRequestMergeRefused, + mergeRefusalText, + squashMergeChangeRequest, +} from "./github-pr-api.ts"; import { useReleases } from "./use-releases"; import { draftsModeEnabled } from "./use-version-gate"; import { PublishDialog, type PublishDialogIntent } from "./publish-dialog.tsx"; @@ -154,12 +158,6 @@ export function HeaderActions({ virtualMcpId }: Props) { ? attachment.repo : null; - const githubClient = useMCPClient({ - connectionId: githubRepo?.connectionId ?? "", - orgId: org.id, - orgSlug: org.slug, - }); - const { lifecycle: sseLifecycle, branch: sseBranchMeta, @@ -207,7 +205,7 @@ export function HeaderActions({ virtualMcpId }: Props) { const prQuery = usePrByBranch({ orgId: org.id, orgSlug: org.slug, - connectionId: githubRepo?.connectionId ?? "", + target: repoToolTarget(githubRepo), owner: githubRepo?.owner ?? "", repo: githubRepo?.name ?? "", branch: githubHeadBranch, @@ -217,7 +215,7 @@ export function HeaderActions({ virtualMcpId }: Props) { const checksQuery = useChecks({ orgId: org.id, orgSlug: org.slug, - connectionId: githubRepo?.connectionId ?? "", + target: repoToolTarget(githubRepo), owner: githubRepo?.owner ?? "", repo: githubRepo?.name ?? "", branch: githubHeadBranch, @@ -226,7 +224,7 @@ export function HeaderActions({ virtualMcpId }: Props) { const reviewsQuery = usePrReviews({ orgId: org.id, orgSlug: org.slug, - connectionId: githubRepo?.connectionId ?? "", + target: repoToolTarget(githubRepo), owner: githubRepo?.owner ?? "", repo: githubRepo?.name ?? "", branch: githubHeadBranch, @@ -345,14 +343,12 @@ export function HeaderActions({ virtualMcpId }: Props) { }; const handleSquashMerge = async (pullNumber: number) => { - if (!githubRepo?.connectionId || githubActionPending) return; + if (!githubRepo || githubActionPending) return; setGithubActionPending(true); try { const coAuthor = coAuthorFromSessionUser(session?.user); - await squashMergePullRequest(githubClient, { - owner: githubRepo.owner, - repo: githubRepo.name, - pullNumber, + await squashMergeChangeRequest(org.slug, repoToolTarget(githubRepo), { + number: pullNumber, coAuthor, }); toast.success( @@ -362,9 +358,11 @@ export function HeaderActions({ virtualMcpId }: Props) { await switchToFreshBranch(); } catch (err) { toast.error( - err instanceof Error - ? err.message - : t("thread.headerActions.failedToMergePullRequest"), + err instanceof ChangeRequestMergeRefused + ? (err.detail ?? mergeRefusalText(err.reason, t)) + : err instanceof Error + ? err.message + : t("thread.headerActions.failedToMergePullRequest"), ); } finally { setGithubActionPending(false); @@ -457,7 +455,7 @@ export function HeaderActions({ virtualMcpId }: Props) { virtualMcpId={virtualMcpId} branch={sandboxRouteBranch} baseBranch={baseBranch} - githubConnectionId={githubRepo.connectionId ?? ""} + repoTarget={repoToolTarget(githubRepo)} owner={githubRepo.owner} repo={githubRepo.name} previewUrl={previewUrl} diff --git a/apps/web/src/components/thread/github/merge-state.test.ts b/apps/web/src/components/thread/github/merge-state.test.ts new file mode 100644 index 0000000000..3a6abdd631 --- /dev/null +++ b/apps/web/src/components/thread/github/merge-state.test.ts @@ -0,0 +1,46 @@ +/** + * The panel's four-value mergeability, from the three neutral facts a + * provider reports. Pure — the read itself is e2e. + */ +import { describe, expect, it } from "bun:test"; +import { toMergeableState } from "./use-pr-reviews.ts"; + +const cr = (over: Partial[0]> = {}) => ({ + conflicting: false, + reviewBlocked: false, + unresolvedConversations: 0, + ...over, +}); + +describe("toMergeableState", () => { + it("is clean when nothing is outstanding", () => { + expect(toMergeableState(cr())).toBe("clean"); + }); + + /** A conflict is about the branch, and it outranks anything a person owes. */ + it("is dirty for a conflict, whatever else is outstanding", () => { + expect(toMergeableState(cr({ conflicting: true }))).toBe("dirty"); + expect( + toMergeableState(cr({ conflicting: true, reviewBlocked: true })), + ).toBe("dirty"); + }); + + /** + * Unknown must not read as clean: both providers compute mergeability + * asynchronously, so "not worked out yet" is routine and the panel has to + * say so rather than promise a merge. + */ + it("is unknown while the provider has not worked mergeability out", () => { + expect(toMergeableState(cr({ conflicting: null }))).toBe("unknown"); + expect( + toMergeableState(cr({ conflicting: null, reviewBlocked: true })), + ).toBe("unknown"); + }); + + it("is blocked when a person still owes something", () => { + expect(toMergeableState(cr({ reviewBlocked: true }))).toBe("blocked"); + expect(toMergeableState(cr({ unresolvedConversations: 2 }))).toBe( + "blocked", + ); + }); +}); diff --git a/apps/web/src/components/thread/github/pr-sub-tabs.tsx b/apps/web/src/components/thread/github/pr-sub-tabs.tsx index 650142e2f7..e9b37394f1 100644 --- a/apps/web/src/components/thread/github/pr-sub-tabs.tsx +++ b/apps/web/src/components/thread/github/pr-sub-tabs.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import type { RepoToolTarget } from "@/lib/github-repo.ts"; import { useProjectContext } from "@/sdk"; import { Tabs, @@ -20,7 +21,7 @@ interface Props { pr: PrSummary; virtualMcpId: string; branch: string; - connectionId: string; + target: RepoToolTarget; owner: string; repo: string; } @@ -31,7 +32,7 @@ export function PrSubTabs({ pr, virtualMcpId, branch, - connectionId, + target, owner, repo, }: Props) { @@ -48,7 +49,14 @@ export function PrSubTabs({ base: pr.base, headSha: pr.headSha, pullNumber: pr.number, - connectionId, + /** + * The Changes tab reads the sandbox's own diff first; this is the fallback + * for when the sandbox has none, and it is still GitHub-only (it walks + * `get_file_contents` per file over the MCP connection). An empty + * connection disables it, so a GitLab project shows the sandbox diff and + * nothing else rather than erroring. + */ + connectionId: target.connectionId ?? "", owner, repo, enabled: activeValue === "changes", @@ -110,23 +118,13 @@ export function PrSubTabs({
    - + - +
    diff --git a/apps/web/src/components/thread/github/publish-dialog.tsx b/apps/web/src/components/thread/github/publish-dialog.tsx index b61e9f797a..090878f02a 100644 --- a/apps/web/src/components/thread/github/publish-dialog.tsx +++ b/apps/web/src/components/thread/github/publish-dialog.tsx @@ -1,4 +1,5 @@ import { SELF_MCP_ALIAS_ID, useMCPClient } from "@/sdk"; +import type { RepoToolTarget } from "@/lib/github-repo.ts"; import { Spinner } from "@decocms/ui/components/spinner.tsx"; import { Button } from "@decocms/ui/components/button.tsx"; import { Dialog, DialogContent } from "@decocms/ui/components/dialog.tsx"; @@ -65,7 +66,8 @@ export interface PublishDialogProps { virtualMcpId: string; branch: string; baseBranch: string; - githubConnectionId: string; + /** Which repository this publishes to, and which credential writes it. */ + repoTarget: RepoToolTarget; owner: string; repo: string; previewUrl?: string | null; @@ -114,7 +116,7 @@ function PublishDialogBody({ virtualMcpId, branch, baseBranch, - githubConnectionId, + repoTarget, owner, repo, previewUrl, @@ -126,11 +128,6 @@ function PublishDialogBody({ onPublished, }: PublishDialogProps) { const t = useT(); - const githubClient = useMCPClient({ - connectionId: githubConnectionId, - orgId, - orgSlug, - }); const selfClient = useMCPClient({ connectionId: SELF_MCP_ALIAS_ID, orgId, @@ -149,13 +146,6 @@ function PublishDialogBody({ const coAuthor = coAuthorFromSessionUser(session?.user); const commitToOpenPr = openPullRequest?.state === "open"; - // The branch's already-known open PR (from the header's polled PR state). - // Passed to openPullRequestForBranch so it reuses this PR instead of calling - // list_pull_requests to rediscover it. - const existingOpenPr = - openPullRequest?.state === "open" - ? { number: openPullRequest.number, htmlUrl: openPullRequest.htmlUrl } - : undefined; const openPrFromCommits = dialogIntent === "open-pr" && !commitToOpenPr; /** Side "Publish" button — direct publish to base, single green button. */ const isPublishOnly = dialogIntent === "publish-only"; @@ -338,12 +328,11 @@ function PublishDialogBody({ branch, threadId: sandboxRef.threadId, baseBranch, - githubClient, + target: repoTarget, owner, repo, headBranch: githubHeadBranch, coAuthor, - existingOpenPr, expectedHeadSha: headSha ?? undefined, }; diff --git a/apps/web/src/components/thread/github/publish-flow.ts b/apps/web/src/components/thread/github/publish-flow.ts index e44de2dc47..74ee727e24 100644 --- a/apps/web/src/components/thread/github/publish-flow.ts +++ b/apps/web/src/components/thread/github/publish-flow.ts @@ -8,13 +8,13 @@ import type { CoAuthorIdentity } from "@decocms/sandbox/shared"; import type { SandboxProxyRef } from "@/sdk/sandbox-url"; +import type { RepoToolTarget } from "@/lib/github-repo"; import { toast } from "sonner"; import type { TFunction } from "@/i18n/use-t.ts"; import { - openPullRequestForBranch, - squashMergePullRequest, + openChangeRequestForBranch, + squashMergeChangeRequest, type CreatedPullRequest, - type GithubMcpClient, } from "./github-pr-api.ts"; import { fetchGitStatus, @@ -75,14 +75,13 @@ export interface PublishTarget { /** Route sandbox-less Fast Preview git operations to the upstream API. */ fastPreview?: boolean; baseBranch: string; - githubClient: GithubMcpClient; + /** Which repository, and which credential reads it. */ + target: RepoToolTarget; owner: string; repo: string; /** Branch name on GitHub; the sandbox's HEAD can differ from `branch`. */ headBranch: string; coAuthor?: CoAuthorIdentity; - /** The branch's already-open pull request, updated instead of opening a new one. */ - existingOpenPr?: CreatedPullRequest; /** * The head the confirmed change list was read from. Checked against the live * head before anything mutates; see {@link PublishHeadMovedError}. Omit to @@ -186,15 +185,12 @@ function openPullRequest( target: PublishTarget, parts: PublishMessage, ): Promise { - return openPullRequestForBranch(target.githubClient, { - owner: target.owner, - repo: target.repo, + return openChangeRequestForBranch(target.orgSlug, target.target, { branch: target.headBranch, title: parts.title, body: parts.body, base: target.baseBranch, coAuthor: target.coAuthor, - existing: target.existingOpenPr, }); } @@ -221,10 +217,8 @@ export async function runPublishFlow( () => openPullRequest(target, parts), ); try { - await squashMergePullRequest(target.githubClient, { - owner: target.owner, - repo: target.repo, - pullNumber: pr.number, + await squashMergeChangeRequest(target.orgSlug, target.target, { + number: pr.number, commitTitle: parts.title, commitMessage: parts.body, coAuthor: target.coAuthor, diff --git a/apps/web/src/components/thread/github/use-branches.ts b/apps/web/src/components/thread/github/use-branches.ts index b3fb14c135..4bedcf92b7 100644 --- a/apps/web/src/components/thread/github/use-branches.ts +++ b/apps/web/src/components/thread/github/use-branches.ts @@ -1,7 +1,25 @@ -import { KEYS, type SandboxMap, useMCPClient } from "@/sdk"; +/** + * Branches for the picker: the sandbox map for yours/recent, the repository's + * provider for the rest. + * + * Browsing and searching are ONE server call with different arguments — + * `REPOSITORY_SEARCH_BRANCHES` with an empty query browses from the start, and + * with a term filters at the provider. They used to be two paths: a paged MCP + * `list_branches` against the GitHub connection, plus a GitHub-only search + * tool. Collapsing them is what lets a GitLab project's picker work at all, + * and it deletes the local dedupe the paged path needed. + */ + +import { type SandboxMap } from "@/sdk"; +import { + hasRepoCredential, + type RepoToolTarget, + repoTargetKey, +} from "@/lib/github-repo"; +import { KEYS } from "@/lib/query-keys"; import { callStudioTool } from "@/lib/studio-tools"; import { useDebouncedValue } from "@/hooks/use-debounced-value.ts"; -import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { useInfiniteQuery } from "@tanstack/react-query"; import { groupBranches } from "./group-branches"; export interface Branch { @@ -27,8 +45,8 @@ export interface UseBranchesResult { /** True while a server-side search for the current term is in flight. */ isSearching: boolean; /** - * Matches the server found but did not return (search is capped at - * `SEARCH_LIMIT`). 0 when browsing or when every match is shown. + * Matches the server found but did not return (a search reads one window). + * 0 when browsing or when every match is shown. */ hiddenMatchCount: number; hasMore: boolean; @@ -40,197 +58,98 @@ interface UseBranchesArgs { orgId: string; orgSlug: string; userId: string; - connectionId: string | null | undefined; + target: RepoToolTarget; sandboxMap: SandboxMap | undefined; owner: string; repo: string; /** - * Current search term. Non-empty switches the repo list from paged browsing - * to a server-side search; sandbox-derived groups always filter locally. + * Current search term. Non-empty switches the repository list from paged + * browsing to a provider-side search; sandbox-derived groups always filter + * locally. */ search?: string; /** - * When false the github fetch is skipped (e.g. dialog closed). + * When false the provider fetch is skipped (e.g. dialog closed). * Your-branches still resolve from the in-memory sandboxMap. */ enabled?: boolean; } -type RawBranch = { - name?: string; - commit?: { author?: { login?: string } | string | null } | null; -}; - -type RawBranchesResponse = - | RawBranch[] - | { - branches?: RawBranch[]; - default_branch?: string; - }; - -interface BranchesPage { - branches: RawBranch[]; - default_branch: string | null; - page: number; -} - -/** Flattens paged `list_branches` results, last page winning on duplicates. */ -export function dedupePagedBranches( - pages: { branches: RawBranch[] }[] | undefined, -): { name: string; author: string | null }[] { - const byName = new Map(); - for (const page of pages ?? []) { - for (const branch of page.branches) { - if (typeof branch.name === "string") byName.set(branch.name, branch); - } - } - return [...byName.values()].map((b) => ({ - name: b.name as string, - author: - typeof b.commit?.author === "string" - ? b.commit.author - : (b.commit?.author?.login ?? null), - })); -} - +/** One window big enough to fill the picker; the server reports the true total. */ const BRANCHES_PER_PAGE = 100; -/** Enough to fill the picker without paging; the server reports the true total. */ const SEARCH_LIMIT = 50; const SEARCH_DEBOUNCE_MS = 300; -/** Case-insensitive substring — the same predicate GitHub's `refs(query:)` applies. */ +/** Case-insensitive substring — the same predicate the providers apply. */ export function matchesBranchSearch(name: string, search: string): boolean { const needle = search.trim().toLowerCase(); if (!needle) return true; return name.toLowerCase().includes(needle); } -/** - * github-mcp-server may return either: - * - `structuredContent` with parsed JSON, OR - * - `content: [{ type: "text", text: "" }]` (most common) - * Accept both. - */ -function extractBranches(r: unknown): RawBranchesResponse { - const result = r as { - structuredContent?: RawBranchesResponse; - content?: Array<{ type?: string; text?: string }>; - }; - if (result.structuredContent) return result.structuredContent; - const textPart = result.content?.find((c) => c.type === "text")?.text; - if (textPart) { - try { - return JSON.parse(textPart) as RawBranchesResponse; - } catch { - return []; - } - } - return []; -} - -/** Branches for the picker: sandboxMap for yours/recent, github for the rest. */ export function useBranches({ orgId, orgSlug, userId, - connectionId, + target, sandboxMap, owner, repo, search = "", enabled = true, }: UseBranchesArgs): UseBranchesResult { - const client = useMCPClient({ - connectionId: connectionId ?? null, - orgId, - orgSlug, - }); - const trimmedSearch = search.trim(); const debouncedSearch = useDebouncedValue(trimmedSearch, SEARCH_DEBOUNCE_MS); - const repoReady = !!connectionId && !!owner && !!repo; + /** + * A repository row is enough on its own — a GitLab project never has a + * connection, so requiring one here is what would keep its picker empty. + */ + const repoReady = hasRepoCredential(target); const isSearchMode = trimmedSearch.length > 0; + /** + * One query for both modes, keyed by the debounced term: browsing is the + * empty-term case, so switching to a search does not throw away the browse + * pages and switching back does not refetch them. + */ const { data, isLoading, isError, + isFetching, hasNextPage, isFetchingNextPage, fetchNextPage, - } = useInfiniteQuery({ - queryKey: KEYS.githubBranches(orgId, orgSlug, connectionId, owner, repo), - enabled: enabled && repoReady && !isSearchMode, - staleTime: 30_000, - retry: false, - initialPageParam: 1, - queryFn: async ({ pageParam, signal }) => { - const page = Number(pageParam); - const result = await client.callTool( - { - name: "list_branches", - arguments: { owner, repo, page, perPage: BRANCHES_PER_PAGE }, - }, - undefined, - { signal }, - ); - const parsed = extractBranches(result); - const branches = Array.isArray(parsed) ? parsed : (parsed.branches ?? []); - - return { - branches, - default_branch: Array.isArray(parsed) - ? null - : (parsed.default_branch ?? null), - page, - }; - }, - getNextPageParam: (lastPage) => { - if (lastPage.branches.length < BRANCHES_PER_PAGE) { - return undefined; - } - - return lastPage.page + 1; - }, - }); - - const { - data: searchData, - isLoading: isSearchLoading, - isError: isSearchError, - isFetching: isSearchFetching, - } = useQuery({ + } = useInfiniteQuery({ queryKey: KEYS.githubBranchSearch( orgId, orgSlug, - connectionId, + repoTargetKey(target), owner, repo, debouncedSearch, ), - enabled: enabled && repoReady && debouncedSearch.length > 0, + enabled: enabled && repoReady, staleTime: 30_000, retry: false, - queryFn: () => - callStudioTool(orgSlug, "GITHUB_SEARCH_BRANCHES", { - connectionId: connectionId as string, - owner, - repo, + initialPageParam: null as string | null, + queryFn: ({ pageParam }) => + callStudioTool(orgSlug, "REPOSITORY_SEARCH_BRANCHES", { + ...target, query: debouncedSearch, - limit: SEARCH_LIMIT, + limit: debouncedSearch ? SEARCH_LIMIT : BRANCHES_PER_PAGE, + cursor: pageParam, }), + // A search reads one window and reports the rest as a count; only browsing pages. + getNextPageParam: (lastPage) => + debouncedSearch ? undefined : (lastPage.nextCursor ?? undefined), }); // Results still describe the pre-debounce term, so count the gap as pending. const isSearching = - isSearchMode && (debouncedSearch !== trimmedSearch || isSearchFetching); + isSearchMode && (debouncedSearch !== trimmedSearch || isFetching); - // Until the first search settles, the already-paged branches are the only - // matches we can show; cmdk narrows them with the same predicate the server - // applies, so the list filters instantly instead of blanking for a debounce. - const rawBranches = searchData - ? searchData.branches - : dedupePagedBranches(data?.pages); + const rawBranches = (data?.pages ?? []).flatMap((page) => page.branches); const grouped = groupBranches({ sandboxMap, @@ -248,26 +167,24 @@ export function useBranches({ ); const others = grouped.others; + const firstPage = data?.pages[0]; const hiddenMatchCount = - searchData && !isSearching - ? Math.max(0, searchData.totalCount - searchData.branches.length) + isSearchMode && firstPage && !isSearching + ? Math.max(0, firstPage.totalCount - firstPage.branches.length) : 0; return { recent, yours, others, - isLoading: isSearchMode ? isSearchLoading : isLoading, - isError: isSearchMode ? isSearchError : isError, + isLoading, + isError, isSearching, hiddenMatchCount, - // Paging browses the full repo list; a search is answered in one shot. - hasMore: !isSearchMode && (hasNextPage ?? false), + hasMore: !isSearchMode && hasNextPage, isFetchingMore: isFetchingNextPage, fetchMore: () => { - if (!isSearchMode && hasNextPage && !isFetchingNextPage) { - void fetchNextPage(); - } + void fetchNextPage(); }, }; } diff --git a/apps/web/src/components/thread/github/use-pr-data.ts b/apps/web/src/components/thread/github/use-pr-data.ts index 66b887308c..7b83ed950d 100644 --- a/apps/web/src/components/thread/github/use-pr-data.ts +++ b/apps/web/src/components/thread/github/use-pr-data.ts @@ -1,23 +1,25 @@ /** - * PR panel data hooks. The branch's PR, its check runs, its review state and its - * comments all come from ONE polled read — the `GITHUB_PR_STATE` tool. The hooks - * below are selectors over that one cache entry (they share - * `KEYS.githubPrState`), so mounting all four costs one request and every - * surface reads the same instant of the PR. + * Change-request panel data hooks. The branch's change request, its CI runs, + * its review state and its comments all come from ONE polled read — the + * `CHANGE_REQUEST_STATE` tool. The hooks below are selectors over that one + * cache entry (they share `KEYS.githubPrState`), so mounting all four costs + * one request and every surface reads the same instant of it. * - * Still on github-mcp-server: `list_pull_requests` for the branch picker's - * "PRs" tab (a repo-wide list, not this PR), and `GET_CHECK_RUN` for one run's - * `output` markdown when a Checks row is expanded. + * Nothing here knows which provider answered. The panel used to hold a GitHub + * MCP client and call `list_pull_requests` and `GET_CHECK_RUN` by name, which + * is why a GitLab project's panel had nothing to call at all; both are now + * neutral Studio tools. */ import { useQuery } from "@tanstack/react-query"; -import { useMCPClient, useMCPToolCallQuery } from "@/sdk"; import { callStudioTool } from "@/lib/studio-tools"; +import { + hasRepoCredential, + type RepoToolTarget, + repoTargetKey, +} from "@/lib/github-repo"; import { KEYS } from "@/lib/query-keys"; - -import { extractPullRequestList } from "./github-pr-api.ts"; -import { assertToolOk, extractToolJson } from "./extract-tool-json.ts"; import type { CheckRunOutput } from "./check-run-output.ts"; export interface PrSummary { @@ -29,19 +31,20 @@ export interface PrSummary { mergedAt: string | null; base: string; head: string; - /** SHA of the PR head commit — used to key the diff. */ + /** SHA of the head commit — used to key the diff. */ headSha: string; /** - * `owner/name` of the repo the head branch lives in. Differs from the base - * repo for cross-fork PRs (or null when the fork was deleted) — such PRs - * can't be opened as a local branch. Same as the base repo for internal PRs. + * `owner/name` of the repository the head branch lives in. Differs from the + * base repository for a cross-fork change request (or null when the fork was + * deleted) — such a change request can't be opened as a local branch. Same + * as the base repository for internal ones. */ headRepoFullName: string | null; htmlUrl: string; author: string; /** - * Files the PR touches. null when the source could not say — the repo-wide - * PR list does not carry it — so a caller must not read null as "no changes". + * Files it touches. null when the source could not say, so a caller must not + * read null as "no changes". */ changedFiles: number | null; } @@ -49,10 +52,17 @@ export interface PrSummary { const POLL = 60_000; const STALE = 30_000; -interface RepoArgs { +/** + * Which repository the hooks act on. + * + * `owner`/`repo` stay because they key the query cache and the diff reader; a + * GitLab project in subgroups carries every namespace level in `owner`. + * `target` is what actually identifies the repository to the server. + */ +export interface RepoArgs { orgId: string; orgSlug: string; - connectionId: string; + target: RepoToolTarget; owner: string; repo: string; } @@ -75,7 +85,8 @@ export interface PrFile { } export interface CheckRun { - id: string; + /** Null for a run with no addressable log (a GitHub commit status). */ + id: string | null; name: string; status: "queued" | "in_progress" | "completed"; conclusion: @@ -92,77 +103,84 @@ export interface CheckRun { } export interface PrComment { - id: number; + id: string; author: string; body: string; createdAt: string; htmlUrl: string; } -type PrState = Awaited< - ReturnType> ->["pullRequest"]; +type ChangeRequestState = Awaited< + ReturnType> +>["changeRequest"]; + +type ChangeRequestSummary = Awaited< + ReturnType> +>["changeRequest"]; -/** Maps a raw github-mcp list PR object into the app's PrSummary. */ -function mapRawPr(p: Record): PrSummary { - const base = p.base as Record | undefined; - const head = p.head as Record | undefined; - const headRepo = head?.repo as Record | undefined; - const user = p.user as Record | undefined; +/** + * The panel's view of a change request. `merged` is split back out of the + * three-value state because the panel's own state machine is written against + * the pair, and a merged one is drawn as closed. + */ +function toPrSummary(cr: NonNullable): PrSummary { return { - number: (p.number as number) ?? 0, - title: (p.title as string) ?? "", - body: (p.body as string) ?? "", - state: p.state === "closed" ? ("closed" as const) : ("open" as const), - merged: (p.merged_at as string | null) != null, - mergedAt: (p.merged_at as string | null) ?? null, - base: (base?.ref as string) ?? "main", - head: (head?.ref as string) ?? "", - headSha: (head?.sha as string) ?? "", - headRepoFullName: (headRepo?.full_name as string) ?? null, - htmlUrl: (p.html_url as string) ?? "", - author: (user?.login as string) ?? "", - changedFiles: null, + number: cr.number, + title: cr.title, + body: cr.body, + state: cr.state === "open" ? "open" : "closed", + merged: cr.state === "merged", + mergedAt: cr.mergedAt, + base: cr.base, + head: cr.head, + headSha: cr.headSha, + headRepoFullName: cr.headRepoPath, + htmlUrl: cr.url, + author: cr.author, + changedFiles: cr.changedFiles, }; } -/** The PrSummary view of the unified read. */ -function toPrSummary(pr: NonNullable): PrSummary { +/** The panel's check row. `state` keeps the panel's existing three names. */ +function toCheckRun( + run: NonNullable["checkRuns"][number], +): CheckRun { return { - number: pr.number, - title: pr.title, - body: pr.body, - state: pr.state, - merged: pr.merged, - mergedAt: pr.mergedAt, - base: pr.base, - head: pr.head, - headSha: pr.headSha, - headRepoFullName: pr.headRepoFullName, - htmlUrl: pr.htmlUrl, - author: pr.author, - changedFiles: pr.changedFiles, + id: run.id, + name: run.name, + status: run.state === "running" ? "in_progress" : run.state, + conclusion: run.conclusion, + htmlUrl: run.url ?? "", + durationMs: run.durationMs, }; } /** - * The one PR read every panel hook selects from. Spread this rather than + * The one read every panel hook selects from. Spread this rather than * restating the key — two hooks that disagree about it would double the poll. */ export function prStateQueryOptions( args: RepoArgs & { branch: string | null }, ) { - const { orgSlug, connectionId, owner, repo, branch } = args; + const { orgSlug, target, owner, repo, branch } = args; return { - queryKey: KEYS.githubPrState(orgSlug, connectionId, owner, repo, branch), + queryKey: KEYS.githubPrState( + orgSlug, + repoTargetKey(target), + owner, + repo, + branch, + ), queryFn: () => - callStudioTool(orgSlug, "GITHUB_PR_STATE", { - connectionId, - owner, - repo, + callStudioTool(orgSlug, "CHANGE_REQUEST_STATE", { + ...target, branch: branch as string, }), - enabled: !!branch && !!connectionId && !!owner && !!repo, + /** + * A repository row is enough on its own — a GitLab project never has a + * connection, so requiring one here is what would keep its panel empty. + */ + enabled: !!branch && hasRepoCredential(target) && !!owner && !!repo, refetchInterval: POLL, refetchIntervalInBackground: false, staleTime: STALE, @@ -170,155 +188,145 @@ export function prStateQueryOptions( } /** - * The branch's most recent pull request (open or closed). - * Returns null when no PR exists yet for that branch. + * The branch's most recent change request (open or not). + * Returns null when it has none yet. */ export function usePrByBranch(args: RepoArgs & { branch: string | null }) { return useQuery({ ...prStateQueryOptions(args), select: (r): PrSummary | null => - r.pullRequest ? toPrSummary(r.pullRequest) : null, + r.changeRequest ? toPrSummary(r.changeRequest) : null, }); } /** - * Check runs for the PR's head commit. Empty unless the PR is open — a closed - * PR's checks describe work nobody can act on. + * CI runs for the head commit. Empty unless it is open — a closed change + * request's checks describe work nobody can act on. */ export function useChecks(args: RepoArgs & { branch: string | null }) { return useQuery({ ...prStateQueryOptions(args), select: (r): CheckRun[] => - r.pullRequest?.state === "open" ? r.pullRequest.checks : [], + r.changeRequest?.state === "open" + ? r.changeRequest.checkRuns.map(toCheckRun) + : [], }); } /** - * Issue-level comments on the PR. Does NOT include review comments tied to a - * file + line — those belong near the diff on the Changes tab. + * Comments on the change request itself. Does NOT include review comments tied + * to a file + line — those belong near the diff on the Changes tab. */ export function usePrComments(args: RepoArgs & { branch: string | null }) { return useQuery({ ...prStateQueryOptions(args), - select: (r): PrComment[] => r.pullRequest?.comments ?? [], + select: (r): PrComment[] => + (r.changeRequest?.comments ?? []).map((c) => ({ + id: c.id, + author: c.author, + body: c.body, + createdAt: c.createdAt, + htmlUrl: c.url, + })), }); } +/** The last publish changes only when someone publishes — cheap to keep. */ +const LAST_PUBLISHED_STALE = 5 * 60_000; + /** - * The last publish — in Fast Preview every publish is a squash-merged PR. - * Mounted by the header so the line is warm before the publish surface opens; - * never polled, because it changes only when someone publishes. + * The last publish — in Fast Preview every publish is a squash-merged change + * request. Mounted by the header so the line is warm before the publish + * surface opens; never polled, because it changes only when someone publishes. */ export function useLastPublishedPr( args: RepoArgs & { base: string | null; enabled?: boolean }, ) { - const { orgSlug, connectionId, owner, repo, base } = args; + const { orgSlug, target, owner, repo, base } = args; return useQuery({ queryKey: KEYS.githubLastPublishedPr( orgSlug, - connectionId, + repoTargetKey(target), owner, repo, base, ), queryFn: () => - callStudioTool(orgSlug, "GITHUB_LAST_PUBLISHED_PR", { - connectionId, - owner, - repo, + callStudioTool(orgSlug, "CHANGE_REQUEST_LAST_MERGED", { + ...target, base: base as string, }), enabled: - (args.enabled ?? true) && !!base && !!connectionId && !!owner && !!repo, + (args.enabled ?? true) && + !!base && + hasRepoCredential(target) && + !!owner && + !!repo, staleTime: LAST_PUBLISHED_STALE, select: (r): PrSummary | null => - r.pullRequest - ? { - ...r.pullRequest, - state: "closed" as const, - merged: true, - headRepoFullName: null, - changedFiles: null, - } - : null, + r.changeRequest ? toPrSummary(r.changeRequest) : null, }); } -/** The last publish changes only when someone publishes — cheap to keep. */ -const LAST_PUBLISHED_STALE = 5 * 60_000; - -/** Max open PRs fetched for the picker; the tail beyond this is not shown. */ +/** Max open change requests fetched for the picker; the tail is not shown. */ const OPEN_PRS_PER_PAGE = 50; /** - * Lists open pull requests for the repo (most recent first, as GitHub returns - * them), capped at {@link OPEN_PRS_PER_PAGE}. Powers the branch picker's "PRs" - * tab — selecting a PR is equivalent to selecting its head branch (a PR is - * just a branch). No polling: the picker is an ephemeral popover, so it relies - * on refetch-on-open + `staleTime` rather than a background interval. + * Lists open change requests for the repository (most recently updated + * first), capped at {@link OPEN_PRS_PER_PAGE}. Powers the branch picker's + * "PRs" tab — selecting one is equivalent to selecting its head branch. No + * polling: the picker is an ephemeral popover, so it relies on + * refetch-on-open + `staleTime` rather than a background interval. */ export function useOpenPrs(args: RepoArgs & { enabled?: boolean }) { - const client = useMCPClient({ - connectionId: args.connectionId, - orgId: args.orgId, - orgSlug: args.orgSlug, - }); - - return useMCPToolCallQuery({ - client, - toolName: "list_pull_requests", - toolArguments: { - owner: args.owner, - repo: args.repo, - state: "open", - perPage: OPEN_PRS_PER_PAGE, - }, + const { orgSlug, target, owner, repo } = args; + return useQuery({ + queryKey: KEYS.githubOpenPrs(orgSlug, repoTargetKey(target), owner, repo), + queryFn: () => + callStudioTool(orgSlug, "CHANGE_REQUEST_LIST_OPEN", { + ...target, + limit: OPEN_PRS_PER_PAGE, + }), enabled: - (args.enabled ?? true) && - !!args.connectionId && - !!args.owner && - !!args.repo, + (args.enabled ?? true) && hasRepoCredential(target) && !!owner && !!repo, staleTime: STALE, - select: (r) => { - assertToolOk(r); - return extractPullRequestList(r).map(mapRawPr); - }, + select: (r): PrSummary[] => r.changeRequests.map(toPrSummary), }); } /** - * Fetches a single check run's full `output` (title/summary/text markdown) via - * the github-mcp first-party GET_CHECK_RUN tool. The unified PR read returns a - * minimal check shape without `output`, so the Checks tab lazily loads this - * when a row is expanded. + * One CI run's full report — GitHub's check-run `output` markdown, or the tail + * of a GitLab job's trace. The unified read returns a minimal run shape + * without it, so the Checks tab loads this lazily when a row is expanded. */ export function useCheckRunDetail( - args: RepoArgs & { checkRunId: number | null; enabled: boolean }, + args: RepoArgs & { checkRunId: string | null; enabled: boolean }, ) { - const client = useMCPClient({ - connectionId: args.connectionId, - orgId: args.orgId, - orgSlug: args.orgSlug, - }); - - return useMCPToolCallQuery({ - client, - toolName: "GET_CHECK_RUN", - toolArguments: { - owner: args.owner, - repo: args.repo, - checkRunId: args.checkRunId ?? 0, - }, - enabled: args.enabled && !!args.checkRunId, + const { orgSlug, target, owner, repo, checkRunId } = args; + return useQuery({ + queryKey: KEYS.githubCheckRun( + orgSlug, + repoTargetKey(target), + owner, + repo, + checkRunId, + ), + queryFn: () => + callStudioTool(orgSlug, "CHANGE_REQUEST_CHECK_LOG", { + ...target, + checkId: checkRunId as string, + }), + enabled: args.enabled && !!checkRunId, staleTime: STALE, - select: (r) => { - assertToolOk(r); - const d = extractToolJson<{ output?: Partial }>(r); - return { - title: d?.output?.title ?? null, - summary: d?.output?.summary ?? null, - text: d?.output?.text ?? null, - }; - }, + /** + * The neutral read answers one body of text, where GitHub's check-run + * output had a title, a summary and a text. `summary` is where the panel + * already renders markdown, so the report goes there. + */ + select: (r): CheckRunOutput => ({ + title: null, + summary: r.report, + text: null, + }), }); } diff --git a/apps/web/src/components/thread/github/use-pr-reviews.ts b/apps/web/src/components/thread/github/use-pr-reviews.ts index 5d78265086..bf31bf3c92 100644 --- a/apps/web/src/components/thread/github/use-pr-reviews.ts +++ b/apps/web/src/components/thread/github/use-pr-reviews.ts @@ -1,15 +1,13 @@ /** - * usePrReviews — draft/mergeable/unresolved-conversation/missing-approvals - * signals for the branch's PR. A selector over the same `GITHUB_PR_STATE` entry - * `usePrByBranch` and `useChecks` read, so it costs no extra request. - * - * `missingRequiredApprovals` is `reviewDecision` and `unresolvedConversations` - * counts unresolved review threads — both were inferences over REST before. + * usePrReviews — draft / mergeability / unresolved-conversation / + * missing-approvals signals for the branch's change request. A selector over + * the same `CHANGE_REQUEST_STATE` entry `usePrByBranch` and `useChecks` read, + * so it costs no extra request. */ import { useQuery } from "@tanstack/react-query"; -import { prStateQueryOptions } from "./use-pr-data.ts"; +import { prStateQueryOptions, type RepoArgs } from "./use-pr-data.ts"; export type MergeableState = "clean" | "dirty" | "blocked" | "unknown"; @@ -20,26 +18,39 @@ export interface PrReviewSignals { missingRequiredApprovals: boolean; } -interface Args { - orgId: string; - orgSlug: string; - connectionId: string; - owner: string; - repo: string; - branch: string | null; +type Args = RepoArgs & { branch: string | null }; + +/** + * The panel's four-value vocabulary, from the three neutral facts. + * + * It stays the panel's own word rather than the interface's: "blocked" means + * blocked on a PERSON, which is a statement about this UI's state machine, not + * about what a provider reported. Conflicts and unknowns come first because + * neither is something a reviewer can clear. + */ +export function toMergeableState(cr: { + conflicting: boolean | null; + reviewBlocked: boolean; + unresolvedConversations: number; +}): MergeableState { + if (cr.conflicting === true) return "dirty"; + if (cr.conflicting === null) return "unknown"; + return cr.reviewBlocked || cr.unresolvedConversations > 0 + ? "blocked" + : "clean"; } export function usePrReviews(args: Args) { return useQuery({ ...prStateQueryOptions(args), select: (r): PrReviewSignals | null => { - const pr = r.pullRequest; - if (!pr) return null; + const cr = r.changeRequest; + if (!cr) return null; return { - draft: pr.draft, - mergeableState: pr.mergeableState, - unresolvedConversations: pr.unresolvedConversations, - missingRequiredApprovals: pr.missingRequiredApprovals, + draft: cr.draft, + mergeableState: toMergeableState(cr), + unresolvedConversations: cr.unresolvedConversations, + missingRequiredApprovals: cr.reviewBlocked, }; }, }); diff --git a/apps/web/src/components/thread/github/use-version-gate.ts b/apps/web/src/components/thread/github/use-version-gate.ts index da27d9d9ba..43bf310fa0 100644 --- a/apps/web/src/components/thread/github/use-version-gate.ts +++ b/apps/web/src/components/thread/github/use-version-gate.ts @@ -1,5 +1,5 @@ import { useProjectContext } from "@/sdk"; -import { getActiveGithubRepo } from "@/lib/github-repo"; +import { getActiveGithubRepo, repoToolTarget } from "@/lib/github-repo"; import type { VirtualMCPEntity } from "@decocms/shared/sdk/types"; import { usePrByBranch } from "./use-pr-data.ts"; @@ -17,7 +17,7 @@ export function useBaseBranch( usePrByBranch({ orgId: org.id, orgSlug: org.slug, - connectionId: repo?.connectionId ?? "", + target: repoToolTarget(repo), owner: repo?.owner ?? "", repo: repo?.name ?? "", branch: currentBranch ?? null, diff --git a/apps/web/src/i18n/en/common.ts b/apps/web/src/i18n/en/common.ts index d51d5faade..b3399a9fac 100644 --- a/apps/web/src/i18n/en/common.ts +++ b/apps/web/src/i18n/en/common.ts @@ -128,7 +128,7 @@ export const common = { "Failed to reconnect GitHub: {error}", "common.githubRepoPicker.forkBadge": "Fork", "common.githubRepoPicker.githubConnected": "GitHub connected", - "common.githubRepoPicker.importFromGitHub": "Import from GitHub", + "common.githubRepoPicker.importFromGitHub": "Import repository", "common.githubRepoPicker.importedRepo": "Imported {name} from GitHub", "common.githubRepoPicker.installGitHubApp": "Install the GitHub App", "common.githubRepoPicker.installingGitHubConnection": @@ -284,7 +284,7 @@ export const common = { "common.toolSetSelector.selectConnection": "Select a connection to view its tools", "common.createAgentDropdown.createFromScratch": "Create from scratch", - "common.createAgentDropdown.importFromGitHub": "Import from GitHub", + "common.createAgentDropdown.importFromGitHub": "Import repository", "common.createAgentDropdown.importFromDeco": "Import from deco.cx", "common.mainPanelTabs.preview": "Preview", "common.mainPanelTabs.code": "Code", diff --git a/apps/web/src/i18n/en/dev-agent.ts b/apps/web/src/i18n/en/dev-agent.ts index 90776e9573..8009ae96a9 100644 --- a/apps/web/src/i18n/en/dev-agent.ts +++ b/apps/web/src/i18n/en/dev-agent.ts @@ -1,5 +1,5 @@ export const devAgent = { - "devAgent.devAgentSetup.importButton": "Import from GitHub", + "devAgent.devAgentSetup.importButton": "Import repository", "devAgent.devAgentSetup.importDialogTitle": "Import a dev project from GitHub", "devAgent.devAgentSetup.linkDescription": diff --git a/apps/web/src/i18n/en/home.ts b/apps/web/src/i18n/en/home.ts index bae8a0bc21..dadb513e5f 100644 --- a/apps/web/src/i18n/en/home.ts +++ b/apps/web/src/i18n/en/home.ts @@ -1,7 +1,6 @@ export const home = { - "home.orgAgents.importFromGitHub": "Import from GitHub", - "home.orgAgents.importToGetStarted": - "Import a repository from GitHub to get started.", + "home.orgAgents.importFromGitHub": "Import repository", + "home.orgAgents.importToGetStarted": "Import a repository to get started.", "home.orgHome.connectPill": "Connect your agent to Studio", "home.orgHome.greetingMorning": "Good morning, {name}", "home.orgHome.greetingAfternoon": "Good afternoon, {name}", diff --git a/apps/web/src/i18n/en/task-board.ts b/apps/web/src/i18n/en/task-board.ts index 9a0b4f6f69..ef3fc46314 100644 --- a/apps/web/src/i18n/en/task-board.ts +++ b/apps/web/src/i18n/en/task-board.ts @@ -114,7 +114,9 @@ export const taskBoard = { "taskBoard.taskDialog.activityMergeFailedRateLimited": "couldn't merge yet — GitHub is rate-limiting us. This retries automatically.", "taskBoard.taskDialog.activityMergeFailedRefused": - "GitHub refused the merge: {detail}", + "the provider refused the merge: {detail}", + "taskBoard.taskDialog.activityMergeFailedConflict": + "it conflicts with the base branch and could not be merged", "taskBoard.taskDialog.activityMergeFailedError": "couldn't merge the pull request: {detail}", "taskBoard.taskDialog.tagsButton": "Tags", diff --git a/apps/web/src/i18n/en/thread.ts b/apps/web/src/i18n/en/thread.ts index 3aae16b80d..b21a2c5fe6 100644 --- a/apps/web/src/i18n/en/thread.ts +++ b/apps/web/src/i18n/en/thread.ts @@ -182,6 +182,14 @@ export const thread = { "thread.publishDialog.loadingChanges": "Loading changes…", "thread.publishDialog.mergeFailed": "Changes were pushed and PR #{prNumber} is open, but merge failed: {message}", + "thread.mergeRefused.conflict": + "It conflicts with the base branch — rebase it and try again.", + "thread.mergeRefused.blocked": + "The repository refused the merge — a required review or a branch rule is outstanding.", + "thread.mergeRefused.rateLimited": + "The provider is rate-limiting us — try again in a moment.", + "thread.mergeRefused.notFound": "It no longer exists.", + "thread.mergeRefused.error": "Failed to merge.", "thread.publishDialog.openingComparison": "Opening the comparison…", "thread.publishDialog.opensPullRequestInto": "Opens a pull request into {baseBranch} for review.", diff --git a/apps/web/src/i18n/pt-br/common.ts b/apps/web/src/i18n/pt-br/common.ts index 7c5e2861e8..1314496903 100644 --- a/apps/web/src/i18n/pt-br/common.ts +++ b/apps/web/src/i18n/pt-br/common.ts @@ -134,7 +134,7 @@ export const common = { "Falha ao reconectar GitHub: {error}", "common.githubRepoPicker.forkBadge": "Fork", "common.githubRepoPicker.githubConnected": "GitHub conectado", - "common.githubRepoPicker.importFromGitHub": "Importar do GitHub", + "common.githubRepoPicker.importFromGitHub": "Importar reposit\u00f3rio", "common.githubRepoPicker.importedRepo": "Importado {name} do GitHub", "common.githubRepoPicker.installGitHubApp": "Instalar o aplicativo GitHub", "common.githubRepoPicker.installingGitHubConnection": @@ -296,7 +296,7 @@ export const common = { "common.toolSetSelector.selectConnection": "Selecione uma conexão para visualizar suas ferramentas", "common.createAgentDropdown.createFromScratch": "Criar do zero", - "common.createAgentDropdown.importFromGitHub": "Importar do GitHub", + "common.createAgentDropdown.importFromGitHub": "Importar reposit\u00f3rio", "common.createAgentDropdown.importFromDeco": "Importar do deco.cx", "common.mainPanelTabs.preview": "Visualização", "common.mainPanelTabs.code": "Código", diff --git a/apps/web/src/i18n/pt-br/dev-agent.ts b/apps/web/src/i18n/pt-br/dev-agent.ts index 9f5a88240b..092409d321 100644 --- a/apps/web/src/i18n/pt-br/dev-agent.ts +++ b/apps/web/src/i18n/pt-br/dev-agent.ts @@ -1,7 +1,7 @@ import type { devAgent as devAgentEn } from "../en/dev-agent.ts"; export const devAgent = { - "devAgent.devAgentSetup.importButton": "Importar do GitHub", + "devAgent.devAgentSetup.importButton": "Importar reposit\u00f3rio", "devAgent.devAgentSetup.importDialogTitle": "Importar um projeto de desenvolvimento do GitHub", "devAgent.devAgentSetup.linkDescription": diff --git a/apps/web/src/i18n/pt-br/home.ts b/apps/web/src/i18n/pt-br/home.ts index 33818fab56..24649e9126 100644 --- a/apps/web/src/i18n/pt-br/home.ts +++ b/apps/web/src/i18n/pt-br/home.ts @@ -1,9 +1,9 @@ import type { home as homeEn } from "../en/home.ts"; export const home = { - "home.orgAgents.importFromGitHub": "Importar do GitHub", + "home.orgAgents.importFromGitHub": "Importar reposit\u00f3rio", "home.orgAgents.importToGetStarted": - "Importe um reposit\u00f3rio do GitHub para come\u00e7ar.", + "Importe um reposit\u00f3rio para come\u00e7ar.", "home.orgHome.connectPill": "Conecte seu agente ao Studio", "home.orgHome.greetingMorning": "Bom dia, {name}", "home.orgHome.greetingAfternoon": "Boa tarde, {name}", diff --git a/apps/web/src/i18n/pt-br/task-board.ts b/apps/web/src/i18n/pt-br/task-board.ts index e08c5c3463..2828ddc958 100644 --- a/apps/web/src/i18n/pt-br/task-board.ts +++ b/apps/web/src/i18n/pt-br/task-board.ts @@ -119,7 +119,9 @@ export const taskBoard = { "taskBoard.taskDialog.activityMergeFailedRateLimited": "ainda não deu para fazer o merge — o GitHub está limitando nossas requisições. Isso é tentado de novo automaticamente.", "taskBoard.taskDialog.activityMergeFailedRefused": - "o GitHub recusou o merge: {detail}", + "o provider recusou o merge: {detail}", + "taskBoard.taskDialog.activityMergeFailedConflict": + "conflita com a branch base e não pôde ser mesclado", "taskBoard.taskDialog.activityMergeFailedError": "não conseguiu mesclar o pull request: {detail}", "taskBoard.taskDialog.tagsButton": "Tags", diff --git a/apps/web/src/i18n/pt-br/thread.ts b/apps/web/src/i18n/pt-br/thread.ts index 1857e2b16f..697b857e20 100644 --- a/apps/web/src/i18n/pt-br/thread.ts +++ b/apps/web/src/i18n/pt-br/thread.ts @@ -192,6 +192,14 @@ export const thread = { "thread.publishDialog.loadingChanges": "Carregando alterações…", "thread.publishDialog.mergeFailed": "Alterações foram enviadas e PR #{prNumber} está aberto, mas a mesclagem falhou: {message}", + "thread.mergeRefused.conflict": + "Conflita com a branch base — faça rebase e tente de novo.", + "thread.mergeRefused.blocked": + "O repositório recusou o merge — falta uma revisão obrigatória ou uma regra de branch.", + "thread.mergeRefused.rateLimited": + "O provider está limitando as requisições — tente de novo em instantes.", + "thread.mergeRefused.notFound": "Não existe mais.", + "thread.mergeRefused.error": "Falha ao fazer merge.", "thread.publishDialog.openingComparison": "Abrindo a comparação…", "thread.publishDialog.opensPullRequestInto": "Abre um pull request para {baseBranch} para revisão.", diff --git a/apps/web/src/layouts/main-panel-tabs/use-main-panel-tabs.ts b/apps/web/src/layouts/main-panel-tabs/use-main-panel-tabs.ts index cda55b7948..9b2aa234db 100644 --- a/apps/web/src/layouts/main-panel-tabs/use-main-panel-tabs.ts +++ b/apps/web/src/layouts/main-panel-tabs/use-main-panel-tabs.ts @@ -30,7 +30,7 @@ import { agentHasConnectedGithub, } from "@/lib/agent-capabilities"; import { useChatTask } from "@/components/chat/index"; -import { getActiveGithubRepo } from "@/lib/github-repo.ts"; +import { getActiveGithubRepo, repoToolTarget } from "@/lib/github-repo.ts"; import { usePrByBranch } from "@/components/thread/github/use-pr-data.ts"; import { useSandboxEvents } from "@/components/sandbox/hooks/use-sandbox-events"; import { useSandboxLifecycle } from "@/components/sandbox/hooks/sandbox-lifecycle-context"; @@ -119,7 +119,7 @@ export function useMainPanelTabs(ctx: { const prQuery = usePrByBranch({ orgId: org.id, orgSlug: org.slug, - connectionId: githubRepo?.connectionId ?? "", + target: repoToolTarget(githubRepo), owner: githubRepo?.owner ?? "", repo: githubRepo?.name ?? "", branch: githubRepo ? currentBranch : null, diff --git a/apps/web/src/layouts/task-board/task-dialog.tsx b/apps/web/src/layouts/task-board/task-dialog.tsx index d0b207ad76..43b3648ce5 100644 --- a/apps/web/src/layouts/task-board/task-dialog.tsx +++ b/apps/web/src/layouts/task-board/task-dialog.tsx @@ -2628,8 +2628,11 @@ function describeActivity( case "merge_conflict_resolution": return t("taskBoard.taskDialog.activityMergeConflictResolution"); case "merge_failed": { - // `detail` names the repo (no_connection) or carries GitHub's refusal - // text — the difference between "it's broken" and "connect this repo". + /** + * `detail` names the repo (no_connection) or carries the provider's + * refusal text — the difference between "it's broken" and "connect this + * repo". + */ const detail = typeof d.detail === "string" ? d.detail : ""; switch (d.reason) { case "no_pr": @@ -2644,6 +2647,9 @@ function describeActivity( : t("taskBoard.taskDialog.activityMergeFailed"); case "rate_limited": return t("taskBoard.taskDialog.activityMergeFailedRateLimited"); + // The one refusal with an automatic answer: the agent can rebase. + case "conflict": + return t("taskBoard.taskDialog.activityMergeFailedConflict"); case "refused": return detail ? t("taskBoard.taskDialog.activityMergeFailedRefused", { detail }) diff --git a/apps/web/src/lib/github-repo.ts b/apps/web/src/lib/github-repo.ts index f3fa221cfd..fff2d1bc6f 100644 --- a/apps/web/src/lib/github-repo.ts +++ b/apps/web/src/lib/github-repo.ts @@ -83,3 +83,40 @@ export function projectRepo( if (attachment.status === "none") return null; return `${attachment.repo.owner}/${attachment.repo.name}`; } + +/** + * Which repository a change-request or branch tool should act on, and which + * credential reads it. + * + * One value rather than three loose props threaded through the panel: a + * repository id is what a project records now, its URL is what names the + * provider, and a connection is the pre-repository world. Passing only the + * connection — which every one of these call sites used to do — is exactly + * what made the whole panel GitHub-only. + */ +export interface RepoToolTarget { + repositoryId?: string; + repoUrl?: string; + connectionId?: string; +} + +export function repoToolTarget( + githubRepo: GithubRepo | null | undefined, +): RepoToolTarget { + if (!githubRepo) return {}; + return { + repositoryId: githubRepo.repositoryId, + repoUrl: githubRepo.url, + connectionId: githubRepo.connectionId, + }; +} + +/** True when Studio has any credential path to this repository. */ +export function hasRepoCredential(target: RepoToolTarget): boolean { + return !!(target.repositoryId || target.connectionId); +} + +/** The cache identity of the credential — a repository row, else the connection. */ +export function repoTargetKey(target: RepoToolTarget): string | null { + return target.repositoryId ?? target.connectionId ?? null; +} diff --git a/apps/web/src/lib/query-keys.ts b/apps/web/src/lib/query-keys.ts index 35597b533c..7bb0d3dcfb 100644 --- a/apps/web/src/lib/query-keys.ts +++ b/apps/web/src/lib/query-keys.ts @@ -228,9 +228,9 @@ export const KEYS = { ) => ["github-branches", orgId, orgSlug, connectionId, owner, repo] as const, /** - * The branch's pull request with its checks, review state and comments — ONE - * key for all four, so those hooks share a single request (see - * GITHUB_PR_STATE). + * The branch's change request with its CI runs, review state and comments — + * ONE key for all four, so those hooks share a single request (see + * CHANGE_REQUEST_STATE). */ githubPrState: ( orgSlug: string, @@ -256,6 +256,31 @@ export const KEYS = { base, ] as const, + /** A repository's open change requests, for the branch picker's list. */ + githubOpenPrs: ( + orgSlug: string, + connectionId: string | null | undefined, + owner: string, + repo: string, + ) => ["github-open-prs", orgSlug, connectionId, owner, repo] as const, + + /** One CI run's report, loaded when a Checks row is expanded. */ + githubCheckRun: ( + orgSlug: string, + connectionId: string | null | undefined, + owner: string, + repo: string, + checkRunId: string | null, + ) => + [ + "github-check-run", + orgSlug, + connectionId, + owner, + repo, + checkRunId, + ] as const, + githubBranchSearch: ( orgId: string, orgSlug: string, diff --git a/packages/e2e/screenshots/auto-domain-join-multi-org.png b/packages/e2e/screenshots/auto-domain-join-multi-org.png new file mode 100644 index 0000000000..8ed1493ca2 Binary files /dev/null and b/packages/e2e/screenshots/auto-domain-join-multi-org.png differ diff --git a/packages/e2e/screenshots/no-access.png b/packages/e2e/screenshots/no-access.png new file mode 100644 index 0000000000..800a962e2d Binary files /dev/null and b/packages/e2e/screenshots/no-access.png differ diff --git a/packages/e2e/screenshots/not-found.png b/packages/e2e/screenshots/not-found.png new file mode 100644 index 0000000000..df6dd7a921 Binary files /dev/null and b/packages/e2e/screenshots/not-found.png differ diff --git a/packages/e2e/screenshots/pending-invite.png b/packages/e2e/screenshots/pending-invite.png new file mode 100644 index 0000000000..e9ae3b0998 Binary files /dev/null and b/packages/e2e/screenshots/pending-invite.png differ diff --git a/packages/e2e/tests/org-home-agents.spec.ts b/packages/e2e/tests/org-home-agents.spec.ts index cb48b66d54..792d1a5432 100644 --- a/packages/e2e/tests/org-home-agents.spec.ts +++ b/packages/e2e/tests/org-home-agents.spec.ts @@ -74,7 +74,7 @@ test.describe("org home — the agent roster", () => { } }); - test("a fresh org lands on the empty state, with GitHub import offered", async ({ + test("a fresh org lands on the empty state, with repository import offered", async ({ authedPage: { page, orgSlug }, }) => { await page.goto(`/${orgSlug}/home`); @@ -87,8 +87,10 @@ test.describe("org home — the agent roster", () => { await expect(page.getByText("No projects yet")).toBeVisible({ timeout: SHELL_TIMEOUT_MS, }); + /* Named for the repository, not for GitHub: the same control imports a + GitLab project, and the picker behind it lists both. */ await expect( - page.getByRole("button", { name: "Import from GitHub" }), + page.getByRole("button", { name: "Import repository" }), ).toBeVisible(); }); }); diff --git a/packages/e2e/tests/task-board-pr-link.spec.ts b/packages/e2e/tests/task-board-pr-link.spec.ts index af8a51ad74..72f4830f2b 100644 --- a/packages/e2e/tests/task-board-pr-link.spec.ts +++ b/packages/e2e/tests/task-board-pr-link.spec.ts @@ -68,7 +68,13 @@ test.describe("task board PR linking via create/update", () => { expect(prs[0]).toMatchObject({ url: prUrl, number: 77 }); }); - test("rejects a URL that is not a GitHub pull request", async ({ + /** + * The board is provider-neutral: a GitLab merge request links exactly the + * way a GitHub pull request does. The nested namespace is the point — it is + * the shape the old `owner`/`repo` pair could not represent, and every + * namespace level has to survive into `repoOwner`. + */ + test("links a GitLab merge request nested in subgroups", async ({ authedPage, }) => { const { page, orgSlug } = authedPage; @@ -76,11 +82,66 @@ test.describe("task board PR linking via create/update", () => { const call = (name: string, args: unknown) => callSelfMcpTool(request, orgSlug, name, args); - await expect( - call("TASK_BOARD_ITEM_CREATE", { - title: "Bad link", - prUrl: "https://github.com/acme-e2e/widget/issues/9", - }), - ).rejects.toThrow(/not a github pull request url/i); + const prUrl = + "https://gitlab.com/acme-e2e/team/storefront/-/merge_requests/12"; + const { item } = await call<{ item: TaskBoardItem }>( + "TASK_BOARD_ITEM_CREATE", + { title: "Ship the storefront", status: "in_review", prUrl }, + ); + + const { prs } = await call<{ prs: TaskBoardItemPr[] }>( + "TASK_BOARD_ITEM_PRS_GET", + { taskBoardItemId: item.id }, + ); + expect(prs).toHaveLength(1); + expect(prs[0]).toMatchObject({ + url: prUrl, + number: 12, + repoOwner: "acme-e2e/team", + repoName: "storefront", + }); + }); + + /** A browser link to the merge request's own sub-page still names it. */ + test("accepts a merge request URL carrying a sub-path", async ({ + authedPage, + }) => { + const { page, orgSlug } = authedPage; + const request = page.context().request; + const call = (name: string, args: unknown) => + callSelfMcpTool(request, orgSlug, name, args); + + const { item } = await call<{ item: TaskBoardItem }>( + "TASK_BOARD_ITEM_CREATE", + { + title: "Diffs link", + prUrl: "https://gitlab.com/acme-e2e/shop/-/merge_requests/5/diffs", + }, + ); + const { prs } = await call<{ prs: TaskBoardItemPr[] }>( + "TASK_BOARD_ITEM_PRS_GET", + { taskBoardItemId: item.id }, + ); + expect(prs[0]).toMatchObject({ + url: "https://gitlab.com/acme-e2e/shop/-/merge_requests/5", + number: 5, + }); + }); + + test("rejects a URL that names no change request", async ({ authedPage }) => { + const { page, orgSlug } = authedPage; + const request = page.context().request; + const call = (name: string, args: unknown) => + callSelfMcpTool(request, orgSlug, name, args); + + for (const prUrl of [ + "https://github.com/acme-e2e/widget/issues/9", + "https://gitlab.com/acme-e2e/widget/-/issues/9", + "https://github.com/acme-e2e/widget", + ]) { + await expect( + call("TASK_BOARD_ITEM_CREATE", { title: "Bad link", prUrl }), + ).rejects.toThrow(/not a change request url/i); + } }); }); diff --git a/packages/shared/src/git-providers/change-request-ref.test.ts b/packages/shared/src/git-providers/change-request-ref.test.ts new file mode 100644 index 0000000000..f16d71f1ce --- /dev/null +++ b/packages/shared/src/git-providers/change-request-ref.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, test } from "bun:test"; +import { + changeRequestUrl, + findChangeRequestUrl, + parseChangeRequestUrl, +} from "./change-request-ref"; + +describe("changeRequestUrl", () => { + test("each provider's own browser path", () => { + expect( + changeRequestUrl( + { provider: "github", host: "github.com", path: "acme/site" }, + 7, + ), + ).toBe("https://github.com/acme/site/pull/7"); + expect( + changeRequestUrl( + { provider: "gitlab", host: "gitlab.com", path: "group/sub/site" }, + 7, + ), + ).toBe("https://gitlab.com/group/sub/site/-/merge_requests/7"); + }); +}); + +describe("parseChangeRequestUrl", () => { + test("a GitHub pull request", () => { + expect( + parseChangeRequestUrl("https://github.com/acme/site/pull/1006"), + ).toEqual({ + repo: { provider: "github", host: "github.com", path: "acme/site" }, + number: 1006, + url: "https://github.com/acme/site/pull/1006", + }); + }); + + /** The reason the number field is shared: GitLab's iid is per project too. */ + test("a GitLab merge request nested in subgroups", () => { + expect( + parseChangeRequestUrl( + "https://gitlab.com/group/sub/store/-/merge_requests/42", + ), + ).toEqual({ + repo: { + provider: "gitlab", + host: "gitlab.com", + path: "group/sub/store", + }, + number: 42, + url: "https://gitlab.com/group/sub/store/-/merge_requests/42", + }); + }); + + test("a self-hosted GitLab host", () => { + const ref = parseChangeRequestUrl( + "https://gitlab.acme.dev/team/store/-/merge_requests/3", + ); + expect(ref?.repo).toEqual({ + provider: "gitlab", + host: "gitlab.acme.dev", + path: "team/store", + }); + expect(ref?.number).toBe(3); + }); + + test("the API forms map back to the browser URL", () => { + expect( + parseChangeRequestUrl("https://api.github.com/repos/acme/site/pulls/7") + ?.url, + ).toBe("https://github.com/acme/site/pull/7"); + expect( + parseChangeRequestUrl( + "https://gitlab.com/api/v4/projects/group%2Fstore/merge_requests/9", + )?.url, + ).toBe("https://gitlab.com/group/store/-/merge_requests/9"); + }); + + /** A numeric project id names no path, so it cannot identify a repository. */ + test("a GitLab API URL addressing a project by id is not enough", () => { + expect( + parseChangeRequestUrl( + "https://gitlab.com/api/v4/projects/12345/merge_requests/9", + ), + ).toBeNull(); + }); + + test("not a change request URL", () => { + expect(parseChangeRequestUrl("https://github.com/acme/site")).toBeNull(); + expect( + parseChangeRequestUrl("https://github.com/acme/site/issues/4"), + ).toBeNull(); + expect(parseChangeRequestUrl("nothing here")).toBeNull(); + expect(parseChangeRequestUrl("")).toBeNull(); + }); + + test("number zero is rejected", () => { + expect(parseChangeRequestUrl("https://github.com/a/b/pull/0")).toBeNull(); + expect( + parseChangeRequestUrl("https://gitlab.com/a/b/-/merge_requests/0"), + ).toBeNull(); + }); +}); + +describe("findChangeRequestUrl", () => { + test("gh pr create stdout", () => { + expect( + findChangeRequestUrl( + "https://github.com/deco-sites/example-store/pull/1006\n", + ), + ).toEqual({ + repo: { + provider: "github", + host: "github.com", + path: "deco-sites/example-store", + }, + number: 1006, + url: "https://github.com/deco-sites/example-store/pull/1006", + }); + }); + + test("glab mr create stdout", () => { + const out = + "Creating merge request for feat/x into main in group/store\n\n" + + "!12 feat: x\n https://gitlab.com/group/store/-/merge_requests/12\n"; + expect(findChangeRequestUrl(out)?.number).toBe(12); + expect(findChangeRequestUrl(out)?.repo.provider).toBe("gitlab"); + }); + + test("surrounding chatter and markdown wrapping", () => { + expect( + findChangeRequestUrl( + "Creating pull request\nremote: ...\nhttps://github.com/acme/site/pull/42", + )?.number, + ).toBe(42); + expect( + findChangeRequestUrl( + "Opened PR ([#3](https://github.com/acme/site/pull/3)).", + )?.url, + ).toBe("https://github.com/acme/site/pull/3"); + }); + + test("a body carrying both the browser and the API URL prefers the browser one", () => { + const body = JSON.stringify({ + url: "https://api.github.com/repos/acme/site/pulls/9", + html_url: "https://github.com/acme/site/pull/9", + number: 9, + }); + expect(findChangeRequestUrl(body)?.url).toBe( + "https://github.com/acme/site/pull/9", + ); + }); + + test("a response body carrying only the API URL", () => { + expect( + findChangeRequestUrl( + '{"url":"https://api.github.com/repos/acme/site/pulls/7"}', + )?.number, + ).toBe(7); + expect( + findChangeRequestUrl( + '{"web_url":"https://gitlab.com/group/store/-/merge_requests/7"}', + )?.number, + ).toBe(7); + }); + + test("owner and name with dots, dashes and underscores", () => { + const ref = findChangeRequestUrl( + "https://github.com/deco.cx/my_repo-2/pull/5", + ); + expect(ref?.repo.path).toBe("deco.cx/my_repo-2"); + expect(ref?.number).toBe(5); + }); + + test("http, not https, still matches", () => { + expect(findChangeRequestUrl("http://github.com/a/b/pull/1")?.number).toBe( + 1, + ); + }); + + test("finds the URL even when preceded by a large blob", () => { + const noise = "x".repeat(50_000); + expect( + findChangeRequestUrl(`${noise}\nhttps://github.com/acme/site/pull/88`) + ?.number, + ).toBe(88); + }); + + /** The scan is capped, so a URL buried past it is deliberately not found. */ + test("a URL past the scan cap is not found", () => { + const noise = "x".repeat(200_001); + expect( + findChangeRequestUrl(`${noise}https://github.com/acme/site/pull/88`), + ).toBeNull(); + }); + + test("nothing to find", () => { + expect(findChangeRequestUrl("nothing here")).toBeNull(); + expect(findChangeRequestUrl("https://github.com/acme/site")).toBeNull(); + }); +}); diff --git a/packages/shared/src/git-providers/change-request-ref.ts b/packages/shared/src/git-providers/change-request-ref.ts new file mode 100644 index 0000000000..b37c78a857 --- /dev/null +++ b/packages/shared/src/git-providers/change-request-ref.ts @@ -0,0 +1,126 @@ +/** + * Pure helpers over a change request's identity — the provider-neutral name for + * what GitHub calls a pull request and GitLab a merge request. + * + * A change request is addressed by its repository plus a per-repository number + * (GitHub's `number`, GitLab's `iid` — both 1-based and both scoped to the + * project, which is why one field carries them). Every URL convention lives + * here so nothing outside this module hand-builds `github.com/.../pull/1`. + */ + +import { parseRepoUrl, repoWebUrl } from "./repo-ref"; +import type { GitProviderKind, RepoRef } from "./types"; + +export interface ChangeRequestRef { + repo: RepoRef; + /** Per-repository number: GitHub's `number`, GitLab's `iid`. */ + number: number; + /** Canonical browser URL — the display and dedup key. */ + url: string; +} + +/** The provider's browser path for one change request, after the repo path. */ +const WEB_SUFFIX: Record = { + github: "pull", + gitlab: "-/merge_requests", +}; + +/** Canonical browser URL for a change request. */ +export function changeRequestUrl(repo: RepoRef, number: number): string { + return `${repoWebUrl(repo)}/${WEB_SUFFIX[repo.provider]}/${number}`; +} + +/** + * Every URL shape a change request is quoted as, in the order they are tried. + * + * Browser URLs come first so the human-facing link wins over the API one when + * a `curl` response body carries both. Each pattern captures the host, the + * repository path and the number; `parseRepoUrl` then does the actual + * provider/host/path work, so a self-hosted GitLab and a GitHub Enterprise + * host parse through exactly the same code as the SaaS ones. + * + * All are linear (no nested quantifiers) — safe to run on large stdout. + */ +const URL_PATTERNS: { re: RegExp; api?: boolean }[] = [ + // https:////-/merge_requests/ + { re: /https?:\/\/([^/\s"']+)\/([^\s"']+?)\/-\/merge_requests\/(\d+)/ }, + // https://///pull/ + { re: /https?:\/\/([^/\s"']+)\/([^/\s"']+\/[^/\s"']+)\/pull\/(\d+)/ }, + // https://api.github.com/repos///pulls/ + { + re: /https?:\/\/([^/\s"']+)\/repos\/([^/\s"']+\/[^/\s"']+)\/pulls\/(\d+)/, + api: true, + }, + // https:///api/v4/projects//merge_requests/ + { + re: /https?:\/\/([^/\s"']+)\/api\/v4\/projects\/([^/\s"']+)\/merge_requests\/(\d+)/, + api: true, + }, +]; + +/** + * `api.github.com` addresses `github.com`'s repositories, and + * `/api/v4/...` addresses ``'s — so an API match has to be mapped + * back to the browser host before the repository can be identified. + */ +function webHostOf(host: string): string { + const h = host.toLowerCase(); + return h === "api.github.com" ? "github.com" : h; +} + +function refFrom( + match: RegExpExecArray, + api: boolean, +): ChangeRequestRef | null { + const number = Number(match[3]); + if (!Number.isSafeInteger(number) || number <= 0) return null; + const host = webHostOf(match[1]!); + // A GitLab project may be addressed by its numeric id, which names no path. + const rawPath = match[2]!; + const path = api ? decodeURIComponent(rawPath) : rawPath; + if (/^\d+$/.test(path)) return null; + const repo = parseRepoUrl(`https://${host}/${path}`); + if (!repo) return null; + return { repo, number, url: changeRequestUrl(repo, number) }; +} + +/** + * Parse one change request URL. Returns null when the string is not a change + * request URL or names a repository whose provider cannot be determined. + */ +export function parseChangeRequestUrl(input: string): ChangeRequestRef | null { + const raw = input.trim(); + if (!raw) return null; + for (const { re, api } of URL_PATTERNS) { + const match = re.exec(raw); + const ref = match ? refFrom(match, api === true) : null; + if (ref) return ref; + } + return null; +} + +/** + * Cap the scan of free-form output. A single linear regex is fine on big + * input, but a verbose `curl -v` dump can be megabytes — the URL, when + * present, is near the top (the CLI's own output) or in the response body. + * 200KB covers both without scanning a whole log. + */ +const MAX_SCAN = 200_000; + +/** + * Find the first change request URL anywhere in a string. + * + * Every "the agent opened one" scenario collapses to this: a provider tool + * result, `gh pr create` / `glab mr create` stdout, and a raw `curl` response + * body all embed the URL, so callers stringify whatever they have and scan it + * rather than branching per shape. + */ +export function findChangeRequestUrl(text: string): ChangeRequestRef | null { + const s = text.length > MAX_SCAN ? text.slice(0, MAX_SCAN) : text; + for (const { re, api } of URL_PATTERNS) { + const match = re.exec(s); + const ref = match ? refFrom(match, api === true) : null; + if (ref) return ref; + } + return null; +} diff --git a/packages/shared/src/git-providers/cli.ts b/packages/shared/src/git-providers/cli.ts index 24146c6694..126abdb6b5 100644 --- a/packages/shared/src/git-providers/cli.ts +++ b/packages/shared/src/git-providers/cli.ts @@ -18,6 +18,8 @@ export interface ProviderCli { createCommand: string; /** Checks an existing one out by its number, which the caller appends. */ checkoutCommand: string; + /** How the provider writes a change request's number in prose. */ + numberSigil: string; } const CLIS: Record = { @@ -26,15 +28,31 @@ const CLIS: Record = { changeRequest: "pull request", createCommand: "gh pr create", checkoutCommand: "gh pr checkout", + numberSigil: "#", }, gitlab: { cli: "glab", changeRequest: "merge request", createCommand: "glab mr create", checkoutCommand: "glab mr checkout", + numberSigil: "!", }, }; export function providerCli(provider: GitProviderKind): ProviderCli { return CLIS[provider]; } + +/** + * A change request in prose, the way its own provider writes it — "PR #7" and + * "MR !7" are what a reader of that provider expects to see, and a card + * titled "PR #7" for a merge request is simply wrong. + */ +export function changeRequestLabel( + provider: GitProviderKind, + number: number, +): string { + const { cli, numberSigil } = CLIS[provider]; + const abbreviation = cli === "gh" ? "PR" : "MR"; + return `${abbreviation} ${numberSigil}${number}`; +} diff --git a/packages/shared/src/git-providers/index.ts b/packages/shared/src/git-providers/index.ts index f09e925bbd..c6257a356e 100644 --- a/packages/shared/src/git-providers/index.ts +++ b/packages/shared/src/git-providers/index.ts @@ -1,3 +1,4 @@ export * from "./types"; export * from "./repo-ref"; +export * from "./change-request-ref"; export * from "./cli"; diff --git a/packages/shared/src/tools/registry-metadata.ts b/packages/shared/src/tools/registry-metadata.ts index c929c8017c..c57dfce179 100644 --- a/packages/shared/src/tools/registry-metadata.ts +++ b/packages/shared/src/tools/registry-metadata.ts @@ -246,11 +246,8 @@ const ALL_TOOL_NAMES = [ "SANDBOX_START", "SANDBOX_DELETE", - // GitHub tools (app-only) + // GitHub App installations (app-only) "GITHUB_LIST_USER_ORGS", - "GITHUB_SEARCH_BRANCHES", - "GITHUB_PR_STATE", - "GITHUB_LAST_PUBLISHED_PR", // Git provider accounts + repositories (app-only) "GIT_PROVIDER_CAPABILITIES", @@ -261,6 +258,15 @@ const ALL_TOOL_NAMES = [ "REPOSITORY_SEARCH", "REPOSITORY_LINK", "REPOSITORY_DELETE", + "REPOSITORY_SEARCH_BRANCHES", + + // Change requests — pull requests on GitHub, merge requests on GitLab (app-only) + "CHANGE_REQUEST_STATE", + "CHANGE_REQUEST_LAST_MERGED", + "CHANGE_REQUEST_LIST_OPEN", + "CHANGE_REQUEST_CHECK_LOG", + "CHANGE_REQUEST_OPEN", + "CHANGE_REQUEST_MERGE", // Search tools "GLOBAL_SEARCH", @@ -1193,23 +1199,6 @@ export const MANAGEMENT_TOOLS: ToolMetadata[] = [ description: "List GitHub user's personal account and organizations", category: "GitHub", }, - { - name: "GITHUB_SEARCH_BRANCHES", - description: "Search a repository's branches by name substring", - category: "GitHub", - }, - { - name: "GITHUB_PR_STATE", - description: - "Read a branch's pull request with its checks, review state and comments", - category: "GitHub", - }, - { - name: "GITHUB_LAST_PUBLISHED_PR", - description: - "Read the most recently merged pull request into a base branch", - category: "GitHub", - }, // Git provider accounts + repositories { name: "GIT_PROVIDER_CAPABILITIES", @@ -1247,6 +1236,45 @@ export const MANAGEMENT_TOOLS: ToolMetadata[] = [ description: "Link a repository to the organization", category: "Git", }, + { + name: "REPOSITORY_SEARCH_BRANCHES", + description: + "Search a repository's branches by name substring, on either provider", + category: "Git", + }, + { + name: "CHANGE_REQUEST_STATE", + description: + "Read a branch's change request with its CI runs, review state and comments", + category: "Git", + }, + { + name: "CHANGE_REQUEST_LAST_MERGED", + description: + "Read the most recently merged change request into a base branch", + category: "Git", + }, + { + name: "CHANGE_REQUEST_LIST_OPEN", + description: "List a repository's open change requests", + category: "Git", + }, + { + name: "CHANGE_REQUEST_CHECK_LOG", + description: "Read one CI run's report for a change request", + category: "Git", + }, + { + name: "CHANGE_REQUEST_OPEN", + description: + "Propose a branch onto another, reusing the branch's existing change request", + category: "Git", + }, + { + name: "CHANGE_REQUEST_MERGE", + description: "Land a change request, reporting why it could not merge", + category: "Git", + }, { name: "REPOSITORY_DELETE", description: "Unlink a repository from the organization", @@ -1489,15 +1517,17 @@ const PERMISSION_CAPABILITIES: PermissionCapability[] = [ // Sandbox previews, and the branch picker that feeds them "SANDBOX_START", "SANDBOX_DELETE", - "GITHUB_SEARCH_BRANCHES", + "REPOSITORY_SEARCH_BRANCHES", // Repo picker reads: which accounts/repos exist and what can be connected "GIT_PROVIDER_CAPABILITIES", "GIT_ACCOUNT_LIST", "REPOSITORY_LIST", "REPOSITORY_SEARCH", - // The PR panel's whole read side, for anyone who can open a preview - "GITHUB_PR_STATE", - "GITHUB_LAST_PUBLISHED_PR", + // The change-request panel's whole read side, for anyone who can open a preview + "CHANGE_REQUEST_STATE", + "CHANGE_REQUEST_LAST_MERGED", + "CHANGE_REQUEST_LIST_OPEN", + "CHANGE_REQUEST_CHECK_LOG", // Cross-resource discovery / command palette "GLOBAL_SEARCH", // App-shell essentials (read-only) — every member hits these on first diff --git a/packages/shared/src/tools/tool-io.ts b/packages/shared/src/tools/tool-io.ts index 2477057dfb..20f3edc317 100644 --- a/packages/shared/src/tools/tool-io.ts +++ b/packages/shared/src/tools/tool-io.ts @@ -7564,87 +7564,6 @@ export interface StudioToolIO { }[]; }; }; - GITHUB_SEARCH_BRANCHES: { - input: { - connectionId: string; - owner: string; - repo: string; - query: string; - limit?: number | undefined; - }; - output: { - branches: { name: string; author: string | null }[]; - totalCount: number; - }; - }; - GITHUB_PR_STATE: { - input: { - connectionId: string; - owner: string; - repo: string; - branch: string; - }; - output: { - pullRequest: { - number: number; - title: string; - body: string; - state: "open" | "closed"; - merged: boolean; - mergedAt: string | null; - base: string; - head: string; - headSha: string; - headRepoFullName: string | null; - htmlUrl: string; - author: string; - draft: boolean; - mergeableState: "unknown" | "clean" | "dirty" | "blocked"; - unresolvedConversations: number; - missingRequiredApprovals: boolean; - changedFiles: number; - checks: { - id: string; - name: string; - status: "in_progress" | "completed" | "queued"; - conclusion: - | "success" - | "skipped" - | "cancelled" - | "failure" - | "neutral" - | "timed_out" - | "action_required" - | null; - htmlUrl: string; - durationMs: number | null; - }[]; - comments: { - id: number; - author: string; - body: string; - createdAt: string; - htmlUrl: string; - }[]; - } | null; - }; - }; - GITHUB_LAST_PUBLISHED_PR: { - input: { connectionId: string; owner: string; repo: string; base: string }; - output: { - pullRequest: { - number: number; - title: string; - body: string; - mergedAt: string | null; - base: string; - head: string; - headSha: string; - htmlUrl: string; - author: string; - } | null; - }; - }; GIT_PROVIDER_CAPABILITIES: { input: { [x: string]: never }; output: { @@ -7755,6 +7674,191 @@ export interface StudioToolIO { }; }; REPOSITORY_DELETE: { input: { id: string }; output: { deleted: boolean } }; + REPOSITORY_SEARCH_BRANCHES: { + input: { + query: string; + limit?: number | undefined; + cursor?: string | null | undefined; + repositoryId?: string | undefined; + repoUrl?: string | undefined; + connectionId?: string | undefined; + }; + output: { + branches: { name: string; author: string | null }[]; + totalCount: number; + nextCursor: string | null; + }; + }; + CHANGE_REQUEST_STATE: { + input: { + branch: string; + repositoryId?: string | undefined; + repoUrl?: string | undefined; + connectionId?: string | undefined; + }; + output: { + changeRequest: { + number: number; + url: string; + title: string; + body: string; + state: "merged" | "open" | "closed"; + draft: boolean; + mergedAt: string | null; + base: string; + head: string; + headSha: string; + headRepoPath: string | null; + author: string; + conflicting: boolean | null; + checks: "pending" | "passing" | "failing" | null; + changedFiles: number | null; + checkRuns: { + id: string | null; + name: string; + state: "completed" | "running" | "queued"; + conclusion: + | "success" + | "skipped" + | "cancelled" + | "failure" + | "neutral" + | "timed_out" + | "action_required" + | null; + url: string | null; + durationMs: number | null; + summary: string | null; + }[]; + comments: { + id: string; + author: string; + body: string; + createdAt: string; + updatedAt: string; + url: string; + }[]; + unresolvedConversations: number; + reviewBlocked: boolean; + } | null; + }; + }; + CHANGE_REQUEST_LAST_MERGED: { + input: { + base: string; + repositoryId?: string | undefined; + repoUrl?: string | undefined; + connectionId?: string | undefined; + }; + output: { + changeRequest: { + number: number; + url: string; + title: string; + body: string; + state: "merged" | "open" | "closed"; + draft: boolean; + mergedAt: string | null; + base: string; + head: string; + headSha: string; + headRepoPath: string | null; + author: string; + conflicting: boolean | null; + checks: "pending" | "passing" | "failing" | null; + changedFiles: number | null; + } | null; + }; + }; + CHANGE_REQUEST_LIST_OPEN: { + input: { + limit?: number | undefined; + repositoryId?: string | undefined; + repoUrl?: string | undefined; + connectionId?: string | undefined; + }; + output: { + changeRequests: { + number: number; + url: string; + title: string; + body: string; + state: "merged" | "open" | "closed"; + draft: boolean; + mergedAt: string | null; + base: string; + head: string; + headSha: string; + headRepoPath: string | null; + author: string; + conflicting: boolean | null; + checks: "pending" | "passing" | "failing" | null; + changedFiles: number | null; + }[]; + }; + }; + CHANGE_REQUEST_CHECK_LOG: { + input: { + checkId: string; + repositoryId?: string | undefined; + repoUrl?: string | undefined; + connectionId?: string | undefined; + }; + output: { report: string | null }; + }; + CHANGE_REQUEST_OPEN: { + input: { + head: string; + base: string; + title: string; + body?: string | undefined; + repositoryId?: string | undefined; + repoUrl?: string | undefined; + connectionId?: string | undefined; + }; + output: { + changeRequest: { + number: number; + url: string; + title: string; + body: string; + state: "merged" | "open" | "closed"; + draft: boolean; + mergedAt: string | null; + base: string; + head: string; + headSha: string; + headRepoPath: string | null; + author: string; + conflicting: boolean | null; + checks: "pending" | "passing" | "failing" | null; + changedFiles: number | null; + }; + existed: boolean; + }; + }; + CHANGE_REQUEST_MERGE: { + input: { + number: number; + strategy?: "unknown" | "squash" | undefined; + commitTitle?: string | undefined; + commitMessage?: string | undefined; + repositoryId?: string | undefined; + repoUrl?: string | undefined; + connectionId?: string | undefined; + }; + output: { + merged: boolean; + reason?: + | "error" + | "not_found" + | "conflict" + | "blocked" + | "rate_limited" + | undefined; + detail?: string | undefined; + }; + }; GLOBAL_SEARCH: { input: { query: string; diff --git a/plugins/ban-git-provider-reachthrough.js b/plugins/ban-git-provider-reachthrough.js new file mode 100644 index 0000000000..2a9959f739 --- /dev/null +++ b/plugins/ban-git-provider-reachthrough.js @@ -0,0 +1,136 @@ +/** + * Lint plugin enforcing the git-provider boundary. + * + * `apps/api/src/git-providers/` is one interface with one implementation per + * provider. Everything above it speaks `RepoRef` and gets back a contract; + * `github/` and `gitlab/` are the only places a provider's name, hosts, + * endpoints and error prose appear. That property is what makes adding a third + * provider a directory instead of an archaeology exercise — and it is only + * true for as long as nobody reaches past the front door. + * + * Two rules, both about imports: + * + * 1. Code OUTSIDE the layer may not import `git-providers/github/**` or + * `git-providers/gitlab/**`. Import `@/git-providers` instead. A caller + * that genuinely needs one provider wants a capability the interface does + * not express yet — add it to the interface rather than reaching around it. + * 2. One provider's directory may not import another's. A shared helper + * belongs in the contract both implement (this caught a real one: + * `gitlab/change-requests.ts` importing `summarizeChecks` from the GitHub + * side, which quietly made GitLab's CI summary GitHub's). + * + * The ALLOWLIST is deliberately tiny and both entries are provider-specific + * BY CONSTRUCTION — there is no single flow for them to implement: + * - `api/routes/git-providers.ts`: a GitHub App installation and a GitLab + * OAuth grant are different redirect dances. + * - `tools/github/list-user-orgs.ts`: listing App installations has no + * counterpart on another provider to abstract over. + * Do not silence this rule. Extending the allowlist is a conscious act that + * needs a reason of that kind, not a deadline. + * + * Companion to `ban-cross-tree-imports.js`, `ban-web-server-imports.js` and + * `ban-e2e-app-imports.js`. + */ + +const LAYER = "git-providers"; +const PROVIDERS = ["github", "gitlab"]; + +/** Files permitted to reach a provider directory. Suffix-matched. */ +const ALLOWLIST = [ + "apps/api/src/api/routes/git-providers.ts", + "apps/api/src/tools/github/list-user-orgs.ts", +]; + +/** The provider directory this file lives in, or null. */ +function providerOf(filename) { + for (const provider of PROVIDERS) { + if (filename.includes(`/${LAYER}/${provider}/`)) return provider; + } + return null; +} + +function inLayer(filename) { + return filename.includes(`/${LAYER}/`) || filename.startsWith(`${LAYER}/`); +} + +function isAllowed(filename) { + return ALLOWLIST.some((allowed) => filename.endsWith(allowed)); +} + +// Resolve `../` / `./` segments of a relative spec against the importing file. +function resolveRelative(fromFile, spec) { + const parts = fromFile.split("/"); + parts.pop(); // drop the filename → containing directory + for (const seg of spec.split("/")) { + if (seg === "" || seg === ".") continue; + if (seg === "..") parts.pop(); + else parts.push(seg); + } + return parts.join("/"); +} + +/** + * The path a specifier names. `@/` is the API's own alias for + * `apps/api/src/`, and is only read that way for a file inside that app — + * elsewhere the same prefix means a different root. + */ +function resolveSpec(filename, spec) { + if (spec.startsWith(".")) return resolveRelative(filename, spec); + if (spec.startsWith("@/") && filename.includes("/apps/api/")) { + return `apps/api/src/${spec.slice(2)}`; + } + return spec; +} + +/** The provider directory a specifier reaches into, or null. */ +function reaches(filename, spec) { + const resolved = resolveSpec(filename, spec); + const match = new RegExp(`(^|/)${LAYER}/(${PROVIDERS.join("|")})(/|$)`).exec( + resolved, + ); + return match ? match[2] : null; +} + +const rule = { + create(context) { + const filename = context.filename ?? ""; + if (isAllowed(filename)) return {}; + const own = providerOf(filename); + const outside = !inLayer(filename); + if (!outside && own === null) return {}; // the neutral layer composes both + + const check = (node) => { + const src = node?.source; + if (!src || src.type !== "Literal" || typeof src.value !== "string") { + return; + } + const target = reaches(filename, src.value); + if (!target || target === own) return; + + context.report({ + node: src, + message: outside + ? `Git provider boundary: "${src.value}" reaches into ${LAYER}/${target}. ` + + "Import @/git-providers instead — if the capability you need is not " + + "on the interface, add it there rather than around it." + : `Git provider boundary: ${own} must not import ${target}. ` + + "A helper both providers need belongs in the contract they " + + "implement, not in one of them.", + }); + }; + + return { + ImportDeclaration: check, + ExportNamedDeclaration: check, + ExportAllDeclaration: check, + ImportExpression: check, + }; + }, +}; + +const plugin = { + meta: { name: "ban-git-provider-reachthrough" }, + rules: { "ban-git-provider-reachthrough": rule }, +}; + +export default plugin; diff --git a/plugins/ban-git-provider-reachthrough.test.ts b/plugins/ban-git-provider-reachthrough.test.ts new file mode 100644 index 0000000000..ad8d9f76e2 --- /dev/null +++ b/plugins/ban-git-provider-reachthrough.test.ts @@ -0,0 +1,157 @@ +/** + * A boundary rule that matches nothing is indistinguishable from a broken one, + * and this layer is clean today — so every case below is a fixture, not a + * reading of the real tree. + */ +import { + afterAll, + beforeAll, + describe, + expect, + setDefaultTimeout, + test, +} from "bun:test"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; + +// Each test spawns a real `oxlint` subprocess; see ban-web-server-imports.test.ts. +setDefaultTimeout(20_000); + +const ROOT = new URL("..", import.meta.url).pathname.replace(/\/$/, ""); +const TMP = `${ROOT}/.ban-git-provider.tmp`; +const CONFIG = `${TMP}/.oxlintrc.json`; + +const CONFIG_JSON = JSON.stringify({ + jsPlugins: ["../plugins/ban-git-provider-reachthrough.js"], + rules: { + "ban-git-provider-reachthrough/ban-git-provider-reachthrough": "error", + }, +}); + +async function lint(relPath: string): Promise { + const proc = Bun.spawn( + ["node_modules/.bin/oxlint", "-c", CONFIG, "-f", "json", relPath], + { cwd: ROOT, stdout: "pipe", stderr: "pipe" }, + ); + const out = await new Response(proc.stdout).text(); + await proc.exited; + const parsed = JSON.parse(out) as { + diagnostics: { code: string; message: string }[]; + }; + return parsed.diagnostics + .filter((d) => d.code.includes("ban-git-provider-reachthrough")) + .map((d) => d.message); +} + +function fixture(relPath: string, contents: string): string { + const abs = `${TMP}/${relPath}`; + mkdirSync(abs.slice(0, abs.lastIndexOf("/")), { recursive: true }); + writeFileSync(abs, contents); + return `.ban-git-provider.tmp/${relPath}`; +} + +beforeAll(() => { + mkdirSync(TMP, { recursive: true }); + writeFileSync(CONFIG, CONFIG_JSON); +}); +afterAll(() => rmSync(TMP, { recursive: true, force: true })); + +describe("reaching into a provider from outside the layer", () => { + test("bans the `@/` alias, which is how every real caller would write it", async () => { + const f = fixture( + "apps/api/src/tools/task-board/a.ts", + `import { GithubContentClient } from "@/git-providers/github/content";\nexport const x = GithubContentClient;\n`, + ); + const messages = await lint(f); + expect(messages).toHaveLength(1); + expect(messages[0]).toContain("git-providers/github"); + expect(messages[0]).toContain("@/git-providers"); + }); + + test("bans a relative path that climbs into one", async () => { + const f = fixture( + "apps/api/src/decofile/b.ts", + `import { GitlabContentClient } from "../git-providers/gitlab/content";\nexport const x = GitlabContentClient;\n`, + ); + expect(await lint(f)).toHaveLength(1); + }); + + /** Type-only still couples the caller to a provider's object model. */ + test("bans a type-only import and a re-export", async () => { + const typeOnly = fixture( + "apps/api/src/tools/c.ts", + `import type { RawPullRequest } from "@/git-providers/github/change-requests";\nexport type X = RawPullRequest;\n`, + ); + expect(await lint(typeOnly)).toHaveLength(1); + const reexport = fixture( + "apps/api/src/tools/d.ts", + `export { GithubProviderClient } from "@/git-providers/github/client";\n`, + ); + expect(await lint(reexport)).toHaveLength(1); + }); + + test("allows the front door", async () => { + const f = fixture( + "apps/api/src/tools/task-board/e.ts", + `import { changeRequestClientForOrigin } from "@/git-providers";\nexport const x = changeRequestClientForOrigin;\n`, + ); + expect(await lint(f)).toEqual([]); + }); + + /** + * The route file is NAMED for the layer without being in it. A rule keyed on + * the name rather than the directory would flag every import it makes. + */ + test("does not confuse a file named git-providers.ts with the directory", async () => { + const f = fixture( + "apps/api/src/api/routes/git-providers.ts", + `import { readGithubAppConfig } from "@/git-providers/github/env";\nexport const x = readGithubAppConfig;\n`, + ); + expect(await lint(f)).toEqual([]); + }); + + test("allows the second exception, App installations", async () => { + const f = fixture( + "apps/api/src/tools/github/list-user-orgs.ts", + `import { githubFetch } from "@/git-providers/github/http";\nexport const x = githubFetch;\n`, + ); + expect(await lint(f)).toEqual([]); + }); +}); + +describe("inside the layer", () => { + test("lets the composition root reach both — that is its job", async () => { + const f = fixture( + "apps/api/src/git-providers/clients.ts", + `import { GithubContentClient } from "./github/content";\n` + + `import { GitlabContentClient } from "./gitlab/content";\n` + + `export const x = [GithubContentClient, GitlabContentClient];\n`, + ); + expect(await lint(f)).toEqual([]); + }); + + test("lets a provider import its own siblings and the shared contract", async () => { + const f = fixture( + "apps/api/src/git-providers/github/content.ts", + `import { githubFetch } from "./http";\n` + + `import type { RepoContentClient } from "../content";\n` + + `export const x: RepoContentClient | typeof githubFetch = githubFetch;\n`, + ); + expect(await lint(f)).toEqual([]); + }); + + /** + * The case that motivated the second half of the rule: GitLab's change + * requests once imported `summarizeChecks` from the GitHub side, which + * quietly made its CI summary GitHub's. + */ + test("bans one provider importing another", async () => { + const f = fixture( + "apps/api/src/git-providers/gitlab/change-requests.ts", + `import { summarizeChecks } from "../github/change-requests";\nexport const x = summarizeChecks;\n`, + ); + const messages = await lint(f); + expect(messages).toHaveLength(1); + expect(messages[0]).toContain("gitlab must not import github"); + expect(messages[0]).toContain("contract"); + }); +});