Skip to content

Commit 0f0653c

Browse files
Copilotnzakaslumirlumir
authored
feat: issue-PR linking plugin to comment on issues (#226)
* Initial plan * Implement issue-PR linking plugin to comment on issues when PRs reference them Co-authored-by: nzakas <[email protected]> * Address PR feedback: update regex keywords, remove unused parameter, switch to PR body processing Co-authored-by: nzakas <[email protected]> * Address PR feedback: improve regex with word boundaries and named groups, add JSDoc comments, use array for event listeners, add EOL, and enhance test coverage Co-authored-by: lumirlumir <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: nzakas <[email protected]> Co-authored-by: lumirlumir <[email protected]>
1 parent acbeef7 commit 0f0653c

File tree

4 files changed

+693
-0
lines changed

4 files changed

+693
-0
lines changed

src/app.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const plugins = require("./plugins");
2525
const enabledPlugins = new Set([
2626
"autoAssign",
2727
"commitMessage",
28+
"issuePrLink",
2829
"needsInfo",
2930
"recurringIssues",
3031
"releaseMonitor",

src/plugins/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
module.exports = {
1616
autoAssign: require("./auto-assign"),
1717
commitMessage: require("./commit-message"),
18+
issuePrLink: require("./issue-pr-link"),
1819
needsInfo: require("./needs-info"),
1920
recurringIssues: require("./recurring-issues"),
2021
releaseMonitor: require("./release-monitor"),

src/plugins/issue-pr-link/index.js

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/**
2+
* @fileoverview Comment on issues when PRs are created/edited to fix them
3+
* @author ESLint GitHub Bot Contributors
4+
*/
5+
6+
"use strict";
7+
8+
//-----------------------------------------------------------------------------
9+
// Type Definitions
10+
//-----------------------------------------------------------------------------
11+
12+
/** @typedef {import("probot").Context} ProbotContext */
13+
14+
//-----------------------------------------------------------------------------
15+
// Helpers
16+
//-----------------------------------------------------------------------------
17+
18+
/**
19+
* Regex to find issue references in PR bodies
20+
* Matches patterns like: "Fix #123", "Fixes #123", "Closes #123", "Resolves #123", etc.
21+
*/
22+
const ISSUE_REFERENCE_REGEX = /\b(?:fix|fixes|fixed|close|closes|closed|resolve|resolves|resolved)\b:?[ \t]+#(?<issueNumber>\d+)/giu;
23+
24+
/**
25+
* Maximum number of issues to comment on per PR to prevent abuse
26+
*/
27+
const MAX_ISSUES_PER_PR = 3;
28+
29+
/**
30+
* Extract issue numbers from PR body
31+
* @param {string} body PR body
32+
* @returns {number[]} Array of issue numbers
33+
* @private
34+
*/
35+
function extractIssueNumbers(body) {
36+
const matches = [];
37+
let match;
38+
39+
// Reset regex lastIndex to ensure we start from the beginning
40+
ISSUE_REFERENCE_REGEX.lastIndex = 0;
41+
42+
while ((match = ISSUE_REFERENCE_REGEX.exec(body)) !== null && matches.length < MAX_ISSUES_PER_PR) {
43+
const issueNumber = parseInt(match.groups.issueNumber, 10);
44+
if (!matches.includes(issueNumber)) {
45+
matches.push(issueNumber);
46+
}
47+
}
48+
49+
return matches;
50+
}
51+
52+
/**
53+
* Create the comment message for the issue
54+
* @param {string} prUrl URL of the pull request
55+
* @param {string} prAuthor Author of the pull request
56+
* @returns {string} comment message
57+
* @private
58+
*/
59+
function createCommentMessage(prUrl, prAuthor) {
60+
return `👋 Hi! This issue is being addressed in pull request ${prUrl}. Thanks, @${prAuthor}!
61+
62+
[//]: # (issue-pr-link)`;
63+
}
64+
65+
/**
66+
* Check if an issue exists and is open
67+
* @param {ProbotContext} context Probot context object
68+
* @param {number} issueNumber Issue number to check
69+
* @returns {Promise<boolean>} True if issue exists and is open
70+
* @private
71+
*/
72+
async function isIssueOpenAndExists(context, issueNumber) {
73+
try {
74+
const { data: issue } = await context.octokit.issues.get(
75+
context.repo({ issue_number: issueNumber })
76+
);
77+
return issue.state === "open";
78+
} catch {
79+
// Issue doesn't exist or we don't have access
80+
return false;
81+
}
82+
}
83+
84+
/**
85+
* Check if we already commented on this issue for this PR
86+
* @param {ProbotContext} context Probot context object
87+
* @param {number} issueNumber Issue number
88+
* @param {number} prNumber PR number
89+
* @returns {Promise<boolean>} True if we already commented
90+
* @private
91+
*/
92+
async function hasExistingComment(context, issueNumber, prNumber) {
93+
try {
94+
const { data: comments } = await context.octokit.issues.listComments(
95+
context.repo({ issue_number: issueNumber })
96+
);
97+
98+
const botComments = comments.filter(comment =>
99+
comment.user.type === "Bot" &&
100+
comment.body.includes("[//]: # (issue-pr-link)") &&
101+
comment.body.includes(`/pull/${prNumber}`)
102+
);
103+
104+
return botComments.length > 0;
105+
} catch {
106+
// If we can't check comments, assume we haven't commented
107+
return false;
108+
}
109+
}
110+
111+
/**
112+
* Comment on issues referenced in the PR body
113+
* @param {ProbotContext} context Probot context object
114+
* @returns {Promise<void>}
115+
* @private
116+
*/
117+
async function commentOnReferencedIssues(context) {
118+
const { payload } = context;
119+
const pr = payload.pull_request;
120+
121+
if (!pr || !pr.body) {
122+
return;
123+
}
124+
125+
const issueNumbers = extractIssueNumbers(pr.body);
126+
127+
if (issueNumbers.length === 0) {
128+
return;
129+
}
130+
131+
const prUrl = pr.html_url;
132+
const prAuthor = pr.user.login;
133+
const prNumber = pr.number;
134+
135+
// Comment on each referenced issue
136+
for (const issueNumber of issueNumbers) {
137+
try {
138+
// Check if issue exists and is open
139+
if (!(await isIssueOpenAndExists(context, issueNumber))) {
140+
continue;
141+
}
142+
143+
// Check if we already commented on this issue for this PR
144+
if (await hasExistingComment(context, issueNumber, prNumber)) {
145+
continue;
146+
}
147+
148+
// Create the comment
149+
await context.octokit.issues.createComment(
150+
context.repo({
151+
issue_number: issueNumber,
152+
body: createCommentMessage(prUrl, prAuthor)
153+
})
154+
);
155+
} catch (error) {
156+
// Log error but continue with other issues
157+
// eslint-disable-next-line no-console -- Logging errors is intentional
158+
console.error(`Failed to comment on issue #${issueNumber}:`, error);
159+
}
160+
}
161+
}
162+
163+
//-----------------------------------------------------------------------------
164+
// Robot
165+
//-----------------------------------------------------------------------------
166+
167+
module.exports = robot => {
168+
robot.on(["pull_request.opened", "pull_request.edited"], commentOnReferencedIssues);
169+
};

0 commit comments

Comments
 (0)