diff --git a/README.md b/README.md index d94edc3..bfeff25 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ GitHub Actions that back up every GitHub repo I own (personal and org) to a self | Mirror to Gitea | 05:00 (12 AM CDT) | `mirror.js` | Creates missing orgs and pull mirrors, syncs repo/org visibility, descriptions and org avatars | | Sync releases to Gitea | 06:30 (1:30 AM CDT) | `sync-releases.js` | Copies GitHub releases (title, notes, assets) onto the mirrors | | Rename deleted GitHub mirrors | 08:00 (3 AM CDT) | `rename-gone-mirrors.js` | Renames mirrors whose GitHub repo was deleted or moved to `-goneN` and freezes them | -| Sync issues to Gitea | 09:30 (4:30 AM CDT) | `sync-issues.js` | Copies GitHub issues, PR conversations and their comments onto the mirrors | +| Sync issues to Gitea | 09:30 (4:30 AM CDT) | `sync-issues.js` | Copies GitHub issues and their comments onto the mirrors | Each runs once a day, 1.5 hours apart, so each one starts with a fresh hourly GitHub API budget. All can also be run by hand from the Actions tab. Each run posts a summary to ntfy through `notify.js`. `gitea-mirrors.js` holds the API helpers the two sync scripts share. @@ -33,8 +33,8 @@ This relies on Gitea ≥ 1.21.5, where a mirror sync only touches tag-only entri Gitea drops issues for pull mirrors too, but the API still lets you create them. `sync-issues.js` copies them over through the API: - Gitea issue numbers always match GitHub's, so `#N` in commits and comments points at the right thing: - - PRs become issues labelled `pull request`, closed or open like on GitHub. Only their conversation comes along, not code review comments or diffs. - - Numbers that aren't issues on GitHub (deleted, transferred, or used by a discussion) stay gaps on Gitea too. Gitea never reuses a deleted issue's number, so an issue that lands on a gap is deleted and made again until it gets GitHub's number. + - PRs aren't copied, since mirrors have no pull requests. Their numbers stay gaps on Gitea. + - Numbers that aren't issues on GitHub (deleted, transferred, or used by a discussion) stay gaps too. Gitea never reuses a deleted issue's number, so an issue that lands on a gap is deleted and made again until it gets GitHub's number. - If Gitea's counter is already past a GitHub number, or `#N` on Gitea wasn't made by the sync, that repo stops with a failure instead of guessing. - Everything is posted as the Gitea token user. Each issue and comment opens with a quoted line naming the GitHub author, the date, and a link back. The link is how comments are matched on later runs, so don't edit it away. - Title, body, open/closed state, labels and comment edits follow GitHub. Nothing is deleted on Gitea. diff --git a/sync-issues.js b/sync-issues.js index a2862f3..29c038c 100644 --- a/sync-issues.js +++ b/sync-issues.js @@ -4,14 +4,13 @@ const facts = { mirrorsChecked: 0, issuesFound: 0, created: 0, updated: 0, commentsCreated: 0, commentsUpdated: 0, failures: 0, changes: [] }; -const prLabel = { name: 'pull request', color: '8250df', description: 'Pull request on GitHub' }; const who = user => user ? `[@${user.login}](${user.html_url})` : '@ghost'; const quote = (header, body) => body ? `> ${header}\n\n${body}` : `> ${header}`; const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`; const names = labels => labels.map(label => label.name).sort().join('\n'); -// Gitea numbers must match GitHub's so #N references in commits and comments stay correct, -// which is also why PRs come along as issues. +// Gitea numbers must match GitHub's so #N references in commits and comments stay correct. +// PR numbers are left as gaps, since mirrors have no pull requests. const syncIssues = async ({ gh, gtRepo }, ghIssues, count) => { const [gtIssues, gtLabels] = await Promise.all([ pages(`${gtRepo}/issues?state=all&type=issues`, gtHeaders, 'limit'), @@ -37,18 +36,25 @@ const syncIssues = async ({ gh, gtRepo }, ghIssues, count) => { } }; const link = n => `https://github.com/${gh.owner}/${gh.name}/issues/${n}`; + // One-time cleanup: PRs used to be copied as issues with a `pull request` label + for (const gt of gtIssues.filter(gt => gt.body.includes(`opened pull request [GitHub #${gt.number}](${link(gt.number)})`))) { + await request(`${gtRepo}/issues/${gt.number}`, gtHeaders, 'DELETE'); + byNumber.delete(gt.number); + console.log(`Removed PR copy ${gtRepo.split('/repos/')[1]}#${gt.number}`); + } + const prLabel = gtLabels.find(label => label.name === 'pull request' && label.description === 'Pull request on GitHub'); + if (prLabel) await request(`${gtRepo}/labels/${prLabel.id}`, gtHeaders, 'DELETE'); let top = Math.max(0, ...byNumber.keys()); for (const issue of ghIssues.sort((a, b) => a.number - b.number)) { - const n = issue.number, kind = issue.pull_request ? 'pull request' : 'issue'; - const header = `${who(issue.user)} opened ${kind} [GitHub #${n}](${link(n)}) on ${issue.created_at.slice(0, 10)}`; + const n = issue.number; + const header = `${who(issue.user)} opened issue [GitHub #${n}](${link(n)}) on ${issue.created_at.slice(0, 10)}`; const want = { title: issue.title, body: quote(header, issue.body), state: issue.state }; - const labels = issue.pull_request ? [...issue.labels, prLabel] : issue.labels; const gt = byNumber.get(n); if (!gt) { if (n < top) throw new Error(`Gitea has no #${n} and can't reuse the number`); - await create({ title: want.title, body: want.body, closed: want.state === 'closed', labels: await ids(labels) }, n); + await create({ title: want.title, body: want.body, closed: want.state === 'closed', labels: await ids(issue.labels) }, n); top = n; count.created++; continue; @@ -60,8 +66,8 @@ const syncIssues = async ({ gh, gtRepo }, ghIssues, count) => { await request(`${gtRepo}/issues/${n}`, gtHeaders, 'PATCH', want); changed = true; } - if (names(gt.labels) !== names(labels)) { - await request(`${gtRepo}/issues/${n}/labels`, gtHeaders, 'PUT', { labels: await ids(labels) }); + if (names(gt.labels) !== names(issue.labels)) { + await request(`${gtRepo}/issues/${n}/labels`, gtHeaders, 'PUT', { labels: await ids(issue.labels) }); changed = true; } if (changed) count.updated++; @@ -69,7 +75,7 @@ const syncIssues = async ({ gh, gtRepo }, ghIssues, count) => { }; // Conversation comments on issues and PRs; the GitHub comment link in each header maps them back -const syncComments = async ({ gh, gtRepo }, count) => { +const syncComments = async ({ gh, gtRepo }, prs, count) => { const [ghComments, gtComments] = await Promise.all([ pages(`${github}/repos/${enc(gh.owner, gh.name)}/issues/comments?sort=created&direction=asc`, ghHeaders, 'per_page'), pages(`${gtRepo}/issues/comments`, gtHeaders, 'limit') @@ -78,10 +84,12 @@ const syncComments = async ({ gh, gtRepo }, count) => { [comment.body.split('\n', 1)[0].match(/#issuecomment-(\d+)\)/)?.[1], comment] )); for (const comment of ghComments) { + const n = Number(comment.issue_url.split('/').pop()); + if (prs.has(n)) continue; const body = quote(`${who(comment.user)} commented on [${comment.created_at.slice(0, 10)}](${comment.html_url})`, comment.body); const gt = mirrored.get(String(comment.id)); if (!gt) { - await request(`${gtRepo}/issues/${comment.issue_url.split('/').pop()}/comments`, gtHeaders, 'POST', { body }); + await request(`${gtRepo}/issues/${n}/comments`, gtHeaders, 'POST', { body }); count.commentsCreated++; } else if (gt.body !== body) { await request(`${gtRepo}/issues/comments/${gt.id}`, gtHeaders, 'PATCH', { body }); @@ -99,11 +107,14 @@ const syncComments = async ({ gh, gtRepo }, count) => { const { gh, name } = mirror; const count = { created: 0, updated: 0, commentsCreated: 0, commentsUpdated: 0 }; try { - const ghIssues = await pages(`${github}/repos/${enc(gh.owner, gh.name)}/issues?state=all`, ghHeaders, 'per_page', true); - if (!ghIssues?.length) continue; - facts.issuesFound += ghIssues.length; - await syncIssues(mirror, ghIssues, count); - if (ghIssues.some(issue => issue.comments)) await syncComments(mirror, count); + // GitHub lists PRs as issues too + const all = await pages(`${github}/repos/${enc(gh.owner, gh.name)}/issues?state=all`, ghHeaders, 'per_page', true); + if (!all?.length) continue; + const issues = all.filter(issue => !issue.pull_request); + const prs = new Set(all.filter(issue => issue.pull_request).map(issue => issue.number)); + facts.issuesFound += issues.length; + await syncIssues(mirror, issues, count); + if (issues.some(issue => issue.comments)) await syncComments(mirror, prs, count); } catch (error) { facts.failures++; console.error(`Failed to sync issues for ${name}: ${error.message}`);