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
6 changes: 6 additions & 0 deletions src/agents/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ function createAgentBuilderWithGadgets(
llmCallAccumulator?: AccumulatedLlmCall[],
runId?: string,
baseBranch?: string,
projectId?: string,
cardId?: string,
): BuilderType {
return createConfiguredBuilder({
client,
Expand All @@ -203,6 +205,8 @@ function createAgentBuilderWithGadgets(
llmCallAccumulator,
runId,
baseBranch,
projectId,
cardId,
// Implementation agent uses sequential execution to ensure file operations
// are properly ordered (e.g., FileSearchAndReplace then ReadFile on same file)
postConfigure:
Expand Down Expand Up @@ -411,6 +415,8 @@ export async function executeAgent(
llmCallAccumulator,
runId,
project.baseBranch,
project.id,
cardId,
),

injectSyntheticCalls: ({ builder, ctx, trackingContext, repoDir }) =>
Expand Down
6 changes: 5 additions & 1 deletion src/agents/shared/builderFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ export interface CreateBuilderOptions {
runId?: string;
/** Base branch for PR creation (e.g. 'main', 'dev'). Passed to session state. */
baseBranch?: string;
/** Project ID for PR ↔ work item linking. Passed to session state. */
projectId?: string;
/** Work item (card) ID for PR ↔ work item linking. Passed to session state. */
cardId?: string;
}

const MAX_GADGETS_PER_RESPONSE = 25;
Expand Down Expand Up @@ -72,7 +76,7 @@ export function createConfiguredBuilder(options: CreateBuilderOptions): BuilderT

// Initialize session state for gadgets (e.g., Finish checks PR requirement for implementation)
if (!skipSessionState) {
initSessionState(agentType, options.baseBranch);
initSessionState(agentType, options.baseBranch, options.projectId, options.cardId);
}

let builder = new AgentBuilder(client)
Expand Down
2 changes: 2 additions & 0 deletions src/agents/shared/githubAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ export async function executeGitHubAgent<
llmCallAccumulator,
runId,
baseBranch: project.baseBranch,
projectId: project.id,
cardId: input.cardId,
...definition.builderOptions,
}),

Expand Down
10 changes: 10 additions & 0 deletions src/db/migrations/0014_pr_work_items.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
CREATE TABLE pr_work_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
repo_full_name TEXT NOT NULL,
pr_number INTEGER NOT NULL,
work_item_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_pr_work_items_project_pr UNIQUE (project_id, pr_number)
);
CREATE INDEX idx_pr_work_items_work_item ON pr_work_items (work_item_id);
7 changes: 7 additions & 0 deletions src/db/migrations/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@
"when": 1748000000000,
"tag": "0013_integration_model_refactor",
"breakpoints": false
},
{
"idx": 13,
"version": "7",
"when": 1749000000000,
"tag": "0014_pr_work_items",
"breakpoints": false
}
]
}
40 changes: 40 additions & 0 deletions src/db/repositories/prWorkItemsRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { and, eq } from 'drizzle-orm';
import { getDb } from '../client.js';
import { prWorkItems } from '../schema/index.js';

/**
* Upsert a PR ↔ work item link. If a row already exists for the
* (projectId, prNumber) pair, update the work item ID.
*/
export async function linkPRToWorkItem(
projectId: string,
repoFullName: string,
prNumber: number,
workItemId: string,
): Promise<void> {
const db = getDb();
await db
.insert(prWorkItems)
.values({ projectId, repoFullName, prNumber, workItemId })
.onConflictDoUpdate({
target: [prWorkItems.projectId, prWorkItems.prNumber],
set: { workItemId, repoFullName },
});
}

