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 = { 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; } export interface ProjectFieldRefs { projectId: string; statusFieldId: string; statusOptionIds: Record; } 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(); 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> = {}; 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, }, }; } 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(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 { 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 { 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 { await this.graphql( /* GraphQL */ ` mutation Comment($issueId: ID!, $body: String!) { addComment(input: { subjectId: $issueId, body: $body }) { commentEdge { node { id } } } } `, { issueId: issueNodeId, body }, ); } }