Skip to content

Commit 9605ab7

Browse files
author
easonLiangWorldedtech
committed
fix(ci): measure a stacked unit against its parent pull request
The gate diffed every pull request against the merge commit's first parent, which is the base branch tip. For a stacked unit that base is main, so the whole unmerged chain was charged to the unit: the file-safety chain measured 512, 727, 783, 815 and 827 executable lines against a 500 cap, while each unit's own delta is 27-225 lines. The merge commit's second parent is the pull request head. When that head's own parent is another open pull request's head, the pull request is a stacked unit and the gate measures only its delta. A pull request whose parent is not another pull request head keeps the event base, so a multi-commit pull request is never charged only its last commit. An unparsable stacked map degrades to the event base instead of throwing, and the job summary records which base was used.
1 parent 9af61f8 commit 9605ab7

3 files changed

Lines changed: 142 additions & 7 deletions

File tree

‎.github/workflows/mutation-testing.yml‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ on:
88

99
permissions:
1010
contents: read
11+
# The gate only reads open pull request heads to recognise a stacked unit.
12+
pull-requests: read
1113

1214
concurrency:
1315
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -50,9 +52,15 @@ jobs:
5052
if: github.event_name == 'pull_request'
5153
env:
5254
HEAD_SHA: ${{ github.sha }}
55+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
5356
run: |
5457
BASE_SHA="$(git rev-parse "$HEAD_SHA^1")"
55-
node scripts/stryker-diff.mjs ci --base "$BASE_SHA" --head "$HEAD_SHA"
58+
# The merge commit's second parent is the pull request head. When that head's own
59+
# parent is another open pull request's head, this PR is a stacked unit, so the gate
60+
# measures only this unit's delta instead of every unmerged ancestor.
61+
PR_HEAD_SHA="$(git rev-parse "$HEAD_SHA^2" 2>/dev/null || git rev-parse "$HEAD_SHA")"
62+
STACKED_MAP="$(gh api "repos/${GITHUB_REPOSITORY}/pulls?state=open&per_page=100" --jq '[.[] | {number: .number, headSha: .head.sha}]' || '[]')"
63+
node scripts/stryker-diff.mjs ci --base "$BASE_SHA" --head "$HEAD_SHA" --stacked-map "$STACKED_MAP"
5664
5765
- name: Upload mutation reports
5866
id: mutation_report

‎scripts/stryker-diff.mjs‎

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,22 @@ export function resolvePullRequestBase(repoRoot, baseSha, headSha) {
281281
return parents[0]
282282
}
283283

