Compare commits

...

6 Commits

Author SHA1 Message Date
Arunavo Ray 74606f0a5f chore: bump version to 3.20.3 2026-07-01 08:13:15 +05:30
ARUNAVO RAY 187ecc5d60 fix: correctly mirror Gitea release titles and issue/PR labels (#334 + sibling) (#335)
* fix(releases): send Gitea release title as `name`, not `title` (#334)

Gitea/Forgejo expose the release title through the JSON field `name`
(the API Go struct is `Title string `+"`"+`json:"name"`+"`"+`). The release
create and update payloads sent `title:` instead, which Gitea silently
ignores, so every mirrored release landed with a blank title.

Verified live against Gitea 1.24.7: a POST/PATCH with `title` yields
`name: ""`; the same call with `name` sets the title correctly. The
update path also self-heals previously-mirrored releases whose names were
left blank, since the existing-vs-expected name comparison already drives
a PATCH.

Adds gita-release-name.test.ts, which drives the real
mirrorGitHubReleasesToGitea create/update paths with a mocked fetch and
asserts the payload carries `name` (and never `title`).

* fix(issues): reconcile labels on issue/PR update via the labels sub-resource (#334 sibling)

Gitea/Forgejo's `EditIssueOption` has no `labels` field (only
`CreateIssueOption` does), so a `labels` key in a `PATCH .../issues/{index}`
body is silently dropped — the same silent-ignore class as the release
`title` vs `name` bug. The issue and PR-as-issue update paths sent `labels`
in the PATCH body, so label changes never propagated onto already-mirrored
issues (and a deadlock-orphaned issue recovered via PATCH never got its
labels).

Fix: add `reconcileGiteaIssueLabels`, which replaces the label set via
`PUT .../issues/{index}/labels` (idempotent — adds new, removes deleted).
Call it on the two issue update paths and the two PR-issue update paths,
and drop the dead `labels` key from those PATCH bodies. Labels on freshly
created issues still come from CreateIssueOption on the POST.

Verified live against Gitea 1.24.7 (PATCH ignores labels; PUT applies them)
and end-to-end (a drifted mirrored issue reconciled from no-labels to its
GitHub label set). Adds gitea-issue-labels.test.ts driving the real
mirrorGitRepoIssuesToGitea update path; the test carries a self-contained
http-client mock so it is immune to another suite's global module mock.

* test: make #334 regression tests deterministic via pure payload builders

The prior tests drove the real mirror functions with a global `fetch` mock.
That is order/version-fragile: another suite installs a process-global
`mock.module("@/lib/http-client")`, and bun 1.3.13 (CI) runs test files
concurrently, so `globalThis.fetch` races across files and
`isRepoPresentInGitea` (raw fetch) intermittently sees the wrong mock —
green locally on bun 1.3.6, red in CI.

Extract the payload construction into pure, exported builders and assert on
those instead (the repo's existing `classify*` pattern): buildGiteaReleasePayload
(create+update send `name`, never `title`), buildGiteaIssueEditPayload (edit
body never carries `labels`), buildGiteaIssueLabelsPayload (labels sub-resource
body). Behavior is unchanged — the builders return the exact same objects the
call sites built inline — and the fixes remain verified live on Gitea 1.24.7.
2026-07-01 08:12:36 +05:30
Arunavo Ray 632bbd0d4a chore: bump version to 3.20.2 2026-06-24 17:19:10 +05:30
ARUNAVO RAY 0b65e40784 fix(releases): create releases only for tags present in Gitea; stop sending target (#331) (#333)
Release creation failed on some Gitea/Forgejo instances with
"HTTP 404: The target couldn't be found", so no release (and therefore no
assets) was ever created — re-syncing never recovered.

Root cause: the create payload always sent `target: target_commitish`
(e.g. "main"). When the release's git tag is not yet present in the Gitea
mirror — which happens when Gitea's own git mirror clone lags behind the
metadata sync — Gitea tries to *create* the tag from `target`; if that ref
can't be resolved it returns a generic 404 ("The target couldn't be
found"), and if it can, it would create a brand-new tag at the wrong commit.

Reproduced the reporter's exact stack (Forgejo 15 rootless + read_only +
cap_drop ALL + postgres, plus Gitea 1.20-1.26 and Forgejo 1.21-15): a
healthy repo always succeeds — the 404 only occurs when the tag is absent
at create time.

Fix:
- Before creating a release, verify the git tag already exists in Gitea.
  If it isn't synced yet, skip it (logged) and let a later sync create it
  once the mirror has the tag — never create a tag via `target`.
- Drop the `target` field from both the create and update payloads. For a
  mirror the tag is synced from upstream, so Gitea attaches the release to
  the existing tag; `target` is unnecessary and is what triggers the 404.
- Surface skipped-missing-tag releases in the summary log for diagnosability.

Verified end-to-end against the real mirror function on a Forgejo instance:
a release whose tag exists is created with its assets; a release whose tag
was removed is skipped cleanly (no 404, no bogus tag) and picked up once the
tag is present.
2026-06-24 17:18:46 +05:30
Arunavo Ray 4a8b4f6ff3 chore: bump version to 3.20.1 2026-06-23 23:07:16 +05:30
ARUNAVO RAY 1d9dfdeb70 fix(releases): mirror assets idempotently so missing assets self-heal (#331) (#332)
Release assets were uploaded only on the create path of
mirrorGitHubReleasesToGitea(). When a Gitea release already existed, the
update path PATCHed the changelog/title and `continue`d without ever
touching assets. So any release whose assets were not fully uploaded on
that single create-path run — first sync interrupted, a transient
download/upload failure, large multi-MB assets, etc. — stayed permanently
asset-less, and re-syncing always hit the update path and could never
recover it. Asset failures were also swallowed to console.error, so the
job still reported success (the "no errors in the logs" in #331).

Reproduced on a real Forgejo pull-mirror with shauninman/MinUI: a GitHub
release with two ~35-40MB binaries became a Gitea release with 0 assets
(just Forgejo's auto-generated source archive), and re-syncing left it at
0 while logging "Updating existing release".

Fix:
- Add reconcileReleaseAssets(), an idempotent reconciler run on BOTH the
  create and update paths. It compares Gitea's existing attachments to
  GitHub's by name+size: skips matches, uploads missing ones, replaces
  size-mismatched copies. Existing broken releases self-heal on next sync.
- Extract the pure decision into classifyAssetsForReconciliation() for
  unit testing (the absence of asset tests is why this slipped past #310).
- Surface asset upload counts in the summary and emit a visible warning
  when any fail, instead of silently swallowing them.
- Add 5 unit tests covering the broken state, partial backfill,
  idempotency, size-mismatch replacement, and the no-assets case.

Verified end-to-end against the real function on a Forgejo pull-mirror:
0 -> 2 assets backfilled, already-present assets skipped (no re-download),
second run uploads 0.
2026-06-23 23:06:43 +05:30
5 changed files with 572 additions and 82 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "3.20.0",
"version": "3.20.3",
"engines": {
"bun": ">=1.2.9"
},
+52
View File
@@ -0,0 +1,52 @@
/**
* Regression test for the #334 sibling bug — labels silently dropped on issue update.
*
* Gitea/Forgejo's `EditIssueOption` has no `labels` field (only `CreateIssueOption`
* does), so a `labels` key in a `PATCH .../issues/{index}` body is silently ignored.
* The old code put `labels` in the update PATCH, so label changes never propagated
* onto already-mirrored issues. The fix builds the edit body WITHOUT labels
* (`buildGiteaIssueEditPayload`) and reconciles labels separately through the
* sub-resource `PUT .../issues/{index}/labels` (`buildGiteaIssueLabelsPayload`).
*
* Verified live against Gitea 1.24.7: PATCH with `labels` leaves the issue's labels
* unchanged; PUT to the labels sub-resource replaces them. Confirmed end-to-end that
* a drifted (label-less) mirrored issue reconciles back to its GitHub label set.
*/
import { describe, test, expect } from "bun:test";
import { buildGiteaIssueEditPayload, buildGiteaIssueLabelsPayload } from "@/lib/gitea";
describe("buildGiteaIssueEditPayload (#334 sibling)", () => {
test("edit body never carries `labels` (Gitea's EditIssueOption ignores it)", () => {
const payload = buildGiteaIssueEditPayload({
title: "[GH-ISSUE #1] Fix the thing",
body: "desc",
closed: false,
});
expect(payload).not.toHaveProperty("labels");
expect(payload).toEqual({
title: "[GH-ISSUE #1] Fix the thing",
body: "desc",
state: "open",
});
});
test("maps the closed flag to Gitea's `state`", () => {
expect(buildGiteaIssueEditPayload({ title: "t", body: "b", closed: true }).state).toBe("closed");
expect(buildGiteaIssueEditPayload({ title: "t", body: "b", closed: false }).state).toBe("open");
});
});
describe("buildGiteaIssueLabelsPayload (#334 sibling)", () => {
test("replaces the full label set with the resolved Gitea label ids", () => {
expect(buildGiteaIssueLabelsPayload([7, 9])).toEqual({ labels: [7, 9] });
});
test("sends an empty set so upstream label removals propagate", () => {
expect(buildGiteaIssueLabelsPayload([])).toEqual({ labels: [] });
});
test("treats a missing id list as an empty set (defensive)", () => {
expect(buildGiteaIssueLabelsPayload(undefined as any)).toEqual({ labels: [] });
});
});
+51
View File
@@ -0,0 +1,51 @@
/**
* Regression test for #334 — "Release titles not being mirrored properly".
*
* Root cause: the release create/update payloads sent the release title under the
* JSON key `title`, but Gitea/Forgejo's release API expects `name` (the API Go
* struct is `Title string \`json:"name"\``). `title` is silently dropped, so every
* mirrored release landed with a blank name.
*
* `buildGiteaReleasePayload` is the single source of truth for both the create
* (POST) and update (PATCH) bodies. Verified live against Gitea 1.24.7: a payload
* with `title` yields `name: ""`; a payload with `name` sets the title correctly.
*/
import { describe, test, expect } from "bun:test";
import { buildGiteaReleasePayload } from "@/lib/gitea";
describe("buildGiteaReleasePayload (#334)", () => {
test("carries the release title under `name`, never `title`", () => {
const payload = buildGiteaReleasePayload(
{ tag_name: "v0.19.0", name: "v0.19.0", draft: false, prerelease: false },
"## Features\n- something"
);
expect(payload.name).toBe("v0.19.0");
expect(payload).not.toHaveProperty("title");
expect(payload).toEqual({
tag_name: "v0.19.0",
name: "v0.19.0",
body: "## Features\n- something",
draft: false,
prerelease: false,
});
});
test("falls back to tag_name when the GitHub release name is empty or null", () => {
expect(buildGiteaReleasePayload({ tag_name: "v1.2.3", name: null }, "x").name).toBe("v1.2.3");
expect(buildGiteaReleasePayload({ tag_name: "v1.2.3", name: "" }, "x").name).toBe("v1.2.3");
expect(buildGiteaReleasePayload({ tag_name: "v1.2.3" }, "x").name).toBe("v1.2.3");
});
test("passes draft/prerelease/body through unchanged", () => {
const payload = buildGiteaReleasePayload(
{ tag_name: "v2.0.0", name: "Two", draft: true, prerelease: true },
"notes body"
);
expect(payload.body).toBe("notes body");
expect(payload.draft).toBe(true);
expect(payload.prerelease).toBe(true);
expect(payload.tag_name).toBe("v2.0.0");
});
});
+83 -1
View File
@@ -27,7 +27,10 @@
*/
import { describe, expect, it } from "bun:test";
import { classifyReleasesForReconciliation } from "@/lib/gitea";
import {
classifyReleasesForReconciliation,
classifyAssetsForReconciliation,
} from "@/lib/gitea";
describe("classifyReleasesForReconciliation", () => {
describe("normal repo — published_at order matches tag-commit order", () => {
@@ -148,3 +151,82 @@ describe("classifyReleasesForReconciliation", () => {
});
});
});
/**
* Asset reconciliation — regression for #331.
*
* Root cause: assets were uploaded only on the create path. When a Gitea release
* already existed (every re-sync, or after an interrupted first upload), the update
* path PATCHed the body and `continue`d without ever touching assets — so a release
* that existed without its full asset set stayed permanently asset-less and re-syncing
* could never heal it. Reproduced on a real Forgejo pull-mirror: GitHub release with
* two 35-40MB binaries → Gitea release with 0 assets → re-sync logged "Updating
* existing release" and left it at 0.
*
* Fix: reconcile assets idempotently on both paths via classifyAssetsForReconciliation.
*/
describe("classifyAssetsForReconciliation", () => {
it("uploads all assets when the Gitea release has none (the #331 broken state)", () => {
const github = [
{ name: "base.zip", size: 40_264_954 },
{ name: "extras.zip", size: 37_098_528 },
];
const gitea: Array<{ id: number; name: string; size: number }> = [];
const { toUpload, toSkip } = classifyAssetsForReconciliation(github, gitea);
expect(toSkip).toEqual([]);
expect(toUpload).toEqual([
{ name: "base.zip", replaceAssetId: null },
{ name: "extras.zip", replaceAssetId: null },
]);
});
it("backfills only the missing asset when one already exists", () => {
const github = [
{ name: "base.zip", size: 40_264_954 },
{ name: "extras.zip", size: 37_098_528 },
];
const gitea = [{ id: 9, name: "base.zip", size: 40_264_954 }];
const { toUpload, toSkip } = classifyAssetsForReconciliation(github, gitea);
expect(toSkip).toEqual(["base.zip"]);
expect(toUpload).toEqual([{ name: "extras.zip", replaceAssetId: null }]);
});
it("is idempotent — skips everything when all assets already match by name+size", () => {
const github = [
{ name: "base.zip", size: 40_264_954 },
{ name: "extras.zip", size: 37_098_528 },
];
const gitea = [
{ id: 9, name: "base.zip", size: 40_264_954 },
{ id: 10, name: "extras.zip", size: 37_098_528 },
];
const { toUpload, toSkip } = classifyAssetsForReconciliation(github, gitea);
expect(toUpload).toEqual([]);
expect(toSkip).toEqual(["base.zip", "extras.zip"]);
});
it("replaces an asset whose size changed upstream (re-upload over the stale copy)", () => {
const github = [{ name: "firmware.bin", size: 2048 }];
const gitea = [{ id: 42, name: "firmware.bin", size: 1024 }]; // truncated/stale
const { toUpload, toSkip } = classifyAssetsForReconciliation(github, gitea);
expect(toSkip).toEqual([]);
expect(toUpload).toEqual([{ name: "firmware.bin", replaceAssetId: 42 }]);
});
it("handles a release with no GitHub assets", () => {
const { toUpload, toSkip } = classifyAssetsForReconciliation(
[],
[{ id: 1, name: "leftover.zip", size: 10 }]
);
expect(toUpload).toEqual([]);
expect(toSkip).toEqual([]);
});
});
+385 -80
View File
@@ -2186,6 +2186,98 @@ export const syncGiteaRepo = async ({
}
};
/**
* Build the JSON body for creating/updating a Gitea release.
*
* Gitea/Forgejo expose the release title through the JSON field `name`, not
* `title` (the API Go struct is `Title string \`json:"name"\``); sending `title`
* is silently ignored and leaves the release name blank (#334). Create and update
* send the same fields; `target` is intentionally omitted (see #331/#333) so Gitea
* attaches the release to the already-synced tag instead of 404-ing on the target.
*/
export function buildGiteaReleasePayload(
release: { tag_name: string; name?: string | null; draft?: boolean; prerelease?: boolean },
releaseNote: string
): { tag_name: string; name: string; body: string; draft?: boolean; prerelease?: boolean } {
return {
tag_name: release.tag_name,
name: release.name || release.tag_name,
body: releaseNote,
draft: release.draft,
prerelease: release.prerelease,
};
}
/**
* Build the JSON body for a Gitea issue / PR-as-issue edit (PATCH .../issues/{index}).
*
* Deliberately excludes `labels`: Gitea's `EditIssueOption` has no `labels` field
* (only `CreateIssueOption` does), so any `labels` key here is silently dropped —
* the same class of bug as the release `title` mix-up (#334 sibling). Labels are
* applied separately via the labels sub-resource (see buildGiteaIssueLabelsPayload).
*/
export function buildGiteaIssueEditPayload(opts: {
title: string;
body: string;
closed: boolean;
}): { title: string; body: string; state: "open" | "closed" } {
return { title: opts.title, body: opts.body, state: opts.closed ? "closed" : "open" };
}
/**
* Build the JSON body for the Gitea issue labels sub-resource
* (PUT .../issues/{index}/labels), which replaces the full label set idempotently.
*/
export function buildGiteaIssueLabelsPayload(labelIds: number[]): { labels: number[] } {
return { labels: labelIds ?? [] };
}
/**
* Replace the label set on an existing Gitea issue (or PR-as-issue) via the
* dedicated labels sub-resource.
*
* Gitea/Forgejo's `EditIssueOption` has no `labels` field (only
* `CreateIssueOption` does), so a `labels` key in a `PATCH .../issues/{index}`
* body is silently dropped by the JSON decoder — the same class of bug as the
* release `title` vs `name` mix-up (#334). Label changes on an already-mirrored
* issue therefore have to go through `PUT .../issues/{index}/labels`, which
* replaces the whole set idempotently: it both applies newly added labels and
* removes ones deleted upstream.
*
* Best-effort: labels are secondary metadata, so a transient failure here is
* logged and left to self-heal on the next sync rather than failing (and
* retrying) the entire issue + comment mirror.
*/
async function reconcileGiteaIssueLabels({
config,
decryptedConfig,
giteaOwner,
repoName,
issueNumber,
labelIds,
}: {
config: Partial<Config>;
decryptedConfig: Config;
giteaOwner: string;
repoName: string;
issueNumber: number;
labelIds: number[];
}): Promise<void> {
try {
await httpPut(
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${issueNumber}/labels`,
buildGiteaIssueLabelsPayload(labelIds),
{ Authorization: `token ${decryptedConfig.giteaConfig!.token}` }
);
} catch (error) {
console.warn(
`[Labels] Failed to reconcile labels on issue #${issueNumber}: ${
error instanceof Error ? error.message : String(error)
} (will retry on next sync)`
);
}
}
export const mirrorGitRepoIssuesToGitea = async ({
config,
octokit,
@@ -2437,12 +2529,11 @@ export const mirrorGitRepoIssuesToGitea = async ({
targetIssueNumber = existingIssue.number;
await httpPatch(
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${targetIssueNumber}`,
{
buildGiteaIssueEditPayload({
title: issuePayload.title,
body: issuePayload.body,
state: issue.state === "closed" ? "closed" : "open",
labels: issuePayload.labels,
},
closed: issue.state === "closed",
}),
{
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
}
@@ -2487,12 +2578,11 @@ export const mirrorGitRepoIssuesToGitea = async ({
);
await httpPatch(
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${targetIssueNumber}`,
{
buildGiteaIssueEditPayload({
title: issuePayload.title,
body: issuePayload.body,
state: issue.state === "closed" ? "closed" : "open",
labels: issuePayload.labels,
},
closed: issue.state === "closed",
}),
{
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
}
@@ -2531,6 +2621,21 @@ export const mirrorGitRepoIssuesToGitea = async ({
}
}
// Gitea's EditIssueOption ignores `labels`, so the PATCH above can't change
// them on an already-mirrored issue — reconcile via the labels sub-resource.
// Only needed on the update paths; a freshly POSTed issue already got its
// labels from CreateIssueOption. (#334 sibling)
if (existingIssue) {
await reconcileGiteaIssueLabels({
config,
decryptedConfig,
giteaOwner,
repoName,
issueNumber: targetIssueNumber,
labelIds: giteaLabelIds,
});
}
// Clone comments
const comments = await octokit.paginate(
octokit.rest.issues.listComments,
@@ -2691,6 +2796,168 @@ export function classifyReleasesForReconciliation(
return { toCreate, toSkip };
}
/**
* Decide which of a GitHub release's assets need (re)uploading to Gitea.
*
* Compared by name:
* - present in Gitea with a matching size -> skip (already mirrored)
* - present with a different size -> upload, replacing the stale copy
* - absent -> upload (fresh)
*
* Pure function so the create/update reconciliation decision is unit-testable
* without hitting the network (regression guard for #331).
*/
export function classifyAssetsForReconciliation(
githubAssets: Array<{ name: string; size: number }>,
giteaAssets: Array<{ id: number; name: string; size: number }>
): {
toUpload: Array<{ name: string; replaceAssetId: number | null }>;
toSkip: string[];
} {
const existingByName = new Map(giteaAssets.map((a) => [a.name, a]));
const toUpload: Array<{ name: string; replaceAssetId: number | null }> = [];
const toSkip: string[] = [];
for (const asset of githubAssets) {
const existing = existingByName.get(asset.name);
if (existing && existing.size === asset.size) {
toSkip.push(asset.name);
} else {
toUpload.push({ name: asset.name, replaceAssetId: existing ? existing.id : null });
}
}
return { toUpload, toSkip };
}
/**
* Idempotently mirror a GitHub release's assets onto the matching Gitea release.
*
* Runs on BOTH the create and update paths so assets are reconciled on every sync,
* not only on the single sync where the Gitea release is first created. Previously
* assets were uploaded inline in the create path only; the update path PATCHed the
* body and `continue`d without ever looking at assets. Any release whose assets
* failed or were interrupted on first creation therefore stayed permanently
* asset-less, and re-syncing could never heal it (#331).
*
* Strategy: compare by asset name. Skip assets already present with a matching size;
* (re)upload anything missing, and replace an existing asset whose size differs
* (truncated/changed upstream). Returns per-release counts so the caller can report.
*/
async function reconcileReleaseAssets({
config,
decryptedConfig,
repoOwner,
repoName,
giteaReleaseId,
githubAssets,
tagName,
}: {
config: Partial<Config>;
decryptedConfig: Config;
repoOwner: string;
repoName: string;
giteaReleaseId: number;
githubAssets: Array<{ name: string; size: number; browser_download_url: string }>;
tagName: string;
}): Promise<{ uploaded: number; failed: number; skipped: number }> {
let uploaded = 0;
let failed = 0;
let skipped = 0;
if (!githubAssets || githubAssets.length === 0) {
return { uploaded, failed, skipped };
}
const giteaBaseUrl = config.giteaConfig!.url;
const giteaAuth = { Authorization: `token ${decryptedConfig.giteaConfig!.token}` };
// Fetch existing attachments so we only transfer what's missing or changed.
const existingAssets: Array<{ id: number; name: string; size: number }> = await httpGet(
`${giteaBaseUrl}/api/v1/repos/${repoOwner}/${repoName}/releases/${giteaReleaseId}/assets`,
giteaAuth
)
.then((r) => (Array.isArray(r?.data) ? r.data : []))
.catch(() => []);
const { toUpload, toSkip } = classifyAssetsForReconciliation(
githubAssets,
existingAssets
);
skipped = toSkip.length;
const githubByName = new Map(githubAssets.map((a) => [a.name, a]));
for (const { name, replaceAssetId } of toUpload) {
const asset = githubByName.get(name)!;
try {
// Download from GitHub. fetch strips the Authorization header on the
// cross-host redirect to GitHub's object storage, so this works for both
// public and private release assets.
console.log(
`[Releases] Downloading asset: ${asset.name} (${asset.size} bytes) for ${tagName}`
);
const assetResponse = await fetch(asset.browser_download_url, {
headers: {
Accept: "application/octet-stream",
Authorization: `token ${decryptedConfig.githubConfig!.token}`,
},
});
if (!assetResponse.ok) {
console.error(
`[Releases] Failed to download asset ${asset.name}: ${assetResponse.status} ${assetResponse.statusText}`
);
failed++;
continue;
}
const assetData = await assetResponse.arrayBuffer();
// Gitea rejects a duplicate attachment name, so drop a stale/mismatched
// copy before re-uploading.
if (replaceAssetId !== null) {
await httpDelete(
`${giteaBaseUrl}/api/v1/repos/${repoOwner}/${repoName}/releases/${giteaReleaseId}/assets/${replaceAssetId}`,
giteaAuth
).catch(() => null);
}
const formData = new FormData();
formData.append("attachment", new Blob([assetData]), asset.name);
const uploadResponse = await fetch(
`${giteaBaseUrl}/api/v1/repos/${repoOwner}/${repoName}/releases/${giteaReleaseId}/assets?name=${encodeURIComponent(asset.name)}`,
{
method: "POST",
headers: { Authorization: `token ${decryptedConfig.giteaConfig!.token}` },
body: formData,
}
);
if (uploadResponse.ok) {
console.log(`[Releases] Successfully uploaded asset: ${asset.name}`);
uploaded++;
} else {
const errorText = await uploadResponse.text();
console.error(
`[Releases] Failed to upload asset ${asset.name}: ${uploadResponse.status} ${errorText}`
);
failed++;
}
} catch (assetError) {
console.error(
`[Releases] Error processing asset ${asset.name}: ${
assetError instanceof Error ? assetError.message : String(assetError)
}`
);
failed++;
}
}
return { uploaded, failed, skipped };
}
export async function mirrorGitHubReleasesToGitea({
octokit,
repository,
@@ -2776,6 +3043,9 @@ export async function mirrorGitHubReleasesToGitea({
let mirroredCount = 0;
let skippedCount = 0;
let skippedMissingTagCount = 0;
let totalAssetsUploaded = 0;
let totalAssetsFailed = 0;
// Process releases in their GitHub API order (newest first by default)
const releasesToProcess = limitedReleases.slice();
@@ -2830,14 +3100,7 @@ export async function mirrorGitHubReleasesToGitea({
await httpPatch(
`${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases/${existingRelease.id}`,
{
tag_name: release.tag_name,
target: release.target_commitish,
title: release.name || release.tag_name,
body: releaseNote,
draft: release.draft,
prerelease: release.prerelease,
},
buildGiteaReleasePayload(release, releaseNote),
{
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
}
@@ -2853,6 +3116,49 @@ export async function mirrorGitHubReleasesToGitea({
console.log(`[Releases] Release ${release.tag_name} already up-to-date, skipping`);
skippedCount++;
}
// Reconcile assets on every sync — backfill any that are missing or changed.
// The update path used to `continue` here without touching assets, so a
// release that existed without its full asset set stayed broken forever (#331).
const assetResult = await reconcileReleaseAssets({
config,
decryptedConfig,
repoOwner,
repoName,
giteaReleaseId: existingRelease.id,
githubAssets: release.assets || [],
tagName: release.tag_name,
});
if (assetResult.uploaded > 0) {
console.log(
`[Releases] Backfilled ${assetResult.uploaded} missing/changed asset(s) for existing release ${release.tag_name}`
);
}
totalAssetsUploaded += assetResult.uploaded;
totalAssetsFailed += assetResult.failed;
continue;
}
// The git tag must already exist in Gitea before we create a release for it.
// For a mirror, tags are synced from upstream by Gitea's own git mirror, which
// can lag behind this metadata sync (e.g. a large/slow initial clone). If the
// tag isn't present yet, skip and let a later sync pick it up — do NOT ask Gitea
// to create the release against a `target` branch:
// - if the target can't be resolved Gitea returns 404 "The target couldn't be
// found" and the release is lost (#331),
// - if it can, Gitea would create a brand-new tag at the wrong commit.
const tagExists = await httpGet(
`${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/tags/${encodeURIComponent(release.tag_name)}`,
{ Authorization: `token ${decryptedConfig.giteaConfig.token}` }
)
.then(() => true)
.catch(() => false);
if (!tagExists) {
console.warn(
`[Releases] Tag ${release.tag_name} is not present in Gitea yet — skipping release for now (the git mirror may still be syncing; it will be retried on the next sync)`
);
skippedMissingTagCount++;
continue;
}
@@ -2862,71 +3168,31 @@ export async function mirrorGitHubReleasesToGitea({
} else {
console.log(`[Releases] Creating release ${release.tag_name} with GitHub date header (no changelog)`);
}
const createReleaseResponse = await httpPost(
`${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases`,
{
tag_name: release.tag_name,
target: release.target_commitish,
title: release.name || release.tag_name,
body: releaseNote,
draft: release.draft,
prerelease: release.prerelease,
},
buildGiteaReleasePayload(release, releaseNote),
{
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
}
);
// Mirror release assets if they exist
// Mirror release assets if they exist (idempotent — see reconcileReleaseAssets)
if (release.assets && release.assets.length > 0) {
console.log(`[Releases] Mirroring ${release.assets.length} assets for release ${release.tag_name}`);
for (const asset of release.assets) {
try {
// Download the asset from GitHub
console.log(`[Releases] Downloading asset: ${asset.name} (${asset.size} bytes)`);
const assetResponse = await fetch(asset.browser_download_url, {
headers: {
'Accept': 'application/octet-stream',
'Authorization': `token ${decryptedConfig.githubConfig.token}`,
},
});
if (!assetResponse.ok) {
console.error(`[Releases] Failed to download asset ${asset.name}: ${assetResponse.statusText}`);
continue;
}
const assetData = await assetResponse.arrayBuffer();
// Upload the asset to Gitea release
const formData = new FormData();
formData.append('attachment', new Blob([assetData]), asset.name);
const uploadResponse = await fetch(
`${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases/${createReleaseResponse.data.id}/assets?name=${encodeURIComponent(asset.name)}`,
{
method: 'POST',
headers: {
'Authorization': `token ${decryptedConfig.giteaConfig.token}`,
},
body: formData,
}
);
if (uploadResponse.ok) {
console.log(`[Releases] Successfully uploaded asset: ${asset.name}`);
} else {
const errorText = await uploadResponse.text();
console.error(`[Releases] Failed to upload asset ${asset.name}: ${errorText}`);
}
} catch (assetError) {
console.error(`[Releases] Error processing asset ${asset.name}: ${assetError instanceof Error ? assetError.message : String(assetError)}`);
}
}
const assetResult = await reconcileReleaseAssets({
config,
decryptedConfig,
repoOwner,
repoName,
giteaReleaseId: createReleaseResponse.data.id,
githubAssets: release.assets,
tagName: release.tag_name,
});
totalAssetsUploaded += assetResult.uploaded;
totalAssetsFailed += assetResult.failed;
}
mirroredCount++;
const noteInfo = originalReleaseNote ? ` with ${originalReleaseNote.length} character changelog` : " without changelog";
console.log(`[Releases] Successfully mirrored release: ${release.tag_name}${noteInfo}`);
@@ -2935,7 +3201,21 @@ export async function mirrorGitHubReleasesToGitea({
}
}
console.log(`✅ Mirrored/Updated ${mirroredCount} releases to Gitea (${skippedCount} already up-to-date)`);
console.log(
`✅ Mirrored/Updated ${mirroredCount} releases to Gitea (${skippedCount} already up-to-date, ${skippedMissingTagCount} skipped: tag not synced yet); assets uploaded: ${totalAssetsUploaded}, failed: ${totalAssetsFailed}`
);
if (skippedMissingTagCount > 0) {
console.warn(
`[Releases] ${skippedMissingTagCount} release(s) skipped because their git tag is not in Gitea yet for ${repository.fullName} — these will be created automatically once the git mirror finishes syncing the tags`
);
}
if (totalAssetsFailed > 0) {
console.error(
`[Releases] ⚠️ ${totalAssetsFailed} release asset(s) failed to mirror for ${repository.fullName} — they will be retried on the next sync`
);
}
// Enforce release retention limit by removing the oldest excess releases from Gitea
try {
@@ -3279,12 +3559,11 @@ export async function mirrorGitRepoPullRequestsToGitea({
if (existingPrIssue) {
await httpPatch(
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${existingPrIssue.number}`,
{
buildGiteaIssueEditPayload({
title: issueData.title,
body: issueData.body,
state: issueData.closed ? "closed" : "open",
labels: issueData.labels,
},
closed: issueData.closed,
}),
{
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
}
@@ -3323,6 +3602,20 @@ export async function mirrorGitRepoPullRequestsToGitea({
}
}
// Gitea drops `labels` on issue edit, so the "pull-request" marker label
// can't be set via the PATCH above — reconcile it on the update path.
// (#334 sibling)
if (existingPrIssue) {
await reconcileGiteaIssueLabels({
config,
decryptedConfig,
giteaOwner,
repoName,
issueNumber: existingPrIssue.number,
labelIds: issueData.labels,
});
}
successCount++;
console.log(`[Pull Requests] ✅ Successfully created issue for PR #${pr.number}`);
} catch (apiError) {
@@ -3367,12 +3660,11 @@ export async function mirrorGitRepoPullRequestsToGitea({
if (existingPrIssue) {
await httpPatch(
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${existingPrIssue.number}`,
{
buildGiteaIssueEditPayload({
title: basicIssueData.title,
body: basicIssueData.body,
state: basicIssueData.closed ? "closed" : "open",
labels: basicIssueData.labels,
},
closed: basicIssueData.closed,
}),
{
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
}
@@ -3410,6 +3702,19 @@ export async function mirrorGitRepoPullRequestsToGitea({
}
}
// Same as the enriched path — reconcile the marker label via the labels
// sub-resource since PATCH ignores it. (#334 sibling)
if (existingPrIssue) {
await reconcileGiteaIssueLabels({
config,
decryptedConfig,
giteaOwner,
repoName,
issueNumber: existingPrIssue.number,
labelIds: basicIssueData.labels,
});
}
successCount++;
console.log(`[Pull Requests] ✅ Created basic issue for PR #${pr.number}`);
} catch (error) {