diff --git a/apps/bridge/src/routes/user-productivity.ts b/apps/bridge/src/routes/user-productivity.ts index fd63522..5455338 100644 --- a/apps/bridge/src/routes/user-productivity.ts +++ b/apps/bridge/src/routes/user-productivity.ts @@ -26,6 +26,7 @@ import { listGithubProjectsForUser, listGithubReposForUser, listGithubIssueCommentsForCard, + listGithubIssueTimelineForCard, postGithubIssueCommentForCard, syncBoardWithGithub, updateBoardStatusMap, @@ -467,5 +468,19 @@ export function createUserProductivityRouter(): Router { } }); + router.get("/projects/cards/:id/github/timeline", async (req, res) => { + try { + const access = resolveUserTasksAccess(req, "viewer"); + const result = await listGithubIssueTimelineForCard({ + userId: access.ownerUserId, + db: access.db, + cardId: String(req.params.id), + }); + res.json(result); + } catch (err) { + sendErr(err, res); + } + }); + return router; } diff --git a/apps/bridge/src/services/__tests__/github-projects-timeline.test.ts b/apps/bridge/src/services/__tests__/github-projects-timeline.test.ts new file mode 100644 index 0000000..a305446 --- /dev/null +++ b/apps/bridge/src/services/__tests__/github-projects-timeline.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { summarizeGithubTimelineEvent } from "../github-projects.js"; + +describe("summarizeGithubTimelineEvent", () => { + it("formats label and assignee events", () => { + expect( + summarizeGithubTimelineEvent({ + event: "labeled", + actor: { login: "alice" }, + label: { name: "core" }, + }) + ).toBe("alice added label core"); + expect( + summarizeGithubTimelineEvent({ + event: "assigned", + actor: { login: "alice" }, + assignee: { login: "bob" }, + }) + ).toBe("alice assigned bob"); + }); + + it("formats project v2 activity without status detail", () => { + expect( + summarizeGithubTimelineEvent({ + event: "added_to_project_v2", + actor: { login: "github-project-automation[bot]" }, + }) + ).toBe("github-project-automation[bot] added this to a Project"); + expect( + summarizeGithubTimelineEvent({ + event: "project_v2_item_status_changed", + actor: { login: "alice" }, + }) + ).toBe("alice changed the Project status"); + }); + + it("skips comment events and formats close/rename", () => { + expect( + summarizeGithubTimelineEvent({ + event: "commented", + actor: { login: "alice" }, + }) + ).toBeNull(); + expect( + summarizeGithubTimelineEvent({ + event: "closed", + actor: { login: "alice" }, + }) + ).toBe("alice closed this"); + expect( + summarizeGithubTimelineEvent({ + event: "renamed", + actor: { login: "alice" }, + rename: { from: "Old", to: "New" }, + }) + ).toBe('alice renamed from "Old" to "New"'); + }); +}); diff --git a/apps/bridge/src/services/github-projects.ts b/apps/bridge/src/services/github-projects.ts index 88751e8..e07aedf 100644 --- a/apps/bridge/src/services/github-projects.ts +++ b/apps/bridge/src/services/github-projects.ts @@ -2011,4 +2011,220 @@ export async function postGithubIssueCommentForCard(opts: { return mapped; } +export type GithubIssueTimelineEvent = { + id: number; + event: string; + createdAt: string; + actorLogin: string; + actorAvatarUrl: string | null; + summary: string; +}; + +/** Events already shown as Issue comments, or too noisy for the card sheet. */ +const TIMELINE_SKIP_EVENTS = new Set([ + "commented", + "committed", + "head_ref_force_pushed", + "head_ref_deleted", + "head_ref_restored", + "review_requested", + "review_request_removed", + "reviewed", +]); + +type TimelineRaw = { + id?: number; + event?: string; + created_at?: string; + actor?: { login?: string; avatar_url?: string | null } | null; + label?: { name?: string } | null; + assignee?: { login?: string } | null; + milestone?: { title?: string } | null; + rename?: { from?: string; to?: string } | null; + project?: { title?: string } | null; + /** Present on some project_v2 status change payloads. */ + project_title?: string; +}; + +/** + * Turn a GitHub Issue timeline REST event into a short activity line. + * Returns null when the event should be omitted from the card sheet. + */ +export function summarizeGithubTimelineEvent( + raw: TimelineRaw +): string | null { + const event = typeof raw.event === "string" ? raw.event : ""; + if (!event || TIMELINE_SKIP_EVENTS.has(event)) return null; + const actor = raw.actor?.login?.trim() || "Someone"; + const label = raw.label?.name?.trim(); + const assignee = raw.assignee?.login?.trim(); + const milestone = raw.milestone?.title?.trim(); + const projectTitle = + raw.project?.title?.trim() || + (typeof raw.project_title === "string" ? raw.project_title.trim() : ""); + + switch (event) { + case "labeled": + return label ? `${actor} added label ${label}` : `${actor} added a label`; + case "unlabeled": + return label + ? `${actor} removed label ${label}` + : `${actor} removed a label`; + case "assigned": + return assignee + ? `${actor} assigned ${assignee}` + : `${actor} assigned someone`; + case "unassigned": + return assignee + ? `${actor} unassigned ${assignee}` + : `${actor} unassigned someone`; + case "milestoned": + return milestone + ? `${actor} added this to milestone ${milestone}` + : `${actor} set a milestone`; + case "demilestoned": + return milestone + ? `${actor} removed this from milestone ${milestone}` + : `${actor} removed the milestone`; + case "closed": + return `${actor} closed this`; + case "reopened": + return `${actor} reopened this`; + case "renamed": { + const from = raw.rename?.from?.trim(); + const to = raw.rename?.to?.trim(); + if (from && to) return `${actor} renamed from "${from}" to "${to}"`; + return `${actor} renamed this`; + } + case "locked": + return `${actor} locked this conversation`; + case "unlocked": + return `${actor} unlocked this conversation`; + case "pinned": + return `${actor} pinned this`; + case "unpinned": + return `${actor} unpinned this`; + case "transferred": + return `${actor} transferred this`; + case "added_to_project_v2": + return projectTitle + ? `${actor} added this to ${projectTitle}` + : `${actor} added this to a Project`; + case "removed_from_project_v2": + return projectTitle + ? `${actor} removed this from ${projectTitle}` + : `${actor} removed this from a Project`; + case "project_v2_item_status_changed": + return projectTitle + ? `${actor} moved this on ${projectTitle}` + : `${actor} changed the Project status`; + case "converted_to_draft": + return `${actor} converted this to a draft`; + case "ready_for_review": + return `${actor} marked this ready for review`; + case "convert_to_issue": + return `${actor} converted this to an issue`; + case "cross-referenced": + return `${actor} mentioned this in another issue or PR`; + case "referenced": + return `${actor} referenced this`; + case "connected": + return `${actor} connected a tracking reference`; + case "disconnected": + return `${actor} disconnected a tracking reference`; + case "marked_as_duplicate": + return `${actor} marked this as a duplicate`; + case "unmarked_as_duplicate": + return `${actor} unmarked this as a duplicate`; + default: + return `${actor}: ${event.replace(/_/g, " ")}`; + } +} + +function mapRestTimelineEvent(raw: TimelineRaw): GithubIssueTimelineEvent | null { + if (typeof raw.id !== "number" || !Number.isFinite(raw.id)) return null; + const summary = summarizeGithubTimelineEvent(raw); + if (!summary) return null; + return { + id: raw.id, + event: typeof raw.event === "string" ? raw.event : "unknown", + createdAt: typeof raw.created_at === "string" ? raw.created_at : "", + actorLogin: raw.actor?.login?.trim() || "unknown", + actorAvatarUrl: + typeof raw.actor?.avatar_url === "string" ? raw.actor.avatar_url : null, + summary, + }; +} + +/** + * List GitHub Issue timeline/activity events for a linked Issue/PR TaskCard. + * Skips comment events (those come from the comments endpoint). + */ +export async function listGithubIssueTimelineForCard(opts: { + userId: string; + db: AppDatabase; + cardId: string; +}): Promise<{ + linked: boolean; + repo: string | null; + issueNumber: number | null; + url: string | null; + events: GithubIssueTimelineEvent[]; +}> { + const card = await loadOwnedCardGithubContext( + opts.cardId, + opts.userId, + opts.db + ); + const ref = resolveGithubIssueRef(card.context_json); + if (!ref) { + return { + linked: false, + repo: null, + issueNumber: null, + url: null, + events: [], + }; + } + const accessToken = await requireToken(opts.db); + const events: GithubIssueTimelineEvent[] = []; + let page = 1; + while (page <= 10) { + const url = new URL( + `https://api.github.com/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}/issues/${ref.issueNumber}/timeline` + ); + url.searchParams.set("per_page", "100"); + url.searchParams.set("page", String(page)); + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/vnd.github+json", + "User-Agent": "GodMode", + }, + }); + if (!res.ok) { + const status = res.status === 401 || res.status === 403 ? 403 : 502; + throw Object.assign( + new Error(`GitHub timeline list failed (${res.status})`), + { status } + ); + } + const batch = (await res.json()) as TimelineRaw[]; + if (!Array.isArray(batch) || batch.length === 0) break; + for (const row of batch) { + const mapped = mapRestTimelineEvent(row); + if (mapped) events.push(mapped); + } + if (batch.length < 100) break; + page += 1; + } + return { + linked: true, + repo: ref.repo, + issueNumber: ref.issueNumber, + url: ref.url, + events, + }; +} + export { userProjectId, defaultStatusMap, loadProjectMeta }; diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 8b18558..b0e0d20 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -2870,6 +2870,24 @@ export const postUserCardGithubComment = (id: string, body: string) => { method: "POST", body: JSON.stringify({ body }) } ); +export type GithubIssueTimelineEvent = { + id: number; + event: string; + createdAt: string; + actorLogin: string; + actorAvatarUrl: string | null; + summary: string; +}; + +export const fetchUserCardGithubTimeline = (id: string) => + api<{ + linked: boolean; + repo: string | null; + issueNumber: number | null; + url: string | null; + events: GithubIssueTimelineEvent[]; + }>(`/user/projects/cards/${encodeURIComponent(id)}/github/timeline`); + export function slugifyStructureId(raw: string): string { return raw .toLowerCase() diff --git a/apps/web/src/components/intelligence/projects/ProjectsBoard.tsx b/apps/web/src/components/intelligence/projects/ProjectsBoard.tsx index c454d98..a7234c8 100644 --- a/apps/web/src/components/intelligence/projects/ProjectsBoard.tsx +++ b/apps/web/src/components/intelligence/projects/ProjectsBoard.tsx @@ -59,6 +59,7 @@ import { fetchCardComments, fetchUserCardComments, fetchUserCardGithubComments, + fetchUserCardGithubTimeline, postUserCardGithubComment, addCardComment, addUserCardComment, @@ -70,12 +71,16 @@ import { type AiProjectColumn, type AiCardComment, type GithubIssueComment, + type GithubIssueTimelineEvent, } from "@/api"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Markdown } from "@/components/intelligence/Markdown"; import { Dialog, DialogContent, @@ -674,6 +679,16 @@ function CardEditorDialog({ const [githubCommentsError, setGithubCommentsError] = useState( null ); + const [githubTimeline, setGithubTimeline] = useState< + GithubIssueTimelineEvent[] + >([]); + const [githubTimelineLoading, setGithubTimelineLoading] = useState(false); + const [githubTimelineError, setGithubTimelineError] = useState( + null + ); + const [descriptionMode, setDescriptionMode] = useState<"edit" | "preview">( + "edit" + ); const [sheetWidth, setSheetWidth] = useState(readStoredTaskSheetWidth); const [sheetResizing, setSheetResizing] = useState(false); const [composer, setComposer] = useState(""); @@ -805,12 +820,46 @@ function CardEditorDialog({ } }, [card, scope, githubSyncEnabled]); + const reloadGithubTimeline = useCallback(async () => { + if (!card || !isUserScope(scope)) { + setGithubTimeline([]); + setGithubTimelineError(null); + setGithubTimelineLoading(false); + return; + } + const meta = parseGithubCardMeta(card.context_json); + const linked = + Boolean(meta.repo?.includes("/")) && + typeof meta.issueNumber === "number" && + meta.issueNumber > 0; + if (!linked || !githubSyncEnabled) { + setGithubTimeline([]); + setGithubTimelineError(null); + setGithubTimelineLoading(false); + return; + } + setGithubTimelineLoading(true); + setGithubTimelineError(null); + try { + const r = await fetchUserCardGithubTimeline(card.id); + setGithubTimeline(r.events); + } catch (err) { + setGithubTimeline([]); + setGithubTimelineError( + err instanceof Error ? err.message : "Could not load GitHub activity" + ); + } finally { + setGithubTimelineLoading(false); + } + }, [card, scope, githubSyncEnabled]); + // Reset editor fields only when the open card id changes so background // board sync can refresh card props without wiping in-progress edits. useEffect(() => { if (!card) return; setTitle(card.title ?? ""); setDescription(card.description ?? ""); + setDescriptionMode("edit"); setPrompt(card.prompt ?? ""); setTags(parseTags(card.tags_json)); setTagDraft(""); @@ -834,6 +883,7 @@ function CardEditorDialog({ void reloadSubtasks(); void reloadComments(); void reloadGithubComments(); + void reloadGithubTimeline(); setAwaitingRunId(null); if (card.column_id === "review") { fetchWorkflowRuns({ status: "awaiting_input", cardId: card.id }) @@ -1337,9 +1387,15 @@ function CardEditorDialog({ className="h-8 text-xs" /> + {showGithubFields ? ( -
- +
+
+ + {(githubCommentsLoading || githubTimelineLoading) && hasGithubIssue ? ( + + ) : null} +
- ) : null} -
- - -
-
- -