diff --git a/apps/api/package.json b/apps/api/package.json index 8a08203..d909a36 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -6,7 +6,8 @@ "dev": "bun run --watch src/index.ts", "build": "tsc --noEmit && bun build src/index.ts --outdir dist --target bun", "test": "vitest run", - "lint": "eslint src/" + "lint": "eslint src/", + "sync": "bun run src/scripts/sync.ts" }, "dependencies": { "@helm/adapters": "workspace:*", diff --git a/apps/api/src/scripts/sync.ts b/apps/api/src/scripts/sync.ts new file mode 100644 index 0000000..f865bd9 --- /dev/null +++ b/apps/api/src/scripts/sync.ts @@ -0,0 +1,92 @@ +/** + * helm sync — manual item hydration from GitHub Projects v2. + * + * Usage: + * pnpm --filter @helm/api sync + * + * Required env vars: + * GITHUB_TOKEN — GitHub PAT with project read access + * HELM_KNOWLEDGE_REPO_PATH — path to the primary knowledge repo + * + * Optional env vars: + * HELM_DATA_DIR — defaults to ./data relative to CWD + */ +import { join } from 'node:path'; +import { getProductRegistry } from '../services/index.js'; +import { syncProductItems } from '../services/sync.js'; + +async function main(): Promise { + const slug = process.argv[2]?.trim(); + if (!slug) { + console.error('Usage: pnpm --filter @helm/api sync '); + process.exit(1); + } + + const token = process.env.GITHUB_TOKEN?.trim(); + if (!token) { + console.error('Error: GITHUB_TOKEN environment variable is not set or blank'); + process.exit(1); + } + + const knowledgeRepoPath = process.env.HELM_KNOWLEDGE_REPO_PATH?.trim(); + if (!knowledgeRepoPath) { + console.error('Error: HELM_KNOWLEDGE_REPO_PATH environment variable is not set or blank'); + process.exit(1); + } + + const SAFE_FS_PATH_REGEX = /^[A-Za-z0-9._/\-]+$/; + if (!SAFE_FS_PATH_REGEX.test(knowledgeRepoPath)) { + console.error('Error: HELM_KNOWLEDGE_REPO_PATH contains invalid characters'); + process.exit(1); + } + + const envDataDir = process.env.HELM_DATA_DIR?.trim(); + if (envDataDir && !SAFE_FS_PATH_REGEX.test(envDataDir)) { + console.error('Error: HELM_DATA_DIR contains invalid characters'); + process.exit(1); + } + const dataRoot = envDataDir || join(process.cwd(), 'data'); + + let products; + try { + products = await getProductRegistry(); + } catch (err) { + console.error( + `[sync] Failed to load product registry: ${err instanceof Error ? err.message : err}`, + ); + process.exit(1); + } + + const product = products.find((p) => p.product.slug === slug); + if (!product) { + const known = products.map((p) => p.product.slug).join(', '); + console.error(`[sync] Product not found: "${slug}". Known products: ${known}`); + process.exit(1); + } + + if (product.issue_tracker.provider !== 'github_projects') { + console.log( + `[sync] Product "${slug}" uses provider "${product.issue_tracker.provider}" — sync only supports github_projects. Nothing to do.`, + ); + process.exit(0); + } + + console.log(`[sync] Starting sync for product "${slug}"…`); + + try { + const result = await syncProductItems(product, token, dataRoot); + const noun = result.synced === 1 ? 'item' : 'items'; + console.log(`Synced ${result.synced} ${noun} in ${result.durationMs}ms`); + if (result.skipped > 0) { + console.warn(`[sync] Skipped ${result.skipped} item(s) with invalid externalId`); + } + } catch (err) { + console.error(`[sync] Error: ${err instanceof Error ? err.message : err}`); + process.exit(1); + } +} + +main().catch((err) => { + console.error('[sync] Unexpected error:', err); + process.exit(1); +}); diff --git a/apps/api/src/services/sync.test.ts b/apps/api/src/services/sync.test.ts new file mode 100644 index 0000000..0b90780 --- /dev/null +++ b/apps/api/src/services/sync.test.ts @@ -0,0 +1,268 @@ +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; +import { mkdir, rm } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ItemState } from './types.js'; +import { syncProductItems } from './sync.js'; +import type { SyncOptions } from './sync.js'; +import type { NormalizedItem } from '@helm/adapters'; +import type { Product } from '@helm/shared'; +import { INITIAL_STAGE } from '@helm/workflow'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const BASE_PRODUCT: Product = { + helm_version: '0', + product: { slug: 'helm-playground', name: 'Helm Playground' }, + issue_tracker: { + provider: 'github_projects', + org: 'lhpaul', + project_number: 5, + custom_field_name: 'Helm Stage', + }, + code_repos: [ + { url: 'https://github.com/lhpaul/helm-playground', default_branch: 'main', role: 'app' }, + ], + knowledge_repo: { + url: 'https://github.com/lhpaul/helm-playground-knowledge', + default_branch: 'main', + }, + workflow: { + stages_enabled: ['discovery', 'spec-ready', 'released'], + designer_gate: 'skip', + qa_gate: 'skip', + }, + specialists: { + spec_writer: { runtime: 'claude_code', model: 'claude-sonnet-4-6' }, + plan_writer: { runtime: 'claude_code', model: 'claude-sonnet-4-6' }, + implementer: { runtime: 'claude_code', model: 'claude-opus-4-7' }, + code_reviewer: { runtime: 'claude_code', model: 'claude-sonnet-4-6' }, + security_reviewer: { runtime: 'claude_code', model: 'claude-sonnet-4-6' }, + test_reviewer: { runtime: 'claude_code', model: 'claude-sonnet-4-6' }, + remediation: { runtime: 'claude_code', model: 'claude-sonnet-4-6' }, + }, +}; + +function makeItem(overrides: Partial = {}): NormalizedItem { + return { + externalId: 'issue_1', + title: 'Hello world endpoint', + subStage: 'discovery', + status: 'open', + url: 'https://github.com/lhpaul/helm-playground/issues/1', + ...overrides, + }; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeOptions( + items: NormalizedItem[], + capturedWrites: Array<{ path: string; data: unknown }> = [], + existingState: Record = {}, +): SyncOptions { + return { + _listItems: () => Promise.resolve(items), + _writeJson: async (filePath, data) => { + capturedWrites.push({ path: filePath, data }); + }, + _readJson: async (filePath: string): Promise => { + const key = basename(filePath).replace('.json', ''); + return (existingState[key] ?? null) as T | null; + }, + }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('syncProductItems', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = join(tmpdir(), `helm-sync-test-${randomUUID()}`); + await mkdir(join(tmpDir, 'items'), { recursive: true }); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + describe('happy path', () => { + it('returns synced count and writes one ItemState file', async () => { + const writes: Array<{ path: string; data: unknown }> = []; + const result = await syncProductItems( + BASE_PRODUCT, + 'token', + tmpDir, + makeOptions([makeItem()], writes), + ); + + expect(result.synced).toBe(1); + expect(result.skipped).toBe(0); + expect(result.durationMs).toBeGreaterThanOrEqual(0); + expect(writes).toHaveLength(1); + }); + + it('writes the correct ItemState shape', async () => { + const writes: Array<{ path: string; data: unknown }> = []; + await syncProductItems( + BASE_PRODUCT, + 'token', + tmpDir, + makeOptions([makeItem({ externalId: 'issue_42', subStage: 'spec-ready' })], writes), + ); + + const state = writes[0]!.data as Record; + expect(state.externalId).toBe('issue_42'); + expect(state.productSlug).toBe('helm-playground'); + expect(state.currentStage).toBe('spec-ready'); + expect(state.history).toHaveLength(1); + expect((state.history as Array>)[0]!.triggeredBy).toBe( + 'sync:github-projects', + ); + expect((state.history as Array>)[0]!.fromStage).toBeNull(); + }); + + it('writes the file path under items/ dir', async () => { + const writes: Array<{ path: string; data: unknown }> = []; + await syncProductItems(BASE_PRODUCT, 'token', tmpDir, makeOptions([makeItem()], writes)); + + expect(writes[0]!.path).toContain('items'); + expect(writes[0]!.path).toContain('issue_1.json'); + }); + + it('syncs multiple items', async () => { + const items = [ + makeItem({ externalId: 'issue_1' }), + makeItem({ externalId: 'issue_2', subStage: 'plan-ready' }), + makeItem({ externalId: 'issue_3', subStage: null }), + ]; + const writes: Array<{ path: string; data: unknown }> = []; + const result = await syncProductItems( + BASE_PRODUCT, + 'token', + tmpDir, + makeOptions(items, writes), + ); + + expect(result.synced).toBe(3); + expect(writes).toHaveLength(3); + }); + }); + + describe('stage mapping', () => { + it('uses item.subStage when set', async () => { + const writes: Array<{ path: string; data: unknown }> = []; + await syncProductItems( + BASE_PRODUCT, + 'token', + tmpDir, + makeOptions([makeItem({ subStage: 'code-review' })], writes), + ); + + expect((writes[0]!.data as Record).currentStage).toBe('code-review'); + }); + + it('falls back to INITIAL_STAGE when subStage is null', async () => { + const writes: Array<{ path: string; data: unknown }> = []; + await syncProductItems( + BASE_PRODUCT, + 'token', + tmpDir, + makeOptions([makeItem({ subStage: null })], writes), + ); + + expect((writes[0]!.data as Record).currentStage).toBe(INITIAL_STAGE); + }); + }); + + describe('idempotency', () => { + it('skips the write on second run when stage is unchanged (no file drift)', async () => { + const item = makeItem({ externalId: 'issue_7', subStage: 'discovery' }); + const writes1: Array<{ path: string; data: unknown }> = []; + + // First run: no existing state → creates fresh + await syncProductItems(BASE_PRODUCT, 'token', tmpDir, makeOptions([item], writes1)); + expect(writes1).toHaveLength(1); + const firstState = writes1[0]!.data as ItemState; + + // Second run: existing state has same stage → skip write (continue) + const writes2: Array<{ path: string; data: unknown }> = []; + const existing: Record = { issue_7: firstState }; + await syncProductItems(BASE_PRODUCT, 'token', tmpDir, makeOptions([item], writes2, existing)); + expect(writes2).toHaveLength(0); // no write — content unchanged + }); + + it('preserves createdAt and appends to history when stage changes on re-sync', async () => { + const item = makeItem({ externalId: 'issue_8', subStage: 'discovery' }); + const writes1: Array<{ path: string; data: unknown }> = []; + await syncProductItems(BASE_PRODUCT, 'token', tmpDir, makeOptions([item], writes1)); + const firstState = writes1[0]!.data as ItemState; + + // Re-sync with a different stage + const updatedItem = makeItem({ externalId: 'issue_8', subStage: 'spec-ready' }); + const writes2: Array<{ path: string; data: unknown }> = []; + const existing: Record = { issue_8: firstState }; + await syncProductItems( + BASE_PRODUCT, + 'token', + tmpDir, + makeOptions([updatedItem], writes2, existing), + ); + + expect(writes2).toHaveLength(1); + const secondState = writes2[0]!.data as ItemState; + expect(secondState.createdAt).toBe(firstState.createdAt); // preserved + expect(secondState.currentStage).toBe('spec-ready'); + expect(secondState.history).toHaveLength(2); // creation + transition + }); + }); + + describe('error cases', () => { + it('throws when provider is not github_projects', async () => { + const linearProduct: Product = { + ...BASE_PRODUCT, + issue_tracker: { + provider: 'linear', + workspace: 'helm-dev', + team_key: 'HLM', + label_prefix: 'helm:', + }, + }; + + await expect( + syncProductItems(linearProduct, 'token', tmpDir, makeOptions([])), + ).rejects.toThrow("sync only supports provider 'github_projects'"); + }); + + it('skips items with invalid externalIds and increments skipped count', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const writes: Array<{ path: string; data: unknown }> = []; + const items = [ + makeItem({ externalId: 'issue_1' }), // valid + makeItem({ externalId: '../traversal' }), // invalid + makeItem({ externalId: '.hidden' }), // invalid (leading dot) + makeItem({ externalId: 'issue_2' }), // valid + ]; + + const result = await syncProductItems( + BASE_PRODUCT, + 'token', + tmpDir, + makeOptions(items, writes), + ); + + expect(result.synced).toBe(2); + expect(result.skipped).toBe(2); + expect(writes).toHaveLength(2); + }); + + it('returns synced=0 when adapter returns empty list', async () => { + const result = await syncProductItems(BASE_PRODUCT, 'token', tmpDir, makeOptions([])); + expect(result.synced).toBe(0); + expect(result.skipped).toBe(0); + }); + }); +}); diff --git a/apps/api/src/services/sync.ts b/apps/api/src/services/sync.ts new file mode 100644 index 0000000..931022b --- /dev/null +++ b/apps/api/src/services/sync.ts @@ -0,0 +1,169 @@ +import { join } from 'node:path'; +import { z } from 'zod'; +import { GitHubProjectsAdapter } from '@helm/adapters'; +import type { NormalizedItem } from '@helm/adapters'; +import { ensureDataDir, writeJsonAtomic, readJson } from '@helm/storage'; +import { INITIAL_STAGE } from '@helm/workflow'; +import type { WorkflowStage } from '@helm/workflow'; +import type { Product } from '@helm/shared'; +import { EXTERNAL_ID_REGEX } from './types.js'; +import type { ItemState, WorkflowEvent } from './types.js'; + +// Runtime guard for ItemState read from disk — malformed JSON should rebuild rather than crash. +const WorkflowEventSchema = z.object({ + fromStage: z.string().nullable(), + toStage: z.string(), + triggeredBy: z.string(), + at: z.string(), + note: z.string().optional(), +}); +const ItemStateSchema = z.object({ + externalId: z.string(), + productSlug: z.string(), + currentStage: z.string(), + history: z.array(WorkflowEventSchema), + createdAt: z.string(), + updatedAt: z.string(), +}); + +function parseExistingState(raw: unknown): ItemState | null { + const result = ItemStateSchema.safeParse(raw); + return result.success ? (result.data as ItemState) : null; +} + +export type SyncResult = { + synced: number; + skipped: number; + durationMs: number; +}; + +// Injectable interfaces for testing without filesystem or network. +type ListItemsFn = () => Promise; +type WriteJsonFn = (filePath: string, data: unknown) => Promise; +type ReadJsonFn = (filePath: string) => Promise; + +export type SyncOptions = { + /** Injectable adapter list function — defaults to a fresh GitHubProjectsAdapter. */ + _listItems?: ListItemsFn; + /** Injectable writer — defaults to writeJsonAtomic. */ + _writeJson?: WriteJsonFn; + /** Injectable reader — defaults to readJson (used to preserve existing createdAt). */ + _readJson?: ReadJsonFn; +}; + +/** + * Reads all items from the GitHub Project configured on `product` and writes + * them to `data/items/{externalId}.json` using the same ItemState shape that + * the webhook handler produces. + * + * Idempotency: if an item file already exists, its `createdAt` is preserved + * and `updatedAt` is only advanced when the stage actually changes. + * Items with invalid externalIds are skipped with a warning. + * + * Supports both GitHub org and personal (user) accounts. + * + * @param product - Product whose issue_tracker will be queried. + * @param token - GitHub PAT with read access to the project. + * @param dataRoot - Absolute path to Helm's data/ directory. + * @param options - Optional injectable overrides for testing. + */ +export async function syncProductItems( + product: Product, + token: string, + dataRoot: string, + options?: SyncOptions, +): Promise { + const { issue_tracker } = product; + + if (issue_tracker.provider !== 'github_projects') { + throw new Error( + `sync only supports provider 'github_projects', got '${issue_tracker.provider}'`, + ); + } + + const slug = product.product.slug; + const start = Date.now(); + + const listItems: ListItemsFn = + options?._listItems ?? + (() => { + const adapter = new GitHubProjectsAdapter(issue_tracker, token, { ttlMs: 0 }); + return adapter.listItems(); + }); + + const writeJson: WriteJsonFn = options?._writeJson ?? writeJsonAtomic; + const readJsonFn: ReadJsonFn = options?._readJson ?? readJson; + + const items = await listItems(); + const paths = await ensureDataDir(dataRoot); + + let synced = 0; + let skipped = 0; + + for (const item of items) { + if (!EXTERNAL_ID_REGEX.test(item.externalId)) { + console.warn( + `[sync] product=${slug} skipping item with invalid externalId: ${item.externalId}`, + ); + skipped++; + continue; + } + + const filePath = join(paths.items, `${item.externalId}.json`); + const currentStage: WorkflowStage = item.subStage ?? INITIAL_STAGE; + + // Read and validate existing state — treat malformed disk data as absent (rebuild). + const rawExisting = await readJsonFn(filePath); + const existing = rawExisting !== null ? parseExistingState(rawExisting) : null; + const now = new Date().toISOString(); + + let state: ItemState; + if (existing !== null) { + if (existing.currentStage === currentStage) { + // Stage unchanged — preserve file as-is (true idempotency, no write needed). + synced++; + console.log( + `[sync] product=${slug} item=${item.externalId} title="${item.title}" stage=${currentStage} (unchanged)`, + ); + continue; + } + // Stage changed — append transition event, preserve original createdAt. + const event: WorkflowEvent = { + fromStage: existing.currentStage, + toStage: currentStage, + triggeredBy: 'sync:github-projects', + at: now, + }; + state = { + ...existing, + currentStage, + history: [...existing.history, event], + updatedAt: now, + }; + } else { + // New item — create fresh. + const creationEvent: WorkflowEvent = { + fromStage: null, + toStage: currentStage, + triggeredBy: 'sync:github-projects', + at: now, + }; + state = { + externalId: item.externalId, + productSlug: slug, + currentStage, + history: [creationEvent], + createdAt: now, + updatedAt: now, + }; + } + + await writeJson(filePath, state); + console.log( + `[sync] product=${slug} item=${item.externalId} title="${item.title}" stage=${currentStage}`, + ); + synced++; + } + + return { synced, skipped, durationMs: Date.now() - start }; +} diff --git a/packages/adapters/src/github-projects/adapter.test.ts b/packages/adapters/src/github-projects/adapter.test.ts index 55600ee..c5609ad 100644 --- a/packages/adapters/src/github-projects/adapter.test.ts +++ b/packages/adapters/src/github-projects/adapter.test.ts @@ -30,7 +30,13 @@ const OPTION_DISCOVERY = { id: 'opt-disc', name: 'discovery' }; const OPTION_SPEC_READY = { id: 'opt-spec', name: 'spec-ready' }; function projectRes(): GetProjectResponse { - return { organization: { projectV2: { id: PROJECT_ID, title: 'Helm' } } }; + return { + repositoryOwner: { __typename: 'Organization', projectV2: { id: PROJECT_ID, title: 'Helm' } }, + }; +} + +function projectResUser(): GetProjectResponse { + return { repositoryOwner: { __typename: 'User', projectV2: { id: PROJECT_ID, title: 'Helm' } } }; } function fieldsRes(fieldName?: string): GetProjectFieldsResponse { @@ -144,6 +150,64 @@ describe('GitHubProjectsAdapter', () => { }); }); + describe('personal account support (repositoryOwner polymorphic query)', () => { + it('resolves projectId for a personal (User) account', async () => { + const { adapter, gql } = makeAdapter(); + gql + .mockResolvedValueOnce(projectResUser()) + .mockResolvedValueOnce(fieldsRes('Helm Stage')) + .mockResolvedValueOnce( + itemsPage([{ number: 1, title: 'Item', state: 'OPEN' }], false, null), + ); + + const item = await adapter.getItem('issue_1'); + expect(item?.externalId).toBe('issue_1'); + }); + + it('resolves projectId for an org (Organization) account', async () => { + const { adapter, gql } = makeAdapter(); + gql + .mockResolvedValueOnce(projectRes()) + .mockResolvedValueOnce(fieldsRes('Helm Stage')) + .mockResolvedValueOnce( + itemsPage([{ number: 1, title: 'Item', state: 'OPEN' }], false, null), + ); + + const item = await adapter.getItem('issue_1'); + expect(item?.externalId).toBe('issue_1'); + }); + + it('throws GitHubNotFoundError when repositoryOwner is null', async () => { + const { adapter, gql } = makeAdapter(); + gql.mockResolvedValueOnce({ repositoryOwner: null }); + await expect(adapter.getItem('issue_1')).rejects.toThrow(GitHubNotFoundError); + }); + + it('throws GitHubNotFoundError when project is not found under the owner', async () => { + const { adapter, gql } = makeAdapter(); + gql.mockResolvedValueOnce({ repositoryOwner: { __typename: 'User', projectV2: null } }); + await expect(adapter.getItem('issue_1')).rejects.toThrow(GitHubNotFoundError); + }); + + it('regression: dual org+user query threw partial-data error on personal accounts — repositoryOwner fixes this', async () => { + // The previous GET_PROJECT queried `organization` + `user` in parallel. + // When `organization(login: 'lhpaul')` fails, GitHub returns BOTH data AND errors[]. + // @octokit/graphql throws GraphqlResponseError on any errors[], so the user fallback + // was never reached. repositoryOwner resolves without errors for personal accounts. + const { adapter, gql } = makeAdapter(); + gql + .mockResolvedValueOnce(projectResUser()) + .mockResolvedValueOnce(fieldsRes('Helm Stage')) + .mockResolvedValueOnce( + itemsPage([{ number: 5, title: 'Hello world endpoint', state: 'OPEN' }], false, null), + ); + + const item = await adapter.getItem('issue_5'); + expect(item?.externalId).toBe('issue_5'); + expect(item?.title).toBe('Hello world endpoint'); + }); + }); + describe('ensureSubStages', () => { it('creates the Helm Stage field when it does not exist', async () => { const { adapter, gql } = makeAdapter(); @@ -476,8 +540,9 @@ describe('GitHubProjectsAdapter', () => { describe('registerWebhook', () => { it('POSTs to the GitHub org hooks endpoint with correct payload', async () => { const fetchMock = vi.fn().mockResolvedValueOnce({ ok: true, status: 201 }); + const gqlMock = vi.fn().mockResolvedValueOnce(projectRes()); // ensureProjectId call const adapterWithWebhook = new GitHubProjectsAdapter(CONFIG, 'test-token', { - _graphql: vi.fn() as unknown as GraphqlFn, + _graphql: gqlMock as unknown as GraphqlFn, _fetch: fetchMock as unknown as FetchFn, webhookSecret: 'my-secret', }); @@ -492,6 +557,18 @@ describe('GitHubProjectsAdapter', () => { expect(body.config.url).toBe('https://example.com/hook'); }); + it('throws GitHubConfigError for personal account (user) projects', async () => { + const gqlMock = vi.fn().mockResolvedValueOnce(projectResUser()); + const adapterPersonal = new GitHubProjectsAdapter(CONFIG, 'test-token', { + _graphql: gqlMock as unknown as GraphqlFn, + _fetch: vi.fn() as unknown as FetchFn, + webhookSecret: 'my-secret', + }); + await expect(adapterPersonal.registerWebhook('https://example.com/hook')).rejects.toThrow( + /personal account/, + ); + }); + it('throws GitHubConfigError when webhookSecret is not set', async () => { const { adapter } = makeAdapter(); await expect(adapter.registerWebhook('https://example.com/hook')).rejects.toThrow( diff --git a/packages/adapters/src/github-projects/adapter.ts b/packages/adapters/src/github-projects/adapter.ts index 09f35ed..34e10f3 100644 --- a/packages/adapters/src/github-projects/adapter.ts +++ b/packages/adapters/src/github-projects/adapter.ts @@ -74,6 +74,8 @@ export class GitHubProjectsAdapter implements IssueTrackerAdapter { // Stable metadata (fetched once, not TTL-evicted) private projectId: string | null = null; private fieldId: string | null = null; + /** True when the project was resolved via user(login:) — personal accounts don't support org-level webhooks. */ + private isPersonalAccount = false; private readonly stageToOptionId = new Map(); private readonly optionIdToStage = new Map(); private mapsReady = false; @@ -233,6 +235,15 @@ export class GitHubProjectsAdapter implements IssueTrackerAdapter { 'webhookSecret is required for registerWebhook — pass it in constructor options', ); } + // Ensure project owner is known before attempting webhook registration. + await this.ensureProjectId(); + if (this.isPersonalAccount) { + throw new GitHubConfigError( + `registerWebhook is not supported for personal account projects — ` + + `GitHub's REST API only allows webhook creation on organization accounts. ` + + `Use a smee.io channel and configure the webhook manually in your GitHub Project settings instead.`, + ); + } const url = `https://api.github.com/orgs/${this.config.org}/hooks`; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10_000); @@ -349,12 +360,16 @@ export class GitHubProjectsAdapter implements IssueTrackerAdapter { login: this.config.org, number: this.config.project_number, }); - if (!res.organization?.projectV2) { + // repositoryOwner resolves for both orgs and users without partial-data errors. + const owner = res.repositoryOwner; + const projectV2 = owner?.projectV2 ?? null; + if (!owner || !projectV2) { throw new GitHubNotFoundError( - `GitHub project #${this.config.project_number} not found in org '${this.config.org}'`, + `GitHub project #${this.config.project_number} not found for owner '${this.config.org}'`, ); } - this.projectId = res.organization.projectV2.id; + this.isPersonalAccount = owner.__typename === 'User'; + this.projectId = projectV2.id; } catch (err) { if (err instanceof GitHubNotFoundError) throw err; this.mapError(err); @@ -484,20 +499,27 @@ export class GitHubProjectsAdapter implements IssueTrackerAdapter { private mapError(err: unknown): never { if (err !== null && typeof err === 'object' && 'response' in err) { - const status = (err as { response: { status: number } }).response.status; + const e = err as { response: { status: number }; errors?: Array<{ message: string }> }; + const status = e.response.status; + // Include the first GraphQL error message for actionable diagnostics. + const gqlMessage = e.errors?.[0]?.message; + const detail = gqlMessage ? ` — ${gqlMessage}` : ''; if (status === 401) { console.error('[GitHubProjectsAdapter] Authentication error:', err); - throw new GitHubAuthError('GitHub API authentication failed — verify the token is valid'); + throw new GitHubAuthError( + `GitHub API authentication failed — verify the token is valid${detail}`, + ); } if (status === 404) { console.error('[GitHubProjectsAdapter] Resource not found:', err); - throw new GitHubNotFoundError('GitHub resource not found'); + throw new GitHubNotFoundError(`GitHub resource not found${detail}`); } console.error(`[GitHubProjectsAdapter] API error (${status}):`, err); - throw new GitHubAPIError(`GitHub API error`, status); + throw new GitHubAPIError(`GitHub API error${detail}`, status); } + const message = err instanceof Error ? err.message : String(err); console.error('[GitHubProjectsAdapter] Unexpected error:', err); - throw new GitHubAPIError('GitHub API request failed'); + throw new GitHubAPIError(`GitHub API request failed — ${message}`); } getOptionId(stage: WorkflowStage): string | undefined { diff --git a/packages/adapters/src/github-projects/graphql-queries.ts b/packages/adapters/src/github-projects/graphql-queries.ts index e2d0e8b..99ef0f5 100644 --- a/packages/adapters/src/github-projects/graphql-queries.ts +++ b/packages/adapters/src/github-projects/graphql-queries.ts @@ -1,9 +1,18 @@ export const GET_PROJECT = ` query GetProject($login: String!, $number: Int!) { - organization(login: $login) { - projectV2(number: $number) { - id - title + repositoryOwner(login: $login) { + __typename + ... on Organization { + projectV2(number: $number) { + id + title + } + } + ... on User { + projectV2(number: $number) { + id + title + } } } } diff --git a/packages/adapters/src/github-projects/graphql-types.ts b/packages/adapters/src/github-projects/graphql-types.ts index a83d6a1..db9ecf4 100644 --- a/packages/adapters/src/github-projects/graphql-types.ts +++ b/packages/adapters/src/github-projects/graphql-types.ts @@ -6,13 +6,13 @@ export type GitHubFieldOption = { }; // ── GET_PROJECT ─────────────────────────────────────────────────────────────── +// Uses repositoryOwner(login:) with inline fragments so a single query works +// for both GitHub org and personal (user) accounts without partial-data errors. export type GetProjectResponse = { - organization: { - projectV2: { - id: string; - title: string; - } | null; + repositoryOwner: { + __typename: 'Organization' | 'User' | string; + projectV2: { id: string; title: string } | null; } | null; };