mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-08-06 08:53:35 +02:00
* 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.
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
+148
-35
@@ -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,
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user