Merge branch 'devel' into dev/flip-raw-logs-variable

This commit is contained in:
Gabriel Grubba
2025-10-06 18:24:25 -03:00
committed by GitHub
3 changed files with 346 additions and 158 deletions

View 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');
});

View File

@@ -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
)
);
};

View File

@@ -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
},