Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/scripts/generateReleaseSummary.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -145,6 +149,10 @@ const fetchLinearIssue = async (issueId, token) => {
issue(id: $id) {
title
url
state {
name
type
}
}
}
`,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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}`;
}
Expand All @@ -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`));
Expand Down
136 changes: 136 additions & 0 deletions .github/workflows/scripts/generateReleaseSummary.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const assert = require('node:assert/strict');
const {
collectScopedCommits,
detectLatestPackageTag,
generateSummary,
readPathFilter,
resolveCommitTitle,
runGit,
Expand Down Expand Up @@ -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');
Expand Down
Loading