From f9e6b89fb990049ecb5564739defaca922c2ec6f Mon Sep 17 00:00:00 2001 From: mkdev11 Date: Fri, 8 May 2026 23:24:28 +0200 Subject: [PATCH 1/4] Fetch base content for deleted PR files --- packages/das/package.json | 2 + .../das/src/webhook/github-fetcher.service.ts | 27 ++- .../das/src/webhook/pr-file-contents.spec.ts | 179 ++++++++++++++++++ 3 files changed, 198 insertions(+), 10 deletions(-) create mode 100644 packages/das/src/webhook/pr-file-contents.spec.ts diff --git a/packages/das/package.json b/packages/das/package.json index 36691c0..c189ff1 100644 --- a/packages/das/package.json +++ b/packages/das/package.json @@ -6,6 +6,7 @@ "private": true, "license": "UNLICENSED", "scripts": { + "prebuild": "npm test", "build": "nest build", "format": "prettier --write \"src/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\"", @@ -13,6 +14,7 @@ "dev": "nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", + "test": "node --test -r ts-node/register \"src/**/*.spec.ts\"", "lint": "eslint \"src/**/*.ts\"", "lint:fix": "eslint \"src/**/*.ts\" --fix" }, diff --git a/packages/das/src/webhook/github-fetcher.service.ts b/packages/das/src/webhook/github-fetcher.service.ts index 98c8de4..ceec88e 100644 --- a/packages/das/src/webhook/github-fetcher.service.ts +++ b/packages/das/src/webhook/github-fetcher.service.ts @@ -477,15 +477,19 @@ export class GitHubFetcherService implements OnModuleInit { headSha: string, baseSha: string | null, ): Promise { - // Only fetch contents for files that have a meaningful version to fetch - const scored = files.filter((f) => f.status !== "removed"); - if (scored.length === 0) return; + // Added files have only a head blob; removed files have only a base blob. + // Keep removed files when a base SHA is available so deletion scoring has + // the content that existed before the PR. + const contentFiles = files.filter( + (f) => f.status !== "removed" || !!baseSha, + ); + if (contentFiles.length === 0) return; let batchSize = GRAPHQL_FILES_BATCH_SIZE; const minBatchSize = 5; - for (let i = 0; i < scored.length; ) { - const batch = scored.slice(i, i + batchSize); + for (let i = 0; i < contentFiles.length; ) { + const batch = contentFiles.slice(i, i + batchSize); try { await this.fetchContentBatch( repoFullName, @@ -537,11 +541,14 @@ export class GitHubFetcherService implements OnModuleInit { `base${i}: object(expression: "${baseExpr}") { ... on Blob { text byteSize isBinary } }`, ); } - // Head version (already filtered out removed files at caller) - const headExpr = this.escapeGraphql(`${headSha}:${file.filename}`); - fields.push( - `head${i}: object(expression: "${headExpr}") { ... on Blob { text byteSize isBinary } }`, - ); + // Removed files do not exist at head; store a null headContent while + // still fetching the base blob above. + if (file.status !== "removed") { + const headExpr = this.escapeGraphql(`${headSha}:${file.filename}`); + fields.push( + `head${i}: object(expression: "${headExpr}") { ... on Blob { text byteSize isBinary } }`, + ); + } } const query = ` diff --git a/packages/das/src/webhook/pr-file-contents.spec.ts b/packages/das/src/webhook/pr-file-contents.spec.ts new file mode 100644 index 0000000..dfaf735 --- /dev/null +++ b/packages/das/src/webhook/pr-file-contents.spec.ts @@ -0,0 +1,179 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument */ +import assert from "node:assert/strict"; +import test from "node:test"; +import { GitHubFetcherService } from "./github-fetcher.service"; + +type MockRepo> = { + row: T | null; + deletes: unknown[]; + upserts: Record[]; + updates: unknown[]; + repo: { + findOneBy: (criteria: Record) => Promise; + update: ( + criteria: Record | string, + patch: Record, + ) => Promise<{ affected: number }>; + upsert: ( + data: Record, + conflictPaths?: string[], + ) => Promise; + delete: (criteria: Record) => Promise; + }; +}; + +function createMockRepo>( + initialRow: T | null = null, +): MockRepo { + const mock: MockRepo = { + row: initialRow, + deletes: [], + upserts: [], + updates: [], + repo: { + findOneBy: (criteria: Record): Promise => + Promise.resolve(matchesCriteria(mock.row, criteria) ? mock.row : null), + update: ( + criteria: Record | string, + patch: Record, + ): Promise<{ affected: number }> => { + mock.updates.push({ criteria, patch }); + if ( + mock.row && + (typeof criteria === "string" || matchesCriteria(mock.row, criteria)) + ) { + Object.assign(mock.row, patch); + return Promise.resolve({ affected: 1 }); + } + return Promise.resolve({ affected: 0 }); + }, + upsert: (data: Record): Promise => { + mock.upserts.push(data); + mock.row = { ...mock.row, ...data } as T; + return Promise.resolve(); + }, + delete: (criteria: Record): Promise => { + mock.deletes.push(criteria); + return Promise.resolve(); + }, + }, + }; + + return mock; +} + +function matchesCriteria>( + row: T | null, + criteria: Record, +): boolean { + if (!row) return false; + return Object.entries(criteria).every(([key, value]) => row[key] === value); +} + +function createFetcher( + prFileRepo: MockRepo, + prFileContentRepo: MockRepo, + prRepo: MockRepo, +): GitHubFetcherService { + const otherRepo = createMockRepo(); + const config = { + getOrThrow: (key: string): string => + key === "GITHUB_APP_ID" ? "123" : "/tmp/private-key.pem", + }; + + return new GitHubFetcherService( + config as any, + prFileRepo.repo as any, + prFileContentRepo.repo as any, + prRepo.repo as any, + otherRepo.repo as any, + otherRepo.repo as any, + otherRepo.repo as any, + otherRepo.repo as any, + ); +} + +void test("fetchAndStorePrFiles stores base content for removed files", async () => { + const prFileRepo = createMockRepo(); + const prFileContentRepo = createMockRepo(); + const prRepo = createMockRepo({ + repoFullName: "owner/repo", + prNumber: 7, + headSha: "H1", + baseSha: "B1", + mergeBaseSha: null, + }); + const fetcher = createFetcher(prFileRepo, prFileContentRepo, prRepo); + + (fetcher as any).getTokenForRepo = (): Promise => + Promise.resolve("token"); + (fetcher as any).fetchMergeBaseSha = (): Promise => + Promise.resolve("M1"); + (fetcher as any).fetchAllPrFiles = (): Promise => + Promise.resolve([ + { + filename: "src/old.ts", + status: "removed", + additions: 0, + deletions: 20, + changes: 20, + }, + ]); + + let graphQlCalls = 0; + (fetcher as any).githubFetch = ( + _url: string, + init: { body?: string }, + ): Promise => { + graphQlCalls++; + const body = JSON.parse(init.body ?? "{}") as { query?: string }; + assert.match( + body.query ?? "", + /base0: object\(expression: "M1:src\/old\.ts"\)/, + ); + assert.doesNotMatch(body.query ?? "", /head0:/); + + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + data: { + repository: { + base0: { + text: "const deleted = true;\n", + byteSize: 22, + isBinary: false, + }, + }, + }, + }), + }); + }; + + await fetcher.fetchAndStorePrFiles("owner/repo", 7); + + assert.equal(graphQlCalls, 1); + assert.deepEqual(prFileRepo.upserts, [ + { + repoFullName: "owner/repo", + prNumber: 7, + filename: "src/old.ts", + previousFilename: null, + status: "removed", + additions: 0, + deletions: 20, + changes: 20, + }, + ]); + assert.deepEqual(prFileContentRepo.upserts, [ + { + repoFullName: "owner/repo", + prNumber: 7, + filename: "src/old.ts", + baseContent: "const deleted = true;\n", + headContent: null, + isBinary: false, + byteSize: 22, + }, + ]); +}); From f02c3b0c011ef345c44a95cf93c88efc8399b417 Mon Sep 17 00:00:00 2001 From: mkdev11 Date: Sat, 9 May 2026 01:53:24 +0200 Subject: [PATCH 2/4] Tighten deleted file content regression --- packages/das/package.json | 2 - .../webhook/github-fetcher.service.spec.ts | 161 ++++++++++++++++ .../das/src/webhook/github-fetcher.service.ts | 2 +- .../das/src/webhook/pr-file-contents.spec.ts | 179 ------------------ 4 files changed, 162 insertions(+), 182 deletions(-) create mode 100644 packages/das/src/webhook/github-fetcher.service.spec.ts delete mode 100644 packages/das/src/webhook/pr-file-contents.spec.ts diff --git a/packages/das/package.json b/packages/das/package.json index c189ff1..36691c0 100644 --- a/packages/das/package.json +++ b/packages/das/package.json @@ -6,7 +6,6 @@ "private": true, "license": "UNLICENSED", "scripts": { - "prebuild": "npm test", "build": "nest build", "format": "prettier --write \"src/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\"", @@ -14,7 +13,6 @@ "dev": "nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", - "test": "node --test -r ts-node/register \"src/**/*.spec.ts\"", "lint": "eslint \"src/**/*.ts\"", "lint:fix": "eslint \"src/**/*.ts\" --fix" }, diff --git a/packages/das/src/webhook/github-fetcher.service.spec.ts b/packages/das/src/webhook/github-fetcher.service.spec.ts new file mode 100644 index 0000000..6adf472 --- /dev/null +++ b/packages/das/src/webhook/github-fetcher.service.spec.ts @@ -0,0 +1,161 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { ConfigService } from "@nestjs/config"; +import type { Repository } from "typeorm"; +import { + Issue, + LabelEvent, + PrFile, + PrFileContent, + PullRequest, + Repo, + Review, +} from "../entities"; +import { GitHubFetcherService } from "./github-fetcher.service"; + +type PullFile = { + filename: string; + previous_filename?: string; + status: string; +}; + +type FetcherInternals = { + fetchAndStoreBatchedContents( + repoFullName: string, + prNumber: number, + files: PullFile[], + owner: string, + repo: string, + token: string, + headSha: string, + baseSha: string | null, + ): Promise; + githubFetch(url: string, init: RequestInit): Promise; +}; + +const emptyRepo = (): Repository => ({}) as Repository; + +function createService( + onContentUpsert: (content: Partial) => void, +): FetcherInternals { + const contentRepo = { + upsert: (content: Partial): Promise => { + onContentUpsert(content); + return Promise.resolve(); + }, + } as unknown as Repository; + + return new GitHubFetcherService( + { getOrThrow: (): string => "123" } as unknown as ConfigService, + emptyRepo(), + contentRepo, + emptyRepo(), + emptyRepo(), + emptyRepo(), + emptyRepo(), + emptyRepo(), + ) as unknown as FetcherInternals; +} + +function graphqlQueryFrom(init: RequestInit): string { + const body = init.body; + assert.equal(typeof body, "string"); + if (typeof body !== "string") { + throw new Error("Expected GraphQL request body to be a string"); + } + + const parsed: unknown = JSON.parse(body); + assert.equal(typeof parsed, "object"); + assert.notEqual(parsed, null); + + const query = (parsed as { query?: unknown }).query; + assert.equal(typeof query, "string"); + if (typeof query !== "string") { + throw new Error("Expected GraphQL request body to include a query"); + } + + return query; +} + +void test("fetchAndStoreBatchedContents stores only base content for removed files", async () => { + let graphQlCalls = 0; + let storedContent: Partial | null = null; + const service = createService((content) => { + storedContent = content; + }); + + service.githubFetch = ( + _url: string, + init: RequestInit, + ): Promise => { + graphQlCalls++; + const query = graphqlQueryFrom(init); + assert.match(query, /base0: object\(expression: "BASE:src\/old\.ts"\)/); + assert.doesNotMatch(query, /head0:/); + + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + data: { + repository: { + base0: { + text: "export const removed = true;\n", + byteSize: 29, + isBinary: false, + }, + }, + }, + }), + } as Response); + }; + + await service.fetchAndStoreBatchedContents( + "owner/repo", + 7, + [{ filename: "src/old.ts", status: "removed" }], + "owner", + "repo", + "token", + "HEAD", + "BASE", + ); + + assert.equal(graphQlCalls, 1); + assert.deepEqual(storedContent, { + repoFullName: "owner/repo", + prNumber: 7, + filename: "src/old.ts", + baseContent: "export const removed = true;\n", + headContent: null, + isBinary: false, + byteSize: 29, + }); +}); + +void test("fetchAndStoreBatchedContents skips removed files without a base SHA", async () => { + let graphQlCalls = 0; + let storedContent: Partial | null = null; + const service = createService((content) => { + storedContent = content; + }); + + service.githubFetch = (): Promise => { + graphQlCalls++; + throw new Error("Removed files without a base SHA should not be fetched"); + }; + + await service.fetchAndStoreBatchedContents( + "owner/repo", + 7, + [{ filename: "src/old.ts", status: "removed" }], + "owner", + "repo", + "token", + "HEAD", + null, + ); + + assert.equal(graphQlCalls, 0); + assert.equal(storedContent, null); +}); diff --git a/packages/das/src/webhook/github-fetcher.service.ts b/packages/das/src/webhook/github-fetcher.service.ts index ceec88e..f356ce7 100644 --- a/packages/das/src/webhook/github-fetcher.service.ts +++ b/packages/das/src/webhook/github-fetcher.service.ts @@ -481,7 +481,7 @@ export class GitHubFetcherService implements OnModuleInit { // Keep removed files when a base SHA is available so deletion scoring has // the content that existed before the PR. const contentFiles = files.filter( - (f) => f.status !== "removed" || !!baseSha, + (f) => f.status !== "removed" || baseSha !== null, ); if (contentFiles.length === 0) return; diff --git a/packages/das/src/webhook/pr-file-contents.spec.ts b/packages/das/src/webhook/pr-file-contents.spec.ts deleted file mode 100644 index dfaf735..0000000 --- a/packages/das/src/webhook/pr-file-contents.spec.ts +++ /dev/null @@ -1,179 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument */ -import assert from "node:assert/strict"; -import test from "node:test"; -import { GitHubFetcherService } from "./github-fetcher.service"; - -type MockRepo> = { - row: T | null; - deletes: unknown[]; - upserts: Record[]; - updates: unknown[]; - repo: { - findOneBy: (criteria: Record) => Promise; - update: ( - criteria: Record | string, - patch: Record, - ) => Promise<{ affected: number }>; - upsert: ( - data: Record, - conflictPaths?: string[], - ) => Promise; - delete: (criteria: Record) => Promise; - }; -}; - -function createMockRepo>( - initialRow: T | null = null, -): MockRepo { - const mock: MockRepo = { - row: initialRow, - deletes: [], - upserts: [], - updates: [], - repo: { - findOneBy: (criteria: Record): Promise => - Promise.resolve(matchesCriteria(mock.row, criteria) ? mock.row : null), - update: ( - criteria: Record | string, - patch: Record, - ): Promise<{ affected: number }> => { - mock.updates.push({ criteria, patch }); - if ( - mock.row && - (typeof criteria === "string" || matchesCriteria(mock.row, criteria)) - ) { - Object.assign(mock.row, patch); - return Promise.resolve({ affected: 1 }); - } - return Promise.resolve({ affected: 0 }); - }, - upsert: (data: Record): Promise => { - mock.upserts.push(data); - mock.row = { ...mock.row, ...data } as T; - return Promise.resolve(); - }, - delete: (criteria: Record): Promise => { - mock.deletes.push(criteria); - return Promise.resolve(); - }, - }, - }; - - return mock; -} - -function matchesCriteria>( - row: T | null, - criteria: Record, -): boolean { - if (!row) return false; - return Object.entries(criteria).every(([key, value]) => row[key] === value); -} - -function createFetcher( - prFileRepo: MockRepo, - prFileContentRepo: MockRepo, - prRepo: MockRepo, -): GitHubFetcherService { - const otherRepo = createMockRepo(); - const config = { - getOrThrow: (key: string): string => - key === "GITHUB_APP_ID" ? "123" : "/tmp/private-key.pem", - }; - - return new GitHubFetcherService( - config as any, - prFileRepo.repo as any, - prFileContentRepo.repo as any, - prRepo.repo as any, - otherRepo.repo as any, - otherRepo.repo as any, - otherRepo.repo as any, - otherRepo.repo as any, - ); -} - -void test("fetchAndStorePrFiles stores base content for removed files", async () => { - const prFileRepo = createMockRepo(); - const prFileContentRepo = createMockRepo(); - const prRepo = createMockRepo({ - repoFullName: "owner/repo", - prNumber: 7, - headSha: "H1", - baseSha: "B1", - mergeBaseSha: null, - }); - const fetcher = createFetcher(prFileRepo, prFileContentRepo, prRepo); - - (fetcher as any).getTokenForRepo = (): Promise => - Promise.resolve("token"); - (fetcher as any).fetchMergeBaseSha = (): Promise => - Promise.resolve("M1"); - (fetcher as any).fetchAllPrFiles = (): Promise => - Promise.resolve([ - { - filename: "src/old.ts", - status: "removed", - additions: 0, - deletions: 20, - changes: 20, - }, - ]); - - let graphQlCalls = 0; - (fetcher as any).githubFetch = ( - _url: string, - init: { body?: string }, - ): Promise => { - graphQlCalls++; - const body = JSON.parse(init.body ?? "{}") as { query?: string }; - assert.match( - body.query ?? "", - /base0: object\(expression: "M1:src\/old\.ts"\)/, - ); - assert.doesNotMatch(body.query ?? "", /head0:/); - - return Promise.resolve({ - ok: true, - json: () => - Promise.resolve({ - data: { - repository: { - base0: { - text: "const deleted = true;\n", - byteSize: 22, - isBinary: false, - }, - }, - }, - }), - }); - }; - - await fetcher.fetchAndStorePrFiles("owner/repo", 7); - - assert.equal(graphQlCalls, 1); - assert.deepEqual(prFileRepo.upserts, [ - { - repoFullName: "owner/repo", - prNumber: 7, - filename: "src/old.ts", - previousFilename: null, - status: "removed", - additions: 0, - deletions: 20, - changes: 20, - }, - ]); - assert.deepEqual(prFileContentRepo.upserts, [ - { - repoFullName: "owner/repo", - prNumber: 7, - filename: "src/old.ts", - baseContent: "const deleted = true;\n", - headContent: null, - isBinary: false, - byteSize: 22, - }, - ]); -}); From a90fc046058e0aafaac564f77a609d485b15c521 Mon Sep 17 00:00:00 2001 From: mkdev11 Date: Sat, 9 May 2026 01:56:49 +0200 Subject: [PATCH 3/4] Strengthen deleted file content coverage --- .../webhook/github-fetcher.service.spec.ts | 69 +++++++++++++++---- 1 file changed, 56 insertions(+), 13 deletions(-) diff --git a/packages/das/src/webhook/github-fetcher.service.spec.ts b/packages/das/src/webhook/github-fetcher.service.spec.ts index 6adf472..3824069 100644 --- a/packages/das/src/webhook/github-fetcher.service.spec.ts +++ b/packages/das/src/webhook/github-fetcher.service.spec.ts @@ -77,11 +77,11 @@ function graphqlQueryFrom(init: RequestInit): string { return query; } -void test("fetchAndStoreBatchedContents stores only base content for removed files", async () => { +void test("fetchAndStoreBatchedContents stores mixed file-status contents", async () => { let graphQlCalls = 0; - let storedContent: Partial | null = null; + const storedContents: Partial[] = []; const service = createService((content) => { - storedContent = content; + storedContents.push(content); }); service.githubFetch = ( @@ -91,6 +91,10 @@ void test("fetchAndStoreBatchedContents stores only base content for removed fil graphQlCalls++; const query = graphqlQueryFrom(init); assert.match(query, /base0: object\(expression: "BASE:src\/old\.ts"\)/); + assert.doesNotMatch(query, /base1:/); + assert.match(query, /head1: object\(expression: "HEAD:src\/new\.ts"\)/); + assert.match(query, /base2: object\(expression: "BASE:src\/live\.ts"\)/); + assert.match(query, /head2: object\(expression: "HEAD:src\/live\.ts"\)/); assert.doesNotMatch(query, /head0:/); return Promise.resolve({ @@ -104,6 +108,21 @@ void test("fetchAndStoreBatchedContents stores only base content for removed fil byteSize: 29, isBinary: false, }, + head1: { + text: "export const added = true;\n", + byteSize: 27, + isBinary: false, + }, + base2: { + text: "export const live = false;\n", + byteSize: 27, + isBinary: false, + }, + head2: { + text: "export const live = true;\n", + byteSize: 26, + isBinary: false, + }, }, }, }), @@ -113,7 +132,11 @@ void test("fetchAndStoreBatchedContents stores only base content for removed fil await service.fetchAndStoreBatchedContents( "owner/repo", 7, - [{ filename: "src/old.ts", status: "removed" }], + [ + { filename: "src/old.ts", status: "removed" }, + { filename: "src/new.ts", status: "added" }, + { filename: "src/live.ts", status: "modified" }, + ], "owner", "repo", "token", @@ -122,15 +145,35 @@ void test("fetchAndStoreBatchedContents stores only base content for removed fil ); assert.equal(graphQlCalls, 1); - assert.deepEqual(storedContent, { - repoFullName: "owner/repo", - prNumber: 7, - filename: "src/old.ts", - baseContent: "export const removed = true;\n", - headContent: null, - isBinary: false, - byteSize: 29, - }); + assert.deepEqual(storedContents, [ + { + repoFullName: "owner/repo", + prNumber: 7, + filename: "src/old.ts", + baseContent: "export const removed = true;\n", + headContent: null, + isBinary: false, + byteSize: 29, + }, + { + repoFullName: "owner/repo", + prNumber: 7, + filename: "src/new.ts", + baseContent: null, + headContent: "export const added = true;\n", + isBinary: false, + byteSize: 27, + }, + { + repoFullName: "owner/repo", + prNumber: 7, + filename: "src/live.ts", + baseContent: "export const live = false;\n", + headContent: "export const live = true;\n", + isBinary: false, + byteSize: 26, + }, + ]); }); void test("fetchAndStoreBatchedContents skips removed files without a base SHA", async () => { From c0945ea2def314092bc0ea2c61f52bc89a426d0e Mon Sep 17 00:00:00 2001 From: mkdev11 Date: Mon, 11 May 2026 00:29:04 +0200 Subject: [PATCH 4/4] Drop contributor fetcher test --- .../webhook/github-fetcher.service.spec.ts | 204 ------------------ 1 file changed, 204 deletions(-) delete mode 100644 packages/das/src/webhook/github-fetcher.service.spec.ts diff --git a/packages/das/src/webhook/github-fetcher.service.spec.ts b/packages/das/src/webhook/github-fetcher.service.spec.ts deleted file mode 100644 index 3824069..0000000 --- a/packages/das/src/webhook/github-fetcher.service.spec.ts +++ /dev/null @@ -1,204 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import type { ConfigService } from "@nestjs/config"; -import type { Repository } from "typeorm"; -import { - Issue, - LabelEvent, - PrFile, - PrFileContent, - PullRequest, - Repo, - Review, -} from "../entities"; -import { GitHubFetcherService } from "./github-fetcher.service"; - -type PullFile = { - filename: string; - previous_filename?: string; - status: string; -}; - -type FetcherInternals = { - fetchAndStoreBatchedContents( - repoFullName: string, - prNumber: number, - files: PullFile[], - owner: string, - repo: string, - token: string, - headSha: string, - baseSha: string | null, - ): Promise; - githubFetch(url: string, init: RequestInit): Promise; -}; - -const emptyRepo = (): Repository => ({}) as Repository; - -function createService( - onContentUpsert: (content: Partial) => void, -): FetcherInternals { - const contentRepo = { - upsert: (content: Partial): Promise => { - onContentUpsert(content); - return Promise.resolve(); - }, - } as unknown as Repository; - - return new GitHubFetcherService( - { getOrThrow: (): string => "123" } as unknown as ConfigService, - emptyRepo(), - contentRepo, - emptyRepo(), - emptyRepo(), - emptyRepo(), - emptyRepo(), - emptyRepo(), - ) as unknown as FetcherInternals; -} - -function graphqlQueryFrom(init: RequestInit): string { - const body = init.body; - assert.equal(typeof body, "string"); - if (typeof body !== "string") { - throw new Error("Expected GraphQL request body to be a string"); - } - - const parsed: unknown = JSON.parse(body); - assert.equal(typeof parsed, "object"); - assert.notEqual(parsed, null); - - const query = (parsed as { query?: unknown }).query; - assert.equal(typeof query, "string"); - if (typeof query !== "string") { - throw new Error("Expected GraphQL request body to include a query"); - } - - return query; -} - -void test("fetchAndStoreBatchedContents stores mixed file-status contents", async () => { - let graphQlCalls = 0; - const storedContents: Partial[] = []; - const service = createService((content) => { - storedContents.push(content); - }); - - service.githubFetch = ( - _url: string, - init: RequestInit, - ): Promise => { - graphQlCalls++; - const query = graphqlQueryFrom(init); - assert.match(query, /base0: object\(expression: "BASE:src\/old\.ts"\)/); - assert.doesNotMatch(query, /base1:/); - assert.match(query, /head1: object\(expression: "HEAD:src\/new\.ts"\)/); - assert.match(query, /base2: object\(expression: "BASE:src\/live\.ts"\)/); - assert.match(query, /head2: object\(expression: "HEAD:src\/live\.ts"\)/); - assert.doesNotMatch(query, /head0:/); - - return Promise.resolve({ - ok: true, - json: () => - Promise.resolve({ - data: { - repository: { - base0: { - text: "export const removed = true;\n", - byteSize: 29, - isBinary: false, - }, - head1: { - text: "export const added = true;\n", - byteSize: 27, - isBinary: false, - }, - base2: { - text: "export const live = false;\n", - byteSize: 27, - isBinary: false, - }, - head2: { - text: "export const live = true;\n", - byteSize: 26, - isBinary: false, - }, - }, - }, - }), - } as Response); - }; - - await service.fetchAndStoreBatchedContents( - "owner/repo", - 7, - [ - { filename: "src/old.ts", status: "removed" }, - { filename: "src/new.ts", status: "added" }, - { filename: "src/live.ts", status: "modified" }, - ], - "owner", - "repo", - "token", - "HEAD", - "BASE", - ); - - assert.equal(graphQlCalls, 1); - assert.deepEqual(storedContents, [ - { - repoFullName: "owner/repo", - prNumber: 7, - filename: "src/old.ts", - baseContent: "export const removed = true;\n", - headContent: null, - isBinary: false, - byteSize: 29, - }, - { - repoFullName: "owner/repo", - prNumber: 7, - filename: "src/new.ts", - baseContent: null, - headContent: "export const added = true;\n", - isBinary: false, - byteSize: 27, - }, - { - repoFullName: "owner/repo", - prNumber: 7, - filename: "src/live.ts", - baseContent: "export const live = false;\n", - headContent: "export const live = true;\n", - isBinary: false, - byteSize: 26, - }, - ]); -}); - -void test("fetchAndStoreBatchedContents skips removed files without a base SHA", async () => { - let graphQlCalls = 0; - let storedContent: Partial | null = null; - const service = createService((content) => { - storedContent = content; - }); - - service.githubFetch = (): Promise => { - graphQlCalls++; - throw new Error("Removed files without a base SHA should not be fetched"); - }; - - await service.fetchAndStoreBatchedContents( - "owner/repo", - 7, - [{ filename: "src/old.ts", status: "removed" }], - "owner", - "repo", - "token", - "HEAD", - null, - ); - - assert.equal(graphQlCalls, 0); - assert.equal(storedContent, null); -});