From 0e6f294c70a294274b03e56b5ccb9d83bb63d1a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Daxb=C3=B6ck?= Date: Tue, 3 Mar 2026 13:51:35 +0100 Subject: [PATCH 1/4] ci: Add workflow to close resolved reproduction PRs Runs daily at 9am UTC (and manually via workflow_dispatch). For each open PR matching the "Reproduction for sentry-{repo}#{number}" title format, checks whether the upstream issue in getsentry/{repo} is closed. If so, labels the PR as resolved-upstream, posts a comment, and closes it. Includes a dry_run input for safe manual testing. Co-Authored-By: Claude --- .github/workflows/close-resolved-repros.yml | 97 +++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 .github/workflows/close-resolved-repros.yml diff --git a/.github/workflows/close-resolved-repros.yml b/.github/workflows/close-resolved-repros.yml new file mode 100644 index 0000000..59157a2 --- /dev/null +++ b/.github/workflows/close-resolved-repros.yml @@ -0,0 +1,97 @@ +name: Close resolved repros + +on: + schedule: + - cron: "0 9 * * *" # daily at 9am UTC + workflow_dispatch: + inputs: + dry_run: + description: "Log which PRs would be closed without taking action" + type: boolean + default: false + +permissions: + issues: write + pull-requests: write + +jobs: + close-resolved: + runs-on: ubuntu-latest + steps: + - name: Close PRs whose upstream issue is resolved + uses: actions/github-script@v7 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const label = 'resolved-upstream'; + const dryRun = context.payload.inputs?.dry_run === 'true'; + + // Fetch all open PRs (paginated) + const prs = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: 'open', + per_page: 100, + }); + + // Match titles like "Reproduction for sentry-ruby#2842" + const titleRe = /^Reproduction for (sentry-[\w-]+#\d+)/; + + for (const pr of prs) { + const match = pr.title.match(titleRe); + if (!match) continue; + + // Skip if already labelled (already processed on a previous run) + if (pr.labels.some(l => l.name === label)) continue; + + const [upstreamRepo, issueNumber] = match[1].split('#'); + + let issue; + try { + issue = await github.rest.issues.get({ + owner: 'getsentry', + repo: upstreamRepo, + issue_number: Number(issueNumber), + }); + } catch (err) { + core.warning( + `PR #${pr.number}: could not fetch getsentry/${upstreamRepo}#${issueNumber} — ${err.message}` + ); + continue; + } + + if (issue.data.state !== 'closed') continue; + + core.info(`PR #${pr.number}: upstream getsentry/${upstreamRepo}#${issueNumber} is closed`); + + if (dryRun) { + core.info(` [dry run] would label and close PR #${pr.number}`); + continue; + } + + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: pr.number, + labels: [label], + }); + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body: [ + `Upstream issue [getsentry/${upstreamRepo}#${issueNumber}](https://github.com/getsentry/${upstreamRepo}/issues/${issueNumber}) has been closed.`, + '', + 'Closing this reproduction PR. Re-open if the issue resurfaces!', + ].join('\n'), + }); + + await github.rest.pulls.update({ + owner, + repo, + pull_number: pr.number, + state: 'closed', + }); + } From 7d91368eae2c09ff579c01ea035c375acdb99cfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Daxb=C3=B6ck?= Date: Tue, 3 Mar 2026 14:06:37 +0100 Subject: [PATCH 2/4] fix(ci): Move label after close to prevent stuck PRs If the close call fails, the PR stays unlabelled so the next scheduled run will retry instead of skipping it permanently. Co-Authored-By: Claude --- .github/workflows/close-resolved-repros.yml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/close-resolved-repros.yml b/.github/workflows/close-resolved-repros.yml index 59157a2..13bc80b 100644 --- a/.github/workflows/close-resolved-repros.yml +++ b/.github/workflows/close-resolved-repros.yml @@ -70,13 +70,6 @@ jobs: continue; } - await github.rest.issues.addLabels({ - owner, - repo, - issue_number: pr.number, - labels: [label], - }); - await github.rest.issues.createComment({ owner, repo, @@ -94,4 +87,13 @@ jobs: pull_number: pr.number, state: 'closed', }); + + // Label last — if closing fails above, the PR stays unlabelled + // and will be retried on the next run + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: pr.number, + labels: [label], + }); } From 6155b8294a15c5c5c3aa39a967db900aaa16dc19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Daxb=C3=B6ck?= Date: Tue, 3 Mar 2026 14:34:09 +0100 Subject: [PATCH 3/4] fix(ci): Only close repro PRs when upstream is completed Skip issues closed as duplicate or not_planned since the bug may still exist elsewhere. Only act on state_reason: completed. Co-Authored-By: Claude --- .github/workflows/close-resolved-repros.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/close-resolved-repros.yml b/.github/workflows/close-resolved-repros.yml index 13bc80b..40cd78f 100644 --- a/.github/workflows/close-resolved-repros.yml +++ b/.github/workflows/close-resolved-repros.yml @@ -61,9 +61,9 @@ jobs: continue; } - if (issue.data.state !== 'closed') continue; + if (issue.data.state !== 'closed' || issue.data.state_reason !== 'completed') continue; - core.info(`PR #${pr.number}: upstream getsentry/${upstreamRepo}#${issueNumber} is closed`); + core.info(`PR #${pr.number}: upstream getsentry/${upstreamRepo}#${issueNumber} is resolved`); if (dryRun) { core.info(` [dry run] would label and close PR #${pr.number}`); From 1f6b02e960bbbff2becd4247140c0df54881aea5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Daxb=C3=B6ck?= Date: Tue, 3 Mar 2026 14:41:04 +0100 Subject: [PATCH 4/4] fix(ci): Wrap close/comment/label in try-catch A failure on one PR (e.g. addLabels) no longer crashes the loop and skips remaining PRs. Co-Authored-By: Claude --- .github/workflows/close-resolved-repros.yml | 52 +++++++++++---------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/.github/workflows/close-resolved-repros.yml b/.github/workflows/close-resolved-repros.yml index 40cd78f..30b4ef2 100644 --- a/.github/workflows/close-resolved-repros.yml +++ b/.github/workflows/close-resolved-repros.yml @@ -70,30 +70,34 @@ jobs: continue; } - await github.rest.issues.createComment({ - owner, - repo, - issue_number: pr.number, - body: [ - `Upstream issue [getsentry/${upstreamRepo}#${issueNumber}](https://github.com/getsentry/${upstreamRepo}/issues/${issueNumber}) has been closed.`, - '', - 'Closing this reproduction PR. Re-open if the issue resurfaces!', - ].join('\n'), - }); + try { + // Close first — if this fails, the PR stays open and unlabelled + // so the next run retries cleanly with no duplicate comments + await github.rest.pulls.update({ + owner, + repo, + pull_number: pr.number, + state: 'closed', + }); - await github.rest.pulls.update({ - owner, - repo, - pull_number: pr.number, - state: 'closed', - }); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body: [ + `Upstream issue [getsentry/${upstreamRepo}#${issueNumber}](https://github.com/getsentry/${upstreamRepo}/issues/${issueNumber}) has been closed.`, + '', + 'Closing this reproduction PR. Re-open if the issue resurfaces!', + ].join('\n'), + }); - // Label last — if closing fails above, the PR stays unlabelled - // and will be retried on the next run - await github.rest.issues.addLabels({ - owner, - repo, - issue_number: pr.number, - labels: [label], - }); + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: pr.number, + labels: [label], + }); + } catch (err) { + core.warning(`PR #${pr.number}: failed to close — ${err.message}`); + } }