diff --git a/src/__tests__/fixtures/mock-deps.ts b/src/__tests__/fixtures/mock-deps.ts index 316622a9..4fd4c836 100644 --- a/src/__tests__/fixtures/mock-deps.ts +++ b/src/__tests__/fixtures/mock-deps.ts @@ -1,22 +1,31 @@ import { jest } from "@jest/globals"; -import { Dependencies, OctokitClient, Logger } from "../../types/Dependencies.js"; +import { + Dependencies, + OctokitClient, + Logger, +} from "../../types/Dependencies.js"; - -export function createMockLogger(): Logger & { [K in keyof Logger]: jest.Mock } { - return { - info: jest.fn(), - error: jest.fn(), - warning: jest.fn(), - debug: jest.fn(), - }; - } +export function createMockLogger(): Logger & { + [K in keyof Logger]: jest.Mock; +} { + return { + info: jest.fn(), + error: jest.fn(), + warning: jest.fn(), + debug: jest.fn(), + }; +} // creates a mock OctokitClient with sensible defaults -export function createMockOctokit(overrides: Partial> = {}): OctokitClient { +export function createMockOctokit( + overrides: Partial> = {}, +): OctokitClient { return { rest: { repos: { - get: overrides.rest?.repos?.get as OctokitClient["rest"]["repos"]["get"] ?? + get: + (overrides.rest?.repos + ?.get as OctokitClient["rest"]["repos"]["get"]) ?? jest.fn().mockResolvedValue({ data: { name: "test-repo", @@ -28,23 +37,32 @@ export function createMockOctokit(overrides: Partial> created_at: "2024-01-01T00:00:00Z", updated_at: "2024-06-01T00:00:00Z", default_branch: "main", + fork: false, + parent: null, }, }), - listLanguages: overrides.rest?.repos?.listLanguages as OctokitClient["rest"]["repos"]["listLanguages"] ?? + listLanguages: + (overrides.rest?.repos + ?.listLanguages as OctokitClient["rest"]["repos"]["listLanguages"]) ?? jest.fn().mockResolvedValue({ data: { TypeScript: 5000, JavaScript: 2000 }, }), - getContent: overrides.rest?.repos?.getContent as OctokitClient["rest"]["repos"]["getContent"] ?? + getContent: + (overrides.rest?.repos + ?.getContent as OctokitClient["rest"]["repos"]["getContent"]) ?? jest.fn().mockResolvedValue({ data: { sha: "abc123" }, }), - createOrUpdateFileContents: overrides.rest?.repos?.createOrUpdateFileContents as OctokitClient["rest"]["repos"]["createOrUpdateFileContents"] ?? + createOrUpdateFileContents: + (overrides.rest?.repos + ?.createOrUpdateFileContents as OctokitClient["rest"]["repos"]["createOrUpdateFileContents"]) ?? jest.fn().mockResolvedValue({ data: { commit: { sha: "def456" } }, }), }, }, - createPullRequest: overrides.createPullRequest as OctokitClient["createPullRequest"] ?? + createPullRequest: + (overrides.createPullRequest as OctokitClient["createPullRequest"]) ?? jest.fn().mockResolvedValue({ data: { html_url: "https://github.com/test-owner/test-repo/pull/1" }, }), @@ -52,7 +70,9 @@ export function createMockOctokit(overrides: Partial> } // creates a full mock Dependencies object with sensible defaults -export function createMockDeps(overrides: Partial = {}): Dependencies { +export function createMockDeps( + overrides: Partial = {}, +): Dependencies { const mockOctokit = createMockOctokit(); return { @@ -84,4 +104,4 @@ export function createMockDeps(overrides: Partial = {}): Dependenc // helper type for deep partial overrides type DeepPartial = { [P in keyof T]?: T[P] extends object ? DeepPartial : T[P]; -}; \ No newline at end of file +}; diff --git a/src/__tests__/unit/helper.test.ts b/src/__tests__/unit/helper.test.ts index 04af7bbe..74d05ae6 100644 --- a/src/__tests__/unit/helper.test.ts +++ b/src/__tests__/unit/helper.test.ts @@ -37,7 +37,9 @@ describe("createHelpers - calculateMetaData", () => { expect(result.name).toBe("test-repo"); expect(result.description).toBe("A test repository"); - expect(result.repositoryURL).toBe("https://github.com/test-owner/test-repo"); + expect(result.repositoryURL).toBe( + "https://github.com/test-owner/test-repo", + ); expect(result.repositoryVisibility).toBe("public"); expect(result.languages).toEqual(["TypeScript", "JavaScript"]); expect(result.laborHours).toBeGreaterThan(0); @@ -193,7 +195,9 @@ describe("createHelpers - pushDirectlyWithFallback", () => { await helpers.pushDirectlyWithFallback({ name: "test" } as any, "main"); - expect(adminOctokit.rest.repos.createOrUpdateFileContents).toHaveBeenCalled(); + expect( + adminOctokit.rest.repos.createOrUpdateFileContents, + ).toHaveBeenCalled(); expect(deps.setOutput).toHaveBeenCalledWith("method_used", "direct_push"); }); }); @@ -215,7 +219,11 @@ describe("createHelpers - validateOnly", () => { it("succeeds for valid code.json", async () => { const validCodeJSON = await import("../fixtures/test-code.json"); const deps = createMockDeps({ - readFile: jest.fn().mockResolvedValue(JSON.stringify(validCodeJSON.default ?? validCodeJSON)), + readFile: jest + .fn() + .mockResolvedValue( + JSON.stringify(validCodeJSON.default ?? validCodeJSON), + ), }); const helpers = createHelpers(deps); @@ -383,6 +391,60 @@ describe("createHelpers - detectReusedCode", () => { }); }); +describe("createHelpers - detectForkParent", () => { + function mockRepoGet(data: Record): Dependencies { + return createMockDeps({ + octokit: createMockOctokit({ + rest: { + repos: { + get: jest.fn().mockResolvedValue({ data }), + }, + }, + }), + }); + } + + it("returns the upstream parent when the repo is a fork", async () => { + const deps = mockRepoGet({ + fork: true, + parent: { + full_name: "upstream-owner/upstream-repo", + html_url: "https://github.com/upstream-owner/upstream-repo", + }, + }); + + expect(await createHelpers(deps).detectForkParent()).toEqual({ + name: "upstream-owner/upstream-repo", + URL: "https://github.com/upstream-owner/upstream-repo", + }); + }); + + it("returns null when the repo is not a fork", async () => { + const deps = mockRepoGet({ fork: false, parent: null }); + expect(await createHelpers(deps).detectForkParent()).toBeNull(); + }); + + it("returns null when fork is true but parent is missing", async () => { + const deps = mockRepoGet({ fork: true }); + expect(await createHelpers(deps).detectForkParent()).toBeNull(); + }); + + it("returns null and logs when the API call fails", async () => { + const deps = createMockDeps({ + octokit: createMockOctokit({ + rest: { + repos: { + get: jest.fn().mockRejectedValue(new Error("API down")), + }, + }, + }), + }); + + expect(await createHelpers(deps).detectForkParent()).toBeNull(); + expect(deps.log.error).toHaveBeenCalled(); + }); +}); + describe("mergeReusedCode", () => { it("appends detected entries to existing ones", () => { const existing = [{ name: "Other Gov Tool", URL: "https://example.gov" }]; @@ -417,4 +479,4 @@ describe("GOV_DEPENDENCIES integrity", () => { expect(entry.name.trim()).not.toBe(""); expect(entry.URL).toMatch(/^https:\/\//); }); -}); \ No newline at end of file +}); diff --git a/src/__tests__/unit/main.test.ts b/src/__tests__/unit/main.test.ts index 39a5b186..475666be 100644 --- a/src/__tests__/unit/main.test.ts +++ b/src/__tests__/unit/main.test.ts @@ -1,12 +1,23 @@ -import { describe, it, expect, jest, beforeEach, afterEach } from "@jest/globals"; +import { + describe, + it, + expect, + jest, + beforeEach, + afterEach, +} from "@jest/globals"; import { runWithDeps, filterValidFields, getMetaData } from "../../main.js"; import { createHelpers } from "../../helper.js"; -import { createMockDeps } from "../fixtures/mock-deps.js"; +import { createMockDeps, createMockOctokit } from "../fixtures/mock-deps.js"; import validCodeJSON from "../fixtures/test-code.json"; describe("filterValidFields", () => { it("keeps known fields", () => { - const result = filterValidFields({ name: "test", version: "1.0", description: "hi" }); + const result = filterValidFields({ + name: "test", + version: "1.0", + description: "hi", + }); expect(result).toHaveProperty("name", "test"); expect(result).toHaveProperty("version", "1.0"); }); @@ -23,10 +34,15 @@ describe("getMetaData", () => { const deps = createMockDeps(); const helpers = createHelpers(deps); - const existing = { ...validCodeJSON, feedbackMechanism: "https://custom.example.com/feedback" } as any; + const existing = { + ...validCodeJSON, + feedbackMechanism: "https://custom.example.com/feedback", + } as any; const result = await getMetaData(helpers, deps, existing); - expect(result.feedbackMechanism).toBe("https://custom.example.com/feedback"); + expect(result.feedbackMechanism).toBe( + "https://custom.example.com/feedback", + ); }); it("defaults feedbackMechanism to issues URL", async () => { @@ -57,6 +73,42 @@ describe("getMetaData", () => { expect(result.contractNumber).toEqual(["LEGACY-001"]); }); + + it("adds the fork upstream to reusedCode", async () => { + const forkOctokit = createMockOctokit({ + rest: { + repos: { + get: jest.fn().mockResolvedValue({ + data: { + name: "test-repo", + description: "A forked repository", + html_url: "https://github.com/test-owner/test-repo", + private: false, + forks_count: 0, + topics: [], + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-06-01T00:00:00Z", + default_branch: "main", + fork: true, + parent: { + full_name: "upstream-owner/upstream-repo", + html_url: "https://github.com/upstream-owner/upstream-repo", + }, + }, + }), + }, + }, + }); + const deps = createMockDeps({ octokit: forkOctokit }); + const helpers = createHelpers(deps); + + const result = await getMetaData(helpers, deps, null); + + expect(result.reusedCode).toContainEqual({ + name: "upstream-owner/upstream-repo", + URL: "https://github.com/upstream-owner/upstream-repo", + }); + }); }); describe("runWithDeps", () => { @@ -106,9 +158,13 @@ describe("runWithDeps", () => { const adminOctokit = { rest: { repos: { - get: jest.fn().mockResolvedValue({ data: { default_branch: "main" } }), + get: jest + .fn() + .mockResolvedValue({ data: { default_branch: "main" } }), listLanguages: jest.fn().mockResolvedValue({ data: {} }), - getContent: jest.fn().mockResolvedValue({ data: { sha: "abc" } }), + getContent: jest + .fn() + .mockResolvedValue({ data: { sha: "abc" } }), createOrUpdateFileContents: jest.fn().mockResolvedValue({ data: { commit: { sha: "pushed123" } }, }), @@ -125,7 +181,9 @@ describe("runWithDeps", () => { await runWithDeps(deps); - expect(adminOctokit.rest.repos.createOrUpdateFileContents).toHaveBeenCalled(); + expect( + adminOctokit.rest.repos.createOrUpdateFileContents, + ).toHaveBeenCalled(); expect(deps.setOutput).toHaveBeenCalledWith("method_used", "direct_push"); }); @@ -158,4 +216,4 @@ describe("runWithDeps", () => { expect.stringContaining("Action failed"), ); }); -}); \ No newline at end of file +}); diff --git a/src/helper.ts b/src/helper.ts index 521cf180..57be7b12 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -7,7 +7,8 @@ import { ReusedCodeEntry, lookupGovDependency } from "./gov-dependencies.js"; const HOURS_PER_MONTH = 730.001; export function createHelpers(deps: Dependencies) { - const { owner, repo, octokit, adminOctokit, log, setOutput, isArchived } = deps; + const { owner, repo, octokit, adminOctokit, log, setOutput, isArchived } = + deps; //=============================================== // Meta Data @@ -78,7 +79,9 @@ export function createHelpers(deps: Dependencies) { async function getLaborHours(): Promise { try { - const { stdout } = await deps.exec(`scc /github/workspace --format json2`); + const { stdout } = await deps.exec( + `scc /github/workspace --format json2`, + ); const sccData = JSON.parse(stdout); const laborHours = Math.ceil( @@ -124,6 +127,26 @@ export function createHelpers(deps: Dependencies) { } } + //=============================================== + // Fork Upstream + //=============================================== + async function detectForkParent(): Promise { + try { + const repoData = await octokit.rest.repos.get({ owner, repo }); + const { fork, parent } = repoData.data; + + if (!fork || !parent) return null; + + return { + name: parent.full_name, + URL: parent.html_url, + }; + } catch (error) { + log.error(`Failed to detect fork parent: ${error}`); + return null; + } + } + async function getBaseBranch(): Promise { if (deps.branch) { return deps.branch; @@ -179,10 +202,7 @@ export function createHelpers(deps: Dependencies) { } } - async function sendPR( - updatedCodeJSON: CodeJSON, - baseBranchName: string, - ) { + async function sendPR(updatedCodeJSON: CodeJSON, baseBranchName: string) { try { const formattedContent = JSON.stringify(updatedCodeJSON, null, 2) + "\n"; const headBranchName = `code-json-${new Date().getTime()}`; @@ -190,7 +210,9 @@ export function createHelpers(deps: Dependencies) { const PR = await octokit.createPullRequest({ owner, repo, - title: isArchived ? "Update code.json for archival" : "Update code.json", + title: isArchived + ? "Update code.json for archival" + : "Update code.json", body: isArchived ? bodyOfArchivalPR() : bodyOfPR(), base: baseBranchName, head: headBranchName, @@ -258,7 +280,9 @@ export function createHelpers(deps: Dependencies) { sha: currentFileSha, }); - log.info(`Successfully pushed commit with PAT: ${result.data.commit.sha}`); + log.info( + `Successfully pushed commit with PAT: ${result.data.commit.sha}`, + ); setOutput("updated", true); setOutput("commit_sha", result.data.commit.sha); @@ -302,6 +326,7 @@ export function createHelpers(deps: Dependencies) { return { calculateMetaData, detectReusedCode, + detectForkParent, mergeReusedCode, getBaseBranch, validateOnly, diff --git a/src/main.ts b/src/main.ts index 0c74d746..496e5729 100644 --- a/src/main.ts +++ b/src/main.ts @@ -69,7 +69,9 @@ const baselineCodeJSON: Partial = { export { baselineCodeJSON }; -function filterValidFields(existingCodeJSON: Record): Partial { +function filterValidFields( + existingCodeJSON: Record, +): Partial { const validKeys = new Set(Object.keys(baselineCodeJSON)); const filtered: Record = {}; @@ -136,10 +138,14 @@ async function getMetaData( tags?.push("archived"); } - // detect government-made dependencies and merge with any existing reusedCode + // detect the fork upstream and government-made dependencies, then merge with any existing reusedCode + const [forkParent, detectedDeps] = await Promise.all([ + helpers.detectForkParent(), + helpers.detectReusedCode(), + ]); const reusedCode = helpers.mergeReusedCode( existingCodeJSON?.reusedCode ?? [], - await helpers.detectReusedCode(), + [...(forkParent ? [forkParent] : []), ...detectedDeps], ); return { @@ -234,4 +240,4 @@ export async function runWithDeps(deps: Dependencies): Promise { export async function run(): Promise { const deps = createProductionDeps(); return runWithDeps(deps); -} \ No newline at end of file +} diff --git a/src/types/Dependencies.ts b/src/types/Dependencies.ts index c00f470d..c2c28d18 100644 --- a/src/types/Dependencies.ts +++ b/src/types/Dependencies.ts @@ -32,8 +32,16 @@ export interface OctokitClient { rest: { repos: { get: (params: { owner: string; repo: string }) => Promise; - listLanguages: (params: { owner: string; repo: string }) => Promise; - getContent: (params: { owner: string; repo: string; path: string; ref: string }) => Promise; + listLanguages: (params: { + owner: string; + repo: string; + }) => Promise; + getContent: (params: { + owner: string; + repo: string; + path: string; + ref: string; + }) => Promise; createOrUpdateFileContents: (params: { owner: string; repo: string; @@ -71,6 +79,11 @@ export interface RepoResponse { created_at: string; updated_at: string; default_branch: string; + fork?: boolean; + parent?: { + full_name: string; + html_url: string; + } | null; }; } @@ -86,4 +99,4 @@ export interface CommitResponse { data: { commit: { sha: string }; }; -} \ No newline at end of file +}