Skip to content

Commit 2713e1d

Browse files
authored
Merge branch 'main' into patch-1
2 parents 65d999d + 57c83fa commit 2713e1d

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+8149
-822
lines changed

.github/workflows/check-linked-issue.yml

Lines changed: 159 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -2,89 +2,187 @@ name: Require linked issue with community support
22

33
on:
44
pull_request_target:
5-
types: [opened, edited, synchronize, reopened]
6-
workflow_dispatch:
5+
types: [opened, edited, synchronize, reopened, ready_for_review]
76

87
permissions:
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+
1316
jobs:
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
}

.github/workflows/check-pr-size.yml

Lines changed: 76 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ on:
1313
permissions:
1414
contents: read
1515
pull-requests: write
16+
issues: write
17+
18+
concurrency:
19+
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
20+
cancel-in-progress: true
1621

1722
jobs:
1823
size:
@@ -23,22 +28,39 @@ jobs:
2328
id: get_pr
2429
uses: actions/github-script@v7
2530
with:
31+
result-encoding: string
2632
script: |
27-
const { data: pr } = await github.rest.pulls.get({
33+
const { data } = await github.rest.pulls.get({
2834
owner: context.repo.owner,
2935
repo: context.repo.repo,
3036
pull_number: ${{ github.event.inputs.pr_number }}
3137
});
32-
return pr;
38+
return JSON.stringify(data);
3339
3440
- name: Evaluate PR size
3541
if: github.event_name == 'pull_request_target' || (github.event_name == 'workflow_dispatch' && github.event.inputs.pr_number)
3642
uses: actions/github-script@v7
43+
env:
44+
PR_JSON: ${{ steps.get_pr.outputs.result }}
3745
with:
3846
script: |
39-
const pr = context.payload.pull_request || ${{ steps.get_pr.outputs.result || '{}' }};
40-
const totalChanges = pr.additions + pr.deletions;
47+
const pr = context.payload.pull_request || JSON.parse(process.env.PR_JSON || '{}');
48+
if (!pr || !pr.number) {
49+
core.setFailed('Unable to resolve PR data. For workflow_dispatch, pass a valid pr_number.');
50+
return;
51+
}
4152
53+
// Check for draft PRs and bots
54+
const isDraft = !!pr.draft;
55+
const login = pr.user.login;
56+
const isBot = pr.user.type === 'Bot' || /\[bot\]$/.test(login);
57+
58+
if (isDraft || isBot) {
59+
core.info('Draft or bot PR – skipping size enforcement');
60+
return;
61+
}
62+
63+
const totalChanges = pr.additions + pr.deletions;
4264
core.info(`PR contains ${pr.additions} additions and ${pr.deletions} deletions (${totalChanges} total)`);
4365
4466
const sizeLabel =
@@ -47,16 +69,62 @@ jobs:
4769
totalChanges < 600 ? 'size/M' :
4870
totalChanges < 1000 ? 'size/L' : 'size/XL';
4971
72+
// Re-fetch labels to avoid acting on stale payload data
73+
const { data: freshIssue } = await github.rest.issues.get({
74+
...context.repo,
75+
issue_number: pr.number
76+
});
77+
const currentLabels = (freshIssue.labels || []).map(l => l.name);
78+
79+
// Remove old size labels before adding new one
80+
const allSizeLabels = ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL'];
81+
const toRemove = currentLabels.filter(name => allSizeLabels.includes(name) && name !== sizeLabel);
82+
83+
for (const name of toRemove) {
84+
try {
85+
await github.rest.issues.removeLabel({
86+
...context.repo,
87+
issue_number: pr.number,
88+
name
89+
});
90+
} catch (_) {
91+
// Ignore if already removed
92+
}
93+
}
94+
5095
await github.rest.issues.addLabels({
5196
...context.repo,
5297
issue_number: pr.number,
5398
labels: [sizeLabel]
5499
});
55100
101+
// Check if PR author is a maintainer
102+
let authorPerm = 'none';
103+
try {
104+
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
105+
owner: context.repo.owner,
106+
repo: context.repo.repo,
107+
username: pr.user.login,
108+
});
109+
authorPerm = data.permission || 'none';
110+
} catch (_) {
111+
// User might not have any permissions
112+
}
113+
114+
core.info(`Author permission: ${authorPerm}`);
115+
const isMaintainer = ['admin', 'maintain'].includes(authorPerm); // Stricter maintainer definition
116+
117+
// Check for bypass label (using fresh labels)
118+
const hasBypass = currentLabels.includes('bypass:size-limit');
119+
56120
const MAX_LINES = 1000;
57121
if (totalChanges > MAX_LINES) {
58-
core.setFailed(
59-
`This PR contains ${totalChanges} lines of changes, which exceeds the maximum of ${MAX_LINES} lines. ` +
60-
`Please split this into smaller, focused pull requests.`
61-
);
122+
if (isMaintainer || hasBypass) {
123+
core.info(`${isMaintainer ? 'Maintainer' : 'Bypass label'} - allowing large PR with ${totalChanges} lines`);
124+
} else {
125+
core.setFailed(
126+
`This PR contains ${totalChanges} lines of changes, which exceeds the maximum of ${MAX_LINES} lines. ` +
127+
`Please split this into smaller, focused pull requests.`
128+
);
129+
}
62130
}

0 commit comments

Comments
 (0)