diff --git a/.github/workflows/scripts/generateReleaseSummary.js b/.github/workflows/scripts/generateReleaseSummary.js index 7df1c1658b..547950cd12 100644 --- a/.github/workflows/scripts/generateReleaseSummary.js +++ b/.github/workflows/scripts/generateReleaseSummary.js @@ -125,6 +125,10 @@ const collectScopedCommits = ({ ); }; +// Linear state types that mean a ticket is finished; anything else (triage, backlog, +// unstarted, started) counts as open and is surfaced in the summary's warning section. +const CLOSED_STATE_TYPES = new Set(['completed', 'canceled']); + // Helper to fetch Linear issue details const fetchLinearIssue = async (issueId, token) => { if (!token) { @@ -145,6 +149,10 @@ const fetchLinearIssue = async (issueId, token) => { issue(id: $id) { title url + state { + name + type + } } } `, @@ -208,6 +216,7 @@ const generateSummary = async ({ core }) => { const linearRegex = /([a-zA-Z]{2,}-\d+)/g; const issuesFound = new Set(); + const openIssues = []; for (const { subject: line } of commits) { const lower = line.toLowerCase(); @@ -249,6 +258,12 @@ const generateSummary = async ({ core }) => { const issue = await fetchLinearIssue(issueId, linearToken); if (issue) { additionalInfo += ` [${issueId}: ${issue.title}](${issue.url})`; + if ( + issue.state && + !CLOSED_STATE_TYPES.has(issue.state.type) + ) { + openIssues.push(issue); + } } else { additionalInfo += ` ${issueId}`; } @@ -264,6 +279,17 @@ const generateSummary = async ({ core }) => { // 2. Format Output let summary = ''; + // Tickets referenced by this release that are not completed/canceled yet. Placed + // first so reviewers see un-QA'd work before merging; the release is never blocked. + if (openIssues.length > 0) { + summary += '## ⚠️ Open tickets\n'; + openIssues.forEach( + (issue) => + (summary += `- [${issue.id}: ${issue.title}](${issue.url}) — ${issue.state.name}\n`), + ); + summary += '\n'; + } + if (categories.features.length > 0) { summary += '## Features\n'; categories.features.forEach((item) => (summary += `- ${item}\n`)); diff --git a/.github/workflows/scripts/generateReleaseSummary.test.js b/.github/workflows/scripts/generateReleaseSummary.test.js index dfcdab0969..978cdba910 100644 --- a/.github/workflows/scripts/generateReleaseSummary.test.js +++ b/.github/workflows/scripts/generateReleaseSummary.test.js @@ -7,6 +7,7 @@ const assert = require('node:assert/strict'); const { collectScopedCommits, detectLatestPackageTag, + generateSummary, readPathFilter, resolveCommitTitle, runGit, @@ -274,6 +275,141 @@ test('treats a workspace without package tags as a first release', () => { } }); +// Runs generateSummary end-to-end inside a fixture repository with a mocked Linear API, +// capturing the summary output. Restores cwd, env and global.fetch afterwards. +const runGenerateSummary = async (repository, issuesById) => { + const filterPath = path.join(repository, 'filters.yml'); + fs.writeFileSync(filterPath, 'app:\n - "apps/app/**"\n'); + + const originalCwd = process.cwd(); + const originalFetch = global.fetch; + const originalEnvironment = { ...process.env }; + const outputs = {}; + + try { + process.chdir(repository); + process.env.PACKAGE_NAME = '@aragon/app'; + process.env.PATH_FILTER = 'app'; + process.env.FILTERS_PATH = filterPath; + process.env.LINEAR_API_TOKEN = 'test-token'; + process.env.GITHUB_REPOSITORY = 'aragon/app'; + delete process.env.BASE_REF; + + global.fetch = async (_url, options) => { + const { variables } = JSON.parse(options.body); + return { + json: async () => ({ + data: { issue: issuesById[variables.id] ?? null }, + }), + }; + }; + + await generateSummary({ + core: { + setOutput: (name, value) => { + outputs[name] = value; + }, + }, + }); + } finally { + process.chdir(originalCwd); + global.fetch = originalFetch; + process.env = originalEnvironment; + } + + return outputs.summary; +}; + +test('warns about open Linear tickets and their current status', async () => { + const repository = createRepository(); + + try { + commitFile( + repository, + 'apps/app/open.ts', + 'open', + 'feat(APP-100): open work (#1)', + ); + commitFile( + repository, + 'apps/app/done.ts', + 'done', + 'fix(APP-200): finished work (#2)', + ); + commitFile( + repository, + 'apps/app/canceled.ts', + 'canceled', + 'fix(APP-300): canceled work (#3)', + ); + commitFile( + repository, + 'apps/app/phantom.ts', + 'phantom', + 'chore(APP-400): unresolvable id (#4)', + ); + + const summary = await runGenerateSummary(repository, { + 'APP-100': { + title: 'Open feature', + url: 'https://linear.app/aragon/issue/APP-100', + state: { name: 'In Development', type: 'started' }, + }, + 'APP-200': { + title: 'Finished fix', + url: 'https://linear.app/aragon/issue/APP-200', + state: { name: 'Done', type: 'completed' }, + }, + 'APP-300': { + title: 'Canceled fix', + url: 'https://linear.app/aragon/issue/APP-300', + state: { name: 'Canceled', type: 'canceled' }, + }, + }); + + const [warningSection] = summary.split('## Features'); + assert.match(warningSection, /## ⚠️ Open tickets/); + assert.match( + warningSection, + /- \[APP-100: Open feature\]\(https:\/\/linear\.app\/aragon\/issue\/APP-100\) — In Development/, + ); + // Finished, canceled and unresolvable tickets stay out of the warning section. + assert.doesNotMatch(warningSection, /APP-200|APP-300|APP-400/); + // Regular enrichment keeps linking every resolved ticket. + assert.match( + summary, + /\[APP-200: Finished fix\]\(https:\/\/linear\.app\/aragon\/issue\/APP-200\)/, + ); + } finally { + fs.rmSync(repository, { recursive: true, force: true }); + } +}); + +test('omits the warning section when every ticket is finished', async () => { + const repository = createRepository(); + + try { + commitFile( + repository, + 'apps/app/done.ts', + 'done', + 'fix(APP-200): finished work (#2)', + ); + + const summary = await runGenerateSummary(repository, { + 'APP-200': { + title: 'Finished fix', + url: 'https://linear.app/aragon/issue/APP-200', + state: { name: 'Done', type: 'completed' }, + }, + }); + + assert.doesNotMatch(summary, /Open tickets/); + } finally { + fs.rmSync(repository, { recursive: true, force: true }); + } +}); + test('reads named path filters from the central mapper', () => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'release-filter-')); const filterPath = path.join(directory, 'filters.yml');