From 274d5b997e0bee5fb5a0b16ae5c5f61ae451b8a6 Mon Sep 17 00:00:00 2001 From: ReBotics AI Date: Sat, 1 Aug 2026 22:50:45 -0600 Subject: [PATCH] feat(tasks): show GitHub timeline in card sheet and regroup fields Surface Issue timeline activity (labels, assignees, Project moves) via App auth, group GitHub vs GodMode fields in the sheet, and add Description Edit/Preview Markdown toggle. --- apps/bridge/src/routes/user-productivity.ts | 15 + .../github-projects-timeline.test.ts | 58 + apps/bridge/src/services/github-projects.ts | 216 ++++ apps/web/src/api.ts | 18 + .../intelligence/projects/ProjectsBoard.tsx | 1139 ++++++++++------- docs/features/tasks.md | 3 +- 6 files changed, 1004 insertions(+), 445 deletions(-) create mode 100644 apps/bridge/src/services/__tests__/github-projects-timeline.test.ts 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} -
- - -
-
- -