diff --git a/.env.example b/.env.example index 119888ae46..b3e6d982fc 100644 --- a/.env.example +++ b/.env.example @@ -165,7 +165,6 @@ LOOPOVER_REVIEW_DRAFT=false # DRAFT_TOKEN_ENCRYPTION_SECRET= # AES-256-GCM secret for the contributor OAuth token (draft flow) # LOOPOVER_REVIEW_STATS_TOKEN= # bearer token guarding the stats data endpoint # LOOPOVER_DRIFT_ISSUE_TOKEN= # token for auto-filing drift issues -# LOOPOVER_CONTRIBUTOR_ISSUE_TOKEN= # token for contributor-issue automation # PRODUCT_USAGE_HASH_SALT= # salt for hashing product-usage identifiers # ============================================================================= diff --git a/src/env.d.ts b/src/env.d.ts index 7bf0f0aed0..8b8256386a 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -276,7 +276,6 @@ declare global { SENTRY_MIN_SEVERITY?: string; /** Per-repo override map for SENTRY_MIN_SEVERITY — see its doc comment for the shape and precedence. */ SENTRY_REPO_MIN_SEVERITY?: string; - LOOPOVER_CONTRIBUTOR_ISSUE_TOKEN?: string; PRODUCT_USAGE_HASH_SALT?: string; /** Server-to-server API bearer token — bypasses per-repo write checks (src/auth/security.ts). */ LOOPOVER_API_TOKEN?: string; diff --git a/src/github/issues.ts b/src/github/issues.ts new file mode 100644 index 0000000000..8248addd9f --- /dev/null +++ b/src/github/issues.ts @@ -0,0 +1,60 @@ +import { withInstallationTokenRetry } from "./app"; +import { githubRateLimitAdmissionKeyForInstallation, makeInstallationOctokit } from "./client"; +import type { AgentActionMode } from "../settings/agent-execution"; + +// Mirrors parseRepoFullName in labels.ts / assignees.ts (#7425): each GitHub-write module keeps its own copy +// rather than importing a shared one, matching the existing house convention for this tiny pure check. +function parseRepoFullName(repoFullName: string): { owner: string; repo: string } { + const parts = repoFullName.split("/"); + const owner = parts[0]; + const repo = parts[1]; + if (parts.length !== 2 || !owner || !repo || /\s/.test(repoFullName)) { + throw new Error(`Invalid repository full name: ${repoFullName}`); + } + return { owner, repo }; +} + +export type CreateInstallationIssueInput = { + title: string; + body: string; + labels?: string[] | undefined; +}; + +export type CreatedInstallationIssue = { number: number; url: string }; + +/** + * Create a GitHub issue via the installation-token path — the local GitHub App key OR the Orb broker, + * whichever this deployment is configured for (createInstallationToken/withInstallationTokenRetry already pick + * the right one transparently, see src/orb/broker-client.ts) — instead of a flat operator PAT. Every other + * GitHub write in this codebase (labels, comments, check-runs) already goes through this path; issue creation + * was the one write left needing a separately-configured PAT with its own write access to whichever repo was + * targeted, rather than following "wherever this App/Orb-installation is installed" (#7425). + * + * Returns null only when the write itself was suppressed by a non-live mode or GitHub's response omits the + * fields a caller needs (mirrors createOrUpdateNamedCheckRun's publishedOutcome, src/github/app.ts) — a genuine + * GitHub API failure (permission gap, 5xx, rate limit) is NOT swallowed here; it propagates via Octokit's + * throw-on-non-2xx so callers can distinguish "nothing to do" from "the write actually failed" and degrade + * however fits their own contract. + */ +export async function createInstallationIssue( + env: Env, + installationId: number, + repoFullName: string, + issue: CreateInstallationIssueInput, + mode: AgentActionMode = "live", +): Promise { + const { owner, repo } = parseRepoFullName(repoFullName); + return withInstallationTokenRetry(env, installationId, async (token) => { + const octokit = makeInstallationOctokit(env, token, mode, githubRateLimitAdmissionKeyForInstallation(installationId)); + const response = await octokit.request("POST /repos/{owner}/{repo}/issues", { + owner, + repo, + title: issue.title, + body: issue.body, + ...(issue.labels && issue.labels.length > 0 ? { labels: issue.labels } : {}), + }); + const data = response.data as { number?: number; html_url?: string; dryRunSuppressed?: boolean }; + if (data.dryRunSuppressed) return null; + return data.number && data.html_url ? { number: data.number, url: data.html_url } : null; + }); +} diff --git a/src/services/contributor-issue-draft.ts b/src/services/contributor-issue-draft.ts index e298a728f4..e72eef309e 100644 --- a/src/services/contributor-issue-draft.ts +++ b/src/services/contributor-issue-draft.ts @@ -17,9 +17,9 @@ import { import type { IssueRecord, RepositoryRecord, RepositorySettings } from "../types"; import { isGlobalAgentPause } from "../settings/agent-execution"; import { isMaintainerAssociation } from "../github/commands"; -import { githubHeaders, timeoutFetch } from "../github/client"; +import { createInstallationIssue } from "../github/issues"; import { sha256Hex } from "../utils/crypto"; -import { jsonString, nowIso, repoParts } from "../utils/json"; +import { errorMessage, nowIso } from "../utils/json"; import { buildCollisionReport, buildConfigQuality, @@ -259,8 +259,10 @@ export async function generateContributorIssueDrafts( ): Promise { const context = await loadContributorIssueDraftContext(env, repoFullName); // The caller's dryRun flag, OVERLAID with the global agent kill-switch: a paused/frozen agent must not file - // contributor issues even when a caller passes {dryRun:false}. These POSTs use a raw token outside the - // installation-Octokit dry-run chokepoint (#dry-run-chokepoint), so the brake is applied here. (#audit-rawfetch-pause) + // contributor issues even when a caller passes {dryRun:false}. createGitHubContributorIssue now creates via + // the installation-Octokit path (#7425), but it's only ever invoked from the branch below once dryRun is + // already resolved false -- this gate (not the per-call AgentActionMode) remains the actual brake, so it must + // stay here rather than assuming the Octokit chokepoint alone would catch a paused/frozen agent. (#audit-rawfetch-pause) // isGlobalAgentFrozen is an absolute fleet-wide brake with no per-repo bypass, same tier as the env-var // hard stop (isGlobalAgentPause); day-to-day per-repo enable/disable is settings.agentPaused instead. const dryRun = options.dryRun !== false || isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)); @@ -310,7 +312,7 @@ export async function generateContributorIssueDrafts( continue; } if (!dryRun && createRequested) { - const issue = await createGitHubContributorIssue(env, repoFullName, draft); + const issue = await createGitHubContributorIssue(env, repoFullName, draft, context.repo?.installationId); if (issue) { draft.status = "created"; draft.issue = issue; @@ -552,21 +554,36 @@ async function loadContributorIssueDraftQueueCounts(env: Env, repoFullName: stri }; } -async function createGitHubContributorIssue(env: Env, repoFullName: string, draft: ContributorIssueDraft): Promise<{ number: number; url: string } | null> { - const token = env.LOOPOVER_CONTRIBUTOR_ISSUE_TOKEN ?? env.LOOPOVER_DRIFT_ISSUE_TOKEN ?? env.GITHUB_PUBLIC_TOKEN; - if (!token) return null; - const { owner, name } = repoParts(repoFullName); - if (!owner || !name) return null; - const response = await timeoutFetch(`https://api.github.com/repos/${owner}/${name}/issues`, { - method: "POST", - headers: githubHeaders({ token }), - body: jsonString({ +/** + * Creates via the installation-token/Orb-broker path (src/github/issues.ts) instead of a flat PAT (#7425), so + * this works on any repo the caller's App/Orb is actually installed on with no separate token to configure. No + * installation on this repo (installationId absent) fails closed the same way "no PAT configured" used to. + * Catches broadly: unlike the raw fetch this replaces (which returned a checkable `.ok` flag), Octokit THROWS on + * a non-2xx response or a malformed repoFullName -- callers of this function rely on a null return, never a + * throw, to mark a draft `skipped_create_failed` instead of failing the whole batch. + */ +async function createGitHubContributorIssue( + env: Env, + repoFullName: string, + draft: ContributorIssueDraft, + installationId: number | null | undefined, +): Promise<{ number: number; url: string } | null> { + if (!installationId) return null; + try { + return await createInstallationIssue(env, installationId, repoFullName, { title: draft.title, body: draft.body, labels: draft.labels, - }), - }); - if (!response.ok) return null; - const payload = (await response.json()) as { number?: number; html_url?: string }; - return payload.number && payload.html_url ? { number: payload.number, url: payload.html_url } : null; + }); + } catch (error) { + console.warn( + JSON.stringify({ + level: "warn", + event: "contributor_issue_create_failed", + repoFullName, + message: errorMessage(error).slice(0, 200), + }), + ); + return null; + } } diff --git a/test/unit/contributor-issue-draft.test.ts b/test/unit/contributor-issue-draft.test.ts index a38b05f1fc..7a923e52f8 100644 --- a/test/unit/contributor-issue-draft.test.ts +++ b/test/unit/contributor-issue-draft.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { generateKeyPairSync } from "node:crypto"; import { createTestEnv } from "../helpers/d1"; +import { clearInstallationTokenCacheForTest } from "../../src/github/app"; import * as focusManifest from "../../src/signals/focus-manifest"; import { parseFocusManifestContent } from "../../src/signals/focus-manifest"; import { @@ -16,7 +18,7 @@ import { } from "../../src/services/contributor-issue-draft"; import { upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader"; import * as repositories from "../../src/db/repositories"; -import type { IssueRecord } from "../../src/types"; +import type { IssueRecord, RepositoryRecord } from "../../src/types"; import { buildRepoPolicyReadiness } from "../../src/signals/repo-policy-readiness"; import { buildLaneAdvice, buildConfigQuality, buildContributorIntakeHealth, buildLabelAudit, buildQueueHealth, buildCollisionReport } from "../../src/signals/engine"; @@ -45,10 +47,33 @@ function openIssue(number: number, title: string, body?: string): IssueRecord { }; } +// The installation this repo is configured on, so createGitHubContributorIssue (#7425) resolves an +// installationId and actually attempts the installation-token/Orb-broker create path instead of failing closed. +function installedRepo(fullName: string): RepositoryRecord { + const [owner = "", name = ""] = fullName.split("/"); + return { fullName, owner, name, installationId: 123, isInstalled: true, isRegistered: true, isPrivate: false }; +} + +function generateRsaPrivateKeyPem(): string { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + return privateKey.export({ type: "pkcs1", format: "pem" }).toString(); +} + +// Stubs the installation-token mint (App-key path) alongside the real POST /issues under test, mirroring +// github-labels.test.ts / github-issues.test.ts's dual-branch fetch stub convention. +function stubGitHubIssueCreate(respond: (input: RequestInfo | URL, init?: RequestInit) => Response | Promise): void { + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + return respond(input, init); + }); +} + describe("contributor issue drafts", () => { afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); + clearInstallationTokenCacheForTest(); }); it("draft generation fixture includes the full issue body contract", async () => { @@ -153,16 +178,14 @@ describe("contributor issue drafts", () => { }); it("optional create audit test records created drafts", async () => { - vi.stubGlobal( - "fetch", - vi.fn(async () => - Response.json({ - number: 501, - html_url: "https://github.com/JSONbored/loopover/issues/501", - }), - ), + stubGitHubIssueCreate(() => + Response.json({ + number: 501, + html_url: "https://github.com/JSONbored/loopover/issues/501", + }), ); - const env = createTestEnv({ LOOPOVER_CONTRIBUTOR_ISSUE_TOKEN: "token" }); + vi.spyOn(repositories, "getRepository").mockResolvedValue(installedRepo("JSONbored/loopover")); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() }); const manifest = { ...LOOPOVER_MANIFEST, wantedPaths: ["src/unique-path-119/"], @@ -283,19 +306,17 @@ describe("contributor issue drafts", () => { }); it("does not let untrusted closed issue markers suppress draft creation", async () => { - vi.stubGlobal( - "fetch", - vi.fn(async () => - Response.json({ - number: 502, - html_url: "https://github.com/other-owner/other-repo/issues/502", - }), - ), + stubGitHubIssueCreate(() => + Response.json({ + number: 502, + html_url: "https://github.com/other-owner/other-repo/issues/502", + }), ); - const env = createTestEnv({ LOOPOVER_CONTRIBUTOR_ISSUE_TOKEN: "token" }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() }); const repoFullName = "other-owner/other-repo"; const fingerprint = await contributorIssueDraftFingerprint(repoFullName, "policy:focus_policy_missing", "policy:focus_policy_missing"); const marker = contributorIssueDraftMarker(fingerprint); + vi.spyOn(repositories, "getRepository").mockResolvedValue(installedRepo(repoFullName)); vi.spyOn(repositories, "listOpenIssues").mockResolvedValue([]); vi.spyOn(repositories, "listClosedContributorDraftIssues").mockResolvedValue([ { @@ -313,15 +334,19 @@ describe("contributor issue drafts", () => { expect(result.drafts[0]?.status).toBe("created"); }); - it("REGRESSION (#audit-rawfetch-pause): the global agent brake / freeze overrides {dryRun:false}, so no contributor issue is filed (raw POST outside the chokepoint)", async () => { + it("REGRESSION (#audit-rawfetch-pause): the global agent brake / freeze overrides {dryRun:false}, so no contributor issue is filed", async () => { const calls: string[] = []; vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { calls.push(`${(init?.method ?? "GET").toUpperCase()} ${String(input)}`); + if (String(input).includes("/access_tokens")) return Response.json({ token: "installation-token" }); return Response.json({ number: 503, html_url: "https://github.com/other-owner/other-repo/issues/503" }); }); const repoFullName = "other-owner/other-repo"; const fingerprint = await contributorIssueDraftFingerprint(repoFullName, "policy:focus_policy_missing", "policy:focus_policy_missing"); const marker = contributorIssueDraftMarker(fingerprint); + // installedRepo() so a gate bug would actually reach (and succeed at) the installation-token mint below -- + // proving the assertion means "the brake stopped it", not "there was never an installation to act through". + vi.spyOn(repositories, "getRepository").mockResolvedValue(installedRepo(repoFullName)); const seedCandidate = () => { vi.spyOn(repositories, "listOpenIssues").mockResolvedValue([]); vi.spyOn(repositories, "listClosedContributorDraftIssues").mockResolvedValue([ @@ -330,16 +355,16 @@ describe("contributor issue drafts", () => { }; // DB-freeze arm: a frozen agent forces dryRun even though the caller asked to create. - const frozenEnv = createTestEnv({ LOOPOVER_CONTRIBUTOR_ISSUE_TOKEN: "token" }); + const frozenEnv = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() }); await repositories.setGlobalAgentFrozen(frozenEnv, true); seedCandidate(); const frozen = await generateContributorIssueDrafts(frozenEnv, repoFullName, { dryRun: false, create: true, limit: 1 }); expect(frozen.dryRun).toBe(true); // global freeze overrode the caller's dryRun:false expect(frozen.created).toBe(0); - expect(calls.some((c) => c.startsWith("POST"))).toBe(false); // no issue POST reached the network + expect(calls.some((c) => c.startsWith("POST"))).toBe(false); // not even an installation-token mint was attempted // env-brake arm: AGENT_ACTIONS_PAUSED short-circuits before the DB freeze read. - const pausedEnv = createTestEnv({ LOOPOVER_CONTRIBUTOR_ISSUE_TOKEN: "token", AGENT_ACTIONS_PAUSED: "true" }); + const pausedEnv = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), AGENT_ACTIONS_PAUSED: "true" }); seedCandidate(); const paused = await generateContributorIssueDrafts(pausedEnv, repoFullName, { dryRun: false, create: true, limit: 1 }); expect(paused.dryRun).toBe(true); @@ -361,8 +386,9 @@ describe("contributor issue drafts", () => { }); it("records skipped_create_failed when GitHub returns a non-ok response", async () => { - vi.stubGlobal("fetch", vi.fn(async () => new Response("nope", { status: 403 }))); - const env = createTestEnv({ LOOPOVER_CONTRIBUTOR_ISSUE_TOKEN: "token" }); + stubGitHubIssueCreate(() => new Response("nope", { status: 403 })); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() }); + vi.spyOn(repositories, "getRepository").mockResolvedValue(installedRepo("JSONbored/loopover")); vi.spyOn(repositories, "listOpenIssues").mockResolvedValue([]); const result = await generateContributorIssueDrafts(env, "JSONbored/loopover", { @@ -375,18 +401,16 @@ describe("contributor issue drafts", () => { }); it("creates issues and records audit metadata when explicit create succeeds", async () => { - vi.stubGlobal( - "fetch", - vi.fn(async () => - Response.json({ - number: 501, - html_url: "https://github.com/JSONbored/loopover/issues/501", - }), - ), + stubGitHubIssueCreate(() => + Response.json({ + number: 501, + html_url: "https://github.com/JSONbored/loopover/issues/501", + }), ); const auditSpy = vi.spyOn(repositories, "recordAuditEvent").mockResolvedValue(undefined); + vi.spyOn(repositories, "getRepository").mockResolvedValue(installedRepo("JSONbored/loopover")); vi.spyOn(repositories, "listOpenIssues").mockResolvedValue([]); - const env = createTestEnv({ LOOPOVER_CONTRIBUTOR_ISSUE_TOKEN: "token" }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() }); const result = await generateContributorIssueDrafts(env, "JSONbored/loopover", { dryRun: false, create: true, @@ -480,8 +504,11 @@ describe("contributor issue drafts", () => { }); it("returns null for invalid repo names when creating GitHub issues", async () => { - vi.stubGlobal("fetch", vi.fn(async () => Response.json({ number: 1, html_url: "https://example.com/1" }))); - const env = createTestEnv({ LOOPOVER_CONTRIBUTOR_ISSUE_TOKEN: "token" }); + stubGitHubIssueCreate(() => Response.json({ number: 1, html_url: "https://example.com/1" })); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() }); + // installedRepo("invalid") so installationId resolves and execution actually reaches createInstallationIssue's + // own parseRepoFullName -- proving THIS malformed-name throw is caught, not merely that no installation exists. + vi.spyOn(repositories, "getRepository").mockResolvedValue(installedRepo("invalid")); vi.spyOn(repositories, "listOpenIssues").mockResolvedValue([]); const result = await generateContributorIssueDrafts(env, "invalid", { dryRun: false, @@ -492,8 +519,9 @@ describe("contributor issue drafts", () => { }); it("treats malformed GitHub create responses as skipped_create_failed", async () => { - vi.stubGlobal("fetch", vi.fn(async () => Response.json({ html_url: "https://github.com/x/y/issues/1" }))); - const env = createTestEnv({ LOOPOVER_CONTRIBUTOR_ISSUE_TOKEN: "token" }); + stubGitHubIssueCreate(() => Response.json({ html_url: "https://github.com/x/y/issues/1" })); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() }); + vi.spyOn(repositories, "getRepository").mockResolvedValue(installedRepo("JSONbored/loopover")); vi.spyOn(repositories, "listOpenIssues").mockResolvedValue([]); const result = await generateContributorIssueDrafts(env, "JSONbored/loopover", { dryRun: false, @@ -525,18 +553,16 @@ describe("contributor issue drafts", () => { }); it("uses default limit and requestedBy when options omit them", async () => { - vi.stubGlobal( - "fetch", - vi.fn(async () => - Response.json({ - number: 502, - html_url: "https://github.com/JSONbored/loopover/issues/502", - }), - ), + stubGitHubIssueCreate(() => + Response.json({ + number: 502, + html_url: "https://github.com/JSONbored/loopover/issues/502", + }), ); const auditSpy = vi.spyOn(repositories, "recordAuditEvent").mockResolvedValue(undefined); + vi.spyOn(repositories, "getRepository").mockResolvedValue(installedRepo("JSONbored/loopover")); vi.spyOn(repositories, "listOpenIssues").mockResolvedValue([]); - const env = createTestEnv({ LOOPOVER_CONTRIBUTOR_ISSUE_TOKEN: "token" }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() }); const result = await generateContributorIssueDrafts(env, "JSONbored/loopover", { dryRun: false, create: true, diff --git a/test/unit/github-issues.test.ts b/test/unit/github-issues.test.ts new file mode 100644 index 0000000000..a9ab95b5a0 --- /dev/null +++ b/test/unit/github-issues.test.ts @@ -0,0 +1,134 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { generateKeyPairSync } from "node:crypto"; +import { clearInstallationTokenCacheForTest } from "../../src/github/app"; +import { createInstallationIssue } from "../../src/github/issues"; +import { createTestEnv } from "../helpers/d1"; + +describe("createInstallationIssue", () => { + afterEach(() => { + vi.unstubAllGlobals(); + clearInstallationTokenCacheForTest(); + }); + + it("rejects invalid repository names before making any GitHub call", async () => { + let called = false; + vi.stubGlobal("fetch", async () => { + called = true; + return Response.json({ token: "t" }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() }); + for (const malformed of ["invalid", "owner/repo/extra", " owner/repo ", "owner/ repo", "owner /repo"]) { + await expect(createInstallationIssue(env, 123, malformed, { title: "t", body: "b" })).rejects.toThrow( + /Invalid repository full name/, + ); + } + expect(called).toBe(false); + }); + + it("creates an issue via the local GitHub App installation-token path", async () => { + const calls: { method: string; url: string; body: unknown }[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + calls.push({ method, url, body: init?.body ? JSON.parse(init.body as string) : undefined }); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.endsWith("/repos/JSONbored/loopover/issues") && method === "POST") { + return Response.json({ number: 501, html_url: "https://github.com/JSONbored/loopover/issues/501" }); + } + return new Response("unexpected", { status: 599 }); + }); + + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() }); + const result = await createInstallationIssue(env, 123, "JSONbored/loopover", { + title: "feat(issues): plan work", + body: "body text", + labels: ["enhancement", "signals"], + }); + + expect(result).toEqual({ number: 501, url: "https://github.com/JSONbored/loopover/issues/501" }); + const createCall = calls.find((call) => call.method === "POST" && call.url.includes("/issues")); + expect((createCall?.body as { labels?: string[] })?.labels).toEqual(["enhancement", "signals"]); + }); + + it("creates an issue via the Orb broker path when no local App key is used (#7425)", async () => { + const calls: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + calls.push(`${method} ${url}`); + if (url === "https://api.loopover.ai/v1/orb/token") { + return Response.json({ token: "brokered-token", installationId: 123, expiresAt: new Date(Date.now() + 3_600_000).toISOString(), permissions: {} }); + } + if (url.endsWith("/repos/JSONbored/loopover/issues") && method === "POST") { + return Response.json({ number: 777, html_url: "https://github.com/JSONbored/loopover/issues/777" }); + } + return new Response("unexpected", { status: 599 }); + }); + + // Broker mode is signaled purely by ORB_ENROLLMENT_SECRET's presence -- it takes priority over any local App + // key, so this proves the SAME call site works unmodified whether the deployment holds an App key or not. + const env = createTestEnv({ ORB_ENROLLMENT_SECRET: "orbsec_test" }); + const result = await createInstallationIssue(env, 123, "JSONbored/loopover", { title: "t", body: "b" }); + + expect(result).toEqual({ number: 777, url: "https://github.com/JSONbored/loopover/issues/777" }); + expect(calls.some((call) => call === "POST https://api.loopover.ai/v1/orb/token")).toBe(true); + }); + + it("omits the labels field when none are provided", async () => { + const calls: { url: string; body: unknown }[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + calls.push({ url, body: init?.body ? JSON.parse(init.body as string) : undefined }); + return Response.json({ number: 1, html_url: "https://github.com/o/r/issues/1" }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() }); + await createInstallationIssue(env, 123, "o/r", { title: "t", body: "b" }); + expect(calls[0]?.body).not.toHaveProperty("labels"); + + await createInstallationIssue(env, 123, "o/r", { title: "t", body: "b", labels: [] }); + expect(calls[1]?.body).not.toHaveProperty("labels"); + }); + + it("returns null when GitHub's response is missing the number or html_url", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + return Response.json({ html_url: "https://github.com/o/r/issues/1" }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() }); + await expect(createInstallationIssue(env, 123, "o/r", { title: "t", body: "b" })).resolves.toBeNull(); + }); + + it("propagates a non-2xx GitHub response instead of swallowing it", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + return Response.json({ message: "Resource not accessible by integration" }, { status: 403 }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() }); + await expect(createInstallationIssue(env, 123, "o/r", { title: "t", body: "b" })).rejects.toMatchObject({ status: 403 }); + }); + + it("suppresses the write and returns null in a non-live mode", async () => { + const calls: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + calls.push(`${method} ${url}`); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + // A non-live mode's Octokit hook must intercept BEFORE the real POST reaches fetch -- any other URL + // reaching here means suppression silently failed. + return new Response("unexpected", { status: 599 }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() }); + const result = await createInstallationIssue(env, 123, "o/r", { title: "t", body: "b" }, "dry_run"); + expect(result).toBeNull(); + expect(calls.some((call) => call.startsWith("POST") && call.includes("/issues"))).toBe(false); + }); +}); + +function generateRsaPrivateKeyPem(): string { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + return privateKey.export({ type: "pkcs1", format: "pem" }).toString(); +} diff --git a/test/unit/routes-contributor-issue-draft.test.ts b/test/unit/routes-contributor-issue-draft.test.ts index 8402aa251c..0dc04801ac 100644 --- a/test/unit/routes-contributor-issue-draft.test.ts +++ b/test/unit/routes-contributor-issue-draft.test.ts @@ -97,7 +97,7 @@ describe("contributor-issue-drafts route auth", () => { it("requires live GitHub write permission before session issue creation", async () => { const app = createApp(); - const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "", LOOPOVER_CONTRIBUTOR_ISSUE_TOKEN: "service-token" }); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); await seedRegisteredInstalledRepo(env, 201, "repo-owner", "owned-repo"); await upsertPullRequestFromGitHub(env, "repo-owner/owned-repo", { number: 5,