Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 89 additions & 14 deletions src/app/api/metrics/activity/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { NextRequest } from "next/server";
import { authOptions } from "@/lib/auth";
import { getAccountToken, getAllAccounts } from "@/lib/github-accounts";
import { GITHUB_API, fetchUserEvents } from "@/lib/github";
import { githubGraphQL } from "@/lib/github-fetch";
import {
isMetricsCacheBypassed,
METRICS_CACHE_TTL_SECONDS,
Expand All @@ -14,21 +15,91 @@ import { resolveAppUser } from "@/lib/resolve-user";
import {
type ActivityItem,
type RawEvent,
type GraphQLDiscussionCommentNode,
formatActivity,
formatGraphQLDiscussionComment,
mergeActivityItems,
} from "@/lib/activity-formatter";

export const dynamic = "force-dynamic";

// ─── GraphQL discussion query ─────────────────────────────────────────────────

/**
* Fetches the 20 most recent discussion comments the authenticated user has
* made across all repositories. `repositoryDiscussionComments` is the most
* reliable GitHub GraphQL field for this purpose — the REST /user/events
* endpoint does not consistently surface DiscussionCommentEvent entries.
*/
const DISCUSSION_COMMENTS_QUERY = `
query {
viewer {
repositoryDiscussionComments(first: 20) {
nodes {
createdAt
url
discussion {
title
number
url
repository {
nameWithOwner
}
}
}
}
}
}
`;

interface DiscussionCommentsQueryResult {
viewer: {
repositoryDiscussionComments: {
nodes: GraphQLDiscussionCommentNode[];
};
};
}

/**
* Fetches recent discussion-comment activity via GraphQL.
* Returns an empty array on any failure so callers never need to handle
* errors from this path — discussions are supplementary, not a hard
* dependency of the activity feed.
*/
async function fetchDiscussionItemsViaGraphQL(
token: string
): Promise<ActivityItem[]> {
try {
const data = await githubGraphQL<DiscussionCommentsQueryResult>(
DISCUSSION_COMMENTS_QUERY,
token
);
const nodes = data?.viewer?.repositoryDiscussionComments?.nodes ?? [];
return nodes.map(formatGraphQLDiscussionComment);
} catch {
// Discussions may be disabled, rate-limited, or the token may lack the
// required scope — never allow this to block the main activity feed.
return [];
}
}

// ─── Activity fetching ────────────────────────────────────────────────────────

async function fetchFormattedActivity(token: string): Promise<ActivityItem[]> {
const events = (await fetchUserEvents(token)) as RawEvent[];
// Run REST events and GraphQL discussions in parallel.
// fetchDiscussionItemsViaGraphQL always resolves (returns [] on error) so
// Promise.all only rejects if fetchUserEvents throws — preserving the
// existing error-propagation path through fetchFormattedActivityWithFallback.
const [events, discussionItems] = await Promise.all([
fetchUserEvents(token) as Promise<RawEvent[]>,
fetchDiscussionItemsViaGraphQL(token),
]);

return events
const restItems = events
.map(formatActivity)
.filter((item): item is ActivityItem => item !== null)
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
.filter((item): item is ActivityItem => item !== null);

return mergeActivityItems(restItems, discussionItems);
}

async function fetchPublicEvents(
Expand Down Expand Up @@ -64,15 +135,19 @@ async function fetchFormattedActivityWithFallback(
throw new Error("GitHub API error");
}

const events = await fetchPublicEvents(token, githubLogin);
// The primary REST endpoint failed; use the public events fallback.
// Run it in parallel with the GraphQL discussion fetch so discussion
// activity is still included even when /user/events is unavailable.
const [events, discussionItems] = await Promise.all([
fetchPublicEvents(token, githubLogin),
fetchDiscussionItemsViaGraphQL(token),
]);

return events
const restItems = events
.map(formatActivity)
.filter((item): item is ActivityItem => item !== null)
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
.filter((item): item is ActivityItem => item !== null);

return mergeActivityItems(restItems, discussionItems);
}
}

