Skip to content

Commit cc560f5

Browse files
authored
Update issue fetching in GHES release notes generator (#61926)
1 parent da17fbd commit cc560f5

3 files changed

Lines changed: 147 additions & 22 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
export type IssueState = 'open' | 'closed' | 'all'
2+
3+
const VALID_ISSUE_STATES: IssueState[] = ['open', 'closed', 'all']
4+
const EXCLUDED_RELEASE_LABELS = new Set(['public roadmap', 'not planned'])
5+
6+
interface IssueLike {
7+
labels: { name: string }[]
8+
}
9+
10+
/**
11+
* Parse and validate the issue state filter. Defaults to "all".
12+
*/
13+
export function parseIssueState(value?: string): IssueState {
14+
if (!value) return 'all'
15+
16+
const normalized = value.toLowerCase()
17+
if (VALID_ISSUE_STATES.includes(normalized as IssueState)) {
18+
return normalized as IssueState
19+
}
20+
21+
throw new Error(
22+
`Invalid issue state "${value}". Expected one of: ${VALID_ISSUE_STATES.join(', ')}`,
23+
)
24+
}
25+
26+
/**
27+
* Build gh CLI args for listing release issues.
28+
*/
29+
export function buildReleaseIssueListArgs(version: string, issueState: IssueState): string[] {
30+
const label = `GHES ${version}`
31+
return [
32+
'issue',
33+
'list',
34+
'--repo',
35+
'github/releases',
36+
'--label',
37+
label,
38+
'--state',
39+
issueState,
40+
'--limit',
41+
'200',
42+
'--json',
43+
'number,title,url,body,labels',
44+
]
45+
}
46+
47+
/**
48+
* Excludes release issues that should not produce GHES release notes.
49+
*/
50+
export function isExcludedReleaseIssue(issue: IssueLike): boolean {
51+
return issue.labels.some((l) => EXCLUDED_RELEASE_LABELS.has(l.name.toLowerCase()))
52+
}

src/ghes-releases/scripts/generate-release-notes.ts

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* @description Generate GHES release notes from github/releases issues using Copilot CLI
44
*
55
* Generate GHES release notes by:
6-
* 1. Querying github/releases for open issues labeled "GHES <version>"
6+
* 1. Querying github/releases issues labeled "GHES <version>" (all states by default)
77
* 2. Finding corresponding changelog PRs in github/blog
88
* 3. Running each through the ghes-release-notes agent via Copilot CLI
99
* 4. Stitching the YAML outputs into a release notes file
@@ -25,6 +25,12 @@ import {
2525
buildReleaseNotesYaml,
2626
appendNoteLines,
2727
} from '@/ghes-releases/lib/parse-release-notes'
28+
import {
29+
type IssueState,
30+
buildReleaseIssueListArgs,
31+
isExcludedReleaseIssue,
32+
parseIssueState,
33+
} from '@/ghes-releases/lib/release-issues'
2834

2935
// ─── Ctrl+C handling ─────────────────────────────────────────────────────────
3036
// Copilot CLI puts the terminal in raw mode, so we catch Ctrl+C (0x03) manually.
@@ -94,25 +100,12 @@ function gh(args: string[]): string {
94100
}
95101

96102
/**
97-
* Fetch open release issues labeled "GHES <version>"
103+
* Fetch release issues labeled "GHES <version>" using the selected issue state.
98104
*/
99-
function fetchReleaseIssues(version: string): ReleaseIssue[] {
100-
const label = `GHES ${version}`
101-
const output = gh([
102-
'issue',
103-
'list',
104-
'--repo',
105-
'github/releases',
106-
'--label',
107-
label,
108-
'--state',
109-
'open',
110-
'--limit',
111-
'200',
112-
'--json',
113-
'number,title,url,body,labels',
114-
])
115-
return JSON.parse(output) as ReleaseIssue[]
105+
function fetchReleaseIssues(version: string, issueState: IssueState): ReleaseIssue[] {
106+
const output = gh(buildReleaseIssueListArgs(version, issueState))
107+
const issues = JSON.parse(output) as ReleaseIssue[]
108+
return issues.filter((issue) => !isExcludedReleaseIssue(issue))
116109
}
117110

118111
interface ChangelogInfo {
@@ -484,6 +477,11 @@ program
484477
return true
485478
})
486479
.option('--stdout', 'Print output to console instead of writing to file')
480+
.option(
481+
'--issue-state <state>',
482+
'Issue state filter for github/releases issues (open, closed, all). Defaults to all.',
483+
'all',
484+
)
487485
.option(
488486
'-i, --issue <value>',
489487
'Process a single issue by number or URL (replaces its entry if it already exists)',
@@ -507,12 +505,20 @@ program
507505
release: string
508506
rc: boolean
509507
stdout?: boolean
508+
issueState?: string
510509
issue?: number
511510
force?: boolean
512511
}) => {
513512
const { release, stdout, issue: singleIssue, force } = options
514513
const rc = options.rc ?? false
515514
const spinner = ora()
515+
let issueState: IssueState
516+
try {
517+
issueState = parseIssueState(options.issueState)
518+
} catch (error) {
519+
console.error(`Error: ${(error as Error).message}`)
520+
process.exit(1)
521+
}
516522

517523
// ── Prerequisite checks ──
518524
try {
@@ -560,17 +566,25 @@ program
560566
'number,title,url,body,labels',
561567
])
562568
const issue = JSON.parse(output) as ReleaseIssue
569+
if (isExcludedReleaseIssue(issue)) {
570+
spinner.fail(
571+
`Issue #${singleIssue} is excluded by label (public roadmap or not planned).`,
572+
)
573+
process.exit(0)
574+
}
563575
issues = [issue]
564576
spinner.succeed(`Fetched issue #${singleIssue}: ${issue.title}`)
565577
} catch (error) {
566578
spinner.fail(`Failed to fetch issue #${singleIssue}: ${(error as Error).message}`)
567579
process.exit(1)
568580
}
569581
} else {
570-
spinner.start(`Fetching open issues labeled "GHES ${release}"...`)
582+
spinner.start(`Fetching issues labeled "GHES ${release}" (state: ${issueState})...`)
571583
try {
572-
issues = fetchReleaseIssues(release)
573-
spinner.succeed(`Found ${issues.length} open issues labeled "GHES ${release}"`)
584+
issues = fetchReleaseIssues(release, issueState)
585+
spinner.succeed(
586+
`Found ${issues.length} issues labeled "GHES ${release}" (state: ${issueState})`,
587+
)
574588
} catch (error) {
575589
spinner.fail(`Failed to fetch issues: ${(error as Error).message}`)
576590
process.exit(1)
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { describe, expect, test } from 'vitest'
2+
3+
import {
4+
buildReleaseIssueListArgs,
5+
isExcludedReleaseIssue,
6+
parseIssueState,
7+
type IssueState,
8+
} from '@/ghes-releases/lib/release-issues'
9+
10+
describe('parseIssueState', () => {
11+
test('defaults to all when not provided', () => {
12+
expect(parseIssueState()).toBe('all')
13+
})
14+
15+
test('accepts valid values case-insensitively', () => {
16+
expect(parseIssueState('OPEN')).toBe('open')
17+
expect(parseIssueState('closed')).toBe('closed')
18+
expect(parseIssueState('All')).toBe('all')
19+
})
20+
21+
test('throws on invalid values', () => {
22+
expect(() => parseIssueState('anything-else')).toThrow('Invalid issue state')
23+
})
24+
})
25+
26+
describe('buildReleaseIssueListArgs', () => {
27+
test('builds gh issue list args with release label and state', () => {
28+
const args = buildReleaseIssueListArgs('3.20', 'closed' satisfies IssueState)
29+
30+
expect(args).toEqual([
31+
'issue',
32+
'list',
33+
'--repo',
34+
'github/releases',
35+
'--label',
36+
'GHES 3.20',
37+
'--state',
38+
'closed',
39+
'--limit',
40+
'200',
41+
'--json',
42+
'number,title,url,body,labels',
43+
])
44+
})
45+
})
46+
47+
describe('isExcludedReleaseIssue', () => {
48+
test('returns true for public roadmap label', () => {
49+
expect(isExcludedReleaseIssue({ labels: [{ name: 'public roadmap' }] })).toBe(true)
50+
})
51+
52+
test('returns true for not planned label (case-insensitive)', () => {
53+
expect(isExcludedReleaseIssue({ labels: [{ name: 'Not Planned' }] })).toBe(true)
54+
})
55+
56+
test('returns false when no excluded labels are present', () => {
57+
expect(isExcludedReleaseIssue({ labels: [{ name: 'GHES 3.20' }, { name: 'bug' }] })).toBe(false)
58+
})
59+
})

0 commit comments

Comments
 (0)