284+
// A stacked unit branch is built on the previous unit's head, so the pull request's own base (main)
285+
// attributes every unmerged ancestor to this pull request. When the head commit's first parent is the
286+
// head of another open pull request, that parent is the unit's real base and the diff contains only
287+
// this unit's delta. A pull request whose parent is not another pull request's head keeps the event
288+
// base, so a multi-commit pull request is never charged only its last commit.
289+
export function resolveStackedUnitBase(repoRoot, eventBaseSha, prHeadSha, openPullRequests = []) {
290+
validateSha(eventBaseSha, "base SHA")
291+
validateSha(prHeadSha, "pull request head SHA")
292+
const parents = git(repoRoot, ["rev-list", "--parents", "-n", "1", prHeadSha]).trim().split(/\s+/).slice(1)
293+
if (parents.length !== 1) return { baseSha: eventBaseSha, stackedOn: null }
294+
const parentSha = parents[0].toLowerCase()
295+
const parent = openPullRequests.find((pr) => String(pr.headSha).toLowerCase() === parentSha)
296+
if (!parent) return { baseSha: eventBaseSha, stackedOn: null }
297+
return { baseSha: parentSha, stackedOn: parent.number }
298+
}
299+
284300
export function selectFromGit(repoRoot, baseSha, headSha) {
285301
validateSha(baseSha, "base SHA")
286302
validateSha(headSha, "head SHA")
@@ -566,12 +582,17 @@ export function formatBlockingMutants(blockingMutants, packageRoot) {
566582
}
567583

568584
export function formatSummary(rows, advisories, manifest = {}) {
569-
const lines = [
570-
"## Changed-code mutation testing",
571-
"",
585+
const lines = ["## Changed-code mutation testing", ""]
586+
if (manifest.stackedOn) {
587+
lines.push(
588+
`Stacked unit: measured against the head of parent PR #${manifest.stackedOn}, not the event base.`,
589+
"",
590+
)
591+
}
592+
lines.push(
572593
"| Package | Changed executable lines | Valid | Killed | Timeout | Survived | No coverage | Result |",
573594
"| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |",
574-
]
595+
)
575596
for (const row of rows) {
576597
lines.push(
577598
`| ${row.id} | ${row.changedLines} | ${row.valid} | ${row.killed} | ${row.timeout} | ${row.survived} | ${row.noCoverage} | ${row.result} |`,
@@ -796,18 +817,37 @@ function argument(name) {
796817
return index === -1 ? undefined : process.argv[index + 1]
797818
}
798819

820+
// The workflow passes the open pull requests as a JSON array of { number, headSha }. An unparsable
821+
// map must not change the gate's base, so it degrades to the event base instead of throwing.
822+
export function parseStackedMap(value) {
823+
if (!value) return []
824+
try {
825+
const parsed = JSON.parse(value)
826+
if (!Array.isArray(parsed)) return []
827+
return parsed.filter((entry) => entry && entry.number && /^[0-9a-f]{40}$/i.test(String(entry.headSha)))
828+
} catch {
829+
return []
830+
}
831+
}
832+
799833
function main() {
800834
const command = process.argv[2]
801835
if (command !== "ci")
802-
throw new Error("Usage: node scripts/stryker-diff.mjs ci --base <sha> --head <sha> [--reports <path>]")
836+
throw new Error(
837+
"Usage: node scripts/stryker-diff.mjs ci --base <sha> --head <sha> [--reports <path>] [--stacked-map <json>]",
838+
)
803839

804840
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
805841
const baseSha = argument("--base")
806842
const headSha = argument("--head")
807843
if (!baseSha || !headSha) throw new Error("--base and --head are required")
808844

845+
const openPullRequests = parseStackedMap(argument("--stacked-map"))
846+
const resolved = resolveStackedUnitBase(repoRoot, baseSha, headSha, openPullRequests)
847+
if (resolved.stackedOn) console.log(`Stacked unit: measured against the head of parent PR #${resolved.stackedOn}, not the event base.`)
848+
809849
const reportRoot = path.resolve(repoRoot, argument("--reports") ?? "reports/mutation")
810-
const manifest = selectFromGit(repoRoot, baseSha, headSha)
850+
const manifest = { ...selectFromGit(repoRoot, resolved.baseSha, headSha), stackedOn: resolved.stackedOn }
811851
if (manifest.packages.length === 0) {
812852
appendSummary([], manifest.advisories, manifest)
813853
console.log("No changed executable lines in mutation-tested packages; mutation testing is not applicable.")

‎scripts/stryker-diff.test.mjs‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import {
2525
parseNameStatus,
2626
parseVitestTestFiles,
2727
preferDirectTestFiles,
28+
parseStackedMap,
29+
resolveStackedUnitBase,
2830
resolveStrykerTempDir,
2931
resolveVitestBinary,
3032
shouldUseVitestRelated,
@@ -58,6 +60,9 @@ describe("mutation testing workflow", () => {
5860
assert.ok(workflow.includes("HEAD_SHA: ${{ github.sha }}"))
5961
assert.ok(!workflow.includes("HEAD_SHA: ${{ github.event.pull_request.head.sha }}"))
6062
assert.ok(workflow.includes('BASE_SHA="$(git rev-parse "$HEAD_SHA^1")"'))
63+
assert.ok(workflow.includes("pull-requests: read"))
64+
assert.ok(workflow.includes('PR_HEAD_SHA="$(git rev-parse "$HEAD_SHA^2" 2>/dev/null || git rev-parse "$HEAD_SHA")"'))
65+
assert.ok(workflow.includes('--stacked-map "$STACKED_MAP"'))
6166
assert.ok(!workflow.includes("github.event.pull_request.base.sha"))
6267
assert.ok(workflow.includes("steps.mutation_report.outputs.artifact-url"))
6368
assert.ok(workflow.includes("open the package's mutation.html file"))
@@ -175,6 +180,88 @@ describe("pull request revision selection", () => {
175180
})
176181
})
177182

183+
describe("stacked unit base resolution", () => {
184+
const createSyntheticStack = () => {
185+
const repository = fs.mkdtempSync(path.join(os.tmpdir(), "mutation-stack-"))
186+
const run = (...args) => execFileSync("git", args, { cwd: repository, encoding: "utf8" }).trim()
187+
const write = (filePath, contents) => {
188+
fs.mkdirSync(path.join(repository, path.dirname(filePath)), { recursive: true })
189+
fs.writeFileSync(path.join(repository, filePath), contents)
190+
}
191+
192+
run("init", "--quiet", "--initial-branch", "main")
193+
run("config", "user.email", "[email protected]")
194+
run("config", "user.name", "Gate")
195+
run("config", "commit.gpgsign", "false")
196+
197+
write("packages/core/src/unrelated.ts", "export const unrelated = () => 1\n")
198+
run("add", ".")
199+
run("commit", "--quiet", "-m", "initial")
200+
const eventBaseSha = run("rev-parse", "HEAD")
201+
202+
run("checkout", "--quiet", "-b", "unit-1")
203+
write("packages/core/src/unit1.ts", "export const unit1 = () => 1\n")
204+
run("add", ".")
205+
run("commit", "--quiet", "-m", "unit 1")
206+
const parentSha = run("rev-parse", "HEAD")
207+
208+
// The stacked unit is one commit on top of the parent unit's head.
209+
write("packages/core/src/unit2.ts", "export const unit2 = () => 2\n")
210+
run("add", ".")
211+
run("commit", "--quiet", "-m", "unit 2")
212+
const childSha = run("rev-parse", "HEAD")
213+
214+
return { repository, eventBaseSha, parentSha, childSha }
215+
}
216+
217+
it("measures a stacked unit against the parent pull request head", () => {
218+
const { repository, eventBaseSha, parentSha, childSha } = createSyntheticStack()
219+
220+
try {
221+
const resolved = resolveStackedUnitBase(repository, eventBaseSha, childSha, [{ number: 1, headSha: parentSha }])
222+
assert.equal(resolved.baseSha, parentSha)
223+
assert.equal(resolved.stackedOn, 1)
224+
225+
const manifest = selectFromGit(repository, resolved.baseSha, childSha)
226+
assert.deepEqual(
227+
manifest.packages.flatMap((entry) => entry.files.map((file) => file.path)),
228+
["packages/core/src/unit2.ts"],
229+
)
230+
} finally {
231+
fs.rmSync(repository, { recursive: true, force: true })
232+
}
233+
})
234+
235+
it("keeps the event base when the parent commit is not another pull request head", () => {
236+
const { repository, eventBaseSha, parentSha, childSha } = createSyntheticStack()
237+
238+
try {
239+
const resolved = resolveStackedUnitBase(repository, eventBaseSha, childSha, [])
240+
assert.equal(resolved.baseSha, eventBaseSha)
241+
assert.equal(resolved.stackedOn, null)
242+
243+
// A multi-commit pull request must not be charged only its last commit.
244+
const manifest = selectFromGit(repository, resolved.baseSha, childSha)
245+
assert.deepEqual(
246+
manifest.packages.flatMap((entry) => entry.files.map((file) => file.path)).sort(),
247+
["packages/core/src/unit1.ts", "packages/core/src/unit2.ts"],
248+
)
249+
void parentSha
250+
} finally {
251+
fs.rmSync(repository, { recursive: true, force: true })
252+
}
253+
})
254+
255+
it("degrades to the event base for an unparsable stacked map", () => {
256+
assert.deepEqual(parseStackedMap("not json"), [])
257+
assert.deepEqual(parseStackedMap('{"number": 1}'), [])
258+
assert.deepEqual(parseStackedMap("[{\"number\": 1, \"headSha\": \"abc\"}]"), [])
259+
assert.deepEqual(parseStackedMap("[{\"number\": 1, \"headSha\": \"" + "a".repeat(40) + "\"}]"), [
260+
{ number: 1, headSha: "a".repeat(40) },
261+
])
262+
})
263+
})
264+
178265
describe("parseNameStatus", () => {
179266
it("parses added, modified, and renamed paths", () => {
180267
assert.deepEqual(

0 commit comments

Comments
 (0)