agentic-toolkit/src/modules/project-state-client.ts
Khalim Conn-Kowlessar 1d8a77b29b feat: scaffold agentic-toolkit (runner + skills + setup)
Initial implementation of Domna's agentic toolkit per PRD #1:

- Runner CLI (src/cli.ts) wrapping sandcastle.run() with Docker provider
- Pure modules: PhaseScheduler, PromptBuilder, FailureHandler with tests
- Project Status v2 GraphQL client + parsers with tests
- BranchManager (git/gh wrapper) and LoopOrchestrator (per-tick algorithm)
- Variant-aware: per-ticket (one PR per issue, phase-gated, exit between phases)
  vs single-pr (one PR for the whole DAG, halt on failure)
- /to-project skill that creates a repo-level project, configures the Status
  schema the runner expects, and sets initial issue statuses
- setup.sh that installs Matt Pocock skills + Domna skills via npx skills

Out of scope at v1: remote runners, Slack notifications, stacked PRs,
cross-repo projects, SHA-pinning of upstream skills (tracks HEAD until the
skills CLI supports repo#sha).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 12:40:26 +01:00

315 lines
7.8 KiB
TypeScript

import { graphql as defaultGraphql } from "@octokit/graphql";
import type {
IssueKind,
IssueStatus,
ProjectIssue,
ProjectState,
} from "../types.js";
type GraphqlClient = typeof defaultGraphql;
const STATUS_FIELD_NAME = "Status";
const STATUS_NAME_TO_TYPE: Record<string, IssueStatus> = {
Backlog: "Backlog",
Ready: "Ready",
"In progress": "In progress",
"In review": "In review",
"Needs human": "Needs human",
Done: "Done",
};
export const PROJECT_QUERY = /* GraphQL */ `
query ProjectState($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
projectV2(number: $number) {
id
title
field(name: "Status") {
... on ProjectV2SingleSelectField {
id
name
options {
id
name
}
}
}
items(first: 100) {
nodes {
id
fieldValues(first: 20) {
nodes {
... on ProjectV2ItemFieldSingleSelectValue {
name
field {
... on ProjectV2SingleSelectField {
name
}
}
}
}
}
content {
... on Issue {
id
number
title
body
assignees(first: 5) {
nodes {
login
}
}
labels(first: 20) {
nodes {
name
}
}
}
}
}
}
}
}
}
`;
interface RawProjectResponse {
repository: {
projectV2: {
id: string;
title: string;
field: { id: string; name: string; options: { id: string; name: string }[] } | null;
items: { nodes: RawItem[] };
} | null;
};
}
interface RawItem {
id: string;
fieldValues: {
nodes: Array<{
name?: string;
field?: { name?: string };
}>;
};
content:
| {
id: string;
number: number;
title: string;
body: string;
assignees: { nodes: { login: string }[] };
labels: { nodes: { name: string }[] };
}
| Record<string, never>;
}
export interface ProjectFieldRefs {
projectId: string;
statusFieldId: string;
statusOptionIds: Record<IssueStatus, string>;
}
export function detectKindFromLabels(labels: string[]): IssueKind {
const lower = labels.map((l) => l.toLowerCase());
if (lower.includes("hitl")) return "HITL";
if (lower.includes("ready-for-human")) return "HITL";
return "AFK";
}
export function parseBlockedByFromBody(body: string): number[] {
const out = new Set<number>();
const blockedSection = body.match(
/##\s*Blocked\s*by\s*\n([\s\S]*?)(\n##\s|$)/i,
);
const haystack = blockedSection?.[1] ?? body;
const refRegex = /#(\d+)\b/g;
let m: RegExpExecArray | null;
while ((m = refRegex.exec(haystack)) !== null) {
if (m[1]) out.add(Number.parseInt(m[1], 10));
}
return [...out].sort((a, b) => a - b);
}
export function parseProjectResponse(
raw: RawProjectResponse,
ownerLogin: string,
repo: string,
projectNumber: number,
): { state: ProjectState; refs: ProjectFieldRefs } {
const project = raw.repository.projectV2;
if (!project) throw new Error(`Project #${projectNumber} not found in ${ownerLogin}/${repo}`);
const statusField = project.field;
if (!statusField) {
throw new Error(
`Project #${projectNumber} is missing the required "${STATUS_FIELD_NAME}" single-select field. Run /to-project to scaffold it.`,
);
}
const statusOptionIds: Partial<Record<IssueStatus, string>> = {};
for (const opt of statusField.options) {
const mapped = STATUS_NAME_TO_TYPE[opt.name];
if (mapped) statusOptionIds[mapped] = opt.id;
}
const required: IssueStatus[] = [
"Backlog",
"Ready",
"In progress",
"In review",
"Needs human",
"Done",
];
const missing = required.filter((s) => !statusOptionIds[s]);
if (missing.length > 0) {
throw new Error(
`Project #${projectNumber} Status field is missing options: ${missing.join(", ")}. Run /to-project to scaffold them.`,
);
}
const issues: ProjectIssue[] = [];
for (const item of project.items.nodes) {
const content = item.content;
if (!content || !("number" in content)) continue;
const labels = content.labels.nodes.map((l) => l.name);
const statusName = item.fieldValues.nodes.find(
(n) => n.field?.name === STATUS_FIELD_NAME,
)?.name;
const status: IssueStatus = statusName
? (STATUS_NAME_TO_TYPE[statusName] ?? "Backlog")
: "Backlog";
issues.push({
number: content.number,
nodeId: content.id,
itemId: item.id,
title: content.title,
body: content.body ?? "",
kind: detectKindFromLabels(labels),
status,
blockedBy: parseBlockedByFromBody(content.body ?? ""),
assignee: content.assignees.nodes[0]?.login,
});
}
return {
state: {
projectId: project.id,
projectNumber,
ownerLogin,
repo,
issues,
},
refs: {
projectId: project.id,
statusFieldId: statusField.id,
statusOptionIds: statusOptionIds as Record<IssueStatus, string>,
},
};
}
export class ProjectStateClient {
constructor(
private readonly graphql: GraphqlClient,
private readonly ownerLogin: string,
private readonly repo: string,
) {}
async readProjectState(
projectNumber: number,
): Promise<{ state: ProjectState; refs: ProjectFieldRefs }> {
const raw = await this.graphql<RawProjectResponse>(PROJECT_QUERY, {
owner: this.ownerLogin,
repo: this.repo,
number: projectNumber,
});
return parseProjectResponse(
raw,
this.ownerLogin,
this.repo,
projectNumber,
);
}
async setStatus(
projectId: string,
itemId: string,
statusFieldId: string,
statusOptionId: string,
): Promise<void> {
await this.graphql(
/* GraphQL */ `
mutation SetStatus(
$projectId: ID!
$itemId: ID!
$fieldId: ID!
$optionId: String!
) {
updateProjectV2ItemFieldValue(
input: {
projectId: $projectId
itemId: $itemId
fieldId: $fieldId
value: { singleSelectOptionId: $optionId }
}
) {
projectV2Item {
id
}
}
}
`,
{ projectId, itemId, fieldId: statusFieldId, optionId: statusOptionId },
);
}
async assignIssue(issueNodeId: string, userLogin: string): Promise<void> {
const userQuery = await this.graphql<{ user: { id: string } }>(
/* GraphQL */ `
query GetUser($login: String!) {
user(login: $login) {
id
}
}
`,
{ login: userLogin },
);
await this.graphql(
/* GraphQL */ `
mutation Assign($issueId: ID!, $userId: ID!) {
addAssigneesToAssignable(
input: { assignableId: $issueId, assigneeIds: [$userId] }
) {
assignable {
... on Issue {
id
}
}
}
}
`,
{ issueId: issueNodeId, userId: userQuery.user.id },
);
}
async postIssueComment(issueNodeId: string, body: string): Promise<void> {
await this.graphql(
/* GraphQL */ `
mutation Comment($issueId: ID!, $body: String!) {
addComment(input: { subjectId: $issueId, body: $body }) {
commentEdge {
node {
id
}
}
}
}
`,
{ issueId: issueNodeId, body },
);
}
}