btcpay-contribute/scripts/fetch-issues.js

231 lines
9.1 KiB
JavaScript

// @ts-check
/**
* Fetches all open "good first issue" issues across the btcpayserver GitHub org.
* Writes output to public/data/issues.json.
* Exits with code 1 (no-op signal) if data is unchanged — avoids redundant deploys.
*
* Required env (CI): ORG_GITHUB_TOKEN — PAT with repo:read scope.
* The org has 70+ repos. Fetching all of them requires ~70 API requests, which
* exceeds the unauthenticated rate limit of 60 req/hr. A token is mandatory for
* the cron workflow. Set it as a repo secret named ORG_GITHUB_TOKEN.
*
* Local dev: run `ORG_GITHUB_TOKEN=ghp_xxx node scripts/fetch-issues.js`
* You can create a token at https://github.com/settings/tokens (no scopes needed
* for public repos — just generate a classic token with no checkboxes ticked).
*/
import { Octokit } from '@octokit/rest'
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const ORG = 'btcpayserver'
const LABEL = 'good first issue'
const TESTER_LABEL_RE = /user[-\s]testing/i
const TESTER_LABELS = ['User Testing', 'User-Testing']
const WRITER_REPOS = ['btcpayserver-doc', 'blog', 'btcpayserver-translator']
const WRITER_LABEL_NAME = 'recruitment'
const OUT = resolve(__dirname, '../public/data/issues.json')
const BODY_MAX = 600
async function main() {
const token = process.env.ORG_GITHUB_TOKEN
if (!token) {
console.error('ORG_GITHUB_TOKEN is required. The org has 70+ repos and will exceed')
console.error('the unauthenticated rate limit of 60 req/hr.')
console.error('Create a token at https://github.com/settings/tokens (no scopes needed for public repos)')
console.error('then run: ORG_GITHUB_TOKEN=ghp_xxx node scripts/fetch-issues.js')
process.exit(2)
}
const octokit = new Octokit({ auth: token })
// ── 1. Fetch all org repos ─────────────────────────────────────────────────
console.log(`Fetching repos for org: ${ORG}`)
const repos = await octokit.paginate(octokit.rest.repos.listForOrg, {
org: ORG,
type: 'public',
per_page: 100,
})
console.log(`Found ${repos.length} public repos`)
// ── 2. Fetch good-first-issues from each repo ──────────────────────────────
/** @type {any[]} */
const issues = []
for (const repo of repos) {
let page = 1
while (true) {
const { data } = await octokit.rest.issues.listForRepo({
owner: ORG,
repo: repo.name,
labels: repo.name === 'btcpayserver-translator' ? WRITER_LABEL_NAME : LABEL,
state: 'open',
per_page: 100,
page,
})
if (data.length === 0) break
for (const raw of data) {
// Skip pull requests (GitHub returns PRs in issue list)
if (raw.pull_request) continue
// Skip items also labeled with a user-testing variant (they belong in tester tab)
if (raw.labels.some((l) => typeof l === 'object' && TESTER_LABEL_RE.test(l.name ?? ''))) continue
issues.push({
id: raw.id,
number: raw.number,
type: 'issue',
title: raw.title,
body: (raw.body ?? '').slice(0, BODY_MAX),
url: raw.html_url,
createdAt: raw.created_at,
updatedAt: raw.updated_at ?? raw.created_at,
commentsCount: raw.comments,
reactionCount: raw.reactions?.total_count ?? 0,
labels: raw.labels
.filter((l) => typeof l === 'object')
.map((l) => ({ name: l.name ?? '', color: l.color ?? '888888' })),
repo: {
name: repo.name,
fullName: repo.full_name,
language: repo.language ?? null,
url: repo.html_url,
},
assignees: (raw.assignees ?? []).map((a) => ({
login: a.login,
avatarUrl: a.avatar_url,
url: a.html_url,
})),
author: {
login: raw.user?.login ?? 'unknown',
avatarUrl: raw.user?.avatar_url ?? '',
url: raw.user?.html_url ?? '',
},
})
}
if (data.length < 100) break
page++
}
}
// ── 3. Sort by creation date desc ─────────────────────────────────────────
issues.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
// ── 3b. Fetch tester items: "User Testing" label variants via search ────────
/** @type {any[]} */
const testerItems = []
const seenTesterIds = new Set()
for (const label of TESTER_LABELS) {
console.log(`Fetching "${label}" items via search`)
const results = await octokit.paginate(octokit.rest.search.issuesAndPullRequests, {
q: `org:${ORG} is:open label:"${label}"`,
per_page: 100,
})
for (const raw of results) {
if (seenTesterIds.has(raw.id)) continue
seenTesterIds.add(raw.id)
const isPR = !!raw.pull_request
const repoName = raw.repository_url.split('/').pop() ?? ''
const repo = repos.find((r) => r.name === repoName)
if (!repo) continue
testerItems.push({
id: raw.id,
number: raw.number,
type: isPR ? 'pr' : 'issue',
title: raw.title,
body: (raw.body ?? '').slice(0, BODY_MAX),
url: raw.html_url,
createdAt: raw.created_at,
updatedAt: raw.updated_at ?? raw.created_at,
commentsCount: raw.comments,
reactionCount: raw.reactions?.total_count ?? 0,
labels: (raw.labels ?? [])
.filter((l) => typeof l === 'object')
.map((l) => ({ name: l.name ?? '', color: l.color ?? '888888' })),
repo: {
name: repo.name,
fullName: repo.full_name,
language: repo.language ?? null,
url: repo.html_url,
},
assignees: (raw.assignees ?? []).map((a) => ({
login: a.login,
avatarUrl: a.avatar_url,
url: a.html_url,
})),
author: {
login: raw.user?.login ?? 'unknown',
avatarUrl: raw.user?.avatar_url ?? '',
url: raw.user?.html_url ?? '',
},
})
}
}
console.log(`Found ${testerItems.filter((i) => i.type === 'pr').length} PRs and ${testerItems.filter((i) => i.type === 'issue').length} issues with user-testing label`)
// Sort tester items: PRs first, then issues, newest first within each group
testerItems.sort((a, b) => {
if (a.type !== b.type) return a.type === 'pr' ? -1 : 1
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
})
// ── 3c. Writer issues: docs/blog + translator recruitment ─────────────────
const writerIssues = issues.filter((i) => WRITER_REPOS.includes(i.repo.name))
console.log(`Found ${writerIssues.length} writer issues from ${WRITER_REPOS.join(', ')}`)
// ── 4. Build repo list ─────────────────────────────────────────────────────
const reposWithIssues = repos
.filter((r) => issues.some((i) => i.repo.name === r.name))
.map((r) => ({
id: r.id,
name: r.name,
fullName: r.full_name,
description: r.description ?? null,
url: r.html_url,
language: r.language ?? null,
topics: r.topics ?? [],
stars: r.stargazers_count,
}))
const output = {
lastUpdated: new Date().toISOString(),
totalIssues: issues.length,
repoCount: reposWithIssues.length,
repos: reposWithIssues,
issues,
testerItems,
writerIssues,
}
// ── 5. Diff check — skip write if unchanged ────────────────────────────────
const json = JSON.stringify(output, null, 2)
if (existsSync(OUT)) {
const existing = readFileSync(OUT, 'utf8')
/** @param {string} s */
const strip = (s) =>
s
.replace(/"lastUpdated":\s*"[^"]+"/g, '"lastUpdated":""')
.replace(/"updatedAt":\s*"[^"]+"/g, '"updatedAt":""')
.replace(/"commentsCount":\s*\d+/g, '"commentsCount":0')
.replace(/"reactionCount":\s*\d+/g, '"reactionCount":0')
if (strip(existing) === strip(json)) {
console.log('No changes detected — skipping deploy')
process.exit(1) // signals workflow to skip build step
}
}
mkdirSync(dirname(OUT), { recursive: true })
writeFileSync(OUT, json, 'utf8')
console.log(`✓ Wrote ${issues.length} issues, ${testerItems.length} tester items, ${writerIssues.length} writer issues from ${reposWithIssues.length} repos to ${OUT}`)
}
main().catch((err) => {
console.error(err)
process.exit(2)
})