-
Notifications
You must be signed in to change notification settings - Fork 391
Resolve #aw_* temporary IDs during bundle-based signed commit replay
#33181
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b32bff2
8f54ed0
bfb3b3b
2dc59f3
2b491aa
78af32a
4c7dfa5
86b539e
ccca10a
5b4db76
a141293
c0dd5d2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,7 @@ | |
| */ | ||
|
|
||
| const { ERR_API } = require("./error_codes.cjs"); | ||
| const { loadTemporaryIdMapFromResolved, replaceTemporaryIdReferencesInPatch, TEMPORARY_ID_CANDIDATE_REFERENCE_PATTERN } = require("./temporary_id.cjs"); | ||
|
|
||
| /** Sentinel error class used to signal that the commit range contains a shape | ||
| * that the GitHub GraphQL `createCommitOnBranch` mutation cannot represent | ||
|
|
@@ -124,6 +125,45 @@ async function readBlobAsBase64(blobHash, cwd) { | |
| return Buffer.concat(chunks).toString("base64"); | ||
| } | ||
|
|
||
| /** | ||
| * Replace temporary ID references in base64-encoded UTF-8 text content. | ||
| * Returns original content unchanged for: | ||
| * - binary / non-UTF8 blobs | ||
| * - UTF-8 text with no temporary ID matches | ||
| * Returns rewritten base64 content when UTF-8 text contains resolvable temporary IDs. | ||
| * | ||
| * @param {string} base64Content | ||
| * @param {Map<string, {repo: string, number: number}>} temporaryIdMap | ||
| * @param {string} currentRepo | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/diagnose] The function A renaming like |
||
| * @param {string} filePath | ||
| * @returns {string} | ||
| */ | ||
| function maybeReplaceTemporaryIdsInBase64Content(base64Content, temporaryIdMap, currentRepo, filePath) { | ||
| if (!(temporaryIdMap instanceof Map) || temporaryIdMap.size === 0) { | ||
| return base64Content; | ||
| } | ||
|
|
||
| const rawBytes = Buffer.from(base64Content, "base64"); | ||
| const utf8Text = rawBytes.toString("utf8"); | ||
|
|
||
| // Treat only clean UTF-8 round-trippable content as text. | ||
| if (!Buffer.from(utf8Text, "utf8").equals(rawBytes)) { | ||
| return base64Content; | ||
| } | ||
|
|
||
| if (!TEMPORARY_ID_CANDIDATE_REFERENCE_PATTERN.test(utf8Text)) { | ||
| return base64Content; | ||
| } | ||
|
|
||
| const replaced = replaceTemporaryIdReferencesInPatch(utf8Text, temporaryIdMap, currentRepo); | ||
| if (replaced === utf8Text) { | ||
| return base64Content; | ||
| } | ||
|
|
||
| core.info(`pushSignedCommits: resolved temporary ID references in file content: ${filePath}`); | ||
| return Buffer.from(replaced, "utf8").toString("base64"); | ||
| } | ||
|
|
||
| /** | ||
| * Push the local branch to origin using git directly and return the local HEAD | ||
| * SHA after the push succeeds. | ||
|
|
@@ -167,9 +207,20 @@ async function resolveLocalHeadSha(cwd) { | |
| * @param {string} opts.cwd - Working directory of the local git checkout | ||
| * @param {object} [opts.gitAuthEnv] - Environment variables for git push fallback auth | ||
| * @param {boolean} [opts.signedCommits=true] - When false, skip GraphQL signed commits and use git push directly | ||
| * @param {Record<string, any>} [opts.resolvedTemporaryIds] - Resolved temporary IDs map | ||
| * @param {string} [opts.currentRepo] - Repository slug used for same-repo temporary ID resolution | ||
| * @returns {Promise<string | undefined>} SHA of the commit that landed on the target branch | ||
| */ | ||
| async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, cwd, gitAuthEnv, signedCommits = true }) { | ||
| async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, cwd, gitAuthEnv, signedCommits = true, resolvedTemporaryIds, currentRepo }) { | ||
| const effectiveCurrentRepo = currentRepo || `${owner}/${repo}`; | ||
| const temporaryIdMap = loadTemporaryIdMapFromResolved(resolvedTemporaryIds, { | ||
| defaultRepo: effectiveCurrentRepo, | ||
| validatePositiveIntegers: true, | ||
| onInvalidNumber: (normalizedKey, rawValue) => { | ||
| core.warning(`pushSignedCommits: ignoring invalid resolved temporary ID number for '${normalizedKey}': ${String(rawValue)}`); | ||
| }, | ||
| }); | ||
|
|
||
| // The default parameter value converts undefined to true; this check tests only the explicit false value. | ||
| if (signedCommits === false) { | ||
| core.info(`pushSignedCommits: signed-commits disabled (using direct git push) for branch ${branch}`); | ||
|
|
@@ -303,7 +354,8 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c | |
| if (dstMode === "100755") { | ||
| core.warning(`pushSignedCommits: executable bit on ${renamedPath} will be lost in signed commit (GitHub GraphQL does not support mode 100755)`); | ||
| } | ||
| additions.push({ path: renamedPath, contents: await readBlobAsBase64(dstHash, cwd) }); | ||
| const blobContents = await readBlobAsBase64(dstHash, cwd); | ||
| additions.push({ path: renamedPath, contents: maybeReplaceTemporaryIdsInBase64Content(blobContents, temporaryIdMap, effectiveCurrentRepo, renamedPath) }); | ||
| } else if (status && status.startsWith("C")) { | ||
| // Copy: source path is kept (no deletion), only the destination path is added | ||
| const copiedPath = unquoteCPath(paths[1]); | ||
|
|
@@ -322,7 +374,8 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c | |
| if (dstMode === "100755") { | ||
| core.warning(`pushSignedCommits: executable bit on ${copiedPath} will be lost in signed commit (GitHub GraphQL does not support mode 100755)`); | ||
| } | ||
| additions.push({ path: copiedPath, contents: await readBlobAsBase64(dstHash, cwd) }); | ||
| const blobContents = await readBlobAsBase64(dstHash, cwd); | ||
| additions.push({ path: copiedPath, contents: maybeReplaceTemporaryIdsInBase64Content(blobContents, temporaryIdMap, effectiveCurrentRepo, copiedPath) }); | ||
| } else { | ||
| // Added or Modified | ||
| if (dstMode === "160000") { | ||
|
|
@@ -336,7 +389,8 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c | |
| if (dstMode === "100755") { | ||
| core.warning(`pushSignedCommits: executable bit on ${filePath} will be lost in signed commit (GitHub GraphQL does not support mode 100755)`); | ||
| } | ||
| additions.push({ path: filePath, contents: await readBlobAsBase64(dstHash, cwd) }); | ||
| const blobContents = await readBlobAsBase64(dstHash, cwd); | ||
| additions.push({ path: filePath, contents: maybeReplaceTemporaryIdsInBase64Content(blobContents, temporaryIdMap, effectiveCurrentRepo, filePath) }); | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -307,6 +307,99 @@ describe("push_signed_commits integration tests", () => { | |
| expect(Buffer.from(variables.input.fileChanges.additions[0].contents, "base64").toString()).toBe("Hello World\n"); | ||
| }); | ||
|
|
||
| it("should resolve temporary ID references in text file contents before GraphQL replay", async () => { | ||
| execGit(["checkout", "-b", "temp-id-branch"], { cwd: workDir }); | ||
| fs.writeFileSync(path.join(workDir, "quarantine.cs"), '[QuarantinedTest("https://github.com/test-owner/test-repo/issues/#aw_test1")]\n// linked: #aw_test1\n'); | ||
| execGit(["add", "quarantine.cs"], { cwd: workDir }); | ||
| execGit(["commit", "-m", "Add quarantine reference"], { cwd: workDir }); | ||
| execGit(["push", "-u", "origin", "temp-id-branch"], { cwd: workDir }); | ||
|
|
||
| global.exec = makeRealExec(workDir); | ||
| const githubClient = makeMockGithubClient(); | ||
|
|
||
| await pushSignedCommits({ | ||
| githubClient, | ||
| owner: "test-owner", | ||
| repo: "test-repo", | ||
| branch: "temp-id-branch", | ||
| baseRef: "origin/main", | ||
| cwd: workDir, | ||
| resolvedTemporaryIds: { | ||
| aw_test1: { repo: "test-owner/test-repo", number: 66708 }, | ||
| }, | ||
| currentRepo: "test-owner/test-repo", | ||
| }); | ||
|
|
||
| expect(githubClient.graphql).toHaveBeenCalledTimes(1); | ||
| const additions = githubClient.graphql.mock.calls[0][1].input.fileChanges.additions; | ||
| expect(additions).toHaveLength(1); | ||
| const resolvedContent = Buffer.from(additions[0].contents, "base64").toString(); | ||
| expect(resolvedContent).toContain("https://github.com/test-owner/test-repo/issues/66708"); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] The assertion Consider a more specific assertion: expect(resolvedContent).toContain("// linked: #66708");This confirms both the URL path ( |
||
| expect(resolvedContent).toContain("#66708"); | ||
| expect(resolvedContent).not.toContain("#aw_test1"); | ||
| }); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] No test covers the backward-compatibility path where A simple regression guard: it("should leave file content unchanged when resolvedTemporaryIds is undefined", async () => {
// ... set up a commit with a regular file ...
await pushSignedCommits({ githubClient, owner, repo, branch, baseRef, cwd });
const additions = githubClient.graphql.mock.calls[0][1].input.fileChanges.additions;
expect(Buffer.from(additions[0].contents, "base64").toString()).toBe("expected original content");
}); |
||
|
|
||
| it("should still run replacement logic for malformed temporary ID candidates and emit warning", async () => { | ||
| execGit(["checkout", "-b", "temp-id-malformed-candidate-branch"], { cwd: workDir }); | ||
| fs.writeFileSync(path.join(workDir, "quarantine.cs"), "// malformed link: #aw_test-id\n"); | ||
| execGit(["add", "quarantine.cs"], { cwd: workDir }); | ||
| execGit(["commit", "-m", "Add malformed temporary ID reference"], { cwd: workDir }); | ||
| execGit(["push", "-u", "origin", "temp-id-malformed-candidate-branch"], { cwd: workDir }); | ||
|
|
||
| global.exec = makeRealExec(workDir); | ||
| const githubClient = makeMockGithubClient(); | ||
|
|
||
| await pushSignedCommits({ | ||
| githubClient, | ||
| owner: "test-owner", | ||
| repo: "test-repo", | ||
| branch: "temp-id-malformed-candidate-branch", | ||
| baseRef: "origin/main", | ||
| cwd: workDir, | ||
| resolvedTemporaryIds: { | ||
| aw_test: { repo: "test-owner/test-repo", number: 66708 }, | ||
| }, | ||
| currentRepo: "test-owner/test-repo", | ||
| }); | ||
|
|
||
| expect(githubClient.graphql).toHaveBeenCalledTimes(1); | ||
| const additions = githubClient.graphql.mock.calls[0][1].input.fileChanges.additions; | ||
| const replayedContent = Buffer.from(additions[0].contents, "base64").toString(); | ||
| expect(replayedContent).toContain("#66708-id"); | ||
| expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Malformed temporary ID reference '#aw_test-id'")); | ||
| }); | ||
|
|
||
| it("should ignore invalid resolved temporary ID numbers instead of replacing with NaN", async () => { | ||
| execGit(["checkout", "-b", "temp-id-invalid-number-branch"], { cwd: workDir }); | ||
| fs.writeFileSync(path.join(workDir, "quarantine.cs"), "// linked: #aw_test2\n"); | ||
| execGit(["add", "quarantine.cs"], { cwd: workDir }); | ||
| execGit(["commit", "-m", "Add temporary ID with invalid map entry"], { cwd: workDir }); | ||
| execGit(["push", "-u", "origin", "temp-id-invalid-number-branch"], { cwd: workDir }); | ||
|
|
||
| global.exec = makeRealExec(workDir); | ||
| const githubClient = makeMockGithubClient(); | ||
|
|
||
| await pushSignedCommits({ | ||
| githubClient, | ||
| owner: "test-owner", | ||
| repo: "test-repo", | ||
| branch: "temp-id-invalid-number-branch", | ||
| baseRef: "origin/main", | ||
| cwd: workDir, | ||
| resolvedTemporaryIds: { | ||
| aw_test2: { repo: "test-owner/test-repo", number: "not-a-number" }, | ||
| }, | ||
| currentRepo: "test-owner/test-repo", | ||
| }); | ||
|
|
||
| expect(githubClient.graphql).toHaveBeenCalledTimes(1); | ||
| const additions = githubClient.graphql.mock.calls[0][1].input.fileChanges.additions; | ||
| const replayedContent = Buffer.from(additions[0].contents, "base64").toString(); | ||
| expect(replayedContent).toContain("#aw_test2"); | ||
| expect(replayedContent).not.toContain("#NaN"); | ||
| expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("ignoring invalid resolved temporary ID number for 'aw_test2'")); | ||
| }); | ||
|
|
||
| it("should call GraphQL once per commit for multiple new commits", async () => { | ||
| execGit(["checkout", "-b", "multi-commit-branch"], { cwd: workDir }); | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[/zoom-out]
buildTemporaryIdMapis almost identical to the already-exportedloadTemporaryIdMapFromResolvedintemporary_id.cjs. The only meaningful difference is thatloadTemporaryIdMapFromResolvedfalls back to the GitHub Actionscontext.repowhile this function accepts an explicitcurrentRepostring.Rather than maintaining two copies of the same normalisation loop, consider exporting a variant (or extending the existing one with an optional
currentRepoparameter) so this file can delegate:Duplicated logic here will silently diverge if
loadTemporaryIdMapFromResolvedis ever updated.