Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/pm/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import { PROVIDER_CREDENTIAL_ROLES } from '../config/integrationRoles.js';
import { getIntegrationCredentialOrNull } from '../config/provider.js';
import { getIntegrationProvider } from '../db/repositories/credentialsRepository.js';
import type { AgentExecutionConfig } from '../triggers/shared/agent-execution.js';
import type { CascadeConfig, ProjectConfig } from '../types/index.js';
import type { ProjectPMConfig } from './lifecycle.js';
import type { PMProvider } from './types.js';
Expand Down Expand Up @@ -46,6 +47,12 @@ export interface PMIntegration {
/** Extract normalized lifecycle config (labels, statuses) from provider-specific config */
resolveLifecycleConfig(project: ProjectConfig): ProjectPMConfig;

/**
* Optional: Provide source-specific AgentExecutionConfig overrides.
* Used by GitHub to skip PM lifecycle steps (since GitHub agents are PR-based, not card-based).
*/
resolveExecutionConfig?(): AgentExecutionConfig;

// --- Webhook processing ---
/** Parse a raw webhook body into a normalized event, or null if irrelevant */
parseWebhookPayload(raw: unknown): PMWebhookEvent | null;
Expand Down
30 changes: 5 additions & 25 deletions src/pm/webhook-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,8 @@
* ack comment management) is delegated to the PMIntegration interface.
*/