Expand Down
67 changes: 66 additions & 1 deletion src/lib/activity-formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,71 @@ export function formatActivity(event: RawEvent): ActivityItem | null {
url: discussion?.html_url ?? getRepoUrl(repoName),
};
}

return null;
}

// ─── GraphQL discussion types ─────────────────────────────────────────────────

/**
* A single node from the `viewer.repositoryDiscussionComments` GraphQL query.
* Represents a discussion comment authored by the authenticated user.
*/
export interface GraphQLDiscussionCommentNode {
createdAt: string;
url: string;
discussion: {
title: string;
number: number;
url: string;
repository: {
nameWithOwner: string;
};
};
}

/**
* Normalize a GitHub GraphQL discussion comment node into the shared
* ActivityItem format consumed by the RecentActivity widget.
*
* The item URL points to the discussion (not the individual comment) so
* users land on the full context rather than a deep anchor.
*/
export function formatGraphQLDiscussionComment(
node: GraphQLDiscussionCommentNode
): ActivityItem {
return {
id: `gql-disc-${node.url}`,
type: "discussion",
createdAt: node.createdAt,
title: `Commented on discussion #${node.discussion.number}`,
subtitle: node.discussion.title,
repo: node.discussion.repository.nameWithOwner,
url: node.discussion.url,
};
}

/**
* Merge REST-event activity items with GraphQL discussion items.
*
* Deduplication key: `type-repo-createdAt-title` — any item that appears
* in both the REST events feed and the GraphQL discussion feed (e.g. a
* DiscussionCommentEvent from REST that matches a comment from GraphQL)
* is collapsed to a single entry. The result is sorted newest-first.
*/
export function mergeActivityItems(
restItems: ActivityItem[],
discussionItems: ActivityItem[]
): ActivityItem[] {
const all = [...restItems, ...discussionItems];
return Array.from(
new Map(
all.map((item) => [
`${item.type}-${item.repo}-${item.createdAt}-${item.title}`,
item,
])
).values()
).sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}
173 changes: 172 additions & 1 deletion test/activity-formatter.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { describe, it, expect } from "vitest";
import { formatActivity } from "@/lib/activity-formatter";
import {
formatActivity,
formatGraphQLDiscussionComment,
mergeActivityItems,
type GraphQLDiscussionCommentNode,
type ActivityItem,
} from "@/lib/activity-formatter";

