From 456472ae72992fd24843395ad89176963e5f7744 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 4 Dec 2025 14:09:36 -0700 Subject: [PATCH] feat: add automated commenting on linked issues when releases are published MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enhances the comment-on-release action to automatically notify both PRs and their linked issues when changes are released. Key changes: - Query GitHub GraphQL API to find issues closed/fixed by each PR - Post release notifications on linked issues with package versions - Add duplicate comment detection to prevent spam - Add explicit `issues: write` permission to release workflow This matches the functionality from electric-sql/electric#3521, ensuring both contributors and issue reporters are notified when their work ships. šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .github/comment-on-release/README.md | 34 +++- .github/comment-on-release/action.yml | 6 +- .../comment-on-release/comment-on-release.ts | 160 +++++++++++++++++- .github/workflows/release.yml | 3 +- 4 files changed, 191 insertions(+), 12 deletions(-) diff --git a/.github/comment-on-release/README.md b/.github/comment-on-release/README.md index 28427970..1ae4c852 100644 --- a/.github/comment-on-release/README.md +++ b/.github/comment-on-release/README.md @@ -1,6 +1,6 @@ # Comment on Release Action -A reusable GitHub Action that automatically comments on PRs when they are included in a release. +A reusable GitHub Action that automatically comments on PRs and linked issues when they are included in a release. ## What It Does @@ -9,8 +9,12 @@ When packages are published via Changesets: 1. Parses each published package's CHANGELOG to find PR numbers in the latest version 2. Groups PRs by number (handling cases where one PR affects multiple packages) 3. Posts a comment on each PR with release info and CHANGELOG links +4. Finds issues that each PR closes/fixes using GitHub's GraphQL API +5. Posts comments on linked issues notifying them of the release -## Example Comment +## Example Comments + +### On a PR: ``` šŸŽ‰ This PR has been released! @@ -21,6 +25,16 @@ When packages are published via Changesets: Thank you for your contribution! ``` +### On a linked issue: + +``` +šŸŽ‰ The PR fixing this issue (#123) has been released! + +- [@tanstack/query-core@5.0.0](https://github.com/TanStack/query/blob/main/packages/query-core/CHANGELOG.md#500) + +Thank you for reporting! +``` + ## Usage Add this step to your `.github/workflows/release.yml` file after the `changesets/action` step: @@ -49,7 +63,7 @@ Add this step to your `.github/workflows/release.yml` file after the `changesets - Must be using [Changesets](https://github.com/changesets/changesets) for releases - CHANGELOGs must include PR links in the format: `[#123](https://github.com/org/repo/pull/123)` -- Requires `pull-requests: write` permission in the workflow +- Requires `pull-requests: write` and `issues: write` permissions in the workflow - The `gh` CLI must be available (automatically available in GitHub Actions) ## Inputs @@ -67,7 +81,11 @@ The action: 3. Extracts PR numbers from the latest version section using regex 4. Groups all PRs and tracks which packages they contributed to 5. Posts a single comment per PR listing all packages it was released in -6. Uses the `gh` CLI to post comments via the GitHub API +6. For each PR, queries GitHub's GraphQL API to find linked issues (via `closes #N` or `fixes #N` keywords) +7. Groups issues and tracks which PRs fixed them +8. Posts comments on linked issues notifying them of the release +9. Checks for duplicate comments to avoid spamming +10. Uses the `gh` CLI to post comments via the GitHub API ## Troubleshooting @@ -75,9 +93,15 @@ The action: - Verify your CHANGELOGs have PR links in the correct format - Check that `steps.changesets.outputs.published` is `true` -- Ensure the workflow has `pull-requests: write` permission +- Ensure the workflow has `pull-requests: write` and `issues: write` permissions **Script fails to find CHANGELOGs:** - The script expects packages at `packages/{package-name}/CHANGELOG.md` - Package name should match after removing the scope (e.g., `@tanstack/query-core` → `query-core`) + +**Issues aren't being commented on:** + +- Verify that PRs use GitHub's closing keywords (`closes #N`, `fixes #N`, `resolves #N`, etc.) in the PR description +- Check that the linked issues exist and are accessible +- Ensure the `issues: write` permission is granted in the workflow diff --git a/.github/comment-on-release/action.yml b/.github/comment-on-release/action.yml index 82066f77..4b134a9d 100644 --- a/.github/comment-on-release/action.yml +++ b/.github/comment-on-release/action.yml @@ -1,5 +1,5 @@ -name: Comment on PRs about release -description: Automatically comments on PRs when they are included in a release +name: Comment on PRs and issues about release +description: Automatically comments on PRs and linked issues when they are included in a release inputs: published-packages: description: 'JSON string of published packages from changesets/action' @@ -7,7 +7,7 @@ inputs: runs: using: composite steps: - - name: Comment on PRs + - name: Comment on PRs and issues shell: bash run: node ${{ github.action_path }}/comment-on-release.ts env: diff --git a/.github/comment-on-release/comment-on-release.ts b/.github/comment-on-release/comment-on-release.ts index 6b0901de..9bd73810 100644 --- a/.github/comment-on-release/comment-on-release.ts +++ b/.github/comment-on-release/comment-on-release.ts @@ -14,6 +14,12 @@ interface PRInfo { packages: Array<{ name: string; pkgPath: string; version: string }> } +interface IssueInfo { + number: number + prs: Set + packages: Array<{ name: string; pkgPath: string; version: string }> +} + /** * Parse CHANGELOG.md to extract PR numbers from the latest version entry */ @@ -99,18 +105,86 @@ function groupPRsByNumber( return prMap } +/** + * Check if we've already commented on a PR/issue to avoid duplicates + */ +function hasExistingComment(number: number, type: 'pr' | 'issue'): boolean { + try { + const result = execSync( + `gh api repos/\${GITHUB_REPOSITORY}/issues/${number}/comments --jq '[.[] | select(.body | contains("has been released!"))] | length'`, + { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }, + ) + const count = parseInt(result.trim(), 10) + return count > 0 + } catch (error) { + console.warn( + `Warning: Could not check existing comments for ${type} #${number}`, + ) + return false + } +} + +/** + * Find issues that a PR closes/fixes using GitHub's GraphQL API + */ +function findLinkedIssues(prNumber: number, repository: string): Array { + const [owner, repo] = repository.split('/') + const query = ` + query($owner: String!, $repo: String!, $pr: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $pr) { + closingIssuesReferences(first: 10) { + nodes { + number + } + } + } + } + } + ` + + try { + const result = execSync( + `gh api graphql -f query='${query}' -F owner='${owner}' -F repo='${repo}' -F pr=${prNumber} --jq '.data.repository.pullRequest.closingIssuesReferences.nodes[].number'`, + { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }, + ) + + const issueNumbers = result + .trim() + .split('\n') + .filter((line) => line) + .map((line) => parseInt(line, 10)) + + if (issueNumbers.length > 0) { + console.log( + ` PR #${prNumber} links to issues: ${issueNumbers.join(', ')}`, + ) + } + + return issueNumbers + } catch (error) { + return [] + } +} + /** * Post a comment on a GitHub PR using gh CLI */ async function commentOnPR(pr: PRInfo, repository: string): Promise { const { number, packages } = pr + // Check for duplicate comments + if (hasExistingComment(number, 'pr')) { + console.log(`↷ Already commented on PR #${number}, skipping`) + return + } + // Build the comment body let comment = `šŸŽ‰ This PR has been released!\n\n` for (const pkg of packages) { // Link to the package's changelog and version anchor - const changelogUrl = `https://github.com/${repository}/blob/main/${pkg.pkgPath}/CHANGELOG.md#${pkg.version.replaceAll('.', '')}` + const changelogUrl = `https://github.com/${repository}/blob/main/${pkg.pkgPath}/CHANGELOG.md#${pkg.version.replace(/\./g, '')}` comment += `- [${pkg.name}@${pkg.version}](${changelogUrl})\n` } @@ -127,6 +201,49 @@ async function commentOnPR(pr: PRInfo, repository: string): Promise { } } +/** + * Post a comment on a GitHub issue using gh CLI + */ +async function commentOnIssue( + issue: IssueInfo, + repository: string, +): Promise { + const { number, prs, packages } = issue + + // Check for duplicate comments + if (hasExistingComment(number, 'issue')) { + console.log(`↷ Already commented on issue #${number}, skipping`) + return + } + + const prLinks = Array.from(prs) + .map((pr) => `#${pr}`) + .join(', ') + const prWord = prs.size === 1 ? 'PR' : 'PRs' + + // Build the comment body + let comment = `šŸŽ‰ The ${prWord} fixing this issue (${prLinks}) has been released!\n\n` + + for (const pkg of packages) { + // Link to the package's changelog and version anchor + const changelogUrl = `https://github.com/${repository}/blob/main/${pkg.pkgPath}/CHANGELOG.md#${pkg.version.replace(/\./g, '')}` + comment += `- [${pkg.name}@${pkg.version}](${changelogUrl})\n` + } + + comment += `\nThank you for reporting!` + + try { + // Use gh CLI to post the comment + execSync( + `gh issue comment ${number} --body '${comment.replace(/'/g, '"')}'`, + { stdio: 'inherit' }, + ) + console.log(`āœ“ Commented on issue #${number}`) + } catch (error) { + console.error(`āœ— Failed to comment on issue #${number}:`, error) + } +} + /** * Main function */ @@ -170,12 +287,49 @@ async function main() { console.log(`Found ${prMap.size} PR(s) to comment on...`) - // Comment on each PR + // Collect issues linked to PRs + const issueMap = new Map() + + // Comment on each PR and collect linked issues for (const pr of prMap.values()) { await commentOnPR(pr, repository) + + // Find issues that this PR closes/fixes + const linkedIssues = findLinkedIssues(pr.number, repository) + for (const issueNumber of linkedIssues) { + if (!issueMap.has(issueNumber)) { + issueMap.set(issueNumber, { + number: issueNumber, + prs: new Set(), + packages: [], + }) + } + const issueInfo = issueMap.get(issueNumber)! + issueInfo.prs.add(pr.number) + + // Merge packages, avoiding duplicates + for (const pkg of pr.packages) { + if ( + !issueInfo.packages.some( + (p) => p.name === pkg.name && p.version === pkg.version, + ) + ) { + issueInfo.packages.push(pkg) + } + } + } + } + + if (issueMap.size > 0) { + console.log(`\nFound ${issueMap.size} linked issue(s) to comment on...`) + + // Comment on each linked issue + for (const issue of issueMap.values()) { + await commentOnIssue(issue, repository) + } } - console.log('āœ“ Done!') + console.log('\nāœ“ Done!') } main().catch((error) => { diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index df9d9017..a4de0656 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,7 @@ permissions: contents: write id-token: write pull-requests: write + issues: write jobs: release: @@ -41,7 +42,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Comment on PRs about release + - name: Comment on PRs and issues about release if: steps.changesets.outputs.published == 'true' uses: ./.github/comment-on-release with: