Compare commits

...
7 Commits
Author SHA1 Message Date
Arunavo Ray d45ef33c1c chore: bump version to 3.40.5 2026-10-15 18:47:25 +05:30
ARUNAVO RAY 4f96e7dfbf fix: survive SSE client disconnects on Bun 1.4 (#465) (#466)
On Bun 1.4 the node adapter's reader cancel closes the stream before the
request abort fires, so the abort listener's controller.close() threw
ERR_INVALID_STATE as an uncaught exception and the process shut itself
down whenever a dashboard tab disconnected. Both SSE routes now guard the
close and clear their timers on cancel too.

A shutdown forced by an uncaught exception or unhandled rejection now
exits with status 1 instead of 0, so a unit with Restart=on-failure
(the Proxmox community script) starts the service again.
2026-10-15 18:46:56 +05:30
Arunavo Ray 41799154fc chore: bump version to 3.40.4 2026-10-14 17:41:32 +05:30
ARUNAVO RAY 8de3c0c091 fix: assign mirrored issues and pull requests to their milestones (#460) (#463)
* fix: assign mirrored issues and pull requests to their milestones (#460)

Milestones were created in Gitea but issues never pointed at them: the
issue and PR payloads had no milestone field, and milestones were
mirrored after issues anyway.

Milestones now sync first and return a title to Gitea id map, which the
issues and pull requests passes use to set the milestone on create and
to set, change or clear it on update. The edit only carries a milestone
when it differs, so Gitea writes no repeat timeline entries. Unchanged
items are fixed on the weekly full pass from the listing alone. When
milestones are not mirrored, or one is missing in Gitea, the issue is
left as it is.

* fix: review follow-ups for milestone assignment (#460)

- Never clear a milestone made in Gitea alone. The milestones pass now
  returns only the milestones that mirror a GitHub one, and an issue is
  only cleared when it sits on one of those.
- Match existing pull request issues by a [PR #N] prefix, so a mirrored
  issue that mentions [PR #N] in its title is not taken for that PR.
- The milestone edit on an unchanged pull request is best effort: a
  failure is logged instead of retried, and the pass still completes.
- Record a milestone edit on the cached Gitea issue so a retry does not
  send it again.
- Store 0 instead of undefined when a created milestone has no id.

* fix: keep mirrored milestones in line with GitHub (#460 follow-up)

Milestones were only ever created, so closing, reopening, moving the due
date or editing the description on GitHub never reached Gitea. The
milestones pass now edits an already mirrored milestone when its state,
due date or description differs.

Due dates compare as calendar days. Gitea moves an edited deadline to
the end of that day in its own time zone, so comparing instants would
edit the milestone on every sync. A removed due date is cleared with
Gitea's year 9999 value, since its edit endpoint ignores an empty one.
2026-10-14 17:41:10 +05:30
Arunavo Ray 89babdf461 test: isolate the reset-password CLI test and use the real schema (#461 follow-up)
The test failed in CI because another file replaces bun:sqlite with
mock.module, which leaks across files depending on order. It now runs in
an isolated child process like the other fetch and sqlite driven suites.

Each case builds its database with manage-db init, so the tables come
from drizzle/ instead of hand written DDL that could drift, and the
bundle is built once in beforeAll instead of at import time.

The Dockerfile rewrite of the manage-db scripts is now a one line sed.
2026-10-14 17:38:43 +05:30
Ayrat Belyaev 44d927e768 fix: restore password reset CLI in source and Docker installs (#461) 2026-10-14 17:37:35 +05:30
CrumblyLiquid 0f55d29298 fix: NixOS module warning (#459) 2026-10-14 17:35:37 +05:30
17 changed files with 943 additions and 136 deletions

No files matched your search

+3
View File
@@ -76,6 +76,9 @@ COPY --from=builder /app/docker-entrypoint.sh ./docker-entrypoint.sh
COPY --from=builder /app/scripts/runtime-server.ts ./scripts/runtime-server.ts
COPY --from=builder /app/drizzle ./drizzle
# Database commands must use the bundled script: sources are not shipped.
RUN sed -i 's#bun scripts/manage-db.ts#bun dist/scripts/manage-db.js#g' package.json
# Remove build-only packages that are not needed at runtime
# (esbuild, vite, rollup, tailwind, svgo — all only used during `astro build`)
RUN rm -rf node_modules/esbuild node_modules/@esbuild \
+1 -1
View File
@@ -303,7 +303,7 @@ EOF
package = mkOption {
type = types.package;
default = self.packages.${pkgs.system}.default;
default = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
description = "The Gitea Mirror package to use";
};
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: gitea-mirror
description: Kubernetes helm chart for gitea-mirror
type: application
version: 3.40.3
appVersion: "3.40.3"
version: 3.40.5
appVersion: "3.40.5"
icon: https://github.com/RayLabsHQ/gitea-mirror/blob/main/.github/assets/logo.png
keywords:
- git
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "3.40.3",
"version": "3.40.5",
"engines": {
"bun": ">=1.2.9"
},
+1 -1
View File
@@ -221,7 +221,7 @@ async function resetPassword() {
}
const sqlite = new Database(dbPath);
const db = drizzle({ client: sqlite });
const db = drizzle({ client: sqlite, schema: { users, accounts, sessions } });
try {
const user = await db.query.users.findFirst({
+106
View File
@@ -0,0 +1,106 @@
/**
* Regression test for the password reset CLI (#461).
*
* `manage-db reset-password` used a Drizzle client without a schema, so
* `db.query.users` was undefined and every reset failed. Each case builds a
* real database with `manage-db init` (the migrations in drizzle/), seeds it,
* runs the reset through the source script and through the bundle the Docker
* image ships, and checks the result with Better Auth.
*
* Other test files replace bun:sqlite with mock.module, which is process wide
* in bun and depends on file order, so the suite runs in an isolated child
* process (same harness as gitea-incremental-metadata.test.ts).
*/
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
const CHILD_FLAG = "GM_RESET_PASSWORD_CLI_ISOLATED";
const isChild = !!process.env[CHILD_FLAG];
if (!isChild) {
test("reset-password CLI - isolated child suite", () => {
const res = Bun.spawnSync({
cmd: [process.execPath, "test", import.meta.path],
env: { ...process.env, [CHILD_FLAG]: "1" },
stdout: "pipe",
stderr: "pipe",
});
if (res.exitCode !== 0) {
console.error(res.stdout.toString());
console.error(res.stderr.toString());
}
expect(res.exitCode).toBe(0);
}, 60_000);
}
const root = resolve(import.meta.dir, "../../..");
let temporaryRoot = "";
let bundle = "";
function run(cmd: string[], cwd: string) {
const result = Bun.spawnSync({ cmd, cwd, stdout: "pipe", stderr: "pipe" });
expect(result.exitCode, Buffer.from(result.stderr).toString()).toBe(0);
return Buffer.from(result.stdout).toString();
}
describe.skipIf(!isChild)("manage-db reset-password", () => {
beforeAll(() => {
temporaryRoot = mkdtempSync(join(tmpdir(), "gitea-mirror-reset-password-"));
bundle = join(temporaryRoot, "manage-db.js");
run([process.execPath, "build", "scripts/manage-db.ts", "--target=bun", `--outfile=${bundle}`], root);
});
afterAll(() => {
if (temporaryRoot) rmSync(temporaryRoot, { recursive: true, force: true });
});
for (const bundled of [false, true]) {
for (const existingCredential of [false, true]) {
test(`${bundled ? "bundled" : "source"} script, ${existingCredential ? "existing" : "new"} credential`, async () => {
const { Database } = await import("bun:sqlite");
const { verifyPassword } = await import("better-auth/crypto");
const script = bundled ? bundle : join(root, "scripts/manage-db.ts");
// A real schema: init runs the migrations from ./drizzle.
const cwd = mkdtempSync(join(temporaryRoot, "case-"));
symlinkSync(join(root, "drizzle"), join(cwd, "drizzle"));
run([process.execPath, script, "init"], cwd);
const db = new Database(join(cwd, "data/gitea-mirror.db"));
try {
db.exec(`
INSERT INTO users (id, name, email) VALUES ('target', 'Target', 'admin@example.com'), ('other', 'Other', 'other@example.com');
INSERT INTO accounts (id, account_id, user_id, provider_id, password)
VALUES ('oauth', 'remote', 'target', 'github', NULL), ('other-account', 'other', 'other', 'credential', 'unchanged');
INSERT INTO sessions (id, token, user_id, expires_at) VALUES ('one', 'one', 'target', 0), ('two', 'two', 'target', 0), ('other', 'other', 'other', 0);
`);
if (existingCredential) {
db.exec("INSERT INTO accounts (id, account_id, user_id, provider_id, password) VALUES ('credential', 'target', 'target', 'credential', 'old')");
}
const output = run(
[process.execPath, script, "reset-password", "--email=admin@example.com", "--new-password=new-secure-password"],
cwd
);
expect(output).toContain("Password reset for admin@example.com");
expect(output).toContain("Cleared 2 active session(s)");
const credentials = db
.query("SELECT * FROM accounts WHERE user_id = 'target' AND provider_id = 'credential'")
.all() as Array<{ password: string; issuer: string }>;
expect(credentials).toHaveLength(1);
expect(await verifyPassword({ hash: credentials[0].password, password: "new-secure-password" })).toBe(true);
if (!existingCredential) expect(credentials[0].issuer).toBe("local:credential");
expect(db.query("SELECT id FROM sessions").all()).toEqual([{ id: "other" }]);
expect(db.query("SELECT password FROM accounts WHERE id = 'other-account'").get()).toEqual({ password: "unchanged" });
expect(db.query("SELECT password FROM accounts WHERE id = 'oauth'").get()).toEqual({ password: null });
expect(db.query("SELECT id FROM users").all()).toHaveLength(2);
} finally {
db.close();
}
}, 15_000);
}
}
});
+8 -1
View File
@@ -12,7 +12,8 @@ const mockMirrorGitHubReleasesToGitea = mock(() => Promise.resolve());
const mockMirrorGitRepoIssuesToGitea = mock(() => Promise.resolve(undefined));
const mockMirrorGitRepoPullRequestsToGitea = mock(() => Promise.resolve(undefined));
const mockMirrorGitRepoLabelsToGitea = mock(() => Promise.resolve());
const mockMirrorGitRepoMilestonesToGitea = mock(() => Promise.resolve());
const mockMilestoneIds = new Map<string, number>([["v1.0", 7]]);
const mockMirrorGitRepoMilestonesToGitea = mock(() => Promise.resolve(mockMilestoneIds));
const mockSyncRepositoryMetadataToGitea = mock(() => Promise.resolve());
const mockGetGiteaRepoOwnerAsync = mock(() => Promise.resolve("starred"));
const mockCreatePreSyncBundleBackup = mock(() =>
@@ -1576,6 +1577,12 @@ describe("Enhanced Gitea Operations", () => {
expect(mockMirrorGitRepoMilestonesToGitea).toHaveBeenCalledTimes(1);
// Labels should be skipped because issues already import them
expect(mockMirrorGitRepoLabelsToGitea).not.toHaveBeenCalled();
// Milestones run first and their ids reach the issues and pull
// requests passes, which assign each item to its milestone (#460).
const issuesArgs = (mockMirrorGitRepoIssuesToGitea.mock.calls[0] as any[])[0];
const pullsArgs = (mockMirrorGitRepoPullRequestsToGitea.mock.calls[0] as any[])[0];
expect(issuesArgs.milestoneIds).toBe(mockMilestoneIds);
expect(pullsArgs.milestoneIds).toBe(mockMilestoneIds);
});
test("skips issues and PRs when metadata shows they were already synced", async () => {
+41 -36
View File
@@ -42,7 +42,7 @@ import {
strategyNeedsDetection,
} from "./repo-backup";
import { detectForcePush } from "./utils/force-push-detection";
import { sanitizeRepoNameAlphaDashDot } from "./gitea";
import { sanitizeRepoNameAlphaDashDot, type GiteaMilestoneIds } from "./gitea";
import {
parseRepositoryMetadataState,
serializeRepositoryMetadataState,
@@ -1087,6 +1087,44 @@ export async function syncGiteaRepoEnhanced({
}
}
// Milestones go first so the issues and pull requests passes below
// can assign each item to its milestone (#460).
let milestoneIds: GiteaMilestoneIds | undefined;
if (shouldMirrorMilestones) {
const octokit = ensureOctokit();
if (!octokit) {
console.warn(
`[Sync] Skipping milestone mirroring for ${repository.name}: Missing GitHub token`
);
} else {
try {
milestoneIds = await dependencies.mirrorGitRepoMilestonesToGitea({
config,
octokit,
repository,
giteaOwner: repoOwner,
giteaRepoName: repoName,
});
metadataState.components.milestones = true;
metadataUpdated = true;
console.log(
`[Sync] Mirrored milestones for ${repository.name} after sync`
);
} catch (milestoneError) {
// Rate limited: fail the sync so the scheduler restores the row and
// retries after the reset instead of running the next component (#437).
if (isRateLimitError(milestoneError)) throw milestoneError;
console.error(
`[Sync] Failed to mirror milestones for ${repository.name}: ${
milestoneError instanceof Error
? milestoneError.message
: String(milestoneError)
}`
);
}
}
}
if (shouldMirrorIssuesThisRun) {
const octokit = ensureOctokit();
if (!octokit) {
@@ -1098,6 +1136,7 @@ export async function syncGiteaRepoEnhanced({
const issuesCursor = await dependencies.mirrorGitRepoIssuesToGitea({
config,
octokit,
milestoneIds,
repository,
giteaOwner: repoOwner,
giteaRepoName: repoName,
@@ -1141,6 +1180,7 @@ export async function syncGiteaRepoEnhanced({
const pullRequestsCursor = await dependencies.mirrorGitRepoPullRequestsToGitea({
config,
octokit,
milestoneIds,
repository,
giteaOwner: repoOwner,
giteaRepoName: repoName,
@@ -1205,41 +1245,6 @@ export async function syncGiteaRepoEnhanced({
}
}
if (shouldMirrorMilestones) {
const octokit = ensureOctokit();
if (!octokit) {
console.warn(
`[Sync] Skipping milestone mirroring for ${repository.name}: Missing GitHub token`
);
} else {
try {
await dependencies.mirrorGitRepoMilestonesToGitea({
config,
octokit,
repository,
giteaOwner: repoOwner,
giteaRepoName: repoName,
});
metadataState.components.milestones = true;
metadataUpdated = true;
console.log(
`[Sync] Mirrored milestones for ${repository.name} after sync`
);
} catch (milestoneError) {
// Rate limited: fail the sync so the scheduler restores the row and
// retries after the reset instead of running the next component (#437).
if (isRateLimitError(milestoneError)) throw milestoneError;
console.error(
`[Sync] Failed to mirror milestones for ${repository.name}: ${
milestoneError instanceof Error
? milestoneError.message
: String(milestoneError)
}`
);
}
}
}
if (metadataUpdated) {
metadataState.lastSyncedAt = new Date().toISOString();
}
+235 -1
View File
@@ -83,7 +83,9 @@ interface Recorded {
}
/** Destination Gitea with a fixed set of issues already present. */
function fakeGitea(existingIssues: Array<{ number: number; title: string }>) {
function fakeGitea(
existingIssues: Array<{ number: number; title: string; milestone?: { id: number } | null }>
) {
const requests: Recorded[] = [];
let nextNumber = 1000;
@@ -749,3 +751,235 @@ describe.skipIf(!isChild)("mirrorGitRepoPullRequestsToGitea incremental sync", (
expect(github.callsTo("pulls.get").map((c) => c.params.pull_number)).toEqual([5]);
});
});
describe.skipIf(!isChild)("milestone assignment (#460)", () => {
const milestoneIds = new Map<string, number>([
["v1.0", 7],
["v2.0", 8],
]);
const weekOldFullPass = (): MetadataSyncCursor => ({
lastPassStartedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
lastFullPassStartedAt: new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(),
});
const patchesTo = (gitea: ReturnType<typeof fakeGitea>, number: number) =>
gitea.requests
.filter((r) => r.method === "PATCH" && r.url === `${DEST_API}/issues/${number}`)
.map((r) => JSON.parse(r.body!));
const created = (gitea: ReturnType<typeof fakeGitea>) =>
gitea.requests
.filter((r) => r.method === "POST" && r.url === `${DEST_API}/issues`)
.map((r) => JSON.parse(r.body!));
it("creates issues with their milestone", async () => {
const gitea = fakeGitea([]);
const github = fakeOctokit({
issueListing: [ghIssue(1, { milestone: { title: "v1.0" } }), ghIssue(2, { milestone: null })],
});
await mirrorGitRepoIssuesToGitea({
config,
octokit: github.octokit,
repository,
giteaOwner: DEST_OWNER,
giteaRepoName: DEST_REPO,
milestoneIds,
});
const [first, second] = created(gitea);
expect(first.milestone).toBe(7);
expect(second.milestone).toBeUndefined();
});
it("assigns a milestone set on GitHub after the issue was mirrored", async () => {
const gitea = fakeGitea([{ number: 11, title: "[GH-ISSUE #1] Issue 1", milestone: null }]);
const github = fakeOctokit({ issueListing: [ghIssue(1, { milestone: { title: "v2.0" } })] });
await mirrorGitRepoIssuesToGitea({
config,
octokit: github.octokit,
repository,
giteaOwner: DEST_OWNER,
giteaRepoName: DEST_REPO,
syncCursor: recentCursor(),
milestoneIds,
});
expect(patchesTo(gitea, 11).map((p) => p.milestone)).toEqual([8]);
});
it("fixes and clears milestones on unchanged issues during a full pass", async () => {
const gitea = fakeGitea([
// Mirrored before #460: no milestone in Gitea yet.
{ number: 11, title: "[GH-ISSUE #1] Issue 1", milestone: null },
// Milestone removed on GitHub.
{ number: 12, title: "[GH-ISSUE #2] Issue 2", milestone: { id: 7 } },
// Already right: the edit leaves it alone.
{ number: 13, title: "[GH-ISSUE #3] Issue 3", milestone: { id: 8 } },
// On a milestone made in Gitea alone: kept.
{ number: 14, title: "[GH-ISSUE #4] Issue 4", milestone: { id: 99 } },
]);
const github = fakeOctokit({
issueListing: [
ghIssue(1, { milestone: { title: "v1.0" } }),
ghIssue(2, { milestone: null }),
ghIssue(3, { milestone: { title: "v2.0" } }),
ghIssue(4, { milestone: null }),
],
});
await mirrorGitRepoIssuesToGitea({
config,
octokit: github.octokit,
repository,
giteaOwner: DEST_OWNER,
giteaRepoName: DEST_REPO,
syncCursor: weekOldFullPass(),
milestoneIds,
});
// All three are unchanged, so no comment listing runs.
expect(github.callsTo("issues.listComments")).toHaveLength(0);
expect(patchesTo(gitea, 11).map((p) => p.milestone)).toEqual([7]);
expect(patchesTo(gitea, 12).map((p) => p.milestone)).toEqual([0]);
expect(patchesTo(gitea, 13).every((p) => !("milestone" in p))).toBe(true);
expect(patchesTo(gitea, 14).every((p) => !("milestone" in p))).toBe(true);
});
it("leaves milestones alone when they are not mirrored or not found", async () => {
const gitea = fakeGitea([{ number: 11, title: "[GH-ISSUE #1] Issue 1", milestone: { id: 7 } }]);
const github = fakeOctokit({
issueListing: [
ghIssue(1, { milestone: null }),
ghIssue(2, { milestone: { title: "v1.0" } }),
],
});
await mirrorGitRepoIssuesToGitea({
config,
octokit: github.octokit,
repository,
giteaOwner: DEST_OWNER,
giteaRepoName: DEST_REPO,
syncCursor: recentCursor(),
});
expect(patchesTo(gitea, 11).every((p) => !("milestone" in p))).toBe(true);
expect(created(gitea)[0].milestone).toBeUndefined();
// A milestone Gitea does not have is not cleared from the issue either.
const gitea2 = fakeGitea([{ number: 11, title: "[GH-ISSUE #1] Issue 1", milestone: { id: 7 } }]);
await mirrorGitRepoIssuesToGitea({
config,
octokit: fakeOctokit({ issueListing: [ghIssue(1, { milestone: { title: "v9" } })] }).octokit,
repository,
giteaOwner: DEST_OWNER,
giteaRepoName: DEST_REPO,
syncCursor: recentCursor(),
milestoneIds,
});
expect(patchesTo(gitea2, 11).every((p) => !("milestone" in p))).toBe(true);
});
it("assigns milestones to pull requests, new and unchanged", async () => {
const gitea = fakeGitea([{ number: 21, title: "[PR #1] PR 1", milestone: null }]);
const github = fakeOctokit({
issueListing: [],
pullListing: [
ghPull(1, { updated_at: "2026-01-01T00:00:00Z", milestone: { title: "v1.0" } }),
ghPull(2, { milestone: { title: "v2.0" } }),
],
});
await mirrorGitRepoPullRequestsToGitea({
config,
octokit: github.octokit,
repository,
giteaOwner: DEST_OWNER,
giteaRepoName: DEST_REPO,
syncCursor: weekOldFullPass(),
milestoneIds,
});
// #1 is unchanged: no detail calls, only the milestone is patched.
expect(github.callsTo("pulls.get").map((c) => c.params.pull_number)).toEqual([2]);
expect(patchesTo(gitea, 21)).toEqual([{ milestone: 7 }]);
expect(created(gitea).map((c) => c.milestone)).toEqual([8]);
});
it("carries the milestone through an incremental pull request listing", async () => {
const gitea = fakeGitea([{ number: 21, title: "[PR #1] PR 1", milestone: null }]);
const github = fakeOctokit({
issueListing: [
{ ...ghIssue(1), title: "PR 1", pull_request: { merged_at: null }, milestone: { title: "v2.0" } },
],
});
await mirrorGitRepoPullRequestsToGitea({
config,
octokit: github.octokit,
repository,
giteaOwner: DEST_OWNER,
giteaRepoName: DEST_REPO,
syncCursor: recentCursor(),
milestoneIds,
});
expect(patchesTo(gitea, 21).map((p) => p.milestone)).toEqual([8]);
});
it("does not take a mirrored issue that mentions [PR #N] for that pull request", async () => {
const gitea = fakeGitea([
{ number: 21, title: "[PR #1] PR 1", milestone: null },
{ number: 40, title: "[GH-ISSUE #40] Revert [PR #1]", milestone: null },
]);
const github = fakeOctokit({
issueListing: [],
pullListing: [ghPull(1, { updated_at: "2026-01-01T00:00:00Z", milestone: { title: "v1.0" } })],
});
await mirrorGitRepoPullRequestsToGitea({
config,
octokit: github.octokit,
repository,
giteaOwner: DEST_OWNER,
giteaRepoName: DEST_REPO,
syncCursor: weekOldFullPass(),
milestoneIds,
});
expect(patchesTo(gitea, 21)).toEqual([{ milestone: 7 }]);
expect(patchesTo(gitea, 40)).toEqual([]);
});
it("finishes the pass when the milestone edit of an unchanged pull request fails", async () => {
fakeGitea([{ number: 21, title: "[PR #1] PR 1", milestone: null }]);
let milestoneEdits = 0;
globalThis.fetch = ((original) =>
(async (input: any, init?: any) => {
const url = typeof input === "string" ? input : String(input);
if (String(init?.method ?? "GET").toUpperCase() === "PATCH" && url === `${DEST_API}/issues/21`) {
milestoneEdits += 1;
return new Response("boom", { status: 500 });
}
return original(input, init);
}) as unknown as typeof fetch)(globalThis.fetch);
const github = fakeOctokit({
issueListing: [],
pullListing: [ghPull(1, { updated_at: "2026-01-01T00:00:00Z", milestone: { title: "v1.0" } })],
});
const cursor = await mirrorGitRepoPullRequestsToGitea({
config,
octokit: github.octokit,
repository,
giteaOwner: DEST_OWNER,
giteaRepoName: DEST_REPO,
syncCursor: weekOldFullPass(),
milestoneIds,
});
// Tried once, not retried, and the pass still completes.
expect(milestoneEdits).toBe(1);
expect(cursor).toBeDefined();
});
});
+91 -1
View File
@@ -14,7 +14,13 @@
*/
import { describe, test, expect } from "bun:test";
import { buildGiteaIssueEditPayload, buildGiteaIssueLabelsPayload } from "@/lib/gitea";
import {
buildGiteaIssueEditPayload,
buildGiteaIssueLabelsPayload,
buildGiteaMilestoneEditPayload,
giteaMilestoneEdit,
resolveGiteaMilestoneId,
} from "@/lib/gitea";
describe("buildGiteaIssueEditPayload (#334 sibling)", () => {
test("edit body never carries `labels` (Gitea's EditIssueOption ignores it)", () => {
@@ -37,6 +43,90 @@ describe("buildGiteaIssueEditPayload (#334 sibling)", () => {
});
});
describe("issue milestones (#460)", () => {
const ids = new Map([["v1.0", 7]]);
test("the edit body carries a milestone only when one is given", () => {
expect(buildGiteaIssueEditPayload({ title: "t", body: "b", closed: false })).not.toHaveProperty(
"milestone"
);
expect(
buildGiteaIssueEditPayload({ title: "t", body: "b", closed: false, milestone: 7 }).milestone
).toBe(7);
// 0 is how Gitea clears a milestone, so it has to survive.
expect(
buildGiteaIssueEditPayload({ title: "t", body: "b", closed: false, milestone: 0 }).milestone
).toBe(0);
});
test("resolves the Gitea id by title, 0 for none, undefined when unknown", () => {
expect(resolveGiteaMilestoneId(ids, { title: "v1.0" })).toBe(7);
expect(resolveGiteaMilestoneId(ids, null)).toBe(0);
expect(resolveGiteaMilestoneId(ids, { title: "missing" })).toBeUndefined();
expect(resolveGiteaMilestoneId(undefined, { title: "v1.0" })).toBeUndefined();
expect(resolveGiteaMilestoneId(undefined, null)).toBeUndefined();
});
test("only edits when the issue carries a different milestone", () => {
expect(giteaMilestoneEdit({ milestone: null }, ids, { title: "v1.0" })).toBe(7);
expect(giteaMilestoneEdit({ milestone: { id: 7 } }, ids, { title: "v1.0" })).toBeUndefined();
expect(giteaMilestoneEdit({ milestone: { id: 7 } }, ids, null)).toBe(0);
expect(giteaMilestoneEdit({}, ids, null)).toBeUndefined();
expect(giteaMilestoneEdit({ milestone: { id: 7 } }, undefined, null)).toBeUndefined();
});
test("never clears a milestone made in Gitea alone", () => {
// 42 is not in the map: no GitHub milestone has its title.
expect(giteaMilestoneEdit({ milestone: { id: 42 } }, ids, null)).toBeUndefined();
// GitHub does name a milestone, so it wins over the Gitea-only one.
expect(giteaMilestoneEdit({ milestone: { id: 42 } }, ids, { title: "v1.0" })).toBe(7);
});
});
describe("buildGiteaMilestoneEditPayload (#460 follow-up)", () => {
test("no edit when state, due day and description match", () => {
expect(
buildGiteaMilestoneEditPayload(
// Created from GitHub: Gitea keeps the instant, shown in its own zone.
{ state: "open", due_on: "2026-11-01T00:00:00-07:00", description: "d" },
{ state: "open", due_on: "2026-11-01T07:00:00Z", description: "d" }
)
).toBeNull();
expect(
buildGiteaMilestoneEditPayload(
// Edited through the API: moved to the end of the day.
{ state: "closed", due_on: "2026-12-15T23:59:59+09:00", description: null },
{ state: "closed", due_on: "2026-12-15T08:00:00Z", description: null }
)
).toBeNull();
// Year 9999 is how Gitea stores no deadline.
expect(
buildGiteaMilestoneEditPayload(
{ state: "open", due_on: "9999-12-31T23:59:59-08:00", description: "" },
{ state: "open", due_on: null, description: "" }
)
).toBeNull();
});
test("carries over a close, a due date and a description", () => {
expect(
buildGiteaMilestoneEditPayload(
{ state: "open", due_on: null, description: "old" },
{ state: "closed", due_on: "2026-12-15T08:00:00Z", description: "new" }
)
).toEqual({ state: "closed", due_on: "2026-12-15T12:00:00Z", description: "new" });
});
test("clears a removed due date with Gitea's no-deadline value", () => {
expect(
buildGiteaMilestoneEditPayload(
{ state: "open", due_on: "2026-11-01T00:00:00-07:00", description: "" },
{ state: "open", due_on: null, description: "" }
)
).toEqual({ due_on: "9999-12-31T00:00:00Z" });
});
});
describe("buildGiteaIssueLabelsPayload (#334 sibling)", () => {
test("replaces the full label set with the resolved Gitea label ids", () => {
expect(buildGiteaIssueLabelsPayload([7, 9])).toEqual({ labels: [7, 9] });
+2 -1
View File
@@ -146,7 +146,8 @@ describe("milestone dedup on sync", () => {
// Defensive: if `milestones` ever contains a same-named entry
// twice (unlikely but cheap to guard), we shouldn't POST it twice.
expect(
/existingMilestones\.add\(\s*milestone\.title\s*\)/.test(body),
// A map since #460 (title to id, for issue assignment).
/existingMilestones\.set\(\s*milestone\.title\b/.test(body),
"after a successful create, the new milestone title must be added to existingMilestones"
).toBe(true);
});
+306 -82
View File
@@ -1178,6 +1178,43 @@ export const mirrorGithubRepoToGitea = async ({
}
}
// Milestones go first so the issues and pull requests passes below
// can assign each item to its milestone (#460).
let milestoneIds: GiteaMilestoneIds | undefined;
const shouldMirrorMilestones = mirrorOptions.mirrorMilestones && octokit !== null;
console.log(
`[Metadata] Milestone mirroring check: mirrorMilestones=${mirrorOptions.mirrorMilestones}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorMilestones=${shouldMirrorMilestones}`
);
if (shouldMirrorMilestones) {
try {
milestoneIds = await mirrorGitRepoMilestonesToGitea({
config,
octokit,
repository,
giteaOwner: repoOwner,
giteaRepoName: targetRepoName,
});
metadataState.components.milestones = true;
metadataUpdated = true;
console.log(
`[Metadata] Successfully mirrored milestones for ${repository.name}`
);
} catch (error) {
// A rate limit is not this repository's fault and the other
// components would run into the same wall: fail the mirror so the
// scheduler puts the row back and retries after the reset (#437).
if (isRateLimitError(error)) throw error;
console.error(
`[Metadata] Failed to mirror milestones for ${repository.name}: ${
error instanceof Error ? error.message : String(error)
}`
);
// Continue with other metadata operations even if milestones fail
}
}
// Reconcile metadata on every sync (matches the release path above).
// The underlying mirror* functions are idempotent: issues/PRs are
// matched via [GH-ISSUE #N] / [GH-PR #N] markers and PATCHed in place,
@@ -1193,6 +1230,7 @@ export const mirrorGithubRepoToGitea = async ({
const issuesCursor = await mirrorGitRepoIssuesToGitea({
config,
octokit,
milestoneIds,
repository,
giteaOwner: repoOwner,
giteaRepoName: targetRepoName,
@@ -1235,6 +1273,7 @@ export const mirrorGithubRepoToGitea = async ({
const pullRequestsCursor = await mirrorGitRepoPullRequestsToGitea({
config,
octokit,
milestoneIds,
repository,
giteaOwner: repoOwner,
giteaRepoName: targetRepoName,
@@ -1301,40 +1340,6 @@ export const mirrorGithubRepoToGitea = async ({
}
}
const shouldMirrorMilestones = mirrorOptions.mirrorMilestones && octokit !== null;
console.log(
`[Metadata] Milestone mirroring check: mirrorMilestones=${mirrorOptions.mirrorMilestones}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorMilestones=${shouldMirrorMilestones}`
);
if (shouldMirrorMilestones) {
try {
await mirrorGitRepoMilestonesToGitea({
config,
octokit,
repository,
giteaOwner: repoOwner,
giteaRepoName: targetRepoName,
});
metadataState.components.milestones = true;
metadataUpdated = true;
console.log(
`[Metadata] Successfully mirrored milestones for ${repository.name}`
);
} catch (error) {
// A rate limit is not this repository's fault and the other
// components would run into the same wall: fail the mirror so the
// scheduler puts the row back and retries after the reset (#437).
if (isRateLimitError(error)) throw error;
console.error(
`[Metadata] Failed to mirror milestones for ${repository.name}: ${
error instanceof Error ? error.message : String(error)
}`
);
// Continue with other metadata operations even if milestones fail
}
}
if (metadataUpdated) {
metadataState.lastSyncedAt = new Date().toISOString();
}
@@ -1986,6 +1991,43 @@ export async function mirrorGitHubRepoToGiteaOrg({
}
}
// Milestones go first so the issues and pull requests passes below
// can assign each item to its milestone (#460).
let milestoneIds: GiteaMilestoneIds | undefined;
const shouldMirrorMilestones = mirrorOptions.mirrorMilestones && octokit !== null;
console.log(
`[Metadata] Milestone mirroring check: mirrorMilestones=${mirrorOptions.mirrorMilestones}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorMilestones=${shouldMirrorMilestones}`
);
if (shouldMirrorMilestones) {
try {
milestoneIds = await mirrorGitRepoMilestonesToGitea({
config,
octokit,
repository,
giteaOwner: orgName,
giteaRepoName: targetRepoName,
});
metadataState.components.milestones = true;
metadataUpdated = true;
console.log(
`[Metadata] Successfully mirrored milestones for ${repository.name} to org ${orgName}/${targetRepoName}`
);
} catch (error) {
// A rate limit is not this repository's fault and the other
// components would run into the same wall: fail the mirror so the
// scheduler puts the row back and retries after the reset (#437).
if (isRateLimitError(error)) throw error;
console.error(
`[Metadata] Failed to mirror milestones for ${repository.name} to org ${orgName}/${targetRepoName}: ${
error instanceof Error ? error.message : String(error)
}`
);
// Continue with other metadata operations even if milestones fail
}
}
// Reconcile metadata on every sync. See note in mirrorGithubRepoToGitea
// above. The underlying mirror* functions are idempotent.
const shouldMirrorIssuesThisRun = mirrorOptions.mirrorIssues && octokit !== null;
@@ -1999,6 +2041,7 @@ export async function mirrorGitHubRepoToGiteaOrg({
const issuesCursor = await mirrorGitRepoIssuesToGitea({
config,
octokit,
milestoneIds,
repository,
giteaOwner: orgName,
giteaRepoName: targetRepoName,
@@ -2041,6 +2084,7 @@ export async function mirrorGitHubRepoToGiteaOrg({
const pullRequestsCursor = await mirrorGitRepoPullRequestsToGitea({
config,
octokit,
milestoneIds,
repository,
giteaOwner: orgName,
giteaRepoName: targetRepoName,
@@ -2107,40 +2151,6 @@ export async function mirrorGitHubRepoToGiteaOrg({
}
}
const shouldMirrorMilestones = mirrorOptions.mirrorMilestones && octokit !== null;
console.log(
`[Metadata] Milestone mirroring check: mirrorMilestones=${mirrorOptions.mirrorMilestones}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorMilestones=${shouldMirrorMilestones}`
);
if (shouldMirrorMilestones) {
try {
await mirrorGitRepoMilestonesToGitea({
config,
octokit,
repository,
giteaOwner: orgName,
giteaRepoName: targetRepoName,
});
metadataState.components.milestones = true;
metadataUpdated = true;
console.log(
`[Metadata] Successfully mirrored milestones for ${repository.name} to org ${orgName}/${targetRepoName}`
);
} catch (error) {
// A rate limit is not this repository's fault and the other
// components would run into the same wall: fail the mirror so the
// scheduler puts the row back and retries after the reset (#437).
if (isRateLimitError(error)) throw error;
console.error(
`[Metadata] Failed to mirror milestones for ${repository.name} to org ${orgName}/${targetRepoName}: ${
error instanceof Error ? error.message : String(error)
}`
);
// Continue with other metadata operations even if milestones fail
}
}
if (metadataUpdated) {
metadataState.lastSyncedAt = new Date().toISOString();
}
@@ -2616,8 +2626,73 @@ 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" };
/** Gitea milestone id to set, 0 to clear it, undefined to leave it alone. */
milestone?: number;
}): { title: string; body: string; state: "open" | "closed"; milestone?: number } {
const payload: { title: string; body: string; state: "open" | "closed"; milestone?: number } = {
title: opts.title,
body: opts.body,
state: opts.closed ? "closed" : "open",
};
if (opts.milestone !== undefined) payload.milestone = opts.milestone;
return payload;
}
/**
* Gitea milestone ids by title for the milestones that exist on GitHub, as
* returned by mirrorGitRepoMilestonesToGitea. Milestones made in Gitea
* alone are not in it.
*/
export type GiteaMilestoneIds = Map<string, number>;
/**
* The Gitea milestone a mirrored issue or pull request should carry (#460):
* the id of the milestone with the same title, 0 when the GitHub item has
* none, or undefined when it cannot be told (milestones are not mirrored
* for this repository, or this one is missing in Gitea). Undefined means
* leave the Gitea side as it is.
*/
export function resolveGiteaMilestoneId(
milestoneIds: GiteaMilestoneIds | undefined,
githubMilestone: { title?: string | null } | null | undefined
): number | undefined {
if (!milestoneIds) return undefined;
const title = githubMilestone?.title;
if (!title) return 0;
const id = milestoneIds.get(title);
return typeof id === "number" && id > 0 ? id : undefined;
}
/**
* The milestone value to send when editing an existing Gitea issue: the
* wanted id when it differs from what the issue carries, otherwise
* undefined so the edit does not touch it (and Gitea writes no timeline
* entry). A milestone made in Gitea alone is never cleared, only one that
* mirrors a GitHub milestone.
*/
export function giteaMilestoneEdit(
existingIssue: { milestone?: { id?: number } | null } | null | undefined,
milestoneIds: GiteaMilestoneIds | undefined,
githubMilestone: { title?: string | null } | null | undefined
): number | undefined {
const wanted = resolveGiteaMilestoneId(milestoneIds, githubMilestone);
if (wanted === undefined) return undefined;
const current = existingIssue?.milestone?.id ?? 0;
if (current === wanted) return undefined;
if (wanted === 0 && ![...milestoneIds!.values()].includes(current)) return undefined;
return wanted;
}
/**
* Record a milestone edit on the cached Gitea issue, so a retry of the same
* item does not send it again.
*/
function rememberGiteaMilestone(
existingIssue: { milestone?: { id?: number } | null } | null | undefined,
milestone: number | undefined
) {
if (!existingIssue || milestone === undefined) return;
existingIssue.milestone = milestone ? { id: milestone } : null;
}
/**
@@ -2683,12 +2758,18 @@ export const mirrorGitRepoIssuesToGitea = async ({
syncCursor,
passProgress,
onPassProgress,
milestoneIds,
}: {
config: Partial<Config>;
octokit: Octokit;
repository: Repository;
giteaOwner: string;
giteaRepoName?: string;
/**
* Gitea milestone ids by title from the milestones pass that ran just
* before (#460). Without it the issues' milestones are left untouched.
*/
milestoneIds?: GiteaMilestoneIds;
/**
* Watermark from the previous completed issues pass (#449). When set,
* only issues updated since then are listed and reconciled.
@@ -3004,12 +3085,14 @@ export const mirrorGitRepoIssuesToGitea = async ({
const mirroredTitle = `${issueMarker} ${issue.title}`;
const issueBody = `${issueOriginHeader}\nOriginal GitHub issue: ${issue.html_url}${originalAssignees}\n\n${issue.body ?? ""}`;
const wantedMilestoneId = resolveGiteaMilestoneId(milestoneIds, issue.milestone);
const issuePayload: any = {
title: mirroredTitle,
body: issueBody,
closed: issue.state === "closed",
labels: giteaLabelIds,
};
if (wantedMilestoneId) issuePayload.milestone = wantedMilestoneId;
let existingIssue = giteaIssueByGitHubNumber.get(issue.number);
if (!existingIssue) {
@@ -3040,10 +3123,12 @@ export const mirrorGitRepoIssuesToGitea = async ({
// comment listing, which is the per-issue GitHub call.
if (itemClass === "unchanged" && existingIssue) {
const wantClosed = issue.state === "closed";
const milestoneChange = giteaMilestoneEdit(existingIssue, milestoneIds, issue.milestone);
if (
existingIssue.title !== issuePayload.title ||
String(existingIssue.body ?? "") !== issuePayload.body ||
(existingIssue.state === "closed") !== wantClosed
(existingIssue.state === "closed") !== wantClosed ||
milestoneChange !== undefined
) {
await httpPatch(
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${existingIssue.number}`,
@@ -3051,11 +3136,13 @@ export const mirrorGitRepoIssuesToGitea = async ({
title: issuePayload.title,
body: issuePayload.body,
closed: wantClosed,
milestone: milestoneChange,
}),
{
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
}
);
rememberGiteaMilestone(existingIssue, milestoneChange);
}
const currentLabelIds = (Array.isArray(existingIssue.labels) ? existingIssue.labels : [])
.map((label: any) => Number(label?.id))
@@ -3079,17 +3166,20 @@ export const mirrorGitRepoIssuesToGitea = async ({
let targetIssueNumber: number;
if (existingIssue) {
targetIssueNumber = existingIssue.number;
const milestoneChange = giteaMilestoneEdit(existingIssue, milestoneIds, issue.milestone);
await httpPatch(
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${targetIssueNumber}`,
buildGiteaIssueEditPayload({
title: issuePayload.title,
body: issuePayload.body,
closed: issue.state === "closed",
milestone: milestoneChange,
}),
{
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
}
);
rememberGiteaMilestone(existingIssue, milestoneChange);
} else {
// Defensive recheck before create: a previous retry attempt may
// have already created this issue and then thrown. The common
@@ -3128,17 +3218,20 @@ export const mirrorGitRepoIssuesToGitea = async ({
console.log(
`[Issues] Recovered orphan from prior failed attempt for #${issue.number}; switching to PATCH`
);
const milestoneChange = giteaMilestoneEdit(recheckHit, milestoneIds, issue.milestone);
await httpPatch(
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${targetIssueNumber}`,
buildGiteaIssueEditPayload({
title: issuePayload.title,
body: issuePayload.body,
closed: issue.state === "closed",
milestone: milestoneChange,
}),
{
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
}
);
rememberGiteaMilestone(recheckHit, milestoneChange);
} else {
const createdIssue = await httpPost(
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues`,
@@ -4226,12 +4319,15 @@ export async function mirrorGitRepoPullRequestsToGitea({
syncCursor,
passProgress,
onPassProgress,
milestoneIds,
}: {
config: Partial<Config>;
octokit: Octokit;
repository: Repository;
giteaOwner: string;
giteaRepoName?: string;
/** Gitea milestone ids by title; see the issues pass (#460). */
milestoneIds?: GiteaMilestoneIds;
/**
* Watermark from the previous completed pull request pass (#449).
* When set, only pull requests updated since then are processed.
@@ -4355,6 +4451,7 @@ export async function mirrorGitRepoPullRequestsToGitea({
created_at: entry.created_at,
updated_at: entry.updated_at,
merged_at: (entry as any).pull_request?.merged_at ?? null,
milestone: entry.milestone ?? null,
}) as unknown as ListedPullRequest
);
};
@@ -4443,7 +4540,7 @@ export async function mirrorGitRepoPullRequestsToGitea({
if (!pageIssues.length) break;
for (const issue of pageIssues) {
const match = String(issue.title || "").match(/\[PR #(\d+)\]/i);
const match = String(issue.title || "").match(/^\[PR #(\d+)\]/i);
if (!match?.[1]) continue;
const prNumber = Number.parseInt(match[1], 10);
if (Number.isFinite(prNumber)) {
@@ -4517,10 +4614,38 @@ export async function mirrorGitRepoPullRequestsToGitea({
existsInDestination: existingPrIssuesByNumber.has(listedPr.number),
});
if (itemClass !== "sync") {
if (itemClass === "unchanged") unchangedCount++;
if (itemClass === "unchanged") {
unchangedCount++;
// The listing carries the milestone, so a change on the Gitea
// side (or an issue mirrored before #460) is fixed without the
// detail calls. Best effort: a failed edit is retried by the
// next full pass and does not hold this one back.
const existing = existingPrIssuesByNumber.get(listedPr.number);
const milestoneChange = giteaMilestoneEdit(existing, milestoneIds, listedPr.milestone);
if (existing && milestoneChange !== undefined) {
try {
await httpPatch(
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${existing.number}`,
{ milestone: milestoneChange },
{
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
}
);
rememberGiteaMilestone(existing, milestoneChange);
} catch (milestoneError) {
if (isRateLimitError(milestoneError)) throw milestoneError;
console.error(
`[Pull Requests] Failed to set the milestone for PR #${listedPr.number}: ${
milestoneError instanceof Error ? milestoneError.message : String(milestoneError)
}`
);
}
}
}
completedNumbers.add(listedPr.number);
return;
}
const wantedMilestoneId = resolveGiteaMilestoneId(milestoneIds, listedPr.milestone);
try {
// Fetch additional PR data for rich metadata
const [prDetail, commits, files] = await Promise.all([
@@ -4601,6 +4726,7 @@ export async function mirrorGitRepoPullRequestsToGitea({
body: richBody,
labels: pullRequestLabelId ? [pullRequestLabelId] : [],
closed: pr.state === "closed" || pr.merged_at !== null,
...(wantedMilestoneId ? { milestone: wantedMilestoneId } : {}),
};
let existingPrIssue = existingPrIssuesByNumber.get(pr.number);
@@ -4619,7 +4745,7 @@ export async function mirrorGitRepoPullRequestsToGitea({
);
const candidates = Array.isArray(recheck.data) ? recheck.data : [];
const hit = candidates.find((c: any) => {
const m = String(c.title || "").match(/\[PR #(\d+)\]/i);
const m = String(c.title || "").match(/^\[PR #(\d+)\]/i);
return m && Number.parseInt(m[1], 10) === pr.number;
});
if (hit) {
@@ -4634,17 +4760,20 @@ export async function mirrorGitRepoPullRequestsToGitea({
}
}
if (existingPrIssue) {
const milestoneChange = giteaMilestoneEdit(existingPrIssue, milestoneIds, listedPr.milestone);
await httpPatch(
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${existingPrIssue.number}`,
buildGiteaIssueEditPayload({
title: issueData.title,
body: issueData.body,
closed: issueData.closed,
milestone: milestoneChange,
}),
{
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
}
);
rememberGiteaMilestone(existingPrIssue, milestoneChange);
} else {
console.log(`[Pull Requests] Creating enriched issue for PR #${pr.number}: ${pr.title}`);
const createdPrIssue = await httpPost(
@@ -4707,6 +4836,7 @@ export async function mirrorGitRepoPullRequestsToGitea({
body: `**Original Pull Request:** ${pr.html_url}\n\n**State:** ${pr.state}\n**Merged:** ${pr.merged_at ? 'Yes' : 'No'}\n\n---\n\n${pr.body || 'No description provided'}`,
labels: pullRequestLabelId ? [pullRequestLabelId] : [],
closed: pr.state === "closed" || pr.merged_at !== null,
...(wantedMilestoneId ? { milestone: wantedMilestoneId } : {}),
};
try {
@@ -4724,7 +4854,7 @@ export async function mirrorGitRepoPullRequestsToGitea({
);
const candidates = Array.isArray(recheck.data) ? recheck.data : [];
const hit = candidates.find((c: any) => {
const m = String(c.title || "").match(/\[PR #(\d+)\]/i);
const m = String(c.title || "").match(/^\[PR #(\d+)\]/i);
return m && Number.parseInt(m[1], 10) === pr.number;
});
if (hit) {
@@ -4739,17 +4869,20 @@ export async function mirrorGitRepoPullRequestsToGitea({
}
}
if (existingPrIssue) {
const milestoneChange = giteaMilestoneEdit(existingPrIssue, milestoneIds, listedPr.milestone);
await httpPatch(
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${existingPrIssue.number}`,
buildGiteaIssueEditPayload({
title: basicIssueData.title,
body: basicIssueData.body,
closed: basicIssueData.closed,
milestone: milestoneChange,
}),
{
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
}
);
rememberGiteaMilestone(existingPrIssue, milestoneChange);
} else {
const createdBasicPrIssue = await httpPost(
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues`,
@@ -4979,6 +5112,56 @@ export async function mirrorGitRepoLabelsToGitea({
console.log(`✅ Mirrored ${mirroredCount} new labels to Gitea`);
}
/**
* Gitea treats a deadline in year 9999 as none, and its edit endpoint
* ignores an empty `due_on`, so this is how a removed due date is cleared.
*/
const GITEA_NO_MILESTONE_DEADLINE = "9999-12-31T00:00:00Z";
/**
* A milestone due date as a calendar day. Gitea moves an edited deadline
* to the end of that day in its own time zone and reports it with that
* offset, so the date part of what it returns is the day it holds. A year
* of 9999 is how it stores no deadline.
*/
function giteaMilestoneDueDay(value: string | null | undefined): string | null {
const day = /^(\d{4})-\d{2}-\d{2}/.exec(value ?? "");
if (!day || Number(day[1]) >= 9999) return null;
return day[0];
}
/** GitHub due dates are instants in UTC; the day is their UTC date. */
function githubMilestoneDueDay(value: string | null | undefined): string | null {
const ms = value ? Date.parse(value) : NaN;
return Number.isFinite(ms) ? new Date(ms).toISOString().slice(0, 10) : null;
}
/**
* The edit that brings an already mirrored Gitea milestone in line with
* its GitHub milestone (state, due date, description), or null when they
* already match.
*/
export function buildGiteaMilestoneEditPayload(
gitea: { state?: string | null; due_on?: string | null; description?: string | null },
github: { state?: string | null; due_on?: string | null; description?: string | null }
): { state?: string; due_on?: string; description?: string } | null {
const edit: { state?: string; due_on?: string; description?: string } = {};
const wantState = github.state === "closed" ? "closed" : "open";
if ((gitea.state === "closed" ? "closed" : "open") !== wantState) edit.state = wantState;
const wantDay = githubMilestoneDueDay(github.due_on);
if (wantDay !== giteaMilestoneDueDay(gitea.due_on)) {
// Noon UTC lands on the same day in any server time zone within 11
// hours of UTC, which is the day Gitea then keeps.
edit.due_on = wantDay ? `${wantDay}T12:00:00Z` : GITEA_NO_MILESTONE_DEADLINE;
}
const wantDescription = github.description ?? "";
if ((gitea.description ?? "") !== wantDescription) edit.description = wantDescription;
return Object.keys(edit).length > 0 ? edit : null;
}
export async function mirrorGitRepoMilestonesToGitea({
config,
octokit,
@@ -4991,7 +5174,7 @@ export async function mirrorGitRepoMilestonesToGitea({
repository: Repository;
giteaOwner: string;
giteaRepoName?: string;
}) {
}): Promise<GiteaMilestoneIds> {
// The source client arrives as `octokit`; see mirrorGitRepoIssuesToGitea.
if (!config.giteaConfig?.token || !config.giteaConfig?.url) {
throw new Error("Missing destination configuration.");
@@ -5034,7 +5217,7 @@ export async function mirrorGitRepoMilestonesToGitea({
if (milestones.length === 0) {
console.log(`No milestones to mirror for ${repository.fullName}`);
return;
return new Map();
}
// Get existing milestones from Gitea. Two correctness requirements:
@@ -5054,7 +5237,12 @@ export async function mirrorGitRepoMilestonesToGitea({
// page 1 and re-POSTs every milestone past index 50 on every sync.
// (Repro: post-fix deploy on Subnet-Calculator leaked 9 unique
// milestones past page 1 of a 74-row /milestones response.)
const existingMilestones = new Set<string>();
// Title to Gitea id, so issues and pull requests can be assigned to
// their milestone afterwards (#460). The first id seen for a title
// wins when older syncs left duplicates behind.
const existingMilestones: GiteaMilestoneIds = new Map();
// The Gitea milestone behind each id above, to bring it in line below.
const giteaMilestoneByTitle = new Map<string, any>();
const milestonesPerPage = 50;
let milestonesPage = 1;
let milestonesFetched = 0;
@@ -5069,7 +5257,12 @@ export async function mirrorGitRepoMilestonesToGitea({
? giteaMilestonesRes.data
: [];
if (!pageMilestones.length) break;
for (const ms of pageMilestones) existingMilestones.add(ms.title);
for (const ms of pageMilestones) {
if (!existingMilestones.has(ms.title)) {
existingMilestones.set(ms.title, ms.id);
giteaMilestoneByTitle.set(ms.title, ms);
}
}
milestonesFetched += pageMilestones.length;
const linkHeader = giteaMilestonesRes.headers.get("link") || "";
@@ -5087,10 +5280,34 @@ export async function mirrorGitRepoMilestonesToGitea({
}
let mirroredCount = 0;
let updatedCount = 0;
for (const milestone of milestones) {
const giteaMilestone = giteaMilestoneByTitle.get(milestone.title);
if (giteaMilestone) {
// Already mirrored: carry over a close, reopen, due date or
// description change made on GitHub since.
const edit = buildGiteaMilestoneEditPayload(giteaMilestone, milestone);
if (!edit) continue;
try {
await httpPatch(
`${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repoName}/milestones/${giteaMilestone.id}`,
edit,
{
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
}
);
updatedCount++;
} catch (error) {
if (isRateLimitError(error)) throw error;
console.error(
`Failed to update milestone "${milestone.title}": ${error instanceof Error ? error.message : String(error)}`
);
}
continue;
}
if (!existingMilestones.has(milestone.title)) {
try {
await httpPost(
const created = await httpPost(
`${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repoName}/milestones`,
{
title: milestone.title,
@@ -5104,7 +5321,8 @@ export async function mirrorGitRepoMilestonesToGitea({
);
// Track locally so a duplicate within `milestones` (shouldn't
// happen, but defensive) doesn't trigger a second POST.
existingMilestones.add(milestone.title);
const createdId = created.data?.id;
existingMilestones.set(milestone.title, typeof createdId === "number" ? createdId : 0);
mirroredCount++;
} catch (error) {
console.error(
@@ -5114,7 +5332,13 @@ export async function mirrorGitRepoMilestonesToGitea({
}
}
console.log(`✅ Mirrored ${mirroredCount} new milestones to Gitea`);
console.log(`✅ Mirrored ${mirroredCount} new milestones to Gitea, updated ${updatedCount}`);
// Only the milestones that mirror a GitHub one: a milestone made in
// Gitea alone is never assigned or cleared by the issues passes.
const githubTitles = new Set(milestones.map((m) => m.title));
return new Map(
[...existingMilestones].filter(([title, id]) => githubTitles.has(title) && id > 0)
);
}
/**
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, test } from "bun:test";
import { shutdownExitCode } from "./shutdown-manager";
describe("shutdownExitCode (#465)", () => {
test("a crash-driven shutdown exits non-zero so Restart=on-failure restarts the service", () => {
expect(shutdownExitCode("UNCAUGHT_EXCEPTION")).toBe(1);
expect(shutdownExitCode("UNHANDLED_REJECTION")).toBe(1);
});
test("a signal-driven stop is a clean exit", () => {
expect(shutdownExitCode("SIGTERM")).toBe(0);
expect(shutdownExitCode("SIGINT")).toBe(0);
expect(shutdownExitCode("SIGHUP")).toBe(0);
});
});
+9 -2
View File
@@ -147,6 +147,11 @@ async function executeShutdownCallbacks(): Promise<void> {
/**
* Perform graceful shutdown of the application
*/
/** Exit status for a completed shutdown: 1 after a crash, 0 after a signal. */
export function shutdownExitCode(signal: string): number {
return signal === 'UNCAUGHT_EXCEPTION' || signal === 'UNHANDLED_REJECTION' ? 1 : 0;
}
export async function gracefulShutdown(signal: string = 'UNKNOWN'): Promise<void> {
if (shutdownInProgress) {
console.log('⚠️ Shutdown already in progress, ignoring additional signal');
@@ -187,8 +192,10 @@ export async function gracefulShutdown(signal: string = 'UNKNOWN'): Promise<void
shutdownTimeout = null;
}
// Exit with success code
process.exit(0);
// A shutdown forced by a crash exits non-zero so a supervisor with
// Restart=on-failure (the Proxmox script's unit) starts the service
// again; a signal-driven stop is a clean exit (#465).
process.exit(shutdownExitCode(signal));
} catch (error) {
console.error('\n❌ Error during graceful shutdown:', error);
+85
View File
@@ -0,0 +1,85 @@
/**
* Regression test for #465: a client disconnect took the whole server down.
*
* The node adapter cancels the stream reader when the socket closes and then
* fires the request's abort signal. On Bun 1.4 the cancel has already closed
* the stream, so an unguarded controller.close() in the abort listener throws
* ERR_INVALID_STATE as an uncaught exception, and the uncaught-exception
* handler shut the process down. Both SSE routes are covered.
*
* The routes import @/lib/auth-guards and @/lib/events, replaced here with
* mock.module, which is process wide in bun, so the suite runs in an isolated
* child process (same harness as the other route tests).
*/
import { describe, expect, mock, test } from "bun:test";
const CHILD_FLAG = "GM_SSE_ROUTES_ISOLATED";
const isChild = !!process.env[CHILD_FLAG];
if (!isChild) {
test("SSE routes - isolated child suite", () => {
const res = Bun.spawnSync({
cmd: [process.execPath, "test", import.meta.path],
env: { ...process.env, [CHILD_FLAG]: "1" },
stdout: "pipe",
stderr: "pipe",
});
if (res.exitCode !== 0) {
console.error(res.stdout.toString());
console.error(res.stderr.toString());
}
expect(res.exitCode).toBe(0);
}, 60_000);
}
if (isChild) {
mock.module("@/lib/auth-guards", () => ({
requireAuthenticatedUserId: async () => ({ userId: "user-1" }),
}));
mock.module("@/lib/events", () => ({
getNewEvents: async () => [],
}));
}
const routes = isChild
? {
events: (await import("./index")).GET,
sse: (await import("../sse/index")).GET,
}
: ({} as Record<string, any>);
/**
* Opens the stream, then closes it the way the node adapter does on a
* disconnect: the reader is cancelled first, the abort signal fires second.
*/
async function disconnect(route: any) {
const abort = new AbortController();
const request = new Request("http://localhost/api/x", { signal: abort.signal });
const response = await route({ request, locals: {} });
const reader = response.body.getReader();
const first = await reader.read();
expect(new TextDecoder().decode(first.value)).toContain(": connected");
const uncaught: unknown[] = [];
const record = (error: unknown) => uncaught.push(error);
process.on("uncaughtException", record);
try {
await reader.cancel();
abort.abort();
await new Promise((resolve) => setTimeout(resolve, 20));
} finally {
process.off("uncaughtException", record);
}
return uncaught;
}
describe.skipIf(!isChild)("SSE routes survive a client disconnect (#465)", () => {
test("/api/events", async () => {
expect(await disconnect(routes.events)).toEqual([]);
});
test("/api/sse", async () => {
expect(await disconnect(routes.sse)).toEqual([]);
});
});
+22 -3
View File
@@ -8,6 +8,7 @@ export const GET: APIRoute = async ({ request, locals }) => {
const userId = authResult.userId;
// Create a new ReadableStream for SSE
let cancelStream: (() => void) | undefined;
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
@@ -47,12 +48,30 @@ export const GET: APIRoute = async ({ request, locals }) => {
}
}, 30000);
// Cleanup on close
request.signal.addEventListener("abort", () => {
// Cleanup on close. When the client disconnects, the node adapter
// cancels the stream reader and then fires this abort; on Bun 1.4 the
// cancel has already closed the stream, so close() throws
// ERR_INVALID_STATE inside an event listener, an uncaught exception
// that took the whole process down (#465).
let closed = false;
const stop = () => {
if (closed) return;
closed = true;
clearInterval(pollInterval);
clearInterval(heartbeatInterval);
controller.close();
};
request.signal.addEventListener("abort", () => {
stop();
try {
controller.close();
} catch {
// Already closed by the reader cancel.
}
});
cancelStream = stop;
},
cancel() {
cancelStream?.();
},
});
+15 -4
View File
@@ -11,6 +11,7 @@ export const GET: APIRoute = async ({ request, locals }) => {
let isClosed = false;
const POLL_INTERVAL = 5000; // Poll every 5 seconds (reduced from 2 seconds for low-traffic usage)
let cancelStream: (() => void) | undefined;
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
@@ -73,20 +74,30 @@ export const GET: APIRoute = async ({ request, locals }) => {
}
}, 30000);
// Handle client disconnection
// Handle client disconnection. The reader cancel runs first on Bun 1.4
// and closes the stream, so close() can throw ERR_INVALID_STATE here;
// inside an event listener that is an uncaught exception (#465).
request.signal?.addEventListener("abort", () => {
if (!isClosed) {
isClosed = true;
if (pollIntervalId) {
clearInterval(pollIntervalId);
}
controller.close();
try {
controller.close();
} catch {
// Already closed by the reader cancel.
}
}
});
cancelStream = () => {
isClosed = true;
if (pollIntervalId) clearInterval(pollIntervalId);
clearInterval(heartbeatInterval);
};
},
cancel() {
// Extra safety in case cancel is triggered
isClosed = true;
cancelStream?.();
},
});