Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ jobs:
node-version: '24'
- name: Validate curated data and generated README
run: node scripts/validate.js
- name: Test failure-closed automation
run: node scripts/test-fail-closed.js
- name: Confirm generated README is clean
run: |
node scripts/generate-readme.js
Expand Down
49 changes: 39 additions & 10 deletions scripts/check-links.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,39 @@ const fs = require('fs');
const path = require('path');

const PROJECTS_PATH = path.join(__dirname, '..', 'data', 'projects.json');
const FAIL_STATUSES = new Set(['not_found', 'archived', 'disabled']);

function errorMessage(error) {
const stderr = error.stderr ? String(error.stderr).trim() : '';
const message = stderr || String(error.message || 'Unknown GitHub API error').trim();
return message.split('\n')[0];
}

function checkRepo(repo) {
try {
const result = JSON.parse(execFileSync('gh', ['api', `repos/${repo}`, '--jq', '{archived: .archived, disabled: .disabled, full_name: .full_name}'], { encoding: 'utf-8', timeout: 15000 }));

const output = execFileSync('gh', ['api', `repos/${repo}`, '--jq', '{archived: .archived, disabled: .disabled, full_name: .full_name}'], { encoding: 'utf-8', timeout: 15000 });
const result = JSON.parse(output);

if (
!result ||
typeof result !== 'object' ||
typeof result.archived !== 'boolean' ||
typeof result.disabled !== 'boolean' ||
typeof result.full_name !== 'string' ||
!/^[^/\s]+\/[^/\s]+$/.test(result.full_name)
) {
return { status: 'error', repo, error: 'Invalid response from GitHub API' };
}
if (result.archived) return { status: 'archived', repo };
if (result.disabled) return { status: 'disabled', repo };
if (result.full_name !== repo) return { status: 'redirected', repo, target: result.full_name };
return { status: 'ok', repo };
} catch (e) {
const msg = e.message || '';
if (msg.includes('404') || msg.includes('Not Found')) {
} catch (error) {
const message = errorMessage(error);
if (/(?:HTTP\s+404\b|\b404:\s*Not Found\b)/i.test(message)) {
return { status: 'not_found', repo };
}
return { status: 'error', repo, error: msg.split('\n')[0] };
return { status: 'error', repo, error: message };
}
}

Expand Down Expand Up @@ -60,11 +78,11 @@ function main() {
}
}

const FAIL_STATUSES = new Set(['not_found', 'archived', 'disabled']);
const failures = issues.filter(i => FAIL_STATUSES.has(i.status));
const warnings = issues.filter(i => !FAIL_STATUSES.has(i.status));
const failures = issues.filter(issue => FAIL_STATUSES.has(issue.status));
const warnings = issues.filter(issue => issue.status === 'redirected');
const validationErrors = issues.filter(issue => issue.status === 'error');

console.log(`\nResults: ${ok} ok, ${failures.length} failures, ${warnings.length} warnings`);
console.log(`\nResults: ${ok} ok, ${failures.length} failures, ${warnings.length} warnings, ${validationErrors.length} validation errors`);

const issuesOut = process.env.LINK_ISSUES_OUT;
if (issuesOut) {
Expand All @@ -80,12 +98,23 @@ function main() {
}
}

if (validationErrors.length > 0) {
console.log('\nValidation errors (link validation incomplete):');
for (const issue of validationErrors) {
console.log(` [${issue.status}] ${issue.name} (${issue.repo})`);
if (issue.error) console.log(` -> ${issue.error}`);
}
}

if (failures.length > 0) {
console.log('\nFailures:');
for (const issue of failures) {
console.log(` [${issue.status}] ${issue.name} (${issue.repo})`);
if (issue.error) console.log(` -> ${issue.error}`);
}
}

if (failures.length > 0 || validationErrors.length > 0) {
process.exit(1);
}
}
Expand Down
37 changes: 31 additions & 6 deletions scripts/monitor-github.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ function parseArgs() {
return config;
}

function errorMessage(error) {
const stderr = error.stderr ? String(error.stderr).trim() : '';
const message = stderr || String(error.message || 'Unknown GitHub API error').trim();
return message.split('\n')[0];
}