/**
* Look up the work item ID linked to a PR.
* Returns null if no link exists.
*/
export async function lookupWorkItemForPR(
projectId: string,
prNumber: number,
): Promise<string | null> {
const db = getDb();
const rows = await db
.select({ workItemId: prWorkItems.workItemId })
.from(prWorkItems)
.where(and(eq(prWorkItems.projectId, projectId), eq(prWorkItems.prNumber, prNumber)))
.limit(1);
return rows.length > 0 ? rows[0].workItemId : null;
}
1 change: 1 addition & 0 deletions src/db/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ export { projects } from './projects.js';
export { agentRunLlmCalls, agentRunLogs, agentRuns, debugAnalyses } from './runs.js';
export { promptPartials } from './promptPartials.js';
export { sessions, users } from './users.js';
export { prWorkItems } from './prWorkItems.js';
export { webhookLogs } from './webhookLogs.js';
20 changes: 20 additions & 0 deletions src/db/schema/prWorkItems.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { index, integer, pgTable, text, timestamp, unique, uuid } from 'drizzle-orm/pg-core';
import { projects } from './projects.js';

export const prWorkItems = pgTable(
'pr_work_items',
{
id: uuid('id').primaryKey().defaultRandom(),
projectId: text('project_id')
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
repoFullName: text('repo_full_name').notNull(),
prNumber: integer('pr_number').notNull(),
workItemId: text('work_item_id').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
unique('uq_pr_work_items_project_pr').on(table.projectId, table.prNumber),
index('idx_pr_work_items_work_item').on(table.workItemId),
],
);
20 changes: 19 additions & 1 deletion src/gadgets/github/CreatePR.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Gadget, z } from 'llmist';
import { getBaseBranch, recordPRCreation } from '../sessionState.js';
import { linkPRToWorkItem } from '../../db/repositories/prWorkItemsRepository.js';
import { logger } from '../../utils/logging.js';
import { getBaseBranch, getCardId, getProjectId, recordPRCreation } from '../sessionState.js';
import { createPR } from './core/createPR.js';

export class CreatePR extends Gadget({
Expand Down Expand Up @@ -92,6 +94,22 @@ If hooks fail or timeout, the full output will be shown.`,

recordPRCreation(result.prUrl);

// Persist PR ↔ work item link (best-effort, don't fail PR creation)
const projectId = getProjectId();
const cardId = getCardId();
if (projectId && cardId) {
try {
await linkPRToWorkItem(projectId, result.repoFullName, result.prNumber, cardId);
} catch (err) {
logger.warn('Failed to persist PR-work-item link', {
projectId,
prNumber: result.prNumber,
cardId,
error: String(err),
});
}
}

if (result.alreadyExisted) {
return `PR already exists for this branch: #${result.prNumber} — ${result.prUrl}`;
}
Expand Down
9 changes: 8 additions & 1 deletion src/gadgets/github/core/createPR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface CreatePRParams {
export interface CreatePRResult {
prNumber: number;
prUrl: string;
repoFullName: string;
alreadyExisted: boolean;
}

Expand Down Expand Up @@ -108,7 +109,12 @@ export async function createPR(params: CreatePRParams): Promise<CreatePRResult>
draft: params.draft,
});

