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
1 change: 1 addition & 0 deletions apps/bridge/src/kernel/__tests__/route-waves.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]);
});
Expand Down
8 changes: 8 additions & 0 deletions apps/bridge/src/kernel/protocol-exceptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
34 changes: 34 additions & 0 deletions apps/bridge/src/routes/user-productivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
linkBoardToGithubProject,
listGithubProjectsForUser,
listGithubReposForUser,
listGithubIssueCommentsForCard,
postGithubIssueCommentForCard,
syncBoardWithGithub,
updateBoardStatusMap,
getGithubProjectMetaForUser,
Expand Down Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
@@ -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();
});
});
205 changes: 205 additions & 0 deletions apps/bridge/src/services/github-projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Parameters<typeof mapRestIssueComment>[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<GithubIssueComment> {
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<typeof mapRestIssueComment>[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 };
25 changes: 25 additions & 0 deletions apps/web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading