-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathgenerate.js
More file actions
188 lines (170 loc) · 7.6 KB
/
generate.js
File metadata and controls
188 lines (170 loc) · 7.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
#!/usr/bin/env node
// generate.js — fetches GitHub data and writes index.html
// Run: GITHUB_TOKEN=... node generate.js
import { readFileSync, writeFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const ORG = 'projectbluefin';
const TOKEN = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || '';
// Core repos that move the needle — explicitly scoped, no noise
const REPOS = [
'bluefin',
'bluefin-lts',
'common',
'dakota',
'actions',
'renovate-config',
'bonedigger',
'knuckle',
];
const SCOPE = REPOS.map(r => `repo:${ORG}/${r}`).join(' ');
const headers = {
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
...(TOKEN ? { 'Authorization': `Bearer ${TOKEN}` } : {}),
};
async function search(q) {
const url = `https://api.github.com/search/issues?q=${encodeURIComponent(q)}&per_page=100&sort=updated&order=desc`;
const res = await fetch(url, { headers });
if (!res.ok) throw new Error(`GitHub API ${res.status} for: ${q}`);
return (await res.json()).items || [];
}
// Returns { total, items } — total reflects actual count beyond per_page cap
async function searchFull(q, perPage = 10) {
const url = `https://api.github.com/search/issues?q=${encodeURIComponent(q)}&per_page=${perPage}&sort=updated&order=desc`;
const res = await fetch(url, { headers });
if (!res.ok) throw new Error(`GitHub API ${res.status} for: ${q}`);
const json = await res.json();
return { total: json.total_count || 0, items: json.items || [] };
}
const BOT_EXCL = `-author:app/renovate -author:dependabot -author:github-actions[bot]`;
// Load baseline — counters start at 0 from this snapshot date
const __dir = dirname(fileURLToPath(import.meta.url));
const baseline = JSON.parse(readFileSync(join(__dir, 'baseline.json'), 'utf8'));
const START_DATE = baseline.startDate;
const DATA_FILE = join(__dir, 'data.json');
// Run all queries in parallel
const [
p0, p1, triage, blocked,
prApproved, prRequired, prNone,
dreams, relief, toil,
agentReady,
] = await Promise.all([
search(`${SCOPE} is:issue is:open label:"hive/p0"`),
search(`${SCOPE} is:issue is:open label:"hive/p1"`),
search(`${SCOPE} is:issue is:open label:"needs-triage"`),
search(`${SCOPE} is:issue is:open label:"agent/blocked"`),
search(`${SCOPE} is:pr is:open -is:draft review:approved`),
search(`${SCOPE} is:pr is:open -is:draft review:required`),
search(`${SCOPE} is:pr is:open -is:draft review:none`),
searchFull(`${SCOPE} is:pr is:merged merged:>=${START_DATE} ${BOT_EXCL}`),
searchFull(`${SCOPE} is:issue is:closed closed:>=${START_DATE}`),
searchFull(`${SCOPE} is:pr is:merged merged:>=${START_DATE} author:app/renovate`),
search(`org:${ORG} is:issue is:open label:"queue/agent-ready"`),
]);
// Deduplicate issues across tiers (higher tier wins)
const seen = new Set();
function dedup(items) {
return items.filter(i => { if (seen.has(i.html_url)) return false; seen.add(i.html_url); return true; });
}
const p0d = dedup(p0);
const p1d = dedup(p1.filter(i => !new Set(p0d.map(x=>x.html_url)).has(i.html_url)));
const triaged = dedup(triage.filter(i => !p0d.find(x=>x.html_url===i.html_url) && !p1d.find(x=>x.html_url===i.html_url)));
const blockedD = dedup(blocked.filter(i => !p0d.find(x=>x.html_url===i.html_url) && !p1d.find(x=>x.html_url===i.html_url)));
// Deduplicate PRs — approved wins over required wins over none
const prSeen = new Set();
function dedupPR(items) {
return items.filter(i => { if (prSeen.has(i.html_url)) return false; prSeen.add(i.html_url); return true; });
}
const approvedD = dedupPR(prApproved);
const requiredD = dedupPR(prRequired.filter(i => !prSeen.has(i.html_url)));
const noneD = dedupPR(prNone.filter(i => !prSeen.has(i.html_url)));
// Fetch approval counts for a list of PRs via GraphQL (batched, 25 per query)
async function fetchReviewCounts(prs) {
if (!prs.length || !TOKEN) return new Map();
const BATCH = 25;
const map = new Map();
for (let i = 0; i < prs.length; i += BATCH) {
const batch = prs.slice(i, i + BATCH);
const fragments = batch.map((pr, j) => {
const parts = pr.html_url.split('/');
const owner = parts[3], repo = parts[4], number = parseInt(parts[6]);
return `pr${i + j}: repository(owner:"${owner}",name:"${repo}") {
pullRequest(number:${number}) {
approved: reviews(states:[APPROVED]) { totalCount }
changes: reviews(states:[CHANGES_REQUESTED]) { totalCount }
}
}`;
}).join('\n');
const res = await fetch('https://api.github.com/graphql', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ query: `{ ${fragments} }` }),
});
const json = await res.json();
if (json.data) {
batch.forEach((pr, j) => {
const d = json.data[`pr${i + j}`]?.pullRequest;
map.set(pr.html_url, {
approved: d?.approved?.totalCount ?? 0,
changes: d?.changes?.totalCount ?? 0,
});
});
}
}
return map;
}
// Fetch review counts for all open PRs
const allPRs = [...approvedD, ...requiredD, ...noneD];
const reviewCounts = await fetchReviewCounts(allPRs);
function withReviews(items) {
return items.map(pr => ({ ...pr, _reviews: reviewCounts.get(pr.html_url) || { approved: 0, changes: 0 } }));
}
const approvedRD = withReviews(approvedD);
const requiredRD = withReviews(requiredD);
const noneRD = withReviews(noneD);
// Slim items to only what the template needs (keeps data.json lean)
function slimLabel({ name, color }) { return { name, color }; }
function slimIssue({ title, html_url, repository_url, updated_at, labels }) {
return { title, html_url, repository_url, updated_at, labels: (labels || []).map(slimLabel) };
}
function slimPR({ title, html_url, repository_url, updated_at, labels, _reviews }) {
return { title, html_url, repository_url, updated_at, labels: (labels || []).map(slimLabel), _reviews };
}
function slimVictory(items) {
return items.slice(0, 10).map(({ title, html_url, repository_url, updated_at }) =>
({ title, html_url, repository_url, updated_at })
);
}
const data = {
generated: new Date().toISOString(),
repos: REPOS,
issues: {
p0: p0d.map(slimIssue),
p1: p1d.map(slimIssue),
triage: triaged.map(slimIssue),
blocked: blockedD.map(slimIssue),
agentReady: agentReady.map(slimIssue),
},
prs: {
approved: approvedRD.map(slimPR),
required: requiredRD.map(slimPR),
none: noneRD.map(slimPR),
},
victories: {
startDate: START_DATE,
dreams: { count: Math.max(0, dreams.total - baseline.dreams), recent: slimVictory(dreams.items) },
relief: { count: Math.max(0, relief.total - baseline.relief), recent: slimVictory(relief.items) },
toil: { count: Math.max(0, toil.total - baseline.toil), recent: slimVictory(toil.items) },
},
};
const template = readFileSync(join(__dir, 'template.html'), 'utf8');
const output = template.replace('__DATA__', JSON.stringify(data));
writeFileSync(join(__dir, 'index.html'), output);
writeFileSync(DATA_FILE, JSON.stringify(data)); // served live for client-side refresh
const total = p0d.length + p1d.length + triaged.length + blockedD.length;
const totalPRs = approvedD.length + requiredD.length + noneD.length;
console.log(`✓ Generated: ${total} issues, ${totalPRs} PRs, ${agentReady.length} agent-ready`);
console.log(` Repos: ${REPOS.join(', ')}`);
console.log(` Since ${START_DATE}: dreams=${data.victories.dreams.count} relief=${data.victories.relief.count} toil=${data.victories.toil.count} (baseline: d=${baseline.dreams} r=${baseline.relief} t=${baseline.toil})`);
console.log(` Timestamp: ${data.generated}`);