Auto-close bug reports opened without a valid GSD Version (Issue Forms enforce required only in the web UI). Bug reports only; version-shaped validation; version-exempt opt-out. Closes #1180
This commit is contained in:
5
.changeset/issue-version-gate.md
Normal file
5
.changeset/issue-version-gate.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 1181
|
||||
---
|
||||
Bug-report issues that lack a valid GSD Version are now auto-closed on open by a new `version-gate.yml` GitHub Actions workflow. GitHub Issue Forms only enforce `required: true` in the web UI, so issues filed via the REST API, `gh issue create`, or AI reporters can arrive without a version; values like `idk`, `_No response_`, or an empty field are treated as missing. Affected issues receive a closing comment with instructions to add the version (e.g. `1.18.0`) and reopen; maintainers can add the `version-exempt` label to opt an issue out.
|
||||
47
.github/workflows/version-gate.yml
vendored
Normal file
47
.github/workflows/version-gate.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
name: Issue Version Gate
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const gate = require(`${process.env.GITHUB_WORKSPACE}/scripts/issue-version-gate.cjs`);
|
||||
const issue = context.payload.issue;
|
||||
if (!issue) return;
|
||||
if (issue.pull_request) return;
|
||||
const labels = (issue.labels || []).map((l) => (typeof l === 'string' ? l : l.name));
|
||||
const result = gate.evaluateVersionGate({ labels, body: issue.body || '' });
|
||||
core.info(`version-gate: #${issue.number} → ${result.action} (${result.reason})`);
|
||||
if (result.action !== 'close') return;
|
||||
const { owner, repo } = context.repo;
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number: issue.number,
|
||||
labels: [gate.NEEDS_VERSION_LABEL],
|
||||
});
|
||||
} catch (err) {
|
||||
core.warning(`could not add ${gate.NEEDS_VERSION_LABEL} label: ${err.message}`);
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: issue.number,
|
||||
body: gate.renderCloseComment(),
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner, repo, issue_number: issue.number,
|
||||
state: 'closed', state_reason: 'not_planned',
|
||||
});
|
||||
@@ -10,6 +10,8 @@ Maps the five canonical triage roles to the actual label strings in `open-gsd/gs
|
||||
| `ready-for-human` | `approved-enhancement` / `approved-feature` | Enhancement/feature approved by maintainer — human codes it |
|
||||
| `wontfix` | `wontfix` | Will not be actioned |
|
||||
| `possible-duplicate` | `possible-duplicate` | Applied by the Duplicate check workflow when a new issue's title closely matches existing open issues. The reporter (or a maintainer) replies justifying why it is not a duplicate within 24h, or the Duplicate auto-close sweep closes it. A reply clears this label and applies needs-maintainer-review for human adjudication. React 👎 to the bot comment to veto auto-close. |
|
||||
| `needs-version` | `needs-version` | Applied by the Version gate workflow when a bug report is auto-closed for missing a valid GSD Version. Edit the issue to add the version and reopen it. |
|
||||
| `version-exempt` | `version-exempt` | Maintainer-only opt-out label. Prevents the Version gate from closing an issue where a version genuinely does not apply. |
|
||||
|
||||
## Notes on this repo's label model
|
||||
|
||||
@@ -27,3 +29,14 @@ The `possible-duplicate` label is managed by three GitHub Actions workflows that
|
||||
2. **Challenge comment + reporter window** — The reporter (or a maintainer) has `DEFAULT_WINDOW_HOURS` (24h) to reply explaining why the issue is not a duplicate. Reacting 👎 to the bot comment also signals the reporter objects to auto-close.
|
||||
3. **Daily sweep auto-close** — `duplicate-sweep.yml` runs at 07:00 UTC daily. For each open issue with `possible-duplicate`, it checks whether the window has elapsed, whether the reporter replied, and whether a 👎 reaction exists. Issues with exempt labels (`priority: critical`, `pinned`, `confirmed-bug`, `confirmed`, `fix-pending`) are never auto-closed. Issues that pass the close check receive a closing comment and are closed with `state_reason: duplicate`.
|
||||
4. **Reporter reply clears label** — `remove-duplicate-label.yml` fires on every new non-bot comment. If the issue still carries `possible-duplicate`, it removes that label and applies `needs-maintainer-review` (the value of `HUMAN_REVIEW_LABEL` in `scripts/issue-dedupe.cjs`), routing the issue to a maintainer for manual adjudication.
|
||||
|
||||
## Version gate lifecycle
|
||||
|
||||
The `needs-version` label is managed by a single GitHub Actions workflow that runs immediately on issue open:
|
||||
|
||||
1. **Gate on open** — When an issue is opened, `version-gate.yml` checks whether it is a bug report (has the `bug` label, or its body contains a `### GSD Version` heading from the bug template). Non-bug issues are skipped.
|
||||
2. **Version check** — The gate extracts the value under the `### GSD Version` heading. A value is considered valid if it contains a semver-ish token (e.g. `1.18.0`, `v1.4.1`, `1.18.0-dev`) or a git commit SHA (7-40 hex chars). Missing, blank, `_No response_`, or junk values like `idk` are treated as absent.
|
||||
3. **Auto-close** — If the version is absent or invalid, the workflow posts a comment with instructions and closes the issue as `not_planned`, then applies `needs-version`.
|
||||
4. **Reopen path** — The reporter edits the issue to add a valid version and reopens it. Alternatively, a maintainer can add the `version-exempt` label to any bug where a version does not apply (e.g. docs bugs, spec questions), which prevents the gate from closing it on future edits.
|
||||
|
||||
The gate logic lives in `scripts/issue-version-gate.cjs` (pure exports, no GitHub API dependency) and is covered by `tests/issue-version-gate.test.cjs`.
|
||||
|
||||
140
scripts/issue-version-gate.cjs
Normal file
140
scripts/issue-version-gate.cjs
Normal file
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const { PACKAGE_NAME } = require('../gsd-core/bin/lib/package-identity.cjs');
|
||||
|
||||
/**
|
||||
* Version gate for bug-report issues.
|
||||
*
|
||||
* GitHub Issue Forms enforce `required: true` only in the web form; issues
|
||||
* filed via the REST API, `gh issue create`, or automated/AI reporters can
|
||||
* omit the GSD Version field entirely. This module provides the pure logic
|
||||
* for detecting bug reports missing a usable version so a workflow can
|
||||
* auto-close them. See .github/workflows/version-gate.yml.
|
||||
*/
|
||||
|
||||
const BUG_LABEL = 'bug';
|
||||
const NEEDS_VERSION_LABEL = 'needs-version';
|
||||
const VERSION_GATE_MARKER = '<!-- gsd-version-gate -->';
|
||||
|
||||
// Labels that opt an issue out of the version gate.
|
||||
const EXEMPT_LABELS = ['version-exempt'];
|
||||
|
||||
// Heading GitHub renders for the bug template's `label: GSD Version` field.
|
||||
// Issue Forms render input labels as `### <label>`; matches the exact heading
|
||||
// `### GSD Version` (any heading level) — bare `### Version` is NOT matched.
|
||||
const VERSION_HEADING_RE = /^#{1,6}\s*GSD\s+Version\s*$/i;
|
||||
|
||||
// GitHub's placeholder for an empty optional form field.
|
||||
const NO_RESPONSE_RE = /^_no response_$/i;
|
||||
|
||||
// A value "looks like a version" if it contains a semver-ish token
|
||||
// (1.18, v1.4.1, 1.18.0-dev) or a git commit SHA (7-40 hex chars).
|
||||
const SEMVER_TOKEN_RE = /\bv?\d+\.\d+(?:\.\d+)?(?:[-+.][0-9A-Za-z.-]+)?\b/;
|
||||
const SHA_TOKEN_RE = /\b[0-9a-f]{7,40}\b/i;
|
||||
|
||||
function normalizeLabels(labels) {
|
||||
if (!Array.isArray(labels)) return [];
|
||||
return labels
|
||||
.map((l) => (typeof l === 'string' ? l : l && l.name))
|
||||
.filter(Boolean)
|
||||
.map((l) => String(l).toLowerCase());
|
||||
}
|
||||
|
||||
function hasExemptLabel(labels) {
|
||||
const names = normalizeLabels(labels);
|
||||
return names.some((l) => EXEMPT_LABELS.includes(l));
|
||||
}
|
||||
|
||||
function isBugReport({ labels, body } = {}) {
|
||||
const names = normalizeLabels(labels);
|
||||
if (names.includes(BUG_LABEL)) return true;
|
||||
// Labels are authoritative: if any label was applied and it isn't `bug`,
|
||||
// this is not a bug report (e.g. an `enhancement`-labeled issue that happens
|
||||
// to contain a version heading must not be gated). Only fall back to the
|
||||
// bug-template's `### GSD Version` heading for fully unlabeled submissions
|
||||
// (bare REST API / `gh issue create`).
|
||||
if (names.length > 0) return false;
|
||||
return hasVersionHeading(body);
|
||||
}
|
||||
|
||||
function hasVersionHeading(body) {
|
||||
if (!body) return false;
|
||||
return String(body)
|
||||
.split(/\r?\n/)
|
||||
.some((line) => VERSION_HEADING_RE.test(line));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the value beneath the "GSD Version" heading. Returns null when the
|
||||
* section is absent, '' when the section is present but empty.
|
||||
*/
|
||||
function extractVersion(body) {
|
||||
if (!body) return null;
|
||||
const lines = String(body).split(/\r?\n/);
|
||||
let start = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (VERSION_HEADING_RE.test(lines[i])) {
|
||||
start = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (start === -1) return null;
|
||||
const collected = [];
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
if (/^#{1,6}\s/.test(lines[i])) break; // next section heading
|
||||
collected.push(lines[i]);
|
||||
}
|
||||
return collected.join('\n').trim();
|
||||
}
|
||||
|
||||
function isValidVersion(value) {
|
||||
if (value == null) return false;
|
||||
const v = String(value).trim();
|
||||
if (!v) return false;
|
||||
if (NO_RESPONSE_RE.test(v)) return false;
|
||||
return SEMVER_TOKEN_RE.test(v) || SHA_TOKEN_RE.test(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide what to do with an issue. Returns { action: 'skip'|'close', reason }.
|
||||
*/
|
||||
function evaluateVersionGate({ labels, body } = {}) {
|
||||
if (hasExemptLabel(labels)) return { action: 'skip', reason: 'exempt-label' };
|
||||
if (!isBugReport({ labels, body })) return { action: 'skip', reason: 'not-a-bug' };
|
||||
const version = extractVersion(body);
|
||||
if (isValidVersion(version)) return { action: 'skip', reason: 'valid-version' };
|
||||
const reason = version == null ? 'missing-version' : 'invalid-version';
|
||||
return { action: 'close', reason };
|
||||
}
|
||||
|
||||
function renderCloseComment() {
|
||||
return [
|
||||
VERSION_GATE_MARKER,
|
||||
'Closing automatically — this bug report does not include a valid **GSD Version**.',
|
||||
'',
|
||||
'A version is required to reproduce and triage bugs. Grab it with one of:',
|
||||
'',
|
||||
'```',
|
||||
`npm list -g ${PACKAGE_NAME}`,
|
||||
`npx ${PACKAGE_NAME} --version`,
|
||||
'```',
|
||||
'',
|
||||
'Then **edit this issue to add the version (e.g. `1.18.0`) and reopen it**, or comment with the version and a maintainer will reopen it. If a version genuinely does not apply, a maintainer can add the `version-exempt` label.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BUG_LABEL,
|
||||
NEEDS_VERSION_LABEL,
|
||||
VERSION_GATE_MARKER,
|
||||
EXEMPT_LABELS,
|
||||
normalizeLabels,
|
||||
hasExemptLabel,
|
||||
isBugReport,
|
||||
hasVersionHeading,
|
||||
extractVersion,
|
||||
isValidVersion,
|
||||
evaluateVersionGate,
|
||||
renderCloseComment,
|
||||
};
|
||||
546
tests/issue-version-gate.test.cjs
Normal file
546
tests/issue-version-gate.test.cjs
Normal file
@@ -0,0 +1,546 @@
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
BUG_LABEL,
|
||||
NEEDS_VERSION_LABEL,
|
||||
VERSION_GATE_MARKER,
|
||||
EXEMPT_LABELS,
|
||||
normalizeLabels,
|
||||
hasExemptLabel,
|
||||
isBugReport,
|
||||
hasVersionHeading,
|
||||
extractVersion,
|
||||
isValidVersion,
|
||||
evaluateVersionGate,
|
||||
renderCloseComment,
|
||||
} = require('../scripts/issue-version-gate.cjs');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers — realistic issue body templates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a template-shaped bug body with the given version value (or none).
|
||||
*/
|
||||
function bugBody(versionValue) {
|
||||
const versionSection =
|
||||
versionValue === undefined
|
||||
? '' // no section at all
|
||||
: `### GSD Version\n\n${versionValue}\n\n`;
|
||||
return (
|
||||
versionSection +
|
||||
'### What happened?\n\nSomething broke.\n\n' +
|
||||
'### Steps to reproduce\n\n1. Run gsd\n2. Observe error\n\n' +
|
||||
'### Expected behavior\n\nIt should work.'
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('issue-version-gate constants', () => {
|
||||
test('BUG_LABEL is "bug"', () => {
|
||||
assert.equal(BUG_LABEL, 'bug');
|
||||
});
|
||||
|
||||
test('NEEDS_VERSION_LABEL is "needs-version"', () => {
|
||||
assert.equal(NEEDS_VERSION_LABEL, 'needs-version');
|
||||
});
|
||||
|
||||
test('VERSION_GATE_MARKER is an HTML comment string', () => {
|
||||
assert.ok(typeof VERSION_GATE_MARKER === 'string');
|
||||
assert.ok(VERSION_GATE_MARKER.startsWith('<!--'));
|
||||
assert.ok(VERSION_GATE_MARKER.endsWith('-->'));
|
||||
});
|
||||
|
||||
test('EXEMPT_LABELS includes version-exempt', () => {
|
||||
assert.ok(Array.isArray(EXEMPT_LABELS));
|
||||
assert.ok(EXEMPT_LABELS.includes('version-exempt'));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// normalizeLabels
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('normalizeLabels', () => {
|
||||
test('returns [] for non-array', () => {
|
||||
assert.deepEqual(normalizeLabels(null), []);
|
||||
assert.deepEqual(normalizeLabels(undefined), []);
|
||||
assert.deepEqual(normalizeLabels('bug'), []);
|
||||
});
|
||||
|
||||
test('handles string labels', () => {
|
||||
assert.deepEqual(normalizeLabels(['Bug', 'Enhancement']), ['bug', 'enhancement']);
|
||||
});
|
||||
|
||||
test('handles object labels with .name', () => {
|
||||
assert.deepEqual(normalizeLabels([{ name: 'Bug' }, { name: 'triage' }]), ['bug', 'triage']);
|
||||
});
|
||||
|
||||
test('handles mixed string and object labels', () => {
|
||||
assert.deepEqual(normalizeLabels(['bug', { name: 'version-exempt' }]), ['bug', 'version-exempt']);
|
||||
});
|
||||
|
||||
test('filters falsy entries', () => {
|
||||
assert.deepEqual(normalizeLabels([null, undefined, 'bug', '']), ['bug']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// hasExemptLabel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('hasExemptLabel', () => {
|
||||
test('true when labels include "version-exempt" as string', () => {
|
||||
assert.equal(hasExemptLabel(['bug', 'version-exempt']), true);
|
||||
});
|
||||
|
||||
test('true when labels include "version-exempt" as object', () => {
|
||||
assert.equal(hasExemptLabel([{ name: 'bug' }, { name: 'version-exempt' }]), true);
|
||||
});
|
||||
|
||||
test('true case-insensitive', () => {
|
||||
assert.equal(hasExemptLabel(['Version-Exempt']), true);
|
||||
});
|
||||
|
||||
test('false when exempt label absent', () => {
|
||||
assert.equal(hasExemptLabel(['bug', 'needs-triage']), false);
|
||||
});
|
||||
|
||||
test('false for empty labels', () => {
|
||||
assert.equal(hasExemptLabel([]), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// hasVersionHeading
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('hasVersionHeading', () => {
|
||||
test('true for "### GSD Version" heading', () => {
|
||||
assert.equal(hasVersionHeading(bugBody('1.18.0')), true);
|
||||
});
|
||||
|
||||
test('false for "### Version" (without GSD prefix — bare Version no longer matches)', () => {
|
||||
assert.equal(hasVersionHeading('### Version\n\n1.0.0'), false);
|
||||
});
|
||||
|
||||
test('true for h1 "# GSD Version"', () => {
|
||||
assert.equal(hasVersionHeading('# GSD Version\n\n1.0.0'), true);
|
||||
});
|
||||
|
||||
test('false for body with no version heading', () => {
|
||||
assert.equal(
|
||||
hasVersionHeading('### What happened?\n\nSomething broke.\n\n### Steps to reproduce\n\n1. Run gsd'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('false for null/undefined body', () => {
|
||||
assert.equal(hasVersionHeading(null), false);
|
||||
assert.equal(hasVersionHeading(undefined), false);
|
||||
});
|
||||
|
||||
test('false for empty body', () => {
|
||||
assert.equal(hasVersionHeading(''), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// extractVersion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('extractVersion', () => {
|
||||
test('returns the version value when section present with "1.18.0"', () => {
|
||||
const body = bugBody('1.18.0');
|
||||
assert.equal(extractVersion(body), '1.18.0');
|
||||
});
|
||||
|
||||
test('returns null when section is absent', () => {
|
||||
// bugBody with no arg produces no ### GSD Version section
|
||||
const body = bugBody(undefined);
|
||||
assert.equal(extractVersion(body), null);
|
||||
});
|
||||
|
||||
test('returns "" when section present but value is blank line', () => {
|
||||
const body = '### GSD Version\n\n\n### What happened?\n\nSomething broke.';
|
||||
assert.equal(extractVersion(body), '');
|
||||
});
|
||||
|
||||
test('returns "_No response_" raw string for GitHub placeholder (isValidVersion will reject it)', () => {
|
||||
const body = '### GSD Version\n\n_No response_\n\n### What happened?';
|
||||
assert.equal(extractVersion(body), '_No response_');
|
||||
});
|
||||
|
||||
test('trims surrounding blank lines from extracted value', () => {
|
||||
const body = '### GSD Version\n\n\n 1.18.0 \n\n### What happened?';
|
||||
assert.equal(extractVersion(body), '1.18.0');
|
||||
});
|
||||
|
||||
test('stops at the next section heading', () => {
|
||||
const body = '### GSD Version\n\n1.4.1\n\n### What happened?\n\n1.18.0';
|
||||
assert.equal(extractVersion(body), '1.4.1');
|
||||
});
|
||||
|
||||
test('returns null for null body', () => {
|
||||
assert.equal(extractVersion(null), null);
|
||||
});
|
||||
|
||||
test('returns null for empty body', () => {
|
||||
assert.equal(extractVersion(''), null);
|
||||
});
|
||||
|
||||
test('handles CRLF line endings', () => {
|
||||
const body = '### GSD Version\r\n\r\n1.18.0\r\n\r\n### What happened?';
|
||||
assert.equal(extractVersion(body), '1.18.0');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isValidVersion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('isValidVersion', () => {
|
||||
test('true for "1.18.0"', () => {
|
||||
assert.equal(isValidVersion('1.18.0'), true);
|
||||
});
|
||||
|
||||
test('true for "v1.4.1"', () => {
|
||||
assert.equal(isValidVersion('v1.4.1'), true);
|
||||
});
|
||||
|
||||
test('true for "1.18.0-dev"', () => {
|
||||
assert.equal(isValidVersion('1.18.0-dev'), true);
|
||||
});
|
||||
|
||||
test('true for "1.4" (two-part semver)', () => {
|
||||
assert.equal(isValidVersion('1.4'), true);
|
||||
});
|
||||
|
||||
test('true for a 7-char git SHA "a19a709"', () => {
|
||||
assert.equal(isValidVersion('a19a709'), true);
|
||||
});
|
||||
|
||||
test('true for an 8-char git SHA "a19a709e"', () => {
|
||||
assert.equal(isValidVersion('a19a709e'), true);
|
||||
});
|
||||
|
||||
test('true for a 40-char full git SHA', () => {
|
||||
assert.equal(isValidVersion('a19a709e' + '0'.repeat(32)), true);
|
||||
});
|
||||
|
||||
test('true for version with build metadata "1.18.0+build.1"', () => {
|
||||
assert.equal(isValidVersion('1.18.0+build.1'), true);
|
||||
});
|
||||
|
||||
test('false for null', () => {
|
||||
assert.equal(isValidVersion(null), false);
|
||||
});
|
||||
|
||||
test('false for empty string', () => {
|
||||
assert.equal(isValidVersion(''), false);
|
||||
});
|
||||
|
||||
test('false for whitespace-only', () => {
|
||||
assert.equal(isValidVersion(' '), false);
|
||||
});
|
||||
|
||||
test('false for "_No response_"', () => {
|
||||
assert.equal(isValidVersion('_No response_'), false);
|
||||
});
|
||||
|
||||
test('false for "_no response_" (lowercase)', () => {
|
||||
assert.equal(isValidVersion('_no response_'), false);
|
||||
});
|
||||
|
||||
test('false for "idk"', () => {
|
||||
assert.equal(isValidVersion('idk'), false);
|
||||
});
|
||||
|
||||
test('false for "latest"', () => {
|
||||
assert.equal(isValidVersion('latest'), false);
|
||||
});
|
||||
|
||||
test('false for "main"', () => {
|
||||
assert.equal(isValidVersion('main'), false);
|
||||
});
|
||||
|
||||
test('false for "unknown"', () => {
|
||||
assert.equal(isValidVersion('unknown'), false);
|
||||
});
|
||||
|
||||
test('false for "v2" (no dot — not semver-shaped)', () => {
|
||||
assert.equal(isValidVersion('v2'), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isBugReport
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('isBugReport', () => {
|
||||
test('true when labels include "bug" as string', () => {
|
||||
assert.equal(isBugReport({ labels: ['bug'], body: '' }), true);
|
||||
});
|
||||
|
||||
test('true when labels include "bug" as object', () => {
|
||||
assert.equal(isBugReport({ labels: [{ name: 'bug' }], body: '' }), true);
|
||||
});
|
||||
|
||||
test('true when labels include "Bug" (case-insensitive)', () => {
|
||||
assert.equal(isBugReport({ labels: ['Bug'], body: '' }), true);
|
||||
});
|
||||
|
||||
test('true when no bug label but body has "### GSD Version" heading', () => {
|
||||
// API-filed bug: copied template body but omitted labels
|
||||
const body = bugBody('1.18.0');
|
||||
assert.equal(isBugReport({ labels: [], body }), true);
|
||||
});
|
||||
|
||||
test('false when no bug label and body has bare "### Version" heading (no GSD prefix — not matched)', () => {
|
||||
assert.equal(isBugReport({ labels: [], body: '### Version\n\n1.0.0\n\n### Steps' }), false);
|
||||
});
|
||||
|
||||
test('false for feature request: no bug label, no version heading', () => {
|
||||
const body =
|
||||
'### Feature Description\n\nI would like X.\n\n' +
|
||||
'### Motivation\n\nBecause of Y.';
|
||||
assert.equal(isBugReport({ labels: ['enhancement'], body }), false);
|
||||
});
|
||||
|
||||
test('false when called with no arguments', () => {
|
||||
assert.equal(isBugReport(), false);
|
||||
});
|
||||
|
||||
test('false for non-bug labels and no version heading', () => {
|
||||
assert.equal(isBugReport({ labels: ['enhancement', 'needs-triage'], body: '' }), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// evaluateVersionGate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('evaluateVersionGate', () => {
|
||||
test('bug label + valid version → skip / valid-version', () => {
|
||||
const result = evaluateVersionGate({ labels: ['bug'], body: bugBody('1.18.0') });
|
||||
assert.deepEqual(result, { action: 'skip', reason: 'valid-version' });
|
||||
});
|
||||
|
||||
test('bug label + no version section → close / missing-version', () => {
|
||||
// bug label present but body has no ### GSD Version section
|
||||
const result = evaluateVersionGate({
|
||||
labels: ['bug'],
|
||||
body: '### What happened?\n\nSomething broke.',
|
||||
});
|
||||
assert.deepEqual(result, { action: 'close', reason: 'missing-version' });
|
||||
});
|
||||
|
||||
test('bug label + ### GSD Version section present but blank → close / invalid-version', () => {
|
||||
const result = evaluateVersionGate({
|
||||
labels: ['bug'],
|
||||
body: '### GSD Version\n\n\n### What happened?\n\nSomething broke.',
|
||||
});
|
||||
assert.deepEqual(result, { action: 'close', reason: 'invalid-version' });
|
||||
});
|
||||
|
||||
test('bug label + version value "idk" → close / invalid-version', () => {
|
||||
const result = evaluateVersionGate({ labels: ['bug'], body: bugBody('idk') });
|
||||
assert.deepEqual(result, { action: 'close', reason: 'invalid-version' });
|
||||
});
|
||||
|
||||
test('bug label + "_No response_" → close / invalid-version', () => {
|
||||
const result = evaluateVersionGate({ labels: ['bug'], body: bugBody('_No response_') });
|
||||
assert.deepEqual(result, { action: 'close', reason: 'invalid-version' });
|
||||
});
|
||||
|
||||
test('bug label + version-exempt label → skip / exempt-label', () => {
|
||||
const result = evaluateVersionGate({
|
||||
labels: ['bug', 'version-exempt'],
|
||||
body: bugBody(undefined),
|
||||
});
|
||||
assert.deepEqual(result, { action: 'skip', reason: 'exempt-label' });
|
||||
});
|
||||
|
||||
test('feature request (no bug label, no version heading) → skip / not-a-bug', () => {
|
||||
const result = evaluateVersionGate({
|
||||
labels: ['enhancement'],
|
||||
body: '### Feature Description\n\nI want X.\n\n### Motivation\n\nY.',
|
||||
});
|
||||
assert.deepEqual(result, { action: 'skip', reason: 'not-a-bug' });
|
||||
});
|
||||
|
||||
test('no labels, no body → skip / not-a-bug', () => {
|
||||
const result = evaluateVersionGate({ labels: [], body: '' });
|
||||
assert.deepEqual(result, { action: 'skip', reason: 'not-a-bug' });
|
||||
});
|
||||
|
||||
test('no arguments → skip / not-a-bug', () => {
|
||||
const result = evaluateVersionGate();
|
||||
assert.deepEqual(result, { action: 'skip', reason: 'not-a-bug' });
|
||||
});
|
||||
|
||||
test('API-filed bug: version heading in body, no bug label, valid version → skip / valid-version', () => {
|
||||
// Bug filed via REST with template body but no labels
|
||||
const result = evaluateVersionGate({ labels: [], body: bugBody('1.4.1') });
|
||||
assert.deepEqual(result, { action: 'skip', reason: 'valid-version' });
|
||||
});
|
||||
|
||||
test('no bug label and no version heading (body omits GSD Version section entirely) → skip / not-a-bug', () => {
|
||||
// bugBody(undefined) produces NO ### GSD Version section, so isBugReport
|
||||
// returns false via both label and heading paths → not a bug report at all.
|
||||
const result = evaluateVersionGate({ labels: [], body: bugBody(undefined) });
|
||||
assert.deepEqual(result, { action: 'skip', reason: 'not-a-bug' });
|
||||
});
|
||||
|
||||
test('no bug label but body has GSD Version heading with invalid value "idk" → isBugReport true via heading fallback → close / invalid-version', () => {
|
||||
// API-filed bug: body includes ### GSD Version heading (isBugReport = true)
|
||||
// but the value is a junk string that fails isValidVersion.
|
||||
const result = evaluateVersionGate({ labels: [], body: bugBody('idk') });
|
||||
assert.deepEqual(result, { action: 'close', reason: 'invalid-version' });
|
||||
});
|
||||
|
||||
test('API-filed bug: version heading present but empty, no bug label → close / invalid-version', () => {
|
||||
// Body has the ### GSD Version heading but the value is empty (heading present → invalid, not missing)
|
||||
const body = '### GSD Version\n\n\n### What happened?\n\nSomething broke.';
|
||||
const result = evaluateVersionGate({ labels: [], body });
|
||||
assert.deepEqual(result, { action: 'close', reason: 'invalid-version' });
|
||||
});
|
||||
|
||||
test('git SHA as version is accepted → skip / valid-version', () => {
|
||||
const result = evaluateVersionGate({ labels: ['bug'], body: bugBody('a19a709') });
|
||||
assert.deepEqual(result, { action: 'skip', reason: 'valid-version' });
|
||||
});
|
||||
|
||||
test('CRLF body with GSD Version 1.18.0 → skip / valid-version', () => {
|
||||
const body =
|
||||
'### GSD Version\r\n\r\n1.18.0\r\n\r\n### What happened?\r\n\r\nBoom.';
|
||||
const result = evaluateVersionGate({ labels: ['bug'], body });
|
||||
assert.deepEqual(result, { action: 'skip', reason: 'valid-version' });
|
||||
});
|
||||
|
||||
test('GSD Version is the last section with value on final line (no trailing newline) → skip / valid-version', () => {
|
||||
const body = '### What happened?\n\nBoom.\n\n### GSD Version\n\n1.18.0';
|
||||
const result = evaluateVersionGate({ labels: ['bug'], body });
|
||||
assert.deepEqual(result, { action: 'skip', reason: 'valid-version' });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Additional isBugReport tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('isBugReport — bare Version heading (no GSD prefix)', () => {
|
||||
test('bare "### Version" heading, no bug label → false (not treated as bug)', () => {
|
||||
assert.equal(isBugReport({ labels: [], body: '### Version\n\n1.0.0\n\n### Steps' }), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Additional isBugReport tests — labels-authoritative behavior
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('isBugReport — labels are authoritative', () => {
|
||||
test('labels ["enhancement"] + body with "### GSD Version" heading → false (labels win)', () => {
|
||||
const body = '### GSD Version\n\n_No response_\n\n### What happened?\n\nSomething broke.';
|
||||
assert.equal(isBugReport({ labels: ['enhancement'], body }), false);
|
||||
});
|
||||
|
||||
test('labels ["enhancement","needs-review"] + body with "### GSD Version" heading → false', () => {
|
||||
const body = '### GSD Version\n\n_No response_\n\n### What happened?\n\nSomething broke.';
|
||||
assert.equal(isBugReport({ labels: ['enhancement', 'needs-review'], body }), false);
|
||||
});
|
||||
|
||||
test('labels [] (unlabeled) + body with "### GSD Version" heading → true (heading fallback)', () => {
|
||||
const body = '### GSD Version\n\n1.18.0\n\n### What happened?\n\nSomething broke.';
|
||||
assert.equal(isBugReport({ labels: [], body }), true);
|
||||
});
|
||||
|
||||
test('labels ["bug"] + body with "### GSD Version" heading → true (bug label fast-path)', () => {
|
||||
const body = '### GSD Version\n\n1.18.0\n\n### What happened?\n\nSomething broke.';
|
||||
assert.equal(isBugReport({ labels: ['bug'], body }), true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Additional isValidVersion tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('isValidVersion — git SHA acceptance', () => {
|
||||
test('8-char hex git SHA "deadbeef" → true', () => {
|
||||
assert.equal(isValidVersion('deadbeef'), true);
|
||||
});
|
||||
|
||||
test('7-char short SHA "a19a709" → true', () => {
|
||||
assert.equal(isValidVersion('a19a709'), true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// renderCloseComment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('renderCloseComment', () => {
|
||||
test('contains the VERSION_GATE_MARKER', () => {
|
||||
const comment = renderCloseComment();
|
||||
assert.ok(comment.includes(VERSION_GATE_MARKER), `expected marker in comment: ${comment}`);
|
||||
});
|
||||
|
||||
test('contains the words "GSD Version"', () => {
|
||||
const comment = renderCloseComment();
|
||||
assert.ok(comment.includes('GSD Version'), `expected "GSD Version" in comment: ${comment}`);
|
||||
});
|
||||
|
||||
test('contains reopen instructions', () => {
|
||||
const comment = renderCloseComment();
|
||||
assert.ok(comment.includes('reopen'), `expected reopen instructions: ${comment}`);
|
||||
});
|
||||
|
||||
test('contains the version-exempt escape hatch', () => {
|
||||
const comment = renderCloseComment();
|
||||
assert.ok(comment.includes('version-exempt'), `expected version-exempt label mention: ${comment}`);
|
||||
});
|
||||
|
||||
test('returns a non-empty string', () => {
|
||||
const comment = renderCloseComment();
|
||||
assert.ok(typeof comment === 'string' && comment.length > 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// evaluateVersionGate — labels-authoritative new coverage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('evaluateVersionGate — labels authoritative', () => {
|
||||
test('labels ["enhancement"] + body with "### GSD Version" heading → skip / not-a-bug', () => {
|
||||
const body = '### GSD Version\n\nidk\n\n### What happened?\n\nSomething broke.';
|
||||
const result = evaluateVersionGate({ labels: ['enhancement'], body });
|
||||
assert.deepEqual(result, { action: 'skip', reason: 'not-a-bug' });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// extractVersion — double-heading documents first-heading-wins behavior
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('extractVersion — double heading', () => {
|
||||
test('two "### GSD Version" headings: first section "_No response_", second "1.18.0" → returns "_No response_" (first wins)', () => {
|
||||
const body =
|
||||
'### GSD Version\n\n_No response_\n\n### GSD Version\n\n1.18.0\n\n### What happened?\n\nBoom.';
|
||||
assert.equal(extractVersion(body), '_No response_');
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluateVersionGate — double heading', () => {
|
||||
test('labels ["bug"] + double-heading body (first "_No response_") → close / invalid-version (first heading wins)', () => {
|
||||
const body =
|
||||
'### GSD Version\n\n_No response_\n\n### GSD Version\n\n1.18.0\n\n### What happened?\n\nBoom.';
|
||||
const result = evaluateVersionGate({ labels: ['bug'], body });
|
||||
assert.deepEqual(result, { action: 'close', reason: 'invalid-version' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user