Skip to content

Commit 2e7a9e3

Browse files
authored
feat: implement auto-close for PRs with negative reviews (#309)
Add symmetric auto-close functionality to complement auto-merge: - PRs with negative coefficient (< 0) now auto-close after extended period - Close duration: (1 + coefficient) × baseCloseTimeInHours (default: 100 days) - Timer resets on commits, just like merge path - 10x more forgiving than merge (100 vs 10 days default) Configuration: - New baseCloseTimeInHours config option (default: 2400 hours = 100 days) - Configurable via .worlddriven.ini per repository Implementation: - Updated time calculation logic to handle both merge and close paths - Added closePullRequest() API function with hybrid auth support - GitHub status shows "Close at YYYY-MM-DD" for negative coefficients - Processor now handles close action alongside merge action Examples: - Coefficient 0: merge at 10 days (unchanged) - Coefficient -0.5: close at 50 days - Coefficient -1.0: close immediately Addresses stale PR cleanup while maintaining democratic rejection consensus.
1 parent 7c21617 commit 2e7a9e3

File tree

6 files changed

+483
-41
lines changed

6 files changed

+483
-41
lines changed

src/helpers/config.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export function parseIniFile(content) {
5858
export async function fetchRepositoryConfig(githubClient, owner, repo) {
5959
const defaultConfig = {
6060
baseMergeTimeInHours: 240,
61+
baseCloseTimeInHours: 2400,
6162
perCommitTimeInHours: 0,
6263
merge_method: 'squash',
6364
};
@@ -121,6 +122,13 @@ export async function fetchRepositoryConfig(githubClient, owner, repo) {
121122
}
122123
}
123124

125+
if (defaultSection.baseCloseTimeInHours) {
126+
const value = parseFloat(defaultSection.baseCloseTimeInHours);
127+
if (!isNaN(value) && value >= 0) {
128+
config.baseCloseTimeInHours = value;
129+
}
130+
}
131+
124132
if (defaultSection.merge_method) {
125133
const method = defaultSection.merge_method.toLowerCase();
126134
if (['merge', 'squash', 'rebase'].includes(method)) {

src/helpers/github.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import {
33
getPullRequestsApp,
44
mergePullRequestApp,
5+
closePullRequestApp,
56
setCommitStatusApp,
67
getLatestCommitShaApp,
78
createIssueCommentApp,
@@ -128,6 +129,62 @@ export async function mergePullRequest(
128129
}
129130
}
130131

132+
/**
133+
* closePullRequest - Hybrid authentication (GitHub App or PAT)
134+
*
135+
* @param {object|number} userOrInstallationId - User object with githubAccessToken or installationId number
136+
* @param {string} owner
137+
* @param {string} repo
138+
* @param {number} number
139+
* @return {void}
140+
*/
141+
export async function closePullRequest(
142+
userOrInstallationId,
143+
owner,
144+
repo,
145+
number
146+
) {
147+
// If it's a number, treat as installationId (GitHub App)
148+
if (
149+
typeof userOrInstallationId === 'number' ||
150+
(typeof userOrInstallationId === 'string' && !isNaN(userOrInstallationId))
151+
) {
152+
return await closePullRequestApp(
153+
parseInt(userOrInstallationId),
154+
owner,
155+
repo,
156+
number
157+
);
158+
}
159+
160+
// Otherwise, use existing PAT logic
161+
const user = userOrInstallationId;
162+
const url = `https://api.github.com/repos/${owner}/${repo}/pulls/${number}`;
163+
164+
try {
165+
const response = await fetch(url, {
166+
method: 'PATCH',
167+
headers: {
168+
Accept: 'application/vnd.github.v3+json',
169+
Authorization: `token ${user.githubAccessToken}`,
170+
'Content-Type': 'application/json',
171+
},
172+
body: JSON.stringify({
173+
state: 'closed',
174+
}),
175+
});
176+
177+
if (!response.ok) {
178+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
179+
}
180+
181+
return response;
182+
} catch (e) {
183+
console.log(e);
184+
return;
185+
}
186+
}
187+
131188
/**
132189
* createIssueComment - Hybrid authentication (GitHub App or PAT)
133190
*

src/helpers/githubApp.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,24 @@ export async function mergePullRequestApp(
5959
}
6060
}
6161

62+
export async function closePullRequestApp(installationId, owner, repo, number) {
63+
const octokit = await getInstallationOctokit(installationId);
64+
try {
65+
return await octokit.rest.pulls.update({
66+
owner,
67+
repo,
68+
pull_number: number,
69+
state: 'closed',
70+
});
71+
} catch (error) {
72+
console.error(
73+
`Failed to close PR ${owner}/${repo}#${number}:`,
74+
error.message
75+
);
76+
throw error;
77+
}
78+
}
79+
6280
export async function setCommitStatusApp(
6381
installationId,
6482
owner,

src/helpers/pullRequest.js

Lines changed: 48 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ function processReviews(contributors, reviews, pull) {
128128
}
129129

130130
/**
131-
* Calculate time-based metrics for pull request merging
131+
* Calculate time-based metrics for pull request merging or closing
132132
*
133133
* @param {Array} contributors - Array of contributor objects
134134
* @param {object} dates - Date metrics object
@@ -140,32 +140,57 @@ function processReviews(contributors, reviews, pull) {
140140
function calculateTimeMetrics(contributors, dates, pull, coefficient, config) {
141141
const age = (new Date().getTime() - dates.max) / 1000;
142142

143-
const totalMergeTime =
144-
(config.baseMergeTimeInHours / 24 +
145-
(pull.commits * config.perCommitTimeInHours) / 24) *
146-
24 *
147-
60 *
148-
60;
149-
150-
const mergeDuration = (1 - coefficient) * totalMergeTime;
151-
const daysToMerge = mergeDuration - age;
152-
153-
// Calculate individual contributor time values
143+
// Calculate individual contributor time values (for merge path)
154144
const votesTotal = contributors.reduce((total, current) => {
155145
return total + current.commits;
156146
}, 0);
157147

158-
for (const contributor of contributors) {
159-
contributor.timeValue = (contributor.commits / votesTotal) * totalMergeTime;
160-
}
148+
if (coefficient >= 0) {
149+
// MERGE PATH: Positive or neutral coefficient
150+
const totalMergeTime =
151+
(config.baseMergeTimeInHours / 24 +
152+
(pull.commits * config.perCommitTimeInHours) / 24) *
153+
24 *
154+
60 *
155+
60;
161156

162-
return {
163-
age,
164-
daysToMerge,
165-
mergeDuration,
166-
totalMergeTime,
167-
mergeDate: (new Date(dates.max).getTime() + mergeDuration * 1000) / 1000,
168-
};
157+
const mergeDuration = (1 - coefficient) * totalMergeTime;
158+
const daysToMerge = mergeDuration - age;
159+
160+
for (const contributor of contributors) {
161+
contributor.timeValue =
162+
(contributor.commits / votesTotal) * totalMergeTime;
163+
}
164+
165+
return {
166+
action: 'merge',
167+
age,
168+
daysToMerge,
169+
mergeDuration,
170+
totalMergeTime,
171+
mergeDate: (new Date(dates.max).getTime() + mergeDuration * 1000) / 1000,
172+
};
173+
} else {
174+
// CLOSE PATH: Negative coefficient
175+
const totalCloseTime = (config.baseCloseTimeInHours / 24) * 24 * 60 * 60;
176+
177+
const closeDuration = (1 + coefficient) * totalCloseTime;
178+
const daysToClose = closeDuration - age;
179+
180+
// Set contributor time values to 0 for close path (not applicable)
181+
for (const contributor of contributors) {
182+
contributor.timeValue = 0;
183+
}
184+
185+
return {
186+
action: 'close',
187+
age,
188+
daysToClose,
189+
closeDuration,
190+
totalCloseTime,
191+
closeDate: (new Date(dates.max).getTime() + closeDuration * 1000) / 1000,
192+
};
193+
}
169194
}
170195

171196
/**
@@ -231,12 +256,7 @@ export async function getPullRequestData(githubClient, owner, repo, number) {
231256
coefficient: coefficient,
232257
},
233258
dates: dates,
234-
times: {
235-
daysToMerge: timeMetrics.daysToMerge,
236-
mergeDuration: timeMetrics.mergeDuration,
237-
totalMergeTime: timeMetrics.totalMergeTime,
238-
mergeDate: timeMetrics.mergeDate,
239-
},
259+
times: timeMetrics,
240260
};
241261

242262
return pullRequestData;

src/helpers/pullRequestProcessor.js

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { getPullRequestData } from './pullRequest.js';
22
import {
33
mergePullRequest,
4+
closePullRequest,
45
setCommitStatus,
56
getLatestCommitSha,
67
} from './github.js';
@@ -31,13 +32,14 @@ async function setPullRequestStatus(
3132

3233
let state, description;
3334

34-
if (coefficient >= 0) {
35+
if (pullRequestData.times.action === 'merge') {
3536
const mergeDate = new Date(pullRequestData.times.mergeDate * 1000);
3637
state = 'success';
3738
description = `${coefficient.toFixed(2)} Merge at ${mergeDate.toISOString().split('T')[0]}`;
38-
} else {
39+
} else if (pullRequestData.times.action === 'close') {
40+
const closeDate = new Date(pullRequestData.times.closeDate * 1000);
3941
state = 'error';
40-
description = `${coefficient.toFixed(2)} Will not merge`;
42+
description = `${coefficient.toFixed(2)} Close at ${closeDate.toISOString().split('T')[0]}`;
4143
}
4244

4345
await setCommitStatus(
@@ -140,8 +142,14 @@ export async function processPullRequests() {
140142
pullRequest.number
141143
);
142144

143-
prResult.daysToMerge = pullRequestData.times.daysToMerge;
144-
console.log(`Days to merge: ${pullRequestData.times.daysToMerge}`);
145+
prResult.daysToMerge = pullRequestData.times.daysToMerge || null;
146+
prResult.daysToClose = pullRequestData.times.daysToClose || null;
147+
const actionDays =
148+
pullRequestData.times.daysToMerge ||
149+
pullRequestData.times.daysToClose;
150+
console.log(
151+
`Days to ${pullRequestData.times.action}: ${actionDays}`
152+
);
145153

146154
// Set GitHub status for this pull request
147155
await setPullRequestStatus(
@@ -152,7 +160,10 @@ export async function processPullRequests() {
152160
pullRequestData
153161
);
154162

155-
if (pullRequestData.times.daysToMerge < 0) {
163+
if (
164+
pullRequestData.times.action === 'merge' &&
165+
pullRequestData.times.daysToMerge < 0
166+
) {
156167
console.log(
157168
`⚡ Merging ${repository.owner}/${repository.repo} - ${pullRequestData.title} using ${pullRequestData.config.merge_method} method`
158169
);
@@ -181,15 +192,47 @@ export async function processPullRequests() {
181192
console.log('❌ Cannot merge PR');
182193
prResult.action = 'merge_failed';
183194
}
195+
} else if (
196+
pullRequestData.times.action === 'close' &&
197+
pullRequestData.times.daysToClose < 0
198+
) {
199+
console.log(
200+
`🗑️ Closing ${repository.owner}/${repository.repo} - ${pullRequestData.title} due to negative feedback`
201+
);
202+
203+
const closeResponse = await closePullRequest(
204+
authMethod,
205+
repository.owner,
206+
repository.repo,
207+
pullRequest.number
208+
);
209+
210+
if (closeResponse) {
211+
await updateOrCreateWorlddrivenComment(
212+
authMethod,
213+
repository.owner,
214+
repository.repo,
215+
pullRequest.number,
216+
pullRequestData,
217+
'Pull request closed by worlddriven ❌'
218+
);
219+
console.log('✅ Closed successfully');
220+
prResult.action = 'closed';
221+
} else {
222+
console.log('❌ Cannot close PR');
223+
prResult.action = 'close_failed';
224+
}
184225
} else {
185226
const daysRemaining = Math.ceil(
186-
pullRequestData.times.daysToMerge / 86400
227+
(pullRequestData.times.daysToMerge ||
228+
pullRequestData.times.daysToClose) / 86400
187229
);
230+
const action = pullRequestData.times.action;
188231
console.log(
189-
`⏳ PR not ready for merge (${daysRemaining} days remaining)`
232+
`⏳ PR not ready for ${action} (${daysRemaining} days remaining)`
190233
);
191234

192-
// Update comment to refresh merge countdown without adding activity log entry
235+
// Update comment to refresh countdown without adding activity log entry
193236
await updateOrCreateWorlddrivenComment(
194237
authMethod,
195238
repository.owner,

0 commit comments

Comments
 (0)