Skip to content
Open
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
103 changes: 103 additions & 0 deletions .github/workflows/close-resolved-repros.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
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' || issue.data.state_reason !== 'completed') continue;

core.info(`PR #${pr.number}: upstream getsentry/${upstreamRepo}#${issueNumber} is resolved`);

if (dryRun) {
core.info(` [dry run] would label and close PR #${pr.number}`);
continue;
}

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.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.issues.addLabels({
owner,
repo,
issue_number: pr.number,
labels: [label],
});
} catch (err) {
core.warning(`PR #${pr.number}: failed to close — ${err.message}`);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partial failure leaves PR closed without label permanently

Low Severity

The close, comment, and label API calls are wrapped in a single try/catch. If pulls.update (close) succeeds but createComment or addLabels throws, the PR ends up closed without the resolved-upstream label. Since subsequent runs only fetch open PRs via state: 'open', this PR will never be retried, and the label — described as key for distinguishing these from manually closed PRs — is permanently lost. Separate try/catch blocks for the comment and label calls after the close would allow those operations to be attempted independently.

Additional Locations (1)

Fix in Cursor Fix in Web

}