mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
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
89 lines
4.2 KiB
TypeScript
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`)
|
|
}
|