import { execFile } from 'node:child_process'; // Read-only GitHub REST client. Anonymous access works for public repos but // is rate-limited to 60 requests/hour; a token (GITHUB_TOKEN or GH_TOKEN env, // or the gh CLI's stored token) raises that to 5000. const API = 'https://api.github.com'; // GitHub reaction names → the exact emoji set of org.tangled.feed.reaction. export const REACTION_EMOJI = { '+1': '👍', '-1': '👎', laugh: '😆', hooray: '🎉', confused: '🫤', heart: '❤️', rocket: '🚀', eyes: '👀', }; // Accepts "owner/repo", github.com URLs (with or without extra path bits), // and git@github.com:owner/repo.git remotes. export function parseGitHubRepo(input) { const m = input.match( /^(?:https?:\/\/(?:www\.)?github\.com\/|git@github\.com:)([^/\s]+)\/([^/\s#?]+?)(?:\.git)?(?:[/#?].*)?$/, ) || input.match(/^([A-Za-z0-9][A-Za-z0-9-]*)\/([A-Za-z0-9._-]+?)(?:\.git)?$/); if (!m) { throw new Error( `Cannot parse "${input}" as a GitHub repo. Use owner/name or a github.com URL.`, ); } return { owner: m[1], repo: m[2] }; } export async function githubToken() { if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN; if (process.env.GH_TOKEN) return process.env.GH_TOKEN; // Borrow the gh CLI's token when it's installed and logged in. return new Promise((resolve) => { execFile('gh', ['auth', 'token'], (err, stdout) => resolve(err ? null : stdout.trim() || null), ); }); } async function ghFetch(path, { token, accept = 'application/vnd.github+json', text = false } = {}) { const headers = { accept, 'user-agent': 'tangy-cli', 'x-github-api-version': '2022-11-28', }; if (token) headers.authorization = `Bearer ${token}`; const res = await fetch(API + path, { headers }); if (!res.ok) { if ((res.status === 403 || res.status === 429) && res.headers.get('x-ratelimit-remaining') === '0') { throw new Error( 'GitHub API rate limit exceeded. Set GITHUB_TOKEN (or log in with `gh auth login`) and retry.', ); } if (res.status === 404) { throw new Error(`GitHub returned 404 for ${path} — is the repo public and the name right?`); } const body = await res.text().catch(() => ''); throw new Error(`GitHub API error ${res.status} on ${path}: ${body.slice(0, 200)}`); } return text ? res.text() : res.json(); } async function ghPaged(path, opts) { const sep = path.includes('?') ? '&' : '?'; const all = []; for (let page = 1; ; page++) { const batch = await ghFetch(`${path}${sep}per_page=100&page=${page}`, opts); all.push(...batch); if (batch.length < 100) return all; } } // Open issues, oldest first, with pull requests filtered out (GitHub's issues // endpoint returns both). export async function fetchOpenIssues(owner, repo, token) { const items = await ghPaged( `/repos/${owner}/${repo}/issues?state=open&sort=created&direction=asc`, { token }, ); return items.filter((i) => !i.pull_request); } export function fetchOpenPulls(owner, repo, token) { return ghPaged(`/repos/${owner}/${repo}/pulls?state=open&sort=created&direction=asc`, { token, }); } // Conversation comments; works for both issues and pull requests. export function fetchComments(owner, repo, number, token) { return ghPaged(`/repos/${owner}/${repo}/issues/${number}/comments`, { token }); } // Individual reactions ({ content, user, created_at }); works for both kinds. export function fetchIssueReactions(owner, repo, number, token) { return ghPaged(`/repos/${owner}/${repo}/issues/${number}/reactions`, { token }); } export function fetchCommentReactions(owner, repo, commentId, token) { return ghPaged(`/repos/${owner}/${repo}/issues/comments/${commentId}/reactions`, { token }); }