mirror of
https://github.com/VectifyAI/PageIndex.git
synced 2026-04-24 23:56:21 +02:00
184 lines
6.2 KiB
JavaScript
184 lines
6.2 KiB
JavaScript
|
|
/**
|
|||
|
|
* 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);
|
|||
|
|
});
|