mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +00:00
Enforce GitHub issue template compliance
Issues that don't follow the issue template are automatically labeled with `needs-template` and receive a bot comment explaining what sections are missing. The label is removed when the issue is edited to become compliant. Includes the workflow, decision logic, compliance checker, and comprehensive tests verifying sync with the actual template definitions.
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
import { appendFileSync, readFileSync } from 'node:fs'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
// Why hardcoded: the runner has no YAML parser without a pnpm install. The
|
||||
// vitest drift guard fails whenever these diverge from .github/ISSUE_TEMPLATE.
|
||||
export const ISSUE_TEMPLATES = [
|
||||
{ file: 'bug_report.yml', name: 'Bug report', requiredFields: ['Operating system', 'Details'] },
|
||||
{
|
||||
file: 'feature_request.yml',
|
||||
name: 'Feature request',
|
||||
requiredFields: ['Problem or use case', 'Proposed solution']
|
||||
},
|
||||
{ file: 'other.yml', name: 'Other', requiredFields: ['Details'] }
|
||||
]
|
||||
|
||||
export const COMPLIANCE_LABEL = 'needs-template'
|
||||
export const COMMENT_MARKER = '<!-- orca-issue-template-compliance -->'
|
||||
// Why: GitHub renders a skipped optional form field as this literal.
|
||||
const EMPTY_FIELD_VALUE = '_No response_'
|
||||
|
||||
// Issue forms render each field as `### <label>` followed by the value.
|
||||
export function parseIssueFormSections(body) {
|
||||
const sections = new Map()
|
||||
let current = null
|
||||
let inFence = false
|
||||
for (const rawLine of (body ?? '').split(/\r?\n/)) {
|
||||
const line = rawLine.trimEnd()
|
||||
if (/^\s*(`{3,}|~{3,})/.test(line)) {
|
||||
inFence = !inFence
|
||||
}
|
||||
const heading = !inFence && line.match(/^### (.+)$/)
|
||||
if (heading) {
|
||||
current = heading[1].trim()
|
||||
if (!sections.has(current)) {
|
||||
sections.set(current, [])
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (current) {
|
||||
sections.get(current).push(rawLine)
|
||||
}
|
||||
}
|
||||
return new Map(
|
||||
[...sections].map(([label, lines]) => {
|
||||
const value = lines.join('\n').trim()
|
||||
return [label, value === EMPTY_FIELD_VALUE ? '' : value]
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function evaluateIssueTemplateCompliance(body, templates = ISSUE_TEMPLATES) {
|
||||
const sections = parseIssueFormSections(body)
|
||||
const candidates = templates.map((template) => {
|
||||
const present = template.requiredFields.filter((field) => sections.has(field))
|
||||
const missingFields = template.requiredFields.filter((field) => !sections.get(field))
|
||||
return { template, present: present.length, missingFields }
|
||||
})
|
||||
// Why: pick the template the author most likely used, then judge its required fields.
|
||||
const best = candidates.reduce((a, b) =>
|
||||
b.present > a.present ||
|
||||
(b.present === a.present && b.missingFields.length < a.missingFields.length)
|
||||
? b
|
||||
: a
|
||||
)
|
||||
if (best.present === 0) {
|
||||
return { compliant: false, template: null, missingFields: [] }
|
||||
}
|
||||
return {
|
||||
compliant: best.missingFields.length === 0,
|
||||
template: best.template.name,
|
||||
missingFields: best.missingFields
|
||||
}
|
||||
}
|
||||
|
||||
function hasComplianceLabel(issue) {
|
||||
return (issue.labels ?? []).some(
|
||||
(label) => (typeof label === 'string' ? label : label.name) === COMPLIANCE_LABEL
|
||||
)
|
||||
}
|
||||
|
||||
// Returns what the workflow should do; performing it stays in the workflow.
|
||||
export function decideIssueTemplateAction(event, templates = ISSUE_TEMPLATES) {
|
||||
const { action, issue } = event
|
||||
if (!issue) {
|
||||
return { kind: 'skip', reason: 'no issue in event payload' }
|
||||
}
|
||||
if (issue.pull_request) {
|
||||
return { kind: 'skip', reason: 'pull requests are not checked' }
|
||||
}
|
||||
if (issue.user?.type === 'Bot') {
|
||||
return { kind: 'skip', reason: `bot author ${issue.user.login}` }
|
||||
}
|
||||
const flagged = hasComplianceLabel(issue)
|
||||
if (action === 'edited' && !flagged) {
|
||||
// Why: older issues predate enforcement; only re-check ones this workflow already flagged.
|
||||
return { kind: 'skip', reason: 'edited issue was never flagged' }
|
||||
}
|
||||
if (action !== 'opened' && action !== 'edited') {
|
||||
return { kind: 'skip', reason: `unhandled action ${action}` }
|
||||
}
|
||||
const result = evaluateIssueTemplateCompliance(issue.body, templates)
|
||||
if (result.compliant) {
|
||||
return flagged
|
||||
? { kind: 'clear', template: result.template }
|
||||
: { kind: 'skip', reason: 'compliant' }
|
||||
}
|
||||
return { kind: 'flag', template: result.template, missingFields: result.missingFields }
|
||||
}
|
||||
|
||||
export function renderComplianceComment(decision, repository) {
|
||||
const chooser = `https://github.com/${repository}/issues/new/choose`
|
||||
const detail = decision.template
|
||||
? `It looks like the **${decision.template}** form, but these required sections are empty or missing:\n\n${decision.missingFields.map((field) => `- ${field}`).join('\n')}`
|
||||
: 'It does not contain any of the sections from our issue forms, so it was probably filed without one (for example through the API or a third-party client).'
|
||||
return [
|
||||
COMMENT_MARKER,
|
||||
'Thanks for the report! This issue was not filed with one of our issue templates, so it is missing information maintainers need to act on it.',
|
||||
'',
|
||||
detail,
|
||||
'',
|
||||
`Please edit the issue body to follow the matching template from ${chooser}. The \`${COMPLIANCE_LABEL}\` label is removed automatically once the required sections are filled in.`
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function writeOutputs(outputs) {
|
||||
const target = process.env.GITHUB_OUTPUT
|
||||
const lines = Object.entries(outputs).map(([key, value]) => {
|
||||
const delimiter = `EOF_${key}_${process.pid}`
|
||||
return `${key}<<${delimiter}\n${value}\n${delimiter}\n`
|
||||
})
|
||||
if (!target) {
|
||||
process.stdout.write(lines.join(''))
|
||||
return
|
||||
}
|
||||
appendFileSync(target, lines.join(''))
|
||||
}
|
||||
|
||||
function main() {
|
||||
const eventPath = process.env.GITHUB_EVENT_PATH
|
||||
if (!eventPath) {
|
||||
console.error('GITHUB_EVENT_PATH is not set')
|
||||
return 2
|
||||
}
|
||||
const event = JSON.parse(readFileSync(eventPath, 'utf8'))
|
||||
const decision = decideIssueTemplateAction(event)
|
||||
console.log(
|
||||
`Issue #${event.issue?.number}: ${decision.kind}${decision.reason ? ` (${decision.reason})` : ''}`
|
||||
)
|
||||
writeOutputs({
|
||||
kind: decision.kind,
|
||||
label: COMPLIANCE_LABEL,
|
||||
marker: COMMENT_MARKER,
|
||||
comment:
|
||||
decision.kind === 'flag'
|
||||
? renderComplianceComment(decision, process.env.GITHUB_REPOSITORY ?? '')
|
||||
: ''
|
||||
})
|
||||
return 0
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
process.exitCode = main()
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
name: Enforce issue templates
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: issue-template-compliance-${{ github.event.issue.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
# Why: `issues` events check out the default branch, so only reviewed code runs
|
||||
# under the write-scoped token. No dependency install: the script is node-only.
|
||||
- name: Checkout compliance script
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts/issue-template-compliance.mjs
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Decide whether the issue follows a template
|
||||
id: decision
|
||||
run: node .github/scripts/issue-template-compliance.mjs
|
||||
|
||||
- name: Label and comment on a non-compliant issue
|
||||
if: steps.decision.outputs.kind == 'flag'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ISSUE_URL: ${{ github.event.issue.html_url }}
|
||||
LABEL: ${{ steps.decision.outputs.label }}
|
||||
MARKER: ${{ steps.decision.outputs.marker }}
|
||||
COMMENT: ${{ steps.decision.outputs.comment }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Why no --force: a maintainer's recolor or reworded description must survive re-runs.
|
||||
gh label create "$LABEL" --color d93f0b \
|
||||
--description "Issue does not follow an issue template; awaiting author edit" \
|
||||
|| echo "Label $LABEL already exists."
|
||||
gh issue edit "$ISSUE_URL" --add-label "$LABEL"
|
||||
# Why: an author may edit several times; keep a single bot comment per issue.
|
||||
if gh issue view "$ISSUE_URL" --json comments --jq '.comments[].body' | grep -qF "$MARKER"; then
|
||||
echo "Compliance comment already present; skipping."
|
||||
else
|
||||
gh issue comment "$ISSUE_URL" --body "$COMMENT"
|
||||
fi
|
||||
|
||||
- name: Clear the label once the issue is compliant
|
||||
if: steps.decision.outputs.kind == 'clear'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ISSUE_URL: ${{ github.event.issue.html_url }}
|
||||
LABEL: ${{ steps.decision.outputs.label }}
|
||||
run: gh issue edit "$ISSUE_URL" --remove-label "$LABEL"
|
||||
@@ -0,0 +1,302 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
import {
|
||||
COMMENT_MARKER,
|
||||
COMPLIANCE_LABEL,
|
||||
ISSUE_TEMPLATES,
|
||||
decideIssueTemplateAction,
|
||||
evaluateIssueTemplateCompliance,
|
||||
parseIssueFormSections,
|
||||
renderComplianceComment
|
||||
} from '../../.github/scripts/issue-template-compliance.mjs'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const templateDir = join(projectDir, '.github/ISSUE_TEMPLATE')
|
||||
const scriptPath = join(projectDir, '.github/scripts/issue-template-compliance.mjs')
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/issue-template-compliance.yml'), 'utf8')
|
||||
)
|
||||
|
||||
const bugBody = [
|
||||
'### Operating system',
|
||||
'',
|
||||
'macOS',
|
||||
'',
|
||||
'### Orca version',
|
||||
'',
|
||||
'_No response_',
|
||||
'',
|
||||
'### Details',
|
||||
'',
|
||||
'Orca crashes when opening a worktree.',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
const featureBody = [
|
||||
'### Problem or use case',
|
||||
'',
|
||||
'Switching repositories is slow.',
|
||||
'',
|
||||
'### Proposed solution',
|
||||
'',
|
||||
'Pin repositories on the home screen.',
|
||||
'',
|
||||
'### Alternatives or additional context',
|
||||
'',
|
||||
'_No response_'
|
||||
].join('\n')
|
||||
|
||||
const issueEvent = (action, body, overrides = {}) => ({
|
||||
action,
|
||||
issue: { number: 1, body, labels: [], user: { login: 'someone', type: 'User' }, ...overrides }
|
||||
})
|
||||
|
||||
describe('ISSUE_TEMPLATES stays in sync with .github/ISSUE_TEMPLATE', () => {
|
||||
it('lists every form with exactly its required field labels', () => {
|
||||
const forms = readdirSync(templateDir).filter((name) => name !== 'config.yml')
|
||||
const expected = forms.map((file) => {
|
||||
const form = parse(readFileSync(join(templateDir, file), 'utf8'))
|
||||
return {
|
||||
file,
|
||||
name: form.name,
|
||||
requiredFields: form.body
|
||||
.filter((field) => field.type !== 'markdown' && field.validations?.required)
|
||||
.map((field) => field.attributes.label)
|
||||
}
|
||||
})
|
||||
expect([...ISSUE_TEMPLATES].sort((a, b) => a.file.localeCompare(b.file))).toEqual(
|
||||
expected.sort((a, b) => a.file.localeCompare(b.file))
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps blank issues disabled so the chooser is the only UI path', () => {
|
||||
expect(parse(readFileSync(join(templateDir, 'config.yml'), 'utf8'))).toEqual({
|
||||
blank_issues_enabled: false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseIssueFormSections', () => {
|
||||
it('maps headings to trimmed values and treats _No response_ as empty', () => {
|
||||
const sections = parseIssueFormSections(bugBody)
|
||||
expect([...sections.keys()]).toEqual(['Operating system', 'Orca version', 'Details'])
|
||||
expect(sections.get('Operating system')).toBe('macOS')
|
||||
expect(sections.get('Orca version')).toBe('')
|
||||
expect(sections.get('Details')).toBe('Orca crashes when opening a worktree.')
|
||||
})
|
||||
|
||||
it('ignores headings inside fenced code blocks and CRLF line endings', () => {
|
||||
const body = '### Details\r\n\r\n```\r\n### Operating system\r\nnot a heading\r\n```\r\n'
|
||||
const sections = parseIssueFormSections(body)
|
||||
expect([...sections.keys()]).toEqual(['Details'])
|
||||
expect(sections.get('Details')).toContain('### Operating system')
|
||||
})
|
||||
|
||||
it('handles null, empty and heading-less bodies', () => {
|
||||
expect(parseIssueFormSections(null).size).toBe(0)
|
||||
expect(parseIssueFormSections('').size).toBe(0)
|
||||
expect(parseIssueFormSections('## Steps\n1. do a thing').size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('evaluateIssueTemplateCompliance', () => {
|
||||
it('accepts a fully filled bug report and feature request', () => {
|
||||
expect(evaluateIssueTemplateCompliance(bugBody)).toEqual({
|
||||
compliant: true,
|
||||
template: 'Bug report',
|
||||
missingFields: []
|
||||
})
|
||||
expect(evaluateIssueTemplateCompliance(featureBody)).toEqual({
|
||||
compliant: true,
|
||||
template: 'Feature request',
|
||||
missingFields: []
|
||||
})
|
||||
})
|
||||
|
||||
it('flags a bug report whose required field was blanked out', () => {
|
||||
const body = bugBody.replace('Orca crashes when opening a worktree.', '_No response_')
|
||||
expect(evaluateIssueTemplateCompliance(body)).toEqual({
|
||||
compliant: false,
|
||||
template: 'Bug report',
|
||||
missingFields: ['Details']
|
||||
})
|
||||
})
|
||||
|
||||
it('flags a partially copied template, naming the missing sections', () => {
|
||||
expect(evaluateIssueTemplateCompliance('### Problem or use case\n\nI want X.')).toEqual({
|
||||
compliant: false,
|
||||
template: 'Feature request',
|
||||
missingFields: ['Proposed solution']
|
||||
})
|
||||
})
|
||||
|
||||
it('flags a free-form body with no recognizable template', () => {
|
||||
expect(evaluateIssueTemplateCompliance('it broke, please fix')).toEqual({
|
||||
compliant: false,
|
||||
template: null,
|
||||
missingFields: []
|
||||
})
|
||||
expect(evaluateIssueTemplateCompliance(null).compliant).toBe(false)
|
||||
})
|
||||
|
||||
it('prefers the bug form over the other form when both share a Details section', () => {
|
||||
// Bug report and Other both require "Details"; the OS field disambiguates.
|
||||
expect(evaluateIssueTemplateCompliance('### Details\n\nSomething').template).toBe('Other')
|
||||
expect(evaluateIssueTemplateCompliance(bugBody).template).toBe('Bug report')
|
||||
})
|
||||
})
|
||||
|
||||
describe('decideIssueTemplateAction', () => {
|
||||
it('flags a newly opened issue that skipped the template', () => {
|
||||
expect(decideIssueTemplateAction(issueEvent('opened', 'help'))).toEqual({
|
||||
kind: 'flag',
|
||||
template: null,
|
||||
missingFields: []
|
||||
})
|
||||
})
|
||||
|
||||
it('skips compliant, bot-authored and pull-request payloads', () => {
|
||||
expect(decideIssueTemplateAction(issueEvent('opened', bugBody)).kind).toBe('skip')
|
||||
expect(
|
||||
decideIssueTemplateAction(
|
||||
issueEvent('opened', 'x', { user: { login: 'dependabot[bot]', type: 'Bot' } })
|
||||
).kind
|
||||
).toBe('skip')
|
||||
expect(
|
||||
decideIssueTemplateAction(issueEvent('opened', 'x', { pull_request: { url: 'u' } })).kind
|
||||
).toBe('skip')
|
||||
expect(decideIssueTemplateAction({ action: 'opened' }).kind).toBe('skip')
|
||||
})
|
||||
|
||||
it('leaves edits to older, never-flagged issues alone', () => {
|
||||
// Why: pre-enforcement issues were written by hand; editing one must not retroactively flag it.
|
||||
expect(decideIssueTemplateAction(issueEvent('edited', 'old hand-written report'))).toEqual({
|
||||
kind: 'skip',
|
||||
reason: 'edited issue was never flagged'
|
||||
})
|
||||
})
|
||||
|
||||
it('clears a flagged issue once its edit makes it compliant, and re-flags otherwise', () => {
|
||||
const flagged = { labels: [{ name: COMPLIANCE_LABEL }, 'bug'] }
|
||||
expect(decideIssueTemplateAction(issueEvent('edited', bugBody, flagged))).toEqual({
|
||||
kind: 'clear',
|
||||
template: 'Bug report'
|
||||
})
|
||||
expect(decideIssueTemplateAction(issueEvent('edited', 'still nothing', flagged)).kind).toBe(
|
||||
'flag'
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores actions the workflow does not subscribe to', () => {
|
||||
expect(decideIssueTemplateAction(issueEvent('reopened', 'x')).kind).toBe('skip')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderComplianceComment', () => {
|
||||
it('carries the dedupe marker, the chooser link and the missing sections', () => {
|
||||
const comment = renderComplianceComment(
|
||||
{ kind: 'flag', template: 'Bug report', missingFields: ['Details'] },
|
||||
'stablyai/orca'
|
||||
)
|
||||
expect(comment.startsWith(COMMENT_MARKER)).toBe(true)
|
||||
expect(comment).toContain('https://github.com/stablyai/orca/issues/new/choose')
|
||||
expect(comment).toContain('**Bug report**')
|
||||
expect(comment).toContain('- Details')
|
||||
expect(comment).toContain(`\`${COMPLIANCE_LABEL}\``)
|
||||
})
|
||||
|
||||
it('explains the no-template case without listing sections', () => {
|
||||
const comment = renderComplianceComment(
|
||||
{ kind: 'flag', template: null, missingFields: [] },
|
||||
'o/r'
|
||||
)
|
||||
expect(comment).toContain('third-party client')
|
||||
expect(comment).not.toMatch(/^- /m)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CLI entrypoint', () => {
|
||||
const tempDirs = []
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
function runScript(event) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'issue-template-compliance-'))
|
||||
tempDirs.push(dir)
|
||||
const eventPath = join(dir, 'event.json')
|
||||
const outputPath = join(dir, 'output.txt')
|
||||
writeFileSync(eventPath, JSON.stringify(event))
|
||||
writeFileSync(outputPath, '')
|
||||
const stdout = execFileSync(process.execPath, [scriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
GITHUB_EVENT_PATH: eventPath,
|
||||
GITHUB_OUTPUT: outputPath,
|
||||
GITHUB_REPOSITORY: 'o/r'
|
||||
}
|
||||
})
|
||||
return { stdout, output: readFileSync(outputPath, 'utf8') }
|
||||
}
|
||||
|
||||
it('writes the decision and a multi-line comment to GITHUB_OUTPUT', () => {
|
||||
const { stdout, output } = runScript(issueEvent('opened', 'nope'))
|
||||
expect(stdout).toContain('Issue #1: flag')
|
||||
expect(output).toMatch(/^kind<<EOF_kind_\d+\nflag\nEOF_kind_\d+\n/m)
|
||||
expect(output).toContain(`label<<EOF_label_`)
|
||||
expect(output).toContain(`\n${COMPLIANCE_LABEL}\n`)
|
||||
expect(output).toContain(COMMENT_MARKER)
|
||||
expect(output).toContain('https://github.com/o/r/issues/new/choose')
|
||||
})
|
||||
|
||||
it('emits an empty comment for compliant issues', () => {
|
||||
const { output } = runScript(issueEvent('opened', bugBody))
|
||||
expect(output).toMatch(/^kind<<EOF_kind_\d+\nskip\n/m)
|
||||
expect(output).toMatch(/^comment<<EOF_comment_(\d+)\n\nEOF_comment_\1\n/m)
|
||||
})
|
||||
})
|
||||
|
||||
describe('issue-template-compliance workflow contract', () => {
|
||||
const steps = workflow.jobs.check.steps
|
||||
const checkout = steps.find((step) => step.uses?.startsWith('actions/checkout'))
|
||||
const decision = steps.find((step) => step.id === 'decision')
|
||||
|
||||
it('reacts only to issue opened and edited events with issue-write scope', () => {
|
||||
expect(workflow.on).toEqual({ issues: { types: ['opened', 'edited'] } })
|
||||
expect(workflow.permissions).toEqual({ contents: 'read', issues: 'write' })
|
||||
expect(workflow.concurrency.group).toContain('github.event.issue.number')
|
||||
})
|
||||
|
||||
it('checks out only the reviewed script, without persisting credentials or installing deps', () => {
|
||||
expect(checkout.with['persist-credentials']).toBe(false)
|
||||
expect(checkout.with['sparse-checkout']).toBe('.github/scripts/issue-template-compliance.mjs')
|
||||
expect(
|
||||
steps.some(
|
||||
(step) => step.uses?.includes('install-node-dependencies') || /pnpm/.test(step.run ?? '')
|
||||
)
|
||||
).toBe(false)
|
||||
expect(decision.run).toBe('node .github/scripts/issue-template-compliance.mjs')
|
||||
})
|
||||
|
||||
it('gates every gh call on the script decision and never closes the issue', () => {
|
||||
const actors = steps.filter((step) => /\bgh /.test(step.run ?? ''))
|
||||
expect(actors.map((step) => step.if)).toEqual([
|
||||
"steps.decision.outputs.kind == 'flag'",
|
||||
"steps.decision.outputs.kind == 'clear'"
|
||||
])
|
||||
for (const step of actors) {
|
||||
expect(step.run).not.toMatch(/gh issue close|--state/)
|
||||
expect(step.env.GH_TOKEN).toBe('${{ secrets.GITHUB_TOKEN }}')
|
||||
}
|
||||
// Why: comment text flows through env, never interpolated into the shell script.
|
||||
expect(actors[0].run).not.toContain('${{')
|
||||
expect(actors[0].run).toContain('grep -qF "$MARKER"')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user