mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-10-31 06:31:58 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d45ef33c1c | ||
|
|
4f96e7dfbf | ||
|
|
41799154fc | ||
|
|
8de3c0c091 | ||
|
|
89babdf461 | ||
|
|
44d927e768 | ||
|
|
0f55d29298 |
No files matched your search
@@ -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 \
|
||||
|
||||
@@ -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,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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gitea-mirror",
|
||||
"type": "module",
|
||||
"version": "3.40.3",
|
||||
"version": "3.40.5",
|
||||
"engines": {
|
||||
"bun": ">=1.2.9"
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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] });
|
||||
|
||||
@@ -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
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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?.();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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?.();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user