return { prNumber: pr.number, prUrl: pr.htmlUrl, alreadyExisted: false };
return {
prNumber: pr.number,
prUrl: pr.htmlUrl,
repoFullName: `${owner}/${repo}`,
alreadyExisted: false,
};
} catch (error) {
if (
error instanceof Error &&
Expand All @@ -121,6 +127,7 @@ export async function createPR(params: CreatePRParams): Promise<CreatePRResult>
return {
prNumber: existingPR.number,
prUrl: existingPR.htmlUrl,
repoFullName: `${owner}/${repo}`,
alreadyExisted: true,
};
}
Expand Down
19 changes: 18 additions & 1 deletion src/gadgets/sessionState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,26 @@
let sessionState = {
agentType: null as string | null,
baseBranch: 'main' as string,
projectId: null as string | null,
cardId: null as string | null,
prCreated: false,
prUrl: null as string | null,
reviewSubmitted: false,
reviewUrl: null as string | null,
initialCommentId: null as number | null,
};

export function initSessionState(agentType: string, baseBranch?: string): void {
export function initSessionState(
agentType: string,
baseBranch?: string,
projectId?: string,
cardId?: string,
): void {
sessionState = {
agentType,
baseBranch: baseBranch ?? 'main',
projectId: projectId ?? null,
cardId: cardId ?? null,
prCreated: false,
prUrl: null,
reviewSubmitted: false,
Expand All @@ -25,6 +34,14 @@ export function getBaseBranch(): string {
return sessionState.baseBranch;
}

export function getProjectId(): string | null {
return sessionState.projectId;
}

export function getCardId(): string | null {
return sessionState.cardId;
}

export function recordPRCreation(prUrl: string): void {
sessionState.prCreated = true;
sessionState.prUrl = prUrl;
Expand Down
2 changes: 2 additions & 0 deletions src/github/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface PRDetails {
headSha: string;
baseRef: string;
merged: boolean;
user: { login: string };
}

export interface PRReviewComment {
Expand Down Expand Up @@ -128,6 +129,7 @@ export const githubClient = {
headSha: data.head.sha,
baseRef: data.base.ref,
merged: data.merged ?? false,
user: { login: data.user?.login || 'unknown' },
};
},

Expand Down
31 changes: 21 additions & 10 deletions src/triggers/github/check-suite-failure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/
import { logger } from '../../utils/logging.js';
import { parseRepoFullName } from '../../utils/repo.js';
import { type GitHubCheckSuitePayload, isGitHubCheckSuitePayload } from './types.js';
import { extractTrelloCardId, hasTrelloCardUrl } from './utils.js';
import { resolveWorkItemId } from './utils.js';

// Track fix attempts per PR to prevent infinite loops
const fixAttempts = new Map<number, number>();
Expand All @@ -17,7 +17,8 @@ export function resetFixAttempts(prNumber: number): void {

export class CheckSuiteFailureTrigger implements TriggerHandler {
name = 'check-suite-failure';
description = 'Triggers review agent when check suite fails on a PR with Trello card';
description =
'Triggers respond-to-ci agent when check suite fails on a PR by the implementer persona';

matches(ctx: TriggerContext): boolean {
if (ctx.source !== 'github') return false;
Expand Down Expand Up @@ -53,12 +54,16 @@ export class CheckSuiteFailureTrigger implements TriggerHandler {
const prNumber = prRef.number;
const headSha = payload.check_suite.head_sha;

// Fetch PR to check for Trello card URL
// Fetch PR details
const prDetails = await githubClient.getPR(owner, repo, prNumber);

if (!hasTrelloCardUrl(prDetails.body)) {
logger.info('PR does not have Trello card URL, skipping check failure trigger', {
// Gate on PR author being the implementer persona
if (!ctx.personaIdentities) return null;
const implLogin = ctx.personaIdentities.implementer;
if (prDetails.user.login !== implLogin && prDetails.user.login !== `${implLogin}[bot]`) {
logger.info('PR not authored by implementer persona, skipping check failure trigger', {
prNumber,
prAuthor: prDetails.user.login,
});
return null;
}
Expand All @@ -73,7 +78,13 @@ export class CheckSuiteFailureTrigger implements TriggerHandler {
return null;
}

const cardId = extractTrelloCardId(prDetails.body);
// Resolve work item from DB (with PR body fallback)
const workItemId = await resolveWorkItemId(
ctx.project.id,
prNumber,
prDetails.body,
ctx.project,
);

// Get ALL check runs for this commit to verify they're all complete
const checkStatus = await githubClient.getCheckSuiteStatus(owner, repo, headSha);
Expand Down Expand Up @@ -126,9 +137,9 @@ export class CheckSuiteFailureTrigger implements TriggerHandler {
// Increment attempt counter
fixAttempts.set(prNumber, attempts + 1);

logger.info('Check suite failure on PR with Trello card - all checks complete', {
logger.info('Check suite failure on implementer PR - all checks complete', {
prNumber,
cardId,
workItemId,
attempt: attempts + 1,
totalChecks: checkStatus.totalCount,
failedChecks: checkStatus.checkRuns
Expand All @@ -149,10 +160,10 @@ export class CheckSuiteFailureTrigger implements TriggerHandler {
repoFullName: payload.repository.full_name,
headSha,
triggerType: 'check-failure',
cardId: cardId || undefined,
cardId: workItemId,
},
prNumber,
workItemId: cardId || undefined,
workItemId,
};
}
}
Loading