Something went wrong. Try again.
A basic CLI to set up Tangled repos
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109import { 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 });}