Skip to content

Commit 6dcc991

Browse files
Merge branch 'dev' into DSACMS/haseeb/add-gov-dependencies
2 parents 3043f02 + 073f549 commit 6dcc991

4 files changed

Lines changed: 215 additions & 16 deletions

File tree

.github/workflows/e2e.yml

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
name: End-to-End
2+
3+
on:
4+
pull_request:
5+
types: [opened, synchronize, reopened]
6+
7+
permissions:
8+
contents: read
9+
10+
concurrency:
11+
group: e2e-${{ github.event.pull_request.number }}
12+
cancel-in-progress: true
13+
14+
env:
15+
SANDBOX_REPO: DSACMS/codejson-action-e2e
16+
SANDBOX_WORKFLOW: e2e.yml
17+
18+
jobs:
19+
e2e:
20+
name: End-to-End
21+
runs-on: ubuntu-latest
22+
timeout-minutes: 30
23+
24+
# Fork PRs get no secrets, so they can't reach the sandbox. Skip rather than fail
25+
# the check, and run the e2e by hand for those.
26+
if: github.event.pull_request.head.repo.full_name == github.repository
27+
28+
steps:
29+
- name: Generate sandbox token
30+
id: app_token
31+
uses: actions/create-github-app-token@v2
32+
with:
33+
app-id: ${{ secrets.E2E_APP_ID }}
34+
private-key: ${{ secrets.E2E_APP_PRIVATE_KEY }}
35+
owner: ${{ github.repository_owner }}
36+
repositories: codejson-action-e2e
37+
38+
- name: Trigger sandbox run
39+
id: trigger
40+
env:
41+
GH_TOKEN: ${{ steps.app_token.outputs.token }}
42+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
43+
PR_NUMBER: ${{ github.event.pull_request.number }}
44+
run: |
45+
set -euo pipefail
46+
47+
# workflow_dispatch doesn't return the run it created, so tag the run with an
48+
# id only this job could produce and find it by name below.
49+
RUN_NAME="e2e ${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
50+
echo "run_name=${RUN_NAME}" >> "$GITHUB_OUTPUT"
51+
52+
gh workflow run "$SANDBOX_WORKFLOW" \
53+
--repo "$SANDBOX_REPO" \
54+
--ref main \
55+
-f action_sha="$HEAD_SHA" \
56+
-f pr_number="$PR_NUMBER" \
57+
-f correlation_id="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
58+
59+
echo "Dispatched ${SANDBOX_REPO} for ${HEAD_SHA} (PR #${PR_NUMBER})"
60+
61+
- name: Wait for sandbox verdict
62+
env:
63+
GH_TOKEN: ${{ steps.app_token.outputs.token }}
64+
RUN_NAME: ${{ steps.trigger.outputs.run_name }}
65+
run: |
66+
set -euo pipefail
67+
68+
RUN_ID=""
69+
for _ in $(seq 1 24); do
70+
RUN_ID=$(gh api "repos/${SANDBOX_REPO}/actions/workflows/${SANDBOX_WORKFLOW}/runs?per_page=100" \
71+
--jq "[.workflow_runs[] | select(.name == \"${RUN_NAME}\" or .display_title == \"${RUN_NAME}\")] | .[0].id // empty")
72+
[ -n "$RUN_ID" ] && break
73+
sleep 5
74+
done
75+
76+
if [ -z "$RUN_ID" ]; then
77+
echo "::error::Sandbox run '${RUN_NAME}' never appeared in ${SANDBOX_REPO}."
78+
exit 1
79+
fi
80+
81+
RUN_URL="https://github.com/${SANDBOX_REPO}/actions/runs/${RUN_ID}"
82+
echo "Sandbox run: ${RUN_URL}"
83+
echo "Sandbox run: ${RUN_URL}" >> "$GITHUB_STEP_SUMMARY"
84+
85+
# The sandbox serializes its runs, so this can sit queued behind another PR.
86+
while true; do
87+
STATUS=$(gh api "repos/${SANDBOX_REPO}/actions/runs/${RUN_ID}" --jq '.status')
88+
[ "$STATUS" = "completed" ] && break
89+
echo " ${STATUS}..."
90+
sleep 15
91+
done
92+
93+
CONCLUSION=$(gh api "repos/${SANDBOX_REPO}/actions/runs/${RUN_ID}" --jq '.conclusion')
94+
95+
if [ "$CONCLUSION" != "success" ]; then
96+
echo "::error::Sandbox e2e ${CONCLUSION}. See ${RUN_URL}"
97+
exit 1
98+
fi
99+
echo "Sandbox e2e passed."

