qwen-code/scripts/tests/pr-self-report-label.test.js
Shaojin Wen 09f01de881
ci: label a PR that closes an issue its own author opened (#7630)
* ci: label a PR that closes an issue its own author opened

Some PRs fix an issue the PR author themselves reported — self-reported
and self-fixed. That is not wrong, but the problem was never
independently validated, so a reviewer wants to check the issue is real,
not only that the fix is correct. This applies a `review/self-reported`
label so that shows at a glance and can be filtered.

A small pull_request_target workflow, metadata only (it never checks out
the PR's code): it reads the PR's closingIssuesReferences ("Fixes/Closes
#N" plus the Development-sidebar links) and, if any of those issues was
opened by the PR author, adds the label; it removes the label if the
link is later re-pointed or dropped. PR-controlled values reach the
script only through env, never interpolated into the run body.

* ci: single-quote workflow string values for yamllint

The repo's .yamllint.yml enforces quoted-strings (quote-type single,
required). The initial workflow left name, on/types, permissions,
concurrency group, runs-on, and the env values unquoted, failing the
Test job's yaml lint. Single-quote them (double where a value contains
single quotes, block scalar for the if), matching the qwen-fleet-shepherd
style. No behaviour change.

* fix(ci): never strip self-report label on a failed GraphQL query

Track whether the closingIssuesReferences query succeeded (API_OK) and gate label removal on it, so an API blip can no longer masquerade as "no self-reported link" and strip a correct label. Also re-run on synchronize so a commit-message "Fixes #N" link updates the label, add a fail-open regression test, and use the root yaml dependency in the test.

* fix(ci): add timeout-minutes and labelCreated test assertion (#7630)

---------

Co-authored-by: wenshao <wenshao@example.com>
Co-authored-by: Qwen Code Autofix <qwen-code-autofix@users.noreply.github.com>
Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>
2026-07-24 05:50:47 +00:00

132 lines
4.4 KiB
JavaScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { execFileSync } from 'node:child_process';
import { parse } from 'yaml';
const workflow = parse(
readFileSync('.github/workflows/pr-self-report-label.yml', 'utf8'),
);
const runBlock = workflow.jobs.label.steps[0].run;
// The step decides label state from two inputs: the logins that opened the PR's
// closed issues (gh api graphql) and whether the label is already present (gh pr
// view). Stub both and record which mutation, if any, it makes.
const GH_STUB = [
'#!/usr/bin/env bash',
'echo "gh $*" >> "${CALLS_LOG}"',
'case "$*" in',
" *'api graphql'*) [ \"${GH_API_FAILS:-false}\" = true ] && exit 1; printf '%s\\n' ${GH_ISSUE_AUTHORS:-} ;;",
" *'pr view'*labels*) { [ \"${GH_HAS_LABEL:-false}\" = true ] && printf 'true' || printf 'false'; } ;;",
' *) : ;;',
'esac',
'exit 0',
].join('\n');
describe('pr-self-report-label', () => {
const run = ({
prAuthor = 'alice',
issueAuthors = '',
hasLabel = false,
apiFails = false,
}) => {
const dir = mkdtempSync(join(tmpdir(), 'lbl-'));
const bin = join(dir, 'bin');
mkdirSync(bin);
const callsLog = join(dir, 'calls.log');
writeFileSync(join(bin, 'gh'), GH_STUB);
chmodSync(join(bin, 'gh'), 0o755);
const out = execFileSync('bash', ['-c', runBlock], {
env: {
...process.env,
PATH: `${bin}:${process.env.PATH}`,
CALLS_LOG: callsLog,
REPO: 'o/r',
PR: '1',
PR_AUTHOR: prAuthor,
LABEL: 'review/self-reported',
GH_ISSUE_AUTHORS: issueAuthors,
GH_HAS_LABEL: String(hasLabel),
GH_API_FAILS: String(apiFails),
},
encoding: 'utf8',
});
const calls = existsSync(callsLog) ? readFileSync(callsLog, 'utf8') : '';
rmSync(dir, { recursive: true, force: true });
return {
added: /add-label/.test(calls),
removed: /remove-label/.test(calls),
labelCreated: /label create/.test(calls),
out: out.trim(),
};
};
it('labels a PR that closes an issue its own author opened', () => {
expect(run({ prAuthor: 'alice', issueAuthors: 'alice' })).toMatchObject({
added: true,
removed: false,
labelCreated: true,
});
// One self-reported issue among several linked is enough.
expect(
run({ prAuthor: 'alice', issueAuthors: 'bob\nalice\ncarol' }).added,
).toBe(true);
});
it('removes the label once no closed issue is self-reported', () => {
// The link was re-pointed to someone else's issue…
expect(
run({ prAuthor: 'alice', issueAuthors: 'bob', hasLabel: true }),
).toMatchObject({ added: false, removed: true });
// …or removed entirely.
expect(
run({ prAuthor: 'alice', issueAuthors: '', hasLabel: true }).removed,
).toBe(true);
});
it('makes no change when the label already matches the state', () => {
expect(
run({ prAuthor: 'alice', issueAuthors: 'alice', hasLabel: true }),
).toMatchObject({ added: false, removed: false });
expect(
run({ prAuthor: 'alice', issueAuthors: 'bob', hasLabel: false }),
).toMatchObject({ added: false, removed: false });
// A PR that closes no issue is never labelled.
expect(
run({ prAuthor: 'alice', issueAuthors: '', hasLabel: false }),
).toMatchObject({ added: false, removed: false });
});
it('fails open: a GraphQL failure never adds or removes the label', () => {
// gh api graphql 5xx → API_OK=false. The label state is unknown, so the
// step must leave it untouched rather than read empty results as
// "no self-reported link" and strip a correct label.
expect(
run({ prAuthor: 'alice', hasLabel: true, apiFails: true }),
).toMatchObject({ added: false, removed: false });
expect(
run({ prAuthor: 'alice', hasLabel: false, apiFails: true }),
).toMatchObject({ added: false, removed: false });
});
it('never interpolates PR-controlled data into the run body (env only)', () => {
// pull_request_target hardening: the author/number/label reach the script
// only through env, so a crafted title or body cannot inject shell.
expect(runBlock).not.toMatch(/\$\{\{/);
});
});