From c28fc3f53f0f5786aad9f59b54befae1a6cb9da6 Mon Sep 17 00:00:00 2001 From: jony376 Date: Sat, 4 Jul 2026 23:26:14 -0700 Subject: [PATCH] fix(integrations): reject blank project IDs before GitHub calls GitHubProjectsAdapter.attachToProject and resolveProjectV2Fields now return early when projectId is empty or whitespace-only, avoiding wasted installation-token round-trips and GraphQL errors on malformed input. Co-authored-by: Cursor --- src/integrations/project-tracker-adapter.ts | 2 ++ test/unit/project-tracker-adapter.test.ts | 27 +++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/integrations/project-tracker-adapter.ts b/src/integrations/project-tracker-adapter.ts index 30ce18517b..cc95147b3b 100644 --- a/src/integrations/project-tracker-adapter.ts +++ b/src/integrations/project-tracker-adapter.ts @@ -166,6 +166,7 @@ type PullRequestNodeIdResponse = { * custom field are two independent GraphQL mutations, and this PR only needs the first). */ export async function resolveProjectV2Fields(ctx: ProjectTrackerContext, projectId: string): Promise { + if (typeof projectId !== "string" || projectId.trim().length === 0) return []; const token = await createInstallationToken(ctx.env, ctx.installationId); const octokit = makeInstallationOctokit(ctx.env, token, "live", githubRateLimitAdmissionKeyForInstallation(ctx.installationId)); const response = await octokit.graphql( @@ -226,6 +227,7 @@ export class GitHubProjectsAdapter implements ProjectTrackerAdapter { } async attachToProject(ctx: ProjectTrackerContext, pullNumber: number, projectId: string): Promise { + if (typeof projectId !== "string" || projectId.trim().length === 0) return { attached: false }; const { owner, repo } = parseRepoFullName(ctx.repoFullName); const token = await createInstallationToken(ctx.env, ctx.installationId); const octokit = makeInstallationOctokit(ctx.env, token, "live", githubRateLimitAdmissionKeyForInstallation(ctx.installationId)); diff --git a/test/unit/project-tracker-adapter.test.ts b/test/unit/project-tracker-adapter.test.ts index 64794838d6..05f22dacab 100644 --- a/test/unit/project-tracker-adapter.test.ts +++ b/test/unit/project-tracker-adapter.test.ts @@ -294,6 +294,20 @@ describe("GitHubProjectsAdapter (#3184)", () => { expect(mutationVariables).toEqual({ projectId: "PVT_1", contentId: "PR_kwABC" }); }); + it("attachToProject rejects a blank projectId without calling GitHub", async () => { + let called = false; + vi.stubGlobal("fetch", async () => { + called = true; + return new Response("unexpected", { status: 500 }); + }); + const adapter = new GitHubProjectsAdapter(); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() }); + for (const projectId of ["", " "]) { + await expect(adapter.attachToProject({ env, installationId: 123, repoFullName: "some-org/gittensory" }, 4, projectId)).resolves.toEqual({ attached: false }); + } + expect(called).toBe(false); + }); + it("attachToProject reports not-attached when GitHub returns no item (e.g. a permission/visibility gap)", async () => { vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); @@ -352,6 +366,19 @@ describe("resolveProjectV2Fields (#3184)", () => { ]); }); + it("returns an empty list when projectId is blank without calling GitHub", async () => { + let called = false; + vi.stubGlobal("fetch", async () => { + called = true; + return new Response("unexpected", { status: 500 }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() }); + for (const projectId of ["", " "]) { + await expect(resolveProjectV2Fields({ env, installationId: 123, repoFullName: "some-org/gittensory" }, projectId)).resolves.toEqual([]); + } + expect(called).toBe(false); + }); + it("returns an empty list when the project node has no fields (e.g. not found / inaccessible)", async () => { vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { const url = input.toString();