src/__tests__/unit/main.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,28 @@ describe("getMetaData", () => {
5454
expect(result.feedbackMechanism).toContain("/issues");
5555
});
5656

57+
it("preserves existing languages over GitHub-detected languages", async () => {
58+
const deps = createMockDeps();
59+
const helpers = createHelpers(deps);
60+
61+
const existing = {
62+
...validCodeJSON,
63+
languages: ["TypeScript", "Markdown"],
64+
} as any;
65+
const result = await getMetaData(helpers, deps, existing);
66+
67+
expect(result.languages).toEqual(["TypeScript", "Markdown"]);
68+
});
69+
70+
it("falls back to GitHub-detected languages when none exist", async () => {
71+
const deps = createMockDeps();
72+
const helpers = createHelpers(deps);
73+
74+
const result = await getMetaData(helpers, deps, null);
75+
76+
expect(result.languages).toEqual(["TypeScript", "JavaScript"]);
77+
});
78+
5779
it("sets Archival status when isArchived", async () => {
5880
const deps = createMockDeps({ isArchived: true });
5981
const helpers = createHelpers(deps);
@@ -109,6 +131,43 @@ describe("getMetaData", () => {
109131
URL: "https://github.com/upstream-owner/upstream-repo",
110132
});
111133
});
134+
135+
it("preserves existing tags that are not repository topics", async () => {
136+
const deps = createMockDeps();
137+
const helpers = createHelpers(deps);
138+
139+
const existing = {
140+
...validCodeJSON,
141+
tags: ["featured"],
142+
} as any;
143+
144+
const result = await getMetaData(helpers, deps, existing);
145+
146+
expect(result.tags).toEqual(["test", "automation", "featured"]);
147+
});
148+
149+
it("does not duplicate tags that already exist as repository topics", async () => {
150+
const deps = createMockDeps();
151+
const helpers = createHelpers(deps);
152+
153+
const existing = {
154+
...validCodeJSON,
155+
tags: ["test", "featured"],
156+
} as any;
157+
158+
const result = await getMetaData(helpers, deps, existing);
159+
160+
expect(result.tags).toEqual(["test", "automation", "featured"]);
161+
});
162+
163+
it("uses repository topics when no existing code.json is present", async () => {
164+
const deps = createMockDeps();
165+
const helpers = createHelpers(deps);
166+
167+
const result = await getMetaData(helpers, deps, null);
168+
169+
expect(result.tags).toEqual(["test", "automation"]);
170+
});
112171
});
113172

