Skip to content

Commit 11967db

Browse files
committed
feat: implement Linked Issue Enforcer bot to close PRs without linked issues
Signed-off-by: MonaaEid <[email protected]>
1 parent b97935d commit 11967db

File tree

3 files changed

+167
-0
lines changed

3 files changed

+167
-0
lines changed
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// A script to closes pull requests without a linked issue after 3 days automatically.
2+
3+
// dryRun env var: any case-insensitive 'true' value will enable dry-run
4+
const dryRun = (process.env.DRY_RUN || 'false').toString().toLowerCase() === 'true';
5+
const daysBeforeClose = parseInt(process.env.DAYS_BEFORE_CLOSE || '3', 10);
6+
const requireAuthorAssigned = (process.env.REQUIRE_AUTHOR_ASSIGNED || 'true').toLowerCase() === 'true';
7+
8+
const getDaysOpen = (pr) =>
9+
Math.floor((Date.now() - new Date(pr.created_at)) / 86400000);
10+
11+
// Check if the PR author is assigned to the issue
12+
const isAuthorAssigned = (issue, login) => {
13+
if (!issue || issue.state?.toUpperCase() !== 'OPEN') return false;
14+
const assignees = issue.assignees?.nodes?.map(a => a.login) || [];
15+
return assignees.includes(login);
16+
};
17+
18+
const baseMessage = `Hi there! I'm the LinkedIssueBot.\nThis pull request has been automatically closed due to the following reason(s):
19+
`;
20+
const messageSuffix = `Please read - [Creating Issues](../docs/sdk_developers/creating_issues.md) - [How To Link Issues](../docs/sdk_developers/how_to_link_issues.md)\n\nThank you
21+
From Python SDK team`
22+
23+
const messages = {
24+
no_issue: `${baseMessage} - Reason: This pull request is not linked to any issue. Please link it to an issue and reopen the pull request if this is an error.\n${messageSuffix}`,
25+
not_assigned: `${baseMessage} - Reason: You are not assigned to the linked issue. Please ensure you are assigned before reopening the pull request.\n${messageSuffix}`
26+
};
27+
28+
// Fetch linked issues using GraphQL
29+
async function getLinkedIssues(github, pr, owner, repo) {
30+
const query = `
31+
query($owner: String!, $repo: String!, $prNumber: Int!) {
32+
repository(owner: $owner, name: $repo) {
33+
pullRequest(number: $prNumber) {
34+
closingIssuesReferences(first: 100) {
35+
nodes {
36+
number
37+
state
38+
assignees(first: 100) {
39+
nodes { login }
40+
}
41+
}
42+
}
43+
}
44+
}
45+
}
46+
`;
47+
try {
48+
const result = await github.graphql(query, { owner, repo, prNumber: pr.number });
49+
const allIssues = result.repository.pullRequest.closingIssuesReferences.nodes || [];
50+
// Return only open issues
51+
return allIssues.filter(issue => issue.state === 'OPEN');
52+
} catch (err) {
53+
console.error(`GraphQL query failed for PR #${pr.number}:`, err.message);
54+
return null; // Signal error
55+
}
56+
}
57+
58+
// Validation
59+
async function validatePR(github, pr, owner, repo) {
60+
const issues = await getLinkedIssues(github, pr, owner, repo);
61+
62+
// Skip on API errors (fail-safe)
63+
if (issues === null) {
64+
console.log(`Skipping PR #${pr.number} due to API error`);
65+
return { valid: true };
66+
}
67+
68+
if (issues.length === 0) return { valid: false, reason: 'no_issue' };
69+
70+
if (requireAuthorAssigned) {
71+
const assigned = issues.some(issue => isAuthorAssigned(issue, pr.user.login));
72+
if (!assigned) return { valid: false, reason: 'not_assigned' };
73+
}
74+
return { valid: true };
75+
}
76+
77+
async function closePR(github, pr, owner, repo, reason) {
78+
if (dryRun) {
79+
console.log(`[DRY RUN] Would close PR #${pr.number} ${pr.html_url} (${reason})`);
80+
return true;
81+
}
82+
83+
try {
84+
await github.rest.issues.createComment({
85+
owner, repo, issue_number: pr.number,
86+
body: messages[reason]
87+
});
88+
await github.rest.pulls.update({
89+
owner, repo, pull_number: pr.number, state: 'closed'
90+
});
91+
console.log(`✓ Closed PR #${pr.number} (${reason}) link: ${pr.html_url}`);
92+
} catch (err) {
93+
console.error(`✗ Failed to close PR #${pr.number}:`, err.message);
94+
}
95+
}
96+
97+
module.exports = async ({ github, context }) => {
98+
try {
99+
const { owner, repo } = context.repo;
100+
const prs = await github.paginate(github.rest.pulls.list, {
101+
owner, repo, state: 'open', per_page: 100
102+
});
103+
104+
console.log(`Evaluating ${prs.length} open PRs\n`);
105+
106+
for (const pr of prs) {
107+
const days = getDaysOpen(pr);
108+
if (days <= daysBeforeClose)
109+
{
110+
console.log(`PR #${pr.number} link: ${pr.html_url} is only ${days} days old. Skipping.`);
111+
continue;
112+
}
113+
114+
const { valid, reason } = await validatePR(github, pr, owner, repo);
115+
if (valid) {
116+
console.log(`PR #${pr.number} link: ${pr.html_url} is Valid ✓.`);
117+
} else {
118+
await closePR(github, pr, owner, repo, reason);
119+
}
120+
}
121+
} catch (err) {
122+
console.error('Unexpected error:', err.message);
123+
}
124+
};
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# This workflow automatically closes pull requests without a linked issue after 3 days.
2+
3+
name: Linked Issue Enforcer
4+
5+
on:
6+
schedule:
7+
- cron: '45 11 * * 1,4'
8+
workflow_dispatch:
9+
inputs:
10+
dry_run:
11+
description: 'If true, do not post comments (dry run). Accepts "true" or "false". Default true for manual runs.'
12+
required: false
13+
default: 'true'
14+
15+
permissions:
16+
pull-requests: write
17+
contents: read
18+
19+
jobs:
20+
pr-linked-issue-checker:
21+
runs-on: ubuntu-latest
22+
env:
23+
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
24+
25+
steps:
26+
- name: Harden the runner
27+
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
28+
with:
29+
egress-policy: audit
30+
- name: Checkout repository
31+
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
32+
- name: Enforce linked issues on PRs
33+
env:
34+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
35+
DRY_RUN: ${{ env.DRY_RUN }}
36+
DAYS_BEFORE_CLOSE: '3'
37+
REQUIRE_AUTHOR_ASSIGNED: 'true'
38+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd #v8.0.0
39+
with:
40+
script: |
41+
const script = require('./.github/scripts/linked_issue_enforce.js');
42+
await script({ github, context});

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.
5656
- Added GitHub workflow that makes sure newly added test files follow pytest test files naming conventions (#1054)
5757
- Added advanced issue template for contributors `.github/ISSUE_TEMPLATE/06_advanced_issue.yml`.
5858
- Add new tests to `tests/unit/topic_info_query_test.py` (#1124)
59+
- Add Linked Issue Enforcer to automatically close PRs without linked issues `.github/workflows/bot-linked-issue-enforcer.yml`.
5960

6061
### Changed
6162
- Reduce office-hours reminder spam by posting only on each user's most recent open PR, grouping by author and sorting by creation time (#1121)

0 commit comments

Comments
 (0)