diff --git a/cdk/src/constructs/jira-integration.ts b/cdk/src/constructs/jira-integration.ts index 4c666e3a..1dac4c70 100644 --- a/cdk/src/constructs/jira-integration.ts +++ b/cdk/src/constructs/jira-integration.ts @@ -148,7 +148,7 @@ export class JiraIntegration extends Construct { */ public readonly workspaceRegistryTable: dynamodb.Table; - /** Webhook dedup table — `{issueKey}#{webhookEvent}#{timestamp}` keys with 8h TTL. */ + /** Webhook dedup table — issue timestamps or stable comment IDs, with an 8h TTL. */ public readonly webhookDedupTable: dynamodb.Table; /** Jira webhook signing secret (placeholder — populated by `bgagent jira setup`). */ @@ -168,7 +168,8 @@ export class JiraIntegration extends Construct { this.workspaceRegistryTable = workspaceRegistry.table; // Dedup table: Jira webhook retries collapse to a single processor invoke - // within the 8h TTL window. Keyed on `{issueKey}#{webhookEvent}#{timestamp}`. + // within the 8h TTL window. Issue events use the event timestamp; comment + // events use the stable Jira comment ID. this.webhookDedupTable = new dynamodb.Table(this, 'WebhookDedupTable', { partitionKey: { name: 'dedup_key', type: dynamodb.AttributeType.STRING }, billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, diff --git a/cdk/src/constructs/task-table-indexes.ts b/cdk/src/constructs/task-table-indexes.ts new file mode 100644 index 00000000..d514db1a --- /dev/null +++ b/cdk/src/constructs/task-table-indexes.ts @@ -0,0 +1,26 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * GSI name for resolving Jira-origin tasks by tenant and issue key. + * + * Kept dependency-free because both CDK constructs and bundled Lambda handlers + * use this deployment/runtime contract. + */ +export const JIRA_ISSUE_INDEX_NAME = 'JiraIssueIndex'; diff --git a/cdk/src/constructs/task-table.ts b/cdk/src/constructs/task-table.ts index dfbf9e27..1e9c6599 100644 --- a/cdk/src/constructs/task-table.ts +++ b/cdk/src/constructs/task-table.ts @@ -20,6 +20,9 @@ import { RemovalPolicy } from 'aws-cdk-lib'; import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; import { Construct } from 'constructs'; +import { JIRA_ISSUE_INDEX_NAME } from './task-table-indexes'; + +export { JIRA_ISSUE_INDEX_NAME } from './task-table-indexes'; /** * Properties for TaskTable construct. @@ -47,13 +50,15 @@ export interface TaskTableProps { /** * DynamoDB table for persisting task state across the agent lifecycle. * - * Schema: task_id (PK) with three GSIs for querying by user+status, - * by status (queue processing), and by idempotency key (dedup). + * Schema: task_id (PK) with GSIs for querying by user+status, by status + * (queue processing), by idempotency key (dedup), and by Jira issue. * * GSIs: * - UserStatusIndex (PK: user_id, SK: status_created_at) — "my tasks" queries * - StatusIndex (PK: status, SK: created_at) — queue processing, monitoring * - IdempotencyIndex (PK: idempotency_key) — sparse index for dedup + * - JiraIssueIndex (PK: jira_issue_identity, SK: created_at) — sparse index + * for resolving a Jira issue to its newest PR-producing task */ export class TaskTable extends Construct { /** @@ -74,6 +79,12 @@ export class TaskTable extends Construct { */ public static readonly IDEMPOTENCY_INDEX = 'IdempotencyIndex'; + /** + * GSI for resolving a tenant-scoped Jira issue to its newest ABCA task. + * PK: jira_issue_identity (`{cloudId}#{issueKey}`), SK: created_at. + */ + public static readonly JIRA_ISSUE_INDEX = JIRA_ISSUE_INDEX_NAME; + /** * The underlying DynamoDB table. Use this to grant access or read the table name. */ @@ -118,5 +129,16 @@ export class TaskTable extends Construct { partitionKey: { name: 'idempotency_key', type: dynamodb.AttributeType.STRING }, projectionType: dynamodb.ProjectionType.KEYS_ONLY, }); + + // Sparse: only Jira-origin tasks with issue metadata carry the top-level + // jira_issue_identity attribute. Keep the projection limited to fields the + // comment-trigger resolver needs. + this.table.addGlobalSecondaryIndex({ + indexName: TaskTable.JIRA_ISSUE_INDEX, + partitionKey: { name: 'jira_issue_identity', type: dynamodb.AttributeType.STRING }, + sortKey: { name: 'created_at', type: dynamodb.AttributeType.STRING }, + projectionType: dynamodb.ProjectionType.INCLUDE, + nonKeyAttributes: ['pr_url', 'pr_number', 'status', 'repo', 'user_id', 'channel_metadata'], + }); } } diff --git a/cdk/src/handlers/jira-webhook-processor.ts b/cdk/src/handlers/jira-webhook-processor.ts index 3ab5ea95..5b50a7de 100644 --- a/cdk/src/handlers/jira-webhook-processor.ts +++ b/cdk/src/handlers/jira-webhook-processor.ts @@ -24,6 +24,10 @@ import { S3Client } from '@aws-sdk/client-s3'; import { DynamoDBDocumentClient, GetCommand, ScanCommand } from '@aws-sdk/lib-dynamodb'; import { ulid } from 'ulid'; import type { ScreeningConfig } from './shared/attachment-screening'; +import { + buildIterationInstruction, + parseCommentTrigger, +} from './shared/comment-trigger'; import { createTaskCore } from './shared/create-task-core'; import { extractDescriptionMarkdown } from './shared/jira-adf'; import { @@ -35,6 +39,11 @@ import { } from './shared/jira-attachments'; import { reportIssueFailure } from './shared/jira-feedback'; import { resolveJiraOauthToken } from './shared/jira-oauth-resolver'; +import { + prNumberFromTask, + resolveTaskByJiraIssue, + type JiraIssueTask, +} from './shared/jira-task-by-issue'; import { logger } from './shared/logger'; import type { Attachment, PassedAttachmentRecord } from './shared/types'; import { MAX_TASK_DESCRIPTION_LENGTH } from './shared/validation'; @@ -44,6 +53,7 @@ const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); const PROJECT_MAPPING_TABLE = process.env.JIRA_PROJECT_MAPPING_TABLE_NAME!; const USER_MAPPING_TABLE = process.env.JIRA_USER_MAPPING_TABLE_NAME!; +const TASK_TABLE = process.env.TASK_TABLE_NAME!; const WORKSPACE_REGISTRY_TABLE = process.env.JIRA_WORKSPACE_REGISTRY_TABLE_NAME; const DEFAULT_LABEL_FILTER = 'bgagent'; @@ -156,7 +166,7 @@ async function resolveSoleTenantCloudId(): Promise { * Undocumented fields are tolerated. */ interface JiraIssueEvent { - readonly webhookEvent: 'jira:issue_created' | 'jira:issue_updated' | string; + readonly webhookEvent: 'jira:issue_created' | 'jira:issue_updated' | 'comment_created' | string; readonly timestamp?: number; readonly cloudId?: string; readonly user?: { @@ -189,6 +199,15 @@ interface JiraIssueEvent { readonly toString?: string | null; }>; }; + readonly comment?: { + readonly id?: string; + readonly body?: unknown; + readonly author?: { + readonly accountId?: string; + readonly accountType?: string; + readonly displayName?: string; + }; + }; } interface ProcessorEvent { @@ -236,11 +255,12 @@ export async function handler(event: ProcessorEvent): Promise { return; } - if ( - payload.webhookEvent !== 'jira:issue_created' && - payload.webhookEvent !== 'jira:issue_updated' - ) { - logger.info('Jira processor skipping non-issue event', { webhookEvent: payload.webhookEvent }); + const isIssueEvent = + payload.webhookEvent === 'jira:issue_created' + || payload.webhookEvent === 'jira:issue_updated'; + const isCommentEvent = payload.webhookEvent === 'comment_created'; + if (!isIssueEvent && !isCommentEvent) { + logger.info('Jira processor skipping unsupported event', { webhookEvent: payload.webhookEvent }); return; } @@ -274,6 +294,18 @@ export async function handler(event: ProcessorEvent): Promise { } else { cloudId = payload.cloudId ?? (await resolveSoleTenantCloudId()); } + + if (isCommentEvent) { + if (!cloudId) { + logger.warn('Jira comment webhook missing cloudId and no sole active tenant', { + issue_key: issue.key, + }); + return; + } + await handleCommentTrigger(payload, issue, cloudId); + return; + } + const projectKey = issue.fields?.project?.key; if (!projectKey) { logger.info('Jira issue has no project.key — skipping (cannot route to a repo)', { @@ -544,6 +576,211 @@ export async function handler(event: ProcessorEvent): Promise { }); } +/** + * Handle `comment_created` independently of the label-trigger path. + * + * The prior task is the routing source of truth: comments do not require the + * trigger label to still be present or the Jira project mapping to remain + * active. This preserves reviewer follow-ups after the original run. + */ +async function handleCommentTrigger( + payload: JiraIssueEvent, + issue: NonNullable, + cloudId: string, +): Promise { + const comment = payload.comment; + if (!comment?.id) { + logger.warn('Jira comment payload missing comment.id', { issue_key: issue.key }); + return; + } + + // Native app users are never human reviewers. ABCA's own 3LO comments are + // attributed to the authorizing Atlassian user, so parseCommentTrigger also + // rejects ABCA's stable rendered prefixes to prevent self-trigger loops. + if (comment.author?.accountType?.toLowerCase() === 'app') { + logger.info('Ignoring Jira app-authored comment', { + issue_key: issue.key, + comment_id: comment.id, + }); + return; + } + + const commentBody = extractDescriptionMarkdown(comment.body); + const trigger = parseCommentTrigger(commentBody); + if (!trigger.triggered) { + logger.info('Jira comment has no @bgagent trigger', { + issue_key: issue.key, + comment_id: comment.id, + }); + return; + } + + const priorTask = await resolveTaskByJiraIssue( + ddb, + TASK_TABLE, + cloudId, + issue.key, + ); + const prNumber = priorTask ? prNumberFromTask(priorTask) : null; + if (!priorTask || prNumber === null) { + await safeReportIssueFailure( + issue.key, + cloudId, + "❌ I couldn't find an ABCA pull request for this Jira issue. Run the issue with the configured ABCA trigger label first, then retry this comment.", + ); + return; + } + if (!priorTask.repo) { + await safeReportIssueFailure( + issue.key, + cloudId, + "❌ I found the earlier ABCA task, but it has no repository recorded, so I can't update its pull request.", + ); + return; + } + + const linkedCommentAuthor = comment.author?.accountId + ? await lookupPlatformUser(cloudId, comment.author.accountId) + : null; + const platformUserId = linkedCommentAuthor ?? priorTask.user_id; + if (!platformUserId) { + await safeReportIssueFailure( + issue.key, + cloudId, + '❌ I found the pull request, but neither the comment author nor the original task has a linked ABCA user.', + ); + return; + } + + if (!WORKSPACE_REGISTRY_TABLE) { + logger.warn('Cannot run Jira comment iteration: workspace registry is not configured', { + issue_key: issue.key, + comment_id: comment.id, + }); + return; + } + const resolved = await resolveJiraOauthToken(cloudId, WORKSPACE_REGISTRY_TABLE); + if (!resolved) { + logger.warn('Cannot run Jira comment iteration: tenant OAuth is unavailable', { + jira_cloud_id: cloudId, + issue_key: issue.key, + comment_id: comment.id, + }); + return; + } + + const channelMetadata = buildIterationChannelMetadata( + priorTask, + issue, + cloudId, + comment.id, + resolved.oauthSecretArn, + resolved.siteUrl, + ); + const idempotencyKey = buildCommentIdempotencyKey(cloudId, issue.key, comment.id); + const requestId = crypto.randomUUID(); + const result = await createTaskCore( + { + repo: priorTask.repo, + workflow_ref: 'coding/pr-iteration-v1', + pr_number: prNumber, + task_description: buildIterationInstruction(trigger), + }, + { + userId: platformUserId, + channelSource: 'jira', + channelMetadata, + idempotencyKey, + }, + requestId, + ); + + if (result.statusCode === 200) { + logger.info('Jira comment iteration was an idempotent replay', { + issue_key: issue.key, + comment_id: comment.id, + prior_task_id: priorTask.task_id, + }); + return; + } + + if (result.statusCode !== 201) { + logger.warn('Jira comment iteration task creation returned non-201', { + status: result.statusCode, + body: result.body, + issue_key: issue.key, + comment_id: comment.id, + }); + await safeReportIssueFailure( + issue.key, + cloudId, + buildCreateTaskFailureMessage( + result.statusCode, + result.body, + 'Please add a new `@bgagent` comment in a few minutes.', + ), + ); + return; + } + + await safeReportIssueFailure( + issue.key, + cloudId, + `👀 ABCA accepted this follow-up and is updating PR #${prNumber}.`, + ); + logger.info('Jira comment-triggered PR iteration task created', { + issue_key: issue.key, + comment_id: comment.id, + prior_task_id: priorTask.task_id, + repo: priorTask.repo, + pr_number: prNumber, + attributed_to_linked_comment_author: Boolean(linkedCommentAuthor), + request_id: requestId, + }); +} + +function buildIterationChannelMetadata( + priorTask: JiraIssueTask, + issue: NonNullable, + cloudId: string, + commentId: string, + oauthSecretArn: string, + siteUrl: string, +): Record { + const previous = priorTask.channel_metadata ?? {}; + const metadata: Record = { + jira_cloud_id: cloudId, + jira_issue_id: issue.id, + jira_issue_key: issue.key, + jira_oauth_secret_arn: oauthSecretArn, + jira_site_url: siteUrl, + jira_trigger_comment_id: commentId, + jira_prior_task_id: priorTask.task_id, + }; + + const projectKey = issue.fields?.project?.key ?? previous.jira_project_key; + if (projectKey) metadata.jira_project_key = projectKey; + if (previous.jira_status_on_start) { + metadata.jira_status_on_start = previous.jira_status_on_start; + } + if (previous.jira_status_on_pr) { + metadata.jira_status_on_pr = previous.jira_status_on_pr; + } + return metadata; +} + +function buildCommentIdempotencyKey( + cloudId: string, + issueKey: string, + commentId: string, +): string { + const digest = crypto + .createHash('sha256') + .update(`${cloudId}\0${issueKey}\0${commentId}`) + .digest('hex'); + return `jira-iterate-${digest}`; +} + /** * Decide whether a Jira issue event should trigger a task. * @@ -598,7 +835,11 @@ function tokenizeLabelString(value: string | null | undefined): string[] { * Translate a `createTaskCore` non-201 response into a user-facing Jira * comment. Mirrors the Linear-side helper. */ -function buildCreateTaskFailureMessage(statusCode: number, rawBody: string): string { +function buildCreateTaskFailureMessage( + statusCode: number, + rawBody: string, + retryHint = 'Please re-apply the trigger label in a few minutes.', +): string { let detail = ''; try { if (rawBody) { @@ -616,7 +857,7 @@ function buildCreateTaskFailureMessage(statusCode: number, rawBody: string): str return `❌ ABCA couldn't accept this task: ${detail}`; } if (statusCode === 503) { - return `❌ ABCA is temporarily unavailable (status ${statusCode}). Please re-apply the trigger label in a few minutes.`; + return `❌ ABCA is temporarily unavailable (status ${statusCode}). ${retryHint}`; } if (detail) { return `❌ ABCA couldn't create this task (status ${statusCode}): ${detail}`; diff --git a/cdk/src/handlers/jira-webhook.ts b/cdk/src/handlers/jira-webhook.ts index 3efe2cf8..80ae5cc7 100644 --- a/cdk/src/handlers/jira-webhook.ts +++ b/cdk/src/handlers/jira-webhook.ts @@ -59,6 +59,9 @@ interface JiraWebhookEnvelope { readonly key?: string; readonly fields?: { readonly project?: { readonly id?: string; readonly key?: string } }; }; + readonly comment?: { + readonly id?: string; + }; /** `cloudId` is delivered as a top-level field on Atlassian Cloud webhooks. */ readonly matchedWebhookIds?: number[]; readonly user?: { readonly accountId?: string }; @@ -79,7 +82,8 @@ interface JiraEnvelopeWithCloud extends JiraWebhookEnvelope { * POST /v1/jira/webhook — Jira Cloud webhook receiver. * * Verifies the `X-Hub-Signature` HMAC over the raw body, dedups on - * `(issueKey, webhookEvent, timestamp)` with an 8h TTL, and async-invokes + * issue events on `(issueKey, webhookEvent, timestamp)` and comment events on + * `(issueKey, comment_created, commentId)` with an 8h TTL, then async-invokes * the processor Lambda so we can ack quickly. Atlassian sends the * algorithm prefix (`sha256=…`) — `verifyJiraSignature` strips it before * comparison. @@ -184,9 +188,12 @@ export async function handler(event: APIGatewayProxyEvent): Promise trimmed.startsWith(prefix)); +} + +/** Build the instruction passed to the PR-iteration workflow. */ +export function buildIterationInstruction(trigger: CommentTrigger): string { + return trigger.instruction || 'Address the latest review feedback on this pull request.'; +} diff --git a/cdk/src/handlers/shared/create-task-core.ts b/cdk/src/handlers/shared/create-task-core.ts index 95c725e8..680aadf8 100644 --- a/cdk/src/handlers/shared/create-task-core.ts +++ b/cdk/src/handlers/shared/create-task-core.ts @@ -684,6 +684,16 @@ export async function createTaskCore( ...(context.idempotencyKey && { idempotency_key: context.idempotencyKey }), channel_source: context.channelSource, channel_metadata: context.channelMetadata, + // DynamoDB GSIs cannot key on nested map values. Hoist the tenant-scoped + // Jira issue identity so JiraIssueIndex can resolve comment triggers back + // to the newest PR-producing task. + ...(context.channelSource === 'jira' + && context.channelMetadata.jira_cloud_id + && context.channelMetadata.jira_issue_key + && { + jira_issue_identity: + `${context.channelMetadata.jira_cloud_id}#${context.channelMetadata.jira_issue_key}`, + }), ...(attachmentRecords.length > 0 && { attachments: attachmentRecords }), status_created_at: `${initialStatus}#${now}`, created_at: now, diff --git a/cdk/src/handlers/shared/jira-adf.ts b/cdk/src/handlers/shared/jira-adf.ts index e624f318..62322525 100644 --- a/cdk/src/handlers/shared/jira-adf.ts +++ b/cdk/src/handlers/shared/jira-adf.ts @@ -29,7 +29,7 @@ * The full ADF spec has dozens of node types; rolling a complete converter * here would dwarf the rest of the integration and add a new dependency * surface. The agent gets a coherent text rendering; richer rendering (tables, - * mentions) can land in a follow-up. + * text marks) can land in a follow-up. * * Tests: cdk/test/handlers/jira-webhook-processor.test.ts (via the processor) * and cdk/test/handlers/shared/jira-attachments.test.ts (comment rendering). @@ -43,6 +43,8 @@ export interface AdfNode { readonly text?: string; readonly attrs?: { readonly level?: number; + /** `mention` node: rendered account label, including the leading `@`. */ + readonly text?: string; /** `media` node: `"external"` carries a direct `url`; `"file"`/`"link"` * carry an attachment `id` that needs a Jira API call to resolve. */ readonly type?: string; @@ -133,6 +135,9 @@ function walkAdf(node: AdfNode | undefined, out: string[], depth: number): void case 'text': if (node.text) out.push(node.text); return; + case 'mention': + if (node.attrs?.text) out.push(node.attrs.text); + return; default: // Unknown node — descend into its content if any so embedded text // (e.g. inside a panel or quote) isn't lost. @@ -142,6 +147,8 @@ function walkAdf(node: AdfNode | undefined, out: string[], depth: number): void function textOf(node: AdfNode): string { if (node.type === 'text' && node.text) return node.text; + if (node.type === 'mention' && node.attrs?.text) return node.attrs.text; + if (node.type === 'hardBreak') return '\n'; if (node.content) return node.content.map(textOf).join(''); return ''; } diff --git a/cdk/src/handlers/shared/jira-task-by-issue.ts b/cdk/src/handlers/shared/jira-task-by-issue.ts new file mode 100644 index 00000000..74e245af --- /dev/null +++ b/cdk/src/handlers/shared/jira-task-by-issue.ts @@ -0,0 +1,110 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { type DynamoDBDocumentClient, QueryCommand } from '@aws-sdk/lib-dynamodb'; +import { JIRA_ISSUE_INDEX_NAME } from '../../constructs/task-table-indexes'; + +const QUERY_PAGE_SIZE = 25; + +export interface JiraIssueTask { + readonly task_id: string; + readonly user_id?: string; + readonly repo?: string; + readonly pr_url?: string; + readonly pr_number?: number; + readonly status?: string; + readonly channel_metadata?: Record; +} + +/** Tenant-scoped sparse-index key for a Jira issue. */ +export function jiraIssueIdentity(cloudId: string, issueKey: string): string { + return `${cloudId}#${issueKey}`; +} + +/** + * Resolve a Jira issue to its newest PR-producing ABCA task. + * + * Newer attempts that never opened a PR are skipped. Query pagination is + * required because DynamoDB applies Limit before any client-side selection; + * stopping after a PR-less first page could hide an older valid PR target. + */ +export async function resolveTaskByJiraIssue( + ddb: DynamoDBDocumentClient, + taskTableName: string, + cloudId: string, + issueKey: string, +): Promise { + const identity = jiraIssueIdentity(cloudId, issueKey); + let exclusiveStartKey: Record | undefined; + + do { + const result = await ddb.send(new QueryCommand({ + TableName: taskTableName, + IndexName: JIRA_ISSUE_INDEX_NAME, + KeyConditionExpression: 'jira_issue_identity = :identity', + ExpressionAttributeValues: { ':identity': identity }, + ScanIndexForward: false, + Limit: QUERY_PAGE_SIZE, + ExclusiveStartKey: exclusiveStartKey, + })); + + for (const item of result.Items ?? []) { + if (prNumberFromTask(item) === null || typeof item.task_id !== 'string') { + continue; + } + return { + task_id: item.task_id, + ...(typeof item.user_id === 'string' && { user_id: item.user_id }), + ...(typeof item.repo === 'string' && { repo: item.repo }), + ...(typeof item.pr_url === 'string' && { pr_url: item.pr_url }), + ...(typeof item.pr_number === 'number' && { pr_number: item.pr_number }), + ...(typeof item.status === 'string' && { status: item.status }), + ...(isStringRecord(item.channel_metadata) && { channel_metadata: item.channel_metadata }), + }; + } + exclusiveStartKey = result.LastEvaluatedKey; + } while (exclusiveStartKey); + + return null; +} + +/** Extract a PR number from the canonical field or a persisted GitHub PR URL. */ +export function prNumberFromTask(task: { pr_number?: unknown; pr_url?: unknown }): number | null { + if ( + typeof task.pr_number === 'number' + && Number.isInteger(task.pr_number) + && task.pr_number > 0 + ) { + return task.pr_number; + } + if (typeof task.pr_url === 'string') { + const match = task.pr_url.match(/\/pull\/(\d+)\b/); + if (match) { + const parsed = Number(match[1]); + if (Number.isSafeInteger(parsed) && parsed > 0) return parsed; + } + } + return null; +} + +function isStringRecord(value: unknown): value is Record { + return typeof value === 'object' + && value !== null + && Object.values(value).every((entry) => typeof entry === 'string'); +} diff --git a/cdk/src/handlers/shared/types.ts b/cdk/src/handlers/shared/types.ts index dc4d9c84..cf29423c 100644 --- a/cdk/src/handlers/shared/types.ts +++ b/cdk/src/handlers/shared/types.ts @@ -99,6 +99,8 @@ export interface TaskRecord { readonly idempotency_key?: string; readonly channel_source: ChannelSource; readonly channel_metadata?: Record; + /** Sparse JiraIssueIndex key (`{cloudId}#{issueKey}`); internal only. */ + readonly jira_issue_identity?: string; readonly status_created_at: string; readonly created_at: string; readonly updated_at: string; diff --git a/cdk/test/constructs/task-table.test.ts b/cdk/test/constructs/task-table.test.ts index c9e58332..638d289e 100644 --- a/cdk/test/constructs/task-table.test.ts +++ b/cdk/test/constructs/task-table.test.ts @@ -19,7 +19,7 @@ import { App, RemovalPolicy, Stack } from 'aws-cdk-lib'; import { Match, Template } from 'aws-cdk-lib/assertions'; -import { TaskTable } from '../../src/constructs/task-table'; +import { JIRA_ISSUE_INDEX_NAME, TaskTable } from '../../src/constructs/task-table'; describe('TaskTable', () => { let template: Template; @@ -104,6 +104,31 @@ describe('TaskTable', () => { }); }); + test('creates sparse JiraIssueIndex GSI with the resolver projection', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + GlobalSecondaryIndexes: Match.arrayWith([ + Match.objectLike({ + IndexName: 'JiraIssueIndex', + KeySchema: [ + { AttributeName: 'jira_issue_identity', KeyType: 'HASH' }, + { AttributeName: 'created_at', KeyType: 'RANGE' }, + ], + Projection: { + ProjectionType: 'INCLUDE', + NonKeyAttributes: Match.arrayWith([ + 'pr_url', + 'pr_number', + 'status', + 'repo', + 'user_id', + 'channel_metadata', + ]), + }, + }), + ]), + }); + }); + test('declares all required attribute definitions', () => { template.hasResourceProperties('AWS::DynamoDB::Table', { AttributeDefinitions: Match.arrayWith([ @@ -113,6 +138,7 @@ describe('TaskTable', () => { { AttributeName: 'status', AttributeType: 'S' }, { AttributeName: 'created_at', AttributeType: 'S' }, { AttributeName: 'idempotency_key', AttributeType: 'S' }, + { AttributeName: 'jira_issue_identity', AttributeType: 'S' }, ]), }); }); @@ -130,6 +156,8 @@ describe('TaskTable', () => { expect(TaskTable.USER_STATUS_INDEX).toBe('UserStatusIndex'); expect(TaskTable.STATUS_INDEX).toBe('StatusIndex'); expect(TaskTable.IDEMPOTENCY_INDEX).toBe('IdempotencyIndex'); + expect(TaskTable.JIRA_ISSUE_INDEX).toBe('JiraIssueIndex'); + expect(JIRA_ISSUE_INDEX_NAME).toBe(TaskTable.JIRA_ISSUE_INDEX); }); }); diff --git a/cdk/test/handlers/jira-webhook-processor.test.ts b/cdk/test/handlers/jira-webhook-processor.test.ts index 4b7394d6..3ec35ab2 100644 --- a/cdk/test/handlers/jira-webhook-processor.test.ts +++ b/cdk/test/handlers/jira-webhook-processor.test.ts @@ -40,6 +40,15 @@ jest.mock('../../src/handlers/shared/jira-oauth-resolver', () => ({ resolveJiraOauthToken: (...args: unknown[]) => resolveJiraOauthTokenMock(...args), })); +const resolveTaskByJiraIssueMock = jest.fn(); +jest.mock('../../src/handlers/shared/jira-task-by-issue', () => { + const actual = jest.requireActual('../../src/handlers/shared/jira-task-by-issue'); + return { + ...actual, + resolveTaskByJiraIssue: (...args: unknown[]) => resolveTaskByJiraIssueMock(...args), + }; +}); + // The processor screens the folded comment section via the Bedrock Guardrail // (fail-open on intervention). Mock the client so it passes by default; the // comment-block-dropped test overrides it to GUARDRAIL_INTERVENED. @@ -68,6 +77,7 @@ jest.mock('../../src/handlers/shared/jira-attachments', () => { process.env.JIRA_PROJECT_MAPPING_TABLE_NAME = 'JiraProjects'; process.env.JIRA_USER_MAPPING_TABLE_NAME = 'JiraUsers'; process.env.JIRA_WORKSPACE_REGISTRY_TABLE_NAME = 'JiraWorkspaceRegistry'; +process.env.TASK_TABLE_NAME = 'Tasks'; // Attachment enrichment needs a bucket + guardrail configured (#577); with // these set the processor initializes S3/Bedrock clients (cheap, no network at // construction) and screens attachments through the mocked helper. @@ -111,6 +121,39 @@ function issue(overrides: Record = {}): Record }; } +function comment(overrides: Record = {}): Record { + return { + webhookEvent: 'comment_created', + timestamp: 1784079623761, + cloudId: 'cloud-1', + user: { accountId: 'reviewer-1', displayName: 'Reviewer' }, + issue: { + id: '10001', + key: 'ENG-42', + fields: { project: { id: 'p1', key: 'ENG' } }, + }, + comment: { + id: 'comment-1', + author: { + accountId: 'reviewer-1', + accountType: 'atlassian', + displayName: 'Reviewer', + }, + body: { + type: 'doc', + version: 1, + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: '@bgagent update the README too' }], + }, + ], + }, + }, + ...overrides, + }; +} + describe('jira-webhook-processor handler', () => { beforeEach(() => { ddbSend.mockReset(); @@ -126,6 +169,8 @@ describe('jira-webhook-processor handler', () => { siteUrl: 'https://acme.atlassian.net', oauthSecretArn: 'arn:aws:secretsmanager:us-east-1:123:secret:bgagent-jira-oauth-cloud-1', }); + resolveTaskByJiraIssueMock.mockReset(); + resolveTaskByJiraIssueMock.mockResolvedValue(null); // Default (#577): no comments, no attachments. Per-case tests override. fetchRecentHumanCommentsMock.mockReset(); fetchRecentHumanCommentsMock.mockResolvedValue([]); @@ -146,11 +191,271 @@ describe('jira-webhook-processor handler', () => { expect(createTaskCoreMock).not.toHaveBeenCalled(); }); - test('skips non-issue webhookEvent', async () => { - await handler(eventWith({ webhookEvent: 'comment_created', issue: { id: 'x', key: 'X-1' } })); + test('skips unsupported webhookEvent', async () => { + await handler(eventWith({ webhookEvent: 'comment_updated', issue: { id: 'x', key: 'X-1' } })); expect(createTaskCoreMock).not.toHaveBeenCalled(); }); + describe('comment-triggered PR iteration', () => { + const priorTask = { + task_id: 'prior-task', + user_id: 'original-owner', + repo: 'org/repo', + pr_number: 42, + status: 'COMPLETED', + channel_metadata: { + jira_cloud_id: 'cloud-1', + jira_project_key: 'ENG', + jira_issue_id: '10001', + jira_issue_key: 'ENG-42', + jira_status_on_start: 'Doing', + jira_status_on_pr: 'Code Review', + }, + }; + + test('ADF @bgagent comment creates a PR iteration for the linked comment author', async () => { + resolveTaskByJiraIssueMock.mockResolvedValueOnce(priorTask); + ddbSend.mockResolvedValueOnce({ + Item: { platform_user_id: 'linked-reviewer', status: 'active' }, + }); + createTaskCoreMock.mockResolvedValueOnce({ statusCode: 201, body: '{}' }); + + await handler(eventWith(comment())); + + expect(resolveTaskByJiraIssueMock).toHaveBeenCalledWith( + expect.anything(), + 'Tasks', + 'cloud-1', + 'ENG-42', + ); + expect(createTaskCoreMock).toHaveBeenCalledTimes(1); + const [request, context] = createTaskCoreMock.mock.calls[0]; + expect(request).toEqual({ + repo: 'org/repo', + workflow_ref: 'coding/pr-iteration-v1', + pr_number: 42, + task_description: 'update the README too', + }); + expect(context.userId).toBe('linked-reviewer'); + expect(context.channelSource).toBe('jira'); + expect(context.idempotencyKey).toMatch(/^jira-iterate-[a-f0-9]{64}$/); + expect(context.channelMetadata).toMatchObject({ + jira_cloud_id: 'cloud-1', + jira_project_key: 'ENG', + jira_issue_id: '10001', + jira_issue_key: 'ENG-42', + jira_status_on_start: 'Doing', + jira_status_on_pr: 'Code Review', + jira_trigger_comment_id: 'comment-1', + jira_prior_task_id: 'prior-task', + jira_oauth_secret_arn: + 'arn:aws:secretsmanager:us-east-1:123:secret:bgagent-jira-oauth-cloud-1', + }); + expect(reportIssueFailureMock).toHaveBeenCalledWith( + expect.anything(), + 'ENG-42', + '👀 ABCA accepted this follow-up and is updating PR #42.', + ); + // Comment triggers route from the prior task, not the current project + // mapping or label state. The only DDB Get is author attribution. + expect(ddbSend.mock.calls).toHaveLength(1); + expect(ddbSend.mock.calls[0][0].input.Key) + .toEqual({ jira_identity: 'cloud-1#reviewer-1' }); + }); + + test('ADF mention node creates a PR iteration', async () => { + resolveTaskByJiraIssueMock.mockResolvedValueOnce(priorTask); + ddbSend.mockResolvedValueOnce({ Item: undefined }); + createTaskCoreMock.mockResolvedValueOnce({ statusCode: 201, body: '{}' }); + const payload = comment(); + (payload.comment as Record).body = { + type: 'doc', + version: 1, + content: [{ + type: 'paragraph', + content: [ + { + type: 'mention', + attrs: { id: 'bgagent-account', text: '@bgagent' }, + }, + { type: 'text', text: ' handle the null case' }, + ], + }], + }; + + await handler(eventWith(payload)); + + expect(createTaskCoreMock).toHaveBeenCalledTimes(1); + expect(createTaskCoreMock.mock.calls[0][0].task_description) + .toBe('handle the null case'); + }); + + test('preserves ADF hard breaks in a multiline instruction', async () => { + resolveTaskByJiraIssueMock.mockResolvedValueOnce(priorTask); + ddbSend.mockResolvedValueOnce({ Item: undefined }); + createTaskCoreMock.mockResolvedValueOnce({ statusCode: 201, body: '{}' }); + const payload = comment(); + (payload.comment as Record).body = { + type: 'doc', + version: 1, + content: [{ + type: 'paragraph', + content: [ + { type: 'text', text: '@bgagent please:' }, + { type: 'hardBreak' }, + { type: 'text', text: '- update docs' }, + { type: 'hardBreak' }, + { type: 'text', text: '- add a test' }, + ], + }], + }; + + await handler(eventWith(payload)); + + expect(createTaskCoreMock.mock.calls[0][0].task_description) + .toBe('please:\n- update docs\n- add a test'); + }); + + test('plain-text comment falls back to the original task owner when author is unlinked', async () => { + resolveTaskByJiraIssueMock.mockResolvedValueOnce({ + ...priorTask, + pr_number: undefined, + pr_url: 'https://github.com/org/repo/pull/73', + }); + ddbSend.mockResolvedValueOnce({ Item: undefined }); + createTaskCoreMock.mockResolvedValueOnce({ statusCode: 201, body: '{}' }); + const payload = comment(); + (payload.comment as Record).body = '@bgagent rename the flag'; + + await handler(eventWith(payload)); + + const [request, context] = createTaskCoreMock.mock.calls[0]; + expect(request.pr_number).toBe(73); + expect(request.task_description).toBe('rename the flag'); + expect(context.userId).toBe('original-owner'); + }); + + test('bare mention uses the latest-review fallback instruction', async () => { + resolveTaskByJiraIssueMock.mockResolvedValueOnce(priorTask); + ddbSend.mockResolvedValueOnce({ Item: undefined }); + createTaskCoreMock.mockResolvedValueOnce({ statusCode: 201, body: '{}' }); + const payload = comment(); + (payload.comment as Record).body = '@bgagent'; + + await handler(eventWith(payload)); + + const [request] = createTaskCoreMock.mock.calls[0]; + expect(request.task_description) + .toBe('Address the latest review feedback on this pull request.'); + }); + + test('comment without @bgagent is a no-op', async () => { + const payload = comment(); + (payload.comment as Record).body = 'Looks good to me'; + + await handler(eventWith(payload)); + + expect(resolveTaskByJiraIssueMock).not.toHaveBeenCalled(); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + expect(reportIssueFailureMock).not.toHaveBeenCalled(); + }); + + test.each([ + { + author: { accountId: 'app-1', accountType: 'app' }, + body: '@bgagent update the README', + }, + { + author: { accountId: 'reviewer-1', accountType: 'atlassian' }, + body: '🤖 ABCA picked up this issue. Example: @bgagent update the README', + }, + ])('app/self comment is ignored: %o', async ({ author, body }) => { + const payload = comment(); + (payload.comment as Record).author = author; + (payload.comment as Record).body = body; + + await handler(eventWith(payload)); + + expect(resolveTaskByJiraIssueMock).not.toHaveBeenCalled(); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + }); + + test('posts clear feedback when no prior PR-producing task exists', async () => { + await handler(eventWith(comment())); + + expect(createTaskCoreMock).not.toHaveBeenCalled(); + expect(reportIssueFailureMock).toHaveBeenCalledTimes(1); + expect(reportIssueFailureMock.mock.calls[0][2]).toContain( + "couldn't find an ABCA pull request", + ); + }); + + test('propagates task lookup failures for asynchronous retry', async () => { + resolveTaskByJiraIssueMock.mockRejectedValueOnce(new Error('DynamoDB unavailable')); + + await expect(handler(eventWith(comment()))).rejects.toThrow('DynamoDB unavailable'); + + expect(createTaskCoreMock).not.toHaveBeenCalled(); + expect(reportIssueFailureMock).not.toHaveBeenCalled(); + }); + + test('unattributable prior task posts feedback instead of creating a task', async () => { + resolveTaskByJiraIssueMock.mockResolvedValueOnce({ + ...priorTask, + user_id: undefined, + }); + ddbSend.mockResolvedValueOnce({ Item: undefined }); + + await handler(eventWith(comment())); + + expect(createTaskCoreMock).not.toHaveBeenCalled(); + expect(reportIssueFailureMock.mock.calls[0][2]).toContain('linked ABCA user'); + }); + + test('idempotent replay creates no duplicate task acknowledgement', async () => { + resolveTaskByJiraIssueMock.mockResolvedValueOnce(priorTask); + ddbSend.mockResolvedValueOnce({ Item: undefined }); + createTaskCoreMock.mockResolvedValueOnce({ statusCode: 200, body: '{}' }); + + await handler(eventWith(comment())); + + expect(createTaskCoreMock).toHaveBeenCalledTimes(1); + expect(reportIssueFailureMock).not.toHaveBeenCalled(); + }); + + test('task admission failure is reported instead of acknowledged', async () => { + resolveTaskByJiraIssueMock.mockResolvedValueOnce(priorTask); + ddbSend.mockResolvedValueOnce({ Item: undefined }); + createTaskCoreMock.mockResolvedValueOnce({ + statusCode: 400, + body: JSON.stringify({ + error: { message: 'Task description was blocked by content policy.' }, + }), + }); + + await handler(eventWith(comment())); + + expect(reportIssueFailureMock).toHaveBeenCalledTimes(1); + expect(reportIssueFailureMock.mock.calls[0][2]).toContain( + 'blocked by content policy', + ); + expect(reportIssueFailureMock.mock.calls[0][2]).not.toContain('accepted this follow-up'); + }); + + test('transient admission failure tells the reviewer to post a new comment', async () => { + resolveTaskByJiraIssueMock.mockResolvedValueOnce(priorTask); + ddbSend.mockResolvedValueOnce({ Item: undefined }); + createTaskCoreMock.mockResolvedValueOnce({ statusCode: 503, body: '{}' }); + + await handler(eventWith(comment())); + + const message = reportIssueFailureMock.mock.calls[0][2] as string; + expect(message).toContain('temporarily unavailable'); + expect(message).toContain('add a new `@bgagent` comment'); + expect(message).not.toContain('re-apply the trigger label'); + }); + }); + test('skips when issue.id or issue.key is missing', async () => { await handler(eventWith({ webhookEvent: 'jira:issue_created' })); expect(createTaskCoreMock).not.toHaveBeenCalled(); diff --git a/cdk/test/handlers/jira-webhook.test.ts b/cdk/test/handlers/jira-webhook.test.ts index 4186ae9a..8e7fb779 100644 --- a/cdk/test/handlers/jira-webhook.test.ts +++ b/cdk/test/handlers/jira-webhook.test.ts @@ -107,6 +107,20 @@ function issueCreatePayload(overrides: Record = {}): string { }); } +function commentCreatePayload(overrides: Record = {}): string { + return JSON.stringify({ + webhookEvent: 'comment_created', + timestamp: Date.now(), + issue: { + id: '10001', + key: 'ENG-42', + fields: { project: { id: 'p1', key: 'ENG' } }, + }, + comment: { id: 'comment-1' }, + ...overrides, + }); +} + describe('jira-webhook handler', () => { beforeEach(() => { ddbSend.mockReset(); @@ -134,9 +148,9 @@ describe('jira-webhook handler', () => { expect(lambdaSend).not.toHaveBeenCalled(); }); - test('ignores non-Issue webhookEvent types with 200', async () => { + test('ignores unsupported webhookEvent types with 200', async () => { const body = JSON.stringify({ - webhookEvent: 'comment_created', + webhookEvent: 'comment_updated', timestamp: Date.now(), comment: { id: 'c-1' }, }); @@ -146,6 +160,48 @@ describe('jira-webhook handler', () => { expect(lambdaSend).not.toHaveBeenCalled(); }); + test('verified comment_created event dedups by comment id and invokes processor', async () => { + const body = commentCreatePayload(); + ddbSend.mockResolvedValueOnce({}); + lambdaSend.mockResolvedValueOnce({}); + + const result = await handler(makeEvent(body, sign(body))); + + expect(result.statusCode).toBe(200); + const putCall = ddbSend.mock.calls.find(([cmd]) => cmd._type === 'Put'); + expect(putCall![0].input.Item.dedup_key) + .toBe('ENG-42#comment_created#comment-1'); + const invokeCall = lambdaSend.mock.calls[0][0]; + const decoded = JSON.parse(new TextDecoder().decode(invokeCall.input.Payload)); + expect(decoded.raw_body).toBe(body); + }); + + test('distinct comments do not collapse even when their timestamps match', async () => { + const timestamp = Date.now(); + const body1 = commentCreatePayload({ timestamp, comment: { id: 'comment-1' } }); + const body2 = commentCreatePayload({ timestamp, comment: { id: 'comment-2' } }); + ddbSend.mockResolvedValue({}); + lambdaSend.mockResolvedValue({}); + + await handler(makeEvent(body1, sign(body1))); + await handler(makeEvent(body2, sign(body2))); + + const putCalls = ddbSend.mock.calls.filter(([cmd]) => cmd._type === 'Put'); + expect(putCalls.map(([cmd]) => cmd.input.Item.dedup_key)).toEqual([ + 'ENG-42#comment_created#comment-1', + 'ENG-42#comment_created#comment-2', + ]); + }); + + test('400s when a verified comment_created event has no comment.id', async () => { + const body = commentCreatePayload({ comment: {} }); + const result = await handler(makeEvent(body, sign(body))); + + expect(result.statusCode).toBe(400); + expect(ddbSend).not.toHaveBeenCalled(); + expect(lambdaSend).not.toHaveBeenCalled(); + }); + test('400s when issue.id or issue.key is missing on a verified Issue event', async () => { const body = JSON.stringify({ webhookEvent: 'jira:issue_created', diff --git a/cdk/test/handlers/shared/comment-trigger.test.ts b/cdk/test/handlers/shared/comment-trigger.test.ts new file mode 100644 index 00000000..613adba1 --- /dev/null +++ b/cdk/test/handlers/shared/comment-trigger.test.ts @@ -0,0 +1,93 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { + buildIterationInstruction, + isKnownAbcaComment, + parseCommentTrigger, +} from '../../../src/handlers/shared/comment-trigger'; + +describe('parseCommentTrigger', () => { + test('extracts instruction text after a token-bounded mention', () => { + expect(parseCommentTrigger('Please @bgagent update the README too')).toEqual({ + triggered: true, + instruction: 'Please update the README too', + }); + }); + + test('matches case-insensitively and removes every occurrence', () => { + expect(parseCommentTrigger('@BgAgent do X and @bgagent also Y')).toEqual({ + triggered: true, + instruction: 'do X and also Y', + }); + }); + + test('preserves multiline reviewer instructions', () => { + expect(parseCommentTrigger('@bgagent please:\n- update docs\n- add a test')).toEqual({ + triggered: true, + instruction: 'please:\n- update docs\n- add a test', + }); + }); + + test('accepts a bare mention with an empty instruction', () => { + expect(parseCommentTrigger('@bgagent')).toEqual({ + triggered: true, + instruction: '', + }); + }); + + test.each([ + 'ping @bgagentbot', + 'email foo@bgagent.io', + 'handle-like prefix foo@bgagent fix it', + 'ordinary human discussion', + '', + ])('does not trigger on %p', (body) => { + expect(parseCommentTrigger(body).triggered).toBe(false); + }); + + test.each([ + '🤖 ABCA started. Example: @bgagent fix it', + '✅ Task completed. @bgagent', + '❌ ABCA could not create the task. @bgagent retry', + '👀 ABCA accepted this follow-up. @bgagent', + ])('does not trigger on known ABCA-rendered comment %p', (body) => { + expect(parseCommentTrigger(body).triggered).toBe(false); + expect(isKnownAbcaComment(body)).toBe(true); + }); + + test('does not treat a human status emoji as proof that ABCA authored the comment', () => { + expect(parseCommentTrigger('✅ @bgagent ship the documentation update')).toEqual({ + triggered: true, + instruction: '✅ ship the documentation update', + }); + }); +}); + +describe('buildIterationInstruction', () => { + test('uses explicit reviewer instructions', () => { + expect(buildIterationInstruction({ triggered: true, instruction: 'Rename the flag' })) + .toBe('Rename the flag'); + }); + + test('uses review-feedback fallback for a bare mention', () => { + expect(buildIterationInstruction({ triggered: true, instruction: '' })) + .toBe('Address the latest review feedback on this pull request.'); + }); +}); diff --git a/cdk/test/handlers/shared/create-task-core.test.ts b/cdk/test/handlers/shared/create-task-core.test.ts index 0c49aa20..6eba74b1 100644 --- a/cdk/test/handlers/shared/create-task-core.test.ts +++ b/cdk/test/handlers/shared/create-task-core.test.ts @@ -114,6 +114,45 @@ describe('createTaskCore', () => { expect(mockLambdaSend).toHaveBeenCalledTimes(1); }); + test('hoists tenant-scoped Jira issue identity for the sparse lookup index', async () => { + const result = await createTaskCore( + { repo: 'org/repo', task_description: 'Fix the Jira issue' }, + makeContext({ + channelSource: 'jira', + channelMetadata: { + jira_cloud_id: 'cloud-1', + jira_issue_key: 'ENG-42', + }, + }), + 'req-jira-identity', + ); + + expect(result.statusCode).toBe(201); + const taskPut = mockSend.mock.calls.find( + ([command]) => command._type === 'Put' && command.input.TableName === 'Tasks', + ); + expect(taskPut![0].input.Item.jira_issue_identity).toBe('cloud-1#ENG-42'); + }); + + test('does not hoist Jira issue identity for incomplete or non-Jira metadata', async () => { + await createTaskCore( + { repo: 'org/repo', task_description: 'Regular task' }, + makeContext({ + channelSource: 'api', + channelMetadata: { + jira_cloud_id: 'cloud-1', + jira_issue_key: 'ENG-42', + }, + }), + 'req-no-jira-identity', + ); + + const taskPut = mockSend.mock.calls.find( + ([command]) => command._type === 'Put' && command.input.TableName === 'Tasks', + ); + expect(taskPut![0].input.Item).not.toHaveProperty('jira_issue_identity'); + }); + test('accepts an initial_approvals pattern whose value contains a colon', async () => { // Regression: the degenerate-pattern guard used split(':', 2)[1], which // truncated the value at the next colon. For "ab:cdefgh" that yields the diff --git a/cdk/test/handlers/shared/jira-task-by-issue.test.ts b/cdk/test/handlers/shared/jira-task-by-issue.test.ts new file mode 100644 index 00000000..c15a1cf8 --- /dev/null +++ b/cdk/test/handlers/shared/jira-task-by-issue.test.ts @@ -0,0 +1,113 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +const send = jest.fn(); +jest.mock('@aws-sdk/lib-dynamodb', () => ({ + QueryCommand: jest.fn((input: unknown) => ({ _type: 'Query', input })), +})); + +import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import { + jiraIssueIdentity, + prNumberFromTask, + resolveTaskByJiraIssue, +} from '../../../src/handlers/shared/jira-task-by-issue'; + +const ddb = { send } as unknown as DynamoDBDocumentClient; + +beforeEach(() => { + send.mockReset(); +}); + +test('builds a tenant-scoped Jira issue identity', () => { + expect(jiraIssueIdentity('cloud-1', 'ENG-42')).toBe('cloud-1#ENG-42'); +}); + +test('returns the newest PR-producing task and uses JiraIssueIndex', async () => { + send.mockResolvedValueOnce({ + Items: [{ + task_id: 'task-2', + user_id: 'user-1', + repo: 'org/repo', + pr_number: 42, + status: 'COMPLETED', + channel_metadata: { jira_cloud_id: 'cloud-1', jira_issue_key: 'ENG-42' }, + }], + }); + + const result = await resolveTaskByJiraIssue(ddb, 'Tasks', 'cloud-1', 'ENG-42'); + + expect(result).toMatchObject({ task_id: 'task-2', pr_number: 42, repo: 'org/repo' }); + expect(send.mock.calls[0][0].input).toMatchObject({ + TableName: 'Tasks', + IndexName: 'JiraIssueIndex', + ExpressionAttributeValues: { ':identity': 'cloud-1#ENG-42' }, + ScanIndexForward: false, + }); +}); + +test('skips newer PR-less tasks on the same page', async () => { + send.mockResolvedValueOnce({ + Items: [ + { task_id: 'task-new', repo: 'org/repo' }, + { task_id: 'task-pr', repo: 'org/repo', pr_url: 'https://github.com/org/repo/pull/18' }, + ], + }); + + await expect(resolveTaskByJiraIssue(ddb, 'Tasks', 'cloud-1', 'ENG-42')) + .resolves.toMatchObject({ task_id: 'task-pr', pr_url: expect.stringContaining('/pull/18') }); +}); + +test('paginates past a PR-less first page', async () => { + send + .mockResolvedValueOnce({ + Items: [{ task_id: 'task-new', repo: 'org/repo' }], + LastEvaluatedKey: { jira_issue_identity: 'cloud-1#ENG-42', created_at: '2026-01-02' }, + }) + .mockResolvedValueOnce({ + Items: [{ task_id: 'task-pr', repo: 'org/repo', pr_number: 17 }], + }); + + await expect(resolveTaskByJiraIssue(ddb, 'Tasks', 'cloud-1', 'ENG-42')) + .resolves.toMatchObject({ task_id: 'task-pr', pr_number: 17 }); + expect(send).toHaveBeenCalledTimes(2); + expect(send.mock.calls[1][0].input.ExclusiveStartKey).toEqual({ + jira_issue_identity: 'cloud-1#ENG-42', + created_at: '2026-01-02', + }); +}); + +test('returns null when no PR-producing task exists', async () => { + send.mockResolvedValueOnce({ Items: [{ task_id: 'task-new' }] }); + await expect(resolveTaskByJiraIssue(ddb, 'Tasks', 'cloud-1', 'ENG-42')).resolves.toBeNull(); +}); + +test('propagates query failures so the async processor can retry', async () => { + send.mockRejectedValueOnce(new Error('DynamoDB unavailable')); + await expect(resolveTaskByJiraIssue(ddb, 'Tasks', 'cloud-1', 'ENG-42')) + .rejects.toThrow('DynamoDB unavailable'); +}); + +test.each([ + [{ pr_number: 12 }, 12], + [{ pr_url: 'https://github.com/o/r/pull/99/files' }, 99], + [{ pr_number: 0, pr_url: 'not-a-pr' }, null], +])('prNumberFromTask(%o) returns %p', (task, expected) => { + expect(prNumberFromTask(task)).toBe(expected); +}); diff --git a/cli/src/commands/jira.ts b/cli/src/commands/jira.ts index 97a486fc..b813fd44 100644 --- a/cli/src/commands/jira.ts +++ b/cli/src/commands/jira.ts @@ -672,7 +672,7 @@ export function makeJiraCommand(): Command { console.log(' Webhook signing secret needed for THIS tenant.'); console.log(' In Jira → Settings → System → Webhooks → Create a Webhook:'); console.log(` URL: ${apiBaseUrl}/jira/webhook`); - console.log(' Events: Issue: created, updated'); + console.log(' Events: Issue: created, Issue: updated, Comment: created'); console.log(' Secret: choose a strong random value (e.g. `openssl rand -hex 32`)'); console.log(); const webhookSigningSecret = await promptSecret('Webhook signing secret: '); diff --git a/docs/guides/JIRA_SETUP_GUIDE.md b/docs/guides/JIRA_SETUP_GUIDE.md index 80c82ce6..d2c4e736 100644 --- a/docs/guides/JIRA_SETUP_GUIDE.md +++ b/docs/guides/JIRA_SETUP_GUIDE.md @@ -1,6 +1,6 @@ # Jira integration setup guide -Set up the ABCA Jira Cloud integration so that adding a label to a Jira issue triggers an autonomous task. ABCA posts progress comments back on the issue as it works — a "started" comment from the agent and a final status comment (with cost, turns, and duration) from the platform when the task finishes. +Set up the ABCA Jira Cloud integration so that adding a label to a Jira issue triggers an autonomous task. After ABCA opens a pull request, reviewers can comment `@bgagent ` on the same Jira issue to request another iteration. ABCA posts progress comments back on the issue as it works — a "started" comment from the agent and a final status comment (with cost, turns, and duration) from the platform when the task finishes. ## Prerequisites @@ -13,7 +13,7 @@ Set up the ABCA Jira Cloud integration so that adding a label to a Jira issue tr ## How it works -A Jira-site admin creates an Atlassian OAuth 2.0 (3LO) app and authorizes it on the site. The OAuth token bundle is stored in a per-tenant Secrets Manager secret (`bgagent-jira-oauth-`). When a user adds the trigger label to a Jira issue, Jira fires a webhook to ABCA; the receiver verifies the `X-Hub-Signature` HMAC, dedupes, and async-invokes the processor, which resolves the tenant, looks up the project→repo mapping, enriches the task with the issue's recent comments and screened file attachments (see [Issue context](#issue-context-attachments-and-comments)), and creates a task. Jira-triggered tasks always run the `coding/new-task-v1` workflow (the processor pins `workflow_ref` explicitly, since a label-triggered task always targets a mapped repo). The agent clones the repo, opens a PR, and posts a "started" comment on the Jira issue via the Jira REST v3 API (using the same stored OAuth token). When the task reaches a terminal state, the platform's fan-out plane posts a single final status comment — outcome, cost, turns, duration, and PR link — via the same REST v3 endpoint (see [How it works](#how-it-works) below). +A Jira-site admin creates an Atlassian OAuth 2.0 (3LO) app and authorizes it on the site. The OAuth token bundle is stored in a per-tenant Secrets Manager secret (`bgagent-jira-oauth-`). When a user adds the trigger label to a Jira issue, Jira fires a webhook to ABCA; the receiver verifies the `X-Hub-Signature` HMAC, dedupes, and async-invokes the processor, which resolves the tenant, looks up the project→repo mapping, enriches the task with the issue's recent comments and screened file attachments (see [Issue context](#issue-context-attachments-and-comments)), and creates a task. Jira-triggered tasks always run the `coding/new-task-v1` workflow (the processor pins `workflow_ref` explicitly, since a label-triggered task always targets a mapped repo). A later `@bgagent` comment resolves that Jira issue to its most recent ABCA pull request and runs `coding/pr-iteration-v1` against the same PR. The agent clones the repo, opens or updates the PR, and posts a "started" comment on the Jira issue via the Jira REST v3 API (using the same stored OAuth token). When the task reaches a terminal state, the platform's fan-out plane posts a single final status comment — outcome, cost, turns, duration, and PR link — via the same REST v3 endpoint (see [How it works](#how-it-works) below). **Tenant key.** Everything is indexed on `cloudId` — the Atlassian tenant UUID, *not* the site domain or name. Webhook payloads and the OAuth flow both surface `cloudId`; it is the join key across the project-mapping, user-mapping, and workspace-registry tables. @@ -131,7 +131,7 @@ This runs the OAuth 3LO dance: `setup` then prompts for a **webhook signing secret**. Unlike Linear, Atlassian does **not** auto-generate one — the operator chooses it at webhook-create time. In a second terminal, open **Jira → Settings → System → Webhooks → Create a Webhook** and enter: - **URL** — the `…/jira/webhook` URL that `setup` prints -- **Events** — *Issue: created* and *Issue: updated* +- **Events** — *Issue: created*, *Issue: updated*, and *Comment: created* - **Secret** — a strong random value, e.g. `openssl rand -hex 32` Paste that same secret value back at the `Webhook signing secret:` prompt. ABCA stores it on the per-tenant OAuth bundle (and mirrors it stack-wide), and the receiver looks it up to verify `X-Hub-Signature` on each delivery. @@ -180,6 +180,8 @@ The teammate needs their own ABCA account first (Cognito user + configured CLI). Add the trigger label (`bgagent` by default) to a Jira issue in a mapped project. The agent should start within ~30 seconds, comment on the issue as it works, and post a PR link when ready. The issue **summary** plus the **description** (converted from Atlassian Document Format to markdown), the issue's **recent comments**, and any supported **file attachments** become the task context — see [Issue context: attachments and comments](#issue-context-attachments-and-comments). +After the PR exists, add a Jira comment such as `@bgagent update the README too`. ABCA should acknowledge the request on the issue and update the existing PR. + ## How webhook signature verification works Atlassian signs each delivery with HMAC-SHA256 over the **raw request body**, delivered as `X-Hub-Signature: sha256=`. The receiver: @@ -194,8 +196,19 @@ The body must be verified as the *raw unparsed bytes* — never parsed-and-restr - **`jira:issue_created`** — triggers if the trigger label is already present on the new issue. - **`jira:issue_updated`** — triggers only if the label was **newly added** in this update. Jira reports label changes in `changelog.items[]` (`field: "labels"`, with `fromString` / `toString`), *not* by re-sending the full label list. The processor diffs the changelog rather than inspecting `issue.fields.labels`, so re-saving an issue that already has the label does not re-trigger. +- **`comment_created`** — triggers only when the new comment contains a token-bounded `@bgagent` mention and the issue has a prior ABCA pull request. - All other event types get a silent `200`. +## Comment-triggered PR iteration + +A `comment_created` webhook starts a PR iteration only when the comment contains the token-bounded mention `@bgagent` (case-insensitive). The remaining comment text becomes the `coding/pr-iteration-v1` instruction. A bare `@bgagent` asks the agent to address the latest PR review feedback. + +ABCA resolves the Jira tenant and issue key to the newest prior task that actually opened a PR. Newer attempts without a PR do not hide an older valid PR target. If no ABCA PR exists, ABCA posts a clear comment and creates no task. + +When the comment author has linked their Jira and ABCA accounts, the iteration is attributed to that user. Otherwise, ABCA falls back to the original task owner so a useful reviewer request is not dropped. Comments without the mention, app-authored comments, and ABCA's own generated status comments are no-ops. + +The acknowledgement is immediate after task admission. The existing platform fan-out path posts the terminal outcome and cost comment when the iteration finishes. Comment redelivery is idempotent: the webhook receiver deduplicates by Jira comment ID, and task creation uses a deterministic idempotency key as a second guard. + ## Issue context: attachments and comments Beyond the summary and description, the processor enriches the task with the practical context a Jira ticket usually carries — attached files and recent clarifications — so the agent isn't left guessing at "see the attached log" or an acceptance detail buried in a comment. Both are fetched **authenticated at task-admission time** using the tenant's existing `read:jira-work` scope (**no new OAuth scopes, no re-authorization**), because a headless agent can't fetch them itself. @@ -242,11 +255,12 @@ The feature targets Jira **statuses**, not board columns. Because moving a card ## Webhook dedup -The receiver dedupes on `{issueKey}#{webhookEventTimestamp}` with an 8-hour TTL. Using the event timestamp (rather than event type) means two distinct label-adds in quick succession are not collapsed. Jira retries far less aggressively than Linear, so 8 hours is safe parity. +The receiver dedupes issue events on `{issueKey}#{webhookEvent}#{timestamp}` and comment-created events on `{issueKey}#comment_created#{commentId}`, with an 8-hour TTL. The timestamp keeps distinct label additions separate; the stable comment ID collapses redelivery without merging separate comments. Jira retries far less aggressively than Linear, so 8 hours is safe parity. ## Usage - **Trigger a task**: add the trigger label to an issue in a mapped Jira project. +- **Iterate on its PR**: comment `@bgagent ` on the Jira issue after ABCA has opened a PR. - **Check status**: from the Jira issue (progress comments) or `bgagent list` / `bgagent status `. - **Cancel**: `bgagent cancel `. Removing the Jira label does not cancel a running task. diff --git a/docs/src/content/docs/using/Jira-setup-guide.md b/docs/src/content/docs/using/Jira-setup-guide.md index 6c5643c6..f8ddf1e2 100644 --- a/docs/src/content/docs/using/Jira-setup-guide.md +++ b/docs/src/content/docs/using/Jira-setup-guide.md @@ -4,7 +4,7 @@ title: Jira setup guide # Jira integration setup guide -Set up the ABCA Jira Cloud integration so that adding a label to a Jira issue triggers an autonomous task. ABCA posts progress comments back on the issue as it works — a "started" comment from the agent and a final status comment (with cost, turns, and duration) from the platform when the task finishes. +Set up the ABCA Jira Cloud integration so that adding a label to a Jira issue triggers an autonomous task. After ABCA opens a pull request, reviewers can comment `@bgagent ` on the same Jira issue to request another iteration. ABCA posts progress comments back on the issue as it works — a "started" comment from the agent and a final status comment (with cost, turns, and duration) from the platform when the task finishes. ## Prerequisites @@ -17,7 +17,7 @@ Set up the ABCA Jira Cloud integration so that adding a label to a Jira issue tr ## How it works -A Jira-site admin creates an Atlassian OAuth 2.0 (3LO) app and authorizes it on the site. The OAuth token bundle is stored in a per-tenant Secrets Manager secret (`bgagent-jira-oauth-`). When a user adds the trigger label to a Jira issue, Jira fires a webhook to ABCA; the receiver verifies the `X-Hub-Signature` HMAC, dedupes, and async-invokes the processor, which resolves the tenant, looks up the project→repo mapping, enriches the task with the issue's recent comments and screened file attachments (see [Issue context](#issue-context-attachments-and-comments)), and creates a task. Jira-triggered tasks always run the `coding/new-task-v1` workflow (the processor pins `workflow_ref` explicitly, since a label-triggered task always targets a mapped repo). The agent clones the repo, opens a PR, and posts a "started" comment on the Jira issue via the Jira REST v3 API (using the same stored OAuth token). When the task reaches a terminal state, the platform's fan-out plane posts a single final status comment — outcome, cost, turns, duration, and PR link — via the same REST v3 endpoint (see [How it works](#how-it-works) below). +A Jira-site admin creates an Atlassian OAuth 2.0 (3LO) app and authorizes it on the site. The OAuth token bundle is stored in a per-tenant Secrets Manager secret (`bgagent-jira-oauth-`). When a user adds the trigger label to a Jira issue, Jira fires a webhook to ABCA; the receiver verifies the `X-Hub-Signature` HMAC, dedupes, and async-invokes the processor, which resolves the tenant, looks up the project→repo mapping, enriches the task with the issue's recent comments and screened file attachments (see [Issue context](#issue-context-attachments-and-comments)), and creates a task. Jira-triggered tasks always run the `coding/new-task-v1` workflow (the processor pins `workflow_ref` explicitly, since a label-triggered task always targets a mapped repo). A later `@bgagent` comment resolves that Jira issue to its most recent ABCA pull request and runs `coding/pr-iteration-v1` against the same PR. The agent clones the repo, opens or updates the PR, and posts a "started" comment on the Jira issue via the Jira REST v3 API (using the same stored OAuth token). When the task reaches a terminal state, the platform's fan-out plane posts a single final status comment — outcome, cost, turns, duration, and PR link — via the same REST v3 endpoint (see [How it works](#how-it-works) below). **Tenant key.** Everything is indexed on `cloudId` — the Atlassian tenant UUID, *not* the site domain or name. Webhook payloads and the OAuth flow both surface `cloudId`; it is the join key across the project-mapping, user-mapping, and workspace-registry tables. @@ -135,7 +135,7 @@ This runs the OAuth 3LO dance: `setup` then prompts for a **webhook signing secret**. Unlike Linear, Atlassian does **not** auto-generate one — the operator chooses it at webhook-create time. In a second terminal, open **Jira → Settings → System → Webhooks → Create a Webhook** and enter: - **URL** — the `…/jira/webhook` URL that `setup` prints -- **Events** — *Issue: created* and *Issue: updated* +- **Events** — *Issue: created*, *Issue: updated*, and *Comment: created* - **Secret** — a strong random value, e.g. `openssl rand -hex 32` Paste that same secret value back at the `Webhook signing secret:` prompt. ABCA stores it on the per-tenant OAuth bundle (and mirrors it stack-wide), and the receiver looks it up to verify `X-Hub-Signature` on each delivery. @@ -184,6 +184,8 @@ The teammate needs their own ABCA account first (Cognito user + configured CLI). Add the trigger label (`bgagent` by default) to a Jira issue in a mapped project. The agent should start within ~30 seconds, comment on the issue as it works, and post a PR link when ready. The issue **summary** plus the **description** (converted from Atlassian Document Format to markdown), the issue's **recent comments**, and any supported **file attachments** become the task context — see [Issue context: attachments and comments](#issue-context-attachments-and-comments). +After the PR exists, add a Jira comment such as `@bgagent update the README too`. ABCA should acknowledge the request on the issue and update the existing PR. + ## How webhook signature verification works Atlassian signs each delivery with HMAC-SHA256 over the **raw request body**, delivered as `X-Hub-Signature: sha256=`. The receiver: @@ -198,8 +200,19 @@ The body must be verified as the *raw unparsed bytes* — never parsed-and-restr - **`jira:issue_created`** — triggers if the trigger label is already present on the new issue. - **`jira:issue_updated`** — triggers only if the label was **newly added** in this update. Jira reports label changes in `changelog.items[]` (`field: "labels"`, with `fromString` / `toString`), *not* by re-sending the full label list. The processor diffs the changelog rather than inspecting `issue.fields.labels`, so re-saving an issue that already has the label does not re-trigger. +- **`comment_created`** — triggers only when the new comment contains a token-bounded `@bgagent` mention and the issue has a prior ABCA pull request. - All other event types get a silent `200`. +## Comment-triggered PR iteration + +A `comment_created` webhook starts a PR iteration only when the comment contains the token-bounded mention `@bgagent` (case-insensitive). The remaining comment text becomes the `coding/pr-iteration-v1` instruction. A bare `@bgagent` asks the agent to address the latest PR review feedback. + +ABCA resolves the Jira tenant and issue key to the newest prior task that actually opened a PR. Newer attempts without a PR do not hide an older valid PR target. If no ABCA PR exists, ABCA posts a clear comment and creates no task. + +When the comment author has linked their Jira and ABCA accounts, the iteration is attributed to that user. Otherwise, ABCA falls back to the original task owner so a useful reviewer request is not dropped. Comments without the mention, app-authored comments, and ABCA's own generated status comments are no-ops. + +The acknowledgement is immediate after task admission. The existing platform fan-out path posts the terminal outcome and cost comment when the iteration finishes. Comment redelivery is idempotent: the webhook receiver deduplicates by Jira comment ID, and task creation uses a deterministic idempotency key as a second guard. + ## Issue context: attachments and comments Beyond the summary and description, the processor enriches the task with the practical context a Jira ticket usually carries — attached files and recent clarifications — so the agent isn't left guessing at "see the attached log" or an acceptance detail buried in a comment. Both are fetched **authenticated at task-admission time** using the tenant's existing `read:jira-work` scope (**no new OAuth scopes, no re-authorization**), because a headless agent can't fetch them itself. @@ -246,11 +259,12 @@ The feature targets Jira **statuses**, not board columns. Because moving a card ## Webhook dedup -The receiver dedupes on `{issueKey}#{webhookEventTimestamp}` with an 8-hour TTL. Using the event timestamp (rather than event type) means two distinct label-adds in quick succession are not collapsed. Jira retries far less aggressively than Linear, so 8 hours is safe parity. +The receiver dedupes issue events on `{issueKey}#{webhookEvent}#{timestamp}` and comment-created events on `{issueKey}#comment_created#{commentId}`, with an 8-hour TTL. The timestamp keeps distinct label additions separate; the stable comment ID collapses redelivery without merging separate comments. Jira retries far less aggressively than Linear, so 8 hours is safe parity. ## Usage - **Trigger a task**: add the trigger label to an issue in a mapped Jira project. +- **Iterate on its PR**: comment `@bgagent ` on the Jira issue after ABCA has opened a PR. - **Check status**: from the Jira issue (progress comments) or `bgagent list` / `bgagent status `. - **Cancel**: `bgagent cancel `. Removing the Jira label does not cancel a running task.