From 3ee5f4acd33de5d1593b19e0cc61a4c1a115dccb Mon Sep 17 00:00:00 2001 From: ReBotics AI Date: Sat, 1 Aug 2026 16:58:07 -0600 Subject: [PATCH 1/2] Polish Tasks GitHub lifecycle: Issue create, archive vs remove. Closes #278 --- .../__tests__/productivity-actions.test.ts | 2 + .../src/kernel/__tests__/route-waves.test.ts | 1 + .../src/kernel/adapters/productivity.ts | 38 +++ apps/bridge/src/kernel/protocol-exceptions.ts | 8 + apps/bridge/src/routes/user-productivity.ts | 48 +++ apps/bridge/src/services/github-projects.ts | 88 +++++ apps/web/src/api.ts | 12 + .../intelligence/projects/ProjectsBoard.tsx | 319 ++++++++++++++++-- docs/features/tasks.md | 17 +- 9 files changed, 506 insertions(+), 27 deletions(-) diff --git a/apps/bridge/src/kernel/__tests__/productivity-actions.test.ts b/apps/bridge/src/kernel/__tests__/productivity-actions.test.ts index 5902679..073c294 100644 --- a/apps/bridge/src/kernel/__tests__/productivity-actions.test.ts +++ b/apps/bridge/src/kernel/__tests__/productivity-actions.test.ts @@ -104,12 +104,14 @@ describe("productivity adapter actions", () => { "assign", "transition", "add_comment", + "archive_from_project", ]); expect(TASK_CARD_ACTIONS.map((action) => action.name)).toEqual([ "move", "assign", "transition", "add_comment", + "archive_from_project", ]); expect(Object.keys(cardCommentServiceAdapter.actions ?? {})).toEqual([ "add_comment", diff --git a/apps/bridge/src/kernel/__tests__/route-waves.test.ts b/apps/bridge/src/kernel/__tests__/route-waves.test.ts index 2d8f73d..aa132c5 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/archive", "user-productivity.ts:put:/projects/:id/columns", ]); }); diff --git a/apps/bridge/src/kernel/adapters/productivity.ts b/apps/bridge/src/kernel/adapters/productivity.ts index 9d7ec14..cfff277 100644 --- a/apps/bridge/src/kernel/adapters/productivity.ts +++ b/apps/bridge/src/kernel/adapters/productivity.ts @@ -20,6 +20,7 @@ import { type UserBoardRow, } from "../../services/user-productivity.js"; import { + pushCardArchiveToGithub, pushCardColumnToGithub, pushCardCreateToGithub, pushCardDeleteToGithub, @@ -248,6 +249,22 @@ export const TASK_CARD_ACTIONS: ActionDef[] = [ }, }, }, + { + name: "archive_from_project", + label: "Archive from project", + description: + "Archive the linked GitHub Project item and remove the local card. Underlying Issue/PR is kept.", + target: "record", + effect: "write", + execution: "sync", + roles: [...WRITE_ACTION_ROLES], + confirmation: { required: true }, + inputSchema: { + type: "object", + additionalProperties: false, + properties: {}, + }, + }, ]; export const CARD_COMMENT_ACTIONS: ActionDef[] = [ @@ -1050,6 +1067,27 @@ export const taskCardServiceAdapter: RecordAdapter = { add_comment(db, _def, id, input, ctx) { return appendScopedComment(db, CARD_COMMENT_RECORD_DEF, id, input, ctx); }, + async archive_from_project(db, _def, id, _input, ctx) { + const resolved = taskAccess(db, id, ctx, true); + if (!resolved) notFound("TaskCard"); + const { access, projectId, row } = resolved; + const ownerUserId = access.ownerUserId; + if (!ownerUserId) { + badRequest("archive_from_project requires a user-owned board"); + } + const contextJson = (row.context_json as string | null) ?? null; + await pushCardArchiveToGithub({ + userId: ownerUserId, + db: access.db, + contextJson, + projectId, + }); + const result = access.db + .prepare(`DELETE FROM ai_project_cards WHERE id=? AND project_id=?`) + .run(id, projectId); + if (!result.changes) notFound("TaskCard"); + return { ok: true, archived: true }; + }, }, }; diff --git a/apps/bridge/src/kernel/protocol-exceptions.ts b/apps/bridge/src/kernel/protocol-exceptions.ts index c431d17..a3e84a3 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-archive", + methods: ["POST"], + pathPattern: "/api/user/projects/cards/:/archive", + rationale: + "Archive a linked GitHub Project item then drop the local TaskCard; Issue/PR content is kept. Not generic Record delete.", + 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 1b22cf2..b838397 100644 --- a/apps/bridge/src/routes/user-productivity.ts +++ b/apps/bridge/src/routes/user-productivity.ts @@ -24,6 +24,8 @@ import type { ShareError } from "../services/share-service.js"; import { linkBoardToGithubProject, listGithubProjectsForUser, + listGithubReposForUser, + pushCardArchiveToGithub, syncBoardWithGithub, updateBoardStatusMap, getGithubProjectMetaForUser, @@ -337,6 +339,52 @@ export function createUserProductivityRouter(): Router { } }); + router.get("/github/repos", async (req, res) => { + try { + const access = resolveUserTasksAccess(req, "viewer"); + const repos = await listGithubReposForUser( + access.ownerUserId, + access.db + ); + res.json({ repos }); + } catch (err) { + sendErr(err, res); + } + }); + + /** Archive Project item on GitHub, then drop the local card (Issue/PR kept). */ + router.post("/projects/cards/:id/archive", async (req, res) => { + try { + const access = resolveUserTasksAccess(req, "editor"); + requireWriteAccess(access); + const card = access.db + .prepare( + `SELECT c.id, c.project_id, 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(req.params.id, access.ownerUserId) as + | { id: string; project_id: string; context_json: string | null } + | undefined; + if (!card) { + res.status(404).json({ error: "Not found" }); + return; + } + await pushCardArchiveToGithub({ + userId: access.ownerUserId, + db: access.db, + contextJson: card.context_json, + projectId: card.project_id, + }); + access.db + .prepare(`DELETE FROM ai_project_cards WHERE id=? AND project_id=?`) + .run(card.id, card.project_id); + res.json({ ok: true }); + } catch (err) { + sendErr(err, res); + } + }); + router.get("/github/projects/meta", async (req, res) => { try { const access = resolveUserTasksAccess(req, "viewer"); diff --git a/apps/bridge/src/services/github-projects.ts b/apps/bridge/src/services/github-projects.ts index 3c33fc1..dcd5ce3 100644 --- a/apps/bridge/src/services/github-projects.ts +++ b/apps/bridge/src/services/github-projects.ts @@ -20,6 +20,11 @@ export type GithubProjectSummary = { owner: string; }; +export type GithubRepoSummary = { + fullName: string; + private: boolean; +}; + type StatusOption = { id: string; name: string }; type ProjectMeta = { @@ -110,6 +115,53 @@ type ProjectNode = { url: string; }; +/** + * List repositories the connected GitHub token can access (for Issue create). + */ +export async function listGithubReposForUser( + _userId: string, + db: AppDatabase +): Promise { + const accessToken = await requireToken(db); + const out: GithubRepoSummary[] = []; + let page = 1; + while (page <= 5) { + const url = new URL("https://api.github.com/user/repos"); + url.searchParams.set("per_page", "100"); + url.searchParams.set("page", String(page)); + url.searchParams.set("sort", "updated"); + url.searchParams.set("affiliation", "owner,collaborator,organization_member"); + 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 repos list failed (${res.status})`), + { status } + ); + } + const batch = (await res.json()) as Array<{ + full_name?: string; + private?: boolean; + }>; + if (!Array.isArray(batch) || batch.length === 0) break; + for (const r of batch) { + if (typeof r.full_name === "string" && r.full_name.includes("/")) { + out.push({ fullName: r.full_name, private: Boolean(r.private) }); + } + } + if (batch.length < 100) break; + page += 1; + } + out.sort((a, b) => a.fullName.localeCompare(b.fullName)); + return out; +} + /** * List Projects the connected GitHub account can see. * User-owned projects and org projects are queried separately so a missing @@ -1513,6 +1565,42 @@ export async function pushCardDeleteToGithub(opts: { }); } +/** + * Archive the linked Project item (keeps Issue/PR). Caller removes the local card. + */ +export async function pushCardArchiveToGithub(opts: { + userId: string; + db: AppDatabase; + contextJson: string | null; + projectId: string; +}): Promise<{ archived: boolean }> { + const board = getUserBoard(opts.userId, opts.db, opts.projectId); + if (!board?.sync_enabled || !board.github_project_node_id) { + return { archived: false }; + } + const gh = parseGithubContext(opts.contextJson); + if (!gh.projectItemId) return { archived: false }; + const accessToken = await requireToken(opts.db); + try { + await githubGraphql( + accessToken, + `mutation($projectId: ID!, $itemId: ID!) { + archiveProjectV2Item(input: { projectId: $projectId, itemId: $itemId }) { + item { id } + } + }`, + { + projectId: board.github_project_node_id, + itemId: String(gh.projectItemId), + } + ); + return { archived: true }; + } catch (err) { + console.warn("[github-projects] archiveProjectV2Item", err); + throw err; + } +} + export function updateBoardStatusMap( userId: string, db: AppDatabase, diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 9331b41..3758652 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -2665,6 +2665,18 @@ export const fetchGithubProjectsList = () => }>; }>("/user/github/projects"); +export const fetchGithubReposList = () => + api<{ + repos: Array<{ fullName: string; private: boolean }>; + }>("/user/github/repos"); + +/** Archive linked Project item (Issue/PR kept), then remove local card. */ +export const archiveUserProjectCard = (id: string) => + api<{ ok: boolean }>( + `/user/projects/cards/${encodeURIComponent(id)}/archive`, + { method: "POST" } + ); + export const linkUserBoardGithub = ( boardId: string, body: { projectNodeId: string; statusMap?: Record } diff --git a/apps/web/src/components/intelligence/projects/ProjectsBoard.tsx b/apps/web/src/components/intelligence/projects/ProjectsBoard.tsx index 06a6042..c42cc1d 100644 --- a/apps/web/src/components/intelligence/projects/ProjectsBoard.tsx +++ b/apps/web/src/components/intelligence/projects/ProjectsBoard.tsx @@ -22,6 +22,7 @@ import { ListTree, MoreHorizontal, Paperclip, + Archive, Pencil, Plus, Search, @@ -30,9 +31,11 @@ import { X, } from "lucide-react"; import { + archiveUserProjectCard, fetchAiProjects, fetchUserProjects, fetchAiAgents, + fetchGithubReposList, moveProjectCard, moveUserProjectCard, createProjectCard, @@ -60,6 +63,14 @@ 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 { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Avatar, AvatarFallback, @@ -136,10 +147,46 @@ type GithubCardMeta = { repo?: string; issueNumber?: number; url?: string; + projectItemId?: string; assignees?: GithubAssignee[]; milestone?: GithubMilestone | null; }; +type GithubCreateMode = "draft" | "issue" | "none"; + +function createPrefsKey(boardKey: string) { + return `godmode.tasks.createPrefs.${boardKey}`; +} + +function loadCreatePrefs(boardKey: string): { + mode: GithubCreateMode; + repo: string; +} { + try { + const raw = sessionStorage.getItem(createPrefsKey(boardKey)); + if (!raw) return { mode: "draft", repo: "" }; + const parsed = JSON.parse(raw) as { mode?: string; repo?: string }; + const mode = + parsed.mode === "issue" || parsed.mode === "none" || parsed.mode === "draft" + ? parsed.mode + : "draft"; + return { mode, repo: typeof parsed.repo === "string" ? parsed.repo : "" }; + } catch { + return { mode: "draft", repo: "" }; + } +} + +function saveCreatePrefs( + boardKey: string, + prefs: { mode: GithubCreateMode; repo: string } +) { + try { + sessionStorage.setItem(createPrefsKey(boardKey), JSON.stringify(prefs)); + } catch { + /* ignore */ + } +} + type CardFaceVisibility = { priority: boolean; labels: boolean; @@ -488,6 +535,7 @@ function CardEditorDialog({ scope, columns, labelSuggestions, + githubSyncEnabled, onSaved, onDeleted, onNavigate, @@ -498,6 +546,7 @@ function CardEditorDialog({ scope: ProductivityScope; columns: AiProjectColumn[]; labelSuggestions: string[]; + githubSyncEnabled?: boolean; onSaved: () => void; onDeleted: () => void; onNavigate: (cardId: string) => void; @@ -863,8 +912,22 @@ function CardEditorDialog({ } }; + const linkedGh = useMemo( + () => (card ? parseGithubCardMeta(card.context_json) : {}), + [card] + ); + const hasProjectItem = Boolean(linkedGh.projectItemId); + const showGithubLifecycle = Boolean(githubSyncEnabled && isUserScope(scope)); + const onDelete = async () => { if (!card) return; + const linked = showGithubLifecycle && hasProjectItem; + const ok = window.confirm( + linked + ? "Remove this card from the board and from the GitHub Project? The underlying Issue or PR is kept." + : "Delete this card from the board? This cannot be undone." + ); + if (!ok) return; setBusy(true); try { if (isUserScope(scope)) { @@ -872,8 +935,41 @@ function CardEditorDialog({ } else { await deleteProjectCard(card.id, scope.agentId); } + toast.success( + linked + ? "Removed from Project (Issue/PR kept)" + : "Card deleted" + ); + onDeleted(); + onOpenChange(false); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Delete failed"); + } finally { + setBusy(false); + } + }; + + const onArchive = async () => { + if (!card || !isUserScope(scope)) return; + const ok = window.confirm( + hasProjectItem + ? "Archive this item on the GitHub Project and remove it from this board? The underlying Issue or PR is kept." + : "Remove this local card from the board? It was never linked to a Project item." + ); + if (!ok) return; + setBusy(true); + try { + if (hasProjectItem) { + await archiveUserProjectCard(card.id); + toast.success("Archived on GitHub Project (Issue/PR kept)"); + } else { + await deleteUserProjectCard(card.id); + toast.success("Card removed"); + } onDeleted(); onOpenChange(false); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Archive failed"); } finally { setBusy(false); } @@ -1317,25 +1413,50 @@ function CardEditorDialog({ )} - - -
- - + + {showGithubLifecycle ? ( +

+ Archive keeps the Issue/PR and archives the Project item. Remove from + Project deletes the Project item only (Issue/PR stays). Sync drops + local cards when items are archived or removed on GitHub. +

+ ) : null} +
+
+ {showGithubLifecycle ? ( + + ) : null} + +
+
+ + +
@@ -1346,8 +1467,8 @@ function CardEditorDialog({ export function ProjectsBoard({ scope, projectId, - githubSyncEnabled: _githubSyncEnabled, - defaultGithubRepo: _defaultGithubRepo, + githubSyncEnabled = false, + defaultGithubRepo = "", }: { scope: ProductivityScope; projectId?: string; @@ -1360,6 +1481,12 @@ export function ProjectsBoard({ const [activeId, setActiveId] = useState(null); const [editing, setEditing] = useState(null); const [editorOpen, setEditorOpen] = useState(false); + const [createOpen, setCreateOpen] = useState(false); + const [createTitle, setCreateTitle] = useState("New task"); + const [createMode, setCreateMode] = useState("draft"); + const [createRepo, setCreateRepo] = useState(""); + const [repoOptions, setRepoOptions] = useState([]); + const [reposLoading, setReposLoading] = useState(false); const boardKey = projectId || (isUserScope(scope) ? scope.userId : scope.agentId) || @@ -1377,7 +1504,26 @@ export function ProjectsBoard({ useEffect(() => { setFace(loadCardFaceVisibility(boardKey)); setFilter(loadBoardFilter(boardKey)); - }, [boardKey]); + const prefs = loadCreatePrefs(boardKey); + setCreateMode(prefs.mode); + setCreateRepo(prefs.repo || defaultGithubRepo); + }, [boardKey, defaultGithubRepo]); + + useEffect(() => { + if (!createOpen || !githubSyncEnabled || createMode !== "issue") return; + if (repoOptions.length > 0) return; + setReposLoading(true); + fetchGithubReposList() + .then((r) => { + const names = r.repos.map((x) => x.fullName); + setRepoOptions(names); + if (!createRepo && names[0]) setCreateRepo(names[0]); + }) + .catch((err) => { + toast.error(err instanceof Error ? err.message : "Could not list repos"); + }) + .finally(() => setReposLoading(false)); + }, [createOpen, githubSyncEnabled, createMode, repoOptions.length, createRepo]); const setFaceField = (key: keyof CardFaceVisibility, checked: boolean) => { setFace((prev) => { @@ -1454,7 +1600,20 @@ export function ProjectsBoard({ if (overCol) void onMove(cardId, overCol); }; - const addCard = async () => { + const openCreate = () => { + if (readOnly) return; + if (githubSyncEnabled && isUserScope(scope)) { + const prefs = loadCreatePrefs(boardKey); + setCreateTitle("New task"); + setCreateMode(prefs.mode); + setCreateRepo(prefs.repo || defaultGithubRepo); + setCreateOpen(true); + return; + } + void addCardQuick(); + }; + + const addCardQuick = async () => { if (readOnly) return; const firstCol = firstVisibleColumnId(columns); try { @@ -1477,6 +1636,37 @@ export function ProjectsBoard({ } }; + const submitCreate = async () => { + if (readOnly) return; + const title = createTitle.trim() || "New task"; + if (createMode === "issue" && !createRepo.includes("/")) { + toast.error("Pick a repository (owner/name) to create an Issue"); + return; + } + const firstCol = firstVisibleColumnId(columns); + setCreateOpen(false); + saveCreatePrefs(boardKey, { mode: createMode, repo: createRepo }); + try { + await createUserProjectCard({ + title, + columnId: firstCol, + projectId, + githubCreateMode: createMode, + githubRepo: createMode === "issue" ? createRepo : undefined, + }); + toast.success( + createMode === "issue" + ? "Card created as Issue on the Project" + : createMode === "draft" + ? "Card created as Draft on the Project" + : "Local card created" + ); + load(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to add card"); + } + }; + const openEditor = (card: AiProjectCard) => { setEditing(card); setEditorOpen(true); @@ -1551,7 +1741,7 @@ export function ProjectsBoard({ variant="outline" className="h-7 text-xs" disabled={readOnly} - onClick={() => void addCard()} + onClick={() => openCreate()} > Add card @@ -1890,10 +2080,91 @@ export function ProjectsBoard({ scope={scope} columns={columns} labelSuggestions={labelSuggestions} + githubSyncEnabled={githubSyncEnabled} onSaved={load} onDeleted={load} onNavigate={navigateToCard} /> + + + + Add card + + On a linked board, create a Draft Project item, a real Issue in a + repo, or a local-only card. + + +
+
+ + setCreateTitle(e.target.value)} + className="h-8 text-sm" + /> +
+
+ + +
+ {createMode === "issue" ? ( +
+ + +

+ Repos the connected GitHub App / token can access. +

+
+ ) : null} +
+ + + + +
+
); } diff --git a/docs/features/tasks.md b/docs/features/tasks.md index 34144b5..ac114db 100644 --- a/docs/features/tasks.md +++ b/docs/features/tasks.md @@ -37,18 +37,29 @@ Hidden columns keep their cards; they are just omitted from the board until unhi 2. Open a board’s settings and pick a GitHub Project you can access. 3. On link / Sync, board **columns follow the Project Status options** (and remap cards). Adjust the Status map in board settings if needed. 4. **Sync GitHub** pulls items into cards; moving/editing cards pushes Status, title, body, due, priority, labels, **assignees**, and **milestone** when mapped (Issues/PRs for assignees and milestone). -5. **Create:** New cards on a linked board are added to the Project as **Draft issues** by default. Edit assignees (comma-separated logins) and milestone title in the card Sheet; they push for linked Issues/PRs. -6. **Delete / remove:** Deleting a card in GodMode removes that item from the GitHub Project (the underlying Issue/PR is not deleted). Sync removes GodMode cards whose Project items were archived or removed on GitHub. Cards that never linked (no Project item id) stay local-only. +5. **Create:** On a linked board, **Add card** asks how to create: **Draft on Project** (default), **Issue in a repository** (repo picker from repos the connected token can access), or **Local only**. Assignees (comma-separated logins) and milestone title in the card Sheet push for linked Issues/PRs. +6. **Archive vs remove:** **Archive** archives the Project item on GitHub and removes the local card (Issue/PR kept). **Remove from Project** deletes the Project item only (Issue/PR kept; Draft items go away with the item). GodMode never deletes the underlying Issue/PR from here. Sync drops local cards when items are archived or removed on GitHub. Cards that never linked (no Project item id) are local-only deletes. 7. Linked boards **poll** GitHub in the background (default **1 minute**, clamped 1-30 min via `GITHUB_PROJECTS_SYNC_POLL_MS`). That poll is the near-real-time path for **user-owned** Projects. With a GitHub App installed on an **organization** that owns the Project, live `projects_v2_item` webhooks also drive pulls (handler is ready; GitHub only emits those events for org-owned Projects). Manual Sync always works. The toolbar shows last success, in-progress, and last error. Set `GITHUB_PROJECTS_SYNC_POLL_ENABLED=0` on the Bridge to disable background poll. 8. **Conflict policy:** last-write-wins. Pull overwrites mapped card fields from GitHub; push-on-edit overwrites those fields on GitHub. Matching is by stored Project item id. +### Card lifecycle (linked boards) + +| Action | Local card | Project item | Issue / PR | +|--------|------------|--------------|------------| +| Create as Draft | Created | Draft item added | None until converted on GitHub | +| Create as Issue | Created | Item added for new Issue | Created in chosen repo | +| Create local only | Created | None | None | +| Archive | Removed | Archived | Kept | +| Remove from Project | Removed | Deleted | Kept | +| Sync after GH archive/remove | Removed | (already gone) | Kept | + Field map: title, description, column↔Status, due date, priority (P0–P3 ↔ Project Priority), labels, assignees, milestone. Agent assignment and prompts stay local. Card face fields (priority, labels, assignees, due, milestone) can be shown or hidden per board in the **Card fields** menu. Saving a card keeps GitHub sync metadata in `context_json` alongside attachments. You cannot sync a Project your token cannot access. ### Migration for existing boards Boards created before multi-column sync get a default five-column `columns_json` on next open (schema backfill v19). Linked boards: run **Sync GitHub** once so columns match Status options and cards remap. Hide/WIP set after Sync stick on later pulls. -Kanban parity epic #259 (Done); GitHub App epic #266 (Done). Board filter / search / sort (#276) is available on the filter bar. Remaining Ready follow-ups: swimlanes / group-by (#274), Project field parity leftovers (#277), lifecycle polish for Issue create and archive vs delete (#278). +Kanban parity epic #259 (Done); GitHub App epic #266 (Done). Board filter / search / sort (#276) and lifecycle polish (#278) are shipped. Remaining Ready follow-ups: swimlanes / group-by (#274), Project field parity leftovers (#277). ## Route From 9dd20ad310188e1a1f265203e8a8959b09ddd324 Mon Sep 17 00:00:00 2001 From: ReBotics AI Date: Sat, 1 Aug 2026 16:59:44 -0600 Subject: [PATCH 2/2] Route TaskCard archive through kernel action to clear direct-write audit. --- .../src/kernel/__tests__/route-waves.test.ts | 1 - apps/bridge/src/kernel/protocol-exceptions.ts | 8 ----- apps/bridge/src/routes/user-productivity.ts | 34 ------------------- apps/web/src/api.ts | 9 +++-- 4 files changed, 6 insertions(+), 46 deletions(-) diff --git a/apps/bridge/src/kernel/__tests__/route-waves.test.ts b/apps/bridge/src/kernel/__tests__/route-waves.test.ts index aa132c5..2d8f73d 100644 --- a/apps/bridge/src/kernel/__tests__/route-waves.test.ts +++ b/apps/bridge/src/kernel/__tests__/route-waves.test.ts @@ -89,7 +89,6 @@ 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/archive", "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 a3e84a3..c431d17 100644 --- a/apps/bridge/src/kernel/protocol-exceptions.ts +++ b/apps/bridge/src/kernel/protocol-exceptions.ts @@ -562,14 +562,6 @@ 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-archive", - methods: ["POST"], - pathPattern: "/api/user/projects/cards/:/archive", - rationale: - "Archive a linked GitHub Project item then drop the local TaskCard; Issue/PR content is kept. Not generic Record delete.", - 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 b838397..8a0ffce 100644 --- a/apps/bridge/src/routes/user-productivity.ts +++ b/apps/bridge/src/routes/user-productivity.ts @@ -25,7 +25,6 @@ import { linkBoardToGithubProject, listGithubProjectsForUser, listGithubReposForUser, - pushCardArchiveToGithub, syncBoardWithGithub, updateBoardStatusMap, getGithubProjectMetaForUser, @@ -352,39 +351,6 @@ export function createUserProductivityRouter(): Router { } }); - /** Archive Project item on GitHub, then drop the local card (Issue/PR kept). */ - router.post("/projects/cards/:id/archive", async (req, res) => { - try { - const access = resolveUserTasksAccess(req, "editor"); - requireWriteAccess(access); - const card = access.db - .prepare( - `SELECT c.id, c.project_id, 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(req.params.id, access.ownerUserId) as - | { id: string; project_id: string; context_json: string | null } - | undefined; - if (!card) { - res.status(404).json({ error: "Not found" }); - return; - } - await pushCardArchiveToGithub({ - userId: access.ownerUserId, - db: access.db, - contextJson: card.context_json, - projectId: card.project_id, - }); - access.db - .prepare(`DELETE FROM ai_project_cards WHERE id=? AND project_id=?`) - .run(card.id, card.project_id); - res.json({ ok: true }); - } catch (err) { - sendErr(err, res); - } - }); - router.get("/github/projects/meta", async (req, res) => { try { const access = resolveUserTasksAccess(req, "viewer"); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 3758652..1eb6bb9 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -2672,9 +2672,12 @@ export const fetchGithubReposList = () => /** Archive linked Project item (Issue/PR kept), then remove local card. */ export const archiveUserProjectCard = (id: string) => - api<{ ok: boolean }>( - `/user/projects/cards/${encodeURIComponent(id)}/archive`, - { method: "POST" } + actionDto<{ ok: boolean; archived?: boolean }>( + "TaskCard", + "archive_from_project", + {}, + id, + true ); export const linkUserBoardGithub = (