diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000000..23e3363b18c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,31 @@ +# Pull request template (#36400) + +## Summary + + + +## Closing scope + + + +- Closes? / Part of? : + +### Done when (copy from the issue; tick only what this PR completes) + +- [ ] + +### What remains (required for Part of #N) + + + +## Test plan + +- [ ] Named gates run in the pinned Docker image (`./script/docker-exec.sh` / `./script/phpunit.sh`) +- [ ] `php script/check-issue-close-scope.php --pr-body --repo PurHur/php-compiler` (when using Closes #N) +- [ ] Repro / Done-when commands pasted with output diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index be5a8cdce71..74bddb6f18b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,6 +18,23 @@ Please follow [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) in all project interactio The sections below are for **maintainers and collaborators who have already coordinated** on other channels. +### Definition of Done ([#36400](https://github.com/PurHur/php-compiler/issues/36400)) + +An issue may be closed by a PR only when **all** of the following hold: + +1. Every checkbox under the issue’s `## Done when` section appears in the PR body as a **ticked** copy (`- [x]` + the same text), together with the command output that proves it. +2. The named gates for that change class were run in the **pinned Docker image** (`./script/docker-exec.sh` / `./script/phpunit.sh`), and the transcript is in the PR. +3. Partial work uses **`Part of #N`** (not `Closes #N`) and lists what remains. Issues without a `## Done when` list get one **before** being claimed. + +Gate (local / PR body check): + +```bash +php script/check-issue-close-scope.php --self-test +php script/check-issue-close-scope.php --pr-body /tmp/pr.md --repo PurHur/php-compiler +``` + +A `Closes #N` without the full ticked Done-when list **fails** the gate; `Part of #N` does not. Weekly audit: `php script/audit-closed-but-partial.php --days 7 --dry-run` (post with `--post --tracker 36379`; optional `--apply-labels`). See [`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md). + ### Pull request process All submissions, including by project members, require review via GitHub pull requests. See [GitHub Help](https://help.github.com/articles/about-pull-requests/) for using pull requests. diff --git a/script/audit-closed-but-partial.php b/script/audit-closed-but-partial.php new file mode 100755 index 00000000000..6a1845ab539 --- /dev/null +++ b/script/audit-closed-but-partial.php @@ -0,0 +1,506 @@ +#!/usr/bin/env php + (int) $row['number'], + 'title' => (string) ($row['title'] ?? ''), + 'reason' => $reason, + 'prUrl' => (string) ($row['prUrl'] ?? ''), + ]; + } + + if ($flagged === []) { + fwrite(STDOUT, "audit-closed-but-partial: none flagged in last {$days} day(s)\n"); + if ($post && !$dryRun) { + $body = build_audit_comment($days, $flagged, $repo, null); + post_tracker_comment($repo, $tracker, $body); + fwrite(STDOUT, "audit-closed-but-partial: posted empty-result summary on #{$tracker}\n"); + } + + return 0; + } + + fwrite(STDOUT, 'audit-closed-but-partial: '.count($flagged)." candidate(s):\n"); + foreach ($flagged as $f) { + fwrite(STDOUT, " #{$f['number']}\t{$f['reason']}\t{$f['title']}\n"); + } + + if ($dryRun) { + fwrite(STDOUT, "audit-closed-but-partial: dry-run — not labeling or posting\n"); + + return 0; + } + + $umbrella = null; + if ($post) { + $umbrella = create_umbrella_issue($repo, $days, $flagged); + fwrite(STDOUT, "audit-closed-but-partial: umbrella child #{$umbrella}\n"); + } + + if ($applyLabels) { + ensure_label($repo, $label); + foreach ($flagged as $f) { + apply_label($repo, $f['number'], $label); + fwrite(STDOUT, "audit-closed-but-partial: labeled #{$f['number']} {$label}\n"); + } + if ($umbrella !== null) { + apply_label($repo, $umbrella, $label); + } + } else { + fwrite(STDOUT, "audit-closed-but-partial: skipping per-issue labels (pass --apply-labels to opt in)\n"); + } + + if ($post) { + $body = build_audit_comment($days, $flagged, $repo, $umbrella); + post_tracker_comment($repo, $tracker, $body); + fwrite(STDOUT, "audit-closed-but-partial: posted summary on #{$tracker}\n"); + } + + return 0; +} + +/** + * @return list> + */ +function load_fixture_rows(string $path): array +{ + if (!is_readable($path)) { + fwrite(STDERR, "audit-closed-but-partial: fixture not readable: {$path}\n"); + exit(1); + } + $data = json_decode((string) file_get_contents($path), true); + if (!is_array($data)) { + fwrite(STDERR, "audit-closed-but-partial: fixture must be a JSON array\n"); + exit(1); + } + + return $data; +} + +/** + * @return list> + */ +function fetch_recently_closed_rows(string $repo, int $days): array +{ + $since = (new DateTimeImmutable('now', new DateTimeZone('UTC'))) + ->modify('-'.$days.' days') + ->format('Y-m-d'); + $cmd = 'gh issue list --repo '.escapeshellarg($repo) + .' --state closed --limit 100 --search '.escapeshellarg("closed:>={$since}") + .' --json number,title,closedAt,url 2>/dev/null'; + $json = shell_exec($cmd); + if ($json === null || $json === '') { + fwrite(STDERR, "audit-closed-but-partial: gh issue list failed\n"); + exit(1); + } + $issues = json_decode($json, true); + if (!is_array($issues)) { + return []; + } + + $rows = []; + foreach ($issues as $issue) { + $n = (int) ($issue['number'] ?? 0); + if ($n <= 0) { + continue; + } + $pr = find_closing_pr($repo, $n); + $issueBody = fetch_issue_body_raw($repo, $n); + $rows[] = [ + 'number' => $n, + 'title' => (string) ($issue['title'] ?? ''), + 'closedAt' => (string) ($issue['closedAt'] ?? ''), + 'closingPrBody' => $pr['body'] ?? '', + 'prUrl' => $pr['url'] ?? '', + 'issueBody' => $issueBody ?? '', + ]; + } + + return $rows; +} + +/** + * @return array{body?:string,url?:string} + */ +function find_closing_pr(string $repo, int $issueNumber): array +{ + // Prefer timeline events via gh api + $cmd = 'gh api repos/'.escapeshellarg($repo).'/issues/'.$issueNumber.'/timeline --paginate 2>/dev/null'; + // gh api with escapeshellarg on repo breaks the path — build carefully + $cmd = 'gh api '.escapeshellarg("repos/{$repo}/issues/{$issueNumber}/timeline").' --paginate 2>/dev/null'; + $json = shell_exec($cmd); + if ($json === null || $json === '') { + return []; + } + // --paginate may concatenate arrays; try decode + $events = json_decode($json, true); + if (!is_array($events)) { + return []; + } + foreach (array_reverse($events) as $ev) { + if (($ev['event'] ?? '') !== 'closed' && ($ev['event'] ?? '') !== 'cross-referenced') { + // look for connected PR + } + $src = $ev['source'] ?? null; + if (is_array($src) && isset($src['issue']['pull_request'])) { + $prNum = (int) ($src['issue']['number'] ?? 0); + if ($prNum > 0) { + return fetch_pr($repo, $prNum); + } + } + } + // Fallback: search PRs that mention Closes #N + $cmd = 'gh pr list --repo '.escapeshellarg($repo) + .' --state merged --limit 20 --search '.escapeshellarg((string) $issueNumber) + .' --json number,url,body 2>/dev/null'; + $list = json_decode((string) shell_exec($cmd), true); + if (!is_array($list)) { + return []; + } + foreach ($list as $pr) { + $body = (string) ($pr['body'] ?? ''); + if (preg_match('/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#'.$issueNumber.'\b/i', $body)) { + return ['body' => $body, 'url' => (string) ($pr['url'] ?? '')]; + } + } + + return []; +} + +/** + * @return array{body?:string,url?:string} + */ +function fetch_pr(string $repo, int $number): array +{ + $cmd = 'gh pr view '.escapeshellarg((string) $number) + .' --repo '.escapeshellarg($repo) + .' --json body,url 2>/dev/null'; + $data = json_decode((string) shell_exec($cmd), true); + if (!is_array($data)) { + return []; + } + + return ['body' => (string) ($data['body'] ?? ''), 'url' => (string) ($data['url'] ?? '')]; +} + +function fetch_issue_body_raw(string $repo, int $number): ?string +{ + $cmd = 'gh issue view '.escapeshellarg((string) $number) + .' --repo '.escapeshellarg($repo) + .' --json body -q .body 2>/dev/null'; + $out = shell_exec($cmd); + if ($out === null || $out === '') { + return null; + } + + return $out; +} + +/** + * @param array $row + */ +function classify_partial_close(array $row): ?string +{ + $prBody = (string) ($row['closingPrBody'] ?? ''); + $issueBody = (string) ($row['issueBody'] ?? ''); + $n = (int) ($row['number'] ?? 0); + + if ($prBody === '') { + return null; // no closing PR found — skip (manual close) + } + + // Explicit partial language in the closing PR (narrow patterns — avoid "partial" in prose titles) + if (preg_match('/\bPart of #\d+\b/i', $prBody) + || preg_match('/\b(?:follow-?up|remain(?:s|ing)?)\b/i', $prBody) + || preg_match('/\bpartial\s+(?:merge|only|slice|fix|land|work)\b/i', $prBody) + ) { + // Only flag when the same PR also claims Closes #N for this issue + if (preg_match('/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#'.$n.'\b/i', $prBody)) { + return 'closing PR both Closes #'.$n.' and uses partial/follow-up language'; + } + } + + // Closes #N without ticked Done-when + if ($issueBody !== '' && preg_match('/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#'.$n.'\b/i', $prBody)) { + $required = audit_extract_done_when_items($issueBody); + if ($required === []) { + return 'Closes #'.$n.' but issue has no Done-when checklist'; + } + $ticked = audit_extract_ticked_items($prBody); + $tickedSet = array_fill_keys($ticked, true); + foreach ($required as $item) { + if (!isset($tickedSet[$item])) { + return 'Closes #'.$n.' without ticked Done-when item: '.$item; + } + } + } + + return null; +} + +/** @return list */ +function audit_extract_done_when_items(string $issueBody): array +{ + if (!preg_match('/^##\s+Done when\s*$/mi', $issueBody, $m, PREG_OFFSET_CAPTURE)) { + return []; + } + $start = (int) $m[0][1] + strlen($m[0][0]); + $rest = substr($issueBody, $start); + if (preg_match('/^##\s+/m', $rest, $next, PREG_OFFSET_CAPTURE)) { + $rest = substr($rest, 0, (int) $next[0][1]); + } + $items = []; + if (preg_match_all('/^\s*-\s*\[([ xX])\]\s*(.+?)\s*$/m', $rest, $boxes, PREG_SET_ORDER)) { + foreach ($boxes as $box) { + $text = audit_normalize($box[2]); + if ($text !== '') { + $items[] = $text; + } + } + } + + return $items; +} + +/** @return list */ +function audit_extract_ticked_items(string $prBody): array +{ + $items = []; + if (preg_match_all('/^\s*-\s*\[[xX]\]\s*(.+?)\s*$/m', $prBody, $boxes, PREG_SET_ORDER)) { + foreach ($boxes as $box) { + $text = audit_normalize($box[1]); + if ($text !== '') { + $items[] = $text; + } + } + } + + return $items; +} + +function audit_normalize(string $text): string +{ + $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $text = preg_replace('/\s+/u', ' ', trim($text)) ?? trim($text); + + return $text; +} + +/** + * @param list $flagged + */ +function build_audit_comment(int $days, array $flagged, string $repo, ?int $umbrella): string +{ + $date = gmdate('Y-m-d'); + $lines = []; + $lines[] = "## Closed-but-partial audit ({$date}, last {$days} days) — #36400"; + $lines[] = ''; + if ($flagged === []) { + $lines[] = 'No closed issues in the window matched partial-close heuristics (`Closes #N` + partial/follow-up language, or `Closes #N` without a full ticked Done-when copy).'; + $lines[] = ''; + $lines[] = 'Label `needs-respin` was not applied.'; + + return implode("\n", $lines)."\n"; + } + $lines[] = 'Flagged **'.count($flagged).'** issue(s):'; + $lines[] = ''; + foreach ($flagged as $f) { + $link = "https://github.com/{$repo}/issues/{$f['number']}"; + $pr = $f['prUrl'] !== '' ? " — PR: {$f['prUrl']}" : ''; + $lines[] = "- [#{$f['number']}]({$link}) — {$f['reason']}{$pr}"; + } + $lines[] = ''; + if ($umbrella !== null) { + $lines[] = "Umbrella respin child: [#{$umbrella}](https://github.com/{$repo}/issues/{$umbrella}) (queue for fleet — do not mass-reopen here)."; + } else { + $lines[] = 'Fleet: open a fresh child (or re-open) for each entry before claiming that scope again.'; + } + + return implode("\n", $lines)."\n"; +} + +/** + * @param list $flagged + */ +function create_umbrella_issue(string $repo, int $days, array $flagged): int +{ + $date = gmdate('Y-m-d'); + $title = "Respin queue from #36400 closed-but-partial audit ({$date})"; + $bodyLines = []; + $bodyLines[] = "Parent: #36400 / tracker #36379"; + $bodyLines[] = ''; + $bodyLines[] = "First weekly audit found **".count($flagged)."** closed issues in the last {$days} days whose closing PR looks partial under the #36400 heuristics."; + $bodyLines[] = ''; + $bodyLines[] = '## Queue'; + $bodyLines[] = ''; + foreach ($flagged as $f) { + $bodyLines[] = "- #{$f['number']} — {$f['reason']} — {$f['title']}"; + } + $bodyLines[] = ''; + $bodyLines[] = '## Done when'; + $bodyLines[] = ''; + $bodyLines[] = '- [ ] Each queue entry either has an open successor child covering the remaining Done-when, or is confirmed complete and removed from this list'; + $bodyLines[] = '- [ ] No new `Closes #N` lands without a full ticked Done-when copy (`script/check-issue-close-scope.php`)'; + $body = implode("\n", $bodyLines)."\n"; + $tmp = tempnam(sys_get_temp_dir(), 'umbrella36400-'); + if ($tmp === false) { + fwrite(STDERR, "audit-closed-but-partial: tempnam failed for umbrella\n"); + exit(1); + } + file_put_contents($tmp, $body); + ensure_label($repo, 'needs-respin'); + $cmd = 'gh issue create --repo '.escapeshellarg($repo) + .' --title '.escapeshellarg($title) + .' --body-file '.escapeshellarg($tmp) + .' --label '.escapeshellarg('needs-respin') + .' 2>&1'; + exec($cmd, $out, $code); + if ($code !== 0) { + $out = []; + $cmd = 'gh issue create --repo '.escapeshellarg($repo) + .' --title '.escapeshellarg($title) + .' --body-file '.escapeshellarg($tmp) + .' 2>&1'; + exec($cmd, $out, $code); + } + @unlink($tmp); + $text = implode("\n", $out); + if ($code !== 0 || !preg_match('#/issues/(\d+)#', $text, $m)) { + fwrite(STDERR, "audit-closed-but-partial: failed to create umbrella issue: {$text}\n"); + exit(1); + } + + return (int) $m[1]; +} + +function ensure_label(string $repo, string $label): void +{ + $cmd = 'gh label list --repo '.escapeshellarg($repo).' --limit 200 --json name -q '."'.[].name' 2>/dev/null"; + // simpler: + $cmd = 'gh label list --repo '.escapeshellarg($repo).' --limit 200 --json name 2>/dev/null'; + $data = json_decode((string) shell_exec($cmd), true); + $names = []; + if (is_array($data)) { + foreach ($data as $row) { + $names[] = (string) ($row['name'] ?? ''); + } + } + if (in_array($label, $names, true)) { + return; + } + $create = 'gh label create '.escapeshellarg($label) + .' --repo '.escapeshellarg($repo) + .' --description '.escapeshellarg('Closed before Done-when was complete; needs a respin child (#36400)') + .' --color '.escapeshellarg('B60205').' 2>&1'; + exec($create, $out, $code); + if ($code !== 0) { + fwrite(STDERR, "audit-closed-but-partial: could not create label {$label}: ".implode("\n", $out)."\n"); + } +} + +function apply_label(string $repo, int $number, string $label): void +{ + $cmd = 'gh issue edit '.escapeshellarg((string) $number) + .' --repo '.escapeshellarg($repo) + .' --add-label '.escapeshellarg($label).' 2>&1'; + exec($cmd, $out, $code); + if ($code !== 0) { + fwrite(STDERR, "audit-closed-but-partial: failed to label #{$number}: ".implode("\n", $out)."\n"); + } +} + +function post_tracker_comment(string $repo, int $tracker, string $body): void +{ + $tmp = tempnam(sys_get_temp_dir(), 'audit36400-'); + if ($tmp === false) { + fwrite(STDERR, "audit-closed-but-partial: tempnam failed\n"); + exit(1); + } + file_put_contents($tmp, $body); + $cmd = 'gh issue comment '.escapeshellarg((string) $tracker) + .' --repo '.escapeshellarg($repo) + .' --body-file '.escapeshellarg($tmp).' 2>&1'; + exec($cmd, $out, $code); + @unlink($tmp); + if ($code !== 0) { + fwrite(STDERR, "audit-closed-but-partial: failed to comment on #{$tracker}: ".implode("\n", $out)."\n"); + exit(1); + } +} diff --git a/script/check-issue-close-scope.php b/script/check-issue-close-scope.php new file mode 100755 index 00000000000..104972a8184 --- /dev/null +++ b/script/check-issue-close-scope.php @@ -0,0 +1,270 @@ +#!/usr/bin/env php + + */ +function extract_closes_numbers(string $body): array +{ + // Do not treat "Part of #N" as a close. Match Closes/Fixes/Resolves variants. + if (!preg_match_all( + '/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)\b/i', + $body, + $m + )) { + return []; + } + $nums = array_map('intval', $m[1]); + $nums = array_values(array_unique($nums)); + sort($nums); + + return $nums; +} + +/** + * @return list normalized checkbox texts (no leading - [x]/ empty if none + */ +function extract_done_when_items(string $issueBody): array +{ + if (!preg_match('/^##\s+Done when\s*$/mi', $issueBody, $m, PREG_OFFSET_CAPTURE)) { + return []; + } + $start = (int) $m[0][1] + strlen($m[0][0]); + $rest = substr($issueBody, $start); + // Stop at next ## heading or EOF + if (preg_match('/^##\s+/m', $rest, $next, PREG_OFFSET_CAPTURE)) { + $rest = substr($rest, 0, (int) $next[0][1]); + } + $items = []; + if (preg_match_all('/^\s*-\s*\[([ xX])\]\s*(.+?)\s*$/m', $rest, $boxes, PREG_SET_ORDER)) { + foreach ($boxes as $box) { + $text = normalize_checkbox_text($box[2]); + if ($text !== '') { + $items[] = $text; + } + } + } + + return $items; +} + +function normalize_checkbox_text(string $text): string +{ + $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $text = preg_replace('/\s+/u', ' ', trim($text)) ?? trim($text); + + return $text; +} + +/** + * @return list ticked checkbox texts in PR body + */ +function extract_ticked_items(string $prBody): array +{ + $items = []; + if (preg_match_all('/^\s*-\s*\[[xX]\]\s*(.+?)\s*$/m', $prBody, $boxes, PREG_SET_ORDER)) { + foreach ($boxes as $box) { + $text = normalize_checkbox_text($box[1]); + if ($text !== '') { + $items[] = $text; + } + } + } + + return $items; +} + +/** + * @return list error messages + */ +function validate_closes_against_issue(int $issueNumber, string $issueBody, string $prBody): array +{ + $errors = []; + $required = extract_done_when_items($issueBody); + if ($required === []) { + $errors[] = "Closes #{$issueNumber}: issue has no ## Done when checklist — add one before closing, or use Part of #{$issueNumber}"; + + return $errors; + } + $ticked = extract_ticked_items($prBody); + $tickedSet = array_fill_keys($ticked, true); + foreach ($required as $item) { + if (!isset($tickedSet[$item])) { + $errors[] = "Closes #{$issueNumber}: missing ticked Done-when item: `- [x] {$item}`"; + } + } + + return $errors; +} + +function fetch_issue_body(string $repo, int $number): ?string +{ + $cmd = 'gh issue view '.escapeshellarg((string) $number) + .' --repo '.escapeshellarg($repo) + .' --json body -q .body 2>/dev/null'; + $out = shell_exec($cmd); + if ($out === null || $out === '') { + return null; + } + + return $out; +} + +function run_self_test(string $root): int +{ + $fixtureDir = $root.'/test/fixtures/issue-close-scope'; + $issue = $fixtureDir.'/sample-issue.md'; + $bad = $fixtureDir.'/pr-closes-without-ticks.md'; + $good = $fixtureDir.'/pr-closes-with-ticks.md'; + $part = $fixtureDir.'/pr-part-of-only.md'; + foreach ([$issue, $bad, $good, $part] as $path) { + if (!is_readable($path)) { + fwrite(STDERR, "check-issue-close-scope: missing fixture {$path}\n"); + + return 1; + } + } + + $php = escapeshellarg(PHP_BINARY); + $script = escapeshellarg($root.'/script/check-issue-close-scope.php'); + + $badCmd = "{$php} {$script} --pr-body ".escapeshellarg($bad) + .' --issue-body '.escapeshellarg($issue).' --issue-number 36400'; + exec($badCmd.' 2>&1', $badOut, $badCode); + if ($badCode === 0) { + fwrite(STDERR, "check-issue-close-scope: self-test FAIL — expected reject for Closes without ticks\n"); + fwrite(STDERR, implode("\n", $badOut)."\n"); + + return 1; + } + $badText = implode("\n", $badOut); + if (!str_contains($badText, 'missing ticked Done-when item')) { + fwrite(STDERR, "check-issue-close-scope: self-test FAIL — expected missing-tick message\n{$badText}\n"); + + return 1; + } + + $goodCmd = "{$php} {$script} --pr-body ".escapeshellarg($good) + .' --issue-body '.escapeshellarg($issue).' --issue-number 36400'; + exec($goodCmd.' 2>&1', $goodOut, $goodCode); + if ($goodCode !== 0) { + fwrite(STDERR, "check-issue-close-scope: self-test FAIL — expected OK for fully ticked Closes\n"); + fwrite(STDERR, implode("\n", $goodOut)."\n"); + + return 1; + } + + $partCmd = "{$php} {$script} --pr-body ".escapeshellarg($part) + .' --issue-body '.escapeshellarg($issue).' --issue-number 36400'; + exec($partCmd.' 2>&1', $partOut, $partCode); + if ($partCode !== 0) { + fwrite(STDERR, "check-issue-close-scope: self-test FAIL — Part of #N must pass without ticks\n"); + fwrite(STDERR, implode("\n", $partOut)."\n"); + + return 1; + } + + fwrite(STDOUT, "check-issue-close-scope: self-test OK (reject bare Closes; accept ticked Closes; accept Part of)\n"); + + return 0; +} diff --git a/script/ci-common.sh b/script/ci-common.sh index 323a902b4d1..ba1839396d1 100644 --- a/script/ci-common.sh +++ b/script/ci-common.sh @@ -123,6 +123,14 @@ ci_run_m2_spine_issue_hygiene_check() { "$PHP_BIN" "${PHP_OPTS[@]}" script/check-m2-spine-issue-hygiene.php } +ci_run_issue_close_scope_check() { + if [[ "${ISSUE_CLOSE_SCOPE_GATE:-1}" != "1" ]]; then + return 0 + fi + echo "Issue close-scope self-test (ISSUE_CLOSE_SCOPE_GATE=1, issue #36400)..." + "$PHP_BIN" "${PHP_OPTS[@]}" script/check-issue-close-scope.php --self-test +} + ci_run_examples_readme_sync_check() { if [[ "${EXAMPLES_README_SYNC_GATE:-1}" != "1" ]]; then return 0 @@ -558,6 +566,7 @@ ci_run_inventory_checks() { ci_ensure_generated_doc script/bootstrap-profile.php docs/bootstrap-profile.json ci_run_wave3_roadmap_sync_check ci_run_m2_spine_issue_hygiene_check + ci_run_issue_close_scope_check ci_run_examples_readme_sync_check ci_run_examples_ladder_discovery_check ci_run_rebuild_examples_005_sync_check diff --git a/script/ci-defaults.env b/script/ci-defaults.env index 9ece6b55289..7df9019ed5b 100755 --- a/script/ci-defaults.env +++ b/script/ci-defaults.env @@ -75,6 +75,7 @@ export BOOTSTRAP_PHPTYPES_UNIT_PROBE_GATE="${BOOTSTRAP_PHPTYPES_UNIT_PROBE_GATE: export BOOTSTRAP_M3_EMIT_TU_EXECUTE_GATE="${BOOTSTRAP_M3_EMIT_TU_EXECUTE_GATE:-0}" # opt-in (#2444); PHPUnit @group selfhost-m3-emit after unit probes; default-on after #2442 green export CAPABILITY_SYNTAX_CHECK="${CAPABILITY_SYNTAX_CHECK:-1}" export M2_SPINE_ISSUE_HYGIENE_GATE="${M2_SPINE_ISSUE_HYGIENE_GATE:-1}" # default on (#1819); opt-out for bulk spine PRs +export ISSUE_CLOSE_SCOPE_GATE="${ISSUE_CLOSE_SCOPE_GATE:-1}" # default on (#36400); self-test that bare Closes #N is rejected without ticked Done-when export WAVE3_ROADMAP_SYNC_GATE="${WAVE3_ROADMAP_SYNC_GATE:-1}" # default on (#1814); opt-out WAVE3_ROADMAP_SYNC_GATE=0 for doc-only iteration export EXAMPLES_README_SYNC_GATE="${EXAMPLES_README_SYNC_GATE:-1}" # default on (#1822); opt-out EXAMPLES_README_SYNC_GATE=0 for doc-only iteration export EXAMPLES_LADDER_DISCOVERY_GATE="${EXAMPLES_LADDER_DISCOVERY_GATE:-1}" # default on (#1913); opt-out EXAMPLES_LADDER_DISCOVERY_GATE=0 for doc-only iteration diff --git a/test/fixtures/issue-close-scope/pr-closes-with-ticks.md b/test/fixtures/issue-close-scope/pr-closes-with-ticks.md new file mode 100644 index 00000000000..f89c5c58c56 --- /dev/null +++ b/test/fixtures/issue-close-scope/pr-closes-with-ticks.md @@ -0,0 +1,12 @@ +## Summary + +Closes #36400 with every Done-when box ticked. + +## Done when (from #36400) + +- [x] Template + gate live; a test PR that says "Closes #N" without the ticked list is rejected by the gate +- [x] First weekly audit posted; every `needs-respin` from it has a fresh child issue + +## Test plan + +- [x] `php script/check-issue-close-scope.php --self-test` diff --git a/test/fixtures/issue-close-scope/pr-closes-without-ticks.md b/test/fixtures/issue-close-scope/pr-closes-without-ticks.md new file mode 100644 index 00000000000..e04f38876fa --- /dev/null +++ b/test/fixtures/issue-close-scope/pr-closes-without-ticks.md @@ -0,0 +1,7 @@ +## Summary + +Closes #36400 without ticking Done-when — must be rejected by the gate. + +## Test plan + +- [ ] something unrelated diff --git a/test/fixtures/issue-close-scope/pr-part-of-only.md b/test/fixtures/issue-close-scope/pr-part-of-only.md new file mode 100644 index 00000000000..fae7e0a1b2b --- /dev/null +++ b/test/fixtures/issue-close-scope/pr-part-of-only.md @@ -0,0 +1,9 @@ +## Summary + +Partial slice only — does not close the issue. + +Part of #36400 + +## Remaining + +- Weekly audit automation still needs a scheduled job diff --git a/test/fixtures/issue-close-scope/sample-issue.md b/test/fixtures/issue-close-scope/sample-issue.md new file mode 100644 index 00000000000..aa6f7aa1a76 --- /dev/null +++ b/test/fixtures/issue-close-scope/sample-issue.md @@ -0,0 +1,8 @@ +## Done when + +- [ ] Template + gate live; a test PR that says "Closes #N" without the ticked list is rejected by the gate +- [ ] First weekly audit posted; every `needs-respin` from it has a fresh child issue + +## Notes + +Fixture issue body for `script/check-issue-close-scope.php --self-test` (#36400). diff --git a/test/unit/IssueCloseScopeTest.php b/test/unit/IssueCloseScopeTest.php new file mode 100644 index 00000000000..e764a803240 --- /dev/null +++ b/test/unit/IssueCloseScopeTest.php @@ -0,0 +1,50 @@ +&1'; + exec($cmd, $out, $code); + $this->assertSame(0, $code, implode("\n", $out)); + $this->assertStringContainsString('self-test OK', implode("\n", $out)); + } + + public function testContributingDocumentsDefinitionOfDone(): void + { + $body = (string) file_get_contents(dirname(__DIR__, 2).'/CONTRIBUTING.md'); + $this->assertStringContainsString('Definition of Done', $body); + $this->assertStringContainsString('check-issue-close-scope.php', $body); + $this->assertStringContainsString('Part of #N', $body); + } + + public function testPullRequestTemplateExists(): void + { + $path = dirname(__DIR__, 2).'/.github/PULL_REQUEST_TEMPLATE.md'; + $this->assertFileExists($path); + $body = (string) file_get_contents($path); + $this->assertStringContainsString('Done when', $body); + $this->assertStringContainsString('Part of', $body); + $this->assertStringContainsString('#36400', $body); + } + + public function testCiRunsCloseScopeSelfTest(): void + { + $common = (string) file_get_contents(dirname(__DIR__, 2).'/script/ci-common.sh'); + $this->assertStringContainsString('check-issue-close-scope.php', $common); + $defaults = (string) file_get_contents(dirname(__DIR__, 2).'/script/ci-defaults.env'); + $this->assertStringContainsString('ISSUE_CLOSE_SCOPE_GATE', $defaults); + } +}