Add repository merge-frequency analytics endpoint

swadhinbiswas committed 8 months ago
0952217
1 parent f00b5bc
commit 0952217
4 files changed
+225 -1
▼doc/feature_audit.md
+1-1
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
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
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
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