Compare commits

...

4 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
4 changed files with 284 additions and 35 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "3.20.1",
"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");
});
});
+180 -34
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,
@@ -2938,6 +3043,7 @@ export async function mirrorGitHubReleasesToGitea({
let mirroredCount = 0;
let skippedCount = 0;
let skippedMissingTagCount = 0;
let totalAssetsUploaded = 0;
let totalAssetsFailed = 0;
@@ -2994,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}`,
}
@@ -3040,23 +3139,39 @@ export async function mirrorGitHubReleasesToGitea({
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;
}
// Create new release with changelog/body content (includes GitHub date header)
if (originalReleaseNote) {
console.log(`[Releases] Including changelog for ${release.tag_name} (${originalReleaseNote.length} characters + GitHub date header)`);
} 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}`,
}
@@ -3087,9 +3202,15 @@ export async function mirrorGitHubReleasesToGitea({
}
console.log(
`✅ Mirrored/Updated ${mirroredCount} releases to Gitea (${skippedCount} already up-to-date); assets uploaded: ${totalAssetsUploaded}, failed: ${totalAssetsFailed}`
`✅ 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`
@@ -3438,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}`,
}
@@ -3482,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) {
@@ -3526,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}`,
}
@@ -3569,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) {