` preserving the props we assert on.
+vi.mock('../../../web/src/components/projects/project-secret-field.js', () => ({
+ ProjectSecretField: (props: {
+ projectId: string;
+ envVarKey: string;
+ label: string;
+ description?: string;
+ placeholder?: string;
+ }) =>
+ createElement(
+ 'div',
+ {
+ 'data-component': 'ProjectSecretField',
+ 'data-env-var-key': props.envVarKey,
+ 'data-project-id': props.projectId,
+ },
+ createElement('label', null, props.label),
+ createElement('input', { type: 'password', placeholder: props.placeholder ?? '' }),
+ ),
+}));
+
+import {
+ GitHubProjectsWebhookAdapter,
+ normalizeGitHubProjectsActiveWebhooks,
+} from '../../../web/src/components/projects/pm-providers/github-projects/webhook-step.js';
+import type { WizardState } from '../../../web/src/components/projects/pm-wizard-state.js';
+
+function makeState(ownerType: 'user' | 'organization'): WizardState {
+ return { githubProjectsOwnerType: ownerType } as WizardState;
+}
+
+function makeProviderHooks(overrides: Record
= {}): Record {
+ return {
+ webhookUrl: 'https://router.example.com/github-projects/webhook',
+ projectIdForSecret: 'proj-123',
+ webhookSecretCredential: undefined,
+ callbackBaseUrl: 'https://router.example.com',
+ activeGithubProjectsWebhooks: [],
+ webhooksLoading: false,
+ createGithubProjectsWebhook: () => {},
+ createLoading: false,
+ createError: undefined,
+ deleteGithubProjectsWebhook: () => {},
+ deleteLoading: false,
+ ...overrides,
+ };
+}
+
+function render(
+ ownerType: 'user' | 'organization',
+ hookOverrides: Record = {},
+): string {
+ return renderToStaticMarkup(
+ createElement(GitHubProjectsWebhookAdapter, {
+ state: makeState(ownerType),
+ dispatch: () => {},
+ providerHooks: makeProviderHooks(hookOverrides),
+ }),
+ );
+}
+
+describe('GitHubProjectsWebhookAdapter', () => {
+ it('renders the shared WebhookUrlDisplayStep with the webhook URL', () => {
+ const html = render('organization');
+ expect(html).toContain('data-step-component="webhook-url-display"');
+ expect(html).toContain('https://router.example.com/github-projects/webhook');
+ });
+
+ it('renders a ProjectSecretField bound to GITHUB_WEBHOOK_SECRET', () => {
+ const html = render('organization');
+ expect(html).toContain('data-env-var-key="GITHUB_WEBHOOK_SECRET"');
+ expect(html).toContain('Webhook Signing Secret');
+ });
+
+ it('does not render the ProjectSecretField when projectIdForSecret is empty', () => {
+ const html = render('organization', { projectIdForSecret: '' });
+ expect(html).not.toContain('Webhook Signing Secret');
+ });
+
+ it('shows an owner-appropriate info banner title', () => {
+ // Org owners can create programmatically, so the banner is not "Manual … Required".
+ expect(render('organization')).toContain('Webhook Setup');
+ expect(render('organization')).not.toContain('Manual Webhook Setup Required');
+ expect(render('user')).toContain('Manual Webhook Setup Required');
+ });
+
+ it('never claims CASCADE cannot create webhooks programmatically (the removed false blanket claim)', () => {
+ expect(render('organization')).not.toContain('cannot create them programmatically');
+ expect(render('user')).not.toContain('cannot create them programmatically');
+ });
+
+ it('for org owners: points at organization settings and omits the user-only limitation', () => {
+ const html = render('organization');
+ expect(html).toContain('organization');
+ // Org webhooks are creatable via the API — the copy must acknowledge that
+ // rather than assert a platform prohibition.
+ expect(html).toContain('created via the GitHub API');
+ expect(html).not.toContain('no create-webhook API');
+ });
+
+ it('for user owners: states the genuine no-webhook-API limitation for user-owned projects', () => {
+ const html = render('user');
+ expect(html).toContain('no create-webhook API');
+ expect(html).toContain('projects_v2_item');
+ });
+
+ it('for org owners: renders programmatic Create button + active-webhooks list', () => {
+ const html = render('organization');
+ expect(html).toContain('data-action="create-webhook"');
+ expect(html).toContain('data-section="active-webhooks"');
+ });
+
+ it('for org owners: renders a delete button for each active webhook', () => {
+ const html = render('organization', {
+ activeGithubProjectsWebhooks: [
+ { id: '42', url: 'https://router.example.com/github-projects/webhook', active: true },
+ ],
+ });
+ expect(html).toContain('data-action="delete-webhook"');
+ expect(html).toContain('data-webhook-id="42"');
+ });
+
+ it('for user owners: does NOT render the programmatic Create/list UI (no webhook API)', () => {
+ const html = render('user');
+ expect(html).not.toContain('data-action="create-webhook"');
+ expect(html).not.toContain('data-section="active-webhooks"');
+ });
+
+ it('surfaces a create error when present (org owners)', () => {
+ const html = render('organization', { createError: 'admin:org_hook scope required' });
+ expect(html).toContain('admin:org_hook scope required');
+ });
+});
+
+describe('normalizeGitHubProjectsActiveWebhooks', () => {
+ it('keeps only CASCADE github-projects hooks and drops unrelated org hooks', () => {
+ const active = normalizeGitHubProjectsActiveWebhooks({
+ githubProjects: [
+ { id: 1, active: true, config: { url: 'https://r.example.com/github-projects/webhook' } },
+ { id: 2, active: false, config: { url: 'https://other.example.com/some/thing' } },
+ { id: 3, active: true, config: {} },
+ ],
+ });
+ expect(active).toEqual([
+ { id: '1', url: 'https://r.example.com/github-projects/webhook', active: true },
+ ]);
+ });
+
+ it('returns [] for missing data', () => {
+ expect(normalizeGitHubProjectsActiveWebhooks(undefined)).toEqual([]);
+ });
+});
diff --git a/tests/unit/webhook/webhookParsers.test.ts b/tests/unit/webhook/webhookParsers.test.ts
index ee1b4b3d9..489d98a5d 100644
--- a/tests/unit/webhook/webhookParsers.test.ts
+++ b/tests/unit/webhook/webhookParsers.test.ts
@@ -20,6 +20,7 @@ vi.mock('../../../src/utils/index.js', () => ({
import {
parseGitHubPayload,
+ parseGitHubProjectsPayload,
parseJiraPayload,
parseTrelloPayload,
} from '../../../src/webhook/webhookParsers.js';
@@ -216,3 +217,62 @@ describe('parseJiraPayload', () => {
);
});
});
+
+describe('parseGitHubProjectsPayload', () => {
+ it('extracts eventType as projects_v2_item/', async () => {
+ const payload = {
+ action: 'edited',
+ projects_v2_item: { node_id: 'PVTI_1', project_node_id: 'PVT_1' },
+ };
+ const ctx = makeHonoContext(payload);
+
+ const result = await parseGitHubProjectsPayload(ctx as never);
+
+ expect(result.ok).toBe(true);
+ expect(result.eventType).toBe('projects_v2_item/edited');
+ expect(result.payload).toEqual(payload);
+ });
+
+ it('defaults eventType to "unknown" when action is missing', async () => {
+ const payload = { projects_v2_item: { node_id: 'PVTI_1' } };
+ const ctx = makeHonoContext(payload);
+
+ const result = await parseGitHubProjectsPayload(ctx as never);
+
+ expect(result.ok).toBe(true);
+ expect(result.eventType).toBe('unknown');
+ });
+
+ it('returns ok=false and error string on parse failure', async () => {
+ const ctx = {
+ req: {
+ text: vi.fn().mockResolvedValue('not valid json {{{'),
+ header: vi.fn(),
+ },
+ };
+
+ const result = await parseGitHubProjectsPayload(ctx as never);
+
+ expect(result.ok).toBe(false);
+ expect(result.error).toBeDefined();
+ });
+
+ it('logs info with action, eventType, and project node ID', async () => {
+ const payload = {
+ action: 'edited',
+ projects_v2_item: { node_id: 'PVTI_1', project_node_id: 'PVT_abc' },
+ };
+ const ctx = makeHonoContext(payload);
+
+ await parseGitHubProjectsPayload(ctx as never);
+
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ 'Received GitHub Projects webhook',
+ expect.objectContaining({
+ action: 'edited',
+ eventType: 'projects_v2_item/edited',
+ projectId: 'PVT_abc',
+ }),
+ );
+ });
+});
diff --git a/tests/unit/worker-entry.test.ts b/tests/unit/worker-entry.test.ts
index 3232c24a8..fbb90773c 100644
--- a/tests/unit/worker-entry.test.ts
+++ b/tests/unit/worker-entry.test.ts
@@ -44,6 +44,10 @@ vi.mock('../../src/triggers/linear/webhook-handler.js', () => ({
processLinearWebhook: vi.fn().mockResolvedValue(undefined),
}));
+vi.mock('../../src/triggers/github-projects/webhook-handler.js', () => ({
+ processGitHubProjectsWebhook: vi.fn().mockResolvedValue(undefined),
+}));
+
vi.mock('../../src/router/pm-ack-dispatch.js', () => ({
dispatchPMAck: vi.fn(),
}));
@@ -59,6 +63,7 @@ vi.mock('../../src/router/ackMessageGenerator.js', () => ({
extractTrelloContext: vi.fn().mockReturnValue(''),
extractJiraContext: vi.fn().mockReturnValue(''),
extractLinearContext: vi.fn().mockReturnValue(''),
+ extractGitHubProjectsContext: vi.fn().mockReturnValue(''),
generateAckMessage: vi.fn().mockResolvedValue('🔨 Generated ack message'),
}));
@@ -108,6 +113,7 @@ import { BootFailureError } from '../../src/agents/shared/bootFailureError.js';
import { loadProjectConfigById } from '../../src/config/provider.js';
import { getRunById, markDebugAnalysisFailed } from '../../src/db/repositories/runsRepository.js';
import {
+ extractGitHubProjectsContext,
extractJiraContext,
extractLinearContext,
extractTrelloContext,
@@ -116,6 +122,7 @@ import {
import { readOffloadedJobData } from '../../src/router/job-data-offload.js';
import { dispatchPMAck } from '../../src/router/pm-ack-dispatch.js';
import { captureException, flush } from '../../src/sentry.js';
+import { processGitHubProjectsWebhook } from '../../src/triggers/github-projects/webhook-handler.js';
import { processGitHubWebhook, processJiraWebhook } from '../../src/triggers/index.js';
import { processLinearWebhook } from '../../src/triggers/linear/webhook-handler.js';
import { processSentryWebhook } from '../../src/triggers/sentry/webhook-handler.js';
@@ -126,6 +133,7 @@ import {
type DebugAnalysisJobData,
dispatchJob,
type GitHubJobData,
+ type GitHubProjectsJobData,
type JiraJobData,
type LinearJobData,
type ManualRunJobData,
@@ -380,6 +388,36 @@ describe('dispatchJob routing', () => {
expect(dispatchPMAck).not.toHaveBeenCalled();
});
+ it('routes github-projects job to processGitHubProjectsWebhook with payload, registry, ackCommentId, triggerResult', async () => {
+ const mockRegistry = {};
+ const jobPayload = { action: 'edited', projects_v2_item: { id: 'PVTI_1' } };
+ const triggerResult = { matched: true, agentType: 'implementation' } as never;
+
+ const jobData: GitHubProjectsJobData = {
+ type: 'github-projects',
+ source: 'github-projects',
+ payload: jobPayload,
+ projectId: 'proj-1',
+ workItemId: 'gh-item-1',
+ eventType: 'projects_v2_item/edited',
+ receivedAt: '2024-01-01T00:00:00Z',
+ ackCommentId: 'gh-comment-789',
+ triggerResult,
+ };
+
+ await dispatchJob('job-github-projects-1', jobData, mockRegistry as never);
+
+ expect(processGitHubProjectsWebhook).toHaveBeenCalledWith(
+ jobPayload,
+ mockRegistry,
+ 'gh-comment-789',
+ triggerResult,
+ 'proj-1',
+ );
+ // Without pendingAck, the deferred-ack path is NOT taken
+ expect(dispatchPMAck).not.toHaveBeenCalled();
+ });
+
it('handles unknown job type by calling captureException with worker_unknown_job tag', async () => {
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?) => {
throw new Error(`process.exit(${code})`);
@@ -411,6 +449,7 @@ describe('dispatchJob - deferred ack (pendingAck=true)', () => {
vi.mocked(extractJiraContext).mockReset().mockReturnValue('');
vi.mocked(extractLinearContext).mockReset().mockReturnValue('');
vi.mocked(extractTrelloContext).mockReset().mockReturnValue('');
+ vi.mocked(extractGitHubProjectsContext).mockReset().mockReturnValue('');
vi.mocked(generateAckMessage).mockReset().mockResolvedValue('🔨 Generated ack');
});
@@ -660,6 +699,71 @@ describe('dispatchJob - deferred ack (pendingAck=true)', () => {
);
});
+ it('github-projects pendingAck: extracts context, generates ack, posts via dispatchPMAck, passes new commentId to processGitHubProjectsWebhook', async () => {
+ vi.mocked(extractGitHubProjectsContext).mockReturnValueOnce('Item: PVTI_1 — Fix flaky test');
+ vi.mocked(generateAckMessage).mockResolvedValueOnce('🔨 Fixing the flaky test');
+ vi.mocked(dispatchPMAck).mockResolvedValueOnce({
+ commentId: 'gh-deferred-1',
+ message: '🔨 Fixing the flaky test',
+ });
+
+ const jobData: GitHubProjectsJobData = {
+ type: 'github-projects',
+ source: 'github-projects',
+ payload: { action: 'edited', projects_v2_item: { id: 'PVTI_1' } },
+ projectId: 'proj-1',
+ workItemId: 'PVTI_1',
+ eventType: 'projects_v2_item/edited',
+ receivedAt: '2024-01-01T00:00:00Z',
+ pendingAck: true,
+ ackContextHint: 'Fix flaky test',
+ triggerResult: { agentType: 'implementation' } as never,
+ };
+
+ await dispatchJob('job-github-projects-deferred', jobData, {} as never);
+
+ expect(extractGitHubProjectsContext).toHaveBeenCalledWith(jobData.payload);
+ expect(generateAckMessage).toHaveBeenCalledWith(
+ 'implementation',
+ 'Item: PVTI_1 — Fix flaky test',
+ 'proj-1',
+ );
+ expect(dispatchPMAck).toHaveBeenCalledWith({
+ projectId: 'proj-1',
+ workItemId: 'PVTI_1',
+ pmType: 'github-projects',
+ message: '🔨 Fixing the flaky test',
+ agentType: 'implementation',
+ });
+ expect(processGitHubProjectsWebhook).toHaveBeenCalledWith(
+ jobData.payload,
+ expect.anything(),
+ 'gh-deferred-1',
+ jobData.triggerResult,
+ 'proj-1',
+ );
+ });
+
+ it('github-projects pendingAck without workItemId: skips deferred ack entirely', async () => {
+ const jobData: GitHubProjectsJobData = {
+ type: 'github-projects',
+ source: 'github-projects',
+ payload: {},
+ projectId: 'proj-1',
+ // workItemId is missing
+ eventType: 'projects_v2_item/created',
+ receivedAt: '2024-01-01T00:00:00Z',
+ pendingAck: true,
+ triggerResult: { agentType: 'implementation' } as never,
+ };
+
+ await dispatchJob('job-github-projects-no-id', jobData, {} as never);
+
+ // Without workItemId, the deferred-ack branch is skipped
+ expect(dispatchPMAck).not.toHaveBeenCalled();
+ expect(processGitHubProjectsWebhook).toHaveBeenCalled();
+ });
+
it('linear pendingAck without workItemId: skips deferred ack entirely', async () => {
const jobData: LinearJobData = {
type: 'linear',
@@ -1235,4 +1339,28 @@ describe('main() - environment variable validation', () => {
);
expect(flush).toHaveBeenCalled();
});
+
+ // ── defensive fallback: mismatched key, no inline value ─────────────────────
+ //
+ // main()'s upfront check only requires JOB_DATA *or* JOB_DATA_REDIS_KEY to be
+ // present — it does not know whether a present key actually names this job.
+ // If a stale baked key (see the two tests above) doesn't match JOB_ID *and*
+ // there's no fresh inline JOB_DATA to fall back on, resolveRawJobData() has no
+ // channel left to read from. This is the defensive branch that turns that into
+ // a clear, grep-able exit instead of undefined behavior.
+ it('exits 1 with worker_env tag when JOB_DATA_REDIS_KEY is stale (mismatched) and no inline JOB_DATA fallback exists', async () => {
+ process.env.JOB_ID = 'job-no-fallback';
+ process.env.JOB_TYPE = 'linear';
+ process.env.JOB_DATA_REDIS_KEY = 'cascade:jobdata:some-other-job';
+ // JOB_DATA intentionally absent — no channel names this job's payload.
+
+ await expect(main()).rejects.toThrow('process.exit(1)');
+
+ expect(readOffloadedJobData).not.toHaveBeenCalled();
+ expect(captureException).toHaveBeenCalledWith(
+ expect.objectContaining({ message: 'JOB_DATA could not be resolved from env or Redis' }),
+ expect.objectContaining({ tags: { source: 'worker_env' } }),
+ );
+ expect(flush).toHaveBeenCalled();
+ });
});
diff --git a/web/src/components/projects/pm-providers/github-projects/auth.ts b/web/src/components/projects/pm-providers/github-projects/auth.ts
new file mode 100644
index 000000000..d855656fb
--- /dev/null
+++ b/web/src/components/projects/pm-providers/github-projects/auth.ts
@@ -0,0 +1,16 @@
+import type { ProviderAuthMetadata, ProviderCredentialPersistenceMapping } from '../types.js';
+
+export const githubProjectsAuthMetadata: ProviderAuthMetadata = {
+ rawCredentials: [{ role: 'token', stateField: 'githubProjectsToken' }],
+ storedCredentials: { fallbackWhenStateFieldEmpty: 'githubProjectsToken' },
+ missingCredentialsMessage: 'Enter your GitHub Personal Access Token before verifying',
+};
+
+export const githubProjectsCredentialPersistence: readonly ProviderCredentialPersistenceMapping[] =
+ [
+ {
+ envVarKey: 'GITHUB_TOKEN',
+ stateField: 'githubProjectsToken',
+ label: 'GitHub Personal Access Token',
+ },
+ ];
diff --git a/web/src/components/projects/pm-providers/github-projects/hooks.ts b/web/src/components/projects/pm-providers/github-projects/hooks.ts
new file mode 100644
index 000000000..c889c2888
--- /dev/null
+++ b/web/src/components/projects/pm-providers/github-projects/hooks.ts
@@ -0,0 +1,234 @@
+import { useMutation, useQuery } from '@tanstack/react-query';
+import type { Dispatch } from 'react';
+import { useEffect, useMemo } from 'react';
+import { trpcClient } from '@/lib/trpc.js';
+import type {
+ GitHubProjectsStatusOption,
+ WizardAction,
+ WizardState,
+} from '../../pm-wizard-state.js';
+import type { ProviderAuthMetadata } from '../types.js';
+
+/**
+ * Fetches the current GitHub user to populate the owner selection.
+ * Uses the 'currentUser' discovery capability which returns the authenticated viewer.
+ */
+async function fetchCurrentUser(
+ token: string | undefined,
+ projectId: string,
+ hasStoredCredentials: boolean,
+): Promise<{ login: string; type: 'user' } | null> {
+ // In edit mode with stored credentials but no token, use project-scoped discovery
+ if (!token && hasStoredCredentials) {
+ try {
+ const result = (await trpcClient.pm.discovery.discover.mutate({
+ providerId: 'github-projects',
+ capability: 'currentUser',
+ args: {},
+ projectId,
+ })) as { id: string; name: string; displayName: string };
+ return { login: result.displayName || result.name, type: 'user' };
+ } catch {
+ return null;
+ }
+ }
+
+ // Otherwise use credential-based discovery
+ if (!token) return null;
+ try {
+ const result = (await trpcClient.pm.discovery.discover.mutate({
+ providerId: 'github-projects',
+ capability: 'currentUser',
+ args: {},
+ credentials: { token },
+ })) as { id: string; name: string; displayName: string };
+ return { login: result.displayName || result.name, type: 'user' };
+ } catch {
+ return null;
+ }
+}
+
+export function useGitHubProjectsDiscovery(
+ state: WizardState,
+ dispatch: Dispatch,
+ advanceToStep: (step: number) => void,
+ projectId: string,
+) {
+ const githubProjectsProjectsMutation = useMutation({
+ mutationFn: async () => {
+ if (state.isEditing && state.hasStoredCredentials && !state.githubProjectsToken) {
+ return (await trpcClient.pm.discovery.discover.mutate({
+ providerId: 'github-projects',
+ capability: 'projects',
+ args: { containerId: `${state.githubProjectsOwner}:${state.githubProjectsOwnerType}` },
+ projectId,
+ })) as Array<{ id: string; name: string; url: string }>;
+ }
+ if (!state.githubProjectsToken) {
+ throw new Error('Enter your GitHub token before fetching projects');
+ }
+ return (await trpcClient.pm.discovery.discover.mutate({
+ providerId: 'github-projects',
+ capability: 'projects',
+ args: { containerId: `${state.githubProjectsOwner}:${state.githubProjectsOwnerType}` },
+ credentials: { token: state.githubProjectsToken },
+ })) as Array<{ id: string; name: string; url: string }>;
+ },
+ onSuccess: (projects) =>
+ dispatch({
+ type: 'SET_GITHUB_PROJECTS_PROJECTS',
+ // Discovery returns `{ id, name, url }` — structurally identical to
+ // GitHubProjectsProjectOption, so no cast is needed (the `name` field
+ // now lines up; the previous `as` cast masked a `title` mismatch).
+ projects,
+ }),
+ });
+
+ const githubProjectsStatusesMutation = useMutation({
+ mutationFn: async (projectIdArg: string) => {
+ if (state.isEditing && state.hasStoredCredentials && !state.githubProjectsToken) {
+ return (await trpcClient.pm.discovery.discover.mutate({
+ providerId: 'github-projects',
+ capability: 'states',
+ args: { containerId: projectIdArg },
+ projectId,
+ })) as Array<{ id: string; name: string; category: string; color?: string }>;
+ }
+ if (!state.githubProjectsToken) {
+ throw new Error('Enter your GitHub token before fetching statuses');
+ }
+ return (await trpcClient.pm.discovery.discover.mutate({
+ providerId: 'github-projects',
+ capability: 'states',
+ args: { containerId: projectIdArg },
+ credentials: { token: state.githubProjectsToken },
+ })) as Array<{ id: string; name: string; category: string; color?: string }>;
+ },
+ onSuccess: (statuses) => {
+ dispatch({
+ type: 'SET_GITHUB_PROJECTS_STATUS_OPTIONS',
+ options: statuses as GitHubProjectsStatusOption[],
+ });
+ advanceToStep(4);
+ },
+ });
+
+ const handleProjectSelect = (projectIdArg: string) => {
+ dispatch({ type: 'SET_GITHUB_PROJECTS_PROJECT_ID', id: projectIdArg });
+ if (projectIdArg) {
+ githubProjectsStatusesMutation.mutate(projectIdArg);
+ }
+ };
+
+ // Auto-fetch projects when verification succeeds or owner is selected.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally trigger only on verification/owner change
+ useEffect(() => {
+ if (!state.verificationResult || state.provider !== 'github-projects') return;
+ if (
+ state.githubProjectsOwner &&
+ state.githubProjectsProjects.length === 0 &&
+ !githubProjectsProjectsMutation.isPending
+ ) {
+ githubProjectsProjectsMutation.mutate();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [state.verificationResult, state.githubProjectsOwner]);
+
+ // In edit mode, auto-fetch projects and statuses.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally trigger on edit mode and stored creds
+ useEffect(() => {
+ if (!state.isEditing || state.provider !== 'github-projects') return;
+ const canFetch = state.githubProjectsToken ? true : state.hasStoredCredentials;
+ if (
+ state.githubProjectsOwner &&
+ state.githubProjectsProjects.length === 0 &&
+ canFetch &&
+ !githubProjectsProjectsMutation.isPending
+ ) {
+ githubProjectsProjectsMutation.mutate();
+ }
+ if (
+ state.githubProjectsProjectId &&
+ state.githubProjectsStatusOptions.length === 0 &&
+ canFetch &&
+ !githubProjectsStatusesMutation.isPending
+ ) {
+ githubProjectsStatusesMutation.mutate(state.githubProjectsProjectId);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [state.isEditing, state.githubProjectsProjectId, state.hasStoredCredentials]);
+
+ return {
+ githubProjectsProjectsMutation,
+ githubProjectsStatusesMutation,
+ handleProjectSelect,
+ };
+}
+
+export function useGitHubProjectsOwnerManagement(
+ state: WizardState,
+ dispatch: Dispatch,
+ projectId: string,
+) {
+ const { data: currentUser } = useQuery({
+ queryKey: ['github-projects', 'currentUser', state.githubProjectsToken, projectId],
+ queryFn: () =>
+ fetchCurrentUser(state.githubProjectsToken, projectId, state.hasStoredCredentials),
+ enabled:
+ state.provider === 'github-projects' &&
+ (Boolean(state.githubProjectsToken) || state.hasStoredCredentials),
+ });
+
+ const ownerOptions = useMemo(() => {
+ if (currentUser) {
+ return [{ login: currentUser.login, type: currentUser.type }];
+ }
+ // Fallback: if we have an owner already set in state, use that
+ if (state.githubProjectsOwner) {
+ return [{ login: state.githubProjectsOwner, type: state.githubProjectsOwnerType }];
+ }
+ return [];
+ }, [currentUser, state.githubProjectsOwner, state.githubProjectsOwnerType]);
+
+ const setOwner = (login: string, ownerType: 'user' | 'organization') => {
+ dispatch({ type: 'SET_GITHUB_PROJECTS_OWNER', login, ownerType });
+ };
+
+ return {
+ ownerOptions,
+ setOwner,
+ };
+}
+
+/**
+ * Hook for label creation in GitHub Projects.
+ *
+ * @remarks
+ * This is intentionally a no-op. GitHub Projects v2 does not support programmatic
+ * label creation through the minimal integration. Labels in GitHub are managed
+ * at the repository level, not the project level, and require different permissions.
+ * This stub maintains API compatibility with other PM providers (Trello, JIRA, Linear)
+ * that do support label creation.
+ *
+ * @param _providerId - Unused. The provider identifier ('github-projects').
+ * @param _auth - Unused. Provider authentication metadata.
+ * @param _state - Unused. Current wizard state.
+ * @param _dispatch - Unused. State dispatch function.
+ * @param _projectId - Unused. The project ID.
+ * @returns Stub mutations that perform no operations.
+ */
+export function useGitHubProjectsLabelCreation(
+ _providerId: string,
+ _auth: ProviderAuthMetadata,
+ _state: WizardState,
+ _dispatch: Dispatch,
+ _projectId: string,
+) {
+ // Label creation is not supported for GitHub Projects in the minimal integration.
+ // Labels in GitHub are repository-scoped and require different permissions than
+ // project-scoped operations. This stub maintains API compatibility with other providers.
+ return {
+ createLabelMutation: { mutate: () => {}, isPending: false },
+ createMissingLabelsMutation: { mutate: () => {}, isPending: false },
+ };
+}
diff --git a/web/src/components/projects/pm-providers/github-projects/index.ts b/web/src/components/projects/pm-providers/github-projects/index.ts
new file mode 100644
index 000000000..3cf748b65
--- /dev/null
+++ b/web/src/components/projects/pm-providers/github-projects/index.ts
@@ -0,0 +1,12 @@
+/**
+ * GitHub Projects frontend provider barrel.
+ * Side-effect: registers the wizard definition with the global registry.
+ */
+
+import { registerProviderWizard } from '../registry.js';
+import { githubProjectsProviderWizard } from './wizard.js';
+
+registerProviderWizard(githubProjectsProviderWizard);
+
+export * from './state.js';
+export { githubProjectsProviderWizard };
diff --git a/web/src/components/projects/pm-providers/github-projects/state.ts b/web/src/components/projects/pm-providers/github-projects/state.ts
new file mode 100644
index 000000000..f8101f737
--- /dev/null
+++ b/web/src/components/projects/pm-providers/github-projects/state.ts
@@ -0,0 +1,119 @@
+export interface GitHubProjectsOwnerOption {
+ login: string;
+ type: 'user' | 'organization';
+}
+
+export interface GitHubProjectsProjectOption {
+ id: string;
+ name: string;
+ url: string;
+}
+
+export interface GitHubProjectsStatusOption {
+ id: string;
+ name: string;
+ color?: string;
+}
+
+export interface GitHubProjectsWizardStateSlice {
+ githubProjectsToken: string;
+ githubProjectsOwner: string;
+ githubProjectsOwnerType: 'user' | 'organization';
+ githubProjectsOwners: GitHubProjectsOwnerOption[];
+ githubProjectsProjectId: string;
+ githubProjectsProjects: GitHubProjectsProjectOption[];
+ githubProjectsStatusOptions: GitHubProjectsStatusOption[];
+ githubProjectsStatusMappings: Record;
+}
+
+interface VerificationState {
+ verificationResult: { provider: string; display: string } | null;
+ verifyError: string | null;
+}
+
+export type GitHubProjectsWizardAction =
+ | { type: 'SET_GITHUB_PROJECTS_TOKEN'; value: string }
+ | { type: 'SET_GITHUB_PROJECTS_OWNER'; login: string; ownerType: 'user' | 'organization' }
+ | { type: 'SET_GITHUB_PROJECTS_OWNERS'; owners: GitHubProjectsOwnerOption[] }
+ | { type: 'SET_GITHUB_PROJECTS_PROJECT_ID'; id: string }
+ | { type: 'SET_GITHUB_PROJECTS_PROJECTS'; projects: GitHubProjectsProjectOption[] }
+ | { type: 'SET_GITHUB_PROJECTS_STATUS_OPTIONS'; options: GitHubProjectsStatusOption[] }
+ | { type: 'SET_GITHUB_PROJECTS_STATUS_MAPPING'; key: string; value: string }
+ | { type: 'RESET_GITHUB_PROJECTS_PROJECT_STATE' };
+
+export function createInitialGitHubProjectsState(): GitHubProjectsWizardStateSlice {
+ return {
+ githubProjectsToken: '',
+ githubProjectsOwner: '',
+ githubProjectsOwnerType: 'user',
+ githubProjectsOwners: [],
+ githubProjectsProjectId: '',
+ githubProjectsProjects: [],
+ githubProjectsStatusOptions: [],
+ githubProjectsStatusMappings: {},
+ };
+}
+
+export function isGitHubProjectsWizardAction(action: {
+ type: string;
+}): action is GitHubProjectsWizardAction {
+ return action.type.includes('GITHUB_PROJECTS');
+}
+
+export function githubProjectsWizardReducer<
+ T extends GitHubProjectsWizardStateSlice & VerificationState,
+>(state: T, action: GitHubProjectsWizardAction): T {
+ switch (action.type) {
+ case 'SET_GITHUB_PROJECTS_TOKEN':
+ return {
+ ...state,
+ githubProjectsToken: action.value,
+ verificationResult: null,
+ verifyError: null,
+ };
+ case 'SET_GITHUB_PROJECTS_OWNER':
+ return {
+ ...state,
+ githubProjectsOwner: action.login,
+ githubProjectsOwnerType: action.ownerType,
+ ...resetGitHubProjectsProjectState(),
+ };
+ case 'SET_GITHUB_PROJECTS_OWNERS':
+ return { ...state, githubProjectsOwners: action.owners };
+ case 'SET_GITHUB_PROJECTS_PROJECT_ID':
+ return {
+ ...state,
+ githubProjectsProjectId: action.id,
+ githubProjectsStatusMappings: {},
+ };
+ case 'SET_GITHUB_PROJECTS_PROJECTS':
+ return { ...state, githubProjectsProjects: action.projects };
+ case 'SET_GITHUB_PROJECTS_STATUS_OPTIONS':
+ return { ...state, githubProjectsStatusOptions: action.options };
+ case 'SET_GITHUB_PROJECTS_STATUS_MAPPING':
+ return {
+ ...state,
+ githubProjectsStatusMappings: {
+ ...state.githubProjectsStatusMappings,
+ [action.key]: action.value,
+ },
+ };
+ case 'RESET_GITHUB_PROJECTS_PROJECT_STATE':
+ return { ...state, ...resetGitHubProjectsProjectState() };
+ }
+}
+
+export function resetGitHubProjectsProjectState(): Pick<
+ GitHubProjectsWizardStateSlice,
+ | 'githubProjectsProjectId'
+ | 'githubProjectsProjects'
+ | 'githubProjectsStatusOptions'
+ | 'githubProjectsStatusMappings'
+> {
+ return {
+ githubProjectsProjectId: '',
+ githubProjectsProjects: [],
+ githubProjectsStatusOptions: [],
+ githubProjectsStatusMappings: {},
+ };
+}
diff --git a/web/src/components/projects/pm-providers/github-projects/webhook-step.tsx b/web/src/components/projects/pm-providers/github-projects/webhook-step.tsx
new file mode 100644
index 000000000..900b5d206
--- /dev/null
+++ b/web/src/components/projects/pm-providers/github-projects/webhook-step.tsx
@@ -0,0 +1,319 @@
+/**
+ * GitHub Projects webhook step adapter.
+ *
+ * The setup story differs by owner type:
+ *
+ * - **organization** — `projects_v2_item` is a valid org-hook event, so the
+ * webhook can be created **programmatically** (`POST /orgs/{org}/hooks`) — the
+ * step renders a "Create Webhook" button + active-webhooks list + delete
+ * (mirroring Trello/JIRA), with manual Organization Settings → Webhooks
+ * instructions as a fallback.
+ * - **user** — user-owned Projects have no webhook settings page and no
+ * create-webhook API; events can only reach CASCADE via an org-owned project
+ * or a GitHub App subscribed to `projects_v2_item`. Manual setup only.
+ *
+ * All tRPC wiring (webhooks.list/create/delete with githubProjectsOnly:true)
+ * lives in the wizard's `useProviderHooks`; this component renders what it gets.
+ */
+
+import { Info, Loader2, Trash2 } from 'lucide-react';
+import { createElement, Fragment, type ReactElement } from 'react';
+import { Button } from '@/components/ui/button.js';
+import type { DataProps } from '@/lib/data-props.js';
+import { type ProjectCredentialMeta, ProjectSecretField } from '../../project-secret-field.js';
+import { WebhookUrlDisplayStep } from '../steps/webhook-url-display.js';
+import type { ProviderWizardStepProps } from '../types.js';
+
+export interface ActiveWebhook {
+ readonly id: string;
+ readonly url: string;
+ readonly active: boolean;
+}
+
+export interface GitHubProjectsWebhookListData {
+ readonly githubProjects?: ReadonlyArray<{
+ readonly id: string | number;
+ readonly active?: boolean;
+ readonly config?: { readonly url?: string };
+ }>;
+}
+
+/**
+ * Normalize the `webhooks.list` payload's github-projects org hooks for the UI.
+ * Org webhooks can include hooks from other integrations, so we show only
+ * CASCADE's own (`…/github-projects/webhook`) — the delete path matches the same
+ * callback URL, so unrelated org hooks are neither listed nor deletable here.
+ */
+export function normalizeGitHubProjectsActiveWebhooks(
+ webhooksData: GitHubProjectsWebhookListData | undefined,
+): ActiveWebhook[] {
+ return (webhooksData?.githubProjects ?? [])
+ .filter((webhook) => (webhook.config?.url ?? '').endsWith('/github-projects/webhook'))
+ .map((webhook) => ({
+ id: String(webhook.id),
+ url: webhook.config?.url ?? '',
+ active: webhook.active ?? true,
+ }));
+}
+
+interface GitHubProjectsWebhookProviderHooks {
+ readonly webhookUrl: string;
+ readonly projectIdForSecret: string;
+ readonly webhookSecretCredential: ProjectCredentialMeta | undefined;
+ // Org-owned programmatic webhook management (undefined for user-owned).
+ readonly callbackBaseUrl?: string;
+ readonly activeGithubProjectsWebhooks?: ReadonlyArray;
+ readonly webhooksLoading?: boolean;
+ readonly createGithubProjectsWebhook?: () => void;
+ readonly createLoading?: boolean;
+ readonly createError?: string | undefined;
+ readonly deleteGithubProjectsWebhook?: (callbackBaseUrl: string) => void;
+ readonly deleteLoading?: boolean;
+}
+
+function asGitHubProjectsWebhookHooks(
+ providerHooks: Record | undefined,
+): GitHubProjectsWebhookProviderHooks {
+ return (providerHooks ?? {}) as unknown as GitHubProjectsWebhookProviderHooks;
+}
+
+/** Body copy for the info banner, scoped to owner type (see file header). */
+function bannerBody(isOrg: boolean): string {
+ return isOrg
+ ? 'Organization webhooks can be created via the GitHub API — use the "Create Webhook" button ' +
+ 'below, or add it manually in your organization settings and enable the Projects v2 events.'
+ : "User-owned GitHub Projects have no webhook settings page and no create-webhook API. To receive Projects v2 events, move the project under an organization (Organization Settings → Webhooks) or use a GitHub App subscribed to 'projects_v2_item'.";
+}
+
+/** The shared WebhookUrlDisplayStep's inline instruction line. */
+function urlDisplayInstructions(isOrg: boolean): string {
+ return isOrg
+ ? 'Click "Create Webhook" to register automatically, or configure this URL in your GitHub organization settings.'
+ : 'Configure this webhook URL in the organization or GitHub App that receives your project events.';
+}
+
+/** Owner-aware "where to add the webhook" first step; the rest are shared. */
+function locationStep(isOrg: boolean): ReactElement {
+ return isOrg
+ ? createElement(
+ 'li',
+ { key: 'loc' },
+ 'Go to your GitHub ',
+ createElement('strong', null, 'organization'),
+ ' Settings → Webhooks.',
+ )
+ : createElement(
+ 'li',
+ { key: 'loc' },
+ 'User-owned projects have no webhook settings — receive events through the ',
+ createElement('strong', null, 'organization'),
+ ' that owns the project (Organization Settings → Webhooks) or a GitHub App subscribed to ',
+ createElement('code', null, 'projects_v2_item'),
+ '.',
+ );
+}
+
+function instructionSteps(isOrg: boolean): ReactElement[] {
+ return [
+ locationStep(isOrg),
+ createElement('li', { key: 'add' }, 'Click "Add webhook" and enter the URL above.'),
+ createElement(
+ 'li',
+ { key: 'content-type' },
+ 'Set Content type to ',
+ createElement('code', null, 'application/json'),
+ '.',
+ ),
+ createElement(
+ 'li',
+ { key: 'events' },
+ 'Select "Let me select individual events" and enable ',
+ createElement('strong', null, 'Projects v2'),
+ ' events.',
+ ),
+ createElement(
+ 'li',
+ { key: 'secret' },
+ 'If you set a secret in GitHub, paste it into the field above so CASCADE can verify webhook authenticity.',
+ ),
+ ];
+}
+
+function infoBanner(isOrg: boolean): ReactElement {
+ return createElement(
+ 'div',
+ {
+ className:
+ 'rounded-md border border-blue-200 bg-blue-50 px-4 py-3 dark:border-blue-900/50 dark:bg-blue-900/20',
+ 'data-section': 'info-banner',
+ },
+ createElement(
+ 'div',
+ { className: 'flex items-start gap-2' },
+ createElement(Info, {
+ className: 'h-4 w-4 text-blue-600 dark:text-blue-400 shrink-0 mt-0.5',
+ }),
+ createElement(
+ 'div',
+ { className: 'space-y-1' },
+ createElement(
+ 'p',
+ { className: 'text-sm font-medium text-blue-700 dark:text-blue-300' },
+ isOrg ? 'Webhook Setup' : 'Manual Webhook Setup Required',
+ ),
+ createElement(
+ 'p',
+ { className: 'text-xs text-blue-600 dark:text-blue-400' },
+ bannerBody(isOrg),
+ ),
+ ),
+ ),
+ );
+}
+
+/** Active-webhooks list (org-owned only). */
+function activeWebhookList(h: GitHubProjectsWebhookProviderHooks): ReactElement {
+ const active = h.activeGithubProjectsWebhooks ?? [];
+ return createElement(
+ 'div',
+ { className: 'space-y-2', 'data-section': 'active-webhooks' },
+ h.webhooksLoading
+ ? createElement(
+ 'p',
+ {
+ 'data-state': 'loading',
+ className: 'flex items-center gap-2 text-sm text-muted-foreground',
+ },
+ createElement(Loader2, { className: 'h-4 w-4 animate-spin' }),
+ 'Loading webhooks…',
+ )
+ : active.length === 0
+ ? createElement(
+ 'p',
+ { className: 'text-sm text-amber-600 dark:text-amber-400' },
+ 'No GitHub Projects webhooks configured for this organization.',
+ )
+ : createElement(
+ 'ul',
+ { className: 'space-y-1' },
+ ...active.map((wh) =>
+ createElement(
+ 'li',
+ {
+ key: wh.id,
+ className: 'flex items-center justify-between rounded-md border px-3 py-2',
+ 'data-webhook-id': wh.id,
+ },
+ createElement(
+ 'div',
+ { className: 'flex items-center gap-2 text-sm' },
+ createElement('span', {
+ className: `inline-block h-2 w-2 rounded-full ${wh.active ? 'bg-green-500 dark:bg-green-400' : 'bg-amber-500 dark:bg-amber-400'}`,
+ 'data-active': wh.active ? 'true' : 'false',
+ }),
+ createElement('code', { className: 'font-mono text-xs break-all' }, wh.url),
+ ),
+ createElement(
+ Button,
+ {
+ type: 'button',
+ variant: 'ghost',
+ size: 'icon-sm',
+ 'data-action': 'delete-webhook',
+ 'data-webhook-id': wh.id,
+ disabled: h.deleteLoading,
+ onClick: () => {
+ // Strip the trailing /github-projects/webhook to recover the base URL.
+ const base = wh.url.replace(/\/github-projects\/webhook$/, '');
+ h.deleteGithubProjectsWebhook?.(base);
+ },
+ title: 'Delete webhook',
+ } as React.ComponentProps & DataProps,
+ createElement(Trash2, { className: 'h-4 w-4' }),
+ ),
+ ),
+ ),
+ ),
+ );
+}
+
+/** "Create Webhook" button (org-owned only). */
+function createButton(h: GitHubProjectsWebhookProviderHooks): ReactElement {
+ const createDisabled = !h.callbackBaseUrl || h.createLoading;
+ return createElement(
+ 'div',
+ { className: 'space-y-2' },
+ createElement(
+ Button,
+ {
+ type: 'button',
+ variant: 'default',
+ 'data-action': 'create-webhook',
+ disabled: createDisabled,
+ onClick: () => h.createGithubProjectsWebhook?.(),
+ } as React.ComponentProps & DataProps,
+ h.createLoading ? createElement(Loader2, { className: 'h-4 w-4 animate-spin' }) : null,
+ h.createLoading ? 'Creating…' : 'Create Webhook',
+ ),
+ h.createError
+ ? createElement(
+ 'p',
+ { className: 'text-sm text-destructive', 'data-state': 'error' },
+ h.createError,
+ )
+ : null,
+ );
+}
+
+export function GitHubProjectsWebhookAdapter({
+ state,
+ providerHooks,
+}: ProviderWizardStepProps): ReactElement {
+ const h = asGitHubProjectsWebhookHooks(providerHooks);
+ const isOrg = state.githubProjectsOwnerType === 'organization';
+
+ return createElement(
+ Fragment,
+ null,
+ infoBanner(isOrg),
+ WebhookUrlDisplayStep({
+ step: {
+ kind: 'webhook-url-display',
+ id: 'github-projects-webhook',
+ config: { instructions: urlDisplayInstructions(isOrg) },
+ },
+ providerId: 'github-projects',
+ webhookUrl: h.webhookUrl,
+ }),
+ // Programmatic create/list/delete — organization-owned projects only.
+ isOrg ? activeWebhookList(h) : null,
+ isOrg ? createButton(h) : null,
+ h.projectIdForSecret
+ ? createElement(ProjectSecretField, {
+ projectId: h.projectIdForSecret,
+ envVarKey: 'GITHUB_WEBHOOK_SECRET',
+ label: 'Webhook Signing Secret (optional)',
+ description:
+ 'Paste the signing secret from your GitHub webhook. CASCADE verifies HMAC-SHA256 on every incoming GitHub Projects webhook request when this is set; verification is skipped if left blank.',
+ placeholder: 'ghp_...',
+ credential: h.webhookSecretCredential,
+ })
+ : null,
+ createElement(
+ 'div',
+ { className: 'space-y-2' },
+ createElement(
+ 'p',
+ { className: 'text-xs text-muted-foreground font-medium' },
+ isOrg ? 'Manual setup (alternative):' : 'Setup instructions:',
+ ),
+ createElement(
+ 'ol',
+ {
+ className: 'list-decimal list-inside space-y-1 text-xs text-muted-foreground pl-1',
+ },
+ ...instructionSteps(isOrg),
+ ),
+ ),
+ );
+}
diff --git a/web/src/components/projects/pm-providers/github-projects/wizard.ts b/web/src/components/projects/pm-providers/github-projects/wizard.ts
new file mode 100644
index 000000000..e2e95726a
--- /dev/null
+++ b/web/src/components/projects/pm-providers/github-projects/wizard.ts
@@ -0,0 +1,348 @@
+/**
+ * GitHub Projects ProviderWizardDefinition.
+ */
+
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import type { ReactElement } from 'react';
+import { API_URL } from '@/lib/api.js';
+import { trpc, trpcClient } from '@/lib/trpc.js';
+import type { ProjectCredentialMeta } from '../../project-secret-field.js';
+import { buildMissingStatusTriggerConfigs } from '../save-trigger-configs.js';
+import { ContainerPickStep } from '../steps/container-pick.js';
+import { CredentialsStep } from '../steps/credentials.js';
+import { ProjectScopeStep } from '../steps/project-scope.js';
+import { StatusMappingStep } from '../steps/status-mapping.js';
+import type { ProviderWizardDefinition, ProviderWizardStepProps } from '../types.js';
+import { githubProjectsAuthMetadata, githubProjectsCredentialPersistence } from './auth.js';
+import {
+ useGitHubProjectsDiscovery,
+ useGitHubProjectsLabelCreation,
+ useGitHubProjectsOwnerManagement,
+} from './hooks.js';
+import {
+ type ActiveWebhook,
+ GitHubProjectsWebhookAdapter,
+ normalizeGitHubProjectsActiveWebhooks,
+} from './webhook-step.js';
+
+export const GITHUB_PROJECTS_STATUS_SLOTS = [
+ { key: 'backlog', label: 'Backlog' },
+ { key: 'todo', label: 'Todo' },
+ { key: 'inProgress', label: 'In Progress' },
+ { key: 'inReview', label: 'In Review' },
+ { key: 'done', label: 'Done' },
+ { key: 'merged', label: 'Merged' },
+ { key: 'alerts', label: 'Alerts' },
+ { key: 'friction', label: 'Friction' },
+] as const;
+
+const GITHUB_PROJECTS_CREDENTIAL_ROLES = [{ role: 'token', label: 'GitHub Personal Access Token' }];
+
+function isCredentialsComplete(state: {
+ githubProjectsToken: string;
+ verificationResult: unknown;
+ isEditing: boolean;
+ hasStoredCredentials: boolean;
+}): boolean {
+ if (state.isEditing && state.hasStoredCredentials) return true;
+ return Boolean(state.githubProjectsToken && state.verificationResult);
+}
+
+function areRequiredStepsDone(
+ state: Parameters[0] & {
+ githubProjectsProjectId: string;
+ githubProjectsStatusMappings: Record;
+ },
+): boolean {
+ return (
+ isCredentialsComplete(state) &&
+ Boolean(state.githubProjectsProjectId) &&
+ Object.keys(state.githubProjectsStatusMappings).length > 0
+ );
+}
+
+interface GitHubProjectsProviderHooks {
+ readonly projectOptions: ReadonlyArray<{ readonly id: string; readonly name: string }>;
+ readonly projectsLoading: boolean;
+ readonly projectsError: string | undefined;
+ readonly onProjectSelect: (projectId: string) => void;
+ readonly statusOptionsLoading: boolean;
+ readonly providerStates: ReadonlyArray<{ readonly id: string; readonly name: string }>;
+ readonly webhookUrl: string;
+ readonly projectIdForSecret: string;
+ readonly webhookSecretCredential: ProjectCredentialMeta | undefined;
+ readonly workflowStatuses: ReadonlyArray<{ readonly key: string; readonly label: string }>;
+ // Org-owned programmatic webhook management (consumed by GitHubProjectsWebhookAdapter).
+ readonly callbackBaseUrl: string;
+ readonly activeGithubProjectsWebhooks: ReadonlyArray;
+ readonly webhooksLoading: boolean;
+ readonly createGithubProjectsWebhook: () => void;
+ readonly createLoading: boolean;
+ readonly createError: string | undefined;
+ readonly deleteGithubProjectsWebhook: (callbackBaseUrl: string) => void;
+ readonly deleteLoading: boolean;
+}
+
+function asGitHubProjectsHooks(
+ providerHooks: Record | undefined,
+): GitHubProjectsProviderHooks {
+ return (providerHooks ?? {}) as unknown as GitHubProjectsProviderHooks;
+}
+
+function GitHubProjectsCredentialsAdapter({
+ state,
+ dispatch,
+}: ProviderWizardStepProps): ReactElement {
+ return CredentialsStep({
+ step: { kind: 'credentials', id: 'github-projects-credentials' },
+ providerId: 'github-projects',
+ credentialRoles: GITHUB_PROJECTS_CREDENTIAL_ROLES,
+ values: { token: state.githubProjectsToken },
+ onChange: (role, value) => {
+ if (role === 'token') dispatch({ type: 'SET_GITHUB_PROJECTS_TOKEN', value });
+ },
+ });
+}
+
+function GitHubProjectsScopeAdapter({
+ state,
+ dispatch,
+ providerHooks,
+}: ProviderWizardStepProps): ReactElement {
+ const h = providerHooks as { projectIdForSecret: string } | undefined;
+ const { ownerOptions, setOwner } = useGitHubProjectsOwnerManagement(
+ state,
+ dispatch,
+ h?.projectIdForSecret ?? '',
+ );
+ const selectedOwner = ownerOptions.find(
+ (o) => o.login === state.githubProjectsOwner && o.type === state.githubProjectsOwnerType,
+ );
+ return ProjectScopeStep({
+ step: { kind: 'project-scope', id: 'github-projects-scope' },
+ providerId: 'github-projects',
+ projects: ownerOptions.map((o) => ({
+ id: `${o.login}:${o.type}`,
+ name: `${o.login} (${o.type})`,
+ })),
+ selectedProjectId: selectedOwner ? `${selectedOwner.login}:${selectedOwner.type}` : null,
+ onSelect: (v) => {
+ if (!v) return;
+ const [login, ownerType] = v.split(':') as [string, 'user' | 'organization'];
+ setOwner(login, ownerType);
+ },
+ loading: false,
+ });
+}
+
+function GitHubProjectsContainerPickAdapter({
+ state,
+ dispatch,
+ providerHooks,
+}: ProviderWizardStepProps): ReactElement {
+ const h = asGitHubProjectsHooks(providerHooks);
+ return ContainerPickStep({
+ step: { kind: 'container-pick', id: 'github-projects-selection' },
+ providerId: 'github-projects',
+ label: 'Select Project',
+ options: h.projectOptions,
+ selectedId: state.githubProjectsProjectId || null,
+ onSelect: (id) => {
+ if (id) h.onProjectSelect(id);
+ else dispatch({ type: 'SET_GITHUB_PROJECTS_PROJECT_ID', id: '' });
+ },
+ loading: h.projectsLoading,
+ error: h.projectsError,
+ searchable: true,
+ });
+}
+
+function GitHubProjectsStatusMappingAdapter({
+ state,
+ dispatch,
+ providerHooks,
+}: ProviderWizardStepProps): ReactElement {
+ const h = asGitHubProjectsHooks(providerHooks);
+ return StatusMappingStep({
+ step: { kind: 'status-mapping', id: 'github-projects-statuses' },
+ providerId: 'github-projects',
+ cascadeStatuses:
+ h.workflowStatuses.length > 0 ? h.workflowStatuses : GITHUB_PROJECTS_STATUS_SLOTS,
+ providerStates: h.providerStates,
+ mappings: state.githubProjectsStatusMappings,
+ onMappingChange: (key, value) =>
+ dispatch({ type: 'SET_GITHUB_PROJECTS_STATUS_MAPPING', key, value }),
+ loading: h.statusOptionsLoading,
+ });
+}
+
+export const githubProjectsProviderWizard: ProviderWizardDefinition = {
+ id: 'github-projects',
+ label: 'GitHub Projects',
+ auth: githubProjectsAuthMetadata,
+ formatVerificationDisplay: (me) => me.displayName || me.name,
+ credentialPersistence: githubProjectsCredentialPersistence,
+
+ steps: [
+ {
+ id: 'github-projects-credentials',
+ title: 'GitHub credentials',
+ Component: GitHubProjectsCredentialsAdapter,
+ isComplete: isCredentialsComplete,
+ },
+ {
+ id: 'github-projects-scope',
+ title: 'Owner',
+ Component: GitHubProjectsScopeAdapter,
+ isComplete: (state) => Boolean(state.githubProjectsOwner),
+ },
+ {
+ id: 'github-projects-selection',
+ title: 'Project',
+ Component: GitHubProjectsContainerPickAdapter,
+ isComplete: (state) => Boolean(state.githubProjectsProjectId),
+ },
+ {
+ id: 'github-projects-statuses',
+ title: 'Status mapping',
+ Component: GitHubProjectsStatusMappingAdapter,
+ isComplete: (state) => Object.keys(state.githubProjectsStatusMappings).length > 0,
+ },
+ {
+ id: 'github-projects-webhook',
+ title: 'Webhook',
+ Component: GitHubProjectsWebhookAdapter,
+ isComplete: (state) => areRequiredStepsDone(state),
+ },
+ ],
+
+ buildIntegrationConfig: (state) => ({
+ projectId: state.githubProjectsProjectId,
+ owner: state.githubProjectsOwner,
+ ownerType: state.githubProjectsOwnerType,
+ statuses: state.githubProjectsStatusMappings,
+ }),
+
+ buildSaveTriggerConfigs: ({ state, workflowStatuses, existingConfigs }) =>
+ buildMissingStatusTriggerConfigs({
+ statusMappings: state.githubProjectsStatusMappings,
+ workflowStatuses,
+ existingConfigs,
+ }),
+
+ buildEditState: (initialConfig, configuredKeys) => {
+ const config = initialConfig as {
+ projectId?: string;
+ owner?: string;
+ ownerType?: 'user' | 'organization';
+ statuses?: Record;
+ };
+ return {
+ provider: 'github-projects',
+ githubProjectsProjectId: config.projectId ?? '',
+ githubProjectsOwner: config.owner ?? '',
+ githubProjectsOwnerType: config.ownerType ?? 'user',
+ ...(config.statuses ? { githubProjectsStatusMappings: config.statuses } : {}),
+ hasStoredCredentials: configuredKeys.has('GITHUB_TOKEN'),
+ };
+ },
+
+ isSetupComplete: (state) => {
+ if (!state.githubProjectsProjectId) return false;
+ if (Object.keys(state.githubProjectsStatusMappings).length === 0) return false;
+ return isCredentialsComplete(state);
+ },
+
+ useProviderHooks: ({ providerId, auth, state, dispatch, projectId, advanceToStep }) => {
+ const discovery = useGitHubProjectsDiscovery(state, dispatch, advanceToStep, projectId ?? '');
+ const labels = useGitHubProjectsLabelCreation(
+ providerId,
+ auth,
+ state,
+ dispatch,
+ projectId ?? '',
+ );
+ const credentialsQuery = useQuery(
+ trpc.projects.credentials.list.queryOptions({ projectId: projectId ?? '' }),
+ );
+ const workflowStatusesQuery = useQuery(trpc.workflowStatuses.list.queryOptions());
+ const webhookSecretCredential = credentialsQuery.data?.find(
+ (c) => c.envVarKey === 'GITHUB_WEBHOOK_SECRET',
+ );
+
+ const routerOrigin =
+ API_URL ||
+ (typeof window !== 'undefined' ? window.location.origin.replace(':5173', ':3000') : '');
+ const webhookUrl = routerOrigin ? `${routerOrigin}/github-projects/webhook` : '';
+
+ // Programmatic org-webhook management (mirrors Trello/JIRA). The backend
+ // no-ops for user-owned projects, so it's safe to always wire the hooks.
+ const queryClient = useQueryClient();
+ const callbackBaseUrl = routerOrigin;
+ const webhooksListOptions = trpc.webhooks.list.queryOptions({ projectId: projectId ?? '' });
+ const webhooksQuery = useQuery({
+ ...webhooksListOptions,
+ enabled: state.githubProjectsOwnerType === 'organization' && Boolean(projectId),
+ });
+ const activeGithubProjectsWebhooks = normalizeGitHubProjectsActiveWebhooks(webhooksQuery.data);
+ // Carry the just-entered token so create works before it is persisted.
+ const oneTimeTokens = state.githubProjectsToken
+ ? { githubProjectsToken: state.githubProjectsToken }
+ : undefined;
+ const invalidateWebhooks = () =>
+ queryClient.invalidateQueries({ queryKey: webhooksListOptions.queryKey });
+ const createWebhookMutation = useMutation({
+ mutationFn: () =>
+ trpcClient.webhooks.create.mutate({
+ projectId: projectId ?? '',
+ callbackBaseUrl,
+ githubProjectsOnly: true,
+ oneTimeTokens,
+ }),
+ onSuccess: invalidateWebhooks,
+ });
+ const deleteWebhookMutation = useMutation({
+ mutationFn: (deleteBaseUrl: string) =>
+ trpcClient.webhooks.delete.mutate({
+ projectId: projectId ?? '',
+ callbackBaseUrl: deleteBaseUrl,
+ githubProjectsOnly: true,
+ oneTimeTokens,
+ }),
+ onSuccess: invalidateWebhooks,
+ });
+
+ return {
+ projectOptions: state.githubProjectsProjects.map((p) => ({ id: p.id, name: p.name })),
+ projectsLoading: discovery.githubProjectsProjectsMutation.isPending,
+ projectsError: discovery.githubProjectsProjectsMutation.isError
+ ? (discovery.githubProjectsProjectsMutation.error as Error).message
+ : undefined,
+ onProjectSelect: discovery.handleProjectSelect,
+ statusOptionsLoading: discovery.githubProjectsStatusesMutation.isPending,
+ providerStates: state.githubProjectsStatusOptions.map((s) => ({
+ id: s.id,
+ name: s.name,
+ })),
+ webhookUrl,
+ projectIdForSecret: projectId ?? '',
+ webhookSecretCredential,
+ workflowStatuses:
+ workflowStatusesQuery.data?.map((status) => ({
+ key: status.key,
+ label: status.label,
+ })) ?? GITHUB_PROJECTS_STATUS_SLOTS,
+ callbackBaseUrl,
+ activeGithubProjectsWebhooks,
+ webhooksLoading: webhooksQuery.isLoading,
+ createGithubProjectsWebhook: () => createWebhookMutation.mutate(),
+ createLoading: createWebhookMutation.isPending,
+ createError: createWebhookMutation.isError
+ ? (createWebhookMutation.error as Error).message
+ : undefined,
+ deleteGithubProjectsWebhook: (baseUrl: string) => deleteWebhookMutation.mutate(baseUrl),
+ deleteLoading: deleteWebhookMutation.isPending,
+ ...labels,
+ } satisfies GitHubProjectsProviderHooks & Record;
+ },
+};
diff --git a/web/src/components/projects/pm-providers/index.ts b/web/src/components/projects/pm-providers/index.ts
index 78278ef0b..eafa293be 100644
--- a/web/src/components/projects/pm-providers/index.ts
+++ b/web/src/components/projects/pm-providers/index.ts
@@ -22,3 +22,4 @@
import './trello/index.js';
import './jira/index.js';
import './linear/index.js';
+import './github-projects/index.js';
diff --git a/web/src/components/projects/pm-wizard-state.ts b/web/src/components/projects/pm-wizard-state.ts
index 04a53e6d8..35f675b59 100644
--- a/web/src/components/projects/pm-wizard-state.ts
+++ b/web/src/components/projects/pm-wizard-state.ts
@@ -3,6 +3,14 @@
* Has zero imports from other pm-wizard files to avoid circular dependencies.
*/
import type { Reducer } from 'react';
+import {
+ createInitialGitHubProjectsState,
+ type GitHubProjectsProjectOption,
+ type GitHubProjectsStatusOption,
+ type GitHubProjectsWizardAction,
+ githubProjectsWizardReducer,
+ isGitHubProjectsWizardAction,
+} from './pm-providers/github-projects/state.js';
import {
createInitialJiraState,
INITIAL_JIRA_LABELS,
@@ -33,6 +41,8 @@ import {
} from './pm-providers/trello/state.js';
export type {
+ GitHubProjectsProjectOption,
+ GitHubProjectsStatusOption,
JiraProjectDetails,
JiraProjectOption,
LinearProjectOption,
@@ -73,6 +83,9 @@ export interface WizardState {
jiraBaseUrl: string;
jiraAuthType: JiraWizardAuthType;
linearApiKey: string;
+ githubProjectsToken: string;
+ githubProjectsOwner: string;
+ githubProjectsOwnerType: 'user' | 'organization';
verificationResult: { provider: Provider; display: string } | null;
verifyError: string | null;
// Step 3: Board/Project
@@ -84,6 +97,10 @@ export interface WizardState {
linearTeams: LinearTeamOption[];
linearProjectId: string;
linearProjects: LinearProjectOption[];
+ githubProjectsOwners: Array<{ login: string; type: 'user' | 'organization' }>;
+ githubProjectsProjects: GitHubProjectsProjectOption[];
+ githubProjectsProjectId: string;
+ githubProjectsStatusOptions: GitHubProjectsStatusOption[];
// Step 4: Field mapping
trelloBoardDetails: TrelloBoardDetails | null;
jiraProjectDetails: JiraProjectDetails | null;
@@ -100,6 +117,8 @@ export interface WizardState {
// Linear mappings
linearStatusMappings: Record;
linearLabels: Record;
+ // GitHub Projects mappings
+ githubProjectsStatusMappings: Record;
// Editing mode
isEditing: boolean;
hasStoredCredentials: boolean; // true in edit mode when provider credentials exist in project_credentials
@@ -121,7 +140,8 @@ export type WizardAction =
| { type: 'INIT_EDIT'; state: Partial }
| TrelloWizardAction
| JiraWizardAction
- | LinearWizardAction;
+ | LinearWizardAction
+ | GitHubProjectsWizardAction;
// ============================================================================
// Initial state and constants
@@ -135,6 +155,7 @@ export function createInitialState(): WizardState {
...createInitialTrelloState(),
...createInitialJiraState(),
...createInitialLinearState(),
+ ...createInitialGitHubProjectsState(),
isEditing: false,
hasStoredCredentials: false,
};
@@ -167,6 +188,7 @@ export const wizardReducer: Reducer = (state, action)
if (isTrelloWizardAction(action)) return trelloWizardReducer(state, action);
if (isJiraWizardAction(action)) return jiraWizardReducer(state, action);
if (isLinearWizardAction(action)) return linearWizardReducer(state, action);
+ if (isGitHubProjectsWizardAction(action)) return githubProjectsWizardReducer(state, action);
return state;
}
};
@@ -197,5 +219,6 @@ export function shouldUseStoredCredentials(state: WizardState): boolean {
if (!state.isEditing || !state.hasStoredCredentials) return false;
if (state.provider === 'trello') return !state.trelloApiKey;
if (state.provider === 'jira') return !state.jiraApiToken;
+ if (state.provider === 'github-projects') return !state.githubProjectsToken;
return !state.linearApiKey;
}