From 540ae655c8795124e1d339eb11a4e17fd8c92572 Mon Sep 17 00:00:00 2001 From: ayushtr-aws Date: Wed, 5 Aug 2026 15:15:15 -0400 Subject: [PATCH] feat(jira): orchestrate authored subtask graphs (#574) Refs #574 Co-Authored-By: Codex --- cdk/src/constructs/jira-integration.ts | 9 + cdk/src/handlers/jira-webhook-processor.ts | 253 +++++++++++++ .../handlers/shared/jira-subissue-fetch.ts | 223 +++++++++++ .../handlers/shared/linear-subissue-fetch.ts | 6 + .../shared/orchestration-graph-source.ts | 24 ++ .../handlers/shared/orchestration-release.ts | 5 +- .../handlers/shared/orchestration-store.ts | 8 + cdk/src/stacks/agent.ts | 1 + cdk/test/constructs/jira-integration.test.ts | 15 + ...ra-webhook-processor-orchestration.test.ts | 349 ++++++++++++++++++ .../handlers/jira-webhook-processor.test.ts | 25 ++ .../shared/jira-subissue-fetch.test.ts | 193 ++++++++++ .../shared/orchestration-release.test.ts | 26 ++ .../shared/orchestration-store.test.ts | 28 ++ docs/guides/JIRA_SETUP_GUIDE.md | 10 + .../content/docs/using/Jira-setup-guide.md | 10 + 16 files changed, 1184 insertions(+), 1 deletion(-) create mode 100644 cdk/src/handlers/shared/jira-subissue-fetch.ts create mode 100644 cdk/test/handlers/jira-webhook-processor-orchestration.test.ts create mode 100644 cdk/test/handlers/shared/jira-subissue-fetch.test.ts diff --git a/cdk/src/constructs/jira-integration.ts b/cdk/src/constructs/jira-integration.ts index 101da31a8..c3f06757a 100644 --- a/cdk/src/constructs/jira-integration.ts +++ b/cdk/src/constructs/jira-integration.ts @@ -86,6 +86,9 @@ export interface JiraIntegrationProps { /** The DynamoDB task events table. */ readonly taskEventsTable: dynamodb.ITable; + /** Shared orchestration DAG table. Omit to retain one-issue/one-task mode. */ + readonly orchestrationTable?: dynamodb.ITable; + /** The DynamoDB repo config table (optional — for repo onboarding checks). */ readonly repoTable?: dynamodb.ITable; @@ -244,6 +247,9 @@ export class JiraIntegration extends Construct { if (props.attachmentsBucket) { createTaskEnv.ATTACHMENTS_BUCKET_NAME = props.attachmentsBucket.bucketName; } + if (props.orchestrationTable) { + createTaskEnv.ORCHESTRATION_TABLE_NAME = props.orchestrationTable.tableName; + } // --- Cognito Authorizer (for /jira/link) --- const cognitoAuthorizer = new apigw.CognitoUserPoolsAuthorizer(this, 'JiraCognitoAuthorizer', { @@ -298,6 +304,9 @@ export class JiraIntegration extends Construct { })); props.taskTable.grantReadWriteData(webhookProcessorFn); props.taskEventsTable.grantReadWriteData(webhookProcessorFn); + if (props.orchestrationTable) { + props.orchestrationTable.grantReadWriteData(webhookProcessorFn); + } if (props.repoTable) { props.repoTable.grantReadData(webhookProcessorFn); } diff --git a/cdk/src/handlers/jira-webhook-processor.ts b/cdk/src/handlers/jira-webhook-processor.ts index ad490a47d..9ee28c821 100644 --- a/cdk/src/handlers/jira-webhook-processor.ts +++ b/cdk/src/handlers/jira-webhook-processor.ts @@ -38,12 +38,28 @@ import { } from './shared/jira-attachments'; import { reportIssueFailure } from './shared/jira-feedback'; import { resolveJiraOauthToken } from './shared/jira-oauth-resolver'; +import type { JiraSubIssueNode } from './shared/jira-subissue-fetch'; import { prNumberFromTask, resolveTaskByJiraIssue, type JiraIssueTask, } from './shared/jira-task-by-issue'; +import type { SubIssueNode } from './shared/linear-subissue-fetch'; import { logger } from './shared/logger'; +import { makeJiraChannel } from './shared/orchestration-channel-jira'; +import { discoverOrchestration } from './shared/orchestration-discovery'; +import { jiraGraphSource } from './shared/orchestration-graph-source'; +import { + applyTerminalCreateFailures, + releaseReadyChildren, +} from './shared/orchestration-release'; +import { upsertEpicPanel } from './shared/orchestration-rollup'; +import { + deriveOrchestrationId, + loadOrchestration, + setStatusCommentId, + type OrchestrationReleaseContext, +} from './shared/orchestration-store'; import type { Attachment, PassedAttachmentRecord } from './shared/types'; import { makeClient, makeDocClient } from './shared/ua'; import { MAX_TASK_DESCRIPTION_LENGTH } from './shared/validation'; @@ -55,6 +71,7 @@ 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 ORCHESTRATION_TABLE = process.env.ORCHESTRATION_TABLE_NAME; const DEFAULT_LABEL_FILTER = 'bgagent'; /** Max length of the idempotency key (matches validation's IDEMPOTENCY_KEY_PATTERN). */ @@ -422,6 +439,7 @@ export async function handler(event: ProcessorEvent): Promise { // tenants that only verified via the stack-wide fallback (workspace // unknown to the registry) — we'd burn agent quota with no resolvable // Jira OAuth token for the outbound REST progress comments. + let resolvedJira: Awaited> = null; if (WORKSPACE_REGISTRY_TABLE) { const resolved = await resolveJiraOauthToken(cloudId, WORKSPACE_REGISTRY_TABLE); if (!resolved) { @@ -431,10 +449,60 @@ export async function handler(event: ProcessorEvent): Promise { }); return; } + resolvedJira = resolved; channelMetadata.jira_oauth_secret_arn = resolved.oauthSecretArn; channelMetadata.jira_site_url = resolved.siteUrl; } + let orchestrationChildren: readonly SubIssueNode[] | undefined; + if (ORCHESTRATION_TABLE && resolvedJira) { + // Layer #574 deliberately freezes an orchestration after its first seed. + // Additive re-discovery is introduced by #578; until then, a re-trigger is + // an idempotent no-op and must not create a parent task. + const existing = await loadOrchestration( + ddb, + ORCHESTRATION_TABLE, + deriveOrchestrationId(issue.key), + ); + if (existing) { + logger.info('Jira orchestration already exists — skipping re-trigger', { + issue_key: issue.key, + orchestration_id: existing.meta.orchestration_id, + }); + return; + } + + const graphResult = await jiraGraphSource( + resolvedJira.accessToken, + cloudId, + issue.key, + )(); + if (graphResult.kind === 'error') { + await safeReportIssueFailure( + issue.key, + cloudId, + `❌ ABCA couldn't read this issue's Jira subtasks: ${graphResult.message}`, + ); + return; + } + if (graphResult.kind === 'ok') { + const routed = await routeJiraOrchestrationChildren({ + cloudId, + parentProjectKey: projectKey, + parentMapping: mapping.Item, + parentRepo: repo, + children: graphResult.children as readonly JiraSubIssueNode[], + oauthSecretArn: resolvedJira.oauthSecretArn, + siteUrl: resolvedJira.siteUrl, + }); + if (!routed.ok) { + await safeReportIssueFailure(issue.key, cloudId, `❌ ${routed.message}`); + return; + } + orchestrationChildren = routed.children; + } + } + // Embedded HTTPS image URLs from the description (unchanged, #577 preserves). const urlAttachments = extractImageUrlAttachments(descriptionMarkdown); @@ -507,6 +575,115 @@ export async function handler(event: ProcessorEvent): Promise { const taskDescription = buildTaskDescription(issue, descriptionMarkdown, comments); + if (ORCHESTRATION_TABLE && orchestrationChildren) { + const releaseContext: OrchestrationReleaseContext = { + platform_user_id: platformUserId, + channel_source: 'jira', + trigger_label: (labelFilter || DEFAULT_LABEL_FILTER).trim().toLowerCase(), + parent_context: { + ...(issue.fields?.summary && { title: issue.fields.summary }), + ...(descriptionMarkdown && { description: descriptionMarkdown }), + }, + ...(preScreenedAttachments.length > 0 && { + pre_screened_attachments: preScreenedAttachments, + }), + }; + const discovery = await discoverOrchestration({ + ddb, + tableName: ORCHESTRATION_TABLE, + parentIssueRef: issue.key, + credentialsRef: cloudId, + repo, + now: new Date().toISOString(), + releaseContext, + graphSource: async () => ({ kind: 'ok', children: orchestrationChildren }), + }); + + if (discovery.kind === 'rejected' || discovery.kind === 'error') { + if (preScreenedAttachments.length > 0 && s3Client && ATTACHMENTS_BUCKET) { + await cleanupPreScreenedAttachments(s3Client, ATTACHMENTS_BUCKET, preScreenedAttachments); + } + await safeReportIssueFailure( + issue.key, + cloudId, + `❌ ABCA couldn't create this Jira orchestration: ${discovery.message}`, + ); + return; + } + + // A concurrent replay can win between the preflight read and the seed + // condition. Do not create a parent task or retain duplicate attachments. + if (discovery.kind === 'extended' + || (discovery.kind === 'seeded' && discovery.alreadyExisted)) { + if (preScreenedAttachments.length > 0 && s3Client && ATTACHMENTS_BUCKET) { + await cleanupPreScreenedAttachments(s3Client, ATTACHMENTS_BUCKET, preScreenedAttachments); + } + return; + } + + if (discovery.kind === 'seeded') { + const snapshot = await loadOrchestration(ddb, ORCHESTRATION_TABLE, discovery.orchestrationId); + if (snapshot) { + const now = new Date().toISOString(); + const results = await releaseReadyChildren( + ddb, + ORCHESTRATION_TABLE, + snapshot.children, + snapshot.meta.release_context, + createTaskCore, + now, + snapshot.children, + ); + await applyTerminalCreateFailures( + ddb, + ORCHESTRATION_TABLE, + discovery.orchestrationId, + snapshot.children, + results, + now, + ); + + if (WORKSPACE_REGISTRY_TABLE) { + try { + const fresh = await loadOrchestration( + ddb, + ORCHESTRATION_TABLE, + discovery.orchestrationId, + ); + if (fresh) { + const commentId = await upsertEpicPanel({ + channel: makeJiraChannel(WORKSPACE_REGISTRY_TABLE), + parent: { issueId: issue.key, credentialsRef: cloudId }, + children: fresh.children, + labelFilter, + }); + if (commentId) { + await setStatusCommentId( + ddb, + ORCHESTRATION_TABLE, + discovery.orchestrationId, + commentId, + ); + } + } + } catch (err) { + logger.warn('Failed to post Jira orchestration panel at seed (non-fatal)', { + issue_key: issue.key, + orchestration_id: discovery.orchestrationId, + error: err instanceof Error ? err.message : String(err), + }); + } + } + } + logger.info('Jira orchestration seeded — parent task suppressed', { + issue_key: issue.key, + orchestration_id: discovery.orchestrationId, + child_count: discovery.childCount, + }); + return; + } + } + const requestId = crypto.randomUUID(); const result = await createTaskCore( { @@ -766,9 +943,85 @@ function buildIterationChannelMetadata( if (previous.jira_status_on_pr) { metadata.jira_status_on_pr = previous.jira_status_on_pr; } + if (previous.orchestration_id && previous.orchestration_sub_issue_id) { + metadata.orchestration_id = previous.orchestration_id; + metadata.orchestration_sub_issue_id = previous.orchestration_sub_issue_id; + metadata.orchestration_iteration = 'true'; + metadata.trigger_comment_id = commentId; + metadata.trigger_comment_issue_id = issue.key; + } return metadata; } +type ProjectMapping = Readonly>; + +async function routeJiraOrchestrationChildren(params: { + readonly cloudId: string; + readonly parentProjectKey: string; + readonly parentMapping: ProjectMapping; + readonly parentRepo: string; + readonly children: readonly JiraSubIssueNode[]; + readonly oauthSecretArn: string; + readonly siteUrl: string; +}): Promise< + | { readonly ok: true; readonly children: readonly SubIssueNode[] } + | { readonly ok: false; readonly message: string } +> { + const mappings = new Map([ + [params.parentProjectKey, params.parentMapping], + ]); + for (const projectKey of new Set(params.children.map((child) => child.project_key))) { + if (mappings.has(projectKey)) continue; + const result = await ddb.send(new GetCommand({ + TableName: PROJECT_MAPPING_TABLE, + Key: { jira_project_identity: `${params.cloudId}#${projectKey}` }, + })); + if (result.Item) mappings.set(projectKey, result.Item); + } + + for (const child of params.children) { + const childMapping = mappings.get(child.project_key); + if (!childMapping || childMapping.status !== 'active' || typeof childMapping.repo !== 'string') { + return { + ok: false, + message: `${child.identifier ?? child.id} belongs to Jira project ${child.project_key}, ` + + 'which is not actively mapped to an ABCA repository. Map that project and re-apply the trigger label.', + }; + } + if (childMapping.repo !== params.parentRepo) { + return { + ok: false, + message: `${child.identifier ?? child.id} maps to ${childMapping.repo}, but the parent maps to ` + + `${params.parentRepo}. All executable Jira subtasks must map to the same repository.`, + }; + } + } + + return { + ok: true, + children: params.children.map((child) => { + const childMapping = mappings.get(child.project_key)!; + return { + ...child, + channel_metadata: { + jira_cloud_id: params.cloudId, + jira_project_key: child.project_key, + jira_issue_id: child.issue_id, + jira_issue_key: child.identifier ?? child.id, + jira_oauth_secret_arn: params.oauthSecretArn, + jira_site_url: params.siteUrl, + ...(typeof childMapping.status_on_start === 'string' && { + jira_status_on_start: childMapping.status_on_start, + }), + ...(typeof childMapping.status_on_pr === 'string' && { + jira_status_on_pr: childMapping.status_on_pr, + }), + }, + }; + }), + }; +} + function buildCommentIdempotencyKey( cloudId: string, issueKey: string, diff --git a/cdk/src/handlers/shared/jira-subissue-fetch.ts b/cdk/src/handlers/shared/jira-subissue-fetch.ts new file mode 100644 index 000000000..e0aa3b011 --- /dev/null +++ b/cdk/src/handlers/shared/jira-subissue-fetch.ts @@ -0,0 +1,223 @@ +/** + * 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 { extractDescriptionMarkdown } from './jira-adf'; +import type { SubIssueNode } from './linear-subissue-fetch'; +import { logger } from './logger'; + +const JIRA_API_BASE = 'https://api.atlassian.com/ex/jira'; +const PAGE_SIZE = 100; +const REQUEST_TIMEOUT_MS = 10_000; + +interface JiraLinkedIssue { + readonly key?: string; +} + +interface JiraIssueLink { + readonly type?: { + readonly inward?: string; + readonly outward?: string; + }; + readonly inwardIssue?: JiraLinkedIssue; + readonly outwardIssue?: JiraLinkedIssue; +} + +interface JiraSearchIssue { + readonly id?: string; + readonly key?: string; + readonly fields?: { + readonly summary?: string; + readonly description?: unknown; + readonly project?: { readonly key?: string }; + readonly issuelinks?: readonly JiraIssueLink[]; + }; +} + +interface JiraSearchPage { + readonly issues?: readonly JiraSearchIssue[]; + readonly nextPageToken?: string; + readonly isLast?: boolean; +} + +export interface JiraSubIssueNode extends SubIssueNode { + readonly issue_id: string; + readonly project_key: string; +} + +export type FetchJiraSubIssueGraphResult = + | { readonly kind: 'ok'; readonly children: readonly JiraSubIssueNode[] } + | { readonly kind: 'no_children' } + | { readonly kind: 'error'; readonly message: string }; + +export interface FetchJiraSubIssueGraphOptions { + readonly fetchImpl?: typeof fetch; +} + +function isBlocks(value: string | undefined): boolean { + return value?.trim().toLowerCase() === 'blocks'; +} + +function isBlockedBy(value: string | undefined): boolean { + return value?.trim().toLowerCase() === 'is blocked by'; +} + +function pageUrl(cloudId: string, parentIssueKey: string, nextPageToken?: string): string { + const query = new URLSearchParams({ + jql: `parent = "${parentIssueKey.replaceAll('"', '\\"')}"`, + fields: 'id,key,summary,description,project,issuelinks', + maxResults: String(PAGE_SIZE), + }); + if (nextPageToken) query.set('nextPageToken', nextPageToken); + return `${JIRA_API_BASE}/${encodeURIComponent(cloudId)}/rest/api/3/search/jql?${query.toString()}`; +} + +async function fetchPage( + accessToken: string, + cloudId: string, + parentIssueKey: string, + nextPageToken: string | undefined, + fetchImpl: typeof fetch, +): Promise<{ readonly ok: true; readonly page: JiraSearchPage } | { readonly ok: false; readonly message: string }> { + const url = pageUrl(cloudId, parentIssueKey, nextPageToken); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const response = await fetchImpl(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + signal: controller.signal, + }); + if (!response.ok) { + logger.warn('Jira subtask search returned non-2xx', { + jira_cloud_id: cloudId, + parent_issue_key: parentIssueKey, + status: response.status, + }); + return { + ok: false, + message: `Jira returned status ${response.status} while reading authored subtasks.`, + }; + } + return { ok: true, page: await response.json() as JiraSearchPage }; + } catch (error) { + logger.warn('Jira subtask search failed', { + jira_cloud_id: cloudId, + parent_issue_key: parentIssueKey, + error: error instanceof Error ? error.message : String(error), + }); + return { + ok: false, + message: 'Jira subtasks could not be read. Check the Jira connection and re-apply the trigger label.', + }; + } finally { + clearTimeout(timer); + } +} + +/** + * Read a Jira parent's authored subtasks and standard blocker links. + * + * Standard blocker links must remain inside the authored child set; an external + * blocker would make the persisted graph permanently unreleasable, so it is + * rejected before any rows are written. + */ +export async function fetchJiraSubIssueGraph( + accessToken: string, + cloudId: string, + parentIssueKey: string, + options: FetchJiraSubIssueGraphOptions = {}, +): Promise { + const fetchImpl = options.fetchImpl ?? fetch; + const issues: JiraSearchIssue[] = []; + let nextPageToken: string | undefined; + const seenTokens = new Set(); + + do { + const result = await fetchPage(accessToken, cloudId, parentIssueKey, nextPageToken, fetchImpl); + if (!result.ok) return { kind: 'error', message: result.message }; + issues.push(...(result.page.issues ?? [])); + const next = result.page.nextPageToken; + if (!next || result.page.isLast === true) break; + if (seenTokens.has(next)) { + return { kind: 'error', message: 'Jira returned a repeated pagination token while reading subtasks.' }; + } + seenTokens.add(next); + nextPageToken = next; + } while (nextPageToken); + + if (issues.length === 0) return { kind: 'no_children' }; + + const malformed = issues.find((issue) => + !issue.id || !issue.key || !issue.fields?.project?.key || typeof issue.fields.summary !== 'string'); + if (malformed) { + return { + kind: 'error', + message: 'A Jira subtask is missing its key, project, or summary, so no orchestration was created.', + }; + } + + const childKeys = new Set(issues.map((issue) => issue.key as string)); + const dependencies = new Map>( + [...childKeys].map((key) => [key, new Set()]), + ); + + for (const issue of issues) { + const currentKey = issue.key as string; + for (const link of issue.fields?.issuelinks ?? []) { + if (isBlockedBy(link.type?.inward) && link.inwardIssue?.key) { + const predecessor = link.inwardIssue.key; + if (!childKeys.has(predecessor)) { + return { + kind: 'error', + message: `${currentKey} is blocked by ${predecessor}, which is not an executable subtask of ${parentIssueKey}.`, + }; + } + dependencies.get(currentKey)!.add(predecessor); + } + if (isBlocks(link.type?.outward) && link.outwardIssue?.key) { + const dependent = link.outwardIssue.key; + if (!childKeys.has(dependent)) { + return { + kind: 'error', + message: `${currentKey} blocks ${dependent}, which is not an executable subtask of ${parentIssueKey}.`, + }; + } + dependencies.get(dependent)!.add(currentKey); + } + } + } + + return { + kind: 'ok', + children: issues.map((issue) => { + const key = issue.key as string; + return { + id: key, + identifier: key, + issue_id: issue.id as string, + title: issue.fields!.summary!, + description: extractDescriptionMarkdown(issue.fields?.description), + project_key: issue.fields!.project!.key as string, + depends_on: [...dependencies.get(key)!].sort(), + }; + }), + }; +} diff --git a/cdk/src/handlers/shared/linear-subissue-fetch.ts b/cdk/src/handlers/shared/linear-subissue-fetch.ts index 62ec27c01..f6745e9ce 100644 --- a/cdk/src/handlers/shared/linear-subissue-fetch.ts +++ b/cdk/src/handlers/shared/linear-subissue-fetch.ts @@ -109,6 +109,12 @@ export interface SubIssueNode extends DagNode { * absent when an existing sub-issue graph is fetched by title only. */ readonly description?: string; + /** + * Opaque, adapter-owned metadata copied onto the released child task. + * The orchestration engine persists and forwards these string fields without + * interpreting them. + */ + readonly channel_metadata?: Readonly>; /** Sub-issue ids that block this one (intra-epic predecessors). */ readonly depends_on: readonly string[]; } diff --git a/cdk/src/handlers/shared/orchestration-graph-source.ts b/cdk/src/handlers/shared/orchestration-graph-source.ts index c80088f48..41faf9ad5 100644 --- a/cdk/src/handlers/shared/orchestration-graph-source.ts +++ b/cdk/src/handlers/shared/orchestration-graph-source.ts @@ -50,6 +50,10 @@ * graph from one of these sources lands with the orchestration compute plane. */ +import { + fetchJiraSubIssueGraph, + type FetchJiraSubIssueGraphOptions, +} from './jira-subissue-fetch'; import { fetchSubIssueGraph, type FetchSubIssueGraphOptions, type SubIssueNode } from './linear-subissue-fetch'; /** @@ -87,6 +91,26 @@ export function linearGraphSource( }; } +/** Tier 1 - Jira authored subtasks plus standard blocker links. */ +export function jiraGraphSource( + accessToken: string, + cloudId: string, + parentIssueKey: string, + fetchOptions?: FetchJiraSubIssueGraphOptions, +): OrchestrationGraphSource { + return async () => { + const fetched = await fetchJiraSubIssueGraph( + accessToken, + cloudId, + parentIssueKey, + fetchOptions, + ); + if (fetched.kind === 'error') return { kind: 'error', message: fetched.message }; + if (fetched.kind === 'no_children') return { kind: 'no_children' }; + return { kind: 'ok', children: fetched.children }; + }; +} + /** * Tier 2 — declarative graph. The caller already has the node list (e.g. a * CLI/API request that carries its own edges). An empty list means "no graph" → diff --git a/cdk/src/handlers/shared/orchestration-release.ts b/cdk/src/handlers/shared/orchestration-release.ts index bea88bd17..a318248e7 100644 --- a/cdk/src/handlers/shared/orchestration-release.ts +++ b/cdk/src/handlers/shared/orchestration-release.ts @@ -327,7 +327,7 @@ function buildChildDescription( // echoes the title. const desc = (row.description ?? '').trim(); if (desc && desc !== row.title) parts.push(desc); - return parts.join('\n\n') || `Linear sub-issue ${row.sub_issue_id}`; + return parts.join('\n\n') || `Orchestration child ${row.sub_issue_id}`; } /** @@ -343,6 +343,9 @@ export async function releaseChild(params: ReleaseChildParams): Promise = { + // Adapter-owned values are the base. Engine-owned identity below wins on + // collision so an adapter cannot detach a task from its persisted row. + ...(row.channel_metadata ?? {}), orchestration_id: row.orchestration_id, orchestration_sub_issue_id: row.sub_issue_id, }; diff --git a/cdk/src/handlers/shared/orchestration-store.ts b/cdk/src/handlers/shared/orchestration-store.ts index 8645f2b25..5df35d70b 100644 --- a/cdk/src/handlers/shared/orchestration-store.ts +++ b/cdk/src/handlers/shared/orchestration-store.ts @@ -90,6 +90,12 @@ export interface OrchestrationChildRow { readonly display_id?: string; /** Sub-issue title, used to build the child task description. */ readonly title?: string; + /** + * Opaque, adapter-owned values copied onto the released task's + * ``channel_metadata``. The orchestration engine persists these without + * interpreting them; release-owned keys overwrite collisions. + */ + readonly channel_metadata?: Readonly>; /** * Sub-issue scope/description, when the graph source supplied one. Persisted at * seed so the coding agent's task_description carries the per-piece scope (e.g. @@ -445,6 +451,7 @@ export async function seedOrchestration( ...(c.identifier !== undefined && dualWrite('display_id', 'linear_identifier', c.identifier)), ...(c.title !== undefined && { title: c.title }), ...(c.description !== undefined && c.description !== '' && { description: c.description }), + ...(c.channel_metadata !== undefined && { channel_metadata: c.channel_metadata }), created_at: now, updated_at: now, ...(ttl !== undefined && { ttl }), @@ -639,6 +646,7 @@ export async function extendOrchestration(params: { ...(n.identifier !== undefined && dualWrite('display_id', 'linear_identifier', n.identifier)), ...(n.title !== undefined && { title: n.title }), ...(n.description !== undefined && n.description !== '' && { description: n.description }), + ...(n.channel_metadata !== undefined && { channel_metadata: n.channel_metadata }), created_at: now, updated_at: now, ...(ttl !== undefined && { ttl }), diff --git a/cdk/src/stacks/agent.ts b/cdk/src/stacks/agent.ts index 91ef0ea26..93a500646 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -1134,6 +1134,7 @@ export class AgentStack extends Stack { userPool: taskApi.userPool, taskTable: taskTable.table, taskEventsTable: taskEventsTable.table, + orchestrationTable: orchestrationTable.table, repoTable: repoTable.table, orchestratorFunctionArn: orchestrator.alias.functionArn, guardrailId: inputGuardrail.guardrailId, diff --git a/cdk/test/constructs/jira-integration.test.ts b/cdk/test/constructs/jira-integration.test.ts index 72ece28ba..caf71429c 100644 --- a/cdk/test/constructs/jira-integration.test.ts +++ b/cdk/test/constructs/jira-integration.test.ts @@ -40,12 +40,17 @@ describe('JiraIntegration construct', () => { partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING }, sortKey: { name: 'event_id', type: dynamodb.AttributeType.STRING }, }); + const orchestrationTable = new dynamodb.Table(stack, 'OrchestrationTable', { + partitionKey: { name: 'orchestration_id', type: dynamodb.AttributeType.STRING }, + sortKey: { name: 'sub_issue_id', type: dynamodb.AttributeType.STRING }, + }); new JiraIntegration(stack, 'JiraIntegration', { api, userPool, taskTable, taskEventsTable, + orchestrationTable, }); template = Template.fromStack(stack); @@ -84,4 +89,14 @@ describe('JiraIntegration construct', () => { expect(envVars).toHaveProperty('ABCA_COMPONENT', 'webhook'); } }); + + test('wires the shared orchestration table into the webhook processor', () => { + template.hasResourceProperties('AWS::Lambda::Function', { + Environment: { + Variables: Match.objectLike({ + ORCHESTRATION_TABLE_NAME: Match.anyValue(), + }), + }, + }); + }); }); diff --git a/cdk/test/handlers/jira-webhook-processor-orchestration.test.ts b/cdk/test/handlers/jira-webhook-processor-orchestration.test.ts new file mode 100644 index 000000000..4d035c6d0 --- /dev/null +++ b/cdk/test/handlers/jira-webhook-processor-orchestration.test.ts @@ -0,0 +1,349 @@ +/** + * 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 ddbSend = jest.fn(); +jest.mock('@aws-sdk/client-dynamodb', () => ({ DynamoDBClient: jest.fn(() => ({})) })); +jest.mock('@aws-sdk/lib-dynamodb', () => ({ + DynamoDBDocumentClient: { from: jest.fn(() => ({ send: ddbSend })) }, + GetCommand: jest.fn((input: unknown) => ({ _type: 'Get', input })), + ScanCommand: jest.fn((input: unknown) => ({ _type: 'Scan', input })), +})); + +const createTaskCoreMock = jest.fn(); +jest.mock('../../src/handlers/shared/create-task-core', () => ({ + createTaskCore: (...args: unknown[]) => createTaskCoreMock(...args), +})); + +const reportIssueFailureMock = jest.fn(); +jest.mock('../../src/handlers/shared/jira-feedback', () => ({ + reportIssueFailure: (...args: unknown[]) => reportIssueFailureMock(...args), +})); + +const resolveJiraOauthTokenMock = jest.fn(); +jest.mock('../../src/handlers/shared/jira-oauth-resolver', () => ({ + resolveJiraOauthToken: (...args: unknown[]) => resolveJiraOauthTokenMock(...args), +})); + +const jiraGraphSourceMock = jest.fn(); +jest.mock('../../src/handlers/shared/orchestration-graph-source', () => ({ + jiraGraphSource: (...args: unknown[]) => jiraGraphSourceMock(...args), +})); + +const discoverOrchestrationMock = jest.fn(); +jest.mock('../../src/handlers/shared/orchestration-discovery', () => ({ + discoverOrchestration: (...args: unknown[]) => discoverOrchestrationMock(...args), +})); + +const loadOrchestrationMock = jest.fn(); +const setStatusCommentIdMock = jest.fn(); +jest.mock('../../src/handlers/shared/orchestration-store', () => ({ + deriveOrchestrationId: (parent: string) => `orch-${parent}`, + loadOrchestration: (...args: unknown[]) => loadOrchestrationMock(...args), + setStatusCommentId: (...args: unknown[]) => setStatusCommentIdMock(...args), +})); + +const releaseReadyChildrenMock = jest.fn(); +const applyTerminalCreateFailuresMock = jest.fn(); +jest.mock('../../src/handlers/shared/orchestration-release', () => ({ + releaseReadyChildren: (...args: unknown[]) => releaseReadyChildrenMock(...args), + applyTerminalCreateFailures: (...args: unknown[]) => applyTerminalCreateFailuresMock(...args), +})); + +const upsertEpicPanelMock = jest.fn(); +jest.mock('../../src/handlers/shared/orchestration-rollup', () => ({ + upsertEpicPanel: (...args: unknown[]) => upsertEpicPanelMock(...args), +})); + +jest.mock('../../src/handlers/shared/orchestration-channel-jira', () => ({ + makeJiraChannel: jest.fn(() => ({ kind: 'jira' })), +})); + +jest.mock('../../src/handlers/shared/jira-attachments', () => { + const actual = jest.requireActual('../../src/handlers/shared/jira-attachments'); + return { + ...actual, + fetchRecentHumanComments: jest.fn().mockResolvedValue([]), + downloadScreenAndStoreJiraAttachments: jest.fn().mockResolvedValue([]), + }; +}); + +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'; +process.env.ORCHESTRATION_TABLE_NAME = 'Orchestrations'; + +import { handler } from '../../src/handlers/jira-webhook-processor'; + +const oauth = { + accessToken: 'jira-token', + scope: 'read:jira-work', + siteUrl: 'https://acme.atlassian.net', + oauthSecretArn: 'arn:aws:secretsmanager:us-east-1:123:secret:bgagent-jira-oauth-cloud-1', +}; + +const snapshot = { + meta: { + orchestration_id: 'orch-ENG-1', + parent_issue_ref: 'ENG-1', + credentials_ref: 'cloud-1', + repo: 'org/repo', + child_count: 1, + release_context: { + platform_user_id: 'platform-user', + channel_source: 'jira', + }, + }, + children: [{ + orchestration_id: 'orch-ENG-1', + sub_issue_id: 'ENG-2', + parent_issue_ref: 'ENG-1', + credentials_ref: 'cloud-1', + repo: 'org/repo', + depends_on: [], + child_status: 'ready', + created_at: '2026-08-05T00:00:00.000Z', + updated_at: '2026-08-05T00:00:00.000Z', + }], +}; + +function event(): { raw_body: string } { + return { + raw_body: JSON.stringify({ + webhookEvent: 'jira:issue_created', + cloudId: 'cloud-1', + user: { accountId: 'account-1' }, + issue: { + id: '10001', + key: 'ENG-1', + fields: { + summary: 'Parent work', + description: { + type: 'doc', + version: 1, + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Shared contract' }] }], + }, + labels: ['bgagent'], + project: { id: 'p1', key: 'ENG' }, + }, + }, + }), + }; +} + +function child(key = 'ENG-2', projectKey = 'ENG') { + return { + id: key, + issue_id: key === 'ENG-2' ? '10002' : '10003', + identifier: key, + project_key: projectKey, + title: `Build ${key}`, + description: `Scope ${key}`, + depends_on: [], + }; +} + +describe('jira-webhook-processor orchestration adapter', () => { + beforeEach(() => { + ddbSend.mockReset(); + ddbSend + .mockResolvedValueOnce({ + Item: { + status: 'active', + repo: 'org/repo', + label_filter: 'bgagent', + status_on_start: 'Doing', + status_on_pr: 'Review', + }, + }) + .mockResolvedValueOnce({ + Item: { status: 'active', platform_user_id: 'platform-user' }, + }); + createTaskCoreMock.mockReset(); + createTaskCoreMock.mockResolvedValue({ statusCode: 201, body: '{}' }); + reportIssueFailureMock.mockReset(); + reportIssueFailureMock.mockResolvedValue(undefined); + resolveJiraOauthTokenMock.mockReset(); + resolveJiraOauthTokenMock.mockResolvedValue(oauth); + jiraGraphSourceMock.mockReset(); + jiraGraphSourceMock.mockReturnValue(jest.fn().mockResolvedValue({ + kind: 'ok', + children: [child()], + })); + discoverOrchestrationMock.mockReset(); + discoverOrchestrationMock.mockResolvedValue({ + kind: 'seeded', + orchestrationId: 'orch-ENG-1', + childCount: 1, + rootSubIssueIds: ['ENG-2'], + alreadyExisted: false, + }); + loadOrchestrationMock.mockReset(); + loadOrchestrationMock + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(snapshot) + .mockResolvedValueOnce(snapshot); + releaseReadyChildrenMock.mockReset(); + releaseReadyChildrenMock.mockResolvedValue([]); + applyTerminalCreateFailuresMock.mockReset(); + applyTerminalCreateFailuresMock.mockResolvedValue(snapshot.children); + upsertEpicPanelMock.mockReset(); + upsertEpicPanelMock.mockResolvedValue(null); + setStatusCommentIdMock.mockReset(); + }); + + test('seeds the shared graph, releases roots, and suppresses the parent coding task', async () => { + await handler(event()); + + expect(jiraGraphSourceMock).toHaveBeenCalledWith('jira-token', 'cloud-1', 'ENG-1'); + const params = discoverOrchestrationMock.mock.calls[0][0]; + expect(params).toMatchObject({ + tableName: 'Orchestrations', + parentIssueRef: 'ENG-1', + credentialsRef: 'cloud-1', + repo: 'org/repo', + releaseContext: { + platform_user_id: 'platform-user', + channel_source: 'jira', + trigger_label: 'bgagent', + parent_context: { + title: 'Parent work', + description: 'Shared contract', + }, + }, + }); + await expect(params.graphSource()).resolves.toEqual({ + kind: 'ok', + children: [expect.objectContaining({ + id: 'ENG-2', + channel_metadata: { + jira_cloud_id: 'cloud-1', + jira_project_key: 'ENG', + jira_issue_id: '10002', + jira_issue_key: 'ENG-2', + jira_oauth_secret_arn: oauth.oauthSecretArn, + jira_site_url: oauth.siteUrl, + jira_status_on_start: 'Doing', + jira_status_on_pr: 'Review', + }, + })], + }); + expect(releaseReadyChildrenMock).toHaveBeenCalledTimes(1); + expect(upsertEpicPanelMock).toHaveBeenCalledWith(expect.objectContaining({ + parent: { issueId: 'ENG-1', credentialsRef: 'cloud-1' }, + })); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + }); + + test('falls through to the existing single-task path when Jira has no children', async () => { + jiraGraphSourceMock.mockReturnValueOnce(jest.fn().mockResolvedValue({ kind: 'no_children' })); + + await handler(event()); + + expect(discoverOrchestrationMock).not.toHaveBeenCalled(); + expect(createTaskCoreMock).toHaveBeenCalledTimes(1); + expect(createTaskCoreMock.mock.calls[0][0]).toMatchObject({ + repo: 'org/repo', + workflow_ref: 'coding/new-task-v1', + }); + }); + + test('surfaces Jira graph errors without degrading to a parent task', async () => { + jiraGraphSourceMock.mockReturnValueOnce(jest.fn().mockResolvedValue({ + kind: 'error', + message: 'Jira returned status 401 while reading authored subtasks.', + })); + + await handler(event()); + + expect(discoverOrchestrationMock).not.toHaveBeenCalled(); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + expect(reportIssueFailureMock).toHaveBeenCalledWith( + expect.anything(), + 'ENG-1', + expect.stringContaining('status 401'), + ); + }); + + test('rejects an unmapped cross-project child before seeding', async () => { + jiraGraphSourceMock.mockReturnValueOnce(jest.fn().mockResolvedValue({ + kind: 'ok', + children: [child('OPS-2', 'OPS')], + })); + ddbSend.mockResolvedValueOnce({}); + + await handler(event()); + + expect(discoverOrchestrationMock).not.toHaveBeenCalled(); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + expect(reportIssueFailureMock).toHaveBeenCalledWith( + expect.anything(), + 'ENG-1', + expect.stringContaining('not actively mapped'), + ); + }); + + test('rejects cross-repository child mappings before seeding', async () => { + jiraGraphSourceMock.mockReturnValueOnce(jest.fn().mockResolvedValue({ + kind: 'ok', + children: [child('OPS-2', 'OPS')], + })); + ddbSend.mockResolvedValueOnce({ + Item: { status: 'active', repo: 'other/repo' }, + }); + + await handler(event()); + + expect(discoverOrchestrationMock).not.toHaveBeenCalled(); + expect(reportIssueFailureMock).toHaveBeenCalledWith( + expect.anything(), + 'ENG-1', + expect.stringContaining('All executable Jira subtasks must map to the same repository'), + ); + }); + + test('makes an existing Jira orchestration re-trigger inert in the first layer', async () => { + loadOrchestrationMock.mockReset(); + loadOrchestrationMock.mockResolvedValueOnce(snapshot); + + await handler(event()); + + expect(jiraGraphSourceMock).not.toHaveBeenCalled(); + expect(discoverOrchestrationMock).not.toHaveBeenCalled(); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + }); + + test('surfaces shared DAG rejection and creates no task', async () => { + discoverOrchestrationMock.mockResolvedValueOnce({ + kind: 'rejected', + reason: 'cycle', + message: 'The child graph contains a cycle.', + }); + + await handler(event()); + + expect(releaseReadyChildrenMock).not.toHaveBeenCalled(); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + expect(reportIssueFailureMock).toHaveBeenCalledWith( + expect.anything(), + 'ENG-1', + expect.stringContaining('contains a cycle'), + ); + }); +}); diff --git a/cdk/test/handlers/jira-webhook-processor.test.ts b/cdk/test/handlers/jira-webhook-processor.test.ts index 3ec35ab2c..b71a11570 100644 --- a/cdk/test/handlers/jira-webhook-processor.test.ts +++ b/cdk/test/handlers/jira-webhook-processor.test.ts @@ -263,6 +263,31 @@ describe('jira-webhook-processor handler', () => { .toEqual({ jira_identity: 'cloud-1#reviewer-1' }); }); + test('an orchestrated child iteration preserves routing and marks the restack source', async () => { + resolveTaskByJiraIssueMock.mockResolvedValueOnce({ + ...priorTask, + channel_metadata: { + ...priorTask.channel_metadata, + orchestration_id: 'orch-1', + orchestration_sub_issue_id: 'ENG-42', + }, + }); + ddbSend.mockResolvedValueOnce({ + Item: { platform_user_id: 'linked-reviewer', status: 'active' }, + }); + createTaskCoreMock.mockResolvedValueOnce({ statusCode: 201, body: '{}' }); + + await handler(eventWith(comment())); + + expect(createTaskCoreMock.mock.calls[0][1].channelMetadata).toMatchObject({ + orchestration_id: 'orch-1', + orchestration_sub_issue_id: 'ENG-42', + orchestration_iteration: 'true', + trigger_comment_id: 'comment-1', + trigger_comment_issue_id: 'ENG-42', + }); + }); + test('ADF mention node creates a PR iteration', async () => { resolveTaskByJiraIssueMock.mockResolvedValueOnce(priorTask); ddbSend.mockResolvedValueOnce({ Item: undefined }); diff --git a/cdk/test/handlers/shared/jira-subissue-fetch.test.ts b/cdk/test/handlers/shared/jira-subissue-fetch.test.ts new file mode 100644 index 000000000..e4e5c50eb --- /dev/null +++ b/cdk/test/handlers/shared/jira-subissue-fetch.test.ts @@ -0,0 +1,193 @@ +/** + * 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 { fetchJiraSubIssueGraph } from '../../../src/handlers/shared/jira-subissue-fetch'; + +jest.mock('../../../src/handlers/shared/logger', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +function response(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + } as Response; +} + +function jiraIssue( + id: string, + key: string, + links: readonly Record[] = [], + projectKey = 'ENG', +): Record { + return { + id, + key, + fields: { + summary: `Work on ${key}`, + description: { + type: 'doc', + version: 1, + content: [{ type: 'paragraph', content: [{ type: 'text', text: `Scope for ${key}` }] }], + }, + project: { key: projectKey }, + issuelinks: links, + }, + }; +} + +describe('fetchJiraSubIssueGraph', () => { + test('returns no_children for an empty Jira search', async () => { + const fetchImpl = jest.fn().mockResolvedValue(response({ issues: [], isLast: true })); + + await expect(fetchJiraSubIssueGraph('token', 'cloud-1', 'ENG-1', { + fetchImpl: fetchImpl as typeof fetch, + })).resolves.toEqual({ kind: 'no_children' }); + }); + + test('maps issue identity, project, description, and both blocker directions', async () => { + const fetchImpl = jest.fn().mockResolvedValue(response({ + issues: [ + jiraIssue('101', 'ENG-2', [{ + type: { outward: 'blocks', inward: 'is blocked by' }, + outwardIssue: { key: 'ENG-3' }, + }]), + jiraIssue('102', 'ENG-3', [{ + type: { outward: 'blocks', inward: 'is blocked by' }, + inwardIssue: { key: 'ENG-2' }, + }]), + ], + isLast: true, + })); + + const result = await fetchJiraSubIssueGraph('token', 'cloud-1', 'ENG-1', { + fetchImpl: fetchImpl as typeof fetch, + }); + + expect(result.kind).toBe('ok'); + if (result.kind !== 'ok') return; + expect(result.children).toEqual([ + expect.objectContaining({ + id: 'ENG-2', + issue_id: '101', + identifier: 'ENG-2', + project_key: 'ENG', + description: 'Scope for ENG-2', + depends_on: [], + }), + expect.objectContaining({ + id: 'ENG-3', + issue_id: '102', + depends_on: ['ENG-2'], + }), + ]); + }); + + test('paginates with nextPageToken and preserves a cyclic graph for shared validation', async () => { + const fetchImpl = jest.fn() + .mockResolvedValueOnce(response({ + issues: [jiraIssue('101', 'ENG-2', [{ + type: { inward: 'is blocked by' }, + inwardIssue: { key: 'ENG-3' }, + }])], + nextPageToken: 'page-2', + })) + .mockResolvedValueOnce(response({ + issues: [jiraIssue('102', 'ENG-3', [{ + type: { inward: 'is blocked by' }, + inwardIssue: { key: 'ENG-2' }, + }])], + isLast: true, + })); + + const result = await fetchJiraSubIssueGraph('token', 'cloud-1', 'ENG-1', { + fetchImpl: fetchImpl as typeof fetch, + }); + + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(String(fetchImpl.mock.calls[1][0])).toContain('nextPageToken=page-2'); + expect(result).toMatchObject({ + kind: 'ok', + children: [ + { id: 'ENG-2', depends_on: ['ENG-3'] }, + { id: 'ENG-3', depends_on: ['ENG-2'] }, + ], + }); + }); + + test('rejects malformed children before returning a graph', async () => { + const fetchImpl = jest.fn().mockResolvedValue(response({ + issues: [{ key: 'ENG-2', fields: { summary: 'Missing id', project: { key: 'ENG' } } }], + isLast: true, + })); + + const result = await fetchJiraSubIssueGraph('token', 'cloud-1', 'ENG-1', { + fetchImpl: fetchImpl as typeof fetch, + }); + expect(result).toEqual({ + kind: 'error', + message: 'A Jira subtask is missing its key, project, or summary, so no orchestration was created.', + }); + }); + + test('rejects standard blockers outside the executable child set', async () => { + const fetchImpl = jest.fn().mockResolvedValue(response({ + issues: [jiraIssue('101', 'ENG-2', [{ + type: { inward: 'is blocked by' }, + inwardIssue: { key: 'ENG-999' }, + }])], + isLast: true, + })); + + const result = await fetchJiraSubIssueGraph('token', 'cloud-1', 'ENG-1', { + fetchImpl: fetchImpl as typeof fetch, + }); + expect(result).toEqual({ + kind: 'error', + message: 'ENG-2 is blocked by ENG-999, which is not an executable subtask of ENG-1.', + }); + }); + + test('returns an actionable error on Jira auth/API failure', async () => { + const fetchImpl = jest.fn().mockResolvedValue(response({}, 401)); + + const result = await fetchJiraSubIssueGraph('token', 'cloud-1', 'ENG-1', { + fetchImpl: fetchImpl as typeof fetch, + }); + expect(result).toEqual({ + kind: 'error', + message: 'Jira returned status 401 while reading authored subtasks.', + }); + }); + + test('rejects a repeated pagination token instead of looping', async () => { + const fetchImpl = jest.fn() + .mockResolvedValueOnce(response({ issues: [], nextPageToken: 'same' })) + .mockResolvedValueOnce(response({ issues: [], nextPageToken: 'same' })); + + const result = await fetchJiraSubIssueGraph('token', 'cloud-1', 'ENG-1', { + fetchImpl: fetchImpl as typeof fetch, + }); + expect(result).toEqual({ + kind: 'error', + message: 'Jira returned a repeated pagination token while reading subtasks.', + }); + }); +}); diff --git a/cdk/test/handlers/shared/orchestration-release.test.ts b/cdk/test/handlers/shared/orchestration-release.test.ts index f2d023652..97302886b 100644 --- a/cdk/test/handlers/shared/orchestration-release.test.ts +++ b/cdk/test/handlers/shared/orchestration-release.test.ts @@ -348,6 +348,32 @@ describe('releaseChild — a retry salts the idempotency key with the prior task }); describe('releaseChild — happy path', () => { + test('forwards adapter metadata while protecting orchestration-owned keys', async () => { + const createTaskCore = created('T-jira'); + await releaseChild({ + ddb: { send: jest.fn().mockResolvedValue({}) } as never, + tableName: 'OrchestrationTable', + row: makeRow({ + channel_metadata: { + jira_cloud_id: 'cloud-1', + jira_issue_key: 'ENG-2', + orchestration_id: 'adapter-cannot-override', + }, + }), + platformUserId: 'user-1', + channelSource: 'jira', + createTaskCore: createTaskCore as never, + now: NOW, + }); + + expect(createTaskCore.mock.calls[0][1].channelMetadata).toMatchObject({ + jira_cloud_id: 'cloud-1', + jira_issue_key: 'ENG-2', + orchestration_id: 'orch_abc', + orchestration_sub_issue_id: 'SUB-1', + }); + }); + test('creates a task and flips the row to released', async () => { const ddb = { send: jest.fn().mockResolvedValue({}) }; const createTaskCore = created('T-100'); diff --git a/cdk/test/handlers/shared/orchestration-store.test.ts b/cdk/test/handlers/shared/orchestration-store.test.ts index db571c1ad..71232521e 100644 --- a/cdk/test/handlers/shared/orchestration-store.test.ts +++ b/cdk/test/handlers/shared/orchestration-store.test.ts @@ -169,6 +169,34 @@ describe('seedOrchestration — first write', () => { expect(b).not.toHaveProperty('description'); // absent, not an empty string }); + test('persists adapter-owned channel metadata on the child row', async () => { + const ddb = makeDdb(); + ddb.send.mockResolvedValueOnce({ Item: undefined }).mockResolvedValueOnce({}); + + await seedOrchestration({ + ddb: ddb as never, + tableName: TABLE, + parentIssueRef: 'ENG-1', + credentialsRef: 'cloud-1', + repo: 'o/r', + children: [child('ENG-2', [], { + channel_metadata: { + jira_cloud_id: 'cloud-1', + jira_issue_key: 'ENG-2', + }, + })], + now: NOW, + releaseContext: { ...RC, channel_source: 'jira' }, + }); + + const puts = ddb.send.mock.calls[1][0].input.RequestItems[TABLE] as Array<{ PutRequest: { Item: Record } }>; + const row = puts.find((put) => put.PutRequest.Item.sub_issue_id === 'ENG-2')!.PutRequest.Item; + expect(row.channel_metadata).toEqual({ + jira_cloud_id: 'cloud-1', + jira_issue_key: 'ENG-2', + }); + }); + test('chunks BatchWrite into groups of 25', async () => { const ddb = makeDdb(); ddb.send.mockResolvedValue({}); // Get + all batches diff --git a/docs/guides/JIRA_SETUP_GUIDE.md b/docs/guides/JIRA_SETUP_GUIDE.md index 2a1858894..0e3f6d32e 100644 --- a/docs/guides/JIRA_SETUP_GUIDE.md +++ b/docs/guides/JIRA_SETUP_GUIDE.md @@ -315,6 +315,16 @@ When the comment author has linked their Jira and ABCA accounts, the iteration i 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. +## Authored subtask orchestration + +Applying the trigger label to a parent that already has Jira subtasks runs those subtasks as one orchestration instead of creating a separate coding task for the parent. Each subtask becomes an ordinary ABCA task. Standard Jira `blocks` / `is blocked by` links between those subtasks determine release order: roots start immediately, and blocked work starts only after all predecessors succeed. + +All executable subtasks must belong to active Jira project mappings that resolve to the same repository as the parent. Cross-project subtasks are supported when their mappings name that same repository; cross-repository graphs, unmapped projects, cycles, and blocker links to issues outside the parent's executable subtask set are rejected before any orchestration rows are written. Jira API or authentication failures are reported on the parent and never silently degrade to a single parent task. + +The parent receives orchestration progress and the terminal rollup. Parallel leaves converge through an internal integration task so the orchestration produces one combined pull request; that internal task does not address a nonexistent Jira issue. An `@bgagent` comment on a real child updates that child's pull request and restacks dependent pull requests through the shared orchestration reconciler. + +In this version the authored graph is frozen at the first successful seed. Re-applying the trigger label to an already-seeded parent is an idempotent no-op; it does not rerun completed children or add newly created subtasks. + ## 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. diff --git a/docs/src/content/docs/using/Jira-setup-guide.md b/docs/src/content/docs/using/Jira-setup-guide.md index fa37ee680..ff91e724c 100644 --- a/docs/src/content/docs/using/Jira-setup-guide.md +++ b/docs/src/content/docs/using/Jira-setup-guide.md @@ -319,6 +319,16 @@ When the comment author has linked their Jira and ABCA accounts, the iteration i 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. +## Authored subtask orchestration + +Applying the trigger label to a parent that already has Jira subtasks runs those subtasks as one orchestration instead of creating a separate coding task for the parent. Each subtask becomes an ordinary ABCA task. Standard Jira `blocks` / `is blocked by` links between those subtasks determine release order: roots start immediately, and blocked work starts only after all predecessors succeed. + +All executable subtasks must belong to active Jira project mappings that resolve to the same repository as the parent. Cross-project subtasks are supported when their mappings name that same repository; cross-repository graphs, unmapped projects, cycles, and blocker links to issues outside the parent's executable subtask set are rejected before any orchestration rows are written. Jira API or authentication failures are reported on the parent and never silently degrade to a single parent task. + +The parent receives orchestration progress and the terminal rollup. Parallel leaves converge through an internal integration task so the orchestration produces one combined pull request; that internal task does not address a nonexistent Jira issue. An `@bgagent` comment on a real child updates that child's pull request and restacks dependent pull requests through the shared orchestration reconciler. + +In this version the authored graph is frozen at the first successful seed. Re-applying the trigger label to an already-seeded parent is an idempotent no-op; it does not rerun completed children or add newly created subtasks. + ## 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.