diff --git a/.github/workflows/pr-self-report-label.yml b/.github/workflows/pr-self-report-label.yml new file mode 100644 index 00000000000..32476499238 --- /dev/null +++ b/.github/workflows/pr-self-report-label.yml @@ -0,0 +1,88 @@ +name: 'PR self-report label' + +# When a PR closes an issue that its OWN author opened (self-reported and +# self-fixed), apply a label so a reviewer can see at a glance that the problem +# itself was not independently reported β€” worth a look that the issue is real, +# not only that the fix is correct. Metadata only: it reads the PR's linked +# issues and applies a label, and NEVER checks out the PR's code, so running +# under pull_request_target (needed for write access on fork PRs) is safe. + +on: + pull_request_target: + types: ['opened', 'edited', 'reopened', 'synchronize'] + +permissions: + contents: 'read' + issues: 'write' + pull-requests: 'write' + +concurrency: + group: 'pr-self-report-${{ github.event.pull_request.number }}' + cancel-in-progress: true + +jobs: + label: + if: |- + ${{ github.repository == 'QwenLM/qwen-code' }} + runs-on: 'ubuntu-latest' + timeout-minutes: 5 + steps: + - name: 'Label a self-reported PR' + # PR-controlled values reach the script ONLY through env, never + # interpolated into the run body β€” the injection-safe pattern for + # pull_request_target. + env: + GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + REPO: '${{ github.repository }}' + PR: '${{ github.event.pull_request.number }}' + PR_AUTHOR: '${{ github.event.pull_request.user.login }}' + LABEL: 'review/self-reported' + run: | + set -euo pipefail + # Issues this PR closes β€” "Fixes/Closes #N" keywords AND Development + # sidebar links β€” and the login that OPENED each. Track whether the + # query succeeded: an API blip must never strip a correct label, so a + # failed query sets API_OK=false and blocks removal (fail-open) rather + # than masquerading as "no linked issues". + API_OK='false' + if ISSUE_AUTHORS="$(gh api graphql \ + -f owner="${REPO%/*}" -f name="${REPO#*/}" -F pr="${PR}" -f query=' + query($owner: String!, $name: String!, $pr: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $pr) { + closingIssuesReferences(first: 50) { nodes { author { login } } } + } + } + }' --jq '.data.repository.pullRequest.closingIssuesReferences.nodes[] + | .author.login // empty' 2> /dev/null)"; then + API_OK='true' + fi + + # Self-reported iff SOME closed issue was opened by the PR author. + SELF='false' + while IFS= read -r a; do + if [[ -n "${a}" && "${a}" == "${PR_AUTHOR}" ]]; then + SELF='true' + break + fi + done <<< "${ISSUE_AUTHORS}" + + HAS_LABEL="$(gh pr view "${PR}" --repo "${REPO}" --json labels \ + --jq "[.labels[].name] | index(\"${LABEL}\") != null" 2> /dev/null || echo 'false')" + + if [[ "${SELF}" == 'true' && "${HAS_LABEL}" != 'true' ]]; then + # Create the label on first use (idempotent), then apply it. + gh label create "${LABEL}" --repo "${REPO}" --color 'BFD4F2' \ + --description 'The linked issue was opened by the PR author (self-reported)' \ + 2> /dev/null || true + gh pr edit "${PR}" --repo "${REPO}" --add-label "${LABEL}" + echo "🏷️ #${PR}: added ${LABEL} β€” a closed issue was opened by ${PR_AUTHOR}" + elif [[ "${SELF}" != 'true' && "${HAS_LABEL}" == 'true' && "${API_OK}" == 'true' ]]; then + # The link was removed or re-pointed to someone else's issue β€” clear + # the now-stale label so it never lies. Only when the query itself + # succeeded: an API failure must not look like "no self-reported link". + gh pr edit "${PR}" --repo "${REPO}" --remove-label "${LABEL}" + echo "🏷️ #${PR}: removed ${LABEL} β€” no self-reported linked issue" + else + echo "#${PR}: no change (self=${SELF}, labeled=${HAS_LABEL}, api=${API_OK})" + fi diff --git a/scripts/tests/pr-self-report-label.test.js b/scripts/tests/pr-self-report-label.test.js new file mode 100644 index 00000000000..f9c695d5026 --- /dev/null +++ b/scripts/tests/pr-self-report-label.test.js @@ -0,0 +1,132 @@ +/** + * @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(/\$\{\{/); + }); +});