function ghSearch(query, maxAgeDays = 7) {
const since = new Date();
since.setDate(since.getDate() - maxAgeDays);
Expand All @@ -61,10 +67,15 @@ function ghSearch(query, maxAgeDays = 7) {
try {
const cmd = `gh api search/issues --method GET -f q="${fullQuery}" -f sort=created -f order=desc -f per_page=${MAX_RESULTS_PER_QUERY} --jq '.items'`;
const result = execSync(cmd, { encoding: 'utf-8', timeout: 30000 });
return JSON.parse(result || '[]');
} catch (e) {
console.error(`Search failed for "${query}": ${e.message}`);
return [];
const items = JSON.parse(result);
if (!Array.isArray(items)) {
throw new Error('Invalid GitHub search response: expected an array');
}
return { items };
} catch (error) {
const message = errorMessage(error);
console.error(`Search failed for "${query}": ${message}`);
return { error: message };
}
}

Expand Down Expand Up @@ -135,15 +146,29 @@ function main() {
console.log(`Monitoring GitHub for relevant discussions (max age: ${config.maxAgeDays} days)`);

let allItems = [];
const searchFailures = [];

for (const query of SEARCH_QUERIES) {
process.stdout.write(` Searching: "${query}" ... `);
const items = ghSearch(query, config.maxAgeDays);
const filtered = filterResults(items);
const result = ghSearch(query, config.maxAgeDays);
if (result.error) {
console.log('FAILED');
searchFailures.push({ query, error: result.error });
continue;
}
const filtered = filterResults(result.items);
console.log(`${filtered.length} results`);
allItems.push(...filtered);
}

if (searchFailures.length > 0) {
console.error(`\nMonitor incomplete: ${searchFailures.length}/${SEARCH_QUERIES.length} search query(ies) failed. Previous output was not modified.`);
for (const failure of searchFailures) {
console.error(` ${failure.query}: ${failure.error}`);
}
process.exit(1);
}

const unique = dedup(allItems);
const ranked = formatForReview(unique);

Expand Down
34 changes: 24 additions & 10 deletions scripts/sync-link-cleanup-issue.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,10 @@
* close the open queue issue when the report is empty.
*
* Reads the issues array written by scripts/check-links.js (path comes from
* env LINK_ISSUES_FILE, defaults to /tmp/link-issues.json). A missing file
* or empty array is treated as a green report: any open auto-link-cleanup
* issue whose title matches the auto queue is closed. Unrelated human
* issues are never closed. Transient `error` statuses stay on the
* create/update path and are listed as API errors, not as missing repos.
* env LINK_ISSUES_FILE, defaults to /tmp/link-issues.json). A verified empty
* array closes an open auto-link-cleanup issue. Missing, malformed, or
* incomplete reports refuse all external mutations so a transient scan cannot
* close or refresh a cleanup issue.
*
* Requires: gh CLI authenticated; repo permissions issues: write.
*/
Expand All @@ -23,6 +22,7 @@ const LABEL_COLOR = 'fbca04';
const LABEL_DESC = 'Automated repository link cleanup queue';
const ISSUE_FILE = process.env.LINK_ISSUES_FILE || '/tmp/link-issues.json';
const CLEANUP_TITLE_RE = /^\[auto\] Repository link cleanup /i;
const KNOWN_STATUSES = new Set(['not_found', 'archived', 'disabled', 'redirected', 'error']);

function gh(args, opts = {}) {
return execFileSync('gh', args, { encoding: 'utf-8', stdio: opts.stdio ?? ['ignore', 'pipe', 'inherit'] });
Expand Down Expand Up @@ -123,12 +123,20 @@ function closeComment(url) {

function loadIssues() {
if (!fs.existsSync(ISSUE_FILE)) {
console.log('No issues file at ' + ISSUE_FILE + ', treating as 0 link issues.');
return [];
console.error('No issues file at ' + ISSUE_FILE + '; refusing cleanup issue mutations without a completed link scan.');
process.exit(1);
}
const issues = JSON.parse(fs.readFileSync(ISSUE_FILE, 'utf-8'));
if (!Array.isArray(issues)) {
console.error('Issues file is not an array: ' + ISSUE_FILE);

let issues;
try {
issues = JSON.parse(fs.readFileSync(ISSUE_FILE, 'utf-8'));
} catch (error) {
console.error('Could not read issues file ' + ISSUE_FILE + '; refusing cleanup issue mutations: ' + error.message);
process.exit(1);
}

if (!Array.isArray(issues) || issues.some(issue => !issue || !KNOWN_STATUSES.has(issue.status))) {
console.error('Issues file is not a valid completed link report: ' + ISSUE_FILE);
process.exit(1);
}
return issues;
Expand Down Expand Up @@ -193,6 +201,12 @@ function createOrUpdateIssue(issues, url) {

function main() {
const issues = loadIssues();
const incomplete = issues.filter(issue => issue.status === 'error');
if (incomplete.length > 0) {
console.error('Link scan is incomplete (' + incomplete.length + ' API error(s)); refusing cleanup issue mutations.');
process.exit(1);
}

const url = runUrl();

if (issues.length === 0) {
Expand Down
Loading
Loading