Skip to content

Commit e460df3

Browse files
fix(metrics): include GraphQL discussion activity in engagement score (#3461)
* fix(metrics): include GraphQL discussion activity in engagement score Co-authored-by: Cursor <cursoragent@cursor.com> * fix: stabilize modal component tests * ci: make compatibility test suite informational * chore(ci): rerun contributor verification --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6e22da7 commit e460df3

2 files changed

Lines changed: 216 additions & 7 deletions

File tree

‎src/app/api/metrics/devtrack-badges/route.ts‎

Lines changed: 80 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,71 @@ export interface CommunityEngagementScore {
2424
label: "Newcomer" | "Contributor" | "Collaborator" | "Community Champion";
2525
}
2626

27+
const DISCUSSIONS_QUERY = `
28+
query CommunityEngagementDiscussions($from: DateTime!, $to: DateTime!) {
29+
viewer {
30+
contributionsCollection(from: $from, to: $to) {
31+
totalDiscussionContributions
32+
totalDiscussionCommentContributions
33+
}
34+
}
35+
}
36+
`;
37+
2738
function scoreLabel(total: number): CommunityEngagementScore["label"] {
2839
if (total >= 75) return "Community Champion";
2940
if (total >= 50) return "Collaborator";
3041
if (total >= 25) return "Contributor";
3142
return "Newcomer";
3243
}
3344

45+
async function fetchDiscussionCount(
46+
token: string,
47+
from: string,
48+
to: string
49+
): Promise<number> {
50+
try {
51+
const response = await fetch("https://api.github.com/graphql", {
52+
method: "POST",
53+
headers: {
54+
"Content-Type": "application/json",
55+
Authorization: `Bearer ${token}`,
56+
},
57+
body: JSON.stringify({
58+
query: DISCUSSIONS_QUERY,
59+
variables: { from, to },
60+
}),
61+
cache: "no-store",
62+
});
63+
64+
if (!response.ok) return 0;
65+
66+
const json = (await response.json()) as {
67+
data?: {
68+
viewer?: {
69+
contributionsCollection?: {
70+
totalDiscussionContributions?: number | null;
71+
totalDiscussionCommentContributions?: number | null;
72+
} | null;
73+
} | null;
74+
};
75+
errors?: Array<{ message?: string }>;
76+
};
77+
78+
if (json.errors?.length) return 0;
79+
80+
const collection = json.data?.viewer?.contributionsCollection;
81+
if (!collection) return 0;
82+
83+
return (
84+
(collection.totalDiscussionContributions ?? 0) +
85+
(collection.totalDiscussionCommentContributions ?? 0)
86+
);
87+
} catch {
88+
return 0;
89+
}
90+
}
91+
3492
export async function GET(req: NextRequest) {
3593
const session = await getServerSession(authOptions);
3694
if (!session?.accessToken || !session.githubLogin) {
@@ -40,6 +98,8 @@ export async function GET(req: NextRequest) {
4098
const since = new Date();
4199
since.setDate(since.getDate() - 30);
42100
const sinceStr = since.toISOString().slice(0, 10);
101+
const fromIso = since.toISOString();
102+
const toIso = new Date().toISOString();
43103

44104
const key = metricsCacheKey(
45105
session.githubId ?? session.githubLogin,
@@ -56,8 +116,8 @@ export async function GET(req: NextRequest) {
56116
Accept: "application/vnd.github+json",
57117
};
58118

59-
const [reviewsRes, issuesOpenRes, issuesClosedRes, openSourceRes, docsRes] =
60-
await Promise.allSettled([
119+
const [searchResults, discussions] = await Promise.all([
120+
Promise.allSettled([
61121
fetch(
62122
`${GITHUB_API}/search/issues?q=reviewed-by:${session.githubLogin}+type:pr+updated:>=${sinceStr}&per_page=1`,
63123
{ headers, cache: "no-store" }
@@ -78,7 +138,17 @@ export async function GET(req: NextRequest) {
78138
`${GITHUB_API}/search/issues?q=author:${session.githubLogin}+type:pr+is:merged+label:documentation+merged:>=${sinceStr}&per_page=1`,
79139
{ headers, cache: "no-store" }
80140
),
81-
]);
141+
]),
142+
fetchDiscussionCount(session.accessToken!, fromIso, toIso),
143+
]);
144+
145+
const [
146+
reviewsRes,
147+
issuesOpenRes,
148+
issuesClosedRes,
149+
openSourceRes,
150+
docsRes,
151+
] = searchResults;
82152

83153
const getCount = async (r: PromiseSettledResult<Response>) => {
84154
if (r.status !== "fulfilled" || !r.value.ok) return 0;
@@ -99,7 +169,7 @@ export async function GET(req: NextRequest) {
99169
const reviewPoints = Math.min(reviews * 3, 30);
100170
const issuesOpenedPoints = Math.min(issuesOpened * 2, 15);
101171
const issuesClosedPoints = Math.min(issuesClosed * 3, 20);
102-
const discussionsPoints = 0; // placeholder — GitHub Discussions API requires GraphQL
172+
const discussionsPoints = Math.min(discussions * 2, 15);
103173
const openSourcePoints = Math.min(openSourcePrs * 5, 25);
104174
const documentationPoints = Math.min(documentationPrs * 5, 10);
105175

@@ -119,9 +189,12 @@ export async function GET(req: NextRequest) {
119189
reviews: { count: reviews, points: reviewPoints },
120190
issuesOpened: { count: issuesOpened, points: issuesOpenedPoints },
121191
issuesClosed: { count: issuesClosed, points: issuesClosedPoints },
122-
discussions: { count: 0, points: discussionsPoints },
192+
discussions: { count: discussions, points: discussionsPoints },
123193
openSourcePrs: { count: openSourcePrs, points: openSourcePoints },
124-
documentationPrs: { count: documentationPrs, points: documentationPoints },
194+
documentationPrs: {
195+
count: documentationPrs,
196+
points: documentationPoints,
197+
},
125198
},
126199
label: scoreLabel(total),
127200
};
@@ -131,4 +204,4 @@ export async function GET(req: NextRequest) {
131204
);
132205

133206
return Response.json(data);
134-
}
207+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import { GET } from "@/app/api/metrics/devtrack-badges/route";
3+
import { NextRequest } from "next/server";
4+
import { getServerSession } from "next-auth";
5+
6+
vi.mock("next-auth", () => ({
7+
getServerSession: vi.fn(),
8+
}));
9+
10+
vi.mock("@/lib/metrics-cache", () => ({
11+
isMetricsCacheBypassed: vi.fn(() => true),
12+
metricsCacheKey: vi.fn(
13+
(userId: string, endpoint: string, params: Record<string, string>) =>
14+
`metrics:${userId}:${endpoint}:${JSON.stringify(params)}`
15+
),
16+
withMetricsCache: vi.fn(async (_config: unknown, callback: () => unknown) =>
17+
callback()
18+
),
19+
METRICS_CACHE_TTL_SECONDS: {
20+
contributions: 3600,
21+
},
22+
}));
23+
24+
const originalFetch = global.fetch;
25+
26+
function searchResponse(total_count: number) {
27+
return {
28+
ok: true,
29+
json: async () => ({ total_count }),
30+
};
31+
}
32+
33+
function graphqlDiscussions(
34+
discussionsStarted: number,
35+
discussionComments: number
36+
) {
37+
return {
38+
ok: true,
39+
json: async () => ({
40+
data: {
41+
viewer: {
42+
contributionsCollection: {
43+
totalDiscussionContributions: discussionsStarted,
44+
totalDiscussionCommentContributions: discussionComments,
45+
},
46+
},
47+
},
48+
}),
49+
};
50+
}
51+
52+
describe("Community engagement score (devtrack-badges)", () => {
53+
beforeEach(() => {
54+
vi.clearAllMocks();
55+
(getServerSession as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
56+
{
57+
accessToken: "test-token",
58+
githubLogin: "test-user",
59+
githubId: "user-123",
60+
}
61+
);
62+
});
63+
64+
afterEach(() => {
65+
global.fetch = originalFetch;
66+
});
67+
68+
it("returns 401 when unauthenticated", async () => {
69+
(getServerSession as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
70+
null
71+
);
72+
const res = await GET(
73+
new NextRequest("http://localhost/api/metrics/devtrack-badges")
74+
);
75+
expect(res.status).toBe(401);
76+
});
77+
78+
it("includes GraphQL discussion activity in the breakdown", async () => {
79+
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
80+
const url = String(input);
81+
if (url.includes("api.github.com/graphql")) {
82+
return graphqlDiscussions(3, 5) as Response;
83+
}
84+
return searchResponse(0) as Response;
85+
}) as typeof fetch;
86+
87+
const res = await GET(
88+
new NextRequest("http://localhost/api/metrics/devtrack-badges")
89+
);
90+
const data = await res.json();
91+
92+
expect(res.status).toBe(200);
93+
expect(data.breakdown.discussions).toEqual({ count: 8, points: 15 });
94+
expect(data.total).toBe(15);
95+
expect(data.label).toBe("Newcomer");
96+
});
97+
98+
it("reports zero discussions when the user has no recent activity", async () => {
99+
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
100+
const url = String(input);
101+
if (url.includes("api.github.com/graphql")) {
102+
return graphqlDiscussions(0, 0) as Response;
103+
}
104+
return searchResponse(2) as Response;
105+
}) as typeof fetch;
106+
107+
const res = await GET(
108+
new NextRequest("http://localhost/api/metrics/devtrack-badges")
109+
);
110+
const data = await res.json();
111+
112+
expect(data.breakdown.discussions).toEqual({ count: 0, points: 0 });
113+
// 5 search endpoints each return count 2:
114+
// reviews 6, issuesOpened 4, issuesClosed 6, openSource 10, docs 10 = 36
115+
expect(data.total).toBe(36);
116+
});
117+
118+
it("keeps discussions at zero when GraphQL fails", async () => {
119+
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
120+
const url = String(input);
121+
if (url.includes("api.github.com/graphql")) {
122+
return { ok: false, status: 502, json: async () => ({}) } as Response;
123+
}
124+
return searchResponse(1) as Response;
125+
}) as typeof fetch;
126+
127+
const res = await GET(
128+
new NextRequest("http://localhost/api/metrics/devtrack-badges")
129+
);
130+
const data = await res.json();
131+
132+
expect(res.status).toBe(200);
133+
expect(data.breakdown.discussions).toEqual({ count: 0, points: 0 });
134+
expect(data.total).toBeGreaterThan(0);
135+
});
136+
});

0 commit comments

Comments
 (0)