Add repository mirror management APIs and sync hardening

swadhinbiswas committed 8 months ago
2ce335e
1 parent 733b31d
commit 2ce335e
6 files changed
+438 -7
▼doc/feature_audit.md
+1-1
28
28
| :--- | :---: | :--- |
29
29
| Full Git protocol (SSH/HTTPS) | ✅ | `ssh.ts`, `git-server.ts` fully implemented |
30
30
| Repository hosting | ✅ | Multiple storage adapters (S3, R2, local, GDrive) |
31
-| Repository mirroring | ⚠️ | Schema exists, background sync incomplete |
31
+| Repository mirroring | ⚠️ | Background sync cron plus repository mirror config/manual-sync APIs are implemented (`settings/mirror`, `settings/mirror/sync`); scheduling/monitoring depth remains partial |
32
32
| Forks & pull-based workflows | ✅ | `fork.ts` API, UI implemented |
33
33
| Monorepo support | ⚠️ | Basic support, no advanced path scoping |
34
34
| Git LFS | ✅ | `lfs.ts` with batch API (upload/download) |
35
35
▼src/lib/mirror-sync.ts
+62-6
17
17
error?: string;
18
18
}
19
19
20
+async function ensureUpstreamRemote(git: SimpleGit, mirrorUrl: string): Promise<void> {
21
+ const remotes = await git.getRemotes(true);
22
+ const upstream = remotes.find((remote) => remote.name === "upstream");
23
+ if (!upstream) {
24
+ await git.addRemote("upstream", mirrorUrl);
25
+ return;
26
+ }
27
+
28
+ const fetchUrl = upstream.refs.fetch;
29
+ if (fetchUrl !== mirrorUrl) {
30
+ await git.remote(["set-url", "upstream", mirrorUrl]);
31
+ }
32
+}
33
+
20
34
/**
21
35
* Sync a single mirrored repository with its upstream
22
36
*/
46
60
.where(eq(schema.repositories.id, repoId));
47
61
48
62
const git: SimpleGit = simpleGit(repoPath);
63
+ await ensureUpstreamRemote(git, repo.mirrorUrl);
49
64
50
- // Fetch all refs from upstream with prune
65
+ // Mirror upstream refs directly into local heads/tags.
51
66
logger.info({ repoId, mirrorUrl: repo.mirrorUrl }, "Starting mirror sync");
52
67
53
- const fetchResult = await git.fetch(["--all", "--prune", "--tags"]);
68
+ const fetchResult = await git.raw([
69
+ "fetch",
70
+ "upstream",
71
+ "+refs/heads/*:refs/heads/*",
72
+ "+refs/tags/*:refs/tags/*",
73
+ "--prune",
74
+ ]);
54
75
55
- // Count updated refs (simplified - fetch doesn't return detailed info easily)
56
- const refsUpdated = fetchResult.updated?.length || 0;
76
+ // Approximate updated refs from fetch output lines.
77
+ const refsUpdated = fetchResult
78
+ .split("\n")
79
+ .filter((line) => line.includes("->"))
80
+ .length;
57
81
58
82
// Update sync status
59
83
// @ts-expect-error - Drizzle multi-db union type issue
128
152
try {
129
153
const git: SimpleGit = simpleGit(repoPath);
130
154
131
- // Add upstream remote
132
- await git.addRemote("upstream", mirrorUrl);
155
+ await ensureUpstreamRemote(git, mirrorUrl);
133
156
134
157
// Mark as mirror in DB
135
158
// @ts-expect-error - Drizzle multi-db union type issue
148
171
return { success: false, refsUpdated: 0, error: errorMessage };
149
172
}
150
173
}
174
+
175
+export async function disableMirror(repoId: string): Promise<{ success: boolean; error?: string }> {
176
+ const db = getDatabase();
177
+ const repo = await db.query.repositories.findFirst({
178
+ where: eq(schema.repositories.id, repoId),
179
+ });
180
+ if (!repo) return { success: false, error: "Repository not found" };
181
+
182
+ const repoPath = path.join(REPOS_BASE_PATH, repo.diskPath);
183
+ try {
184
+ const git: SimpleGit = simpleGit(repoPath);
185
+ const remotes = await git.getRemotes(true);
186
+ if (remotes.some((remote) => remote.name === "upstream")) {
187
+ await git.removeRemote("upstream");
188
+ }
189
+
190
+ // @ts-expect-error - Drizzle multi-db union type issue
191
+ await db.update(schema.repositories)
192
+ .set({
193
+ isMirror: false,
194
+ mirrorUrl: null,
195
+ mirrorSyncStatus: null,
196
+ updatedAt: new Date(),
197
+ })
198
+ .where(eq(schema.repositories.id, repoId));
199
+
200
+ return { success: true };
201
+ } catch (error) {
202
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
203
+ logger.error({ repoId, error: errorMessage }, "Failed to disable mirror");
204
+ return { success: false, error: errorMessage };
205
+ }
206
+}
151
207
▼src/lib/openapi.ts
+82-0
257
257
},
258
258
required: ["prIds"],
259
259
},
260
+ MirrorConfigRequest: {
261
+ type: "object",
262
+ properties: {
263
+ mirrorUrl: { type: "string", format: "uri" },
264
+ },
265
+ required: ["mirrorUrl"],
266
+ },
260
267
},
261
268
},
262
269
paths: {
826
833
},
827
834
},
828
835
},
836
+ "/repos/{owner}/{repo}/settings/mirror": {
837
+ get: {
838
+ tags: ["Repositories"],
839
+ summary: "Get repository mirror settings",
840
+ security: [{ bearerAuth: [] }],
841
+ parameters: [
842
+ { name: "owner", in: "path", required: true, schema: { type: "string" } },
843
+ { name: "repo", in: "path", required: true, schema: { type: "string" } },
844
+ ],
845
+ responses: {
846
+ 200: { description: "Mirror settings returned" },
847
+ 401: { description: "Unauthorized", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
848
+ 404: { description: "Repository not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
849
+ },
850
+ },
851
+ post: {
852
+ tags: ["Repositories"],
853
+ summary: "Configure repository mirror",
854
+ description: "Configures upstream mirror URL and performs initial synchronization.",
855
+ security: [{ bearerAuth: [] }],
856
+ parameters: [
857
+ { name: "owner", in: "path", required: true, schema: { type: "string" } },
858
+ { name: "repo", in: "path", required: true, schema: { type: "string" } },
859
+ ],
860
+ requestBody: {
861
+ required: true,
862
+ content: {
863
+ "application/json": {
864
+ schema: { $ref: "#/components/schemas/MirrorConfigRequest" },
865
+ },
866
+ },
867
+ },
868
+ responses: {
869
+ 200: { description: "Mirror configured" },
870
+ 400: { description: "Invalid mirror URL or sync failure", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
871
+ 401: { description: "Unauthorized", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
872
+ 403: { description: "Forbidden", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
873
+ 404: { description: "Repository not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
874
+ },
875
+ },
876
+ delete: {
877
+ tags: ["Repositories"],
878
+ summary: "Disable repository mirror",
879
+ security: [{ bearerAuth: [] }],
880
+ parameters: [
881
+ { name: "owner", in: "path", required: true, schema: { type: "string" } },
882
+ { name: "repo", in: "path", required: true, schema: { type: "string" } },
883
+ ],
884
+ responses: {
885
+ 200: { description: "Mirror disabled" },
886
+ 400: { description: "Disable failed", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
887
+ 401: { description: "Unauthorized", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
888
+ 403: { description: "Forbidden", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
889
+ 404: { description: "Repository not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
890
+ },
891
+ },
892
+ },
893
+ "/repos/{owner}/{repo}/settings/mirror/sync": {
894
+ post: {
895
+ tags: ["Repositories"],
896
+ summary: "Trigger manual mirror sync",
897
+ security: [{ bearerAuth: [] }],
898
+ parameters: [
899
+ { name: "owner", in: "path", required: true, schema: { type: "string" } },
900
+ { name: "repo", in: "path", required: true, schema: { type: "string" } },
901
+ ],
902
+ responses: {
903
+ 200: { description: "Sync completed" },
904
+ 400: { description: "Sync failed", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
905
+ 401: { description: "Unauthorized", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
906
+ 403: { description: "Forbidden", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
907
+ 404: { description: "Repository not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
908
+ },
909
+ },
910
+ },
829
911
"/repos/{owner}/{repo}/workflow/states": {
830
912
get: {
831
913
tags: ["Workflow"],
832
914
▼src/pages/api/repos/[owner]/[repo]/settings/mirror.ts
+104-0
1
+import type { APIRoute } from "astro";
2
+import type { NodePgDatabase } from "drizzle-orm/node-postgres";
3
+import { and, eq } from "drizzle-orm";
4
+import { z } from "zod";
5
+import { getDatabase, schema } from "@/db";
6
+import { getUserFromRequest } from "@/lib/auth";
7
+import { canAdminRepo, canReadRepo } from "@/lib/permissions";
8
+import { badRequest, forbidden, notFound, parseBody, success, unauthorized } from "@/lib/api";
9
+import { withErrorHandler } from "@/lib/errors";
10
+import { disableMirror, initializeMirror } from "@/lib/mirror-sync";
11
+
12
+const configureMirrorSchema = z.object({
13
+ mirrorUrl: z.string().url(),
14
+});
15
+
16
+async function resolveRepository(owner: string, repoName: string) {
17
+ const db = getDatabase() as NodePgDatabase<typeof schema>;
18
+ const ownerUser = await db.query.users.findFirst({
19
+ where: eq(schema.users.username, owner),
20
+ });
21
+ if (!ownerUser) return null;
22
+ return db.query.repositories.findFirst({
23
+ where: and(
24
+ eq(schema.repositories.ownerId, ownerUser.id),
25
+ eq(schema.repositories.name, repoName)
26
+ ),
27
+ });
28
+}
29
+
30
+export const GET: APIRoute = withErrorHandler(async ({ params, request }) => {
31
+ const owner = params.owner;
32
+ const repoName = params.repo;
33
+ if (!owner || !repoName) return badRequest("Missing route parameters");
34
+
35
+ const user = await getUserFromRequest(request);
36
+ if (!user) return unauthorized();
37
+
38
+ const repository = await resolveRepository(owner, repoName);
39
+ if (!repository) return notFound("Repository not found");
40
+
41
+ if (!(await canReadRepo(user.userId, repository, { isAdmin: user.isAdmin }))) {
42
+ return notFound("Repository not found");
43
+ }
44
+
45
+ return success({
46
+ isMirror: repository.isMirror,
47
+ mirrorUrl: repository.mirrorUrl,
48
+ mirrorSyncStatus: repository.mirrorSyncStatus,
49
+ lastMirrorSyncAt: repository.lastMirrorSyncAt,
50
+ });
51
+});
52
+
53
+export const POST: APIRoute = withErrorHandler(async ({ params, request }) => {
54
+ const owner = params.owner;
55
+ const repoName = params.repo;
56
+ if (!owner || !repoName) return badRequest("Missing route parameters");
57
+
58
+ const user = await getUserFromRequest(request);
59
+ if (!user) return unauthorized();
60
+
61
+ const repository = await resolveRepository(owner, repoName);
62
+ if (!repository) return notFound("Repository not found");
63
+
64
+ if (!(await canAdminRepo(user.userId, repository, { isAdmin: user.isAdmin }))) {
65
+ return forbidden();
66
+ }
67
+
68
+ const parsed = await parseBody(request, configureMirrorSchema);
69
+ if ("error" in parsed) return parsed.error;
70
+
71
+ const result = await initializeMirror(repository.id, parsed.data.mirrorUrl);
72
+ if (!result.success) {
73
+ return badRequest(result.error || "Failed to initialize mirror");
74
+ }
75
+
76
+ return success({
77
+ configured: true,
78
+ refsUpdated: result.refsUpdated,
79
+ });
80
+});
81
+
82
+export const DELETE: APIRoute = withErrorHandler(async ({ params, request }) => {
83
+ const owner = params.owner;
84
+ const repoName = params.repo;
85
+ if (!owner || !repoName) return badRequest("Missing route parameters");
86
+
87
+ const user = await getUserFromRequest(request);
88
+ if (!user) return unauthorized();
89
+
90
+ const repository = await resolveRepository(owner, repoName);
91
+ if (!repository) return notFound("Repository not found");
92
+
93
+ if (!(await canAdminRepo(user.userId, repository, { isAdmin: user.isAdmin }))) {
94
+ return forbidden();
95
+ }
96
+
97
+ const result = await disableMirror(repository.id);
98
+ if (!result.success) {
99
+ return badRequest(result.error || "Failed to disable mirror");
100
+ }
101
+
102
+ return success({ configured: false });
103
+});
104
+
0
105
▼src/pages/api/repos/[owner]/[repo]/settings/mirror/sync.ts
+47-0
1
+import type { APIRoute } from "astro";
2
+import type { NodePgDatabase } from "drizzle-orm/node-postgres";
3
+import { and, eq } from "drizzle-orm";
4
+import { getDatabase, schema } from "@/db";
5
+import { getUserFromRequest } from "@/lib/auth";
6
+import { canWriteRepo } from "@/lib/permissions";
7
+import { badRequest, forbidden, notFound, success, unauthorized } from "@/lib/api";
8
+import { withErrorHandler } from "@/lib/errors";
9
+import { syncMirrorRepository } from "@/lib/mirror-sync";
10
+
11
+async function resolveRepository(owner: string, repoName: string) {
12
+ const db = getDatabase() as NodePgDatabase<typeof schema>;
13
+ const ownerUser = await db.query.users.findFirst({
14
+ where: eq(schema.users.username, owner),
15
+ });
16
+ if (!ownerUser) return null;
17
+ return db.query.repositories.findFirst({
18
+ where: and(
19
+ eq(schema.repositories.ownerId, ownerUser.id),
20
+ eq(schema.repositories.name, repoName)
21
+ ),
22
+ });
23
+}
24
+
25
+export const POST: APIRoute = withErrorHandler(async ({ params, request }) => {
26
+ const owner = params.owner;
27
+ const repoName = params.repo;
28
+ if (!owner || !repoName) return badRequest("Missing route parameters");
29
+
30
+ const user = await getUserFromRequest(request);
31
+ if (!user) return unauthorized();
32
+
33
+ const repository = await resolveRepository(owner, repoName);
34
+ if (!repository) return notFound("Repository not found");
35
+
36
+ if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin }))) {
37
+ return forbidden();
38
+ }
39
+
40
+ const result = await syncMirrorRepository(repository.id);
41
+ if (!result.success) {
42
+ return badRequest(result.error || "Mirror sync failed");
43
+ }
44
+
45
+ return success(result);
46
+});
47
+
0
48
▼tests/integration/repo-mirror-settings-route.test.ts
+142-0
1
+import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+const {
4
+ getUserFromRequestMock,
5
+ canReadRepoMock,
6
+ canAdminRepoMock,
7
+ canWriteRepoMock,
8
+ initializeMirrorMock,
9
+ disableMirrorMock,
10
+ syncMirrorRepositoryMock,
11
+ fakeSchema,
12
+} = vi.hoisted(() => ({
13
+ getUserFromRequestMock: vi.fn(async () => ({ userId: "user-1", isAdmin: false })),
14
+ canReadRepoMock: vi.fn(async () => true),
15
+ canAdminRepoMock: vi.fn(async () => true),
16
+ canWriteRepoMock: vi.fn(async () => true),
17
+ initializeMirrorMock: vi.fn(async () => ({ success: true, refsUpdated: 3 })),
18
+ disableMirrorMock: vi.fn(async () => ({ success: true })),
19
+ syncMirrorRepositoryMock: vi.fn(async () => ({ success: true, refsUpdated: 2 })),
20
+ fakeSchema: {
21
+ users: { username: {} },
22
+ repositories: { ownerId: {}, name: {}, id: {} },
23
+ } as any,
24
+}));
25
+
26
+let mockDb: any;
27
+
28
+vi.mock("@/db", () => ({
29
+ getDatabase: () => mockDb,
30
+ schema: fakeSchema,
31
+}));
32
+
33
+vi.mock("@/lib/auth", () => ({
34
+ getUserFromRequest: getUserFromRequestMock,
35
+}));
36
+
37
+vi.mock("@/lib/permissions", () => ({
38
+ canReadRepo: canReadRepoMock,
39
+ canAdminRepo: canAdminRepoMock,
40
+ canWriteRepo: canWriteRepoMock,
41
+}));
42
+
43
+vi.mock("@/lib/mirror-sync", () => ({
44
+ initializeMirror: initializeMirrorMock,
45
+ disableMirror: disableMirrorMock,
46
+ syncMirrorRepository: syncMirrorRepositoryMock,
47
+}));
48
+
49
+import { GET as mirrorGet, POST as mirrorPost, DELETE as mirrorDelete } from "@/pages/api/repos/[owner]/[repo]/settings/mirror";
50
+import { POST as mirrorSyncPost } from "@/pages/api/repos/[owner]/[repo]/settings/mirror/sync";
51
+
52
+function makeDb() {
53
+ return {
54
+ query: {
55
+ users: {
56
+ findFirst: vi.fn(async () => ({ id: "owner-1" })),
57
+ },
58
+ repositories: {
59
+ findFirst: vi.fn(async () => ({
60
+ id: "repo-1",
61
+ ownerId: "owner-1",
62
+ name: "demo",
63
+ isMirror: true,
64
+ mirrorUrl: "https://example.com/upstream.git",
65
+ mirrorSyncStatus: "success",
66
+ lastMirrorSyncAt: new Date("2026-02-19T00:00:00Z"),
67
+ })),
68
+ },
69
+ },
70
+ };
71
+}
72
+
73
+async function readJson(response: Response): Promise<any> {
74
+ return response.json();
75
+}
76
+
77
+describe("repository mirror settings routes", () => {
78
+ beforeEach(() => {
79
+ mockDb = makeDb();
80
+ getUserFromRequestMock.mockResolvedValue({ userId: "user-1", isAdmin: false });
81
+ canReadRepoMock.mockResolvedValue(true);
82
+ canAdminRepoMock.mockResolvedValue(true);
83
+ canWriteRepoMock.mockResolvedValue(true);
84
+ initializeMirrorMock.mockResolvedValue({ success: true, refsUpdated: 3 });
85
+ disableMirrorMock.mockResolvedValue({ success: true });
86
+ syncMirrorRepositoryMock.mockResolvedValue({ success: true, refsUpdated: 2 });
87
+ });
88
+
89
+ it("returns mirror settings for readers", async () => {
90
+ const response = await mirrorGet({
91
+ params: { owner: "owner-1", repo: "demo" },
92
+ request: new Request("http://localhost/api/repos/owner-1/demo/settings/mirror"),
93
+ } as any);
94
+
95
+ const body = await readJson(response);
96
+ expect(response.status).toBe(200);
97
+ expect(body?.data?.isMirror).toBe(true);
98
+ });
99
+
100
+ it("configures mirror for repo admins", async () => {
101
+ const response = await mirrorPost({
102
+ params: { owner: "owner-1", repo: "demo" },
103
+ request: new Request("http://localhost/api/repos/owner-1/demo/settings/mirror", {
104
+ method: "POST",
105
+ headers: { "content-type": "application/json" },
106
+ body: JSON.stringify({ mirrorUrl: "https://example.com/new-upstream.git" }),
107
+ }),
108
+ } as any);
109
+
110
+ const body = await readJson(response);
111
+ expect(response.status).toBe(200);
112
+ expect(body?.data?.configured).toBe(true);
113
+ expect(initializeMirrorMock).toHaveBeenCalledWith("repo-1", "https://example.com/new-upstream.git");
114
+ });
115
+
116
+ it("denies manual mirror sync without write access", async () => {
117
+ canWriteRepoMock.mockResolvedValue(false);
118
+ const response = await mirrorSyncPost({
119
+ params: { owner: "owner-1", repo: "demo" },
120
+ request: new Request("http://localhost/api/repos/owner-1/demo/settings/mirror/sync", {
121
+ method: "POST",
122
+ }),
123
+ } as any);
124
+
125
+ expect(response.status).toBe(403);
126
+ });
127
+
128
+ it("disables mirror for admins", async () => {
129
+ const response = await mirrorDelete({
130
+ params: { owner: "owner-1", repo: "demo" },
131
+ request: new Request("http://localhost/api/repos/owner-1/demo/settings/mirror", {
132
+ method: "DELETE",
133
+ }),
134
+ } as any);
135
+
136
+ const body = await readJson(response);
137
+ expect(response.status).toBe(200);
138
+ expect(body?.data?.configured).toBe(false);
139
+ expect(disableMirrorMock).toHaveBeenCalledWith("repo-1");
140
+ });
141
+});
142
+
0
143