mirror of
https://github.com/meteor/meteor.git
synced 2026-05-02 03:01:46 -04:00
Merge branch 'devel' into dev/flip-raw-logs-variable
This commit is contained in:
175
.github/scripts/__tests__/inactive-issues.test.js
vendored
Normal file
175
.github/scripts/__tests__/inactive-issues.test.js
vendored
Normal file
@@ -0,0 +1,175 @@
|
||||
// Tests for inactive-issues.js using Node's built-in test runner (node:test)
|
||||
// We only care about the last comments (per user instruction), so we don't need pagination logic.
|
||||
|
||||
const { test, beforeEach } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const path = require('node:path');
|
||||
|
||||
// Load the script dynamically so we can pass mocks.
|
||||
const scriptPath = path.join(__dirname, '..', 'inactive-issues.js');
|
||||
|
||||
// Helper to advance days
|
||||
const daysAgo = (days) => {
|
||||
const d = new Date();
|
||||
d.setUTCDate(d.getUTCDate() - days);
|
||||
return d.toISOString();
|
||||
};
|
||||
|
||||
// Factory for github REST mock structure
|
||||
function buildGithubMock({ issues, commentsByIssue }) {
|
||||
return {
|
||||
rest: {
|
||||
issues: {
|
||||
listForRepo: async ({ page, per_page }) => {
|
||||
// simple pagination slice
|
||||
const start = (page - 1) * per_page;
|
||||
const end = start + per_page;
|
||||
return { data: issues.slice(start, end) };
|
||||
},
|
||||
listComments: async ({ issue_number }) => {
|
||||
return { data: commentsByIssue[issue_number] || [] };
|
||||
},
|
||||
createComment: async ({ issue_number, body }) => {
|
||||
// push comment to structure to allow assertions on side effects if needed
|
||||
const arr = commentsByIssue[issue_number] || (commentsByIssue[issue_number] = []);
|
||||
arr.push({
|
||||
id: Math.random(),
|
||||
body,
|
||||
created_at: new Date().toISOString(),
|
||||
user: { login: 'github-actions[bot]', type: 'Bot' }
|
||||
});
|
||||
return {};
|
||||
},
|
||||
addLabels: async ({ issue_number, labels }) => {
|
||||
const issue = issues.find(i => i.number === issue_number);
|
||||
if (issue) {
|
||||
(issue.labels || (issue.labels = [])).push(...labels.map(l => ({ name: l })));
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Wrap script invocation for reuse
|
||||
async function runScript({ issues, commentsByIssue }) {
|
||||
delete require.cache[require.resolve(scriptPath)];
|
||||
const fn = require(scriptPath);
|
||||
const github = buildGithubMock({ issues, commentsByIssue });
|
||||
await fn({ github, context: { repo: { owner: 'meteor', repo: 'meteor' } } });
|
||||
return { issues, commentsByIssue };
|
||||
}
|
||||
|
||||
let baseIssueNumber = 1000;
|
||||
function makeIssue({ daysSinceHumanActivity, isPR = false, labels = [], user = 'user1' }) {
|
||||
// We'll simulate by setting created_at to the human activity date if no comments.
|
||||
const updated_at = daysAgo(daysSinceHumanActivity);
|
||||
return {
|
||||
number: baseIssueNumber++,
|
||||
pull_request: isPR ? {} : undefined,
|
||||
labels: labels.map(n => ({ name: n })),
|
||||
user: { login: user },
|
||||
created_at: daysAgo(daysSinceHumanActivity),
|
||||
updated_at
|
||||
};
|
||||
}
|
||||
|
||||
// TESTS
|
||||
|
||||
beforeEach(() => {
|
||||
baseIssueNumber = 1000;
|
||||
});
|
||||
|
||||
test('60 days inactivity -> adds reminder comment (no prior reminder)', async () => {
|
||||
const issue = makeIssue({ daysSinceHumanActivity: 60 });
|
||||
const issues = [issue];
|
||||
const commentsByIssue = { [issue.number]: [] }; // no comments
|
||||
|
||||
await runScript({ issues, commentsByIssue });
|
||||
|
||||
const botComments = commentsByIssue[issue.number].filter(c => c.user.login === 'github-actions[bot]');
|
||||
assert.equal(botComments.length, 1, 'Should have 1 reminder comment');
|
||||
assert.match(botComments[0].body, /60 days/);
|
||||
assert.ok(!issue.labels.some(l => l.name === 'idle'), 'Should not label yet');
|
||||
});
|
||||
|
||||
test('60 days inactivity but already reminded -> no duplicate comment', async () => {
|
||||
const issue = makeIssue({ daysSinceHumanActivity: 65 });
|
||||
const issues = [issue];
|
||||
const commentsByIssue = {
|
||||
[issue.number]: [
|
||||
{
|
||||
id: 1,
|
||||
body: '👋 @user1 This issue has been open with no human activity for 60 days. Is this issue still relevant? If there is no human response or activity within the next 30 days, this issue will be labeled as `idle`.',
|
||||
created_at: daysAgo(5), // 5 days ago bot comment (means last human is 65 days, bot after human)
|
||||
user: { login: 'github-actions[bot]', type: 'Bot' }
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
await runScript({ issues, commentsByIssue });
|
||||
|
||||
const botComments = commentsByIssue[issue.number].filter(c => c.user.login === 'github-actions[bot]');
|
||||
assert.equal(botComments.length, 1, 'Should still have only the existing reminder');
|
||||
});
|
||||
|
||||
test('90 days inactivity -> label + comment', async () => {
|
||||
const issue = makeIssue({ daysSinceHumanActivity: 95 });
|
||||
const issues = [issue];
|
||||
const commentsByIssue = { [issue.number]: [] };
|
||||
|
||||
await runScript({ issues, commentsByIssue });
|
||||
|
||||
assert.ok(issue.labels.some(l => l.name === 'idle'), 'Should add idle label');
|
||||
const botComments = commentsByIssue[issue.number].filter(c => c.user.login === 'github-actions[bot]');
|
||||
assert.equal(botComments.length, 1, 'Should comment when labeling');
|
||||
assert.match(botComments[0].body, /90 days/i);
|
||||
});
|
||||
|
||||
test('Human reply after reminder resets cycle (no immediate labeling)', async () => {
|
||||
// Scenario: last human activity 10 days ago, bot commented 40 days ago (which was 50 days after original). Should NOT comment again or label.
|
||||
const issue = makeIssue({ daysSinceHumanActivity: 10 });
|
||||
const issues = [issue];
|
||||
const commentsByIssue = {
|
||||
[issue.number]: [
|
||||
{
|
||||
id: 1,
|
||||
body: '👋 @user1 This issue has been open with no human activity for 60 days... ',
|
||||
created_at: daysAgo(50),
|
||||
user: { login: 'github-actions[bot]', type: 'Bot' }
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
body: 'I am still seeing this problem',
|
||||
created_at: daysAgo(10),
|
||||
user: { login: 'some-human', type: 'User' }
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
await runScript({ issues, commentsByIssue });
|
||||
|
||||
const botComments = commentsByIssue[issue.number].filter(c => c.user.login === 'github-actions[bot]');
|
||||
assert.equal(botComments.length, 1, 'Should not add a new bot comment');
|
||||
assert.ok(!issue.labels.some(l => l.name === 'idle'), 'Should not label');
|
||||
});
|
||||
|
||||
test('Only bot comments (no human ever) counts from creation date', async () => {
|
||||
const issue = makeIssue({ daysSinceHumanActivity: 61 });
|
||||
const issues = [issue];
|
||||
const commentsByIssue = {
|
||||
[issue.number]: [
|
||||
{
|
||||
id: 1,
|
||||
body: 'Automated maintenance notice',
|
||||
created_at: daysAgo(30),
|
||||
user: { login: 'github-actions[bot]', type: 'Bot' }
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
await runScript({ issues, commentsByIssue });
|
||||
const botComments = commentsByIssue[issue.number].filter(c => /60 days/.test(c.body));
|
||||
assert.equal(botComments.length, 1, 'Should add a 60-day reminder');
|
||||
});
|
||||
326
.github/scripts/inactive-issues.js
vendored
326
.github/scripts/inactive-issues.js
vendored
@@ -1,190 +1,200 @@
|
||||
/**
|
||||
* Mark issues as idle after a period of inactivity
|
||||
* and post reminders after a shorter period of inactivity.
|
||||
*
|
||||
* 1. Issues with no human activity for 60 days get a reminder comment.
|
||||
* 2. Issues with no human activity for 90 days get labeled as "idle" and get a comment.
|
||||
*
|
||||
* Human activity is defined as any comment from a non-bot user.
|
||||
*
|
||||
* This script is intended to be run as a GitHub Action on a schedule (e.g., daily).
|
||||
*/
|
||||
module.exports = async ({ github, context }) => {
|
||||
const daysToComment = 60;
|
||||
const daysToLabel = 90;
|
||||
|
||||
const idleTimeComment = daysToComment * 24 * 60 * 60 * 1000;
|
||||
const idleTimeLabel = daysToLabel * 24 * 60 * 60 * 1000;
|
||||
const now = new Date();
|
||||
const idleTimeComment = daysToComment * 24 * 60 * 60 * 1000; // 60 days in milliseconds
|
||||
const idleTimeLabel = daysToLabel * 24 * 60 * 60 * 1000; // 90 days in milliseconds
|
||||
|
||||
// Function to fetch issues until we find recently updated ones
|
||||
|
||||
const BOT_LOGIN = 'github-actions[bot]';
|
||||
const REMINDER_PHRASE = 'Is this issue still relevant?';
|
||||
|
||||
const COMMENT_60_TEMPLATE = (login) =>
|
||||
`👋 @${login} This issue has been open with no human activity for ${daysToComment} days. Is this issue still relevant? If there is no human response or activity within the next ${daysToLabel - daysToComment} days, this issue will be labeled as \`idle\`.`;
|
||||
|
||||
const COMMENT_90_TEXT =
|
||||
'This issue has been automatically labeled as `idle` due to 90 days of inactivity (no human interaction). If this is still relevant, please comment to reactivate it.';
|
||||
|
||||
// Fetch all open issues
|
||||
async function fetchAllIssues() {
|
||||
let allIssues = [];
|
||||
let page = 1;
|
||||
let hasNextPage = true;
|
||||
const now = new Date();
|
||||
const minInactivity = idleTimeComment; // 60 days in milliseconds
|
||||
|
||||
while (hasNextPage) {
|
||||
const response = await github.rest.issues.listForRepo({
|
||||
const per_page = 100;
|
||||
const results = [];
|
||||
let keepGoing = true;
|
||||
|
||||
while (keepGoing) {
|
||||
const { data } = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
page: page,
|
||||
per_page,
|
||||
page,
|
||||
sort: 'updated',
|
||||
direction: 'asc' // Oldest updated first
|
||||
direction: 'asc'
|
||||
});
|
||||
|
||||
// Check if the most recently updated issue on this page is too recent
|
||||
let recentIssueFound = false;
|
||||
if (response.data.length > 0) {
|
||||
// Check the last issue on the page (most recently updated)
|
||||
const lastIssue = response.data[response.data.length - 1];
|
||||
const lastIssueUpdatedAt = new Date(lastIssue.updated_at);
|
||||
const timeSinceLastIssueUpdate = now.getTime() - lastIssueUpdatedAt.getTime();
|
||||
|
||||
if (timeSinceLastIssueUpdate < minInactivity) {
|
||||
// This page already has issues that are too recent, filter them out
|
||||
const filteredIssues = response.data.filter(issue => {
|
||||
const issueUpdatedAt = new Date(issue.updated_at);
|
||||
const timeSinceUpdate = now.getTime() - issueUpdatedAt.getTime();
|
||||
return timeSinceUpdate >= minInactivity;
|
||||
});
|
||||
|
||||
allIssues = allIssues.concat(filteredIssues);
|
||||
recentIssueFound = true;
|
||||
hasNextPage = false;
|
||||
} else {
|
||||
// All issues on this page are old enough, keep them all
|
||||
allIssues = allIssues.concat(response.data);
|
||||
}
|
||||
}
|
||||
|
||||
// Stop if we found recent issues or reached the end of pagination
|
||||
if (recentIssueFound) {
|
||||
hasNextPage = false;
|
||||
} else if (response.data.length < 100) {
|
||||
hasNextPage = false;
|
||||
|
||||
if (!data.length) break;
|
||||
results.push(...data);
|
||||
|
||||
if (data.length < per_page) {
|
||||
keepGoing = false;
|
||||
} else {
|
||||
page++;
|
||||
// Small delay to avoid hitting rate limits
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
}
|
||||
}
|
||||
|
||||
return allIssues;
|
||||
return results;
|
||||
}
|
||||
|
||||
// Fetch all issues
|
||||
const allIssues = await fetchAllIssues();
|
||||
|
||||
let processedCount = 0;
|
||||
let commentedCount = 0;
|
||||
let labeledCount = 0;
|
||||
|
||||
for (const issue of allIssues) {
|
||||
processedCount++;
|
||||
|
||||
// Skip pull requests
|
||||
if (issue.pull_request) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip issues that already have the idle label
|
||||
if (issue.labels.some(label => label.name === 'idle')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get latest comment or update date
|
||||
const issueUpdatedAt = new Date(issue.updated_at);
|
||||
const timeSinceUpdate = now.getTime() - issueUpdatedAt.getTime();
|
||||
|
||||
// Handle 60-day idle issues (comment)
|
||||
if (timeSinceUpdate > idleTimeComment && timeSinceUpdate <= idleTimeLabel) {
|
||||
// Check if bot already commented to avoid duplicate comments
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
// Check if there's a recent bot comment
|
||||
const botCommented = comments.data.some(comment => {
|
||||
const commentDate = new Date(comment.created_at);
|
||||
const timeSinceComment = now.getTime() - commentDate.getTime();
|
||||
const isBot = comment.user.login === 'github-actions[bot]';
|
||||
const isRecent = timeSinceComment < idleTimeComment;
|
||||
const hasRightContent = comment.body.includes('Is this issue still relevant?');
|
||||
|
||||
return isBot && isRecent && hasRightContent;
|
||||
});
|
||||
|
||||
if (!botCommented) {
|
||||
try {
|
||||
const result = await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: `👋 @${issue.user.login} This issue has been open for 60 days with no activity. Is this issue still relevant? If there is no response or activity within the next 30 days, this issue will be labeled as \`idle\`.`
|
||||
});
|
||||
commentedCount++;
|
||||
} catch (error) {
|
||||
// Add retry logic
|
||||
try {
|
||||
// Wait for 5 seconds before retrying
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
|
||||
const retryResult = await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: `👋 @${issue.user.login} This issue has been open for 60 days with no activity. Is this issue still relevant? If there is no response or activity within the next 30 days, this issue will be labeled as \`idle\`.`
|
||||
});
|
||||
commentedCount++;
|
||||
} catch (retryError) {
|
||||
// Failed retry, continue with other issues
|
||||
}
|
||||
}
|
||||
// analyse comments to find last human activity and if a reminder was already posted after that
|
||||
async function analyzeComments(issueNumber, issueCreatedAt) {
|
||||
const commentsResp = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
const comments = commentsResp.data;
|
||||
let lastHumanActivity = null;
|
||||
|
||||
for (let i = comments.length - 1; i >= 0; i--) {
|
||||
const c = comments[i];
|
||||
const isBot = c.user?.type === 'Bot' || c.user?.login === BOT_LOGIN;
|
||||
if (!isBot) {
|
||||
lastHumanActivity = new Date(c.created_at);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle 90-day idle issues (add label)
|
||||
else if (timeSinceUpdate > idleTimeLabel) {
|
||||
// Check if the issue has the idle label
|
||||
if (!issue.labels.some(label => label.name === 'idle')) {
|
||||
|
||||
if (!lastHumanActivity) {
|
||||
lastHumanActivity = new Date(issueCreatedAt);
|
||||
}
|
||||
|
||||
const hasReminderAfterLastHuman = comments.some(
|
||||
(c) =>
|
||||
c.user?.login === BOT_LOGIN &&
|
||||
c.body?.includes(REMINDER_PHRASE) &&
|
||||
new Date(c.created_at) > lastHumanActivity
|
||||
);
|
||||
|
||||
return { lastHumanActivity, hasReminderAfterLastHuman };
|
||||
}
|
||||
|
||||
const issues = await fetchAllIssues();
|
||||
|
||||
let processed = 0;
|
||||
let commented = 0;
|
||||
let labeled = 0;
|
||||
let skippedPR = 0;
|
||||
|
||||
for (const issue of issues) {
|
||||
processed++;
|
||||
|
||||
if (issue.pull_request) {
|
||||
skippedPR++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (issue.labels.some((l) => l.name === 'idle')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let analysis;
|
||||
try {
|
||||
analysis = await analyzeComments(issue.number, issue.created_at);
|
||||
} catch (err) {
|
||||
continue; // fail to get comments, skip
|
||||
}
|
||||
|
||||
const { lastHumanActivity, hasReminderAfterLastHuman } = analysis;
|
||||
const inactivityMs = now.getTime() - lastHumanActivity.getTime();
|
||||
|
||||
// 90+ days => label + comment
|
||||
if (inactivityMs >= idleTimeLabel) {
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ['idle']
|
||||
});
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: COMMENT_90_TEXT
|
||||
});
|
||||
labeled++;
|
||||
continue;
|
||||
} catch (err) {
|
||||
// retry simples
|
||||
try {
|
||||
// Add the label
|
||||
const labelResult = await github.rest.issues.addLabels({
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ['idle']
|
||||
});
|
||||
|
||||
// Add a comment when labeling as idle
|
||||
const commentResult = await github.rest.issues.createComment({
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: `This issue has been automatically labeled as \`idle\` due to 90 days of inactivity. If this issue is still relevant, please comment to reactivate it.`
|
||||
body: COMMENT_90_TEXT
|
||||
});
|
||||
labeledCount++;
|
||||
} catch (error) {
|
||||
// Add retry logic with exponential backoff
|
||||
try {
|
||||
// Wait for 5 seconds before retrying
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
|
||||
const retryLabelResult = await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ['idle']
|
||||
});
|
||||
|
||||
// Retry adding comment
|
||||
const retryCommentResult = await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: `This issue has been automatically labeled as \`idle\` due to 90 days of inactivity. If this issue is still relevant, please comment to reactivate it.`
|
||||
});
|
||||
labeledCount++;
|
||||
} catch (retryError) {
|
||||
// Continue with other issues if retry fails
|
||||
}
|
||||
}
|
||||
labeled++;
|
||||
} catch {}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 60-89 days => comment (once)
|
||||
if (
|
||||
inactivityMs >= idleTimeComment &&
|
||||
inactivityMs < idleTimeLabel &&
|
||||
!hasReminderAfterLastHuman
|
||||
) {
|
||||
const body = COMMENT_60_TEMPLATE(issue.user.login);
|
||||
try {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body
|
||||
});
|
||||
commented++;
|
||||
} catch (err) {
|
||||
try {
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body
|
||||
});
|
||||
commented++;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log summary for CI
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ processed, commented, labeled, skippedPR },
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
"prettier": "^2.8.8",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"scripts": {
|
||||
"test:idle-bot": "node --test .github/scripts/__tests__/inactive-issues.test.js"
|
||||
},
|
||||
"jshintConfig": {
|
||||
"esversion": 11
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user