114173
describe("runWithDeps", () => {
@@ -152,6 +211,30 @@ describe("runWithDeps", () => {
152211
expect(deps.setOutput).toHaveBeenCalledWith("method_used", "pull_request");
153212
});
154213

214+
it("includes enum keys in the generated blank code.json", async () => {
215+
process.env.GITHUB_EVENT_NAME = "schedule";
216+
217+
const deps = createMockDeps({
218+
readFile: jest.fn<any>().mockRejectedValue(new Error("no file")),
219+
skipPR: false,
220+
});
221+
222+
await runWithDeps(deps);
223+
224+
const createPullRequestMock = deps.octokit.createPullRequest as jest.Mock;
225+
const pullRequestArgs = createPullRequestMock.mock.calls[0][0] as any;
226+
const codeJSONContent = pullRequestArgs.changes[0].files["code.json"];
227+
const generatedCodeJSON = JSON.parse(codeJSONContent);
228+
229+
expect(generatedCodeJSON).toHaveProperty("status");
230+
expect(generatedCodeJSON).toHaveProperty("repositoryHost");
231+
expect(generatedCodeJSON).toHaveProperty("repositoryVisibility");
232+
expect(generatedCodeJSON).toHaveProperty("softwareType");
233+
expect(generatedCodeJSON).toHaveProperty("maintenance");
234+
expect(generatedCodeJSON).toHaveProperty("repositoryType");
235+
expect(generatedCodeJSON).toHaveProperty("fismaLevel");
236+
});
237+
155238
it("attempts direct push when skipPR is true with admin token", async () => {
156239
process.env.GITHUB_EVENT_NAME = "workflow_dispatch";
157240

src/helper.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,7 @@ export function createHelpers(deps: Dependencies) {
347347
readJSON,
348348
sendPR,
349349
pushDirectlyWithFallback,
350+
mergeTags,
350351
};
351352
}
352353

@@ -403,6 +404,14 @@ export function mergeReusedCode(
403404
return merged;
404405
}
405406

407+
// combines repository topics with existing manually added tags, de-duped
408+
export function mergeTags(
409+
repositoryTopics: string[] = [],
410+
existingTags: string[] = [],
411+
): string[] {
412+
return Array.from(new Set([...repositoryTopics, ...existingTags]));
413+
}
414+
406415
function bodyOfPR(): string {
407416
return `
408417
## Welcome to the Federal Open Source Community!

src/main.ts

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ import { Dependencies } from "./types/Dependencies.js";
33
import { createHelpers, Helpers } from "./helper.js";
44
import { createProductionDeps } from "./create-deps.js";
55

6+
const blankEnumValue = "" as never;
7+
68
const baselineCodeJSON: Partial<CodeJSON> = {
79
name: "",
810
version: "",
911
description: "",
1012
longDescription: "",
11-
status: undefined,
13+
status: blankEnumValue,
1214
permissions: {
1315
licenses: [
1416
{
@@ -21,8 +23,8 @@ const baselineCodeJSON: Partial<CodeJSON> = {
2123
},
2224
organization: "Centers for Medicare & Medicaid Services",
2325
repositoryURL: "",
24-
repositoryHost: undefined,
25-
repositoryVisibility: undefined,
26+
repositoryHost: blankEnumValue,
27+
repositoryVisibility: blankEnumValue,
2628
homepageURL: "",
2729
downloadURL: "",
2830
disclaimerURL: "",
@@ -35,9 +37,9 @@ const baselineCodeJSON: Partial<CodeJSON> = {
3537
},
3638
platforms: [],
3739
categories: [],
38-
softwareType: undefined,
40+
softwareType: blankEnumValue,
3941
languages: [],
40-
maintenance: undefined,
42+
maintenance: blankEnumValue,
4143
contractNumber: [],
4244
SBOM: "",
4345
relatedCode: [],
@@ -56,9 +58,9 @@ const baselineCodeJSON: Partial<CodeJSON> = {
5658
feedbackMechanism: "",
5759
AIUseCaseID: "0",
5860
localisation: false,
59-
repositoryType: undefined,
61+
repositoryType: blankEnumValue,
6062
userInput: false,
61-
fismaLevel: undefined,
63+
fismaLevel: blankEnumValue,
6264
group: "",
6365
projects: [],
6466
systems: [],
@@ -112,12 +114,18 @@ async function getMetaData(
112114
? partialCodeJSON.description
113115
: existingCodeJSON?.description || "";
114116

115-
// only update tags if we have new ones from GitHub Topics, otherwise keep existing
116-
const shouldUpdateTags =
117-
partialCodeJSON.tags && partialCodeJSON.tags.length > 0;
118-
const tags = shouldUpdateTags
119-
? partialCodeJSON.tags
120-
: existingCodeJSON?.tags || [];
117+
// preserve manually curated languages when they already exist in code.json,
118+
// and only fall back to GitHub detected languages for new repositories.
119+
const languages =
120+
existingCodeJSON?.languages && existingCodeJSON.languages.length > 0
121+
? existingCodeJSON.languages
122+
: partialCodeJSON.languages;
123+
124+
// preserve existing tags and append repository topics, de-duped
125+
const tags = helpers.mergeTags(
126+
partialCodeJSON.tags ?? [],
127+
existingCodeJSON?.tags ?? [],
128+
);
121129

122130
// handling legacy contractNumber that turned from string to array which caused validation errors
123131
let contractNumber: string[] = [];
@@ -135,7 +143,7 @@ async function getMetaData(
135143

136144
if (deps.isArchived) {
137145
status = "Archival";
138-
tags?.push("archived");
146+
tags.push("archived");
139147
}
140148

141149
// detect the fork upstream and government-made dependencies, then merge with any existing reusedCode
@@ -151,11 +159,11 @@ async function getMetaData(
151159
return {
152160
name: partialCodeJSON.name,
153161
description: description,
154-
status: status,
162+
status: status ?? blankEnumValue,
155163
repositoryURL: partialCodeJSON.repositoryURL,
156164
repositoryVisibility: partialCodeJSON.repositoryVisibility,
157165
laborHours: partialCodeJSON.laborHours,
158-
languages: partialCodeJSON.languages,
166+
languages: languages,
159167
reuseFrequency: {
160168
forks: partialCodeJSON.reuseFrequency?.forks ?? 0,
161169
clones: existingCodeJSON?.reuseFrequency?.clones ?? 0,

0 commit comments

Comments
 (0)