Add repository developer workload insights API

swadhinbiswas committed 8 months ago
2552825
1 parent 0952217
commit 2552825
5 files changed
+233 -11
▼doc/feature_audit.md
+1-1
173
173
| PR cycle time analytics | ✅ | `developer-metrics.ts` |
174
174
| Review latency tracking | ✅ | Metrics schema exists |
175
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
-| Developer workload insights | ⚠️ | Basic - needs enhancement |
176
+| Developer workload insights | ⚠️ | Repository workload insights API now available via `GET /repos/{owner}/{repo}/analytics/workload` with scored contributor summaries; deeper trend intelligence/recommendation UX remains partial |
177
177
| Hotspot file detection | ✅ | Implemented in analytics library + repository API endpoint |
178
178
| Delivery performance dashboards | ✅ | `insights.astro` pages |
179
179
| Export metrics | ✅ | JSON/CSV/Prometheus export implemented (`analytics/export.ts`) |
180
180
▼src/lib/analytics-advanced.ts
+40-10
5
5
6
6
import { pgTable, text, timestamp, integer, real, jsonb, boolean } from "drizzle-orm/pg-core";
7
7
import { getDatabase, schema } from "@/db";
8
-import { eq, and, gte, lte, sql, desc } from "drizzle-orm";
8
+import { eq, and, gte, lte, sql, desc, inArray } from "drizzle-orm";
9
9
import { logger } from "./logger";
10
10
import { repositories } from "@/db/schema/repositories";
11
11
import { users } from "@/db/schema/users";
522
522
const workloads: DeveloperWorkload[] = [];
523
523
524
524
for (const user of users) {
525
- // Count open PRs authored
525
+ // Count open PRs authored (optionally repository-scoped)
526
+ const openPrConditions = [
527
+ eq(schema.pullRequests.authorId, user.id),
528
+ eq(schema.pullRequests.state, "open"),
529
+ ];
530
+ if (options.repositoryId) {
531
+ openPrConditions.push(eq(schema.pullRequests.repositoryId, options.repositoryId));
532
+ }
526
533
const openPRs = await db.query.pullRequests.findMany({
527
- where: and(
528
- eq(schema.pullRequests.authorId, user.id),
529
- eq(schema.pullRequests.state, "open")
530
- ),
534
+ where: and(...openPrConditions),
531
535
}) || [];
532
536
533
- // Count pending reviews requested (simplified - get all assigned reviews)
534
- const pendingReviews = await db.query.pullRequestReviewers?.findMany({
537
+ // Count pending reviews requested (optionally repository-scoped)
538
+ const pendingReviewRequests = await db.query.pullRequestReviewers?.findMany({
535
539
where: eq(schema.pullRequestReviewers.userId, user.id),
536
540
}) || [];
541
+ let pendingReviews = pendingReviewRequests;
542
+ if (options.repositoryId && pendingReviewRequests.length > 0) {
543
+ const pullRequestIds = pendingReviewRequests.map((r) => r.pullRequestId);
544
+ const repoPullRequests = await db.query.pullRequests.findMany({
545
+ where: and(
546
+ inArray(schema.pullRequests.id, pullRequestIds),
547
+ eq(schema.pullRequests.repositoryId, options.repositoryId)
548
+ ),
549
+ columns: { id: true },
550
+ });
551
+ const repoPullRequestIds = new Set(repoPullRequests.map((pr) => pr.id));
552
+ pendingReviews = pendingReviewRequests.filter((r) => repoPullRequestIds.has(r.pullRequestId));
553
+ }
537
554
538
- // Count assigned issues
539
- const assignedIssues = await db.query.issueAssignees?.findMany({
555
+ // Count assigned issues (optionally repository-scoped)
556
+ const assignedIssueLinks = await db.query.issueAssignees?.findMany({
540
557
where: eq(schema.issueAssignees.userId, user.id),
541
558
}) || [];
559
+ let assignedIssues = assignedIssueLinks;
560
+ if (options.repositoryId && assignedIssueLinks.length > 0) {
561
+ const issueIds = assignedIssueLinks.map((i) => i.issueId);
562
+ const repoIssues = await db.query.issues.findMany({
563
+ where: and(
564
+ inArray(schema.issues.id, issueIds),
565
+ eq(schema.issues.repositoryId, options.repositoryId)
566
+ ),
567
+ columns: { id: true },
568
+ });
569
+ const repoIssueIds = new Set(repoIssues.map((issue) => issue.id));
570
+ assignedIssues = assignedIssueLinks.filter((i) => repoIssueIds.has(i.issueId));
571
+ }
542
572
543
573
// Calculate workload score (weighted)
544
574
const workloadScore = Math.min(100,
545
575
▼src/lib/openapi.ts
+20-0
1033
1033
},
1034
1034
},
1035
1035
},
1036
+ "/repos/{owner}/{repo}/analytics/workload": {
1037
+ get: {
1038
+ tags: ["Analytics"],
1039
+ summary: "Get developer workload insights",
1040
+ description: "Returns repository-scoped workload scores and summary counters for contributors.",
1041
+ security: [{ bearerAuth: [] }],
1042
+ parameters: [
1043
+ { name: "owner", in: "path", required: true, schema: { type: "string" } },
1044
+ { name: "repo", in: "path", required: true, schema: { type: "string" } },
1045
+ { name: "days", in: "query", required: false, schema: { type: "integer", minimum: 7, maximum: 365, default: 30 } },
1046
+ { name: "limit", in: "query", required: false, schema: { type: "integer", minimum: 1, maximum: 200, default: 50 } },
1047
+ ],
1048
+ responses: {
1049
+ 200: { description: "Workload insights returned" },
1050
+ 400: { description: "Invalid query parameters", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
1051
+ 401: { description: "Unauthorized", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
1052
+ 404: { description: "Repository not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
1053
+ },
1054
+ },
1055
+ },
1036
1056
"/repos/{owner}/{repo}/workflow/states": {
1037
1057
get: {
1038
1058
tags: ["Workflow"],
1039
1059
▼src/pages/api/repos/[owner]/[repo]/analytics/workload.ts
+65-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 { getDeveloperWorkloads } from "@/lib/analytics-advanced";
10
+
11
+const querySchema = z.object({
12
+ days: z.coerce.number().int().min(7).max(365).optional(),
13
+ limit: z.coerce.number().int().min(1).max(200).optional(),
14
+});
15
+
16
+async function resolveRepository(owner: string, repoName: string) {
17
+ const db = getDatabase() as NodePgDatabase<typeof schema>;
18
+ const repoOwner = await db.query.users.findFirst({
19
+ where: eq(schema.users.username, owner),
20
+ });
21
+ if (!repoOwner) return null;
22
+ return db.query.repositories.findFirst({
23
+ where: and(
24
+ eq(schema.repositories.ownerId, repoOwner.id),
25
+ eq(schema.repositories.name, repoName)
26
+ ),
27
+ });
28
+}
29
+
30
+export const GET: APIRoute = withErrorHandler(async ({ params, url, locals }) => {
31
+ const owner = params.owner;
32
+ const repoName = params.repo;
33
+ if (!owner || !repoName) return badRequest("Missing route parameters");
34
+
35
+ const parsed = parseQuery(url, querySchema);
36
+ if ("error" in parsed) return parsed.error;
37
+
38
+ const repository = await resolveRepository(owner, repoName);
39
+ if (!repository) return notFound("Repository not found");
40
+ if (!(await canReadRepo(locals.user?.id, repository, { isAdmin: locals.user?.isAdmin }))) {
41
+ return notFound("Repository not found");
42
+ }
43
+
44
+ const days = parsed.data.days ?? 30;
45
+ const limit = parsed.data.limit ?? 50;
46
+ const workloads = await getDeveloperWorkloads({
47
+ repositoryId: repository.id,
48
+ days,
49
+ });
50
+
51
+ const totalScore = workloads.reduce((sum, entry) => sum + entry.workloadScore, 0);
52
+ const averageScore = workloads.length > 0 ? Math.round((totalScore / workloads.length) * 100) / 100 : 0;
53
+
54
+ return success({
55
+ repositoryId: repository.id,
56
+ days,
57
+ summary: {
58
+ totalContributors: workloads.length,
59
+ averageWorkloadScore: averageScore,
60
+ overloadedCount: workloads.filter((w) => w.workloadScore >= 80).length,
61
+ underutilizedCount: workloads.filter((w) => w.workloadScore <= 20).length,
62
+ },
63
+ contributors: workloads.slice(0, limit),
64
+ });
65
+});
0
66
▼tests/integration/analytics-workload-route.test.ts
+107-0
1
+import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+const {
4
+ canReadRepoMock,
5
+ getDeveloperWorkloadsMock,
6
+ fakeSchema,
7
+} = vi.hoisted(() => ({
8
+ canReadRepoMock: vi.fn(async () => true),
9
+ getDeveloperWorkloadsMock: vi.fn(async () => ([
10
+ {
11
+ userId: "u1",
12
+ userName: "Dev 1",
13
+ openPRs: 3,
14
+ pendingReviews: 2,
15
+ assignedIssues: 1,
16
+ recentCommits: 0,
17
+ avgReviewTime: 0,
18
+ workloadScore: 85,
19
+ trend: "increasing",
20
+ },
21
+ {
22
+ userId: "u2",
23
+ userName: "Dev 2",
24
+ openPRs: 0,
25
+ pendingReviews: 0,
26
+ assignedIssues: 1,
27
+ recentCommits: 0,
28
+ avgReviewTime: 0,
29
+ workloadScore: 15,
30
+ trend: "decreasing",
31
+ },
32
+ ])),
33
+ fakeSchema: {
34
+ users: { username: {} },
35
+ repositories: { ownerId: {}, name: {}, id: {} },
36
+ } as any,
37
+}));
38
+
39
+let mockDb: any;
40
+
41
+vi.mock("@/db", () => ({
42
+ getDatabase: () => mockDb,
43
+ schema: fakeSchema,
44
+}));
45
+
46
+vi.mock("@/lib/permissions", () => ({
47
+ canReadRepo: canReadRepoMock,
48
+}));
49
+
50
+vi.mock("@/lib/analytics-advanced", () => ({
51
+ getDeveloperWorkloads: getDeveloperWorkloadsMock,
52
+}));
53
+
54
+import { GET as getWorkload } from "@/pages/api/repos/[owner]/[repo]/analytics/workload";
55
+
56
+function makeDb() {
57
+ return {
58
+ query: {
59
+ users: {
60
+ findFirst: vi.fn(async () => ({ id: "owner-1" })),
61
+ },
62
+ repositories: {
63
+ findFirst: vi.fn(async () => ({ id: "repo-1", ownerId: "owner-1", name: "demo" })),
64
+ },
65
+ },
66
+ };
67
+}
68
+
69
+async function readJson(response: Response): Promise<any> {
70
+ return response.json();
71
+}
72
+
73
+describe("analytics workload route", () => {
74
+ beforeEach(() => {
75
+ mockDb = makeDb();
76
+ canReadRepoMock.mockResolvedValue(true);
77
+ });
78
+
79
+ it("returns repository workload summary", async () => {
80
+ const response = await getWorkload({
81
+ params: { owner: "owner-1", repo: "demo" },
82
+ url: new URL("http://localhost/api/repos/owner-1/demo/analytics/workload?days=14"),
83
+ locals: { user: { id: "user-1" } },
84
+ } as any);
85
+
86
+ const body = await readJson(response);
87
+ expect(response.status).toBe(200);
88
+ expect(body?.data?.summary?.overloadedCount).toBe(1);
89
+ expect(body?.data?.summary?.underutilizedCount).toBe(1);
90
+ expect(getDeveloperWorkloadsMock).toHaveBeenCalledWith({
91
+ repositoryId: "repo-1",
92
+ days: 14,
93
+ });
94
+ });
95
+
96
+ it("applies contributor limit", async () => {
97
+ const response = await getWorkload({
98
+ params: { owner: "owner-1", repo: "demo" },
99
+ url: new URL("http://localhost/api/repos/owner-1/demo/analytics/workload?limit=1"),
100
+ locals: { user: { id: "user-1" } },
101
+ } as any);
102
+
103
+ const body = await readJson(response);
104
+ expect(response.status).toBe(200);
105
+ expect(body?.data?.contributors).toHaveLength(1);
106
+ });
107
+});
0
108