describe("formatActivity", () => {
it("formats PushEvent with 1 commit", () => {
Expand Down Expand Up @@ -300,4 +306,169 @@ describe("formatActivity", () => {
const result = formatActivity(event as any);
expect(result?.title).toBe("Closed issue #11");
});
});

// ─── formatGraphQLDiscussionComment ──────────────────────────────────────────

describe("formatGraphQLDiscussionComment", () => {
const baseNode: GraphQLDiscussionCommentNode = {
createdAt: "2024-03-10T14:30:00Z",
url: "https://github.com/owner/repo/discussions/42#discussioncomment-999",
discussion: {
title: "How to contribute",
number: 42,
url: "https://github.com/owner/repo/discussions/42",
repository: { nameWithOwner: "owner/repo" },
},
};

it("sets type to discussion", () => {
expect(formatGraphQLDiscussionComment(baseNode).type).toBe("discussion");
});

it("builds a human-readable title including the discussion number", () => {
expect(formatGraphQLDiscussionComment(baseNode).title).toBe(
"Commented on discussion #42"
);
});

it("uses the discussion title as subtitle", () => {
expect(formatGraphQLDiscussionComment(baseNode).subtitle).toBe(
"How to contribute"
);
});

it("uses the repository nameWithOwner as repo", () => {
expect(formatGraphQLDiscussionComment(baseNode).repo).toBe("owner/repo");
});

it("links to the discussion URL, not the comment-anchor URL", () => {
expect(formatGraphQLDiscussionComment(baseNode).url).toBe(
"https://github.com/owner/repo/discussions/42"
);
});

it("preserves the comment createdAt timestamp", () => {
expect(formatGraphQLDiscussionComment(baseNode).createdAt).toBe(
"2024-03-10T14:30:00Z"
);
});

it("generates a stable id that includes the comment URL", () => {
const item = formatGraphQLDiscussionComment(baseNode);
expect(item.id).toContain("gql-disc");
expect(item.id).toContain(baseNode.url);
});

it("handles a different discussion number correctly", () => {
const node: GraphQLDiscussionCommentNode = {
...baseNode,
discussion: { ...baseNode.discussion, number: 1 },
};
expect(formatGraphQLDiscussionComment(node).title).toBe(
"Commented on discussion #1"
);
});
});

// ─── mergeActivityItems ───────────────────────────────────────────────────────

describe("mergeActivityItems", () => {
function makeItem(
partial: Partial<ActivityItem> & Pick<ActivityItem, "createdAt">
): ActivityItem {
return {
id: partial.id ?? `id-${Math.random()}`,
type: partial.type ?? "push",
createdAt: partial.createdAt,
title: partial.title ?? "Title",
subtitle: partial.subtitle ?? "subtitle",
repo: partial.repo ?? "owner/repo",
url: partial.url ?? "https://github.com/owner/repo",
};
}

it("returns REST items sorted newest-first when discussion array is empty", () => {
const items = [
makeItem({ createdAt: "2024-01-01T10:00:00Z" }),
makeItem({ createdAt: "2024-01-03T10:00:00Z" }),
makeItem({ createdAt: "2024-01-02T10:00:00Z" }),
];
const result = mergeActivityItems(items, []);
expect(result[0].createdAt).toBe("2024-01-03T10:00:00Z");
expect(result[1].createdAt).toBe("2024-01-02T10:00:00Z");
expect(result[2].createdAt).toBe("2024-01-01T10:00:00Z");
});

it("interleaves discussion items with REST items in chronological order", () => {
const restItems = [
makeItem({ id: "r1", createdAt: "2024-01-03T00:00:00Z", type: "push" }),
makeItem({ id: "r2", createdAt: "2024-01-01T00:00:00Z", type: "issue" }),
];
const discussionItems = [
makeItem({ id: "d1", createdAt: "2024-01-02T00:00:00Z", type: "discussion" }),
];
const result = mergeActivityItems(restItems, discussionItems);
expect(result).toHaveLength(3);
expect(result[0].id).toBe("r1");
expect(result[1].id).toBe("d1");
expect(result[2].id).toBe("r2");
});

it("deduplicates items that share the same type, repo, createdAt, and title", () => {
const shared: ActivityItem = {
id: "rest-123",
type: "discussion",
createdAt: "2024-01-15T10:00:00Z",
title: "Commented on discussion #42",
subtitle: "Some topic",
repo: "owner/repo",
url: "https://github.com/owner/repo/discussions/42",
};
const duplicate: ActivityItem = {
...shared,
id: "gql-disc-https://github.com/owner/repo/discussions/42#comment-1",
};
const result = mergeActivityItems([shared], [duplicate]);
expect(result).toHaveLength(1);
});

it("keeps distinct items that differ only by title", () => {
const a = makeItem({
createdAt: "2024-01-15T10:00:00Z",
type: "discussion",
title: "Commented on discussion #1",
});
const b = makeItem({
createdAt: "2024-01-15T10:00:00Z",
type: "discussion",
title: "Commented on discussion #2",
});
const result = mergeActivityItems([a], [b]);
expect(result).toHaveLength(2);
});

it("returns only REST items when discussion array is empty", () => {
const items = [
makeItem({ id: "r1", createdAt: "2024-01-01T00:00:00Z" }),
makeItem({ id: "r2", createdAt: "2024-01-02T00:00:00Z" }),
];
const result = mergeActivityItems(items, []);
expect(result).toHaveLength(2);
expect(result.map((i) => i.id)).toContain("r1");
expect(result.map((i) => i.id)).toContain("r2");
});

it("returns only discussion items when REST array is empty", () => {
const items = [
makeItem({ id: "d1", createdAt: "2024-01-01T00:00:00Z", type: "discussion" }),
];
const result = mergeActivityItems([], items);
expect(result).toHaveLength(1);
expect(result[0].id).toBe("d1");
});

it("returns an empty array when both inputs are empty", () => {
expect(mergeActivityItems([], [])).toEqual([]);
});
});
Loading
Loading