PageIndex/scripts/autoclose-labeled-issues.js
copilot-swe-agent[bot] b3cb9531a4 Add GitHub Actions workflows for issue deduplication and auto-close
Co-authored-by: BukeLy <19304666+BukeLy@users.noreply.github.com>
2026-03-02 03:54:18 +00:00

183 lines
6.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* scripts/autoclose-labeled-issues.js
*
* Closes open issues that carry the "autoclose" label and have been inactive
* (no updates) for more than INACTIVITY_DAYS days.
*
* Required environment variables:
* GITHUB_TOKEN GitHub Actions token (or PAT with repo:issues write access)
* REPO_OWNER Repository owner (e.g. VectifyAI)
* REPO_NAME Repository name (e.g. PageIndex)
*
* Optional environment variables:
* INACTIVITY_DAYS Days of inactivity before closing (default: 7)
* DRY_RUN If "true", report but do not close issues (default: false)
*/
'use strict';
const https = require('https');
// ── Configuration ─────────────────────────────────────────────────────────────
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
const REPO_OWNER = process.env.REPO_OWNER;
const REPO_NAME = process.env.REPO_NAME;
const INACTIVITY_DAYS = parseInt(process.env.INACTIVITY_DAYS || '7', 10);
const DRY_RUN = process.env.DRY_RUN === 'true';
// ── HTTP helper ───────────────────────────────────────────────────────────────
function githubRequest(method, path, body = null) {
return new Promise((resolve, reject) => {
const payload = body ? JSON.stringify(body) : null;
const options = {
hostname: 'api.github.com',
path,
method,
headers: {
'Authorization': `Bearer ${GITHUB_TOKEN}`,
'Accept': 'application/vnd.github+json',
'User-Agent': 'PageIndex-Autoclose-Script/1.0',
'X-GitHub-Api-Version': '2022-11-28',
...(payload ? {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
} : {}),
},
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', chunk => (data += chunk));
res.on('end', () => {
if (res.statusCode >= 400) {
reject(new Error(`GitHub API ${method} ${path}${res.statusCode}: ${data}`));
return;
}
try {
resolve(data ? JSON.parse(data) : {});
} catch {
resolve({});
}
});
});
req.on('error', reject);
if (payload) req.write(payload);
req.end();
});
}
/** Simple sleep helper for rate-limiting. */
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// ── Core logic ────────────────────────────────────────────────────────────────
/**
* Fetches all open issues with the "autoclose" label, paginating as needed.
*/
async function fetchAutocloseIssues() {
const issues = [];
let page = 1;
while (true) {
const data = await githubRequest(
'GET',
`/repos/${REPO_OWNER}/${REPO_NAME}/issues?state=open&labels=autoclose&per_page=100&page=${page}`
);
if (!Array.isArray(data) || data.length === 0) break;
// Filter out any pull requests that may surface
issues.push(...data.filter(i => !i.pull_request));
if (data.length < 100) break;
page++;
}
return issues;
}
/**
* Closes a single issue with a polite explanatory comment.
*/
async function closeIssue(issueNumber, inactivityDays) {
const body =
`This issue has been automatically closed because it was marked as a **duplicate** ` +
`and has had no new activity for ${inactivityDays} day(s).\n\n` +
`If you believe this was closed in error, please reopen the issue and leave a comment. ` +
`New human activity will prevent automatic closure in the future.\n\n` +
`Thank you for your contribution! 🙏`;
// Post closing comment first
await githubRequest(
'POST',
`/repos/${REPO_OWNER}/${REPO_NAME}/issues/${issueNumber}/comments`,
{ body }
);
// Close the issue
await githubRequest(
'PATCH',
`/repos/${REPO_OWNER}/${REPO_NAME}/issues/${issueNumber}`,
{ state: 'closed', state_reason: 'not_planned' }
);
}
// ── Entry point ───────────────────────────────────────────────────────────────
async function main() {
// Validate required env vars
const missing = ['GITHUB_TOKEN', 'REPO_OWNER', 'REPO_NAME']
.filter(k => !process.env[k]);
if (missing.length) {
console.error(`Missing required environment variables: ${missing.join(', ')}`);
process.exit(1);
}
const cutoff = new Date(Date.now() - INACTIVITY_DAYS * 24 * 60 * 60 * 1000);
console.log(`Auto-close inactive labelled issues`);
console.log(` Repository: ${REPO_OWNER}/${REPO_NAME}`);
console.log(` Inactivity days: ${INACTIVITY_DAYS} (cutoff: ${cutoff.toISOString()})`);
console.log(` Dry run: ${DRY_RUN}`);
const issues = await fetchAutocloseIssues();
console.log(`\nFound ${issues.length} open issue(s) with "autoclose" label.`);
let closedCount = 0;
let skippedCount = 0;
for (const issue of issues) {
const lastActivity = new Date(issue.updated_at);
const inactive = lastActivity < cutoff;
const daysSince = Math.floor((Date.now() - lastActivity.getTime()) / (1000 * 60 * 60 * 24));
if (!inactive) {
console.log(` #${issue.number} — active ${daysSince}d ago, skipping.`);
skippedCount++;
continue;
}
console.log(` #${issue.number} — inactive for ${daysSince}d: "${issue.title}"`);
if (DRY_RUN) {
console.log(` [DRY RUN] Would close issue #${issue.number}`);
closedCount++;
continue;
}
try {
await closeIssue(issue.number, INACTIVITY_DAYS);
console.log(` ✅ Closed issue #${issue.number}`);
closedCount++;
} catch (err) {
console.error(` ❌ Failed to close #${issue.number}: ${err.message}`);
}
// Respect GitHub's secondary rate limit
await sleep(1000);
}
console.log(`\nSummary: ${closedCount} closed, ${skippedCount} still active.`);
}
main().catch(err => {
console.error('Fatal error:', err.message);
process.exit(1);
});