import { withEmailIntegration } from '../email/index.js';
import { withGitHubToken } from '../github/client.js';
import { getPersonaToken } from '../github/personas.js';
import { withSmsIntegration } from '../sms/index.js';
import type { TriggerRegistry } from '../triggers/registry.js';
import { runAgentExecutionPipeline } from '../triggers/shared/agent-execution.js';
import { runAgentWithCredentials } from '../triggers/shared/webhook-execution.js';
import { processNextQueuedWebhook } from '../triggers/shared/webhook-queue.js';
import type { TriggerResult } from '../triggers/types.js';
import type {
Expand All @@ -32,7 +28,6 @@ import {
setProcessing,
startWatchdog,
} from '../utils/index.js';
import { injectLlmApiKeys } from '../utils/llmEnv.js';
import { getPMProvider, withPMProvider } from './context.js';
import type { PMIntegration } from './integration.js';
import { PMLifecycleManager, resolveProjectPMConfig } from './lifecycle.js';
Expand All @@ -48,25 +43,10 @@ async function executeAgent(
project: ProjectConfig,
config: CascadeConfig,
): Promise<void> {
if (!result.agentType) return;
const githubToken = await getPersonaToken(project.id, result.agentType);
const restoreLlmEnv = await injectLlmApiKeys(project.id);

try {
await integration.withCredentials(project.id, () =>
withEmailIntegration(project.id, () =>
withSmsIntegration(project.id, () =>
withGitHubToken(githubToken, () =>
runAgentExecutionPipeline(result, project, config, {
logLabel: `${integration.type} agent`,
}),
),
),
),
);
} finally {
restoreLlmEnv();
}
// Allow integrations to provide source-specific AgentExecutionConfig overrides
// (e.g. GitHubWebhookIntegration skips PM lifecycle steps).
const executionConfig = integration.resolveExecutionConfig?.();
await runAgentWithCredentials(integration, result, project, config, executionConfig);
}

// ============================================================================
Expand Down
121 changes: 121 additions & 0 deletions src/triggers/github/ack-comments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* GitHub acknowledgment comment helpers.
*
* Handles posting, deleting, and updating PR comments that acknowledge
* incoming webhook events and report agent status.
*/

import { INITIAL_MESSAGES } from '../../config/agentMessages.js';
import { githubClient } from '../../github/client.js';
import { extractGitHubContext, generateAckMessage } from '../../router/ackMessageGenerator.js';
import type { AgentResult, ProjectConfig } from '../../types/index.js';
import { parseRepoFullName } from '../../utils/repo.js';
import { safeOperation } from '../../utils/safeOperation.js';
import type { TriggerResult } from '../types.js';

/**
* Delete the progress comment after a successful non-implementation agent run.
*
* The implementation agent's success is handled via lifecycle (handleSuccess),
* which manages the PR comment separately.
*/
export async function deleteProgressCommentOnSuccess(
result: TriggerResult,
_agentResult: AgentResult,
): Promise<void> {
if (result.agentType === 'implementation') return;

const input = result.agentInput as { repoFullName?: string };
if (!input.repoFullName || !result.prNumber) return;

let owner: string;
let repo: string;
try {
({ owner, repo } = parseRepoFullName(input.repoFullName));
} catch {
return;
}

const { getSessionState } = await import('../../gadgets/sessionState.js');
const { initialCommentId } = getSessionState();
if (!initialCommentId) return;

await safeOperation(() => githubClient.deletePRComment(owner, repo, initialCommentId), {
action: 'delete progress comment after agent success',
prNumber: result.prNumber,
});
}

/**
* Update the initial PR comment with an error message when the agent fails.
*/
export async function updateInitialCommentWithError(
result: TriggerResult,
agentResult: { success: boolean; error?: string },
): Promise<void> {
const input = result.agentInput as { repoFullName?: string };
if (!input.repoFullName || !result.prNumber) return;

let owner: string;
let repo: string;
try {
({ owner, repo } = parseRepoFullName(input.repoFullName));
} catch {
return;
}

const { getSessionState } = await import('../../gadgets/sessionState.js');
const { initialCommentId } = getSessionState();
if (!initialCommentId) return;

const errorMessage = agentResult.error || 'Agent completed without making changes';
const body = `⚠️ **${result.agentType} agent failed**\n\n${errorMessage}\n\n<sub>Manual intervention may be required.</sub>`;

await safeOperation(() => githubClient.updatePRComment(owner, repo, initialCommentId, body), {
action: 'update PR comment with error',
prNumber: result.prNumber,
});
}

/**
* Post an acknowledgment comment on the PR.
*
* Generates an LLM-based ack message contextual to the event, falling back
* to static INITIAL_MESSAGES on failure. Injects ackCommentId and ackMessage
* into the result's agentInput so the agent can pre-seed its ProgressMonitor.
*/
export async function postAcknowledgmentComment(
result: TriggerResult,
payload: unknown,
eventType: string,
project: ProjectConfig,
): Promise<void> {
if (!result.agentType || !result.prNumber) {
return;
}
const input = result.agentInput as {
repoFullName?: string;
};
if (!input.repoFullName) {
return;
}
const { owner, repo } = parseRepoFullName(input.repoFullName);
const prNumber = result.prNumber;

let message: string;
try {
const context = extractGitHubContext(payload, eventType);
message = await generateAckMessage(result.agentType, context, project.id);
} catch {
message = INITIAL_MESSAGES[result.agentType] ?? INITIAL_MESSAGES.implementation;
}

const comment = await safeOperation(
() => githubClient.createPRComment(owner, repo, prNumber, message),
{ action: 'post acknowledgment comment', prNumber },
);
if (comment) {
result.agentInput.ackCommentId = comment.id;
result.agentInput.ackMessage = message;
}
}
46 changes: 46 additions & 0 deletions src/triggers/github/check-polling.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* GitHub CI check polling.
*
* Polls until all CI checks pass before allowing an agent to start.
* Used when a trigger sets `waitForChecks: true`.
*/

import { withGitHubToken } from '../../github/client.js';
import { logger } from '../../utils/index.js';
import { parseRepoFullName } from '../../utils/repo.js';
import type { TriggerResult } from '../types.js';

/**
* Poll until all CI checks pass before starting the agent.
* Returns false if checks don't pass after polling (agent should be skipped).
*/
export async function pollWaitForChecks(
result: TriggerResult,
repoFullName: string,
githubToken: string,
): Promise<boolean> {
const { waitForChecks } = await import('./check-suite-success.js');
const { owner, repo } = parseRepoFullName(repoFullName);
const headSha = result.agentInput.headSha as string;
const prNumber = result.prNumber ?? 0;

logger.info('Waiting for all checks to pass before starting agent', { prNumber, headSha });

const checkStatus = await withGitHubToken(githubToken, () =>
waitForChecks(owner, repo, headSha, prNumber),
);

if (!checkStatus.allPassing) {
logger.info('Not all checks passing after polling, skipping agent', {
prNumber,
headSha,
failedChecks: checkStatus.checkRuns
.filter((c) => c.conclusion !== 'success')
.map((c) => c.name),
});
return false;
}

logger.info('All checks passing, proceeding with agent', { prNumber });
return true;
}
153 changes: 153 additions & 0 deletions src/triggers/github/integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/**
* GitHubWebhookIntegration — adapts GitHub webhooks to the PMIntegration interface.
*
* Allows the GitHub webhook handler to delegate to the generic `processPMWebhook()`
* the same way Trello and Jira do, while encapsulating GitHub-specific concerns:
* - Project lookup by repository full name
* - Persona token credential scoping
* - GitHub-specific AgentExecutionConfig overrides
* - Ack comment operations on PRs
*/

import { loadProjectConfigByRepo } from '../../config/provider.js';
import { withGitHubToken } from '../../github/client.js';
import { getPersonaToken } from '../../github/personas.js';
import type { PMIntegration, PMWebhookEvent } from '../../pm/integration.js';
import type { ProjectPMConfig } from '../../pm/lifecycle.js';
import type { PMProvider } from '../../pm/types.js';
import type { CascadeConfig, ProjectConfig } from '../../types/index.js';
import type { AgentExecutionConfig } from '../shared/agent-execution.js';
import { deleteProgressCommentOnSuccess, updateInitialCommentWithError } from './ack-comments.js';

export class GitHubWebhookIntegration implements PMIntegration {
readonly type = 'github';

createProvider(_project: ProjectConfig): PMProvider {
// GitHub doesn't use a PM provider — returning a minimal no-op.
// The PMIntegration interface requires this method, but GitHub's
// agent execution doesn't go through PM lifecycle operations.
throw new Error(
'GitHubWebhookIntegration does not use a PM provider. ' +
'Use integration.withCredentials() and runAgentExecutionPipeline() directly.',
);
}

/**
* Scopes the execution to a GitHub persona token for the relevant agent type.
*
* The agentType is extracted from the trigger result and passed via context.
* For simplicity we use the 'implementation' persona at credential-scope time;
* the actual per-agent persona is resolved inside executeGitHubAgent.
*/
async withCredentials<T>(projectId: string, fn: () => Promise<T>): Promise<T> {
const githubToken = await getPersonaToken(projectId, 'implementation');
return withGitHubToken(githubToken, fn);
}

resolveLifecycleConfig(_project: ProjectConfig): ProjectPMConfig {
// GitHub webhooks do not use PM-style labels or statuses.
return {
labels: {},
statuses: {},
};
}

parseWebhookPayload(raw: unknown): PMWebhookEvent | null {
if (!raw || typeof raw !== 'object') return null;
const p = raw as Record<string, unknown>;
const repository = p.repository as Record<string, unknown> | undefined;
const repoFullName = repository?.full_name as string | undefined;

if (!repoFullName) {
return null;
}

// Determine the event type from the payload shape
const eventType = this.detectEventType(p);

return {
eventType,
projectIdentifier: repoFullName,
// GitHub doesn't embed a PM work item ID in the webhook payload
workItemId: undefined,
raw,
};
}

async isSelfAuthored(_event: PMWebhookEvent, _projectId: string): Promise<boolean> {
// Self-authored check is handled upstream in the GitHub router layer.
// By the time we reach this integration, self-authored events are already filtered.
return false;
}

async postAckComment(
_projectId: string,
_workItemId: string,
_message: string,
): Promise<string | null> {
// GitHub ack comments are posted via postAcknowledgmentComment() in ack-comments.ts,
// which has access to the full TriggerResult (needed for prNumber and repoFullName).
// This method is part of the interface but not used for GitHub.
return null;
}

async deleteAckComment(
_projectId: string,
_workItemId: string,
_commentId: string,
): Promise<void> {
// No-op — GitHub ack comments are managed via the ack-comments module.
}

async sendReaction(_projectId: string, _event: PMWebhookEvent): Promise<void> {
// No-op — GitHub reactions are not part of the PM webhook flow.
}

async lookupProject(
identifier: string,
): Promise<{ project: ProjectConfig; config: CascadeConfig } | null> {
const result = await loadProjectConfigByRepo(identifier);
return result ?? null;
}

extractWorkItemId(_text: string): string | null {
// GitHub webhooks don't embed PM work item IDs in text.
// PR-to-card linking is handled by the trigger registry.
return null;
}

/**
* Returns the GitHub-specific AgentExecutionConfig.
*
* GitHub agents skip PM lifecycle prepare/failure steps because:
* - They are triggered from GitHub PRs, not PM cards
* - handleSuccess is only called for 'implementation' (PR merge tracking)
* - Failure feedback goes to the PR comment, not the PM card
*/
resolveExecutionConfig(): AgentExecutionConfig {
return {
skipPrepareForAgent: true,
skipHandleFailure: true,
handleSuccessOnlyForAgentType: 'implementation',
onSuccess: deleteProgressCommentOnSuccess,
onFailure: updateInitialCommentWithError,
logLabel: 'GitHub agent',
};
}

// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------

private detectEventType(p: Record<string, unknown>): string {
if (p.pull_request) {
const action = p.action as string | undefined;
return action ? `pull_request.${action}` : 'pull_request';
}
if (p.review) return 'pull_request_review';
if (p.comment) return 'pull_request_review_comment';
if (p.check_suite) return 'check_suite';
if (p.check_run) return 'check_run';
return 'unknown';
}
}
Loading