From 187ecc5d60d0cd8ee27e07ee5dc9a97d6be715f3 Mon Sep 17 00:00:00 2001 From: ARUNAVO RAY Date: Wed, 1 Jul 2026 08:12:36 +0530 Subject: [PATCH] fix: correctly mirror Gitea release titles and issue/PR labels (#334 + sibling) (#335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- src/lib/gitea-issue-labels.test.ts | 52 ++++++++ src/lib/gitea-release-name.test.ts | 51 ++++++++ src/lib/gitea.ts | 183 +++++++++++++++++++++++------ 3 files changed, 251 insertions(+), 35 deletions(-) create mode 100644 src/lib/gitea-issue-labels.test.ts create mode 100644 src/lib/gitea-release-name.test.ts diff --git a/src/lib/gitea-issue-labels.test.ts b/src/lib/gitea-issue-labels.test.ts new file mode 100644 index 0000000..f20f53b --- /dev/null +++ b/src/lib/gitea-issue-labels.test.ts @@ -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: [] }); + }); +}); diff --git a/src/lib/gitea-release-name.test.ts b/src/lib/gitea-release-name.test.ts new file mode 100644 index 0000000..342ed31 --- /dev/null +++ b/src/lib/gitea-release-name.test.ts @@ -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"); + }); +}); diff --git a/src/lib/gitea.ts b/src/lib/gitea.ts index ad76a5a..c42e00c 100644 --- a/src/lib/gitea.ts +++ b/src/lib/gitea.ts @@ -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; + decryptedConfig: Config; + giteaOwner: string; + repoName: string; + issueNumber: number; + labelIds: number[]; +}): Promise { + 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, @@ -2995,15 +3100,7 @@ export async function mirrorGitHubReleasesToGitea({ await httpPatch( `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases/${existingRelease.id}`, - { - tag_name: release.tag_name, - // Omit `target` — the release already exists and is anchored to its tag; - // re-sending target_commitish risks the same "target not found" 404 (#331). - title: release.name || release.tag_name, - body: releaseNote, - draft: release.draft, - prerelease: release.prerelease, - }, + buildGiteaReleasePayload(release, releaseNote), { Authorization: `token ${decryptedConfig.giteaConfig.token}`, } @@ -3074,16 +3171,7 @@ export async function mirrorGitHubReleasesToGitea({ const createReleaseResponse = await httpPost( `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases`, - { - tag_name: release.tag_name, - // Intentionally omit `target`: the tag already exists (verified above), so - // Gitea attaches the release to it. Sending target_commitish can 404 with - // "The target couldn't be found" on some Gitea/Forgejo versions (#331). - title: release.name || release.tag_name, - body: releaseNote, - draft: release.draft, - prerelease: release.prerelease, - }, + buildGiteaReleasePayload(release, releaseNote), { Authorization: `token ${decryptedConfig.giteaConfig.token}`, } @@ -3471,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}`, } @@ -3515,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) { @@ -3559,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}`, } @@ -3602,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) {