diff --git a/apps/bridge/src/kernel/__tests__/route-waves.test.ts b/apps/bridge/src/kernel/__tests__/route-waves.test.ts index 2d8f73d..1becf26 100644 --- a/apps/bridge/src/kernel/__tests__/route-waves.test.ts +++ b/apps/bridge/src/kernel/__tests__/route-waves.test.ts @@ -89,6 +89,7 @@ describe("legacy route wave", () => { "user-productivity.ts:post:/projects/:id/github/status-map", "user-productivity.ts:post:/projects/:id/github/sync", "user-productivity.ts:post:/projects/:id/github/unlink", + "user-productivity.ts:post:/projects/cards/:id/github/comments", "user-productivity.ts:put:/projects/:id/columns", ]); }); diff --git a/apps/bridge/src/kernel/protocol-exceptions.ts b/apps/bridge/src/kernel/protocol-exceptions.ts index c431d17..1dee3e5 100644 --- a/apps/bridge/src/kernel/protocol-exceptions.ts +++ b/apps/bridge/src/kernel/protocol-exceptions.ts @@ -562,6 +562,14 @@ export const PROTOCOL_EXCEPTIONS: readonly ProtocolException[] = [ "Persist column↔GitHub Status option map for a linked board; sync config, not TaskCard CRUD.", authenticatedDomainMutations: "none", }, + { + id: "user-task-card-github-comment", + methods: ["POST"], + pathPattern: "/api/user/projects/cards/:/github/comments", + rationale: + "Post a GitHub Issue comment for a linked TaskCard via the connected GitHub token; GitHub transport, not local ai_card_comments Record CRUD.", + authenticatedDomainMutations: "none", + }, { id: "ai-workspace-knowledge-import", methods: ["POST"], diff --git a/apps/bridge/src/routes/user-productivity.ts b/apps/bridge/src/routes/user-productivity.ts index 8a0ffce..fd63522 100644 --- a/apps/bridge/src/routes/user-productivity.ts +++ b/apps/bridge/src/routes/user-productivity.ts @@ -25,6 +25,8 @@ import { linkBoardToGithubProject, listGithubProjectsForUser, listGithubReposForUser, + listGithubIssueCommentsForCard, + postGithubIssueCommentForCard, syncBoardWithGithub, updateBoardStatusMap, getGithubProjectMetaForUser, @@ -433,5 +435,37 @@ export function createUserProductivityRouter(): Router { } }); + router.get("/projects/cards/:id/github/comments", async (req, res) => { + try { + const access = resolveUserTasksAccess(req, "viewer"); + const result = await listGithubIssueCommentsForCard({ + userId: access.ownerUserId, + db: access.db, + cardId: String(req.params.id), + }); + res.json(result); + } catch (err) { + sendErr(err, res); + } + }); + + router.post("/projects/cards/:id/github/comments", async (req, res) => { + try { + const access = resolveUserTasksAccess(req, "editor"); + requireWriteAccess(access); + const body = + typeof req.body?.body === "string" ? req.body.body : ""; + const comment = await postGithubIssueCommentForCard({ + userId: access.ownerUserId, + db: access.db, + cardId: String(req.params.id), + body, + }); + res.status(201).json({ comment }); + } catch (err) { + sendErr(err, res); + } + }); + return router; } diff --git a/apps/bridge/src/services/__tests__/github-projects-comments.test.ts b/apps/bridge/src/services/__tests__/github-projects-comments.test.ts new file mode 100644 index 0000000..54d7549 --- /dev/null +++ b/apps/bridge/src/services/__tests__/github-projects-comments.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { resolveGithubIssueRef } from "../github-projects.js"; + +describe("resolveGithubIssueRef", () => { + it("reads repo and issue number from github context", () => { + const raw = JSON.stringify({ + github: { + repo: "ReBoticsAI/GodMode", + issueNumber: 293, + url: "https://github.com/ReBoticsAI/GodMode/issues/293", + projectItemId: "PVTI_1", + }, + }); + expect(resolveGithubIssueRef(raw)).toEqual({ + repo: "ReBoticsAI/GodMode", + owner: "ReBoticsAI", + name: "GodMode", + issueNumber: 293, + url: "https://github.com/ReBoticsAI/GodMode/issues/293", + }); + }); + + it("returns null for draft items without an issue number", () => { + const raw = JSON.stringify({ + github: { projectItemId: "PVTI_1", contentId: "DI_1" }, + }); + expect(resolveGithubIssueRef(raw)).toBeNull(); + }); + + it("returns null for missing or invalid context", () => { + expect(resolveGithubIssueRef(null)).toBeNull(); + expect(resolveGithubIssueRef("{")).toBeNull(); + expect( + resolveGithubIssueRef( + JSON.stringify({ github: { repo: "nope", issueNumber: 1 } }) + ) + ).toBeNull(); + }); +}); diff --git a/apps/bridge/src/services/github-projects.ts b/apps/bridge/src/services/github-projects.ts index e5110c5..88751e8 100644 --- a/apps/bridge/src/services/github-projects.ts +++ b/apps/bridge/src/services/github-projects.ts @@ -1806,4 +1806,209 @@ export async function getGithubProjectMetaForUser( }; } +export type GithubIssueComment = { + id: number; + body: string; + createdAt: string; + updatedAt: string; + url: string; + authorLogin: string; + authorAvatarUrl: string | null; +}; + +export type GithubIssueRef = { + repo: string; + owner: string; + name: string; + issueNumber: number; + url: string | null; +}; + +/** Resolve owner/repo + issue number from a TaskCard context_json github blob. */ +export function resolveGithubIssueRef( + contextJson: string | null +): GithubIssueRef | null { + const gh = parseGithubContext(contextJson); + const repo = typeof gh.repo === "string" ? gh.repo.trim() : ""; + const issueNumber = + typeof gh.issueNumber === "number" && Number.isFinite(gh.issueNumber) + ? Math.floor(gh.issueNumber) + : null; + if (!repo.includes("/") || issueNumber == null || issueNumber <= 0) { + return null; + } + const [owner, name] = repo.split("/", 2); + if (!owner || !name) return null; + const url = typeof gh.url === "string" ? gh.url : null; + return { repo, owner, name, issueNumber, url }; +} + +function mapRestIssueComment(raw: { + id?: number; + body?: string | null; + created_at?: string; + updated_at?: string; + html_url?: string; + user?: { login?: string; avatar_url?: string | null } | null; +}): GithubIssueComment | null { + if (typeof raw.id !== "number" || !Number.isFinite(raw.id)) return null; + return { + id: raw.id, + body: typeof raw.body === "string" ? raw.body : "", + createdAt: typeof raw.created_at === "string" ? raw.created_at : "", + updatedAt: typeof raw.updated_at === "string" ? raw.updated_at : "", + url: typeof raw.html_url === "string" ? raw.html_url : "", + authorLogin: raw.user?.login?.trim() || "unknown", + authorAvatarUrl: + typeof raw.user?.avatar_url === "string" ? raw.user.avatar_url : null, + }; +} + +async function loadOwnedCardGithubContext( + cardId: string, + userId: string, + db: AppDatabase +): Promise<{ context_json: string | null }> { + const card = db + .prepare( + `SELECT c.context_json FROM ai_project_cards c + JOIN ai_projects p ON p.id = c.project_id + WHERE c.id=? AND p.user_id=?` + ) + .get(cardId, userId) as { context_json: string | null } | undefined; + if (!card) { + throw Object.assign(new Error("Card not found"), { status: 404 }); + } + return card; +} + +/** + * List GitHub Issue comments for a linked Issue/PR TaskCard. + * Draft Project items (no issue number) return linked:false. + */ +export async function listGithubIssueCommentsForCard(opts: { + userId: string; + db: AppDatabase; + cardId: string; +}): Promise<{ + linked: boolean; + repo: string | null; + issueNumber: number | null; + url: string | null; + comments: GithubIssueComment[]; +}> { + 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, + comments: [], + }; + } + const accessToken = await requireToken(opts.db); + const comments: GithubIssueComment[] = []; + let page = 1; + while (page <= 10) { + const url = new URL( + `https://api.github.com/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}/issues/${ref.issueNumber}/comments` + ); + 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 comments list failed (${res.status})`), + { status } + ); + } + const batch = (await res.json()) as Array[0]>; + if (!Array.isArray(batch) || batch.length === 0) break; + for (const row of batch) { + const mapped = mapRestIssueComment(row); + if (mapped) comments.push(mapped); + } + if (batch.length < 100) break; + page += 1; + } + return { + linked: true, + repo: ref.repo, + issueNumber: ref.issueNumber, + url: ref.url, + comments, + }; +} + +/** Post a GitHub Issue comment on a linked Issue/PR TaskCard. */ +export async function postGithubIssueCommentForCard(opts: { + userId: string; + db: AppDatabase; + cardId: string; + body: string; +}): Promise { + const text = opts.body.trim(); + if (!text) { + throw Object.assign(new Error("Comment body required"), { status: 400 }); + } + const card = await loadOwnedCardGithubContext( + opts.cardId, + opts.userId, + opts.db + ); + const ref = resolveGithubIssueRef(card.context_json); + if (!ref) { + throw Object.assign( + new Error("Card is not linked to a GitHub Issue or Pull Request"), + { status: 400 } + ); + } + const accessToken = await requireToken(opts.db); + const res = await fetch( + `https://api.github.com/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}/issues/${ref.issueNumber}/comments`, + { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/vnd.github+json", + "Content-Type": "application/json", + "User-Agent": "GodMode", + }, + body: JSON.stringify({ body: text }), + } + ); + if (!res.ok) { + const status = res.status === 401 || res.status === 403 ? 403 : 502; + let detail = `GitHub comment create failed (${res.status})`; + try { + const errJson = (await res.json()) as { message?: string }; + if (errJson.message) detail = errJson.message; + } catch { + /* keep default */ + } + throw Object.assign(new Error(detail), { status }); + } + const raw = (await res.json()) as Parameters[0]; + const mapped = mapRestIssueComment(raw); + if (!mapped) { + throw Object.assign(new Error("GitHub returned an invalid comment"), { + status: 502, + }); + } + return mapped; +} + export { userProjectId, defaultStatusMap, loadProjectMeta }; diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 1eb6bb9..8b18558 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -2845,6 +2845,31 @@ export const addUserCardComment = ( return addCardComment(id, body, author); }; +export type GithubIssueComment = { + id: number; + body: string; + createdAt: string; + updatedAt: string; + url: string; + authorLogin: string; + authorAvatarUrl: string | null; +}; + +export const fetchUserCardGithubComments = (id: string) => + api<{ + linked: boolean; + repo: string | null; + issueNumber: number | null; + url: string | null; + comments: GithubIssueComment[]; + }>(`/user/projects/cards/${encodeURIComponent(id)}/github/comments`); + +export const postUserCardGithubComment = (id: string, body: string) => + api<{ comment: GithubIssueComment }>( + `/user/projects/cards/${encodeURIComponent(id)}/github/comments`, + { method: "POST", body: JSON.stringify({ body }) } + ); + 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 22b1879..300a59e 100644 --- a/apps/web/src/components/intelligence/projects/ProjectsBoard.tsx +++ b/apps/web/src/components/intelligence/projects/ProjectsBoard.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { DndContext, DragOverlay, @@ -23,6 +23,8 @@ import { MoreHorizontal, Paperclip, Archive, + ExternalLink, + Loader2, Pencil, Plus, Search, @@ -48,6 +50,8 @@ import { fetchUserCardSubtasks, fetchCardComments, fetchUserCardComments, + fetchUserCardGithubComments, + postUserCardGithubComment, addCardComment, addUserCardComment, fetchWorkflowRuns, @@ -57,6 +61,7 @@ import { type AiProjectCard, type AiProjectColumn, type AiCardComment, + type GithubIssueComment, } from "@/api"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -615,6 +620,12 @@ function CardEditorDialog({ const [newSubtask, setNewSubtask] = useState(""); const [comments, setComments] = useState([]); const [activityComments, setActivityComments] = useState([]); + const [githubComments, setGithubComments] = useState([]); + const [githubCommentsLinked, setGithubCommentsLinked] = useState(false); + const [githubCommentsLoading, setGithubCommentsLoading] = useState(false); + const [githubCommentsError, setGithubCommentsError] = useState( + null + ); const [composer, setComposer] = useState(""); const [awaitingRunId, setAwaitingRunId] = useState(null); const [agents, setAgents] = useState([]); @@ -632,6 +643,13 @@ function CardEditorDialog({ [card?.context_json] ); const showGithubFields = isUserScope(scope); + const hasGithubIssue = + Boolean(ghMeta.repo?.includes("/")) && + typeof ghMeta.issueNumber === "number" && + ghMeta.issueNumber > 0; + const useGithubComments = Boolean( + githubSyncEnabled && isUserScope(scope) && hasGithubIssue + ); useEffect(() => { fetchAiAgents() @@ -701,6 +719,44 @@ function CardEditorDialog({ } }, [card, scope, userId]); + const reloadGithubComments = useCallback(async () => { + if (!card || !isUserScope(scope)) { + setGithubComments([]); + setGithubCommentsLinked(false); + setGithubCommentsError(null); + return; + } + const meta = parseGithubCardMeta(card.context_json); + const linked = + Boolean(meta.repo?.includes("/")) && + typeof meta.issueNumber === "number" && + meta.issueNumber > 0; + if (!linked || !githubSyncEnabled) { + setGithubComments([]); + setGithubCommentsLinked(false); + setGithubCommentsError(null); + setGithubCommentsLoading(false); + return; + } + setGithubCommentsLoading(true); + setGithubCommentsError(null); + try { + const r = await fetchUserCardGithubComments(card.id); + setGithubCommentsLinked(r.linked); + setGithubComments(r.comments); + } catch (err) { + setGithubComments([]); + setGithubCommentsLinked(true); + setGithubCommentsError( + err instanceof Error ? err.message : "Could not load Issue comments" + ); + } finally { + setGithubCommentsLoading(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 ?? ""); @@ -727,13 +783,15 @@ function CardEditorDialog({ setNewSubtask(""); void reloadSubtasks(); void reloadComments(); + void reloadGithubComments(); setAwaitingRunId(null); if (card.column_id === "review") { fetchWorkflowRuns({ status: "awaiting_input", cardId: card.id }) .then((r) => setAwaitingRunId(r.runs[0]?.id ?? null)) .catch(() => setAwaitingRunId(null)); } - }, [card, reloadSubtasks, reloadComments]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- preserve edits across soft card refreshes + }, [card?.id]); const subtaskProgress = useMemo(() => { const doneId = doneColumnId(columns); @@ -798,6 +856,12 @@ function CardEditorDialog({ const postComment = async () => { if (!card || !composer.trim()) return; try { + if (useGithubComments) { + await postUserCardGithubComment(card.id, composer.trim()); + setComposer(""); + void reloadGithubComments(); + return; + } if (isUserScope(scope)) { await addUserCardComment(card.id, composer.trim(), "user", userId); } else { @@ -810,6 +874,21 @@ function CardEditorDialog({ } }; + const formatCommentTime = (iso: string) => { + const ms = Date.parse(iso); + if (!Number.isFinite(ms)) return iso; + try { + return new Date(ms).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); + } catch { + return iso; + } + }; + const onApprove = async () => { if (!awaitingRunId) return; setBusy(true); @@ -1049,9 +1128,9 @@ function CardEditorDialog({ - + {card?.parent_card_id && (