Files
hanzo.ai/scripts/sync-bounties.ts
zeekay 438b8644f3 rewards: /leaderboard and /bounty on the one points ledger
Every page that names points reads the cloud's rewards: the board
(GET /v1/rewards), the reader's standing, badges and history
(/v1/rewards/me), the listing (/v1/rewards/optin) and the rules table
(/v1/rewards/rules), snapshotted at build into
lib/data/rewards-rules.json and asked again when a page opens.  The
hard-coded RULES tiles, POINTS, the client-side score and
lib/hanzo/board.ts are gone; /beta and /referral draw the same Rules and
board and link to /leaderboard.

/leaderboard is the home: tabs by source and period, Your standing,
badges earned and locked, the rules, token milestones and levels, Link
GitHub through the existing per-person connection (the page completes
the grant GitHub returns), and Share your rank with a leaderboard card.
/bounty prints the contribution kinds and cap, three steps, the open
issues labelled bounty or good first issue in Hanzo's orgs (searched at
build with a token only), My contributions and the rules.  A star now
counts for the linked GitHub account, so Earn more offers Link GitHub.

The cards, nav, footer, llms.txt and sitemap carry both pages; /founder
no longer says points go nowhere.

Claude-Session: https://claude.ai/code/session_017vrVus6PPBgP5YRzKV1iqW
2026-10-27 01:56:45 -07:00

89 lines
4.2 KiB
TypeScript

/**
* The open bounties /bounty lists: open issues in the public repositories of
* the orgs the rewards rules count, labelled with one of the rules' bounty
* labels, read once per build.
*
* GET https://api.github.com/search/issues?q=is:issue is:open is:public archived:false label:"<label>" org:<org> …
*
* The orgs and the labels are the rules' (lib/data/rewards-rules.json, which
* sync-rewards writes first), so the list and the rules cannot name different
* places; of the orgs, Hanzo's own (lib/hanzo/orgs), since this is hanzo.ai.
*
* AUTHENTICATED OR NOT AT ALL. An anonymous search is allowed ten requests a
* minute and GitHub answers a busy one with a SHORTER list rather than an
* error, which a build would commit as the truth (an org listing here once
* carried 70 projects where the org publishes 334). So without GITHUB_TOKEN or
* GH_TOKEN, on any refusal, and on `incomplete_results`, the committed
* snapshot stays and the build goes on.
*/
import { readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { ours } from '../lib/hanzo/orgs.ts'
const here = dirname(fileURLToPath(import.meta.url))
const OUT = join(here, '..', 'lib', 'data', 'bounties.json')
const RULES = join(here, '..', 'lib', 'data', 'rewards-rules.json')
const API = 'https://api.github.com/search/issues'
type Issue = { repo: string; number: number; title: string; url: string; labels: string[]; comments: number; opened: string; updated: string }
type Found = {
total_count: number
incomplete_results: boolean
items: { html_url: string; repository_url: string; number: number; title: string; labels: { name: string }[]; comments: number; created_at: string; updated_at: string; pull_request?: unknown }[]
}
const keep = (why: string): never => {
console.log(` bounties: ${why} — keeping the committed snapshot`)
process.exit(0)
}
const auth = process.env.GITHUB_TOKEN || process.env.GH_TOKEN
if (!auth) keep('no GITHUB_TOKEN or GH_TOKEN, and an anonymous search can answer short')
const rules = JSON.parse(readFileSync(RULES, 'utf8')) as { github: { orgs: string[]; labels: { bounty: string[] } } }
const orgs = rules.github.orgs.filter(ours)
const labels = rules.github.labels.bounty
if (!orgs.length || !labels.length) keep('the rules name no org or no bounty label')
const head = { accept: 'application/vnd.github+json', authorization: `Bearer ${auth}`, 'x-github-api-version': '2022-11-28' }
/** Every open issue carrying one label, all pages of it (search answers 1,000 at most). */
async function search(label: string): Promise<Found['items']> {
const q = ['is:issue', 'is:open', 'is:public', 'archived:false', `label:"${label}"`, ...orgs.map((o) => `org:${o}`)].join(' ')
const out: Found['items'] = []
for (let page = 1; page <= 10; page++) {
const res = await fetch(`${API}?q=${encodeURIComponent(q)}&sort=updated&order=desc&per_page=100&page=${page}`, { headers: head, signal: AbortSignal.timeout(20000) }).catch((err: Error) => keep(err.message))
if (!res.ok) keep(`search for "${label}" answered ${res.status}`)
const body = (await res.json()) as Found
if (body.incomplete_results) keep(`search for "${label}" answered incomplete results`)
out.push(...body.items)
if (out.length >= body.total_count || body.items.length < 100) break
}
return out
}
const found = (await Promise.all(labels.map(search))).flat()
const seen = new Map<string, Issue>()
for (const i of found) {
if (i.pull_request || seen.has(i.html_url)) continue
seen.set(i.html_url, {
repo: i.repository_url.replace('https://api.github.com/repos/', ''),
number: i.number,
title: i.title,
url: i.html_url,
labels: i.labels.map((l) => l.name),
comments: i.comments,
opened: i.created_at,
updated: i.updated_at,
})
}
const issues = [...seen.values()].sort((a, b) => b.updated.localeCompare(a.updated) || a.url.localeCompare(b.url))
const next = JSON.stringify({ orgs, labels, issues }, null, 2) + '\n'
if (next === readFileSync(OUT, 'utf8')) console.log(` bounties: ${issues.length} open, unchanged`)
else {
writeFileSync(OUT, next)
console.log(` bounties: ${issues.length} open issues across ${new Set(issues.map((i) => i.repo)).size} repositories`)
}