mirror of
https://github.com/Hestia-Homes/agentic-toolkit.git
synced 2026-06-08 11:37:26 +00:00
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>
315 lines
7.8 KiB
TypeScript
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 },
|
|
);
|
|
}
|
|
}
|