Add repository developer workload insights API
swadhinbiswas committed 8 months ago
2552825
1 parent 0952217
commit 2552825
5 files changed
+233 -11
Showing 5 changed files+233-11
▼doc/feature_audit.md
+1-1
| @@ -173,7 +173,7 @@ | ||
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,7 +5,7 @@ | ||
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,23 +522,53 @@ export async function getDeveloperWorkloads(options: { | ||
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,6 +1033,26 @@ export const openApiSpec = { | ||
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
| @@ -0,0 +1,65 @@ | ||
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
| @@ -0,0 +1,107 @@ | ||
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 | |