UNPKG

@sisu-ai/tool-github-projects

Version:

Utilities for integrating with GitHub Projects (Projects v2) via GraphQL. Exposes tools to list issues, fetch issue details, list status columns, move issues between columns, and update issue title/body.

274 lines (273 loc) 9.39 kB
import { firstConfigValue } from '@sisu-ai/core'; import { z } from 'zod'; // --- GitHub GraphQL client helpers --- function resolveGraphQLEndpoint() { const explicit = firstConfigValue(['GITHUB_GRAPHQL_URL']); if (explicit) return explicit.replace(/\/$/, ''); const host = firstConfigValue(['GITHUB_ENTERPRISE_HOSTNAME']) || 'https://api.github.com'; return host.replace(/\/$/, '') + '/graphql'; } function resolveToken() { return firstConfigValue(['GITHUB_ACCESS_TOKEN', 'GITHUB_TOKEN']); } async function ghGraphQL(query, variables, ctx) { const endpoint = resolveGraphQLEndpoint(); const token = resolveToken(); if (!token) throw new Error('Missing GitHub token. Set GITHUB_ACCESS_TOKEN or GITHUB_TOKEN.'); const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, body: JSON.stringify({ query, variables }), signal: ctx.signal, }); const text = await res.text(); if (!res.ok) throw new Error(`GitHub GraphQL HTTP ${res.status}: ${text?.slice(0, 500)}`); let json = {}; try { json = text ? JSON.parse(text) : {}; } catch { throw new Error('GitHub GraphQL returned invalid JSON'); } if (json.errors) throw new Error(`GitHub GraphQL error: ${JSON.stringify(json.errors)}`); return json; } function requireProjectId() { const pid = firstConfigValue(['GITHUB_PROJECT_ID']); if (!pid) throw new Error('GITHUB_PROJECT_ID is not set.'); return pid; } async function listProjectIssues(ctx) { const projectId = requireProjectId(); const query = ` query($projectId: ID!) { node(id: $projectId) { ... on ProjectV2 { items(first: 100) { nodes { id content { ... on Issue { id title state url } } } } } } } `; const json = await ghGraphQL(query, { projectId }, ctx); const nodes = json?.data?.node?.items?.nodes ?? []; const out = []; for (const it of nodes) { const c = it?.content; if (c?.id && c?.title) out.push({ id: c.id, itemId: it.id, title: c.title, state: c.state, url: c.url }); } return out; } async function getIssueDetails(issueId, ctx) { const query = ` query($id: ID!) { node(id: $id) { ... on Issue { title body state url createdAt updatedAt author { login } } } } `; const json = await ghGraphQL(query, { id: issueId }, ctx); const n = json?.data?.node; if (!n) return undefined; return { title: n.title, body: n.body, state: n.state, url: n.url, createdAt: n.createdAt, updatedAt: n.updatedAt, author: n.author?.login }; } async function getStatusFieldId(ctx) { const projectId = requireProjectId(); const query = ` query($projectId: ID!) { node(id: $projectId) { ... on ProjectV2 { fields(first: 50) { nodes { ... on ProjectV2SingleSelectField { id name } } } } } } `; const json = await ghGraphQL(query, { projectId }, ctx); const fields = json?.data?.node?.fields?.nodes ?? []; const status = fields.find(f => f?.name === 'Status') || fields[0]; if (!status?.id) throw new Error('Could not find Status field in project'); return status.id; } async function getProjectItemId(issueId, ctx) { const list = await listProjectIssues(ctx); const found = list.find(i => i.id === issueId); if (!found?.itemId) throw new Error(`Issue ${issueId} not found in project`); return found.itemId; } async function listStatusOptions(ctx) { const projectId = requireProjectId(); const query = ` query($projectId: ID!) { node(id: $projectId) { ... on ProjectV2 { fields(first: 100) { nodes { __typename ... on ProjectV2SingleSelectField { id name options { id name } } } } } } } `; const json = await ghGraphQL(query, { projectId }, ctx); const fields = json?.data?.node?.fields?.nodes ?? []; const statusField = fields.find((f) => f?.__typename === 'ProjectV2SingleSelectField' && f?.name === 'Status'); if (!statusField) return []; const fieldId = statusField.id; const options = statusField.options ?? []; return options.map(o => ({ id: o.id, name: o.name, fieldId })); } async function moveIssueToColumn(issueId, optionId, ctx) { const projectId = requireProjectId(); const fieldId = await getStatusFieldId(ctx); const itemId = await getProjectItemId(issueId, ctx); const mutation = ` mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $singleSelectOptionId: String!) { updateProjectV2ItemFieldValue(input: { projectId: $projectId, itemId: $itemId, fieldId: $fieldId, value: { singleSelectOptionId: $singleSelectOptionId } }) { projectV2Item { id } } } `; const json = await ghGraphQL(mutation, { projectId, itemId, fieldId, singleSelectOptionId: optionId }, ctx); const updated = json?.data?.updateProjectV2ItemFieldValue?.projectV2Item?.id; if (!updated) throw new Error('Failed to move issue'); return { itemId }; } async function updateIssue(issueId, title, body, ctx) { const mutation = ` mutation($input: UpdateIssueInput!) { updateIssue(input: $input) { issue { id title body } } } `; const input = { id: issueId }; if (typeof title === 'string') input.title = title; if (typeof body === 'string') input.body = body; const json = await ghGraphQL(mutation, { input }, ctx); const issue = json?.data?.updateIssue?.issue; if (!issue?.id) throw new Error('Failed to update issue'); return issue; } // --- Tools --- export const listGitHubIssues = { name: 'listGitHubIssues', description: 'List all issues in the configured GitHub Project (Projects v2). Returns id, title, state, url.', schema: z.object({}).strict(), handler: async (_args, ctx) => { try { const issues = await listProjectIssues(ctx); return issues; } catch (e) { return `Failed to list issues: ${e?.message || String(e)}`; } }, }; export const getGitHubIssueDetails = { name: 'getGitHubIssueDetails', description: 'Get details for an issue by node ID (e.g., I_123).', schema: z.object({ issueId: z.string().min(3) }), handler: async ({ issueId }, ctx) => { if (!issueId.startsWith('I_')) return 'Invalid or missing issue ID. Provide a value like I_123.'; try { const d = await getIssueDetails(issueId, ctx); if (!d) return `No details found for issue ${issueId}.`; return d; } catch (e) { return `Failed to fetch issue details: ${e?.message || String(e)}`; } }, }; export const listGitHubProjectColumns = { name: 'listGitHubProjectColumns', description: 'List Status column options for the project with their IDs (usable in moveGitHubIssueToColumn).', schema: z.object({}).strict(), handler: async (_args, ctx) => { try { const cols = await listStatusOptions(ctx); return cols; } catch (e) { return `Failed to fetch columns: ${e?.message || String(e)}`; } }, }; export const moveGitHubIssueToColumn = { name: 'moveGitHubIssueToColumn', description: 'Move an issue to a specific Status option (column) in the configured project.', schema: z.object({ issueId: z.string().min(3), columnId: z.string().min(3) }), handler: async ({ issueId, columnId }, ctx) => { if (!issueId.startsWith('I_')) return 'Invalid or missing issue ID. Provide a value like I_123.'; try { const res = await moveIssueToColumn(issueId, columnId, ctx); return { ok: true, movedItemId: res.itemId }; } catch (e) { return `Failed to move issue: ${e?.message || String(e)}`; } }, }; export const updateGitHubIssue = { name: 'updateGitHubIssue', description: 'Update an issue title and/or body by node ID (e.g., I_123).', schema: z.object({ issueId: z.string().min(3), title: z.string().optional(), body: z.string().optional(), }), handler: async ({ issueId, title, body }, ctx) => { if (!issueId.startsWith('I_')) return 'Invalid or missing issue ID. Provide a value like I_123.'; if (title === undefined && body === undefined) return 'Specify at least one of title or body to update.'; try { const res = await updateIssue(issueId, title, body, ctx); return { ok: true, id: res.id, title: res.title, body: res.body }; } catch (e) { return `Failed to update issue: ${e?.message || String(e)}`; } }, }; export default [ listGitHubIssues, getGitHubIssueDetails, listGitHubProjectColumns, moveGitHubIssueToColumn, updateGitHubIssue, ];