Add repository merge-frequency analytics endpoint
swadhinbiswas committed 8 months ago
0952217
1 parent f00b5bc
commit 0952217
4 files changed
+225 -1
Showing 4 changed files+225-1
▼doc/feature_audit.md
+1-1
| @@ -172,7 +172,7 @@ | ||
172 | 172 | | :--- | :---: | :--- | |
173 | 173 | | PR cycle time analytics | ✅ | `developer-metrics.ts` | |
174 | 174 | | Review latency tracking | ✅ | Metrics schema exists | |
175 | -| Merge frequency metrics | ⚠️ | Basic tracking | | |
175 | +| Merge frequency metrics | ⚠️ | Time-series merge frequency API now available via `GET /repos/{owner}/{repo}/analytics/merge-frequency` (daily/weekly buckets); higher-level dashboarding/forecasting remains partial | | |
176 | 176 | | Developer workload insights | ⚠️ | Basic - needs enhancement | |
177 | 177 | | Hotspot file detection | ✅ | Implemented in analytics library + repository API endpoint | |
178 | 178 | | Delivery performance dashboards | ✅ | `insights.astro` pages | |
179 | 179 | |
▼src/lib/openapi.ts
+20-0
| @@ -1013,6 +1013,26 @@ export const openApiSpec = { | ||
1013 | 1013 | }, |
1014 | 1014 | }, |
1015 | 1015 | }, |
1016 | + "/repos/{owner}/{repo}/analytics/merge-frequency": { | |
1017 | + get: { | |
1018 | + tags: ["Analytics"], | |
1019 | + summary: "Get merge frequency metrics", | |
1020 | + description: "Returns merge frequency points for a repository with daily or weekly bucketing.", | |
1021 | + security: [{ bearerAuth: [] }], | |
1022 | + parameters: [ | |
1023 | + { name: "owner", in: "path", required: true, schema: { type: "string" } }, | |
1024 | + { name: "repo", in: "path", required: true, schema: { type: "string" } }, | |
1025 | + { name: "days", in: "query", required: false, schema: { type: "integer", minimum: 7, maximum: 365, default: 30 } }, | |
1026 | + { name: "bucket", in: "query", required: false, schema: { type: "string", enum: ["day", "week"], default: "day" } }, | |
1027 | + ], | |
1028 | + responses: { | |
1029 | + 200: { description: "Merge frequency metrics returned" }, | |
1030 | + 400: { description: "Invalid query parameters", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, | |
1031 | + 401: { description: "Unauthorized", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, | |
1032 | + 404: { description: "Repository not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, | |
1033 | + }, | |
1034 | + }, | |
1035 | + }, | |
1016 | 1036 | "/repos/{owner}/{repo}/workflow/states": { |
1017 | 1037 | get: { |
1018 | 1038 | tags: ["Workflow"], |
1019 | 1039 | |
▼src/pages/api/repos/[owner]/[repo]/analytics/merge-frequency.ts
+113-0
| @@ -0,0 +1,113 @@ | ||
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 { badRequest, notFound, parseQuery, success } from "@/lib/api"; | |
7 | +import { withErrorHandler } from "@/lib/errors"; | |
8 | +import { canReadRepo } from "@/lib/permissions"; | |
9 | +import { getRepoStats } from "@/lib/analytics"; | |
10 | + | |
11 | +const querySchema = z.object({ | |
12 | + days: z.coerce.number().int().min(7).max(365).optional(), | |
13 | + bucket: z.enum(["day", "week"]).optional(), | |
14 | +}); | |
15 | + | |
16 | +type DailyPoint = { | |
17 | + date: string; | |
18 | + cycleTime: number; | |
19 | + mergeCount: number; | |
20 | + reviewTime: number; | |
21 | +}; | |
22 | + | |
23 | +async function resolveRepository(owner: string, repoName: string) { | |
24 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
25 | + const repoOwner = await db.query.users.findFirst({ | |
26 | + where: eq(schema.users.username, owner), | |
27 | + }); | |
28 | + if (!repoOwner) return null; | |
29 | + return db.query.repositories.findFirst({ | |
30 | + where: and( | |
31 | + eq(schema.repositories.ownerId, repoOwner.id), | |
32 | + eq(schema.repositories.name, repoName) | |
33 | + ), | |
34 | + }); | |
35 | +} | |
36 | + | |
37 | +function isoDateToWeekKey(date: string): string { | |
38 | + const d = new Date(`${date}T00:00:00.000Z`); | |
39 | + const day = d.getUTCDay() || 7; // Mon=1..Sun=7 | |
40 | + d.setUTCDate(d.getUTCDate() + 4 - day); | |
41 | + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); | |
42 | + const weekNo = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7); | |
43 | + return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`; | |
44 | +} | |
45 | + | |
46 | +function aggregateWeekly(points: DailyPoint[]) { | |
47 | + const byWeek = new Map<string, { | |
48 | + mergeCount: number; | |
49 | + cycleWeighted: number; | |
50 | + reviewWeighted: number; | |
51 | + }>(); | |
52 | + | |
53 | + for (const point of points) { | |
54 | + const week = isoDateToWeekKey(point.date); | |
55 | + const current = byWeek.get(week) || { | |
56 | + mergeCount: 0, | |
57 | + cycleWeighted: 0, | |
58 | + reviewWeighted: 0, | |
59 | + }; | |
60 | + current.mergeCount += point.mergeCount; | |
61 | + current.cycleWeighted += point.cycleTime * point.mergeCount; | |
62 | + current.reviewWeighted += point.reviewTime * point.mergeCount; | |
63 | + byWeek.set(week, current); | |
64 | + } | |
65 | + | |
66 | + return Array.from(byWeek.entries()) | |
67 | + .map(([week, value]) => ({ | |
68 | + week, | |
69 | + mergeCount: value.mergeCount, | |
70 | + cycleTime: value.mergeCount > 0 | |
71 | + ? Math.round((value.cycleWeighted / value.mergeCount) * 100) / 100 | |
72 | + : 0, | |
73 | + reviewTime: value.mergeCount > 0 | |
74 | + ? Math.round((value.reviewWeighted / value.mergeCount) * 100) / 100 | |
75 | + : 0, | |
76 | + })) | |
77 | + .sort((a, b) => a.week.localeCompare(b.week)); | |
78 | +} | |
79 | + | |
80 | +export const GET: APIRoute = withErrorHandler(async ({ params, url, locals }) => { | |
81 | + const owner = params.owner; | |
82 | + const repoName = params.repo; | |
83 | + if (!owner || !repoName) return badRequest("Missing route parameters"); | |
84 | + | |
85 | + const parsed = parseQuery(url, querySchema); | |
86 | + if ("error" in parsed) return parsed.error; | |
87 | + | |
88 | + const repository = await resolveRepository(owner, repoName); | |
89 | + if (!repository) return notFound("Repository not found"); | |
90 | + if (!(await canReadRepo(locals.user?.id, repository, { isAdmin: locals.user?.isAdmin }))) { | |
91 | + return notFound("Repository not found"); | |
92 | + } | |
93 | + | |
94 | + const days = parsed.data.days ?? 30; | |
95 | + const bucket = parsed.data.bucket || "day"; | |
96 | + | |
97 | + const stats = await getRepoStats(repository.id, days); | |
98 | + if (bucket === "week") { | |
99 | + return success({ | |
100 | + repositoryId: repository.id, | |
101 | + bucket, | |
102 | + days, | |
103 | + points: aggregateWeekly(stats), | |
104 | + }); | |
105 | + } | |
106 | + | |
107 | + return success({ | |
108 | + repositoryId: repository.id, | |
109 | + bucket, | |
110 | + days, | |
111 | + points: stats, | |
112 | + }); | |
113 | +}); | |
0 | 114 | |
▼tests/integration/analytics-merge-frequency-route.test.ts
+91-0
| @@ -0,0 +1,91 @@ | ||
1 | +import { beforeEach, describe, expect, it, vi } from "vitest"; | |
2 | + | |
3 | +const { | |
4 | + canReadRepoMock, | |
5 | + getRepoStatsMock, | |
6 | + fakeSchema, | |
7 | +} = vi.hoisted(() => ({ | |
8 | + canReadRepoMock: vi.fn(async () => true), | |
9 | + getRepoStatsMock: vi.fn(async () => [ | |
10 | + { date: "2026-02-10", cycleTime: 3, mergeCount: 2, reviewTime: 1.5 }, | |
11 | + { date: "2026-02-11", cycleTime: 5, mergeCount: 1, reviewTime: 2.5 }, | |
12 | + ]), | |
13 | + fakeSchema: { | |
14 | + users: { username: {} }, | |
15 | + repositories: { ownerId: {}, name: {}, id: {} }, | |
16 | + } as any, | |
17 | +})); | |
18 | + | |
19 | +let mockDb: any; | |
20 | + | |
21 | +vi.mock("@/db", () => ({ | |
22 | + getDatabase: () => mockDb, | |
23 | + schema: fakeSchema, | |
24 | +})); | |
25 | + | |
26 | +vi.mock("@/lib/permissions", () => ({ | |
27 | + canReadRepo: canReadRepoMock, | |
28 | +})); | |
29 | + | |
30 | +vi.mock("@/lib/analytics", () => ({ | |
31 | + getRepoStats: getRepoStatsMock, | |
32 | +})); | |
33 | + | |
34 | +import { GET as getMergeFrequency } from "@/pages/api/repos/[owner]/[repo]/analytics/merge-frequency"; | |
35 | + | |
36 | +function makeDb() { | |
37 | + return { | |
38 | + query: { | |
39 | + users: { | |
40 | + findFirst: vi.fn(async () => ({ id: "owner-1" })), | |
41 | + }, | |
42 | + repositories: { | |
43 | + findFirst: vi.fn(async () => ({ id: "repo-1", ownerId: "owner-1", name: "demo" })), | |
44 | + }, | |
45 | + }, | |
46 | + }; | |
47 | +} | |
48 | + | |
49 | +async function readJson(response: Response): Promise<any> { | |
50 | + return response.json(); | |
51 | +} | |
52 | + | |
53 | +describe("merge frequency analytics route", () => { | |
54 | + beforeEach(() => { | |
55 | + mockDb = makeDb(); | |
56 | + canReadRepoMock.mockResolvedValue(true); | |
57 | + getRepoStatsMock.mockResolvedValue([ | |
58 | + { date: "2026-02-10", cycleTime: 3, mergeCount: 2, reviewTime: 1.5 }, | |
59 | + { date: "2026-02-11", cycleTime: 5, mergeCount: 1, reviewTime: 2.5 }, | |
60 | + ]); | |
61 | + }); | |
62 | + | |
63 | + it("returns daily points by default", async () => { | |
64 | + const response = await getMergeFrequency({ | |
65 | + params: { owner: "owner-1", repo: "demo" }, | |
66 | + url: new URL("http://localhost/api/repos/owner-1/demo/analytics/merge-frequency"), | |
67 | + locals: { user: { id: "user-1" } }, | |
68 | + } as any); | |
69 | + | |
70 | + const body = await readJson(response); | |
71 | + expect(response.status).toBe(200); | |
72 | + expect(body?.data?.bucket).toBe("day"); | |
73 | + expect(body?.data?.points).toHaveLength(2); | |
74 | + expect(getRepoStatsMock).toHaveBeenCalledWith("repo-1", 30); | |
75 | + }); | |
76 | + | |
77 | + it("aggregates weekly points when bucket=week", async () => { | |
78 | + const response = await getMergeFrequency({ | |
79 | + params: { owner: "owner-1", repo: "demo" }, | |
80 | + url: new URL("http://localhost/api/repos/owner-1/demo/analytics/merge-frequency?days=60&bucket=week"), | |
81 | + locals: { user: { id: "user-1" } }, | |
82 | + } as any); | |
83 | + | |
84 | + const body = await readJson(response); | |
85 | + expect(response.status).toBe(200); | |
86 | + expect(body?.data?.bucket).toBe("week"); | |
87 | + expect(body?.data?.points?.length).toBeGreaterThan(0); | |
88 | + expect(body?.data?.points?.[0]?.mergeCount).toBe(3); | |
89 | + expect(getRepoStatsMock).toHaveBeenCalledWith("repo-1", 60); | |
90 | + }); | |
91 | +}); | |
0 | 92 | |