@@ -2,89 +2,187 @@ name: Require linked issue with community support
22
33on :
44 pull_request_target :
5- types : [opened, edited, synchronize, reopened]
6- workflow_dispatch :
5+ types : [opened, edited, synchronize, reopened, ready_for_review]
76
87permissions :
98 contents : read
10- issues : read
9+ issues : write
1110 pull-requests : write
1211
12+ concurrency :
13+ group : ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
14+ cancel-in-progress : true
15+
1316jobs :
1417 enforce :
15- if : github.event_name == 'workflow_dispatch' || github.event.pull_request.draft == false
18+ if : github.event_name == 'pull_request_target' && ! github.event.pull_request.draft
1619 runs-on : ubuntu-latest
1720
1821 steps :
19- - name : Verify linked issue
20- if : github.event_name == 'pull_request_target'
21- uses : nearform-actions/github-action-check-linked-issues@v1.2.7
22+ - name : Check linked issue and community support
23+ uses : actions/github-script@v7
2224 with :
2325 github-token : ${{ secrets.GITHUB_TOKEN }}
24- comment : true
25- exclude-branches : main
26- custom-body : |
27- No linked issues found. Please add "Fixes #<issue-number>" to your pull request description.
26+ script : |
27+ // Strip code blocks and inline code to avoid false matches
28+ const stripCode = txt =>
29+ txt.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '');
2830
29- Per our [Contributing Guidelines](https://github.com/google/langextract/blob/main/CONTRIBUTING.md#pull-request-guidelines), all PRs must:
30- - Reference an issue with "Fixes #123" or "Closes #123"
31- - The linked issue should have 5+ 👍 reactions
32- - Include discussion demonstrating the importance of the change
31+ // Combine title + body for comprehensive search
32+ const prText = stripCode(`${context.payload.pull_request.title || ''}\n${context.payload.pull_request.body || ''}`);
3333
34- Use GitHub automation to close the issue when this PR is merged.
34+ // Issue reference pattern: #123, org/repo#123, or full URL (with http/https and optional www)
35+ const issueRef = String.raw`(?:#(?<num>\d+)|(?<o1>[\w.-]+)\/(?<r1>[\w.-]+)#(?<n1>\d+)|https?:\/\/(?:www\.)?github\.com\/(?<o2>[\w.-]+)\/(?<r2>[\w.-]+)\/issues\/(?<n2>\d+))`;
36+
37+ // Keywords - supporting common variants
38+ const closingRe = new RegExp(String.raw`\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\b\s*:?\s+${issueRef}`, 'gi');
39+ const referenceRe = new RegExp(String.raw`\b(?:related\s+to|relates\s+to|refs?|part\s+of|addresses|see(?:\s+also)?|depends\s+on|blocked\s+by|supersedes)\b\s*:?\s+${issueRef}`, 'gi');
40+
41+ // Gather all matches
42+ const closings = [...prText.matchAll(closingRe)];
43+ const references = [...prText.matchAll(referenceRe)];
44+ const first = closings[0] || references[0];
45+
46+ // Check for draft PRs and bots
47+ const pr = context.payload.pull_request;
48+ const isDraft = !!pr.draft;
49+ const login = pr.user.login;
50+ const isBot = pr.user.type === 'Bot' || /\[bot\]$/.test(login);
51+
52+ if (isDraft || isBot) {
53+ core.info('Draft or bot PR – skipping enforcement');
54+ return;
55+ }
3556
36- - name : Check community support
37- if : github.event_name == 'pull_request_target'
38- uses : actions/github-script@v7
39- with :
40- github-token : ${{ secrets.GITHUB_TOKEN }}
41- script : |
4257 // Check if PR author is a maintainer
43- const prAuthor = context.payload.pull_request.user.login;
44- const { data: authorPermission } = await github.rest.repos.getCollaboratorPermissionLevel({
45- owner: context.repo.owner,
46- repo: context.repo.repo,
47- username: prAuthor
48- });
58+ let authorPerm = 'none';
59+ try {
60+ const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
61+ owner: context.repo.owner,
62+ repo: context.repo.repo,
63+ username: pr.user.login,
64+ });
65+ authorPerm = data.permission || 'none';
66+ } catch (_) {
67+ // User might not have any permissions
68+ }
69+
70+ core.info(`Author permission: ${authorPerm}`);
71+ const isMaintainer = ['admin', 'maintain'].includes(authorPerm); // Removed 'write' for stricter maintainer definition
72+
73+ // Maintainers bypass entirely
74+ if (isMaintainer) {
75+ core.info(`Maintainer ${pr.user.login} - bypassing linked issue requirement`);
76+ return;
77+ }
78+
79+ if (!first) {
80+ // Check for existing comment to avoid duplicates
81+ const MARKER = '<!-- linkcheck:missing-issue -->';
82+ const existing = await github.paginate(github.rest.issues.listComments, {
83+ owner: context.repo.owner,
84+ repo: context.repo.repo,
85+ issue_number: context.payload.pull_request.number,
86+ per_page: 100,
87+ });
88+ const alreadyLeft = existing.some(c => c.body && c.body.includes(MARKER));
89+
90+ if (!alreadyLeft) {
91+ const contribUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/blob/main/CONTRIBUTING.md#pull-request-guidelines`;
92+ const commentBody = [
93+ 'No linked issues found. Please link an issue in your pull request description or title.',
94+ '',
95+ `Per our [Contributing Guidelines](${contribUrl}), all PRs must:`,
96+ '- Reference an issue with one of:',
97+ ' - **Closing keywords**: `Fixes #123`, `Closes #123`, `Resolves #123` (auto-closes on merge in the same repository)',
98+ ' - **Reference keywords**: `Related to #123`, `Refs #123`, `Part of #123`, `See #123` (links without closing)',
99+ '- The linked issue should have 5+ 👍 reactions from unique users (excluding bots and the PR author)',
100+ '- Include discussion demonstrating the importance of the change',
101+ '',
102+ 'You can also use cross-repo references like `owner/repo#123` or full URLs.',
103+ '',
104+ MARKER
105+ ].join('\n');
49106
50- const isMaintainer = ['admin', 'maintain'].includes(authorPermission.permission);
107+ await github.rest.issues.createComment({
108+ owner: context.repo.owner,
109+ repo: context.repo.repo,
110+ issue_number: context.payload.pull_request.number,
111+ body: commentBody
112+ });
113+ }
114+
115+ core.setFailed('No linked issue found. Use "Fixes #123" to close an issue or "Related to #123" to reference it.');
116+ return;
117+ }
51118
52- const body = context.payload.pull_request.body || '';
53- const match = body.match(/(?:Fixes|Closes|Resolves)\s+#(\d+)/i);
119+ // Resolve owner/repo/number, defaulting to the current repo
120+ const groups = first.groups || {};
121+ const owner = groups.o1 || groups.o2 || context.repo.owner;
122+ const repo = groups.r1 || groups.r2 || context.repo.repo;
123+ const issue_number = Number(groups.num || groups.n1 || groups.n2);
54124
55- if (!match) {
56- core.setFailed('No linked issue found');
125+ // Validate issue number
126+ if (!Number.isInteger(issue_number) || issue_number <= 0) {
127+ core.setFailed(
128+ 'Found a potential issue link but no valid number. ' +
129+ 'Use "Fixes #123" or "Related to owner/repo#123".'
130+ );
57131 return;
58132 }
59133
60- const issueNumber = Number(match[1]);
61- const { repository } = await github.graphql(`
62- query($owner: String!, $repo: String!, $number: Int!) {
63- repository(owner: $owner, name: $repo) {
64- issue(number: $number) {
65- reactionGroups {
66- content
67- users {
68- totalCount
69- }
70- }
71- }
134+ core.info(`Found linked issue: ${owner}/${repo}#${issue_number}`);
135+
136+ // Count unique users who reacted with 👍 on the linked issue (excluding bots and PR author)
137+ try {
138+ const reactions = await github.paginate(github.rest.reactions.listForIssue, {
139+ owner,
140+ repo,
141+ issue_number,
142+ per_page: 100,
143+ });
144+
145+ const prAuthorId = pr.user.id;
146+ const uniqueThumbs = new Set(
147+ reactions
148+ .filter(r =>
149+ r.content === '+1' &&
150+ r.user &&
151+ r.user.id !== prAuthorId &&
152+ r.user.type !== 'Bot' &&
153+ !String(r.user.login || '').endsWith('[bot]')
154+ )
155+ .map(r => r.user.id)
156+ ).size;
157+
158+ core.info(`Issue ${owner}/${repo}#${issue_number} has ${uniqueThumbs} unique 👍 reactions`);
159+
160+ const REQUIRED_THUMBS_UP = 5;
161+ if (uniqueThumbs < REQUIRED_THUMBS_UP) {
162+ core.setFailed(`Linked issue ${owner}/${repo}#${issue_number} has only ${uniqueThumbs} 👍 (need ${REQUIRED_THUMBS_UP}).`);
163+ return;
164+ }
165+ } catch (error) {
166+ const isSameRepo = owner === context.repo.owner && repo === context.repo.repo;
167+ if (error.status === 404 || error.status === 403) {
168+ if (!isSameRepo) {
169+ core.setFailed(
170+ `Linked issue ${owner}/${repo}#${issue_number} is not accessible. ` +
171+ `Please link to an issue in ${context.repo.owner}/${context.repo.repo} or a public repo.`
172+ );
173+ } else {
174+ core.info(`Cannot access reactions for ${owner}/${repo}#${issue_number}; skipping enforcement for same-repo issue.`);
72175 }
176+ return;
177+ }
178+
179+ // Any other error should fail to prevent accidental bypass
180+ const msg = (error && error.message) ? String(error.message).toLowerCase() : '';
181+ const isRateLimit = msg.includes('rate limit') || error?.headers?.['x-ratelimit-remaining'] === '0';
182+
183+ if (isRateLimit) {
184+ core.setFailed(`Rate limit while checking reactions for ${owner}/${repo}#${issue_number}. Please retry the workflow.`);
185+ } else {
186+ core.setFailed(`Unexpected error checking reactions for ${owner}/${repo}#${issue_number}: ${error?.message || error}`);
73187 }
74- `, {
75- owner: context.repo.owner,
76- repo: context.repo.repo,
77- number: issueNumber
78- });
79-
80- const reactions = repository.issue.reactionGroups;
81- const thumbsUp = reactions.find(g => g.content === 'THUMBS_UP')?.users.totalCount || 0;
82-
83- core.info(`Issue #${issueNumber} has ${thumbsUp} 👍 reactions`);
84-
85- const REQUIRED_THUMBS_UP = 5;
86- if (thumbsUp < REQUIRED_THUMBS_UP && !isMaintainer) {
87- core.setFailed(`Issue #${issueNumber} needs at least ${REQUIRED_THUMBS_UP} 👍 reactions (currently has ${thumbsUp})`);
88- } else if (isMaintainer && thumbsUp < REQUIRED_THUMBS_UP) {
89- core.info(`Maintainer ${prAuthor} bypassing community support requirement (issue has ${thumbsUp} 👍 reactions)`);
90188 }
0 commit comments