mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-08-18 09:19:46 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74606f0a5f | |||
| 187ecc5d60 | |||
| 632bbd0d4a | |||
| 0b65e40784 | |||
| 4a8b4f6ff3 | |||
| 1d9dfdeb70 |
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gitea-mirror",
|
||||
"type": "module",
|
||||
"version": "3.20.0",
|
||||
"version": "3.20.3",
|
||||
"engines": {
|
||||
"bun": ">=1.2.9"
|
||||
},
|
||||
|
||||
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user