@@ -3,6 +3,12 @@ name: PR Issue Link & Assignment Check
33on :
44 pull_request_target :
55 types : [opened, edited, reopened, synchronize]
6+ workflow_dispatch :
7+ inputs :
8+ pr_number :
9+ description : ' PR Number to check (optional. If empty, checks all open PRs)'
10+ required : false
11+ type : string
612
713permissions :
814 issues : write
@@ -20,111 +26,152 @@ jobs:
2026 github-token : ${{ secrets.GITHUB_TOKEN }}
2127 script : |
2228 const { owner, repo } = context.repo;
23- const pr = context.payload.pull_request;
24- const prNumber = pr.number;
25- const prAuthor = pr.user.login;
26- const prBody = pr.body ?? '';
27-
28- // ----------------------------------------------------------------
29- // 1. Parse a linked issue number from the PR body.
30- // Matches: closes/fixes/resolves #123 (case-insensitive)
31- // ----------------------------------------------------------------
32- const LINK_REGEX =
33- /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/gi;
34-
35- const issueNumbers = [];
36- let match;
37- while ((match = LINK_REGEX.exec(prBody)) !== null) {
38- issueNumbers.push(parseInt(match[1], 10));
29+ let pullRequests = [];
30+
31+ const prInput = context.payload.inputs?.pr_number;
32+ if (prInput) {
33+ try {
34+ const { data: pr } = await github.rest.pulls.get({
35+ owner, repo, pull_number: parseInt(prInput, 10),
36+ });
37+ pullRequests.push(pr);
38+ } catch (err) {
39+ core.setFailed(`Failed to fetch PR #${prInput}: ${err.message}`);
40+ return;
41+ }
42+ } else if (context.eventName === 'workflow_dispatch') {
43+ const { data: prs } = await github.rest.pulls.list({
44+ owner, repo, state: 'open', per_page: 100,
45+ });
46+ pullRequests = prs;
47+ core.info(`Fetched ${prs.length} open PR(s) to verify.`);
48+ } else {
49+ pullRequests.push(context.payload.pull_request);
3950 }
4051
4152 const SENTINEL_NO_LINK = '<!-- pr-issue-check: no-link -->';
4253 const SENTINEL_NOT_ASSIGNED = '<!-- pr-issue-check: not-assigned -->';
4354
4455 // Helper: fetch existing bot comments on this PR
45- async function getBotComments() {
56+ async function getBotComments(number ) {
4657 const { data: comments } = await github.rest.issues.listComments({
47- owner, repo, issue_number: prNumber , per_page: 100,
58+ owner, repo, issue_number: number , per_page: 100,
4859 });
4960 return comments.filter(c => c.user?.login === 'github-actions[bot]');
5061 }
5162
5263 // Helper: check whether we already posted a specific sentinel
53- async function alreadyPosted(sentinel) {
54- const comments = await getBotComments();
64+ async function alreadyPosted(number, sentinel) {
65+ const comments = await getBotComments(number );
5566 return comments.some(c => c.body?.includes(sentinel));
5667 }
5768
58- // ----------------------------------------------------------------
59- // 2. NO linked issue found → warn and stop (do NOT close the PR)
60- // ----------------------------------------------------------------
61- if (issueNumbers.length === 0) {
62- const already = await alreadyPosted(SENTINEL_NO_LINK);
63- if (!already) {
64- await github.rest.issues.createComment({
65- owner, repo, issue_number: prNumber,
66- body: `👋 Hey @${prAuthor}! Thanks for your contribution! 🎉
69+ for (const pr of pullRequests) {
70+ const prNumber = pr.number;
71+ const prAuthor = pr.user.login;
72+ const prBody = pr.body ?? '';
6773
68- ${SENTINEL_NO_LINK}
69- It looks like this PR isn't linked to any issue yet.
74+ core.info(`Checking PR #${prNumber} by @${prAuthor}...`);
75+
76+ // ----------------------------------------------------------------
77+ // 1. Parse a linked issue number from the PR body.
78+ // Matches: closes/fixes/resolves #123 (case-insensitive)
79+ // ----------------------------------------------------------------
80+ const LINK_REGEX =
81+ /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/gi;
82+
83+ const issueNumbers = [];
84+ let match;
85+ while ((match = LINK_REGEX.exec(prBody)) !== null) {
86+ issueNumbers.push(parseInt(match[1], 10));
87+ }
88+
89+ // ----------------------------------------------------------------
90+ // 2. NO linked issue found → warn, close the PR, and stop
91+ // ----------------------------------------------------------------
92+ if (issueNumbers.length === 0) {
93+ const already = await alreadyPosted(prNumber, SENTINEL_NO_LINK);
94+ if (!already) {
95+ await github.rest.issues.createComment({
96+ owner, repo, issue_number: prNumber,
97+ body: `👋 Hey @${prAuthor}! Thanks for your contribution! 🎉
7098
71- Please edit your PR description and add a closing keyword so we can track this properly, for example:
99+ ${SENTINEL_NO_LINK}
100+ Unfortunately, this PR has been **automatically closed** because it is not linked to any open issue.
72101
73- \`\`\`
74- Fixes #<issue-number>
75- \`\`\`
102+ To resolve this, please do the following:
103+ 1. **Link a valid open issue** by editing your PR description to include a closing keyword (e.g., \` Fixes #<issue-number>\`).
104+ 2. **Reopen this PR** once the link is added.
76105
77106 > 💡 You can link multiple issues if needed (e.g. \`Fixes #12, Closes #34\`).
78107 > If you're working on something that doesn't have an issue yet, please [open one first](https://github.com/${owner}/${repo}/issues/new/choose) and then link it here.
79108
80- Once you've updated the PR description the check will re-run automatically. 🙌`,
109+ We look forward to reviewing your PR once an issue is linked! 🚀`,
110+ });
111+ }
112+
113+ // Close the PR
114+ await github.rest.pulls.update({
115+ owner, repo, pull_number: prNumber, state: 'closed',
81116 });
117+
118+ continue; // check next PR
82119 }
83- return; // stop here — don't close, just inform
84- }
85120
86- // ----------------------------------------------------------------
87- // 3. Linked issue(s) found — check assignment for each one.
88- // We validate all linked issues and act on the first violation.
89- // ----------------------------------------------------------------
90- for (const issueNumber of issueNumbers) {
91- let issue;
92- try {
93- const { data } = await github.rest.issues.get({
94- owner, repo, issue_number: issueNumber,
95- });
96- issue = data;
97- } catch (err) {
98- if (err.status === 404) {
99- // Linked issue doesn't exist — treat same as no-link
100- const already = await alreadyPosted(SENTINEL_NO_LINK);
101- if (!already) {
102- await github.rest.issues.createComment({
103- owner, repo, issue_number: prNumber,
104- body: `👋 Hey @${prAuthor}!
121+ // ----------------------------------------------------------------
122+ // 3. Linked issue(s) found — check assignment for each one.
123+ // We validate all linked issues and act on the first violation.
124+ // ----------------------------------------------------------------
125+ let prViolation = false;
126+ for (const issueNumber of issueNumbers) {
127+ let issue;
128+ try {
129+ const { data } = await github.rest.issues.get({
130+ owner, repo, issue_number: issueNumber,
131+ });
132+ issue = data;
133+ } catch (err) {
134+ if (err.status === 404) {
135+ // Linked issue doesn't exist — treat same as no-link
136+ const already = await alreadyPosted(prNumber, SENTINEL_NO_LINK);
137+ if (!already) {
138+ await github.rest.issues.createComment({
139+ owner, repo, issue_number: prNumber,
140+ body: `👋 Hey @${prAuthor}!
105141
106142 ${SENTINEL_NO_LINK}
107- This PR references issue **#${issueNumber}**, but that issue doesn't seem to exist (or was deleted).
143+ Unfortunately, this PR has been **automatically closed** because the referenced issue **#${issueNumber}** does not exist (or was deleted).
144+
145+ To resolve this, please do the following:
146+ 1. **Link a valid open issue** by editing your PR description to include a closing keyword (e.g., \`Fixes #<issue-number>\`).
147+ 2. **Reopen this PR** once the link is added.
108148
109149 Please link a valid open issue before this PR can be reviewed. 🙏`,
150+ });
151+ }
152+
153+ // Close the PR
154+ await github.rest.pulls.update({
155+ owner, repo, pull_number: prNumber, state: 'closed',
110156 });
157+
158+ prViolation = true;
159+ break;
111160 }
112- return ;
161+ throw err ;
113162 }
114- throw err;
115- }
116163
117- // Is the PR author assigned to this issue?
118- const assignees = issue.assignees.map(a => a.login.toLowerCase());
119- const isAssigned = assignees.includes(prAuthor.toLowerCase());
164+ // Is the PR author assigned to this issue?
165+ const assignees = issue.assignees.map(a => a.login.toLowerCase());
166+ const isAssigned = assignees.includes(prAuthor.toLowerCase());
120167
121- if (!isAssigned) {
122- // Close the PR with a polite explanation
123- const already = await alreadyPosted(SENTINEL_NOT_ASSIGNED);
124- if (!already) {
125- await github.rest.issues.createComment({
126- owner, repo, issue_number: prNumber,
127- body: `👋 Hey @${prAuthor}! Thanks for your interest in contributing to CommitPulse! 🙏
168+ if (!isAssigned) {
169+ // Close the PR with a polite explanation
170+ const already = await alreadyPosted(prNumber, SENTINEL_NOT_ASSIGNED);
171+ if (!already) {
172+ await github.rest.issues.createComment({
173+ owner, repo, issue_number: prNumber,
174+ body: `👋 Hey @${prAuthor}! Thanks for your interest in contributing to CommitPulse! 🙏
128175
129176 ${SENTINEL_NOT_ASSIGNED}
130177 Unfortunately, this PR has been **automatically closed** because you are not assigned to the linked issue **[#${issueNumber} — ${issue.title}](${issue.html_url})**.
@@ -138,19 +185,20 @@ jobs:
138185 > 💡 You can be assigned to up to **3** open issues at a time. Check your current assignments before claiming a new one.
139186
140187 We look forward to your contribution once you're assigned! 🚀`,
188+ });
189+ }
190+
191+ // Close the PR
192+ await github.rest.pulls.update({
193+ owner, repo, pull_number: prNumber, state: 'closed',
141194 });
142- }
143195
144- // Close the PR
145- await github.rest.pulls.update({
146- owner, repo, pull_number: prNumber, state: 'closed',
147- });
196+ prViolation = true;
197+ break; // stop after first violation
198+ }
199+ }
148200
149- return; // stop after first violation
201+ if (!prViolation) {
202+ core.info(`✅ PR #${prNumber} by @${prAuthor} is properly linked and assigned.`);
150203 }
151204 }
152-
153- // ----------------------------------------------------------------
154- // 4. All checks passed — nothing to do.
155- // ----------------------------------------------------------------
156- core.info(`✅ PR #${prNumber} by @${prAuthor} is properly linked and assigned.`);
0 commit comments