import { readConfig, resolveUser, userLabel } from '../config.js'; import { agentForDid } from '../oauth.js'; import { getRepoRecord, getRecordByUri, deleteRecordByUri, createIssue, createComment, createPull, createReaction, createLabelDefinition, createLabelOp, addRepoLabels, ASSIGNEE_LABEL_URI, } from '../tangled.js'; import { parseGitHubRepo, githubToken, REACTION_EMOJI, fetchOpenIssues, fetchOpenPulls, fetchComments, fetchIssueReactions, fetchCommentReactions, fetchPatch, } from '../github.js'; import { createMapper } from '../mapping.js'; import { readCopies, writeCopies } from '../copies.js'; import { ask } from '../term.js'; // tangy copy [repo] — copy a GitHub repo's open issues and PRs (with // comments, labels, assignees and reactions) into a Tangled repo owned by the // acting user. Closed items are left behind. Everything is created by the // acting user; other people's items get a provenance line pointing back at // GitHub, using the saved GitHub→AT account mapping where one exists. export async function copyCommand(source, nameArg, opts = {}) { const { owner, repo: ghRepo } = parseGitHubRepo(source); const gh = `${owner}/${ghRepo}`; const name = nameArg || ghRepo; const dryRun = Boolean(opts.dryRun); const config = await readConfig(); const user = resolveUser(config, opts.user); const agent = await agentForDid(user.did); const repoRecord = await getRepoRecord(agent, user.did, name); if (!repoRecord) { throw new Error( `${userLabel(user)} has no Tangled repo named "${name}". ` + `Create it first (\`tangy repo create ${name}\`) or adopt one (\`tangy move\`).`, ); } let repoValue = repoRecord.value; const repoDid = repoValue.repoDid; if (!repoDid) { throw new Error( `The Tangled repo "${name}" has no repo DID, so issues cannot be attached to it.`, ); } const token = await githubToken(); const interactive = process.stdin.isTTY && process.stdout.isTTY && !dryRun; console.log( `Copying open issues and PRs from github.com/${gh} to "${name}" as ${userLabel(user)}` + (dryRun ? ' (dry run — nothing will be written)' : '') + '…', ); if (!token) { console.log( '(No GitHub token found — using anonymous API access. Set GITHUB_TOKEN if you hit rate limits.)', ); } const mapper = createMapper({ agent, config, interactive }); // Knowing the acting user's own GitHub login lets tangy create the things // they authored verbatim and recreate their reactions natively. let actorLogin = mapper.loginFor(user.did); if (!actorLogin && interactive) { const answer = await ask( `Your own GitHub username, so your posts are copied as you (Enter to skip): `, ); if (answer) { actorLogin = answer.replace(/^@/, '').toLowerCase(); await mapper.recordLogin(actorLogin, { handle: user.handle, did: user.did }); } } const issues = opts.issues === false ? [] : await fetchOpenIssues(owner, ghRepo, token); const pulls = opts.pulls === false ? [] : await fetchOpenPulls(owner, ghRepo, token); const copies = await readCopies(); const copyKey = `github.com/${gh} -> ${repoRecord.uri}`; const state = (copies[copyKey] ??= { labels: {}, issues: {}, pulls: {}, comments: {}, reactions: {}, }); const persist = () => (dryRun ? undefined : writeCopies(copies)); const stats = { issues: 0, pulls: 0, comments: 0, reactions: 0, labels: 0, skipped: 0 }; const unmapped = new Set(); // dry run: accounts that would prompt for a mapping // ---- body composition -------------------------------------------------- const fmtReactions = (counts) => Object.entries(REACTION_EMOJI) .filter(([kind]) => counts?.[kind] > 0) .map(([kind, emoji]) => `${emoji} ${counts[kind]}`) .join(' · '); const countReactions = (list) => { const counts = {}; for (const r of list) counts[r.content] = (counts[r.content] ?? 0) + 1; return counts; }; // Square brackets (as in "dependabot[bot]") would end the link text early. const escapeLinkText = (s) => s.replace(/[[\]]/g, '\\$&'); // "[@login](github)" plus " = [@handle](tangled)" when a mapping exists. async function mappedRef(ghUser) { if (!ghUser?.login) return { ref: '[@ghost](https://github.com/ghost)', mapping: null }; const mapping = await mapper.resolve(ghUser); const url = ghUser.html_url ?? `https://github.com/${ghUser.login}`; let ref = `[@${escapeLinkText(ghUser.login)}](${url})`; if (mapping?.handle) ref += ` = [@${mapping.handle}](${user.appview}/@${mapping.handle})`; return { ref, mapping }; } // Mapped assignees become native assignee labels (by DID); the rest can // only be mentioned textually in the body. async function splitAssignees(assignees = []) { const dids = []; const textual = []; for (const a of assignees) { const { ref, mapping } = await mappedRef(a); if (mapping?.did) dids.push(mapping.did); else textual.push(ref); } return { dids, textual }; } // Prefix the body with provenance (unless the acting user authored it) and // with whatever GitHub metadata has no native Tangled home. async function composeBody(item, { assigneeRefs = [], reactionCounts } = {}) { const lines = []; const { ref, mapping } = await mappedRef(item.user); if (mapping?.did !== user.did) { lines.push(`[Original](${item.html_url}) by ${ref}.`); } if (assigneeRefs.length) lines.push(`Assignees: ${assigneeRefs.join(', ')}`); const reactions = fmtReactions(reactionCounts); if (reactions) lines.push(`Reactions: ${reactions}`); const body = item.body?.trim() ? item.body.replace(/\r\n/g, '\n') : ''; if (!lines.length) return body; return body ? `${lines.join('\n')}\n\n${body}` : lines.join('\n'); } // ---- labels ------------------------------------------------------------ // Make sure a definition exists for each GitHub label; returns their at-uris. async function ensureLabels(ghLabels = [], createdAt) { const uris = []; for (const l of ghLabels) { if (!l?.name) continue; if (!state.labels[l.name]) { state.labels[l.name] = await createLabelDefinition(agent, user.did, { name: l.name, color: l.color ? `#${l.color}` : undefined, createdAt, }); stats.labels++; } uris.push(state.labels[l.name]); } return uris; } // Subscribe the repo record to any definitions it doesn't yet carry. async function ensureSubscribed(uris) { const missing = uris.filter((u) => !(repoValue.labels ?? []).includes(u)); if (missing.length) { repoValue = await addRepoLabels(agent, user.did, name, repoValue, missing); } } // Labels and assignees land in a single label op per item. async function applyLabels(subject, ghLabels, assigneeDids, when) { const labelUris = await ensureLabels(ghLabels, when); await ensureSubscribed([ ...labelUris, ...(assigneeDids.length ? [ASSIGNEE_LABEL_URI] : []), ]); const add = [ ...labelUris.map((key) => ({ key, value: 'null' })), ...assigneeDids.map((value) => ({ key: ASSIGNEE_LABEL_URI, value })), ]; if (add.length) { await createLabelOp(agent, user.did, { subject, add, performedAt: when }); } } // ---- reactions --------------------------------------------------------- // Natively recreate the reactions the acting user made on GitHub. async function copyActorReactions(list, subject, keyPrefix) { if (!actorLogin) return; for (const r of list) { if (r.user?.login?.toLowerCase() !== actorLogin) continue; const emoji = REACTION_EMOJI[r.content]; if (!emoji) continue; const key = `${keyPrefix}:${r.content}`; if (state.reactions[key]) continue; state.reactions[key] = await createReaction(agent, user.did, { subject, reaction: emoji, createdAt: r.created_at, }); stats.reactions++; } } // ---- comments ---------------------------------------------------------- // Issue/pull entries recorded by older tangy versions are bare at-uri // strings; comments need a full strongRef ({ uri, cid }), so upgrade lazily. async function subjectRef(map, key) { if (typeof map[key] === 'string') { const { cid } = await getRecordByUri(agent, map[key]); map[key] = { uri: map[key], cid }; } return map[key]; } // Copies comments that haven't been copied yet, which also picks up new // comments on items copied in an earlier run. `subject` is a strongRef to // the issue or pull. async function copyComments(ghItem, subject, kind) { const comments = await fetchComments(owner, ghRepo, ghItem.number, token); for (const c of comments) { if (state.comments[c.id]) continue; const list = c.reactions?.total_count > 0 && actorLogin ? await fetchCommentReactions(owner, ghRepo, c.id, token) : []; const body = await composeBody(c, { reactionCounts: c.reactions }); state.comments[c.id] = await createComment(agent, user.did, { subject, body, createdAt: c.created_at, pullRoundIdx: kind === 'pull' ? 0 : undefined, }); stats.comments++; await copyActorReactions(list, state.comments[c.id], `comment:${c.id}`); await persist(); } } // Comments copied by earlier tangy versions used the deprecated // sh.tangled.repo.{issue,pull}.comment collections, which appviews no // longer index — they exist on the PDS but never render. Delete those // records (and any reactions attached to them) and forget them, so this run // recreates them as sh.tangled.feed.comment. const LEGACY_COMMENT_RE = /\/sh\.tangled\.repo\.(issue|pull)\.comment\//; async function migrateLegacyComments() { const legacy = Object.entries(state.comments).filter(([, uri]) => LEGACY_COMMENT_RE.test(uri), ); if (!legacy.length) return; console.log( `Recreating ${legacy.length} comment(s) that were copied in a format Tangled no longer displays…`, ); for (const [id, uri] of legacy) { await deleteRecordByUri(agent, uri); delete state.comments[id]; for (const [key, reactionUri] of Object.entries(state.reactions)) { if (key.startsWith(`comment:${id}:`)) { await deleteRecordByUri(agent, reactionUri); delete state.reactions[key]; } } } await persist(); } function noteUnmapped(ghUser) { const login = ghUser?.login; if (login && !login.endsWith('[bot]') && !mapper.isKnown(login)) unmapped.add(login); } // A comment only counts as copied if it isn't in the deprecated format. const commentCopied = (c) => state.comments[c.id] && !LEGACY_COMMENT_RE.test(state.comments[c.id]); if (!dryRun) await migrateLegacyComments(); // ---- issues ------------------------------------------------------------ for (const issue of issues) { const num = issue.number; if (dryRun) { const comments = await fetchComments(owner, ghRepo, num, token); const fresh = comments.filter((c) => !commentCopied(c)).length; comments.forEach((c) => noteUnmapped(c.user)); if (!state.issues[num]) { stats.issues++; stats.comments += fresh; noteUnmapped(issue.user); issue.assignees?.forEach(noteUnmapped); console.log(` would copy issue #${num} "${issue.title}"${fresh ? ` (+${fresh} comments)` : ''}`); } else if (fresh) { stats.comments += fresh; console.log(` would add ${fresh} new comment(s) to already-copied issue #${num}`); } else { stats.skipped++; } continue; } if (!state.issues[num]) { const list = issue.reactions?.total_count > 0 && actorLogin ? await fetchIssueReactions(owner, ghRepo, num, token) : []; const assignees = await splitAssignees(issue.assignees); const body = await composeBody(issue, { assigneeRefs: assignees.textual, reactionCounts: issue.reactions, }); const ref = await createIssue(agent, user.did, { repoDid, title: issue.title, body, createdAt: issue.created_at, }); state.issues[num] = ref; stats.issues++; await applyLabels(ref.uri, issue.labels, assignees.dids, issue.created_at); await copyActorReactions(list, ref.uri, `issue:${num}`); await persist(); console.log(` ✓ issue #${num} "${issue.title}"`); } else { stats.skipped++; } await copyComments(issue, await subjectRef(state.issues, num), 'issue'); } // ---- pull requests ----------------------------------------------------- for (const pull of pulls) { const num = pull.number; if (dryRun) { const comments = await fetchComments(owner, ghRepo, num, token); const fresh = comments.filter((c) => !commentCopied(c)).length; comments.forEach((c) => noteUnmapped(c.user)); if (!state.pulls[num]) { stats.pulls++; stats.comments += fresh; noteUnmapped(pull.user); pull.assignees?.forEach(noteUnmapped); console.log(` would copy PR #${num} "${pull.title}"${fresh ? ` (+${fresh} comments)` : ''}`); } else if (fresh) { stats.comments += fresh; console.log(` would add ${fresh} new comment(s) to already-copied PR #${num}`); } else { stats.skipped++; } continue; } if (!state.pulls[num]) { let patch; try { patch = await fetchPatch(owner, ghRepo, num, token); } catch (err) { console.warn(` ! PR #${num} "${pull.title}": could not fetch its patch (${err.message}) — skipped.`); continue; } const list = await fetchIssueReactions(owner, ghRepo, num, token); const assignees = await splitAssignees(pull.assignees); const body = await composeBody(pull, { assigneeRefs: assignees.textual, reactionCounts: countReactions(list), }); const ref = await createPull(agent, user.did, { repoDid, targetBranch: pull.base?.ref ?? 'main', sourceBranch: pull.head?.ref, title: pull.title, body, patch, createdAt: pull.created_at, }); state.pulls[num] = ref; stats.pulls++; await applyLabels(ref.uri, pull.labels, assignees.dids, pull.created_at); await copyActorReactions(list, ref.uri, `pull:${num}`); await persist(); console.log(` ✓ PR #${num} "${pull.title}"`); } else { stats.skipped++; } await copyComments(pull, await subjectRef(state.pulls, num), 'pull'); } // ---- summary ----------------------------------------------------------- const summary = `${stats.issues} issue(s), ${stats.pulls} PR(s), ${stats.comments} comment(s)` + (stats.labels ? `, ${stats.labels} label(s)` : '') + (stats.reactions ? `, ${stats.reactions} reaction(s)` : ''); if (dryRun) { console.log(`\nDry run: would copy ${summary} to "${name}" as ${userLabel(user)}.`); if (unmapped.size) { console.log( `A real run would ask for AT handles for: ${[...unmapped].join(', ')}.`, ); } } else { console.log(`\n✓ Copied ${summary} to "${name}" as ${userLabel(user)}.`); } if (stats.skipped) { console.log( ` Skipped ${stats.skipped} already-copied item(s) — tangy remembers copies in copies.json next to its config.`, ); } }