Something went wrong. Try again.
tgl: manage Tangled (tangled.org) from the terminal www.npmjs.com/package/tangled-cli
atproto git cli pull-request
Something went wrong. Try again.
34 kB · 575 lines
JavaScript
at master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576import { gzipSync } from 'node:zlib';import { whoAmI } from '../credentials.js';import { TglError } from '../errors.js';import { branchName, currentBranch, formatPatch, git, isWorkingTreeClean, localRefFor, refExists, remoteDefaultBranch, unpushedCommits,} from '../git.js';import { t, textLanguage } from '../i18n.js';import { formatLabels } from '../labels.js';import { expandNumber, findById, isIdOf, loadItems, notFound, repoPeople } from '../repoData.js';import { buildCommentRecord, buildIssueStateRecord, buildPullRecord, buildStatusRecord, closingRefs, isFork, knotOf, linkClosingRefs, NSID, numberForUri, pullPatch, repoGitUrl, repoWebUrl, resolveRepo,} from '../tangled.js';import { openSession } from './auth.js';import { bodyOptions, checkState, day, editTitleBody, itemLabels, jsonOption, labelCommand, limitOption, loadComments, openInBrowser, parseLimit, parseWebRef, printComments, printJson, readBody, repoOption, webOption,} from './shared.js';
const NO_REF_EN = 'Without a PR, the open PR of the current branch.';const NO_REF_ES = 'Sin indicar PR, la PR abierta de la rama actual.';
const STATE_WORDS = { open: () => t('open', 'abierta'), closed: () => t('closed', 'cerrada'), merged: () => t('merged', 'fusionada'),};
// Finds a PR by its #number, id, full at:// address, or the branch of an open PR in the// same repo. With no ref, the current branch's open PR.async function findPull(opts, ref) { if (!ref) { try { ref = currentBranch(); } catch { throw new TglError(t('Say which PR: its #number, its id (tgl pr list) or its branch name.', 'Indica la PR: su #número, su id (tgl pr list) o el nombre de su rama.')); } } const { repoDid } = await resolveRepo(opts.repo); ref = await expandNumber('pull', repoDid, ref); const isBranch = (value) => value.source?.branch === ref && !isFork(value, repoDid); const found = await loadItems('pull', whoAmI(), repoDid, { match: (uri, value) => isIdOf(ref, uri) || isBranch(value) }); const byId = found.find((p) => isIdOf(ref, p.uri)); if (byId) return { pull: byId, repoDid }; const open = found.filter((p) => p.state === 'open'); if (open.length > 1) { throw new TglError(t(`Several open PRs come from branch "${ref}"; use the id (tgl pr list).`, `Hay varias PRs abiertas de la rama "${ref}"; usa su id (tgl pr list).`)); } if (!open.length) throw notFound('pull', ref); return { pull: open[0], repoDid };}
function changedFiles(patch) { return [...new Set([...patch.matchAll(/^diff --git a\/(.+?) b\//gm)].map((m) => m[1]))];}
async function setPullState(opts, ref, state) { const { pull, repoDid } = await findPull(opts, ref); if (pull.state === state) { console.log(t(`PR "${pull.title}" was already ${STATE_WORDS[state]()}.`, `La PR "${pull.title}" ya estaba ${STATE_WORDS[state]()}.`)); return; } await writePullState(opts, await openSession(), pull, repoDid, state);}
// Writes the PR's new state and, when merged, closes the issues it says it fixes.async function writePullState(opts, session, pull, repoDid, state) { const editors = new Set((await repoPeople(whoAmI(), repoDid)).editors); const refs = state === 'merged' && !opts['keep-issues'] ? await closingRefsOf(pull, editors) : []; // Read while the PR is still listed under its old state, for the link in the comments. const number = refs.length ? await numberForUri(repoDid, 'pull', { uri: pull.uri, title: pull.title, state: pull.state }) : null; await session.createRecord(NSID.pullStatus, buildStatusRecord({ pullUri: pull.uri, state })); console.log(t(`PR "${pull.title}" marked as ${STATE_WORDS[state]()}.`, `PR "${pull.title}" marcada como ${STATE_WORDS[state]()}.`)); if (refs.length) await closeLinkedIssues(session, pull, repoDid, { refs, editors, number });}
// Issues a PR says it fixes: in its title, its description, or a comment on it by its// author or someone who can change the repo's states (owner, collaborators). Other// people's comments don't count, or anyone could close issues through someone's PR.async function closingRefsOf(pull, editors) { const comments = await loadComments(whoAmI(), pull.uri, { collection: NSID.legacyPullComment, path: '.pull' }); const trusted = comments.filter((c) => c.author === pull.author || editors.has(c.author)); return closingRefs([pull.title, pull.body ?? '', ...trusted.map((c) => c.body)].join('\n'));}
// Closes the issues a merged PR says it fixes ("Fixes #12"), with a comment linking to// the PR. Tangled doesn't do this itself yet. Issues of other repos are left alone.async function closeLinkedIssues(session, pull, repoDid, { refs, editors, number }) { const prLink = number ? `[#${number}](${repoWebUrl(repoDid)}/pulls/${number}) ` : ''; const done = new Set(); for (const ref of refs) { const label = ref.uri ?? `#${ref.number}`; try { if (ref.repo && (await resolveRepo(ref.repo)).repoDid !== repoDid) continue; const issue = await findById('issue', whoAmI(), repoDid, ref.uri ?? `#${ref.number}`); if (done.has(issue.uri)) continue; done.add(issue.uri); if (issue.state === 'closed') { console.log(t(`Issue ${label} "${issue.title}" was already closed.`, `La issue ${label} "${issue.title}" ya estaba cerrada.`)); continue; } // Tangled ignores state changes from anyone but the author, the owner and collaborators. if (issue.author !== session.did && !editors.has(session.did)) { console.log(t(`Issue ${label} not closed: only its author or the repo's owner can close it.`, `La issue ${label} no se ha cerrado: solo puede cerrarla quien la creó o el dueño del repo.`)); continue; } // A public comment: in the issue's language, not the user's. const body = textLanguage(`${issue.title}\n${issue.body ?? ''}`) === 'es' ? `Cerrada por la PR fusionada ${prLink}"${pull.title}".` : `Closed by the merged PR ${prLink}"${pull.title}".`; await session.createRecord(NSID.comment, buildCommentRecord({ subject: issue, body })); await session.createRecord(NSID.issueState, buildIssueStateRecord({ issueUri: issue.uri, state: 'closed' })); console.log(t(`Issue ${label} "${issue.title}" closed.`, `Issue ${label} "${issue.title}" cerrada.`)); } catch (err) { console.log(t(`Issue ${label} not closed: ${err.message}`, `La issue ${label} no se ha cerrado: ${err.message}`)); } }}
// Stops before creating or updating a PR from a branch with commits that aren't on// Tangled yet: the PR would show changes that the repo's branch doesn't have.function checkPushed(repoDid, branch, localRef, { force, dryRun }) { const missing = unpushedCommits(repoGitUrl(repoDid), branch, localRef); if (missing === 0 || force) return; const msg = missing === null ? t(`Branch "${branch}" is not on Tangled yet. Push it first (git push -u <remote> ${branch}).`, `La rama "${branch}" aún no está en Tangled. Súbela antes (git push -u <remoto> ${branch}).`) : t(`Branch "${branch}" has ${missing} commit${missing === 1 ? '' : 's'} not pushed to Tangled. Push first (git push).`, `La rama "${branch}" tiene ${missing} commit${missing === 1 ? '' : 's'} sin subir a Tangled. Súbelos antes (git push).`); const hint = t('Use --allow-unpushed to go ahead anyway.', 'Usa --allow-unpushed para seguir de todos modos.'); if (dryRun) { console.log(t(`Warning: ${msg}`, `Aviso: ${msg}`)); return; } throw new TglError(`${msg}\n${hint}`);}
export default { name: 'pr', summary: t('Create, view, check out, edit, update, label, comment on, merge and close pull requests', 'Crear, ver, probar, editar, actualizar, etiquetar, comentar, fusionar y cerrar pull requests'), commands: { create: { summary: t("Open a PR from a branch into the repo's default branch", 'Abrir una PR de una rama hacia la rama principal del repo'), usage: t([ 'Usage: tgl pr create --title <title> [--body <text> | --body-file <file>]', ' [--head <branch>] [--base <branch>] [-R owner/name] [--dry-run]', '', ' -t, --title PR title (required)', ' -b, --body Description', ' -F, --body-file Read the description from a file', ' -H, --head Branch with the changes (default: the current branch)', " -B, --base Target branch (default: the repo's default branch on Tangled)", " -R, --repo Tangled repo (default: this git repo's Tangled remote)", ' --dry-run Show what would be created without creating anything', ' --allow-unpushed Go ahead even if the branch has commits not pushed to Tangled', '', 'The changes are computed in your local copy with "git format-patch base..head".', 'Push the branch first: a Tangled PR keeps the changes it was created with.', ], [ 'Uso: tgl pr create --title <título> [--body <texto> | --body-file <archivo>]', ' [--head <rama>] [--base <rama>] [-R cuenta/nombre] [--dry-run]', '', ' -t, --title Título de la PR (obligatorio)', ' -b, --body Descripción', ' -F, --body-file Leer la descripción de un archivo', ' -H, --head Rama con los cambios (por defecto, la rama actual)', ' -B, --base Rama destino (por defecto, la rama principal del repo en Tangled)', ' -R, --repo Repo de Tangled (por defecto, el remoto de Tangled de este git)', ' --dry-run Enseñar lo que se crearía sin crear nada', ' --allow-unpushed Seguir aunque la rama tenga commits sin subir a Tangled', '', 'Los cambios se calculan en tu copia local con "git format-patch base..head".', 'Sube la rama antes: una PR de Tangled se queda con los cambios con los que se creó.', ]).join('\n'), options: { ...repoOption, title: { type: 'string', short: 't' }, ...bodyOptions, head: { type: 'string', short: 'H' }, base: { type: 'string', short: 'B' }, 'dry-run': { type: 'boolean', default: false }, 'allow-unpushed': { type: 'boolean', default: false }, }, async run(opts) { if (!opts.title?.trim()) throw new TglError(t('Missing title: --title "..."', 'Falta el título: --title "..."')); const body = readBody(opts); const { repoDid, label } = await resolveRepo(opts.repo); const head = opts.head ?? currentBranch(); // The patch uses local refs; the record stores plain branch names. const base = opts.base ? branchName(opts.base) : remoteDefaultBranch(repoGitUrl(repoDid)) ?? 'master'; const baseRef = opts.base ?? localRefFor(base); const headBranch = branchName(head); if (headBranch === base) { throw new TglError(t(`The PR branch and the target branch are the same ("${base}").`, `La rama de cambios y la de destino son la misma ("${base}").`)); } const { patch, commits } = formatPatch(baseRef, head); const gz = gzipSync(Buffer.from(patch, 'utf8'));
console.log(t(`Repository: ${label}`, `Repositorio: ${label}`)); checkPushed(repoDid, headBranch, head, { force: opts['allow-unpushed'], dryRun: opts['dry-run'] }); console.log(t( `PR: ${headBranch} -> ${base} (${commits} commit${commits === 1 ? '' : 's'}, ${gz.length}-byte compressed patch)`, `PR: ${headBranch} -> ${base} (${commits} commit${commits === 1 ? '' : 's'}, parche de ${gz.length} bytes comprimido)`, ));
const fields = { repoDid, title: opts.title, body: linkClosingRefs(body, repoDid), base, head: headBranch }; if (opts['dry-run']) { console.log(t('\nThis record would be created (nothing was created):', '\nSe crearía este registro (no se ha creado nada):')); printJson(buildPullRecord({ ...fields, patchBlob: t('<compressed patch>', '<parche comprimido>') })); return; }
const session = await openSession(); const patchBlob = await session.uploadBlob(gz, 'application/gzip'); const { uri } = await session.createRecord(NSID.pull, buildPullRecord({ ...fields, patchBlob })); console.log(t(`\nPR created. Id: ${uri.split('/').pop()}`, `\nPR creada. Id: ${uri.split('/').pop()}`)); console.log(t(`Address: ${uri}`, `Dirección: ${uri}`)); console.log(t( `See it at: ${repoWebUrl(repoDid)}/pulls (Tangled may take a few seconds to show it)`, `Mírala en: ${repoWebUrl(repoDid)}/pulls (Tangled puede tardar unos segundos en mostrarla)`, )); }, }, list: { summary: t("List the repo's PRs, newest first", 'Listar las PRs del repo, de más nueva a más vieja'), usage: t( 'Usage: tgl pr list [--state open|closed|merged|all] [--limit N] [--web] [--json] [-R owner/name]\n\n -L, --limit How many to show (default 30; 0 = all)\n -w, --web Open the list in the browser', 'Uso: tgl pr list [--state open|closed|merged|all] [--limit N] [--web] [--json] [-R cuenta/nombre]\n\n -L, --limit Cuántas mostrar (por defecto 30; 0 = todas)\n -w, --web Abrir la lista en el navegador', ), options: { ...repoOption, ...limitOption, ...jsonOption, ...webOption, state: { type: 'string', short: 's', default: 'open' } }, async run(opts) { const state = checkState(opts.state, ['open', 'closed', 'merged', 'all']); if (opts.web) { openInBrowser(`${repoWebUrl((await resolveRepo(opts.repo)).repoDid)}/pulls${state === 'open' ? '' : `?state=${state}`}`); return; } const limit = parseLimit(opts.limit); const { repoDid, label } = await resolveRepo(opts.repo); const pulls = await loadItems('pull', whoAmI(), repoDid, { state, limit }); if (opts.json) { printJson(pulls.map(({ rounds, patch, ...p }) => ({ id: p.rkey, ...p }))); return; } if (!pulls.length) { console.log(t(`No ${state} PRs in ${label}.`, `No hay PRs (${state}) en ${label}.`)); return; } for (const p of pulls) { const from = isFork(p, repoDid) ? `(fork) ${p.source.branch}` : p.source?.branch ?? t('(patch)', '(parche)'); console.log(`${p.rkey} ${p.state.padEnd(6)} ${day(p.createdAt).padEnd(10)} ${`${from} -> ${p.target.branch}`.padEnd(28)} ${p.title} [${p.authorHandle}]`); } if (limit && pulls.length === limit) { console.log(t(`\nShowing the ${limit} newest. More with --limit 0 (all) or --limit N.`, `\nSe muestran las ${limit} más recientes. Más con --limit 0 (todas) o --limit N.`)); } }, }, view: { summary: t('Show a PR: description, changed files and comments', 'Ver una PR: descripción, archivos cambiados y comentarios'), usage: t( `Usage: tgl pr view [<#number | id | branch>] [--patch] [--web] [--json] [-R owner/name]\n\n --patch Show the full changes\n -w, --web Open it in the browser\n\n${NO_REF_EN}`, `Uso: tgl pr view [<#número | id | rama>] [--patch] [--web] [--json] [-R cuenta/nombre]\n\n --patch Mostrar los cambios completos\n -w, --web Abrirla en el navegador\n\n${NO_REF_ES}`, ), options: { ...repoOption, ...jsonOption, ...webOption, patch: { type: 'boolean', default: false } }, async run(opts, [ref]) { if (opts.web) { openInBrowser(await parseWebRef(opts, 'pull', ref)); return; } const { pull, repoDid } = await findPull(opts, ref); const patch = await pullPatch(pull, repoDid); const files = changedFiles(patch); const [comments, labels] = await Promise.all([ loadComments(whoAmI(), pull.uri, { collection: NSID.legacyPullComment, path: '.pull' }), itemLabels(repoDid, pull), ]); const revisions = Math.max(pull.rounds?.length ?? 0, 1); if (opts.json) { const { rounds, patch: _inline, ...rest } = pull; printJson({ id: pull.rkey, ...rest, revisions, labels: labels.map(({ raw, ...l }) => l), files, comments, ...(opts.patch ? { patch } : {}) }); return; } const from = isFork(pull, repoDid) ? `(fork) ${pull.source.branch}` : pull.source?.branch ?? t('(patch)', '(parche)'); console.log(pull.title); console.log(`${pull.state} · ${pull.authorHandle} · ${day(pull.createdAt)} · ${from} -> ${pull.target.branch} · ${t('revision', 'revisión')} ${revisions}`); if (labels.length) console.log(`${t('Labels', 'Etiquetas')}: ${formatLabels(labels)}`); if (pull.body) console.log(`\n${pull.body}`); console.log(t(`\nChanged files (${files.length}):`, `\nArchivos cambiados (${files.length}):`)); for (const f of files) console.log(` ${f}`); printComments(comments); if (opts.patch) console.log(`\n${patch}`); }, }, checkout: { summary: t("Bring a PR's changes into a local branch to try them", 'Traer los cambios de una PR a una rama local para probarlos'), usage: t([ 'Usage: tgl pr checkout <#number | id | branch> [--branch <name>] [-R owner/name]', '', "Creates a branch from the PR's target branch and applies the PR's commits to it.", ' --branch Name of the new local branch (default: the PR branch, or pr-<id>)', '', 'Your working copy must have no uncommitted changes. Run "git fetch" first so the', 'target branch is up to date.', ], [ 'Uso: tgl pr checkout <#número | id | rama> [--branch <nombre>] [-R cuenta/nombre]', '', 'Crea una rama a partir de la rama destino de la PR y le aplica los commits de la PR.', ' --branch Nombre de la rama local nueva (por defecto, la de la PR, o pr-<id>)', '', 'Tu copia no puede tener cambios sin guardar en un commit. Ejecuta antes "git fetch"', 'para que la rama destino esté al día.', ]).join('\n'), options: { ...repoOption, branch: { type: 'string' } }, async run(opts, [ref]) { if (!isWorkingTreeClean()) { throw new TglError(t('You have uncommitted changes. Commit or stash them first.', 'Tienes cambios sin guardar. Haz commit o guárdalos aparte (git stash) antes.')); } const { pull, repoDid } = await findPull(opts, requireRef(ref)); const patch = await pullPatch(pull, repoDid); if (!patch.trim()) throw new TglError(t('This PR has no changes to bring.', 'Esta PR no tiene cambios que traer.')); const baseRef = localRefFor(pull.target.branch); const wanted = opts.branch ?? pull.source?.branch; const branch = wanted && !refExists(`refs/heads/${wanted}`) ? wanted : `pr-${pull.rkey}`; if (refExists(`refs/heads/${branch}`)) { throw new TglError(t(`Branch "${branch}" already exists. Choose another name with --branch.`, `La rama "${branch}" ya existe. Elige otro nombre con --branch.`)); } const previous = currentBranch(); git(['switch', '-c', branch, baseRef]); try { if (/^From [0-9a-f]{40} /m.test(patch)) { git(['am', '--3way'], { input: patch }); } else { // A plain diff (PR created by pasting a patch): apply it as one commit. git(['apply', '--index'], { input: patch }); git(['commit', '-m', pull.title]); } } catch (err) { spawnQuiet(['am', '--abort']); spawnQuiet(['switch', previous]); spawnQuiet(['branch', '-D', branch]); // Keep git's first error line; its "hint:" lines describe a state we just undid. const reason = err.message.split('\n')[0]; throw new TglError(t( `The PR's changes do not apply on ${baseRef} (maybe it is out of date: try "git fetch"). Nothing was changed.\n${reason}`, `Los cambios de la PR no encajan sobre ${baseRef} (quizá está desactualizada: prueba "git fetch"). No se ha cambiado nada.\n${reason}`, )); } console.log(t(`You are now on branch "${branch}" with the changes of "${pull.title}".`, `Estás en la rama "${branch}" con los cambios de "${pull.title}".`)); console.log(t(`Back to where you were: git switch ${previous}`, `Para volver a donde estabas: git switch ${previous}`)); }, }, comment: { summary: t('Comment on a PR', 'Comentar en una PR'), usage: t( `Usage: tgl pr comment [<#number | id | branch>] (--body <text> | --body-file <file>) [-R owner/name]\n\n${NO_REF_EN}`, `Uso: tgl pr comment [<#número | id | rama>] (--body <texto> | --body-file <archivo>) [-R cuenta/nombre]\n\n${NO_REF_ES}`, ), options: { ...repoOption, ...bodyOptions }, async run(opts, [ref]) { const body = readBody(opts, { required: true }); const { pull } = await findPull(opts, ref); const session = await openSession(); const pullRoundIdx = Math.max((pull.rounds?.length ?? 1) - 1, 0); await session.createRecord(NSID.comment, buildCommentRecord({ subject: pull, body, pullRoundIdx })); console.log(t(`Comment posted on "${pull.title}".`, `Comentario publicado en "${pull.title}".`)); }, }, edit: { summary: t("Change a PR's title or description", 'Cambiar el título o la descripción de una PR'), usage: t([ 'Usage: tgl pr edit [<#number | id | branch>] [--title <title>] [--body <text> | --body-file <file>] [-R owner/name]', '', "Only the PR's author can edit it. Fields you leave out stay as they are.", NO_REF_EN, ], [ 'Uso: tgl pr edit [<#número | id | rama>] [--title <título>] [--body <texto> | --body-file <archivo>] [-R cuenta/nombre]', '', 'Solo quien creó la PR puede editarla. Lo que no indiques se queda como está.', NO_REF_ES, ]).join('\n'), options: { ...repoOption, title: { type: 'string', short: 't' }, ...bodyOptions }, async run(opts, [ref]) { const { pull, repoDid } = await findPull(opts, ref); const body = linkClosingRefs(readBody(opts), repoDid); const session = await openSession(); const value = await editTitleBody(session, pull, { title: opts.title, body }, { en: 'PR', es: 'PR' }); console.log(t(`PR "${value.title}" updated.`, `PR "${value.title}" actualizada.`)); }, }, update: { summary: t("Send a PR's branch again after new commits (a new revision)", 'Volver a enviar la rama de una PR tras nuevos commits (una revisión nueva)'), usage: t([ 'Usage: tgl pr update [<#number | id | branch>] [--allow-unpushed] [--dry-run] [-R owner/name]', '', 'A Tangled PR keeps the changes it was sent with; commits added to the branch later', 'only show up after this. It adds a new revision, as "resubmit" on the website does.', NO_REF_EN, '', ' --dry-run Check everything without changing the PR', ' --allow-unpushed Go ahead even if the branch has commits not pushed to Tangled', ], [ 'Uso: tgl pr update [<#número | id | rama>] [--allow-unpushed] [--dry-run] [-R cuenta/nombre]', '', 'Una PR de Tangled se queda con los cambios con los que se envió; los commits que se', 'añaden después a la rama solo aparecen tras esto. Añade una revisión nueva, como', '"resubmit" en la web.', NO_REF_ES, '', ' --dry-run Comprobarlo todo sin cambiar la PR', ' --allow-unpushed Seguir aunque la rama tenga commits sin subir a Tangled', ]).join('\n'), options: { ...repoOption, 'dry-run': { type: 'boolean', default: false }, 'allow-unpushed': { type: 'boolean', default: false } }, async run(opts, [ref]) { const { pull, repoDid } = await findPull(opts, ref); if (pull.state !== 'open') throw new TglError(t(`PR "${pull.title}" is ${STATE_WORDS[pull.state]()}; only open PRs can be updated.`, `La PR "${pull.title}" está ${STATE_WORDS[pull.state]()}; solo se pueden actualizar las abiertas.`)); if (pull.author !== whoAmI().did) throw new TglError(t('Only its author can update this PR.', 'Solo quien creó la PR puede actualizarla.')); if (!pull.rounds?.length || !pull.source?.branch || isFork(pull, repoDid)) { throw new TglError(t('tgl can only update PRs sent from a branch of this repo.', 'tgl solo puede actualizar PRs enviadas desde una rama de este repo.')); } const head = localRefFor(pull.source.branch); const { patch, commits } = formatPatch(localRefFor(pull.target.branch), head); if (patch === await pullPatch(pull, repoDid)) { console.log(t(`PR "${pull.title}" is already up to date with ${head}.`, `La PR "${pull.title}" ya está al día con ${head}.`)); return; } checkPushed(repoDid, pull.source.branch, head, { force: opts['allow-unpushed'], dryRun: opts['dry-run'] }); const revision = pull.rounds.length + 1; const summary = t( `PR "${pull.title}": revision ${revision} from ${head} (${commits} commit${commits === 1 ? '' : 's'})`, `PR "${pull.title}": revisión ${revision} desde ${head} (${commits} commit${commits === 1 ? '' : 's'})`, ); if (opts['dry-run']) { console.log(t(`${summary}. Nothing was changed (--dry-run).`, `${summary}. No se ha cambiado nada (--dry-run).`)); return; } const session = await openSession(); const current = await session.getOwnRecord(pull.uri); const patchBlob = await session.uploadBlob(gzipSync(Buffer.from(patch, 'utf8')), 'application/gzip'); // Rounds are append-only (lexicons/pulls/pull.json): Tangled may reject other changes. const rounds = [...(current.value.rounds ?? []), { createdAt: new Date().toISOString(), patchBlob }]; await session.putRecord(current, { ...current.value, rounds }); console.log(t(`${summary}: sent.`, `${summary}: enviada.`)); }, }, label: labelCommand('pull', (opts, ref) => findPull(opts, ref).then((r) => r.pull)), merge: { summary: t('Merge a PR on Tangled (its server applies the changes)', 'Fusionar una PR en Tangled (su servidor aplica los cambios)'), usage: t([ 'Usage: tgl pr merge [<#number | id | branch>] [--keep-issues] [--dry-run] [-R owner/name]', '', "Tangled's server applies the PR's latest revision to its target branch, like the", '"merge" button on the website: each commit is kept (with new hashes). Then the PR', 'is marked as merged and the issues it says it fixes are closed (see "tgl pr close").', 'Only the owner or a collaborator can merge.', NO_REF_EN, '', ' --dry-run Only check whether it merges without conflicts', ' --keep-issues Leave open the issues the PR says it fixes', '', 'If you merge locally and push instead, use "tgl pr close --merged".', ], [ 'Uso: tgl pr merge [<#número | id | rama>] [--keep-issues] [--dry-run] [-R cuenta/nombre]', '', 'El servidor de Tangled aplica la última revisión de la PR a su rama destino, como el', 'botón "merge" de la web: se mantiene cada commit (con hashes nuevos). Después la PR', 'queda fusionada y se cierran las issues que dice resolver (ver "tgl pr close").', 'Solo puede fusionar el dueño o un colaborador.', NO_REF_ES, '', ' --dry-run Solo comprobar si se fusiona sin conflictos', ' --keep-issues Dejar abiertas las issues que la PR dice resolver', '', 'Si fusionas en local y subes, usa en su lugar "tgl pr close --merged".', ]).join('\n'), options: { ...repoOption, 'dry-run': { type: 'boolean', default: false }, 'keep-issues': { type: 'boolean', default: false } }, run: (opts, [ref]) => mergePull(opts, ref), }, close: { summary: t('Close a PR (or mark it merged with --merged)', 'Cerrar una PR (o marcarla como fusionada con --merged)'), usage: t([ 'Usage: tgl pr close [<#number | id | branch>] [--merged [--keep-issues]] [-R owner/name]', '', ' <#number | id | branch> The PR (default: the open PR of the current branch)', ' --merged Mark it as merged instead of closed', ' --keep-issues With --merged, leave open the issues the PR says it fixes', '', "This doesn't merge anything: it only changes the PR's state on Tangled.", 'With --merged, issues named as "Fixes #12" (also "Closes", "Resolves", or with a', "link to the issue) are closed with a comment linking to the PR. They count in the PR's", "title and description, and in its comments by the PR's author or the repo's owner", 'or collaborators.', ], [ 'Uso: tgl pr close [<#número | id | rama>] [--merged [--keep-issues]] [-R cuenta/nombre]', '', ' <#número | id | rama> La PR (por defecto, la PR abierta de la rama actual)', ' --merged Marcarla como fusionada en vez de cerrada', ' --keep-issues Con --merged, dejar abiertas las issues que la PR dice resolver', '', 'No fusiona nada: solo cambia el estado de la PR en Tangled.', 'Con --merged, las issues nombradas como "Fixes #12" (también "Closes", "Resolves", o', 'con un enlace a la issue) se cierran con un comentario que enlaza a la PR. Cuentan en el', 'título y la descripción de la PR, y en sus comentarios de quien creó la PR o del dueño', 'o los colaboradores del repo.', ]).join('\n'), options: { ...repoOption, merged: { type: 'boolean', default: false }, 'keep-issues': { type: 'boolean', default: false } }, run: (opts, [ref]) => setPullState(opts, ref, opts.merged ? 'merged' : 'closed'), }, reopen: { summary: t('Reopen a closed PR', 'Volver a abrir una PR cerrada'), usage: t('Usage: tgl pr reopen <#number | id> [-R owner/name]', 'Uso: tgl pr reopen <#número | id> [-R cuenta/nombre]'), options: repoOption, run: (opts, [ref]) => setPullState(opts, requireRef(ref), 'open'), }, },};
// Merges a PR on Tangled itself, as the website's "merge" button does (appview/pulls/// merge.go): the repo's knot applies the PR's latest revision to the target branch// ("git am" for a format-patch, so each commit is kept, with new hashes), then the PR// is marked as merged.async function mergePull(opts, ref) { const { pull, repoDid } = await findPull(opts, ref); if (pull.state !== 'open') { throw new TglError(t(`PR "${pull.title}" is ${STATE_WORDS[pull.state]()}; only open PRs can be merged.`, `La PR "${pull.title}" está ${STATE_WORDS[pull.state]()}; solo se pueden fusionar las abiertas.`)); } const patch = await pullPatch(pull, repoDid); if (!patch.trim()) throw new TglError(t('This PR has no changes to merge.', 'Esta PR no tiene cambios que fusionar.')); const branch = pull.target.branch; const session = await openSession(); const knot = await knotOf(repoDid); const input = { repo: repoDid, patch, branch };
const check = await session.callService(knot, 'sh.tangled.repo.mergeCheck', input); if (check.error) throw new TglError(t(`Tangled could not check the merge: ${check.error}`, `Tangled no pudo comprobar la fusión: ${check.error}`)); if (check.is_conflicted) { const files = (check.conflicts ?? []).map((c) => ` ${c.filename}${c.reason ? ` (${c.reason})` : ''}`); throw new TglError([ t(`PR "${pull.title}" has conflicts with ${branch}; nothing was merged.`, `La PR "${pull.title}" tiene conflictos con ${branch}; no se ha fusionado nada.`), ...files, t('Update the branch (e.g. rebase it on the target) and run "tgl pr update".', 'Pon la rama al día (por ejemplo, un rebase sobre la de destino) y ejecuta "tgl pr update".'), ].join('\n')); } const summary = t(`PR "${pull.title}" -> ${branch}`, `PR "${pull.title}" -> ${branch}`); if (opts['dry-run']) { console.log(t(`${summary}: no conflicts. Nothing was merged (--dry-run).`, `${summary}: sin conflictos. No se ha fusionado nada (--dry-run).`)); return; }
const merge = { ...input, commitMessage: pull.title, authorName: pull.authorHandle }; if (pull.body) merge.commitBody = pull.body; try { await session.callService(knot, 'sh.tangled.repo.merge', merge); } catch (err) { if (err.xrpcError === 'MergeConflict') { throw new TglError(t(`${branch} changed meanwhile and the PR no longer applies; nothing was merged. ${err.message}`, `${branch} ha cambiado mientras tanto y la PR ya no encaja; no se ha fusionado nada. ${err.message}`)); } throw err; } console.log(t(`${summary}: merged on Tangled.`, `${summary}: fusionada en Tangled.`)); await writePullState(opts, session, pull, repoDid, 'merged'); console.log(t( `Your local copy doesn't have the merge yet: git switch ${branch} && git pull\nIf the repo is mirrored elsewhere (e.g. GitHub), push ${branch} there too.`, `Tu copia local aún no tiene la fusión: git switch ${branch} && git pull\nSi el repo tiene una copia en otro sitio (p. ej. GitHub), sube ${branch} allí también.`, ));}
function requireRef(ref) { if (!ref) throw new TglError(t('Say which PR: its #number or its id (tgl pr list).', 'Indica la PR: su #número o su id (tgl pr list).')); return ref;}
function spawnQuiet(args) { try { git(args); } catch { // Best-effort cleanup. }}