From 3a750fde283ef4d7eab6a390686ad409d063c5ba Mon Sep 17 00:00:00 2001 From: bgagent Date: Fri, 5 Jun 2026 14:09:20 -0400 Subject: [PATCH 01/20] docs(guides): note supported Node version range in Quick Start prerequisites --- docs/guides/QUICK_START.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/guides/QUICK_START.md b/docs/guides/QUICK_START.md index 3d91df13..190213df 100644 --- a/docs/guides/QUICK_START.md +++ b/docs/guides/QUICK_START.md @@ -9,6 +9,7 @@ Install these before you begin: - **AWS account** with credentials configured (`aws configure`). If you use named profiles, set `AWS_PROFILE` before running any commands in this guide. - **Amazon Bedrock** — The agent invokes Claude through Bedrock. IAM `grantInvoke` in the CDK stack is required but **not sufficient**: your account must also satisfy [Amazon Bedrock model access](https://docs.aws.amazon.com/bedrock/latest/userguide/model-access.html) for the model you use (including Anthropic first-time use where applicable, Marketplace subscription flow on first serverless use, and a valid payment method for Marketplace-backed models). See **Amazon Bedrock before your first task** after Step 3. - **Docker** - for building the agent container image +- **Node.js** v20 or later (Node 24 is the supported maximum — see CI matrix) - **mise** - task runner ([install guide](https://mise.jdx.dev/getting-started.html)) - **AWS CDK CLI** - `npm install -g aws-cdk` (after mise is active) From 1560467cc0fdec956bed7f4af12ac7e3221a17dc Mon Sep 17 00:00:00 2001 From: bgagent Date: Mon, 8 Jun 2026 13:39:01 -0400 Subject: [PATCH 02/20] feat(types): add 'jira' to ChannelSource union Phase 1 of Jira Cloud integration (#288). Extends the ChannelSource discriminant on both sides of the wire and updates the agent-side comment so the runtime knows 'jira' is a recognized channel value; no behavior changes yet. --- agent/src/models.py | 5 +++-- cdk/src/handlers/shared/types.ts | 3 ++- cli/src/types.ts | 3 ++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/agent/src/models.py b/agent/src/models.py index 007b94bc..172036d7 100644 --- a/agent/src/models.py +++ b/agent/src/models.py @@ -142,8 +142,9 @@ class TaskConfig(BaseModel): pr_number: str = "" task_id: str = "" # Inbound channel the task was submitted from (mirrors ChannelSource in - # cdk/src/handlers/shared/types.ts). Gates channel-specific MCP wiring and - # prompt additions. Empty string means "no channel context" (legacy / local). + # cdk/src/handlers/shared/types.ts: api | webhook | slack | linear | jira). + # Gates channel-specific MCP wiring and prompt additions. Empty string means + # "no channel context" (legacy / local). channel_source: str = "" channel_metadata: dict[str, str] = Field(default_factory=dict) # Platform user_id (Cognito ``sub``) threaded from the orchestrator diff --git a/cdk/src/handlers/shared/types.ts b/cdk/src/handlers/shared/types.ts index da04b971..d86fbce8 100644 --- a/cdk/src/handlers/shared/types.ts +++ b/cdk/src/handlers/shared/types.ts @@ -49,13 +49,14 @@ export type AttachmentDelivery = 'inline' | 'presigned' | 'url_fetch'; * - ``webhook``: HMAC-signed inbound webhook submissions (generic webhook endpoint) * - ``slack``: Slack @mention / slash-command submissions (see SlackIntegration) * - ``linear``: Linear label-triggered submissions (see LinearIntegration) + * - ``jira``: Jira Cloud label-triggered submissions (see JiraIntegration) * * Narrowed from ``string`` so switches and predicates that read * ``channel_source`` get exhaustiveness checking at compile time; matches the * internal ``CreateTaskContext.channelSource`` literal in ``create-task-core.ts``. * Keep in sync with ``cli/src/types.ts::ChannelSource``. */ -export type ChannelSource = 'api' | 'webhook' | 'slack' | 'linear'; +export type ChannelSource = 'api' | 'webhook' | 'slack' | 'linear' | 'jira'; /** Task types that operate on an existing pull request. */ export function isPrTaskType(taskType: TaskType): boolean { diff --git a/cli/src/types.ts b/cli/src/types.ts index f0475182..e159b67f 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -47,12 +47,13 @@ export type TaskStatusType = * - ``webhook``: HMAC-signed inbound webhook submissions (generic webhook endpoint) * - ``slack``: Slack @mention / slash-command submissions * - ``linear``: Linear label-triggered submissions + * - ``jira``: Jira Cloud label-triggered submissions * * Mirrors ``cdk/src/handlers/shared/types.ts::ChannelSource`` per the CLI * types-sync contract so downstream switches/predicates get exhaustiveness * checking on both sides of the wire. */ -export type ChannelSource = 'api' | 'webhook' | 'slack' | 'linear'; +export type ChannelSource = 'api' | 'webhook' | 'slack' | 'linear' | 'jira'; /** Error categories produced by the runtime error classifier. */ export type ErrorCategoryType = 'auth' | 'network' | 'concurrency' | 'compute' | 'agent' | 'guardrail' | 'config' | 'timeout' | 'unknown'; From 1a634dd7e2201821c8bcb8155edc2a82f8810c66 Mon Sep 17 00:00:00 2001 From: bgagent Date: Mon, 8 Jun 2026 14:03:52 -0400 Subject: [PATCH 03/20] feat(jira): add DDB table constructs for projects, users, workspaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of Jira Cloud integration (#288). Mirrors the Linear constructs file-for-file. Composite PKs use cloudId as the tenant prefix (`{cloudId}#{projectKey}`, `{cloudId}#{accountId}`) so the same project key or account id stays unambiguous across distinct Atlassian tenants. Tables are unwired until Phase 4 — JiraIntegration instantiates and grants them. --- .../constructs/jira-project-mapping-table.ts | 86 +++++++++++++++++ cdk/src/constructs/jira-user-mapping-table.ts | 94 +++++++++++++++++++ .../jira-workspace-registry-table.ts | 91 ++++++++++++++++++ 3 files changed, 271 insertions(+) create mode 100644 cdk/src/constructs/jira-project-mapping-table.ts create mode 100644 cdk/src/constructs/jira-user-mapping-table.ts create mode 100644 cdk/src/constructs/jira-workspace-registry-table.ts diff --git a/cdk/src/constructs/jira-project-mapping-table.ts b/cdk/src/constructs/jira-project-mapping-table.ts new file mode 100644 index 00000000..b85c9b78 --- /dev/null +++ b/cdk/src/constructs/jira-project-mapping-table.ts @@ -0,0 +1,86 @@ +/** + * 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 { RemovalPolicy } from 'aws-cdk-lib'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import { Construct } from 'constructs'; + +/** + * Properties for JiraProjectMappingTable construct. + */ +export interface JiraProjectMappingTableProps { + /** + * Optional table name override. + * @default - auto-generated by CloudFormation + */ + readonly tableName?: string; + + /** + * Removal policy for the table. + * @default RemovalPolicy.DESTROY + */ + readonly removalPolicy?: RemovalPolicy; + + /** + * Whether to enable point-in-time recovery. + * @default true + */ + readonly pointInTimeRecovery?: boolean; +} + +/** + * DynamoDB table mapping Jira Cloud projects to GitHub repositories. + * + * Schema: jira_project_identity (PK) — composite key `{cloudId}#{projectKey}`. + * `cloudId` is the Atlassian tenant identifier (returned by OAuth + present on + * every webhook payload); `projectKey` is the human project key (e.g. `ENG`). + * The composite key keeps the same project key across distinct tenants + * unambiguous. + * + * Fields: + * - repo — `owner/repo` + * - cloud_id — duplicated from the PK for filtering by tenant + * - project_key — duplicated from the PK + * - label_filter — Jira issue label that triggers a task (default `bgagent`) + * - status — 'active' | 'removed' + * - onboarded_at, updated_at — ISO timestamps + */ +export class JiraProjectMappingTable extends Construct { + /** + * The underlying DynamoDB table. + */ + public readonly table: dynamodb.Table; + + constructor(scope: Construct, id: string, props: JiraProjectMappingTableProps = {}) { + super(scope, id); + + this.table = new dynamodb.Table(this, 'Table', { + tableName: props.tableName, + partitionKey: { + name: 'jira_project_identity', + type: dynamodb.AttributeType.STRING, + }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + pointInTimeRecoverySpecification: { + pointInTimeRecoveryEnabled: props.pointInTimeRecovery ?? true, + }, + removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY, + }); + } +} diff --git a/cdk/src/constructs/jira-user-mapping-table.ts b/cdk/src/constructs/jira-user-mapping-table.ts new file mode 100644 index 00000000..1b8485f0 --- /dev/null +++ b/cdk/src/constructs/jira-user-mapping-table.ts @@ -0,0 +1,94 @@ +/** + * 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 { RemovalPolicy } from 'aws-cdk-lib'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import { Construct } from 'constructs'; + +/** + * Properties for JiraUserMappingTable construct. + */ +export interface JiraUserMappingTableProps { + /** + * Optional table name override. + * @default - auto-generated by CloudFormation + */ + readonly tableName?: string; + + /** + * Removal policy for the table. + * @default RemovalPolicy.DESTROY + */ + readonly removalPolicy?: RemovalPolicy; + + /** + * Whether to enable point-in-time recovery. + * @default true + */ + readonly pointInTimeRecovery?: boolean; +} + +/** + * DynamoDB table for mapping Jira user identities to platform user IDs. + * + * Schema: jira_identity (PK) — composite key `{cloudId}#{accountId}` for + * confirmed mappings, `pending#{code}` for in-flight link codes (with TTL). + * `accountId` is Atlassian's stable per-tenant user identifier returned by + * the OAuth identity endpoint and present on issue events. + * + * GSIs: + * - PlatformUserIndex (PK: platform_user_id, SK: linked_at) — list linked Jira accounts for a user + */ +export class JiraUserMappingTable extends Construct { + /** + * GSI name for querying mappings by platform user. + * PK: platform_user_id, SK: linked_at. + */ + public static readonly PLATFORM_USER_INDEX = 'PlatformUserIndex'; + + /** + * The underlying DynamoDB table. + */ + public readonly table: dynamodb.Table; + + constructor(scope: Construct, id: string, props: JiraUserMappingTableProps = {}) { + super(scope, id); + + this.table = new dynamodb.Table(this, 'Table', { + tableName: props.tableName, + partitionKey: { + name: 'jira_identity', + type: dynamodb.AttributeType.STRING, + }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + timeToLiveAttribute: 'ttl', + pointInTimeRecoverySpecification: { + pointInTimeRecoveryEnabled: props.pointInTimeRecovery ?? true, + }, + removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY, + }); + + this.table.addGlobalSecondaryIndex({ + indexName: JiraUserMappingTable.PLATFORM_USER_INDEX, + partitionKey: { name: 'platform_user_id', type: dynamodb.AttributeType.STRING }, + sortKey: { name: 'linked_at', type: dynamodb.AttributeType.STRING }, + projectionType: dynamodb.ProjectionType.ALL, + }); + } +} diff --git a/cdk/src/constructs/jira-workspace-registry-table.ts b/cdk/src/constructs/jira-workspace-registry-table.ts new file mode 100644 index 00000000..af639190 --- /dev/null +++ b/cdk/src/constructs/jira-workspace-registry-table.ts @@ -0,0 +1,91 @@ +/** + * 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 { RemovalPolicy } from 'aws-cdk-lib'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import { Construct } from 'constructs'; + +/** + * Properties for JiraWorkspaceRegistryTable construct. + */ +export interface JiraWorkspaceRegistryTableProps { + /** + * Optional table name override. + * @default - auto-generated by CloudFormation + */ + readonly tableName?: string; + + /** + * Removal policy for the table. + * @default RemovalPolicy.DESTROY + */ + readonly removalPolicy?: RemovalPolicy; + + /** + * Whether to enable point-in-time recovery. + * @default true + */ + readonly pointInTimeRecovery?: boolean; +} + +/** + * DynamoDB table tracking Jira Cloud tenants that have completed OAuth onboarding. + * + * Schema: jira_cloud_id (PK) — Atlassian's tenant identifier (UUID), the stable + * key returned from `accessible-resources` and present on every webhook payload. + * + * Fields: + * - site_url — `https://.atlassian.net` (display + REST base) + * - provider_name — full AgentCore credential provider name + * (`bgagent-jira-oauth-`), the lookup key for resolving the + * tenant's OAuth token via AgentCore Identity + * - installed_by_platform_user_id — Cognito sub of the admin who ran + * `bgagent jira setup` (audit only; runtime callers do not need this) + * - installed_at, updated_at — ISO timestamps + * - status — 'active' | 'revoked' + * + * The webhook processor and orchestrator look up `provider_name` here from + * the inbound webhook's `cloudId`, then call AgentCore Identity with + * `userId='jira-tenant-'` to retrieve the tenant's OAuth token. + * Token sharing is intentional — one bgagent[bot] identity per tenant, + * used for all members' triggered tasks (parity with the Linear adapter). + */ +export class JiraWorkspaceRegistryTable extends Construct { + /** + * The underlying DynamoDB table. + */ + public readonly table: dynamodb.Table; + + constructor(scope: Construct, id: string, props: JiraWorkspaceRegistryTableProps = {}) { + super(scope, id); + + this.table = new dynamodb.Table(this, 'Table', { + tableName: props.tableName, + partitionKey: { + name: 'jira_cloud_id', + type: dynamodb.AttributeType.STRING, + }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + pointInTimeRecoverySpecification: { + pointInTimeRecoveryEnabled: props.pointInTimeRecovery ?? true, + }, + removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY, + }); + } +} From a1223e357dd84eb3814aae4029dca3fe506510df Mon Sep 17 00:00:00 2001 From: bgagent Date: Mon, 8 Jun 2026 14:38:45 -0400 Subject: [PATCH 04/20] feat(jira): add webhook, processor, link Lambdas + shared helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of Jira Cloud integration (#288). Mirrors Linear's adapter shape: per-tenant OAuth resolver (auth.atlassian.com), X-Hub-Signature HMAC verify with per-tenant + stack-wide fallback, REST-based feedback poster (ADF-wrapped, no reaction primitive — marker folded into text), and three Lambdas (webhook, processor, link). Non-trivial bit: the processor diffs `changelog.items[]` where `field === 'labels'` and tokenizes the space-separated `fromString` / `toString` to detect a label add — Atlassian's diff format differs from Linear's `updatedFrom.labelIds`. Includes a minimal ADF→markdown walker for issue descriptions. Handlers reference JIRA_* env vars set by the JiraIntegration construct in Phase 4; they don't deploy yet. --- cdk/src/handlers/jira-link.ts | 136 +++++ cdk/src/handlers/jira-webhook-processor.ts | 542 ++++++++++++++++++ cdk/src/handlers/jira-webhook.ts | 244 ++++++++ cdk/src/handlers/shared/jira-feedback.ts | 157 +++++ .../handlers/shared/jira-oauth-resolver.ts | 506 ++++++++++++++++ cdk/src/handlers/shared/jira-verify.ts | 192 +++++++ 6 files changed, 1777 insertions(+) create mode 100644 cdk/src/handlers/jira-link.ts create mode 100644 cdk/src/handlers/jira-webhook-processor.ts create mode 100644 cdk/src/handlers/jira-webhook.ts create mode 100644 cdk/src/handlers/shared/jira-feedback.ts create mode 100644 cdk/src/handlers/shared/jira-oauth-resolver.ts create mode 100644 cdk/src/handlers/shared/jira-verify.ts diff --git a/cdk/src/handlers/jira-link.ts b/cdk/src/handlers/jira-link.ts new file mode 100644 index 00000000..febdc987 --- /dev/null +++ b/cdk/src/handlers/jira-link.ts @@ -0,0 +1,136 @@ +/** + * 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 { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient, GetCommand, PutCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { ulid } from 'ulid'; +import { extractUserId } from './shared/gateway'; +import { logger } from './shared/logger'; +import { ErrorCode, errorResponse, successResponse } from './shared/response'; +import { parseBody } from './shared/validation'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); + +const USER_MAPPING_TABLE = process.env.JIRA_USER_MAPPING_TABLE_NAME!; + +interface LinkRequest { + readonly code: string; + /** Preview-only: return what would be linked without writing. */ + readonly dry_run?: boolean; +} + +/** + * POST /v1/jira/link — Complete Jira account linking, or preview it. + * + * Called from the CLI (`bgagent jira link `) with a Cognito JWT. + * Looks up the pending link record. With `dry_run: true`, returns the + * Jira identity attached to the code without writing — the CLI uses + * this to render a "you're about to link X" preview before the user + * confirms. Without `dry_run`, writes the mapping and deletes the + * pending record. + */ +export async function handler(event: APIGatewayProxyEvent): Promise { + const requestId = ulid(); + + try { + const userId = extractUserId(event); + if (!userId) { + return errorResponse(401, ErrorCode.UNAUTHORIZED, 'Authentication required.', requestId); + } + + const body = parseBody(event.body ?? null); + if (!body?.code) { + return errorResponse(400, ErrorCode.VALIDATION_ERROR, 'Request body must include a "code" field.', requestId); + } + + // Codes from `bgagent jira invite-user` are case-sensitive (kebab-case + // with a lowercase hex suffix); don't uppercase the incoming value. + const code = body.code.trim(); + + const pending = await ddb.send(new GetCommand({ + TableName: USER_MAPPING_TABLE, + Key: { jira_identity: `pending#${code}` }, + })); + + if (!pending.Item || pending.Item.status !== 'pending') { + return errorResponse(404, ErrorCode.VALIDATION_ERROR, 'Invalid or expired link code.', requestId); + } + + const cloudId = pending.Item.jira_cloud_id as string; + const siteUrl = (pending.Item.jira_site_url as string | undefined) ?? ''; + const jiraAccountId = pending.Item.jira_account_id as string; + const jiraUserName = (pending.Item.jira_user_name as string | undefined) ?? ''; + const jiraUserEmail = (pending.Item.jira_user_email as string | undefined) ?? ''; + + // Dry-run preview: return identity without writing. + if (body.dry_run === true) { + return successResponse(200, { + dry_run: true, + jira_cloud_id: cloudId, + jira_site_url: siteUrl, + jira_account_id: jiraAccountId, + jira_user_name: jiraUserName, + jira_user_email: jiraUserEmail, + }, requestId); + } + + const now = new Date().toISOString(); + + await ddb.send(new PutCommand({ + TableName: USER_MAPPING_TABLE, + Item: { + jira_identity: `${cloudId}#${jiraAccountId}`, + platform_user_id: userId, + jira_cloud_id: cloudId, + jira_account_id: jiraAccountId, + linked_at: now, + status: 'active', + link_method: 'cli', + }, + })); + + await ddb.send(new DeleteCommand({ + TableName: USER_MAPPING_TABLE, + Key: { jira_identity: `pending#${code}` }, + })); + + logger.info('Jira account linked', { + platform_user_id: userId, + jira_cloud_id: cloudId, + jira_account_id: jiraAccountId, + }); + + return successResponse(200, { + message: 'Jira account linked successfully.', + jira_cloud_id: cloudId, + jira_site_url: siteUrl, + jira_account_id: jiraAccountId, + jira_user_name: jiraUserName, + jira_user_email: jiraUserEmail, + linked_at: now, + }, requestId); + } catch (err) { + logger.error('Jira link handler failed', { + error: err instanceof Error ? err.message : String(err), + request_id: requestId, + }); + return errorResponse(500, ErrorCode.INTERNAL_ERROR, 'Internal server error.', requestId); + } +} diff --git a/cdk/src/handlers/jira-webhook-processor.ts b/cdk/src/handlers/jira-webhook-processor.ts new file mode 100644 index 00000000..060743ab --- /dev/null +++ b/cdk/src/handlers/jira-webhook-processor.ts @@ -0,0 +1,542 @@ +/** + * 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 * as crypto from 'crypto'; +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb'; +import { createTaskCore } from './shared/create-task-core'; +import { reportIssueFailure } from './shared/jira-feedback'; +import { resolveJiraOauthToken } from './shared/jira-oauth-resolver'; +import { logger } from './shared/logger'; +import type { Attachment } from './shared/types'; + +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 WORKSPACE_REGISTRY_TABLE = process.env.JIRA_WORKSPACE_REGISTRY_TABLE_NAME; +const DEFAULT_LABEL_FILTER = 'bgagent'; + +/** + * Post a Jira comment without ever propagating an error. Mirrors the + * Linear `safeReportIssueFailure` contract — feedback is best-effort, + * advisory, and must never gate task-rejection logic. + */ +async function safeReportIssueFailure( + issueIdOrKey: string, + cloudId: string | undefined, + message: string, +): Promise { + if (!WORKSPACE_REGISTRY_TABLE) { + logger.warn('Skipping Jira feedback: JIRA_WORKSPACE_REGISTRY_TABLE_NAME not set', { + issue_id_or_key: issueIdOrKey, + }); + return; + } + if (!cloudId) { + logger.warn('Skipping Jira feedback: webhook payload missing cloudId', { + issue_id_or_key: issueIdOrKey, + }); + return; + } + try { + await reportIssueFailure( + { cloudId, registryTableName: WORKSPACE_REGISTRY_TABLE }, + issueIdOrKey, + message, + ); + } catch (err) { + logger.warn('Jira feedback failed (non-fatal)', { + issue_id_or_key: issueIdOrKey, + jira_cloud_id: cloudId, + error: err instanceof Error ? err.message : String(err), + }); + } +} + +/** + * Subset of the Jira Cloud `jira:issue_*` webhook payload we depend on. + * Undocumented fields are tolerated. + */ +interface JiraIssueEvent { + readonly webhookEvent: 'jira:issue_created' | 'jira:issue_updated' | string; + readonly timestamp?: number; + readonly cloudId?: string; + readonly user?: { + readonly accountId?: string; + readonly displayName?: string; + }; + readonly issue?: { + readonly id: string; + readonly key: string; + readonly fields?: { + readonly summary?: string; + readonly description?: unknown; // ADF document + readonly labels?: string[]; + readonly creator?: { readonly accountId?: string }; + readonly reporter?: { readonly accountId?: string }; + readonly project?: { + readonly id?: string; + readonly key?: string; + }; + readonly [key: string]: unknown; + }; + }; + readonly changelog?: { + readonly items?: Array<{ + readonly field?: string; + readonly fieldId?: string; + readonly fromString?: string | null; + readonly toString?: string | null; + }>; + }; +} + +interface ProcessorEvent { + readonly raw_body: string; +} + +/** + * Async processor for verified Jira webhooks. + * + * Responsibilities: + * - Parse the issue payload. + * - Detect whether the configured trigger label was added on creation OR + * added by an `issue_updated` event whose changelog shows a `labels` + * diff with the label newly present (Atlassian's label diff format + * differs from Linear's). + * - Resolve `(cloudId, projectKey)` → repo mapping. + * - Resolve `(cloudId, accountId)` → platform user mapping. + * - Call `createTaskCore` with `channelSource: 'jira'` and metadata the + * agent uses to address the originating issue via the Jira MCP. + */ +export async function handler(event: ProcessorEvent): Promise { + if (!event.raw_body) { + logger.error('Jira webhook processor invoked without raw_body'); + return; + } + + let payload: JiraIssueEvent; + try { + payload = JSON.parse(event.raw_body) as JiraIssueEvent; + } catch (err) { + logger.error('Jira webhook processor could not parse raw_body', { + error: err instanceof Error ? err.message : String(err), + }); + return; + } + + if ( + payload.webhookEvent !== 'jira:issue_created' && + payload.webhookEvent !== 'jira:issue_updated' + ) { + logger.info('Jira processor skipping non-issue event', { webhookEvent: payload.webhookEvent }); + return; + } + + const issue = payload.issue; + if (!issue || !issue.id || !issue.key) { + logger.warn('Jira issue payload missing id or key', { webhookEvent: payload.webhookEvent }); + return; + } + + const cloudId = payload.cloudId; + const projectKey = issue.fields?.project?.key; + if (!projectKey) { + logger.info('Jira issue has no project.key — skipping (cannot route to a repo)', { + issue_key: issue.key, + }); + await safeReportIssueFailure( + issue.key, + cloudId, + "❌ This Jira issue isn't in a project — ABCA needs a Jira project to route the task to a repo. Move the issue into a project and re-apply the trigger label.", + ); + return; + } + + if (!cloudId) { + // Without cloudId we can't resolve which tenant this issue belongs to, + // which means we can't look up the project mapping (composite PK is + // `{cloudId}#{projectKey}`) or post feedback. Log and drop. + logger.warn('Jira webhook missing cloudId — cannot resolve tenant', { + issue_key: issue.key, + project_key: projectKey, + }); + return; + } + + const projectIdentity = `${cloudId}#${projectKey}`; + const mapping = await ddb.send(new GetCommand({ + TableName: PROJECT_MAPPING_TABLE, + Key: { jira_project_identity: projectIdentity }, + })); + if (!mapping.Item || mapping.Item.status !== 'active') { + logger.info('Jira project is not onboarded or is removed — skipping', { + jira_project_identity: projectIdentity, + issue_key: issue.key, + }); + await safeReportIssueFailure( + issue.key, + cloudId, + "❌ This Jira project isn't onboarded to ABCA. An admin can onboard it with `bgagent jira onboard-project --repo / --label `.", + ); + return; + } + const repo = mapping.Item.repo as string; + const labelFilter = (mapping.Item.label_filter as string | undefined) ?? DEFAULT_LABEL_FILTER; + + if (!shouldTrigger(payload, labelFilter)) { + logger.info('Jira webhook does not match trigger criteria', { + webhookEvent: payload.webhookEvent, + issue_key: issue.key, + label_filter: labelFilter, + current_labels: issue.fields?.labels, + changelog_label_items: payload.changelog?.items?.filter((i) => i?.field === 'labels'), + }); + return; + } + + const accountId = payload.user?.accountId + ?? issue.fields?.reporter?.accountId + ?? issue.fields?.creator?.accountId; + if (!accountId) { + logger.warn('Jira webhook missing user.accountId — cannot attribute task', { + issue_key: issue.key, + jira_cloud_id: cloudId, + }); + await safeReportIssueFailure( + issue.key, + cloudId, + "❌ Jira webhook is missing the user accountId — ABCA can't attribute this task to a user. This is unusual; please report it to your ABCA admin.", + ); + return; + } + + const platformUserId = await lookupPlatformUser(cloudId, accountId); + if (!platformUserId) { + logger.warn('Jira account has no linked platform user — skipping task creation', { + jira_cloud_id: cloudId, + jira_account_id: accountId, + issue_key: issue.key, + }); + await safeReportIssueFailure( + issue.key, + cloudId, + "❌ This Jira user isn't linked to a platform user. Run `bgagent jira link ` from a Cognito-authenticated CLI session to complete linking.", + ); + return; + } + + const taskDescription = buildTaskDescription(issue); + + const channelMetadata: Record = { + jira_cloud_id: cloudId, + jira_project_key: projectKey, + jira_issue_id: issue.id, + jira_issue_key: issue.key, + }; + + // Stash the resolved OAuth secret ARN on the task so the agent runtime + // doesn't have to re-do the registry lookup. Also blocks tasks from + // tenants that only verified via the stack-wide fallback (workspace + // unknown to the registry) — we'd burn agent quota with no MCP token. + if (WORKSPACE_REGISTRY_TABLE) { + const resolved = await resolveJiraOauthToken(cloudId, WORKSPACE_REGISTRY_TABLE); + if (!resolved) { + logger.warn('Jira tenant not resolvable from registry — dropping event', { + jira_cloud_id: cloudId, + issue_key: issue.key, + }); + return; + } + channelMetadata.jira_oauth_secret_arn = resolved.oauthSecretArn; + channelMetadata.jira_site_url = resolved.siteUrl; + } + + const attachments = extractImageUrlAttachments(extractDescriptionMarkdown(issue.fields?.description)); + + const requestId = crypto.randomUUID(); + const result = await createTaskCore( + { + repo, + task_description: taskDescription, + ...(attachments.length > 0 && { attachments }), + }, + { + userId: platformUserId, + channelSource: 'jira', + channelMetadata, + }, + requestId, + ); + + if (result.statusCode !== 201) { + logger.warn('Jira-triggered task creation returned non-201', { + status: result.statusCode, + body: result.body, + issue_key: issue.key, + }); + await safeReportIssueFailure( + issue.key, + cloudId, + buildCreateTaskFailureMessage(result.statusCode, result.body), + ); + return; + } + + logger.info('Jira-triggered task created', { + issue_key: issue.key, + issue_id: issue.id, + repo, + request_id: requestId, + }); +} + +/** + * Decide whether a Jira issue event should trigger a task. + * + * Two trigger paths: + * - `jira:issue_created` with the trigger label already present. + * - `jira:issue_updated` whose `changelog.items[]` contains a labels + * change where the trigger label is in `toString` but NOT in + * `fromString` (i.e. it was newly added). Atlassian's label diff is + * delivered as space-separated strings, not arrays, so we tokenize. + */ +function shouldTrigger(payload: JiraIssueEvent, labelFilter: string): boolean { + const filter = labelFilter.toLowerCase(); + const currentLabels = (payload.issue?.fields?.labels ?? []).map((l) => l.toLowerCase()); + const hasLabel = currentLabels.includes(filter); + + if (payload.webhookEvent === 'jira:issue_created') { + return hasLabel; + } + + if (payload.webhookEvent === 'jira:issue_updated') { + if (!hasLabel) return false; + const items = payload.changelog?.items ?? []; + // Match the labels change item. Atlassian uses `field === 'labels'` + // (or sometimes `fieldId === 'labels'`) for the labels system field. + const labelsItem = items.find( + (i) => i?.field === 'labels' || i?.fieldId === 'labels', + ); + if (!labelsItem) return false; + const previous = tokenizeLabelString(labelsItem.fromString); + const next = tokenizeLabelString(labelsItem.toString); + // Trigger only if the label is newly present. + return next.includes(filter) && !previous.includes(filter); + } + + return false; +} + +/** + * Atlassian delivers the labels-field change as a space-separated string + * (e.g. `"bug" → "bug bgagent"`). Tokenize and lowercase for comparison. + * Empty / null inputs return an empty list. + */ +function tokenizeLabelString(value: string | null | undefined): string[] { + if (!value) return []; + return value + .split(/\s+/) + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); +} + +/** + * Translate a `createTaskCore` non-201 response into a user-facing Jira + * comment. Mirrors the Linear-side helper. + */ +function buildCreateTaskFailureMessage(statusCode: number, rawBody: string): string { + let detail = ''; + try { + if (rawBody) { + const parsed = JSON.parse(rawBody) as { error?: { code?: string; message?: string } }; + const message = parsed.error?.message; + if (typeof message === 'string' && message.trim()) { + detail = message.trim(); + } + } + } catch { + // fall through to the generic message + } + + if (statusCode === 400 && detail) { + 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.`; + } + if (detail) { + return `❌ ABCA couldn't create this task (status ${statusCode}): ${detail}`; + } + return `❌ ABCA couldn't create this task (status ${statusCode}). Check the ABCA admin logs for details.`; +} + +function buildTaskDescription(issue: NonNullable): string { + const parts: string[] = []; + const summary = issue.fields?.summary?.trim(); + if (summary) { + parts.push(`${issue.key}: ${summary}`); + } else { + parts.push(issue.key); + } + const description = extractDescriptionMarkdown(issue.fields?.description); + if (description.trim()) { + parts.push(''); + parts.push(description.trim()); + } + return parts.join('\n'); +} + +/** + * Convert a Jira ADF (Atlassian Document Format) document into best-effort + * markdown. Intentionally minimal — extract paragraphs, headings, and + * list items as plain text. Anything else (panels, tables, embeds) is + * collapsed to its textual content. + * + * 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 the issue title + a coherent text rendering of + * the description; richer rendering (tables, mentions, attachments) can + * land in a follow-up. + */ +function extractDescriptionMarkdown(description: unknown): string { + if (!description) return ''; + if (typeof description === 'string') return description; + if (typeof description !== 'object') return ''; + + const lines: string[] = []; + walkAdf(description as AdfNode, lines, 0); + return lines.join('\n').replace(/\n{3,}/g, '\n\n').trim(); +} + +interface AdfNode { + readonly type?: string; + readonly text?: string; + readonly attrs?: { readonly level?: number }; + readonly content?: AdfNode[]; +} + +function walkAdf(node: AdfNode | undefined, out: string[], depth: number): void { + if (!node) return; + switch (node.type) { + case 'doc': + (node.content ?? []).forEach((c) => walkAdf(c, out, depth)); + return; + case 'paragraph': { + const text = (node.content ?? []).map(textOf).join(''); + if (text) { + out.push(text); + out.push(''); + } + return; + } + case 'heading': { + const level = node.attrs?.level ?? 1; + const prefix = '#'.repeat(Math.max(1, Math.min(6, level))); + const text = (node.content ?? []).map(textOf).join(''); + if (text) { + out.push(`${prefix} ${text}`); + out.push(''); + } + return; + } + case 'bulletList': + case 'orderedList': { + (node.content ?? []).forEach((item, idx) => { + const itemText = (item.content ?? []) + .flatMap((sub) => collectInlineLines(sub)) + .join(' ') + .trim(); + if (!itemText) return; + const bullet = node.type === 'orderedList' ? `${idx + 1}.` : '-'; + out.push(`${' '.repeat(depth * 2)}${bullet} ${itemText}`); + }); + out.push(''); + return; + } + case 'codeBlock': { + const text = (node.content ?? []).map(textOf).join(''); + out.push('```'); + out.push(text); + out.push('```'); + out.push(''); + return; + } + case 'text': + if (node.text) out.push(node.text); + return; + default: + // Unknown node — descend into its content if any so embedded text + // (e.g. inside a panel or quote) isn't lost. + (node.content ?? []).forEach((c) => walkAdf(c, out, depth)); + } +} + +function textOf(node: AdfNode): string { + if (node.type === 'text' && node.text) return node.text; + if (node.content) return node.content.map(textOf).join(''); + return ''; +} + +function collectInlineLines(node: AdfNode): string[] { + if (node.type === 'paragraph') { + return [(node.content ?? []).map(textOf).join('')]; + } + if (node.type === 'text' && node.text) { + return [node.text]; + } + return []; +} + +/** + * Extract image URLs from the rendered description markdown. Same limits + * as the Linear processor: HTTPS only, capped at 10. + */ +function extractImageUrlAttachments(description: string | undefined): Attachment[] { + if (!description) return []; + + const imagePattern = /!\[[^\]]*\]\((https:\/\/[^)]+)\)/g; + const attachments: Attachment[] = []; + let match: RegExpExecArray | null; + + while ((match = imagePattern.exec(description)) !== null) { + if (attachments.length >= 10) break; + const url = match[1]; + attachments.push({ type: 'url', url }); + } + + if (attachments.length > 0) { + logger.info('Extracted image URL attachments from Jira issue description', { + count: attachments.length, + }); + } + + return attachments; +} + +async function lookupPlatformUser(cloudId: string, accountId: string): Promise { + const key = `${cloudId}#${accountId}`; + const result = await ddb.send(new GetCommand({ + TableName: USER_MAPPING_TABLE, + Key: { jira_identity: key }, + })); + if (!result.Item || result.Item.status === 'pending') return null; + return (result.Item.platform_user_id as string) ?? null; +} diff --git a/cdk/src/handlers/jira-webhook.ts b/cdk/src/handlers/jira-webhook.ts new file mode 100644 index 00000000..e76379c2 --- /dev/null +++ b/cdk/src/handlers/jira-webhook.ts @@ -0,0 +1,244 @@ +/** + * 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 { ConditionalCheckFailedException, DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; +import { DeleteCommand, DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { + isWebhookTimestampFresh, + verifyJiraRequest, + verifyJiraRequestForTenant, +} from './shared/jira-verify'; +import { logger } from './shared/logger'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const lambdaClient = new LambdaClient({}); + +const WEBHOOK_SECRET_ARN = process.env.JIRA_WEBHOOK_SECRET_ARN!; +const DEDUP_TABLE_NAME = process.env.JIRA_WEBHOOK_DEDUP_TABLE_NAME!; +const PROCESSOR_FUNCTION_NAME = process.env.JIRA_WEBHOOK_PROCESSOR_FUNCTION_NAME!; +/** Optional. When unset, the per-tenant signing-secret path is skipped + * and only the stack-wide secret is consulted (back-compat). */ +const WORKSPACE_REGISTRY_TABLE = process.env.JIRA_WORKSPACE_REGISTRY_TABLE_NAME; + +/** + * Dedup window (seconds). Atlassian retries failed deliveries far less + * aggressively than Linear, but we keep an 8-hour window to cover + * delayed retries on transient outages and clock skew. + */ +const DEDUP_TTL_SECONDS = 8 * 60 * 60; + +/** + * Top-level shape of the Jira webhook envelope we care about for dedup + + * routing. Other fields are forwarded to the processor as part of the raw + * body — the processor parses its own copy. + */ +interface JiraWebhookEnvelope { + readonly webhookEvent?: string; + readonly timestamp?: number; + readonly issue?: { + readonly id?: string; + readonly key?: string; + readonly fields?: { readonly project?: { readonly id?: string; readonly key?: string } }; + }; + /** `cloudId` is delivered as a top-level field on Atlassian Cloud webhooks. */ + readonly matchedWebhookIds?: number[]; + readonly user?: { readonly accountId?: string }; +} + +/** + * Atlassian's webhook payload doesn't always include `cloudId` at the top + * level — older delivery payloads omit it, and self-hosted webhook + * configurations don't carry it. We require it for tenant-scoped + * verification; the receiver passes whatever it can extract through to + * the processor and lets that step report a clear error if absent. + */ +interface JiraEnvelopeWithCloud extends JiraWebhookEnvelope { + readonly cloudId?: string; +} + +/** + * 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 + * the processor Lambda so we can ack quickly. Atlassian sends the + * algorithm prefix (`sha256=…`) — `verifyJiraSignature` strips it before + * comparison. + */ +export async function handler(event: APIGatewayProxyEvent): Promise { + try { + if (!event.body) { + return jsonResponse(400, { error: 'Request body is required' }); + } + + const signature = event.headers['X-Hub-Signature'] ?? event.headers['x-hub-signature'] ?? ''; + if (!signature) { + logger.warn('Jira webhook missing X-Hub-Signature header'); + return jsonResponse(401, { error: 'Missing signature' }); + } + + let payload: JiraEnvelopeWithCloud; + try { + payload = JSON.parse(event.body) as JiraEnvelopeWithCloud; + } catch (err) { + logger.warn('Jira webhook body is not valid JSON', { + error: err instanceof Error ? err.message : String(err), + }); + return jsonResponse(400, { error: 'Invalid JSON' }); + } + + // Per-tenant verification first. Falls through to stack-wide if (a) registry + // table not configured, (b) no cloudId in body, (c) tenant not in registry, + // or (d) tenant's stored secret lacks `webhook_signing_secret`. + // Per-tenant MISMATCH and REVOKED are fatal — no fallback. + let verified = false; + if (WORKSPACE_REGISTRY_TABLE && payload.cloudId) { + const result = await verifyJiraRequestForTenant( + WORKSPACE_REGISTRY_TABLE, + payload.cloudId, + signature, + event.body, + ); + if (result === 'verified') { + verified = true; + } else if (result === 'mismatch') { + logger.warn('Jira webhook signature mismatch against per-tenant secret', { + jira_cloud_id: payload.cloudId, + }); + return jsonResponse(401, { error: 'Invalid signature' }); + } else if (result === 'revoked') { + logger.warn('Jira webhook from revoked tenant — rejecting without stack-wide fallback', { + jira_cloud_id: payload.cloudId, + }); + return jsonResponse(401, { error: 'Tenant not active' }); + } + // 'no-per-tenant-secret' falls through to stack-wide. + } + + if (!verified) { + if (!await verifyJiraRequest(WEBHOOK_SECRET_ARN, signature, event.body)) { + logger.warn('Invalid Jira webhook signature', { + jira_cloud_id: payload.cloudId, + }); + return jsonResponse(401, { error: 'Invalid signature' }); + } + logger.info('Jira webhook verified via stack-wide fallback secret', { + jira_cloud_id: payload.cloudId, + per_tenant_registry_configured: Boolean(WORKSPACE_REGISTRY_TABLE), + }); + } + + // Optional advisory replay window (24h). The dedup table catches the + // common retry case; this guards against very old replays. + if (payload.timestamp !== undefined && !isWebhookTimestampFresh(payload.timestamp)) { + logger.warn('Jira webhook timestamp outside replay window', { + timestamp: payload.timestamp, + }); + return jsonResponse(401, { error: 'Stale webhook timestamp' }); + } + + const webhookEvent = payload.webhookEvent; + if (webhookEvent !== 'jira:issue_created' && webhookEvent !== 'jira:issue_updated') { + // Silent 200 so Atlassian doesn't retry — every non-issue event is acked. + logger.info('Ignoring non-Issue Jira webhook', { webhookEvent }); + return jsonResponse(200, { ok: true }); + } + + const issue = payload.issue; + const issueId = issue?.id; + const issueKey = issue?.key; + if (!issueId || !issueKey) { + logger.warn('Jira Issue webhook missing issue.id or issue.key', { webhookEvent }); + return jsonResponse(400, { error: 'Missing issue identifier' }); + } + + // Dedup via conditional PutItem. + // + // Atlassian doesn't expose a per-delivery message ID we can rely on. The + // payload's top-level `timestamp` (UNIX ms) is set when the event was + // queued and remains stable across retries of the same delivery. + // Composing `${issueKey}#${webhookEvent}#${timestamp}` collapses retries + // (same timestamp) without merging distinct events. + const dedupKey = `${issueKey}#${webhookEvent}#${payload.timestamp ?? 'unknown'}`; + const nowSeconds = Math.floor(Date.now() / 1000); + try { + await ddb.send(new PutCommand({ + TableName: DEDUP_TABLE_NAME, + Item: { + dedup_key: dedupKey, + created_at: new Date().toISOString(), + ttl: nowSeconds + DEDUP_TTL_SECONDS, + }, + ConditionExpression: 'attribute_not_exists(dedup_key)', + })); + } catch (err) { + if (err instanceof ConditionalCheckFailedException) { + logger.info('Jira webhook dedup hit — skipping reprocess', { dedup_key: dedupKey }); + return jsonResponse(200, { ok: true, deduped: true }); + } + throw err; + } + + try { + await lambdaClient.send(new InvokeCommand({ + FunctionName: PROCESSOR_FUNCTION_NAME, + InvocationType: 'Event', + Payload: new TextEncoder().encode(JSON.stringify({ raw_body: event.body })), + })); + } catch (invokeErr) { + logger.error('Failed to invoke Jira webhook processor', { + error: invokeErr instanceof Error ? invokeErr.message : String(invokeErr), + issue_id: issueId, + issue_key: issueKey, + webhookEvent, + }); + // Roll back the dedup row so a future Atlassian retry can dispatch. + // Without this, all retries hit the dedup TTL and silently drop. + try { + await ddb.send(new DeleteCommand({ + TableName: DEDUP_TABLE_NAME, + Key: { dedup_key: dedupKey }, + })); + } catch (cleanupErr) { + logger.warn('Failed to roll back Jira webhook dedup row after invoke failure', { + error: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr), + dedup_key: dedupKey, + }); + } + return jsonResponse(500, { error: 'Dispatch failed' }); + } + + return jsonResponse(200, { ok: true }); + } catch (err) { + logger.error('Jira webhook handler failed', { + error: err instanceof Error ? err.message : String(err), + }); + return jsonResponse(500, { error: 'Internal server error' }); + } +} + +function jsonResponse(statusCode: number, body: Record): APIGatewayProxyResult { + return { + statusCode, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }; +} diff --git a/cdk/src/handlers/shared/jira-feedback.ts b/cdk/src/handlers/shared/jira-feedback.ts new file mode 100644 index 00000000..39ee7372 --- /dev/null +++ b/cdk/src/handlers/shared/jira-feedback.ts @@ -0,0 +1,157 @@ +/** + * 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 { resolveJiraOauthToken } from './jira-oauth-resolver'; +import { logger } from './logger'; + +/** + * Lambda-side helper for posting comments onto Jira Cloud issues via the + * Atlassian REST v3 API. Used by the webhook processor to give users + * feedback on pre-container failures (guardrail block, concurrency cap, + * unmapped project, etc.) — paths where the agent never starts and the + * agent-side Jira MCP cannot run. + * + * Unlike Linear, Jira has no "reaction" primitive. The failure marker + * (❌) is folded into the comment text instead of attached as a separate + * reaction call. + * + * All calls are best-effort. Errors are logged at WARN and swallowed — + * Jira feedback is advisory and must never gate task-rejection logic. + */ + +const REQUEST_TIMEOUT_MS = 5000; + +/** + * Wrap a plain message string in Atlassian Document Format. Jira REST v3 + * comments require ADF, not markdown. We keep this minimal — a single + * paragraph with the raw text — because the messages are short, user- + * facing strings written by the processor (no embedded markdown to + * preserve). + */ +function toAdfDocument(message: string): Record { + return { + type: 'doc', + version: 1, + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: message }], + }, + ], + }; +} + +async function postComment( + accessToken: string, + siteUrl: string, + issueIdOrKey: string, + message: string, +): Promise { + // Strip a trailing slash from siteUrl so the URL stays well-formed + // whether the registry stored it as `https://x.atlassian.net` or + // `https://x.atlassian.net/`. + const base = siteUrl.replace(/\/+$/, ''); + const url = `${base}/rest/api/3/issue/${encodeURIComponent(issueIdOrKey)}/comment`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const resp = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify({ body: toAdfDocument(message) }), + signal: controller.signal, + }); + if (!resp.ok) { + logger.warn('Jira feedback REST non-2xx', { status: resp.status, url }); + return false; + } + return true; + } catch (err) { + logger.warn('Jira feedback request failed', { + error: err instanceof Error ? err.message : String(err), + url, + }); + return false; + } finally { + clearTimeout(timer); + } +} + +/** + * Tenant-scoped feedback context. Resolved once per task by the caller + * (webhook processor / orchestrator) and threaded through to the + * post-comment helper, so the OAuth resolver runs once per task instead + * of once per Jira API call. + */ +export interface JiraFeedbackContext { + /** Atlassian tenant identifier (`cloudId`) — registry key. */ + readonly cloudId: string; + /** Name of JiraWorkspaceRegistryTable, from CDK stack output. */ + readonly registryTableName: string; +} + +async function resolveTenantToken( + ctx: JiraFeedbackContext, +): Promise<{ accessToken: string; siteUrl: string } | null> { + try { + const resolved = await resolveJiraOauthToken(ctx.cloudId, ctx.registryTableName); + if (!resolved) return null; + return { accessToken: resolved.accessToken, siteUrl: resolved.siteUrl }; + } catch (err) { + logger.warn('Jira feedback could not resolve OAuth token', { + jira_cloud_id: ctx.cloudId, + error: err instanceof Error ? err.message : String(err), + }); + return null; + } +} + +/** + * Post a comment onto a Jira issue. Returns true on success, false on any + * failure (network, auth, REST errors). Never throws — callers proceed + * regardless. + */ +export async function postIssueComment( + ctx: JiraFeedbackContext, + issueIdOrKey: string, + body: string, +): Promise { + const resolved = await resolveTenantToken(ctx); + if (!resolved) return false; + return postComment(resolved.accessToken, resolved.siteUrl, issueIdOrKey, body); +} + +/** + * Post a feedback comment with the failure marker (❌) folded into the + * message text. Mirrors `linear-feedback.reportIssueFailure` semantics + * (best-effort, never throws, returns void) so callers don't branch on + * the result. The marker is included in `message` by the caller — this + * helper exists for symmetry with Linear's API surface. + */ +export async function reportIssueFailure( + ctx: JiraFeedbackContext, + issueIdOrKey: string, + message: string, +): Promise { + await Promise.allSettled([postIssueComment(ctx, issueIdOrKey, message)]); +} diff --git a/cdk/src/handlers/shared/jira-oauth-resolver.ts b/cdk/src/handlers/shared/jira-oauth-resolver.ts new file mode 100644 index 00000000..dc35a35a --- /dev/null +++ b/cdk/src/handlers/shared/jira-oauth-resolver.ts @@ -0,0 +1,506 @@ +/** + * 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 { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { + GetSecretValueCommand, + PutSecretValueCommand, + SecretsManagerClient, +} from '@aws-sdk/client-secrets-manager'; +import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb'; +import { logger } from './logger'; + +/** + * Lambda-side resolver for the per-tenant Jira Cloud OAuth token written + * by `bgagent jira setup` (parity with the Linear resolver). + * + * Flow: + * 1. Look up workspace registry by `cloudId` → `oauth_secret_arn`. + * 2. Fetch the secret JSON via Secrets Manager. + * 3. If `expires_at` is within 60s, refresh against Atlassian's + * `/oauth/token` endpoint (with stored `refresh_token`) and write the + * new JSON back to Secrets Manager. + * 4. Return the access token. + * + * Both reads (registry row, secret value) are cached in-memory with a + * short TTL so a hot Lambda doesn't hammer DDB / SM on every invocation. + */ + +const JIRA_TOKEN_ENDPOINT = 'https://auth.atlassian.com/oauth/token'; + +/** Cache TTL for the registry row + secret value lookups, in milliseconds. */ +const REGISTRY_CACHE_TTL_MS = 60_000; +const SECRET_CACHE_TTL_MS = 60_000; + +/** Refresh threshold: refresh tokens with <60s remaining. */ +const REFRESH_THRESHOLD_SECONDS = 60; + +/** Registry row status values. Anything else is treated as `revoked` (fail-closed). */ +type RegistryRowStatus = 'active' | 'revoked'; + +export interface RegistryRow { + readonly jira_cloud_id: string; + readonly site_url: string; + readonly oauth_secret_arn: string; + readonly status: RegistryRowStatus; +} + +export interface StoredOauthToken { + readonly access_token: string; + readonly refresh_token: string; + readonly expires_at: string; + readonly scope: string; + /** Co-located OAuth client credentials so Lambda-side refresh works + * without per-Lambda env vars (parity with the Linear store). */ + readonly client_id: string; + readonly client_secret: string; + readonly cloud_id: string; + readonly site_url: string; + readonly installed_at: string; + readonly updated_at: string; + readonly installed_by_platform_user_id: string; + /** Per-tenant Jira webhook signing secret. + * + * Atlassian's "Generic webhooks" support a per-webhook secret that signs + * events with `X-Hub-Signature: sha256=`. Webhook subscriptions are + * tenant-scoped, so a single stack-wide signing secret cannot verify + * events from multiple tenants. The webhook receiver looks this up by + * `cloudId` at verify time. + * + * Optional for back-compat: tokens written before per-tenant signing + * was wired up won't have it, and the receiver falls back to the + * stack-wide `JIRA_WEBHOOK_SECRET_ARN` for those installs. */ + readonly webhook_signing_secret?: string; +} + +export interface ResolverOptions { + /** AWS region for SDK clients. Falls back to AWS_REGION env. */ + readonly region?: string; + /** Override clients for testing. */ + readonly secretsManagerClient?: SecretsManagerClient; + readonly dynamoDbClient?: DynamoDBDocumentClient; + /** Override fetch for token-endpoint refresh in tests. */ + readonly fetchImpl?: typeof fetch; +} + +interface CacheEntry { + readonly value: T; + readonly expiresAt: number; +} + +const registryCache = new Map>(); +const tokenCache = new Map>(); + +/** + * Drop cached values for a tenant. Used after a refresh so the next caller + * picks up the rotated token. + */ +export function invalidateJiraOauthCache(cloudId: string, oauthSecretArn?: string): void { + registryCache.delete(cloudId); + if (oauthSecretArn) tokenCache.delete(oauthSecretArn); +} + +/** Returns true if `expires_at` is within the refresh threshold. */ +export function isTokenExpiring(expiresAt: string, thresholdSec: number = REFRESH_THRESHOLD_SECONDS): boolean { + const ts = new Date(expiresAt).getTime(); + if (Number.isNaN(ts)) return true; + return Date.now() + thresholdSec * 1000 >= ts; +} + +export interface ResolvedJiraToken { + readonly accessToken: string; + readonly scope: string; + readonly siteUrl: string; + readonly oauthSecretArn: string; +} + +/** + * Resolve a usable Jira Cloud OAuth access token for the given tenant. + * + * On success: returns `{ accessToken, scope, siteUrl, oauthSecretArn }`. + * Refreshes silently if the cached token is expiring. Returns null on any + * failure (registry miss, secret missing, refresh-token revoked) so callers + * can gracefully no-op rather than blowing up. + */ +export async function resolveJiraOauthToken( + cloudId: string, + registryTableName: string, + options: ResolverOptions = {}, +): Promise { + const region = options.region ?? process.env.AWS_REGION ?? 'us-east-1'; + const ddb = options.dynamoDbClient ?? DynamoDBDocumentClient.from(new DynamoDBClient({ region })); + const sm = options.secretsManagerClient ?? new SecretsManagerClient({ region }); + + // ─── Step 1: Registry row ──────────────────────────────────────── + const row = await getRegistryRow(ddb, registryTableName, cloudId); + if (!row) { + logger.warn('Jira tenant not in registry', { jira_cloud_id: cloudId }); + return null; + } + if (row.status !== 'active') { + logger.warn('Jira tenant registry status is not active', { + jira_cloud_id: cloudId, + status: row.status, + }); + return null; + } + + // ─── Step 2: Cached or fresh token JSON ────────────────────────── + const cached = tokenCache.get(row.oauth_secret_arn); + let token: StoredOauthToken; + if (cached && cached.expiresAt > Date.now() && !isTokenExpiring(cached.value.expires_at)) { + token = cached.value; + } else { + const fetched = await getOauthSecret(sm, row.oauth_secret_arn); + if (!fetched) { + logger.error('Jira OAuth secret missing or unreadable', { + oauth_secret_arn: row.oauth_secret_arn, + jira_cloud_id: cloudId, + }); + return null; + } + token = fetched; + } + + // ─── Step 3: Refresh if expiring ───────────────────────────────── + if (isTokenExpiring(token.expires_at)) { + const refreshed = await refreshJiraToken(token, sm, row.oauth_secret_arn, options); + if (!refreshed) { + return null; + } + token = refreshed; + } else { + tokenCache.set(row.oauth_secret_arn, { value: token, expiresAt: Date.now() + SECRET_CACHE_TTL_MS }); + } + + return { + accessToken: token.access_token, + scope: token.scope, + siteUrl: token.site_url, + oauthSecretArn: row.oauth_secret_arn, + }; +} + +/** + * Strict variant of {@link getRegistryRow}: throws on infra error + * (DDB throttle, network) instead of returning null. Use this from the + * webhook signature-verification path where a `null` return would let + * a transient throttle silently downgrade per-tenant verification to + * the stack-wide fallback secret. + */ +export async function getRegistryRowStrict( + ddb: DynamoDBDocumentClient, + tableName: string, + cloudId: string, +): Promise { + const cached = registryCache.get(cloudId); + if (cached && cached.expiresAt > Date.now()) return cached.value; + + const result = await ddb.send(new GetCommand({ + TableName: tableName, + Key: { jira_cloud_id: cloudId }, + })); + return parseRegistryRow(result.Item, cloudId); +} + +export async function getRegistryRow( + ddb: DynamoDBDocumentClient, + tableName: string, + cloudId: string, +): Promise { + const cached = registryCache.get(cloudId); + if (cached && cached.expiresAt > Date.now()) return cached.value; + + let result; + try { + result = await ddb.send(new GetCommand({ + TableName: tableName, + Key: { jira_cloud_id: cloudId }, + })); + } catch (err) { + logger.error('Failed to fetch Jira workspace registry row', { + table_name: tableName, + jira_cloud_id: cloudId, + error: err instanceof Error ? err.message : String(err), + }); + return null; + } + + return parseRegistryRow(result.Item, cloudId); +} + +function parseRegistryRow(rawItem: unknown, cloudId: string): RegistryRow | null { + const item = rawItem as Partial | undefined; + if (!item || !item.oauth_secret_arn || !item.site_url) return null; + + // Fail-closed on the status field: missing or unknown values are treated + // as `revoked`. A partially-written row shouldn't grant access. + const rawStatus = item.status as string | undefined; + const status: RegistryRowStatus = rawStatus === 'active' ? 'active' : 'revoked'; + if (rawStatus !== 'active' && rawStatus !== 'revoked' && rawStatus !== undefined) { + logger.warn('Jira workspace registry row has unknown status — treating as revoked', { + jira_cloud_id: cloudId, + raw_status: rawStatus, + }); + } + + const row: RegistryRow = { + jira_cloud_id: cloudId, + site_url: item.site_url, + oauth_secret_arn: item.oauth_secret_arn, + status, + }; + registryCache.set(cloudId, { value: row, expiresAt: Date.now() + REGISTRY_CACHE_TTL_MS }); + return row; +} + +const STORED_OAUTH_TOKEN_REQUIRED_FIELDS: ReadonlyArray = [ + 'access_token', + 'refresh_token', + 'expires_at', + 'scope', + 'client_id', + 'client_secret', + 'cloud_id', + 'site_url', + 'installed_at', + 'updated_at', + 'installed_by_platform_user_id', +]; + +export async function getOauthSecret( + sm: SecretsManagerClient, + secretArn: string, +): Promise { + try { + const res = await sm.send(new GetSecretValueCommand({ SecretId: secretArn })); + if (!res.SecretString) return null; + return parseOauthSecret(res.SecretString, secretArn); + } catch (err) { + logger.error('Failed to fetch Jira OAuth secret', { + secret_arn: secretArn, + error: err instanceof Error ? err.message : String(err), + }); + return null; + } +} + +/** + * Strict variant of {@link getOauthSecret}: throws on Secrets Manager + * error instead of returning null. Use this from the signature-verification + * path so a transient SM error doesn't silently fall back to stack-wide. + */ +export async function getOauthSecretStrict( + sm: SecretsManagerClient, + secretArn: string, +): Promise { + const res = await sm.send(new GetSecretValueCommand({ SecretId: secretArn })); + if (!res.SecretString) return null; + return parseOauthSecret(res.SecretString, secretArn); +} + +function parseOauthSecret(secretString: string, secretArn: string): StoredOauthToken | null { + let parsed: StoredOauthToken; + try { + parsed = JSON.parse(secretString) as StoredOauthToken; + } catch (err) { + logger.error('Jira OAuth secret value is not valid JSON', { + secret_arn: secretArn, + error: err instanceof Error ? err.message : String(err), + }); + return null; + } + const missing = STORED_OAUTH_TOKEN_REQUIRED_FIELDS.filter( + (f) => typeof parsed[f] !== 'string' || (parsed[f] as string).length === 0, + ); + if (missing.length > 0) { + logger.error('Jira OAuth secret JSON is missing required fields', { + secret_arn: secretArn, + missing_fields: missing, + }); + return null; + } + return parsed; +} + +type RefreshOutcome = + | { kind: 'success'; token: StoredOauthToken } + | { kind: 'invalid_grant' } + | { kind: 'failure' }; + +async function refreshJiraToken( + current: StoredOauthToken, + sm: SecretsManagerClient, + secretArn: string, + options: ResolverOptions, +): Promise { + const first = await tryRefreshOnce(current, sm, secretArn, options); + if (first.kind === 'success') return first.token; + if (first.kind === 'failure') return null; + + // `invalid_grant`: Atlassian rotates refresh_tokens on every use, so a + // concurrent Lambda may have refreshed before us. Re-read the secret + // and retry once if the refresh_token changed. + logger.warn('Jira token refresh got invalid_grant — re-reading secret to check for concurrent refresh', { + secret_arn: secretArn, + cloud_id: current.cloud_id, + }); + + const fresh = await getOauthSecret(sm, secretArn); + if (!fresh) { + invalidateJiraOauthCache(current.cloud_id, secretArn); + return null; + } + if (fresh.refresh_token === current.refresh_token) { + logger.error('Jira token refresh permanently rejected — tenant requires re-onboarding', { + secret_arn: secretArn, + cloud_id: current.cloud_id, + }); + invalidateJiraOauthCache(current.cloud_id, secretArn); + return null; + } + + if (!isTokenExpiring(fresh.expires_at)) { + logger.info('Jira OAuth token was refreshed by a concurrent caller; using freshly-read value', { + secret_arn: secretArn, + cloud_id: fresh.cloud_id, + new_expires_at: fresh.expires_at, + }); + tokenCache.set(secretArn, { value: fresh, expiresAt: Date.now() + SECRET_CACHE_TTL_MS }); + return fresh; + } + + const second = await tryRefreshOnce(fresh, sm, secretArn, options); + if (second.kind === 'success') return second.token; + if (second.kind === 'invalid_grant') { + logger.error('Jira token refresh failed even after re-reading freshly-rotated secret', { + secret_arn: secretArn, + cloud_id: fresh.cloud_id, + }); + } + invalidateJiraOauthCache(current.cloud_id, secretArn); + return null; +} + +async function tryRefreshOnce( + current: StoredOauthToken, + sm: SecretsManagerClient, + secretArn: string, + options: ResolverOptions, +): Promise { + if (!current.client_id || !current.client_secret) { + logger.error('Cannot refresh Jira OAuth token: stored secret is missing client_id/client_secret', { + secret_arn: secretArn, + }); + return { kind: 'failure' }; + } + + const fetchImpl = options.fetchImpl ?? fetch; + const body = JSON.stringify({ + grant_type: 'refresh_token', + client_id: current.client_id, + client_secret: current.client_secret, + refresh_token: current.refresh_token, + }); + + let resp: Response; + try { + resp = await fetchImpl(JIRA_TOKEN_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }); + } catch (err) { + logger.error('Jira token refresh fetch failed', { + error: err instanceof Error ? err.message : String(err), + }); + invalidateJiraOauthCache(current.cloud_id, secretArn); + return { kind: 'failure' }; + } + + let parsed: unknown; + try { + parsed = await resp.json(); + } catch { + logger.error('Jira token refresh returned non-JSON', { status: resp.status }); + return { kind: 'failure' }; + } + + if (!resp.ok) { + const errObj = parsed as { error?: string; error_description?: string }; + logger.error('Jira token refresh rejected', { + status: resp.status, + error: errObj.error, + error_description: errObj.error_description, + }); + invalidateJiraOauthCache(current.cloud_id, secretArn); + if (errObj.error === 'invalid_grant') { + return { kind: 'invalid_grant' }; + } + return { kind: 'failure' }; + } + + const tokenResp = parsed as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + scope?: string; + }; + if (!tokenResp.access_token || !tokenResp.expires_in) { + logger.error('Jira token refresh response missing required fields'); + return { kind: 'failure' }; + } + + const now = new Date(); + const next: StoredOauthToken = { + ...current, + access_token: tokenResp.access_token, + refresh_token: tokenResp.refresh_token ?? current.refresh_token, + expires_at: new Date(now.getTime() + tokenResp.expires_in * 1000).toISOString(), + scope: tokenResp.scope ?? current.scope, + updated_at: now.toISOString(), + }; + + try { + await sm.send(new PutSecretValueCommand({ + SecretId: secretArn, + SecretString: JSON.stringify(next), + })); + } catch (err) { + logger.error('Failed to persist refreshed Jira OAuth token', { + secret_arn: secretArn, + error: err instanceof Error ? err.message : String(err), + }); + } + + logger.info('Jira OAuth token refreshed', { + cloud_id: next.cloud_id, + site_url: next.site_url, + new_expires_at: next.expires_at, + }); + + tokenCache.set(secretArn, { value: next, expiresAt: Date.now() + SECRET_CACHE_TTL_MS }); + return { kind: 'success', token: next }; +} + +/** Test-only: clear all caches. */ +export function _resetCachesForTesting(): void { + registryCache.clear(); + tokenCache.clear(); +} diff --git a/cdk/src/handlers/shared/jira-verify.ts b/cdk/src/handlers/shared/jira-verify.ts new file mode 100644 index 00000000..22df2e71 --- /dev/null +++ b/cdk/src/handlers/shared/jira-verify.ts @@ -0,0 +1,192 @@ +/** + * 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 * as crypto from 'crypto'; +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; +import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import { getOauthSecretStrict, getRegistryRowStrict } from './jira-oauth-resolver'; +import { logger } from './logger'; + +const sm = new SecretsManagerClient({}); +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); + +/** Prefix for Jira-related secrets in Secrets Manager. */ +export const JIRA_SECRET_PREFIX = 'bgagent/jira/'; + +const secretCache = new Map(); +const CACHE_TTL_MS = 5 * 60 * 1000; + +/** + * Maximum age of a Jira webhook event timestamp (ms) before it is rejected. + * + * Atlassian's webhook payloads include a top-level `timestamp` field (UNIX ms, + * the moment the event was queued for delivery). Unlike Linear, Atlassian + * doesn't sign over a timestamp header, so the value is only meaningful as an + * advisory check after signature verification has already passed. We still + * enforce it to bound replay windows for delivery jobs that get stuck and + * surface much later — the dedup table handles the more likely retry case. + */ +export const MAX_WEBHOOK_EVENT_AGE_MS = 24 * 60 * 60 * 1000; + +/** + * Fetch a secret from Secrets Manager with in-memory caching. + */ +export async function getJiraSecret(secretId: string, forceRefresh = false): Promise { + const now = Date.now(); + if (!forceRefresh) { + const cached = secretCache.get(secretId); + if (cached && cached.expiresAt > now) { + return cached.secret; + } + } + + try { + const result = await sm.send(new GetSecretValueCommand({ SecretId: secretId })); + if (!result.SecretString) { + secretCache.delete(secretId); + return null; + } + secretCache.set(secretId, { secret: result.SecretString, expiresAt: now + CACHE_TTL_MS }); + return result.SecretString; + } catch (err) { + const errorName = (err as Error)?.name; + if (errorName === 'ResourceNotFoundException') { + logger.error('Jira secret not found in Secrets Manager', { secret_id: secretId }); + secretCache.delete(secretId); + return null; + } + logger.error('Failed to fetch Jira secret from Secrets Manager', { + secret_id: secretId, + error: err instanceof Error ? err.message : String(err), + }); + throw err; + } +} + +export function invalidateJiraSecretCache(secretId: string): void { + secretCache.delete(secretId); +} + +/** + * Verify a Jira generic-webhook signature. + * + * Atlassian's "Generic webhooks" (configured per-instance in the Jira admin UI) + * sign each delivery with HMAC-SHA256 over the raw request body using the + * instance-configured secret. The signature is delivered as + * `X-Hub-Signature: sha256=` — the `sha256=` prefix is part of the header + * value and must be stripped before timing-safe comparison. + */ +export function verifyJiraSignature( + webhookSecret: string, + signature: string, + body: string, +): boolean { + // Strip the algorithm prefix Atlassian (and most webhook providers using + // X-Hub-Signature) prepend. Be tolerant of operators who paste just the + // hex digest. + const provided = signature.startsWith('sha256=') ? signature.slice('sha256='.length) : signature; + const expected = crypto.createHmac('sha256', webhookSecret).update(body).digest('hex'); + try { + return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided)); + } catch (err) { + logger.warn('Jira signature comparison failed', { + error: err instanceof Error ? err.message : String(err), + expected_length: expected.length, + provided_length: provided.length, + }); + return false; + } +} + +/** + * Check that a Jira webhook event timestamp is within the acceptable window. + * Optional — the receiver only enforces this after signature verification + * succeeds, as a guard against very old replays. + */ +export function isWebhookTimestampFresh(timestamp: number | undefined): boolean { + if (typeof timestamp !== 'number' || !isFinite(timestamp)) { + return false; + } + const age = Math.abs(Date.now() - timestamp); + return age <= MAX_WEBHOOK_EVENT_AGE_MS; +} + +/** + * Verify a Jira webhook request, transparently re-fetching the signing + * secret once if the cached copy is rejected. Mirrors the Linear helper so + * a rotated secret picks up within one webhook delivery rather than 5 min + * of cache TTL. + */ +export async function verifyJiraRequest( + secretId: string, + signature: string, + body: string, +): Promise { + const cached = await getJiraSecret(secretId); + if (cached && verifyJiraSignature(cached, signature, body)) { + return true; + } + + invalidateJiraSecretCache(secretId); + const fresh = await getJiraSecret(secretId, true); + if (!fresh) return false; + if (fresh === cached) return false; + return verifyJiraSignature(fresh, signature, body); +} + +/** + * Verify a Jira webhook against the per-tenant signing secret stored + * alongside the tenant's OAuth bundle. The trust model and outcome + * semantics mirror the Linear per-workspace flow: + * + * - `'verified'` — signature matches the per-tenant secret. + * - `'mismatch'` — registry row + secret found, signature wrong. Reject; + * do NOT fall back to stack-wide. + * - `'revoked'` — registry row exists but status is not `active`. + * Reject; do NOT fall back. + * - `'no-per-tenant-secret'` — no registry row, OR the stored secret + * has no `webhook_signing_secret`. Caller should fall back to the + * stack-wide secret for back-compat with single-tenant installs. + * + * Strict lookups (throw on infra errors) are used so a transient DDB or + * SM error doesn't silently downgrade a per-tenant-secured tenant to + * stack-wide verification. + */ +export async function verifyJiraRequestForTenant( + registryTableName: string, + cloudId: string, + signature: string, + body: string, +): Promise<'verified' | 'mismatch' | 'revoked' | 'no-per-tenant-secret'> { + const row = await getRegistryRowStrict(ddb, registryTableName, cloudId); + if (!row) { + return 'no-per-tenant-secret'; + } + if (row.status !== 'active') { + return 'revoked'; + } + const stored = await getOauthSecretStrict(sm, row.oauth_secret_arn); + if (!stored || !stored.webhook_signing_secret) { + return 'no-per-tenant-secret'; + } + return verifyJiraSignature(stored.webhook_signing_secret, signature, body) + ? 'verified' + : 'mismatch'; +} From 27c2cbde8d156af98a6fb29162f232f751475d7a Mon Sep 17 00:00:00 2001 From: bgagent Date: Mon, 8 Jun 2026 14:44:52 -0400 Subject: [PATCH 05/20] feat(jira): add JiraIntegration construct + stack wiring Phase 4 of Jira Cloud integration (#288). Mirrors LinearIntegration: 3 DDB tables, dedup table (8h TTL), 3 Lambdas (webhook/processor/link), API routes under /jira/*, per-tenant `bgagent-jira-oauth-*` IAM grants, cdk-nag suppressions. Stack wiring grants the agent runtime GetSecretValue on the per-tenant prefix and pipes the workspace registry table + Get/Put grant into the orchestrator (matches Linear's path for pre-container failure feedback). Synth confirms clean CloudFormation + no nag findings. --- cdk/src/constructs/jira-integration.ts | 359 +++++++++++++++++++++++++ cdk/src/stacks/agent.ts | 80 ++++++ 2 files changed, 439 insertions(+) create mode 100644 cdk/src/constructs/jira-integration.ts diff --git a/cdk/src/constructs/jira-integration.ts b/cdk/src/constructs/jira-integration.ts new file mode 100644 index 00000000..aac44560 --- /dev/null +++ b/cdk/src/constructs/jira-integration.ts @@ -0,0 +1,359 @@ +/** + * 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 * as path from 'path'; +import { ArnFormat, Duration, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import * as apigw from 'aws-cdk-lib/aws-apigateway'; +import * as cognito from 'aws-cdk-lib/aws-cognito'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import { Runtime, Architecture } from 'aws-cdk-lib/aws-lambda'; +import * as lambda from 'aws-cdk-lib/aws-lambda-nodejs'; +import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; +import { NagSuppressions } from 'cdk-nag'; +import { Construct } from 'constructs'; +import { JiraProjectMappingTable } from './jira-project-mapping-table'; +import { JiraUserMappingTable } from './jira-user-mapping-table'; +import { JiraWorkspaceRegistryTable } from './jira-workspace-registry-table'; + +/** + * Properties for JiraIntegration construct. + */ +export interface JiraIntegrationProps { + /** The existing REST API to add Jira routes to. */ + readonly api: apigw.RestApi; + + /** Cognito user pool for the /jira/link endpoint (Cognito-authenticated). */ + readonly userPool: cognito.IUserPool; + + /** The DynamoDB task table. */ + readonly taskTable: dynamodb.ITable; + + /** The DynamoDB task events table. */ + readonly taskEventsTable: dynamodb.ITable; + + /** The DynamoDB repo config table (optional — for repo onboarding checks). */ + readonly repoTable?: dynamodb.ITable; + + /** Orchestrator Lambda function ARN for async task invocation. */ + readonly orchestratorFunctionArn?: string; + + /** Bedrock Guardrail ID for input screening. */ + readonly guardrailId?: string; + + /** Bedrock Guardrail version for input screening. */ + readonly guardrailVersion?: string; + + /** Task retention in days for TTL computation. */ + readonly taskRetentionDays?: number; + + /** Removal policy for Jira DynamoDB tables. */ + readonly removalPolicy?: RemovalPolicy; +} + +/** + * CDK construct that adds Jira Cloud integration to the ABCA platform. + * + * Inbound-only adapter: Jira → webhook → task creation. Outbound progress + * updates happen agent-side via the Atlassian Remote MCP server (see + * agent/src/channel_mcp.py), so there is NO DynamoDB Streams consumer + * and NO outbound-notify Lambda here. Mirrors the Linear adapter shape. + * + * Creates: + * - JiraProjectMappingTable (`{cloudId}#{projectKey}` → GitHub repo) + * - JiraUserMappingTable (`{cloudId}#{accountId}` → platform user; with + * GSI for reverse lookup and `pending#{code}` link rows) + * - JiraWorkspaceRegistryTable (`cloudId` → AgentCore credential provider). + * Webhook receiver and processor look up the per-tenant signing/OAuth + * secret here from the inbound webhook's `cloudId`. + * - JiraWebhookDedupTable (8h TTL dedup for webhook retries) + * - Lambda handlers for the webhook receiver, async processor, and account linking + * - API Gateway routes under /jira/* + * - Webhook signing-secret placeholder (populated by `bgagent jira setup`) + */ +export class JiraIntegration extends Construct { + /** Jira `{cloudId}#{projectKey}` → repo mapping table. */ + public readonly projectMappingTable: dynamodb.Table; + + /** Jira `{cloudId}#{accountId}` → platform user mapping table. */ + public readonly userMappingTable: dynamodb.Table; + + /** + * Registry of Jira tenants that have completed OAuth onboarding. + * Lookup `provider_name` (AgentCore credential provider) by `cloudId` + * from the inbound webhook. + */ + public readonly workspaceRegistryTable: dynamodb.Table; + + /** Webhook dedup table — `{issueKey}#{webhookEvent}#{timestamp}` keys with 8h TTL. */ + public readonly webhookDedupTable: dynamodb.Table; + + /** Jira webhook signing secret (placeholder — populated by `bgagent jira setup`). */ + public readonly webhookSecret: secretsmanager.Secret; + + constructor(scope: Construct, id: string, props: JiraIntegrationProps) { + super(scope, id); + + const removalPolicy = props.removalPolicy ?? RemovalPolicy.DESTROY; + + // --- DynamoDB tables --- + const projectMapping = new JiraProjectMappingTable(this, 'ProjectMappingTable', { removalPolicy }); + const userMapping = new JiraUserMappingTable(this, 'UserMappingTable', { removalPolicy }); + const workspaceRegistry = new JiraWorkspaceRegistryTable(this, 'WorkspaceRegistryTable', { removalPolicy }); + this.projectMappingTable = projectMapping.table; + this.userMappingTable = userMapping.table; + 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}`. + this.webhookDedupTable = new dynamodb.Table(this, 'WebhookDedupTable', { + partitionKey: { name: 'dedup_key', type: dynamodb.AttributeType.STRING }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + timeToLiveAttribute: 'ttl', + pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true }, + removalPolicy, + }); + + // --- Webhook signing secret (placeholder, populated by `bgagent jira setup`) --- + // Per-tenant OAuth tokens live in `bgagent-jira-oauth-` secrets + // created by the CLI at runtime — not here. This stack-wide secret is + // a back-compat fallback for single-tenant installs predating per- + // tenant signing. + this.webhookSecret = new secretsmanager.Secret(this, 'WebhookSecret', { + description: 'Jira webhook signing secret — populate via `bgagent jira setup`', + removalPolicy, + }); + + // --- Shared Lambda configuration --- + const handlersDir = path.join(__dirname, '..', 'handlers'); + const commonBundling: lambda.BundlingOptions = { + externalModules: ['@aws-sdk/*'], + }; + + // --- Task creation environment (matches LinearIntegration / SlackIntegration pattern) --- + const createTaskEnv: Record = { + TASK_TABLE_NAME: props.taskTable.tableName, + TASK_EVENTS_TABLE_NAME: props.taskEventsTable.tableName, + TASK_RETENTION_DAYS: String(props.taskRetentionDays ?? 90), + }; + if (props.repoTable) { + createTaskEnv.REPO_TABLE_NAME = props.repoTable.tableName; + } + if (props.orchestratorFunctionArn) { + createTaskEnv.ORCHESTRATOR_FUNCTION_ARN = props.orchestratorFunctionArn; + } + if (props.guardrailId && props.guardrailVersion) { + createTaskEnv.GUARDRAIL_ID = props.guardrailId; + createTaskEnv.GUARDRAIL_VERSION = props.guardrailVersion; + } + + // --- Cognito Authorizer (for /jira/link) --- + const cognitoAuthorizer = new apigw.CognitoUserPoolsAuthorizer(this, 'JiraCognitoAuthorizer', { + cognitoUserPools: [props.userPool], + }); + const cognitoAuthOptions: apigw.MethodOptions = { + authorizer: cognitoAuthorizer, + authorizationType: apigw.AuthorizationType.COGNITO, + }; + const noneAuthOptions: apigw.MethodOptions = { + authorizationType: apigw.AuthorizationType.NONE, + }; + + // ═══════════════════════════════════════════════════════════════════════════ + // Lambda Handlers + // ═══════════════════════════════════════════════════════════════════════════ + + // --- Webhook processor (async, invoked by receiver) --- + const webhookProcessorFn = new lambda.NodejsFunction(this, 'WebhookProcessorFn', { + entry: path.join(handlersDir, 'jira-webhook-processor.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + timeout: Duration.seconds(30), + // 512 MB matches the Linear processor — same attachment-screening + // path bundles the same pdf-parse + URL-resolver libs alongside the + // SDK, and Atlassian's ADF→markdown walker adds a small additional + // working set. Keeps p99 cold-start under the API Gateway 30s deadline. + memorySize: 512, + environment: { + ...createTaskEnv, + JIRA_PROJECT_MAPPING_TABLE_NAME: this.projectMappingTable.tableName, + JIRA_USER_MAPPING_TABLE_NAME: this.userMappingTable.tableName, + JIRA_WORKSPACE_REGISTRY_TABLE_NAME: this.workspaceRegistryTable.tableName, + }, + bundling: commonBundling, + }); + this.projectMappingTable.grantReadData(webhookProcessorFn); + this.userMappingTable.grantReadData(webhookProcessorFn); + this.workspaceRegistryTable.grantReadData(webhookProcessorFn); + // Per-tenant OAuth token secrets are created by the CLI at setup time + // (`bgagent-jira-oauth-`), not by CDK. Grant the processor + // Get + Put on the prefix so it can read tokens and write back rotated + // refresh-token JSON during expiring-token refresh. + webhookProcessorFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['secretsmanager:GetSecretValue', 'secretsmanager:PutSecretValue'], + resources: [ + Stack.of(this).formatArn({ + service: 'secretsmanager', + resource: 'secret', + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + resourceName: 'bgagent-jira-oauth-*', + }), + ], + })); + props.taskTable.grantReadWriteData(webhookProcessorFn); + props.taskEventsTable.grantReadWriteData(webhookProcessorFn); + if (props.repoTable) { + props.repoTable.grantReadData(webhookProcessorFn); + } + if (props.orchestratorFunctionArn) { + webhookProcessorFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['lambda:InvokeFunction'], + resources: [props.orchestratorFunctionArn], + })); + } + if (props.guardrailId) { + webhookProcessorFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['bedrock:ApplyGuardrail'], + resources: [ + Stack.of(this).formatArn({ + service: 'bedrock', + resource: 'guardrail', + resourceName: props.guardrailId, + }), + ], + })); + } + + // --- Webhook receiver (verifies HMAC, dedups, invokes processor) --- + const webhookFn = new lambda.NodejsFunction(this, 'WebhookFn', { + entry: path.join(handlersDir, 'jira-webhook.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + timeout: Duration.seconds(10), + environment: { + JIRA_WEBHOOK_SECRET_ARN: this.webhookSecret.secretArn, + JIRA_WEBHOOK_DEDUP_TABLE_NAME: this.webhookDedupTable.tableName, + JIRA_WEBHOOK_PROCESSOR_FUNCTION_NAME: webhookProcessorFn.functionName, + // Per-tenant signing-secret lookup — selects the right tenant's + // `webhook_signing_secret` from the OAuth secret bundle so multi- + // tenant installs verify correctly. Receiver falls back to + // JIRA_WEBHOOK_SECRET_ARN when this lookup misses (back-compat for + // single-tenant installs). + JIRA_WORKSPACE_REGISTRY_TABLE_NAME: this.workspaceRegistryTable.tableName, + }, + bundling: commonBundling, + }); + this.webhookSecret.grantRead(webhookFn); + this.webhookDedupTable.grantReadWriteData(webhookFn); + this.workspaceRegistryTable.grantReadData(webhookFn); + // Read-only on the per-tenant OAuth secret prefix — we extract + // `webhook_signing_secret` for verification but never mutate; the + // CLI owns the lifecycle of these secrets. + webhookFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['secretsmanager:GetSecretValue'], + resources: [ + Stack.of(this).formatArn({ + service: 'secretsmanager', + resource: 'secret', + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + resourceName: 'bgagent-jira-oauth-*', + }), + ], + })); + webhookProcessorFn.grantInvoke(webhookFn); + + // --- Account linking (Cognito-authenticated) --- + const linkFn = new lambda.NodejsFunction(this, 'LinkFn', { + entry: path.join(handlersDir, 'jira-link.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + timeout: Duration.seconds(10), + environment: { + JIRA_USER_MAPPING_TABLE_NAME: this.userMappingTable.tableName, + }, + bundling: commonBundling, + }); + this.userMappingTable.grantReadWriteData(linkFn); + + // ═══════════════════════════════════════════════════════════════════════════ + // API Gateway Routes + // ═══════════════════════════════════════════════════════════════════════════ + + const jira = props.api.root.addResource('jira'); + + // POST /v1/jira/webhook — HMAC-verified; no Cognito. + const webhookResource = jira.addResource('webhook'); + const webhookMethod = webhookResource.addMethod( + 'POST', + new apigw.LambdaIntegration(webhookFn), + noneAuthOptions, + ); + + // POST /v1/jira/link — Cognito-authenticated. + const linkResource = jira.addResource('link'); + linkResource.addMethod( + 'POST', + new apigw.LambdaIntegration(linkFn), + cognitoAuthOptions, + ); + + // ═══════════════════════════════════════════════════════════════════════════ + // cdk-nag suppressions + // ═══════════════════════════════════════════════════════════════════════════ + + NagSuppressions.addResourceSuppressions(webhookMethod, [ + { + id: 'AwsSolutions-APIG4', + reason: 'Jira webhook endpoint uses X-Hub-Signature HMAC verification instead of Cognito — by design for Jira webhook integration', + }, + { + id: 'AwsSolutions-COG4', + reason: 'Jira webhook endpoint uses X-Hub-Signature HMAC verification instead of Cognito — by design for Jira webhook integration', + }, + ]); + + NagSuppressions.addResourceSuppressions(this.webhookSecret, [ + { + id: 'AwsSolutions-SMG4', + reason: 'Jira webhook signing secret is managed externally (Atlassian admin UI) — automatic rotation is not applicable', + }, + ]); + + const allFunctions = [webhookFn, webhookProcessorFn, linkFn]; + for (const fn of allFunctions) { + NagSuppressions.addResourceSuppressions(fn, [ + { + id: 'AwsSolutions-IAM4', + reason: 'AWSLambdaBasicExecutionRole is the AWS-recommended managed policy for Lambda functions', + }, + { + id: 'AwsSolutions-IAM5', + reason: + 'Wildcards cover (a) DynamoDB index ARN patterns from CDK grant helpers, ' + + 'and (b) the Secrets Manager `bgagent-jira-oauth-*` prefix grant — ' + + 'the per-tenant OAuth secret name is not known at synth time ' + + '(operators add tenants by cloudId at runtime via `bgagent jira setup`).', + }, + ], true); + } + } +} diff --git a/cdk/src/stacks/agent.ts b/cdk/src/stacks/agent.ts index 9ab63dd6..871b3384 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -42,6 +42,7 @@ import { ConcurrencyReconciler } from '../constructs/concurrency-reconciler'; import { DnsFirewall } from '../constructs/dns-firewall'; // import { EcsAgentCluster } from '../constructs/ecs-agent-cluster'; import { FanOutConsumer } from '../constructs/fanout-consumer'; +import { JiraIntegration } from '../constructs/jira-integration'; import { LinearIntegration } from '../constructs/linear-integration'; import { PendingUploadCleanup } from '../constructs/pending-upload-cleanup'; import { RepoTable } from '../constructs/repo-table'; @@ -834,6 +835,85 @@ export class AgentStack extends Stack { description: 'Name of the DynamoDB Linear workspace registry — `bgagent linear setup` writes a row per OAuth-installed workspace', }); + // --- Jira Cloud integration (inbound webhook + agent-side MCP outbound) --- + const jiraIntegration = new JiraIntegration(this, 'JiraIntegration', { + api: taskApi.api, + userPool: taskApi.userPool, + taskTable: taskTable.table, + taskEventsTable: taskEventsTable.table, + repoTable: repoTable.table, + orchestratorFunctionArn: orchestrator.alias.functionArn, + guardrailId: inputGuardrail.guardrailId, + guardrailVersion: inputGuardrail.guardrailVersion, + }); + + // Agent runtime reads the per-tenant Jira OAuth token directly from + // Secrets Manager. The CLI (`bgagent jira setup`) creates + // `bgagent-jira-oauth-` secrets at install time; the secret + // JSON contains access_token, refresh_token, expires_at, and the + // OAuth client_id/client_secret. The orchestrator passes + // `jira_oauth_secret_arn` to the agent via task.channel_metadata, + // so the agent looks up the exact ARN — no discovery needed. + // + // Agent has GetSecretValue ONLY — no Put. Same trust model as the + // Linear adapter: a compromised agent must not be able to overwrite + // any tenant's OAuth bundle. Lambdas (trusted code in this stack) + // own the in-place refresh path; the agent proceeds with whatever + // token Lambdas have most-recently written. + runtime.role.addToPrincipalPolicy(new iam.PolicyStatement({ + actions: ['secretsmanager:GetSecretValue'], + resources: [ + Stack.of(this).formatArn({ + service: 'secretsmanager', + resource: 'secret', + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + resourceName: 'bgagent-jira-oauth-*', + }), + ], + })); + + // Pipe the workspace registry table + per-tenant OAuth-secret-prefix + // grant into the orchestrator so the concurrency-cap rejection path + // can post a Jira comment. The orchestrator only resolves a token + // when `task.channel_source === 'jira'`, but the IAM grant is + // unconditional (per-tenant secrets are created lazily by setup). + jiraIntegration.workspaceRegistryTable.grantReadData(orchestrator.fn); + orchestrator.fn.addEnvironment( + 'JIRA_WORKSPACE_REGISTRY_TABLE_NAME', + jiraIntegration.workspaceRegistryTable.tableName, + ); + orchestrator.fn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['secretsmanager:GetSecretValue', 'secretsmanager:PutSecretValue'], + resources: [ + Stack.of(this).formatArn({ + service: 'secretsmanager', + resource: 'secret', + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + resourceName: 'bgagent-jira-oauth-*', + }), + ], + })); + + new CfnOutput(this, 'JiraWebhookSecretArn', { + value: jiraIntegration.webhookSecret.secretArn, + description: 'Secrets Manager ARN for the Jira webhook signing secret — populate via `bgagent jira setup`', + }); + + new CfnOutput(this, 'JiraProjectMappingTableName', { + value: jiraIntegration.projectMappingTable.tableName, + description: 'Name of the DynamoDB Jira project → repo mapping table', + }); + + new CfnOutput(this, 'JiraUserMappingTableName', { + value: jiraIntegration.userMappingTable.tableName, + description: 'Name of the DynamoDB Jira user mapping table', + }); + + new CfnOutput(this, 'JiraWorkspaceRegistryTableName', { + value: jiraIntegration.workspaceRegistryTable.tableName, + description: 'Name of the DynamoDB Jira workspace registry — `bgagent jira setup` writes a row per OAuth-installed tenant', + }); + // --- Bedrock model invocation logging (account-level) --- const invocationLogGroup = new logs.LogGroup(this, 'ModelInvocationLogGroup', { logGroupName: `/aws/bedrock/model-invocation-logs/${this.stackName}`, From 7bb096933cf25cf1b389fa450f063a435aac1da6 Mon Sep 17 00:00:00 2001 From: bgagent Date: Mon, 8 Jun 2026 14:55:10 -0400 Subject: [PATCH 06/20] feat(jira): wire agent-side MCP + OAuth resolver for jira channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 of Jira Cloud integration (#288). Refactors channel_mcp.py from a single-channel gate to a CHANNEL_MCP_BUILDERS dispatch dict so adding future channels stays one-entry. Adds resolve_jira_oauth_token() to config.py mirroring the Linear resolver — same race-handling, same fail-closed semantics; only differences are the endpoint (auth.atlassian.com, JSON body) and the env-var name (JIRA_API_TOKEN). Pipeline now dispatches to the right resolver based on channel_source. JIRA_MCP_URL is flagged in-source as needs-verification — Atlassian's Remote MCP may still be preview-gated; if so, fall back to a REST shim in a future jira_reactions.py module (Plan B). Tests: 6 new Jira test cases in test_channel_mcp.py; full agent suite remains green (825 passed). --- agent/src/channel_mcp.py | 94 +++++++++++--- agent/src/config.py | 218 ++++++++++++++++++++++++++++++++ agent/src/pipeline.py | 21 ++- agent/src/server.py | 2 +- agent/tests/test_channel_mcp.py | 73 ++++++++++- 5 files changed, 379 insertions(+), 29 deletions(-) diff --git a/agent/src/channel_mcp.py b/agent/src/channel_mcp.py index f9c51c03..686bbb9d 100644 --- a/agent/src/channel_mcp.py +++ b/agent/src/channel_mcp.py @@ -1,35 +1,40 @@ """Channel-specific MCP configuration for the agent container. -For Linear-origin tasks we write (or merge into) ``.mcp.json`` in the cloned -repo ``cwd`` so the Claude Agent SDK — configured with -``setting_sources=["project"]`` — picks up the Linear MCP at session start -and exposes ``mcp__linear-server__*`` tools. +For inbound channel sources that have a hosted MCP we write (or merge into) +``.mcp.json`` in the cloned repo ``cwd`` so the Claude Agent SDK — configured +with ``setting_sources=["project"]`` — picks up the channel MCP at session +start and exposes the server's tools. + +Currently wired channels: +- ``linear`` → Linear hosted MCP (``mcp__linear-server__*`` tools) +- ``jira`` → Atlassian Remote MCP (``mcp__jira-server__*`` tools) For all other channel sources this is a no-op: no MCP is written, and the -SDK sees no Linear tools. That's the gate keeping Slack/API/webhook tasks -from touching Linear. +SDK sees no channel-specific tools. -See: cdk/src/handlers/linear-webhook-processor.ts (inbound), runner.py -(SDK invocation), plans at ~/.claude/plans/linear-mcp-findings.md. +See: cdk/src/handlers/{linear,jira}-webhook-processor.ts (inbound), +runner.py (SDK invocation). """ from __future__ import annotations import json import os -from typing import Any +from typing import Any, Callable from shell import log +# ─── Linear ────────────────────────────────────────────────────────────────── + #: Linear MCP endpoint — hosted by Linear, Streamable HTTP transport. LINEAR_MCP_URL = "https://mcp.linear.app/mcp" #: Key name inside ``mcpServers``. Tools surface as -#: ``mcp__linear-server__*`` in the Agent SDK (verified in findings). +#: ``mcp__linear-server__*`` in the Agent SDK. LINEAR_MCP_SERVER_KEY = "linear-server" #: Env var name the MCP server entry reads via ``${LINEAR_API_TOKEN}`` -#: placeholder expansion. Populated from ``LinearApiTokenSecret`` by run.sh. +#: placeholder expansion. Populated from the OAuth secret by config.py. LINEAR_API_TOKEN_ENV = "LINEAR_API_TOKEN" # noqa: S105 — env var *name*, not a secret value @@ -44,11 +49,55 @@ def _linear_server_entry() -> dict[str, Any]: } +# ─── Jira (Atlassian Remote MCP) ───────────────────────────────────────────── + +#: Atlassian Remote MCP endpoint — Streamable HTTP transport. +#: +#: NOTE: Atlassian's Remote MCP rolled out in mid-2025 and may still be in +#: preview / gated rollout when this code first deploys. Confirm the public +#: URL + auth contract before relying on this in production. If gated, fall +#: back to a REST shim in a future ``jira_reactions.py`` module (Plan B). +JIRA_MCP_URL = "https://mcp.atlassian.com/v1/sse" + +#: Key name inside ``mcpServers``. Tools surface as ``mcp__jira-server__*`` +#: in the Agent SDK. If this changes the agent prompt's channel addendum +#: must be updated in lockstep. +JIRA_MCP_SERVER_KEY = "jira-server" + +#: Env var name the Jira MCP server entry reads via ``${JIRA_API_TOKEN}`` +#: placeholder expansion. Populated from the per-tenant OAuth secret by +#: config.resolve_jira_oauth_token. +JIRA_API_TOKEN_ENV = "JIRA_API_TOKEN" # noqa: S105 — env var *name*, not a secret value + + +def _jira_server_entry() -> dict[str, Any]: + """Build the `mcpServers` entry for Atlassian's Remote MCP.""" + return { + "type": "http", + "url": JIRA_MCP_URL, + "headers": { + "Authorization": f"Bearer ${{{JIRA_API_TOKEN_ENV}}}", + }, + } + + +# ─── Dispatch ──────────────────────────────────────────────────────────────── + +#: Per-channel ``mcpServers`` entry builder. The channel_source values mirror +#: ``ChannelSource`` in cdk/src/handlers/shared/types.ts. Sources that don't +#: have a hosted MCP (api, webhook, slack) intentionally have no entry here — +#: the gate in ``configure_channel_mcp`` short-circuits on missing keys. +CHANNEL_MCP_BUILDERS: dict[str, tuple[str, Callable[[], dict[str, Any]]]] = { + "linear": (LINEAR_MCP_SERVER_KEY, _linear_server_entry), + "jira": (JIRA_MCP_SERVER_KEY, _jira_server_entry), +} + + def _read_existing_mcp_config(path: str) -> dict[str, Any]: """Return the parsed .mcp.json at ``path``, or an empty dict if absent/invalid. Malformed JSON is logged and treated as absent — we prefer to overlay a - valid Linear entry than to crash the agent because a user committed a + valid channel entry than to crash the agent because a user committed a broken .mcp.json to their repo. """ if not os.path.isfile(path): @@ -67,23 +116,26 @@ def _read_existing_mcp_config(path: str) -> dict[str, Any]: def configure_channel_mcp(repo_dir: str, channel_source: str) -> bool: """Write or merge a channel-specific ``.mcp.json`` into ``repo_dir``. - Gated on ``channel_source``: - * ``'linear'`` → ensure the ``linear-server`` entry is present in + Looks up ``channel_source`` in :data:`CHANNEL_MCP_BUILDERS`: + * present → ensure the corresponding ``mcpServers`` entry is in ``.mcp.json`` (merges into any existing config without clobbering other servers). Returns True. - * anything else → no-op. Returns False. + * absent → no-op. Returns False. Args: repo_dir: the cloned-repo working directory the SDK will use as ``cwd``. channel_source: inbound channel (``TaskConfig.channel_source``). Returns: - True if a Linear MCP entry was (re)written into ``repo_dir/.mcp.json``, - False otherwise (including any non-Linear channel or missing repo_dir). + True if a channel MCP entry was (re)written, False otherwise (channel + unmapped, missing repo_dir, or write failure). """ - if channel_source != "linear": + builder_entry = CHANNEL_MCP_BUILDERS.get(channel_source) + if builder_entry is None: return False + server_key, build_entry = builder_entry + if not repo_dir or not os.path.isdir(repo_dir): log("WARN", f"configure_channel_mcp: repo_dir missing or not a directory: {repo_dir!r}") return False @@ -94,7 +146,7 @@ def configure_channel_mcp(repo_dir: str, channel_source: str) -> bool: servers = config.get("mcpServers") if not isinstance(servers, dict): servers = {} - servers[LINEAR_MCP_SERVER_KEY] = _linear_server_entry() + servers[server_key] = build_entry() config["mcpServers"] = servers try: @@ -102,11 +154,11 @@ def configure_channel_mcp(repo_dir: str, channel_source: str) -> bool: json.dump(config, f, indent=2) f.write("\n") except OSError as e: - log("ERROR", f"Failed to write Linear MCP config to {mcp_path}: {e}") + log("ERROR", f"Failed to write {channel_source} MCP config to {mcp_path}: {e}") return False log( "TASK", - f"Linear MCP configured at {mcp_path} (server key: {LINEAR_MCP_SERVER_KEY})", + f"{channel_source} MCP configured at {mcp_path} (server key: {server_key})", ) return True diff --git a/agent/src/config.py b/agent/src/config.py index 04fa162d..319c0b1b 100644 --- a/agent/src/config.py +++ b/agent/src/config.py @@ -313,6 +313,224 @@ def _refresh(current: dict) -> dict | None: return access +def resolve_jira_oauth_token(channel_metadata: dict[str, str] | None = None) -> str: + """Resolve the Jira Cloud OAuth access token from Secrets Manager. + + The orchestrator stamps ``jira_oauth_secret_arn`` into the task + record's ``channel_metadata`` at task-creation time. We fetch the + per-tenant secret, parse the token JSON, refresh if expiring, and + cache the access_token in ``JIRA_API_TOKEN`` so the Atlassian Remote + MCP's ``${JIRA_API_TOKEN}`` placeholder in ``.mcp.json`` resolves. + + For local development, a pre-set ``JIRA_API_TOKEN`` env var + short-circuits the lookup so the agent can run outside the runtime. + + Returns an empty string when the credential is absent — the agent-side + MCP config then renders with an unresolved ``${JIRA_API_TOKEN}`` + placeholder and the Jira MCP fails closed. This function is only + called when ``channel_source == 'jira'``. + + Mirrors :func:`resolve_linear_api_token` in shape; differences are + only the secret key names, env var names, and OAuth endpoint + (``https://auth.atlassian.com/oauth/token``, JSON body — Linear's is + ``api.linear.app/oauth/token`` with form-encoded body). + """ + cached = os.environ.get("JIRA_API_TOKEN", "") + if cached: + return cached + + secret_arn = "" + if channel_metadata: + secret_arn = channel_metadata.get("jira_oauth_secret_arn", "") + if not secret_arn: + secret_arn = os.environ.get("JIRA_OAUTH_SECRET_ARN", "") + if not secret_arn: + return "" + + region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") + if not region: + log("WARN", "resolve_jira_oauth_token: AWS_REGION not set; cannot resolve token") + return "" + + try: + import json + from datetime import datetime, timedelta + + import boto3 + from botocore.exceptions import BotoCoreError, ClientError + except ImportError as e: + log("WARN", f"resolve_jira_oauth_token: boto3 unavailable ({e}); skipping") + return "" + + sm = boto3.client("secretsmanager", region_name=region) + + def _fetch_token() -> dict | None: + resp = sm.get_secret_value(SecretId=secret_arn) + try: + return json.loads(resp["SecretString"]) + except (json.JSONDecodeError, KeyError, TypeError) as e: + log( + "ERROR", + f"resolve_jira_oauth_token: secret '{secret_arn}' is not valid JSON " + f"({type(e).__name__}: {e}); tenant requires re-onboarding", + ) + return None + + def _is_expiring(expires_at_iso: str, threshold_seconds: int = 60) -> bool: + try: + expiry = datetime.fromisoformat(expires_at_iso.replace("Z", "+00:00")) + except ValueError: + log( + "WARN", + f"_is_expiring: malformed expires_at '{expires_at_iso}'; treating as expiring", + ) + return True + return (expiry - datetime.now(UTC)).total_seconds() < threshold_seconds + + def _try_refresh_once(current: dict) -> tuple[str, dict | None]: + """Single Atlassian /oauth/token POST. Same outcome shape as the + Linear refresh helper. + """ + try: + import urllib.error + import urllib.request + except ImportError: + return ("failure", None) + + # Atlassian's OAuth token endpoint expects a JSON body, NOT + # x-www-form-urlencoded — that's the one shape difference from + # Linear. Same params: grant_type, refresh_token, client_id, + # client_secret. + body = json.dumps( + { + "grant_type": "refresh_token", + "client_id": current["client_id"], + "client_secret": current["client_secret"], + "refresh_token": current["refresh_token"], + } + ).encode("utf-8") + req = urllib.request.Request( + "https://auth.atlassian.com/oauth/token", + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=10) as resp: # noqa: S310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected -- URL is hardcoded to https://auth.atlassian.com/oauth/token above; no user-controlled input + payload = json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + err_code = None + try: + err_payload = json.loads(e.read().decode("utf-8")) + err_code = err_payload.get("error") + except (json.JSONDecodeError, UnicodeDecodeError, AttributeError): + pass + log( + "WARN", + f"resolve_jira_oauth_token refresh rejected: status={e.code} error={err_code}", + ) + if err_code == "invalid_grant": + return ("invalid_grant", None) + return ("failure", None) + except (urllib.error.URLError, OSError) as e: + log("WARN", f"resolve_jira_oauth_token refresh failed: {type(e).__name__}: {e}") + return ("failure", None) + + if "access_token" not in payload: + return ("failure", None) + + now = datetime.now(UTC) + if "expires_in" in payload: + future = now + timedelta(seconds=int(payload["expires_in"])) + expires_at_iso = future.replace(microsecond=0).isoformat().replace("+00:00", "Z") + else: + expires_at_iso = now.replace(microsecond=0).isoformat().replace("+00:00", "Z") + next_token = { + **current, + "access_token": payload["access_token"], + "refresh_token": payload.get("refresh_token", current["refresh_token"]), + "expires_at": expires_at_iso, + "scope": payload.get("scope", current["scope"]), + "updated_at": now.isoformat().replace("+00:00", "Z"), + } + + # Same trust model as the Linear resolver: agent runtime has + # GetSecretValue ONLY (no Put). Refreshed token is in-memory only + # for THIS task; Lambdas (trusted code) own persistence. + + cloud_id = next_token.get("cloud_id", "?") + site_url = next_token.get("site_url", "?") + log( + "INFO", + f"jira_oauth_refresh_ok cloud_id={cloud_id} " + f"site_url={site_url} new_expires_at={expires_at_iso}", + ) + return ("success", next_token) + + def _refresh(current: dict) -> dict | None: + """Refresh with one retry on invalid_grant after re-reading the secret.""" + kind, refreshed = _try_refresh_once(current) + if kind == "success": + return refreshed + if kind == "failure": + return None + + log( + "WARN", + "resolve_jira_oauth_token: invalid_grant — re-reading secret to check " + "for concurrent refresh", + ) + try: + fresh = _fetch_token() + except (ClientError, BotoCoreError) as e: + log("WARN", f"resolve_jira_oauth_token: re-read after invalid_grant failed: {e}") + return None + if fresh is None: + return None + + if fresh.get("refresh_token") == current.get("refresh_token"): + log( + "ERROR", + "resolve_jira_oauth_token: refresh_token permanently rejected; re-onboard required", + ) + return None + + if not _is_expiring(fresh.get("expires_at", "")): + log( + "INFO", + "resolve_jira_oauth_token: concurrent refresh detected; using freshly-read token", + ) + return fresh + + kind2, refreshed2 = _try_refresh_once(fresh) + if kind2 == "success": + return refreshed2 + return None + + try: + token_obj = _fetch_token() + except (ClientError, BotoCoreError) as e: + code = "" + if hasattr(e, "response"): + code = getattr(e, "response", {}).get("Error", {}).get("Code", "") or "" + is_hard_failure = code in ("AccessDeniedException", "ResourceNotFoundException") + severity = "ERROR" if is_hard_failure else "WARN" + log(severity, f"resolve_jira_oauth_token failed: {type(e).__name__}: {e}") + return "" + if token_obj is None: + return "" + + if _is_expiring(token_obj.get("expires_at", "")): + refreshed = _refresh(token_obj) + if refreshed: + token_obj = refreshed + + access = token_obj.get("access_token", "") + if access: + os.environ["JIRA_API_TOKEN"] = access + return access + + def build_config( repo_url: str, task_description: str = "", diff --git a/agent/src/pipeline.py b/agent/src/pipeline.py index e476a11c..e48e817c 100644 --- a/agent/src/pipeline.py +++ b/agent/src/pipeline.py @@ -14,7 +14,13 @@ import memory as agent_memory import task_state from channel_mcp import configure_channel_mcp -from config import AGENT_WORKSPACE, build_config, get_config, resolve_linear_api_token +from config import ( + AGENT_WORKSPACE, + build_config, + get_config, + resolve_jira_oauth_token, + resolve_linear_api_token, +) from context import assemble_prompt, fetch_github_issue from linear_reactions import react_task_finished, react_task_started from models import AgentResult, HydratedContext, RepoSetup, TaskConfig, TaskResult @@ -466,13 +472,16 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: system_prompt = build_system_prompt(config, setup, hc, system_prompt_overrides) - # Channel-specific MCP wiring (Linear only, for v1). Must happen - # before discover_project_config so the scan picks up the file we - # just wrote. Resolve the API token from Secrets Manager *before* - # writing .mcp.json so the child SDK process inherits the env var - # that the MCP server entry references via ${LINEAR_API_TOKEN}. + # Channel-specific MCP wiring. Must happen before + # discover_project_config so the scan picks up the file we just + # wrote. Resolve the per-channel access token from Secrets + # Manager *before* writing .mcp.json so the child SDK process + # inherits the env var that the MCP server entry references + # (${LINEAR_API_TOKEN} / ${JIRA_API_TOKEN}). if config.channel_source == "linear": resolve_linear_api_token(config.channel_metadata) + elif config.channel_source == "jira": + resolve_jira_oauth_token(config.channel_metadata) configure_channel_mcp(setup.repo_dir, config.channel_source) # 👀 on the Linear issue — acknowledges the task is picked up. diff --git a/agent/src/server.py b/agent/src/server.py index b01df8da..a9dd0412 100644 --- a/agent/src/server.py +++ b/agent/src/server.py @@ -46,7 +46,7 @@ def _redact_cached_credentials(text: str) -> str: """Remove cached env secrets from debug text before stdout / CloudWatch.""" out = text - for env_key in ("GITHUB_TOKEN", "LINEAR_API_TOKEN"): + for env_key in ("GITHUB_TOKEN", "LINEAR_API_TOKEN", "JIRA_API_TOKEN"): secret = os.environ.get(env_key) or "" if len(secret) >= 12: out = out.replace(secret, f"<{env_key}_REDACTED>") diff --git a/agent/tests/test_channel_mcp.py b/agent/tests/test_channel_mcp.py index 9ef4c221..d7d3321a 100644 --- a/agent/tests/test_channel_mcp.py +++ b/agent/tests/test_channel_mcp.py @@ -1,4 +1,4 @@ -"""Unit tests for channel_mcp.configure_channel_mcp — Linear MCP gating + merge.""" +"""Unit tests for channel_mcp.configure_channel_mcp — Linear/Jira MCP gating + merge.""" from __future__ import annotations @@ -6,6 +6,9 @@ import os from channel_mcp import ( + JIRA_API_TOKEN_ENV, + JIRA_MCP_SERVER_KEY, + JIRA_MCP_URL, LINEAR_API_TOKEN_ENV, LINEAR_MCP_SERVER_KEY, LINEAR_MCP_URL, @@ -139,3 +142,71 @@ def test_missing_repo_dir(self, tmp_path): def test_empty_repo_dir_string(self): wrote = configure_channel_mcp("", "linear") assert wrote is False + + +class TestJiraWrite: + """channel_source=='jira' writes .mcp.json with the jira-server entry.""" + + def test_creates_mcp_json_with_jira_server_key(self, tmp_path): + wrote = configure_channel_mcp(str(tmp_path), "jira") + assert wrote is True + config = _read_mcp(str(tmp_path)) + assert JIRA_MCP_SERVER_KEY in config["mcpServers"] + + def test_renders_jira_url_and_token_placeholder(self, tmp_path): + configure_channel_mcp(str(tmp_path), "jira") + entry = _read_mcp(str(tmp_path))["mcpServers"][JIRA_MCP_SERVER_KEY] + assert entry["type"] == "http" + assert entry["url"] == JIRA_MCP_URL + assert entry["headers"]["Authorization"] == f"Bearer ${{{JIRA_API_TOKEN_ENV}}}" + + def test_server_key_is_jira_server(self): + # If this changes, tools surface under a different mcp__ prefix and + # the agent prompt addendum must be updated in lockstep. + assert JIRA_MCP_SERVER_KEY == "jira-server" + + +class TestJiraMerge: + """Jira entry must coexist with other servers and overwrite stale jira entries.""" + + def test_preserves_existing_mcp_servers(self, tmp_path): + existing = { + "mcpServers": { + "other-server": {"type": "stdio", "command": "/usr/bin/my-mcp"}, + }, + } + (tmp_path / ".mcp.json").write_text(json.dumps(existing)) + + configure_channel_mcp(str(tmp_path), "jira") + merged = _read_mcp(str(tmp_path)) + assert "other-server" in merged["mcpServers"] + assert merged["mcpServers"]["other-server"]["command"] == "/usr/bin/my-mcp" + assert JIRA_MCP_SERVER_KEY in merged["mcpServers"] + + def test_overwrites_existing_jira_server_entry(self, tmp_path): + existing = { + "mcpServers": { + JIRA_MCP_SERVER_KEY: { + "type": "http", + "url": "https://stale.example", + "headers": {"Authorization": "Bearer stale"}, + }, + }, + } + (tmp_path / ".mcp.json").write_text(json.dumps(existing)) + + configure_channel_mcp(str(tmp_path), "jira") + entry = _read_mcp(str(tmp_path))["mcpServers"][JIRA_MCP_SERVER_KEY] + assert entry["url"] == JIRA_MCP_URL + assert "stale" not in entry["headers"]["Authorization"] + + def test_linear_and_jira_can_coexist(self, tmp_path): + # Belt-and-braces: a repo that committed a Linear entry and then + # gets onboarded to Jira (or vice-versa) must keep both. The current + # code path only writes one channel per run, but this test guards + # against a future refactor that writes the wrong key. + configure_channel_mcp(str(tmp_path), "linear") + configure_channel_mcp(str(tmp_path), "jira") + merged = _read_mcp(str(tmp_path)) + assert LINEAR_MCP_SERVER_KEY in merged["mcpServers"] + assert JIRA_MCP_SERVER_KEY in merged["mcpServers"] From 37e4ea2600bb7811a66e56edcf77ec5a8cb4ed5f Mon Sep 17 00:00:00 2001 From: bgagent Date: Mon, 8 Jun 2026 15:10:17 -0400 Subject: [PATCH 07/20] feat(cli): add bgagent jira commands (app-template, setup, link, map) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6 of Jira Cloud integration (#288). Minimal v1 surface (4 of 10 Linear subcommands), per scoping decision. Mirrors the Linear CLI shape where the contracts are similar: - jira-oauth.ts ports linear-oauth.ts. Atlassian's token endpoint takes JSON (Linear takes form-encoded). offline_access scope is required for a refresh_token. fetchAccessibleResources() resolves cloudId + siteUrl post-consent. - commands/jira.ts: app-template prints dev-console values; setup drives the OAuth dance + writes the per-tenant secret + registry row + webhook signing secret; link does dry-run preview UX; map writes the project → repo row. Deferred to follow-ups: add-workspace, update-webhook-secret, invite-user (with self-link picker), list-projects. --- cli/src/api-client.ts | 12 + cli/src/bin/bgagent.ts | 2 + cli/src/commands/jira.ts | 656 +++++++++++++++++++++++++++++++++++++++ cli/src/jira-oauth.ts | 337 ++++++++++++++++++++ cli/src/types.ts | 16 + 5 files changed, 1023 insertions(+) create mode 100644 cli/src/commands/jira.ts create mode 100644 cli/src/jira-oauth.ts diff --git a/cli/src/api-client.ts b/cli/src/api-client.ts index 687c4bcc..4ba86e06 100644 --- a/cli/src/api-client.ts +++ b/cli/src/api-client.ts @@ -35,6 +35,7 @@ import { ErrorResponse, GetPendingResponse, GetPoliciesResponse, + JiraLinkResponse, LinearLinkResponse, NudgeRequest, NudgeResponse, @@ -433,4 +434,15 @@ export class ApiClient { const res = await this.request>('POST', '/linear/link', body); return res.data; } + + /** POST /jira/link — link a Jira account using a verification code. + * + * `dryRun: true` returns the identity attached to the code without + * writing the mapping. Mirrors linearLink. */ + async jiraLink(code: string, opts: { dryRun?: boolean } = {}): Promise { + const body: Record = { code }; + if (opts.dryRun) body.dry_run = true; + const res = await this.request>('POST', '/jira/link', body); + return res.data; + } } diff --git a/cli/src/bin/bgagent.ts b/cli/src/bin/bgagent.ts index eecec7b2..e8955db3 100644 --- a/cli/src/bin/bgagent.ts +++ b/cli/src/bin/bgagent.ts @@ -26,6 +26,7 @@ import { makeCancelCommand } from '../commands/cancel'; import { makeConfigureCommand } from '../commands/configure'; import { makeDenyCommand } from '../commands/deny'; import { makeEventsCommand } from '../commands/events'; +import { makeJiraCommand } from '../commands/jira'; import { makeLinearCommand } from '../commands/linear'; import { makeListCommand } from '../commands/list'; import { makeLoginCommand } from '../commands/login'; @@ -70,6 +71,7 @@ program.addCommand(makePoliciesCommand()); program.addCommand(makeEventsCommand()); program.addCommand(makeSlackCommand()); program.addCommand(makeLinearCommand()); +program.addCommand(makeJiraCommand()); program.addCommand(makeWatchCommand()); program.addCommand(makeTraceCommand()); program.addCommand(makeWebhookCommand()); diff --git a/cli/src/commands/jira.ts b/cli/src/commands/jira.ts new file mode 100644 index 00000000..3f4e5f62 --- /dev/null +++ b/cli/src/commands/jira.ts @@ -0,0 +1,656 @@ +/** + * 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 { execFile } from 'child_process'; +import * as readline from 'readline'; +import { CloudFormationClient, DescribeStacksCommand } from '@aws-sdk/client-cloudformation'; +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { + CreateSecretCommand, + GetSecretValueCommand, + PutSecretValueCommand, + ResourceExistsException, + SecretsManagerClient, +} from '@aws-sdk/client-secrets-manager'; +import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; +import { Command } from 'commander'; +import { ApiClient } from '../api-client'; +import { loadConfig, loadCredentials } from '../config'; +import { CliError } from '../errors'; +import { formatJson } from '../format'; +import { + buildAuthorizationUrl, + computeExpiresAt, + exchangeAuthorizationCode, + fetchAccessibleResources, + generatePkce, + jiraOauthSecretName, + StoredJiraOauthToken, +} from '../jira-oauth'; +import { awaitOauthCallback, CALLBACK_URL } from '../oauth-callback-server'; + +/** Default label that triggers an ABCA task when applied to a Jira issue. */ +const DEFAULT_LABEL_FILTER = 'bgagent'; + +/** Jira project keys are typically 2–10 uppercase chars, but Atlassian + * allows longer alphanumeric keys (and digits). Accept what Atlassian + * accepts at creation time. */ +const PROJECT_KEY_RE = /^[A-Z][A-Z0-9_]{1,99}$/; + +/** + * Render the printable Atlassian developer-console app config. Standalone + * export so `bgagent jira setup` can call it inline. + */ +export interface JiraAppTemplateOptions { + readonly developerName?: string; + readonly description?: string; + readonly callbackUrl?: string; +} + +export function renderJiraAppTemplate(opts: JiraAppTemplateOptions = {}): string { + const developerName = opts.developerName ?? 'ABCA'; + const description = opts.description ?? 'Autonomous Background Coding Agent'; + // Localhost callback works for everyone running setup interactively. + // The redirect_uri value sent to Atlassian MUST byte-match what's + // configured here. + const callbackUrl = opts.callbackUrl ?? CALLBACK_URL; + + const bar = '═'.repeat(72); + return [ + bar, + 'Atlassian OAuth (3LO) app template', + bar, + '', + 'Open https://developer.atlassian.com/console/myapps/ → Create → OAuth 2.0', + 'integration, and enter:', + '', + ` Name: bgagent — ${developerName}`, + ` Description: ${description}`, + '', + 'In the new app, configure:', + '', + ' Permissions → Add APIs:', + ' • Jira API (scopes: read:jira-work, write:jira-work, read:jira-user)', + '', + ' Authorization → OAuth 2.0 (3LO):', + ` Callback URL: ${callbackUrl}`, + '', + ' Distribution: Sharing OFF (private to your developer org)', + '', + 'Save, then open Settings → copy the Client ID and Client Secret and return', + 'here.', + '', + 'Why these specific fields:', + ' • The 3 Jira scopes match what ABCA needs to read issues, post', + ' comments, and resolve account → display name during link preview.', + ' • offline_access is added implicitly by buildAuthorizationUrl — do', + ' not enable it as a scope in the dev console UI; passing it in the', + ' authorize request is sufficient and the dev console doesn\'t list', + ' it as a togglable scope.', + ' • The localhost callback removes the self-signed-cert browser warning', + ' and works without a public hostname on the operator\'s machine.', + bar, + ].join('\n'); +} + +/** + * Spawn the OS-default browser to open the given URL. Returns false on + * failure so callers can fall back to printing the URL. + */ +export function openBrowser(url: string): Promise { + return new Promise((resolve) => { + let opener: { cmd: string; args: string[] }; + if (process.platform === 'darwin') { + opener = { cmd: 'open', args: [url] }; + } else if (process.platform === 'win32') { + opener = { cmd: 'cmd', args: ['/c', 'start', '""', url] }; + } else { + opener = { cmd: 'xdg-open', args: [url] }; + } + execFile(opener.cmd, opener.args, (err) => { + resolve(!err); + }); + }); +} + +/** + * Generate an opaque, URL-safe `state` value for OAuth CSRF protection. + */ +function randomState(): string { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { randomBytes } = require('crypto') as typeof import('crypto'); + return randomBytes(32).toString('base64url'); +} + +/** + * Idempotent secret upsert: tries CreateSecret first; if the secret + * already exists, falls back to PutSecretValue. Returns the secret ARN + * regardless of which branch ran. + */ +export async function upsertOauthSecret( + client: SecretsManagerClient, + secretName: string, + payload: StoredJiraOauthToken, + cloudId: string, +): Promise { + const secretString = JSON.stringify(payload); + try { + const create = await client.send(new CreateSecretCommand({ + Name: secretName, + Description: `Jira OAuth token for tenant '${cloudId}'`, + SecretString: secretString, + Tags: [ + { Key: 'bgagent:integration', Value: 'jira' }, + { Key: 'bgagent:jira:cloud_id', Value: cloudId }, + ], + })); + if (!create.ARN) { + throw new CliError(`CreateSecret returned no ARN for '${secretName}'.`); + } + return create.ARN; + } catch (err) { + if (err instanceof ResourceExistsException) { + const put = await client.send(new PutSecretValueCommand({ + SecretId: secretName, + SecretString: secretString, + })); + if (!put.ARN) { + throw new CliError(`PutSecretValue returned no ARN for '${secretName}'.`); + } + return put.ARN; + } + throw err; + } +} + +/** + * Check whether the JiraWebhookSecret already holds a real signing secret + * (vs CDK's autogenerated placeholder). Used to decide whether to prompt + * for the webhook secret on subsequent setup runs. + * + * Atlassian's generic-webhook signing secrets are operator-chosen — they + * have no fixed prefix like Linear's `lin_wh_`. We treat the placeholder + * as a JSON-encoded value (CDK's default for autogenerated secrets) and + * everything else as a real value. + */ +async function isWebhookSecretConfigured( + client: SecretsManagerClient, + secretArn: string, +): Promise { + try { + const result = await client.send(new GetSecretValueCommand({ SecretId: secretArn })); + const value = result.SecretString; + if (typeof value !== 'string' || value.length === 0) return false; + // CDK's auto-generated secret is a JSON object string starting with `{` + // — operator-set secrets (the Atlassian-side configured value) are bare + // strings. Anything that doesn't look like the placeholder JSON is real. + return !value.trim().startsWith('{'); + } catch (err) { + const errorName = (err as { name?: string }).name; + if (errorName === 'ResourceNotFoundException') { + return false; + } + const message = err instanceof Error ? err.message : String(err); + throw new CliError( + `Failed to read Jira webhook secret '${secretArn}': ${errorName ?? 'Error'}: ${message}. ` + + 'Likely IAM permission gap — confirm your CLI principal has ' + + '`secretsmanager:GetSecretValue` on this ARN.', + ); + } +} + +function promptSecret(label: string): Promise { + return new Promise((resolve) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + // Mute echo so secrets don't render in the terminal as the user types. + const stdout = process.stdout as unknown as { write: (s: string) => boolean }; + const origWrite = stdout.write.bind(stdout); + let muted = false; + stdout.write = ((str: string) => { + if (muted && str !== label) return true; + return origWrite(str); + }) as typeof stdout.write; + rl.question(label, (answer) => { + stdout.write = origWrite; + rl.close(); + process.stdout.write('\n'); + resolve(answer); + }); + muted = true; + }); +} + +function promptLine(label: string): Promise { + return new Promise((resolve) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + rl.question(label.endsWith(' ') ? label : `${label} `, (answer) => { + rl.close(); + resolve(answer); + }); + }); +} + +function extractCognitoSub(): string { + const creds = loadCredentials(); + if (!creds?.id_token) { + throw new Error('not authenticated — run `bgagent login`'); + } + const parts = creds.id_token.split('.'); + if (parts.length !== 3) { + throw new Error('malformed id_token in ~/.bgagent/credentials.json'); + } + const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8')) as { sub?: string }; + if (!payload.sub) { + throw new Error('id_token missing `sub` claim'); + } + return payload.sub; +} + +async function getStackOutput(region: string, stackName: string, outputKey: string): Promise { + try { + const cfn = new CloudFormationClient({ region }); + const result = await cfn.send(new DescribeStacksCommand({ StackName: stackName })); + const outputs = result.Stacks?.[0]?.Outputs ?? []; + const output = outputs.find((o) => o.OutputKey === outputKey); + return output?.OutputValue ?? null; + } catch (err) { + const name = (err as Error)?.name ?? ''; + const message = (err as Error)?.message ?? ''; + if (name === 'ValidationError' && /does not exist/i.test(message)) { + return null; + } + throw err; + } +} + +export function makeJiraCommand(): Command { + const jira = new Command('jira') + .description('Manage Jira Cloud integration'); + + // ─── app-template ───────────────────────────────────────────────────────── + jira.addCommand( + new Command('app-template') + .description('Print the field values to paste into Atlassian\'s developer-console OAuth app form') + .option('--developer-name ', 'Developer name shown on Atlassian\'s consent screen') + .option('--description ', 'App description shown on Atlassian\'s consent screen') + .option('--callback-url ', 'OAuth callback URL (defaults to localhost:8080/oauth/callback)') + .action((opts) => { + console.log(renderJiraAppTemplate({ + developerName: opts.developerName, + description: opts.description, + callbackUrl: opts.callbackUrl, + })); + }), + ); + + // ─── setup ──────────────────────────────────────────────────────────────── + jira.addCommand( + new Command('setup') + .description('Authorize a Jira Cloud tenant via OAuth (3LO direct flow, Secrets Manager storage)') + .option('--region ', 'AWS region (defaults to configured region)') + .option('--stack-name ', 'CloudFormation stack name', 'backgroundagent-dev') + .option('--client-id ', 'Atlassian OAuth app Client ID (else prompted)') + .option('--client-secret ', 'Atlassian OAuth app Client Secret (else prompted; prefer interactive)') + .option('--no-browser', 'Print the authorization URL instead of opening a browser (for SSH/headless)') + .action(async (opts) => { + const config = loadConfig(); + const region = opts.region || config.region; + const stackName = opts.stackName; + + // ─── Stack outputs ─────────────────────────────────────────────── + const [ + workspaceRegistryTable, + webhookSecretArn, + ] = await Promise.all([ + getStackOutput(region, stackName, 'JiraWorkspaceRegistryTableName'), + getStackOutput(region, stackName, 'JiraWebhookSecretArn'), + ]); + + const missing: string[] = []; + if (!workspaceRegistryTable) missing.push('JiraWorkspaceRegistryTableName'); + if (!webhookSecretArn) missing.push('JiraWebhookSecretArn'); + if (missing.length > 0) { + throw new CliError( + `Stack '${stackName}' is missing outputs ${missing.join(', ')}. ` + + 'Re-deploy with the JiraIntegration CDK changes (mise //cdk:deploy).', + ); + } + + // ─── Resolve caller identity ───────────────────────────────────── + const creds = loadCredentials(); + if (!creds?.id_token) { + throw new CliError('Not authenticated — run `bgagent login` first.'); + } + let cognitoSub: string; + try { + cognitoSub = extractCognitoSub(); + } catch (err) { + throw new CliError( + `Could not read Cognito sub from cached id_token: ${err instanceof Error ? err.message : String(err)}. ` + + 'Run `bgagent login` to refresh credentials.', + ); + } + + // ─── Atlassian OAuth app credentials ───────────────────────────── + console.log('bgagent jira setup'); + console.log(` region: ${region}`); + console.log( + '\nAtlassian OAuth app credentials needed. If you have not created one, run `bgagent jira app-template`' + + ' for the values to paste into developer.atlassian.com → My apps.\n', + ); + const clientId = (opts.clientId ?? await promptSecret('Atlassian Client ID: ')).trim(); + if (!clientId) { + throw new CliError('Client ID is required.'); + } + const clientSecret = (opts.clientSecret ?? await promptSecret('Atlassian Client Secret: ')).trim(); + if (!clientSecret) { + throw new CliError('Client Secret is required.'); + } + + // ─── Step 1: Generate PKCE + open browser to Atlassian consent ─── + const pkce = generatePkce(); + const state = randomState(); + const authorizationUrl = buildAuthorizationUrl({ + clientId, + redirectUri: CALLBACK_URL, + state, + codeChallenge: pkce.codeChallenge, + }); + + const callbackPromise = awaitOauthCallback(); + + console.log(); + if (opts.browser !== false) { + const opened = await openBrowser(authorizationUrl); + if (opened) { + console.log(' → Opened your browser to the Atlassian consent screen.'); + console.log(' The browser will redirect to a localhost page after you Authorize — that\'s expected.'); + } else { + console.log(' → Could not open browser automatically. Open this URL manually:'); + console.log(` ${authorizationUrl}`); + } + } else { + console.log(' → --no-browser: open this URL manually:'); + console.log(` ${authorizationUrl}`); + } + + process.stdout.write(' → Waiting for browser callback...'); + const callback = await callbackPromise; + console.log(' ✓'); + + if (callback.kind !== 'direct-oauth') { + throw new CliError( + 'Localhost callback returned an AgentCore session_id, not a direct OAuth code. ' + + 'Verify Atlassian\'s redirect URI is set to http://localhost:8080/oauth/callback and re-run.', + ); + } + if (callback.state !== state) { + throw new CliError( + `OAuth state mismatch (expected '${state}', got '${callback.state}'). ` + + 'Possible CSRF attack or stale tab — re-run setup.', + ); + } + + // ─── Step 2: Exchange code for access token ────────────────────── + process.stdout.write(' → Exchanging code for access token...'); + const tokenResponse = await exchangeAuthorizationCode({ + code: callback.code, + codeVerifier: pkce.codeVerifier, + redirectUri: CALLBACK_URL, + clientId, + clientSecret, + }); + console.log(' ✓'); + + if (!tokenResponse.refresh_token) { + throw new CliError( + 'Atlassian did not return a refresh_token. The integration cannot self-renew tokens; ' + + 'verify the OAuth app requested the offline_access scope (re-run with the latest CLI; ' + + 'this is in the default scope list).', + ); + } + + // ─── Step 3: Fetch accessible resources (cloudId + siteUrl) ────── + process.stdout.write(' → Fetching accessible Atlassian sites...'); + const resources = await fetchAccessibleResources(tokenResponse.access_token); + if (resources.length === 0) { + throw new CliError( + 'Atlassian returned no accessible sites for the issued token. ' + + 'The user that authorized may not have access to any Jira sites — verify and re-run.', + ); + } + console.log(` ✓ (${resources.length} site${resources.length === 1 ? '' : 's'})`); + + let chosen = resources[0]; + if (resources.length > 1) { + console.log(); + console.log(' Multiple Atlassian sites are accessible:'); + resources.forEach((r, i) => { + console.log(` [${i + 1}] ${r.name} (${r.url})`); + }); + const pick = (await promptLine(` Select site [1-${resources.length}]:`)).trim(); + const idx = Number.parseInt(pick, 10) - 1; + if (Number.isNaN(idx) || idx < 0 || idx >= resources.length) { + throw new CliError(`Invalid selection '${pick}'.`); + } + chosen = resources[idx]; + } + + const cloudId = chosen.id; + const siteUrl = chosen.url; + console.log(` Selected: ${chosen.name}`); + console.log(` cloud_id: ${cloudId}`); + console.log(` site_url: ${siteUrl}`); + + // ─── Step 4: Persist token to per-tenant Secrets Manager ───────── + process.stdout.write(' → Storing OAuth token...'); + const sm = new SecretsManagerClient({ region }); + const now = new Date().toISOString(); + const stored: StoredJiraOauthToken = { + access_token: tokenResponse.access_token, + refresh_token: tokenResponse.refresh_token, + expires_at: computeExpiresAt(tokenResponse.expires_in), + scope: tokenResponse.scope, + client_id: clientId, + client_secret: clientSecret, + cloud_id: cloudId, + site_url: siteUrl, + installed_at: now, + updated_at: now, + installed_by_platform_user_id: cognitoSub, + }; + const secretName = jiraOauthSecretName(cloudId); + const oauthSecretArn = await upsertOauthSecret(sm, secretName, stored, cloudId); + console.log(` ✓ (${secretName})`); + + // ─── Step 5: Persist registry row ──────────────────────────────── + const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region })); + await ddb.send(new PutCommand({ + TableName: workspaceRegistryTable!, + Item: { + jira_cloud_id: cloudId, + site_url: siteUrl, + oauth_secret_arn: oauthSecretArn, + installed_by_platform_user_id: cognitoSub, + installed_at: now, + updated_at: now, + status: 'active', + }, + })); + console.log(' ✓ Recorded tenant in registry'); + + // ─── Step 6: Webhook signing secret (per-tenant + stack-wide) ───── + // + // Atlassian doesn't auto-generate webhook signing secrets — they're + // operator-chosen at webhook-create time in the Jira admin UI. + // We treat the secret like Linear's: store it on the per-tenant + // OAuth bundle (primary verification path) AND mirror to stack-wide + // (back-compat fallback). + const stackWideAlreadyConfigured = await isWebhookSecretConfigured(sm, webhookSecretArn!); + let webhookSigningSecret: string | undefined; + + if (stackWideAlreadyConfigured) { + console.log(' ✓ Webhook signing secret already configured stack-wide (mirroring to per-tenant)'); + try { + const value = await sm.send(new GetSecretValueCommand({ SecretId: webhookSecretArn! })); + if (value.SecretString && !value.SecretString.trim().startsWith('{')) { + webhookSigningSecret = value.SecretString; + } + } catch (err) { + console.log(` ⚠ Could not read stack-wide secret to mirror: ${err instanceof Error ? err.message : String(err)}`); + } + } else { + const apiBaseUrl = config.api_url.replace(/\/+$/, ''); + console.log(); + console.log(' Webhook signing secret needed.'); + console.log(' In Jira → Settings → System → Webhooks → Create a Webhook:'); + console.log(` URL: ${apiBaseUrl}/jira/webhook`); + console.log(' Events: Issue: created, updated'); + console.log(' Secret: choose a strong random value (e.g. `openssl rand -hex 32`)'); + console.log(); + const webhookSecret = await promptSecret('Webhook signing secret: '); + if (!webhookSecret) { + throw new CliError('Webhook signing secret is required.'); + } + await sm.send(new PutSecretValueCommand({ + SecretId: webhookSecretArn!, + SecretString: webhookSecret, + })); + console.log(' ✓ Stored webhook signing secret (stack-wide back-compat)'); + webhookSigningSecret = webhookSecret; + } + + if (webhookSigningSecret) { + const merged: StoredJiraOauthToken = { + ...stored, + webhook_signing_secret: webhookSigningSecret, + updated_at: new Date().toISOString(), + }; + await upsertOauthSecret(sm, secretName, merged, cloudId); + console.log(' ✓ Mirrored signing secret to per-tenant OAuth bundle'); + } + + // ─── Done ───────────────────────────────────────────────────────── + console.log(); + console.log('✅ Setup complete.'); + console.log(); + console.log('Next steps:'); + console.log(` 1. Map a Jira project to a GitHub repo:`); + console.log(` bgagent jira map --repo owner/repo`); + console.log(` 2. Link your Jira account so triggered tasks attribute to your platform user:`); + console.log(` (an admin runs \`bgagent jira invite-user\` to issue you a code; this command`); + console.log(` is not yet implemented — populate the user-mapping row manually for now.)`); + console.log(` 3. Add the trigger label '${DEFAULT_LABEL_FILTER}' to a Jira issue in a mapped project.`); + }), + ); + + // ─── link ───────────────────────────────────────────────────────────────── + jira.addCommand( + new Command('link') + .description('Redeem an invite code from `bgagent jira invite-user` to link your Jira identity') + .argument('', 'One-time invite code') + .option('--output ', 'Output format (text or json)', 'text') + .action(async (code: string, opts) => { + const client = new ApiClient(); + + if (opts.output !== 'json') { + const preview = await client.jiraLink(code, { dryRun: true }); + const name = preview.jira_user_name || preview.jira_account_id; + const email = preview.jira_user_email ? ` (${preview.jira_user_email})` : ''; + const tenantLabel = preview.jira_site_url || preview.jira_cloud_id; + console.log('You are about to link the following Jira identity to YOUR ABCA account:'); + console.log(); + console.log(` Jira user: ${name}${email}`); + console.log(` Jira tenant: ${tenantLabel}`); + console.log(); + console.log('After linking, tasks triggered by this Jira user will be attributed to'); + console.log('your platform user (concurrency caps, billing, `bgagent list`).'); + console.log(); + const confirm = (await promptLine('Continue? [Y/n]')).trim().toLowerCase(); + if (confirm && confirm !== 'y' && confirm !== 'yes') { + console.log('Aborted. The invite code is still valid until it expires.'); + return; + } + } + + const result = await client.jiraLink(code); + if (opts.output === 'json') { + console.log(formatJson(result)); + } else { + console.log(); + console.log('✅ Jira account linked.'); + console.log(` Linked at: ${result.linked_at}`); + } + }), + ); + + // ─── map ────────────────────────────────────────────────────────────────── + jira.addCommand( + new Command('map') + .description('Map a Jira project to a GitHub repository (admin IAM required)') + .argument('', 'Atlassian tenant cloudId (UUID)') + .argument('', 'Jira project key (e.g. ENG)') + .requiredOption('--repo ', 'GitHub repository the mapped project should route tasks to') + .option('--label