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
2 changes: 2 additions & 0 deletions apps/bridge/src/kernel/__tests__/productivity-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
38 changes: 38 additions & 0 deletions apps/bridge/src/kernel/adapters/productivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
type UserBoardRow,
} from "../../services/user-productivity.js";
import {
pushCardArchiveToGithub,
pushCardColumnToGithub,
pushCardCreateToGithub,
pushCardDeleteToGithub,
Expand Down Expand Up @@ -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[] = [
Expand Down Expand Up @@ -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 };
},
},
};

Expand Down
14 changes: 14 additions & 0 deletions apps/bridge/src/routes/user-productivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type { ShareError } from "../services/share-service.js";
import {
linkBoardToGithubProject,
listGithubProjectsForUser,
listGithubReposForUser,
syncBoardWithGithub,
updateBoardStatusMap,
getGithubProjectMetaForUser,
Expand Down Expand Up @@ -337,6 +338,19 @@ 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);
}
});

router.get("/github/projects/meta", async (req, res) => {
try {
const access = resolveUserTasksAccess(req, "viewer");
Expand Down
88 changes: 88 additions & 0 deletions apps/bridge/src/services/github-projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ export type GithubProjectSummary = {
owner: string;
};

export type GithubRepoSummary = {
fullName: string;
private: boolean;
};

type StatusOption = { id: string; name: string };

type ProjectMeta = {
Expand Down Expand Up @@ -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<GithubRepoSummary[]> {
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
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2665,6 +2665,21 @@ 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) =>
actionDto<{ ok: boolean; archived?: boolean }>(
"TaskCard",
"archive_from_project",
{},
id,
true
);

export const linkUserBoardGithub = (
boardId: string,
body: { projectNodeId: string; statusMap?: Record<string, string> }
Expand Down
Loading
Loading