diff --git a/agent/src/channel_mcp.py b/agent/src/channel_mcp.py index 51b21b49..230ce0b0 100644 --- a/agent/src/channel_mcp.py +++ b/agent/src/channel_mcp.py @@ -7,13 +7,19 @@ Currently wired channels: - ``linear`` → Linear hosted MCP (``mcp__linear-server__*`` tools) -- ``jira`` → Atlassian Remote MCP (``mcp__jira-server__*`` tools) + +``jira`` deliberately has NO entry: Atlassian's Remote MCP +(``mcp.atlassian.com``) requires an interactive, browser-based OAuth 2.1 +flow with dynamic client registration and does not accept the stored Jira +REST OAuth token as a ``Bearer`` header, so it cannot connect from a +headless agent. Jira progress comments are posted out-of-band by +``jira_reactions`` (a REST shim wired into the pipeline) instead. For all other channel sources this is a no-op: no MCP is written, and the SDK sees no channel-specific tools. See: cdk/src/handlers/{linear,jira}-webhook-processor.ts (inbound), -runner.py (SDK invocation). +runner.py (SDK invocation), jira_reactions.py (Jira outbound REST shim). """ from __future__ import annotations @@ -52,47 +58,17 @@ 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. +#: have a usable hosted MCP intentionally have no entry here — the gate in +#: ``configure_channel_mcp`` short-circuits on missing keys. That includes +#: ``jira``: the Atlassian Remote MCP cannot authenticate from a headless +#: agent (see module docstring), so writing an entry would only produce a +#: confusing "Failed to connect" in every Jira task's logs. 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), } diff --git a/agent/src/config.py b/agent/src/config.py index 52079fbe..f0e511c6 100644 --- a/agent/src/config.py +++ b/agent/src/config.py @@ -332,16 +332,16 @@ def resolve_jira_oauth_token(channel_metadata: dict[str, str] | None = None) -> 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. + cache the access_token in ``JIRA_API_TOKEN`` so the ``jira_reactions`` + REST shim (which posts progress comments on the originating issue) + can authorize its calls. 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'``. + Returns an empty string when the credential is absent — + ``jira_reactions`` then skips its comments (fail closed, logged). + 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 diff --git a/agent/src/pipeline.py b/agent/src/pipeline.py index 85a8e920..69a9f78b 100644 --- a/agent/src/pipeline.py +++ b/agent/src/pipeline.py @@ -724,8 +724,9 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: # 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}). + # inherits the env var the MCP server entry references + # (${LINEAR_API_TOKEN}). Jira has no MCP entry — its token only + # feeds the jira_reactions REST shim below. if config.channel_source == "linear": resolve_linear_api_token(config.channel_metadata) elif config.channel_source == "jira": diff --git a/agent/tests/test_channel_mcp.py b/agent/tests/test_channel_mcp.py index d7d3321a..c54f4089 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/Jira MCP gating + merge.""" +"""Unit tests for channel_mcp.configure_channel_mcp — channel MCP gating + merge.""" from __future__ import annotations @@ -6,9 +6,6 @@ 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, @@ -144,32 +141,22 @@ def test_empty_repo_dir_string(self): assert wrote is False -class TestJiraWrite: - """channel_source=='jira' writes .mcp.json with the jira-server entry.""" +class TestJiraGate: + """channel_source=='jira' is a deliberate no-op. - 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" + Atlassian's Remote MCP requires an interactive OAuth 2.1 flow a headless + agent can't complete, so no entry is written — Jira progress comments are + posted out-of-band by ``jira_reactions`` (REST shim). If a usable + server-to-server auth path ships, restore the entry in + ``CHANNEL_MCP_BUILDERS`` and flip these assertions. + """ + def test_no_op_for_jira_channel(self, tmp_path): + wrote = configure_channel_mcp(str(tmp_path), "jira") + assert wrote is False + assert not (tmp_path / ".mcp.json").exists() -class TestJiraMerge: - """Jira entry must coexist with other servers and overwrite stale jira entries.""" - - def test_preserves_existing_mcp_servers(self, tmp_path): + def test_jira_does_not_touch_existing_mcp_json(self, tmp_path): existing = { "mcpServers": { "other-server": {"type": "stdio", "command": "/usr/bin/my-mcp"}, @@ -177,36 +164,6 @@ def test_preserves_existing_mcp_servers(self, tmp_path): } (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"] + wrote = configure_channel_mcp(str(tmp_path), "jira") + assert wrote is False + assert _read_mcp(str(tmp_path)) == existing diff --git a/agent/tests/test_config.py b/agent/tests/test_config.py index 830c6255..744e7b63 100644 --- a/agent/tests/test_config.py +++ b/agent/tests/test_config.py @@ -501,8 +501,8 @@ class TestResolveJiraOauthToken: The orchestrator stamps `jira_oauth_secret_arn` into the task's channel_metadata at creation time. resolve_jira_oauth_token reads the secret JSON via boto3, refreshes it if expiring, and caches the - access_token in `JIRA_API_TOKEN` for the Atlassian Remote MCP - placeholder. Mirrors resolve_linear_api_token; the differences are the + access_token in `JIRA_API_TOKEN` for the jira_reactions REST shim. + Mirrors resolve_linear_api_token; the differences are the secret/env var names and the Atlassian OAuth endpoint (JSON body). """ diff --git a/cdk/src/constructs/jira-integration.ts b/cdk/src/constructs/jira-integration.ts index aac44560..bb41b9d3 100644 --- a/cdk/src/constructs/jira-integration.ts +++ b/cdk/src/constructs/jira-integration.ts @@ -71,8 +71,8 @@ export interface JiraIntegrationProps { * 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 + * updates happen agent-side via the Jira REST API (see + * agent/src/jira_reactions.py), so there is NO DynamoDB Streams consumer * and NO outbound-notify Lambda here. Mirrors the Linear adapter shape. * * Creates: @@ -132,9 +132,10 @@ export class JiraIntegration extends Construct { // --- 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. + // created by the CLI at runtime — not here. This stack-wide secret + // covers Settings-UI webhooks, whose payloads omit `cloudId` and so + // can't be verified per-tenant; the processor binds such deliveries + // to the sole active tenant (and drops them when that's ambiguous). this.webhookSecret = new secretsmanager.Secret(this, 'WebhookSecret', { description: 'Jira webhook signing secret — populate via `bgagent jira setup`', removalPolicy, diff --git a/cdk/src/handlers/jira-webhook-processor.ts b/cdk/src/handlers/jira-webhook-processor.ts index d5ec1a27..4f8d2562 100644 --- a/cdk/src/handlers/jira-webhook-processor.ts +++ b/cdk/src/handlers/jira-webhook-processor.ts @@ -120,7 +120,7 @@ async function resolveSoleTenantCloudId(): Promise { * Undocumented fields are tolerated. */ interface JiraIssueEvent { - readonly webhookEvent: 'jira:issue_created' | 'jira:issue_updated' | string; + readonly webhookEvent: string; readonly timestamp?: number; readonly cloudId?: string; readonly user?: { @@ -155,6 +155,20 @@ interface JiraIssueEvent { interface ProcessorEvent { readonly raw_body: string; + /** + * How the receiver verified the delivery's signature. + * + * - `'per-tenant'` — the signature matched the secret stored on the + * tenant the body's `cloudId` names, so that `cloudId` is BOUND to + * the verified secret and may be trusted. + * - `'stack-wide'` (or absent, for back-compat with in-flight + * deliveries) — the signature only proves possession of the shared + * stack-wide secret. A body-supplied `cloudId` carries NO binding to + * that secret, so the processor must NOT use it to select a tenant; + * it resolves the sole active tenant from the registry instead and + * drops the event if that resolution is ambiguous. + */ + readonly verified_via?: 'per-tenant' | 'stack-wide'; } /** @@ -169,7 +183,8 @@ interface ProcessorEvent { * - 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. + * agent runtime uses to address the originating issue via the Jira REST + * API (`agent/src/jira_reactions.py`). */ export async function handler(event: ProcessorEvent): Promise { if (!event.raw_body) { @@ -201,11 +216,36 @@ export async function handler(event: ProcessorEvent): Promise { return; } - // `cloudId` is absent from Settings-UI webhook payloads. For a - // single-tenant install we recover it from the registry (see - // resolveSoleTenantCloudId); multi-tenant installs must send a webhook - // that carries its own cloudId. - const cloudId = payload.cloudId ?? (await resolveSoleTenantCloudId()); + // Tenant resolution depends on how the receiver verified the signature: + // + // - per-tenant verification bound the body's `cloudId` to the secret that + // verified it, so we can trust it directly. + // - stack-wide verification proves only possession of the shared secret — + // a body-supplied `cloudId` is attacker-controllable and must NOT steer + // tenant selection (mappings, user attribution, OAuth bundle). We + // resolve the sole active tenant from the registry instead; if the + // payload names a different tenant, we drop rather than guess. + // + // `cloudId` is also absent entirely from Settings-UI webhook payloads — + // the same sole-tenant fallback covers that case. + let cloudId: string | undefined; + if (payload.cloudId && event.verified_via === 'per-tenant') { + cloudId = payload.cloudId; + } else { + cloudId = await resolveSoleTenantCloudId(); + if (payload.cloudId && cloudId && payload.cloudId !== cloudId) { + logger.warn( + 'Jira webhook cloudId not bound by per-tenant verification and differs from the sole active tenant — dropping', + { + payload_cloud_id: payload.cloudId, + sole_tenant_cloud_id: cloudId, + issue_key: issue.key, + verified_via: event.verified_via ?? 'unknown', + }, + ); + return; + } + } const projectKey = issue.fields?.project?.key; if (!projectKey) { logger.info('Jira issue has no project.key — skipping (cannot route to a repo)', { @@ -244,7 +284,7 @@ export async function handler(event: ProcessorEvent): Promise { 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 `.", + "❌ This Jira project isn't onboarded to ABCA. An admin can onboard it with `bgagent jira map --repo /`.", ); return; } diff --git a/cdk/src/handlers/jira-webhook.ts b/cdk/src/handlers/jira-webhook.ts index e76379c2..cf408ab6 100644 --- a/cdk/src/handlers/jira-webhook.ts +++ b/cdk/src/handlers/jira-webhook.ts @@ -58,17 +58,16 @@ interface JiraWebhookEnvelope { 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. + * level — only app/OAuth-registered dynamic webhooks carry it; Settings-UI + * webhooks and older delivery payloads omit 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; @@ -109,7 +108,13 @@ export async function handler(event: APIGatewayProxyEvent): Promise = asyn if (!result) { await failTask(taskId, current.status, 'User concurrency limit reached', task.user_id, false); await emitTaskEvent(taskId, 'admission_rejected', { reason: 'concurrency_limit' }); - // Linear feedback is non-fatal: a throw here would re-run failTask + + // Channel feedback is non-fatal: a throw here would re-run failTask + // emitTaskEvent on the durable-execution retry, producing duplicate events. try { await notifyLinearOnConcurrencyCap(task); + await notifyJiraOnConcurrencyCap(task); } catch (err) { - logger.warn('Linear concurrency-cap feedback failed (non-fatal)', { + logger.warn('Channel concurrency-cap feedback failed (non-fatal)', { task_id: taskId, error: err instanceof Error ? err.message : String(err), }); @@ -342,3 +344,42 @@ export async function notifyLinearOnConcurrencyCap(task: TaskRecord): Promise { + if (task.channel_source !== 'jira') return; + const issueKey = task.channel_metadata?.jira_issue_key; + const cloudId = task.channel_metadata?.jira_cloud_id; + if (!issueKey || !cloudId) return; + const registryTableName = process.env.JIRA_WORKSPACE_REGISTRY_TABLE_NAME; + if (!registryTableName) { + logger.warn('Skipping Jira concurrency-cap feedback: JIRA_WORKSPACE_REGISTRY_TABLE_NAME not set', { + task_id: task.task_id, + }); + return; + } + // Same suppress-and-log contract as the Linear path: a synchronous throw + // bubbling up here would crash the durable-execution step on a transient + // DDB throttle during the registry lookup. + try { + await reportJiraIssueFailure( + { cloudId, registryTableName }, + issueKey, + '❌ ABCA hit your concurrency limit — too many tasks running for your user. Wait for one to finish, then re-apply the trigger label.', + ); + } catch (err) { + logger.warn('Jira concurrency-cap feedback failed (non-fatal)', { + task_id: task.task_id, + jira_cloud_id: cloudId, + issue_key: issueKey, + error: err instanceof Error ? err.message : String(err), + }); + } +} diff --git a/cdk/src/handlers/shared/jira-feedback.ts b/cdk/src/handlers/shared/jira-feedback.ts index 39ee7372..04124ebc 100644 --- a/cdk/src/handlers/shared/jira-feedback.ts +++ b/cdk/src/handlers/shared/jira-feedback.ts @@ -25,7 +25,7 @@ import { logger } from './logger'; * 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. + * agent-side jira_reactions shim cannot run. * * Unlike Linear, Jira has no "reaction" primitive. The failure marker * (❌) is folded into the comment text instead of attached as a separate diff --git a/cdk/test/constructs/jira-integration.test.ts b/cdk/test/constructs/jira-integration.test.ts new file mode 100644 index 00000000..4844cfb3 --- /dev/null +++ b/cdk/test/constructs/jira-integration.test.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 { App, Stack } from 'aws-cdk-lib'; +import { Template, Match } from 'aws-cdk-lib/assertions'; +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 { JiraIntegration } from '../../src/constructs/jira-integration'; + +describe('JiraIntegration construct', () => { + let template: Template; + + beforeAll(() => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + + const api = new apigw.RestApi(stack, 'TestApi'); + const userPool = new cognito.UserPool(stack, 'TestUserPool'); + const taskTable = new dynamodb.Table(stack, 'TaskTable', { + partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING }, + }); + const taskEventsTable = new dynamodb.Table(stack, 'TaskEventsTable', { + partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING }, + sortKey: { name: 'event_id', type: dynamodb.AttributeType.STRING }, + }); + + new JiraIntegration(stack, 'JiraIntegration', { + api, + userPool, + taskTable, + taskEventsTable, + }); + + template = Template.fromStack(stack); + }); + + test('creates four Jira DynamoDB tables (project mapping + user mapping + workspace registry + dedup)', () => { + // TaskTable + TaskEventsTable + JiraProjectMapping + JiraUserMapping + // + JiraWorkspaceRegistry + JiraWebhookDedup = 6 + template.resourceCountIs('AWS::DynamoDB::Table', 6); + }); + + test('project mapping table is keyed on jira_project_identity ({cloudId}#{projectKey})', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + KeySchema: [{ AttributeName: 'jira_project_identity', KeyType: 'HASH' }], + }); + }); + + test('user mapping table is keyed on jira_identity with a PlatformUserIndex GSI', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + KeySchema: [{ AttributeName: 'jira_identity', KeyType: 'HASH' }], + GlobalSecondaryIndexes: Match.arrayWith([ + Match.objectLike({ IndexName: 'PlatformUserIndex' }), + ]), + }); + }); + + test('workspace registry table is keyed on jira_cloud_id', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + KeySchema: [{ AttributeName: 'jira_cloud_id', KeyType: 'HASH' }], + }); + }); + + test('creates three Lambda functions (webhook, processor, link)', () => { + template.resourceCountIs('AWS::Lambda::Function', 3); + }); + + test('creates API Gateway resources under /jira', () => { + template.hasResourceProperties('AWS::ApiGateway::Resource', { PathPart: 'jira' }); + template.hasResourceProperties('AWS::ApiGateway::Resource', { PathPart: 'webhook' }); + template.hasResourceProperties('AWS::ApiGateway::Resource', { PathPart: 'link' }); + }); + + test('creates one Secrets Manager secret (webhook signing) — OAuth tokens are CLI-created at runtime', () => { + // Per-tenant OAuth tokens live in `bgagent-jira-oauth-` secrets + // created by `bgagent jira setup`, NOT by CDK. Only the webhook signing + // secret is CDK-managed. + template.resourceCountIs('AWS::SecretsManager::Secret', 1); + template.hasResourceProperties('AWS::SecretsManager::Secret', { + Description: Match.stringLikeRegexp('Jira webhook signing secret'), + }); + }); + + test('has NO DynamoDB Streams event-source mapping (outbound is the agent-side REST shim)', () => { + template.resourceCountIs('AWS::Lambda::EventSourceMapping', 0); + }); + + test('webhook handler env wires dedup table + processor + secret ARN + registry', () => { + template.hasResourceProperties('AWS::Lambda::Function', { + Environment: { + Variables: Match.objectLike({ + JIRA_WEBHOOK_SECRET_ARN: Match.anyValue(), + JIRA_WEBHOOK_DEDUP_TABLE_NAME: Match.anyValue(), + JIRA_WEBHOOK_PROCESSOR_FUNCTION_NAME: Match.anyValue(), + JIRA_WORKSPACE_REGISTRY_TABLE_NAME: Match.anyValue(), + }), + }, + }); + }); + + test('processor handler env wires all mapping tables + task table + workspace registry', () => { + template.hasResourceProperties('AWS::Lambda::Function', { + Environment: { + Variables: Match.objectLike({ + JIRA_PROJECT_MAPPING_TABLE_NAME: Match.anyValue(), + JIRA_USER_MAPPING_TABLE_NAME: Match.anyValue(), + JIRA_WORKSPACE_REGISTRY_TABLE_NAME: Match.anyValue(), + TASK_TABLE_NAME: Match.anyValue(), + TASK_EVENTS_TABLE_NAME: Match.anyValue(), + }), + }, + }); + }); + + test('webhook dedup table has TTL attribute', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + KeySchema: [{ AttributeName: 'dedup_key', KeyType: 'HASH' }], + TimeToLiveSpecification: { AttributeName: 'ttl', Enabled: true }, + }); + }); + + test('webhook receiver IAM grants are read-only on the per-tenant OAuth secret prefix', () => { + // The receiver only extracts `webhook_signing_secret` for verification; + // it must never hold PutSecretValue (the CLI owns secret lifecycle, the + // processor owns the refresh write-back). + const policies = template.findResources('AWS::IAM::Policy'); + const receiverPolicies = Object.values(policies).filter((p) => { + const statements = (p.Properties as { + PolicyDocument: { Statement: Array<{ Action: string | string[]; Resource: unknown }> }; + }).PolicyDocument.Statement; + return statements.some((s) => { + const actions = Array.isArray(s.Action) ? s.Action : [s.Action]; + return actions.includes('secretsmanager:GetSecretValue') + && JSON.stringify(s.Resource).includes('bgagent-jira-oauth-') + && !actions.includes('secretsmanager:PutSecretValue'); + }); + }); + expect(receiverPolicies.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/cdk/test/contracts/stored-oauth-token-parity.test.ts b/cdk/test/contracts/stored-oauth-token-parity.test.ts index a3b86e72..261b467b 100644 --- a/cdk/test/contracts/stored-oauth-token-parity.test.ts +++ b/cdk/test/contracts/stored-oauth-token-parity.test.ts @@ -39,6 +39,8 @@ import * as path from 'path'; const REPO_ROOT = path.resolve(__dirname, '..', '..', '..'); const LAMBDA_RESOLVER = path.join(REPO_ROOT, 'cdk', 'src', 'handlers', 'shared', 'linear-oauth-resolver.ts'); const CLI_OAUTH = path.join(REPO_ROOT, 'cli', 'src', 'linear-oauth.ts'); +const JIRA_LAMBDA_RESOLVER = path.join(REPO_ROOT, 'cdk', 'src', 'handlers', 'shared', 'jira-oauth-resolver.ts'); +const JIRA_CLI_OAUTH = path.join(REPO_ROOT, 'cli', 'src', 'jira-oauth.ts'); interface InterfaceField { readonly name: string; @@ -107,3 +109,33 @@ describe('StoredOauthToken / StoredLinearOauthToken cross-language parity', () = expect(constFields).toEqual(interfaceRequired); }); }); + +describe('StoredOauthToken / StoredJiraOauthToken cross-language parity (Jira)', () => { + // Same contract as the Linear pair above: the CLI's `bgagent jira setup` + // writes the secret JSON the Lambda-side resolver reads. Drift is a + // silent runtime bug. + test('Lambda and CLI define the same set of fields with the same optionality', () => { + const lambdaSource = fs.readFileSync(JIRA_LAMBDA_RESOLVER, 'utf8'); + const cliSource = fs.readFileSync(JIRA_CLI_OAUTH, 'utf8'); + + const lambdaFields = extractInterfaceFields(lambdaSource, 'StoredOauthToken'); + const cliFields = extractInterfaceFields(cliSource, 'StoredJiraOauthToken'); + + expect(fieldNames(lambdaFields)).toEqual(fieldNames(cliFields)); + expect(requiredFieldNames(lambdaFields)).toEqual(requiredFieldNames(cliFields)); + expect(requiredFieldNames(lambdaFields).length).toBeGreaterThanOrEqual(11); + }); + + test('Lambda STORED_OAUTH_TOKEN_REQUIRED_FIELDS const matches the interface\'s required fields', () => { + const lambdaSource = fs.readFileSync(JIRA_LAMBDA_RESOLVER, 'utf8'); + const interfaceRequired = requiredFieldNames(extractInterfaceFields(lambdaSource, 'StoredOauthToken')); + + const constMatch = /STORED_OAUTH_TOKEN_REQUIRED_FIELDS:\s*ReadonlyArray\s*=\s*\[([\s\S]*?)\];/.exec(lambdaSource); + expect(constMatch).not.toBeNull(); + const constFields = (constMatch![1].match(/'([a-zA-Z_][a-zA-Z0-9_]*)'/g) ?? []) + .map((s) => s.replace(/'/g, '')) + .sort(); + + expect(constFields).toEqual(interfaceRequired); + }); +}); diff --git a/cdk/test/handlers/jira-webhook-multi-tenant.test.ts b/cdk/test/handlers/jira-webhook-multi-tenant.test.ts new file mode 100644 index 00000000..856326e3 --- /dev/null +++ b/cdk/test/handlers/jira-webhook-multi-tenant.test.ts @@ -0,0 +1,396 @@ +/** + * 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. + */ + +/** + * Per-tenant webhook signing-secret tests for the Jira webhook receiver — + * the Jira analog of `linear-webhook-multi-workspace.test.ts`. + * + * Lives in a separate file from `jira-webhook.test.ts` because the handler + * reads `JIRA_WORKSPACE_REGISTRY_TABLE_NAME` at module-load time. Setting + * it here before the import gives us the multi-tenant code path; the + * sibling test file leaves it unset to exercise the single-tenant + * back-compat path. + */ + +import * as crypto from 'crypto'; +import type { APIGatewayProxyEvent } from 'aws-lambda'; + +const ddbSend = jest.fn(); +jest.mock('@aws-sdk/client-dynamodb', () => { + class ConditionalCheckFailedExceptionMock extends Error { + constructor(opts: { message: string; $metadata?: unknown }) { + super(opts.message); + this.name = 'ConditionalCheckFailedException'; + } + } + return { + DynamoDBClient: jest.fn(() => ({})), + ConditionalCheckFailedException: ConditionalCheckFailedExceptionMock, + }; +}); +jest.mock('@aws-sdk/lib-dynamodb', () => ({ + DynamoDBDocumentClient: { from: jest.fn(() => ({ send: ddbSend })) }, + PutCommand: jest.fn((input: unknown) => ({ _type: 'Put', input })), + DeleteCommand: jest.fn((input: unknown) => ({ _type: 'Delete', input })), + GetCommand: jest.fn((input: unknown) => ({ _type: 'Get', input })), +})); + +const lambdaSend = jest.fn(); +jest.mock('@aws-sdk/client-lambda', () => ({ + LambdaClient: jest.fn(() => ({ send: lambdaSend })), + InvokeCommand: jest.fn((input: unknown) => ({ _type: 'Invoke', input })), +})); + +const smSend = jest.fn(); +jest.mock('@aws-sdk/client-secrets-manager', () => ({ + SecretsManagerClient: jest.fn(() => ({ send: smSend })), + GetSecretValueCommand: jest.fn((input: unknown) => ({ _type: 'GetSecretValue', input })), +})); + +process.env.JIRA_WEBHOOK_SECRET_ARN = 'arn:aws:secretsmanager:us-east-1:123:secret:bgagent/jira/webhook-stack'; +process.env.JIRA_WEBHOOK_DEDUP_TABLE_NAME = 'JiraDedup'; +process.env.JIRA_WEBHOOK_PROCESSOR_FUNCTION_NAME = 'jira-processor'; +process.env.JIRA_WORKSPACE_REGISTRY_TABLE_NAME = 'JiraWorkspaceRegistry'; + +import { handler } from '../../src/handlers/jira-webhook'; +import { _resetCachesForTesting } from '../../src/handlers/shared/jira-oauth-resolver'; +import { invalidateJiraSecretCache } from '../../src/handlers/shared/jira-verify'; + +const STACK_WIDE_SECRET = 'jira-stackwide-secret-AAAAAAAAAAAAAAAAAA'; +const TENANT_A_SECRET = 'jira-tenantA-secret-BBBBBBBBBBBBBBBBBB'; +const TENANT_B_SECRET = 'jira-tenantB-secret-CCCCCCCCCCCCCCCCCC'; +const TENANT_A_CLOUD_ID = 'cloud-aaa'; +const TENANT_B_CLOUD_ID = 'cloud-bbb'; +const TENANT_A_SECRET_ARN = 'arn:aws:secretsmanager:us-east-1:123:secret:bgagent-jira-oauth-cloud-aaa'; +const TENANT_B_SECRET_ARN = 'arn:aws:secretsmanager:us-east-1:123:secret:bgagent-jira-oauth-cloud-bbb'; + +function sign(secret: string, body: string): string { + return `sha256=${crypto.createHmac('sha256', secret).update(body).digest('hex')}`; +} + +function makeEvent(body: string, signature: string): APIGatewayProxyEvent { + return { + body, + headers: { 'X-Hub-Signature': signature }, + multiValueHeaders: {}, + httpMethod: 'POST', + isBase64Encoded: false, + path: '/v1/jira/webhook', + pathParameters: null, + queryStringParameters: null, + multiValueQueryStringParameters: null, + stageVariables: null, + requestContext: {} as APIGatewayProxyEvent['requestContext'], + resource: '', + }; +} + +function payloadFor(cloudId: string): string { + return JSON.stringify({ + webhookEvent: 'jira:issue_created', + timestamp: Date.now(), + cloudId, + user: { accountId: 'acc-1' }, + issue: { + id: '10001', + key: 'ENG-42', + fields: { labels: ['bgagent'], project: { id: 'p1', key: 'ENG' } }, + }, + }); +} + +interface StoredOauthFixture { + readonly access_token: string; + readonly refresh_token: string; + readonly expires_at: string; + readonly scope: string; + 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; + readonly webhook_signing_secret?: string; +} + +function makeStoredOauth(overrides: Partial = {}): StoredOauthFixture { + return { + access_token: 'jira_at_xxx', + refresh_token: 'jira_rt_xxx', + expires_at: new Date(Date.now() + 12 * 3600 * 1000).toISOString(), + scope: 'read:jira-work write:jira-work read:jira-user', + client_id: 'cid', + client_secret: 'csec', + cloud_id: 'cloud-default', + site_url: 'https://acme.atlassian.net', + installed_at: '2026-06-09T08:00:00Z', + updated_at: '2026-06-09T08:00:00Z', + installed_by_platform_user_id: 'cog-sub', + ...overrides, + }; +} + +/** Wire the SM mock to respond by SecretId. */ +function configureSecretsManager(secrets: Record) { + smSend.mockImplementation((cmd: { input: { SecretId: string } }) => { + const id = cmd.input.SecretId; + const value = secrets[id]; + if (value === undefined) { + const err = new Error(`SecretId not mocked: ${id}`); + (err as Error & { name: string }).name = 'ResourceNotFoundException'; + return Promise.reject(err); + } + return Promise.resolve({ + SecretString: typeof value === 'string' ? value : JSON.stringify(value), + }); + }); +} + +/** Wire DDB to return registry rows by `jira_cloud_id`. The parser requires + * `site_url` + `oauth_secret_arn` — a row missing either is treated as a + * registry miss, so the fixture always sets both. */ +function configureRegistry(rows: Record) { + ddbSend.mockImplementation((cmd: { _type?: string; input: Record }) => { + if (cmd._type === 'Get') { + const key = cmd.input.Key as { jira_cloud_id?: string } | undefined; + const cloudId = key?.jira_cloud_id; + const item = cloudId ? rows[cloudId] : undefined; + return Promise.resolve(item + ? { Item: { jira_cloud_id: cloudId, site_url: 'https://acme.atlassian.net', ...item } } + : { Item: undefined }); + } + // Dedup Put / rollback Delete — succeed. + return Promise.resolve({}); + }); +} + +/** Extract the processor-invoke payload the receiver dispatched. */ +function dispatchedPayload(): { raw_body: string; verified_via?: string } { + expect(lambdaSend).toHaveBeenCalledTimes(1); + const invoke = lambdaSend.mock.calls[0][0] as { input: { Payload: Uint8Array } }; + return JSON.parse(new TextDecoder().decode(invoke.input.Payload)) as { + raw_body: string; + verified_via?: string; + }; +} + +describe('jira-webhook handler — per-tenant signature verification', () => { + beforeEach(() => { + ddbSend.mockReset(); + smSend.mockReset(); + lambdaSend.mockReset(); + invalidateJiraSecretCache(process.env.JIRA_WEBHOOK_SECRET_ARN!); + _resetCachesForTesting(); + lambdaSend.mockResolvedValue({}); + }); + + test('verifies tenant A using its per-tenant signing secret and forwards verified_via=per-tenant', async () => { + configureRegistry({ + [TENANT_A_CLOUD_ID]: { oauth_secret_arn: TENANT_A_SECRET_ARN, status: 'active' }, + }); + configureSecretsManager({ + [TENANT_A_SECRET_ARN]: makeStoredOauth({ + cloud_id: TENANT_A_CLOUD_ID, + webhook_signing_secret: TENANT_A_SECRET, + }), + }); + const body = payloadFor(TENANT_A_CLOUD_ID); + const result = await handler(makeEvent(body, sign(TENANT_A_SECRET, body))); + expect(result.statusCode).toBe(200); + expect(dispatchedPayload().verified_via).toBe('per-tenant'); + }); + + test('verifies tenant B using its DIFFERENT per-tenant signing secret', async () => { + configureRegistry({ + [TENANT_B_CLOUD_ID]: { oauth_secret_arn: TENANT_B_SECRET_ARN, status: 'active' }, + }); + configureSecretsManager({ + [TENANT_B_SECRET_ARN]: makeStoredOauth({ + cloud_id: TENANT_B_CLOUD_ID, + webhook_signing_secret: TENANT_B_SECRET, + }), + }); + const body = payloadFor(TENANT_B_CLOUD_ID); + const result = await handler(makeEvent(body, sign(TENANT_B_SECRET, body))); + expect(result.statusCode).toBe(200); + expect(lambdaSend).toHaveBeenCalledTimes(1); + }); + + test("rejects tenant A signed with tenant B's secret (per-tenant mismatch is fatal)", async () => { + // The CRITICAL test: an attacker who learns tenant B's signing secret + // cannot dispatch as tenant A by claiming A's cloudId. The receiver + // locks the per-tenant path once it finds A's secret and refuses to + // fall back to the stack-wide secret. + configureRegistry({ + [TENANT_A_CLOUD_ID]: { oauth_secret_arn: TENANT_A_SECRET_ARN, status: 'active' }, + }); + configureSecretsManager({ + [TENANT_A_SECRET_ARN]: makeStoredOauth({ + cloud_id: TENANT_A_CLOUD_ID, + webhook_signing_secret: TENANT_A_SECRET, + }), + [process.env.JIRA_WEBHOOK_SECRET_ARN!]: STACK_WIDE_SECRET, + }); + const body = payloadFor(TENANT_A_CLOUD_ID); + const result = await handler(makeEvent(body, sign(TENANT_B_SECRET, body))); + expect(result.statusCode).toBe(401); + expect(lambdaSend).not.toHaveBeenCalled(); + }); + + test('falls back to stack-wide secret when registry has no row for the cloudId — verified_via=stack-wide', async () => { + // Back-compat: a tenant onboarded before per-tenant signing has no + // registry row. The receiver verifies stack-wide and tags the dispatch + // so the processor treats the body cloudId as untrusted. + configureRegistry({}); // registry miss + configureSecretsManager({ + [process.env.JIRA_WEBHOOK_SECRET_ARN!]: STACK_WIDE_SECRET, + }); + const body = payloadFor('cloud-not-onboarded'); + const result = await handler(makeEvent(body, sign(STACK_WIDE_SECRET, body))); + expect(result.statusCode).toBe(200); + expect(dispatchedPayload().verified_via).toBe('stack-wide'); + }); + + test('falls back to stack-wide secret when per-tenant bundle has no webhook_signing_secret field', async () => { + // Migration mid-state: tenant registered, but its OAuth bundle has no + // signing secret yet. Stack-wide remains the source of truth. + configureRegistry({ + [TENANT_A_CLOUD_ID]: { oauth_secret_arn: TENANT_A_SECRET_ARN, status: 'active' }, + }); + configureSecretsManager({ + [TENANT_A_SECRET_ARN]: makeStoredOauth({ + cloud_id: TENANT_A_CLOUD_ID, + // No webhook_signing_secret — pre-migration bundle. + }), + [process.env.JIRA_WEBHOOK_SECRET_ARN!]: STACK_WIDE_SECRET, + }); + const body = payloadFor(TENANT_A_CLOUD_ID); + const result = await handler(makeEvent(body, sign(STACK_WIDE_SECRET, body))); + expect(result.statusCode).toBe(200); + expect(dispatchedPayload().verified_via).toBe('stack-wide'); + }); + + test('rejects when registry status is not active even if per-tenant secret matches', async () => { + configureRegistry({ + [TENANT_A_CLOUD_ID]: { oauth_secret_arn: TENANT_A_SECRET_ARN, status: 'revoked' }, + }); + configureSecretsManager({ + [TENANT_A_SECRET_ARN]: makeStoredOauth({ + cloud_id: TENANT_A_CLOUD_ID, + webhook_signing_secret: TENANT_A_SECRET, + }), + [process.env.JIRA_WEBHOOK_SECRET_ARN!]: STACK_WIDE_SECRET, + }); + const body = payloadFor(TENANT_A_CLOUD_ID); + const result = await handler(makeEvent(body, sign(TENANT_A_SECRET, body))); + expect(result.statusCode).toBe(401); + expect(lambdaSend).not.toHaveBeenCalled(); + }); + + test('revoked tenant rejected even when the stack-wide secret matches the request', async () => { + // Critical security test: if a revoked tenant's old secret equals the + // stack-wide secret (e.g. it was the first tenant, whose secret seeded + // the stack-wide fallback), the receiver must NOT silently fall through + // to stack-wide verification and re-grant access. The distinct + // `revoked` outcome pins the no-fallback rule. + configureRegistry({ + [TENANT_A_CLOUD_ID]: { oauth_secret_arn: TENANT_A_SECRET_ARN, status: 'revoked' }, + }); + configureSecretsManager({ + [TENANT_A_SECRET_ARN]: makeStoredOauth({ + cloud_id: TENANT_A_CLOUD_ID, + webhook_signing_secret: TENANT_A_SECRET, + }), + // Stack-wide secret == tenant A's secret (the bypass scenario). + [process.env.JIRA_WEBHOOK_SECRET_ARN!]: TENANT_A_SECRET, + }); + const body = payloadFor(TENANT_A_CLOUD_ID); + const result = await handler(makeEvent(body, sign(TENANT_A_SECRET, body))); + expect(result.statusCode).toBe(401); + expect(lambdaSend).not.toHaveBeenCalled(); + }); + + test('payload without cloudId skips per-tenant lookup and verifies stack-wide', async () => { + // Settings-UI webhooks omit cloudId entirely. The receiver can't do a + // per-tenant lookup, verifies stack-wide, and the processor later binds + // the delivery to the sole active tenant. + configureRegistry({ + [TENANT_A_CLOUD_ID]: { oauth_secret_arn: TENANT_A_SECRET_ARN, status: 'active' }, + }); + configureSecretsManager({ + [process.env.JIRA_WEBHOOK_SECRET_ARN!]: STACK_WIDE_SECRET, + }); + const body = JSON.stringify({ + webhookEvent: 'jira:issue_created', + timestamp: Date.now(), + issue: { id: '10001', key: 'ENG-42', fields: { labels: ['bgagent'], project: { key: 'ENG' } } }, + }); + const result = await handler(makeEvent(body, sign(STACK_WIDE_SECRET, body))); + expect(result.statusCode).toBe(200); + expect(dispatchedPayload().verified_via).toBe('stack-wide'); + // No registry Get should have fired — there was no cloudId to look up. + expect(ddbSend.mock.calls.every((c) => (c[0] as { _type?: string })._type !== 'Get')).toBe(true); + }); + + test('infra error during per-tenant lookup surfaces as 500 (no silent stack-wide downgrade)', async () => { + // A DDB throttle on the registry table must NOT collapse to the + // stack-wide fallback — that would silently downgrade a per-tenant- + // secured tenant under load. Strict lookups bubble the error so the + // receiver returns 500 and Atlassian retries. + ddbSend.mockImplementation((cmd: { _type?: string }) => { + if (cmd._type === 'Get') { + const err = new Error('Throttled'); + (err as Error & { name: string }).name = 'ProvisionedThroughputExceededException'; + return Promise.reject(err); + } + return Promise.resolve({}); + }); + configureSecretsManager({ + [process.env.JIRA_WEBHOOK_SECRET_ARN!]: TENANT_A_SECRET, + }); + const body = payloadFor(TENANT_A_CLOUD_ID); + const result = await handler(makeEvent(body, sign(TENANT_A_SECRET, body))); + expect(result.statusCode).toBe(500); + expect(lambdaSend).not.toHaveBeenCalled(); + }); + + test('missing timestamp skips the replay check but still dispatches (logged bypass)', async () => { + // Atlassian's documented issue events always carry `timestamp`, but the + // envelope field is optional. A timestamp-less delivery must not crash + // or fail verification — it dispatches with the `…#unknown` dedup key. + configureRegistry({ + [TENANT_A_CLOUD_ID]: { oauth_secret_arn: TENANT_A_SECRET_ARN, status: 'active' }, + }); + configureSecretsManager({ + [TENANT_A_SECRET_ARN]: makeStoredOauth({ + cloud_id: TENANT_A_CLOUD_ID, + webhook_signing_secret: TENANT_A_SECRET, + }), + }); + const body = JSON.stringify({ + webhookEvent: 'jira:issue_created', + cloudId: TENANT_A_CLOUD_ID, + issue: { id: '10001', key: 'ENG-42', fields: { labels: ['bgagent'], project: { key: 'ENG' } } }, + }); + const result = await handler(makeEvent(body, sign(TENANT_A_SECRET, body))); + expect(result.statusCode).toBe(200); + expect(lambdaSend).toHaveBeenCalledTimes(1); + }); +}); diff --git a/cdk/test/handlers/jira-webhook-processor.test.ts b/cdk/test/handlers/jira-webhook-processor.test.ts index aeac99c8..8289d822 100644 --- a/cdk/test/handlers/jira-webhook-processor.test.ts +++ b/cdk/test/handlers/jira-webhook-processor.test.ts @@ -46,8 +46,16 @@ process.env.JIRA_WORKSPACE_REGISTRY_TABLE_NAME = 'JiraWorkspaceRegistry'; import { handler } from '../../src/handlers/jira-webhook-processor'; -function eventWith(payload: Record): { raw_body: string } { - return { raw_body: JSON.stringify(payload) }; +/** + * Default to `verified_via: 'per-tenant'` — the receiver only forwards a + * payload `cloudId` as trustworthy on that path. Stack-wide-verification + * tests override `verified_via` explicitly. + */ +function eventWith( + payload: Record, + verifiedVia: 'per-tenant' | 'stack-wide' = 'per-tenant', +): { raw_body: string; verified_via: 'per-tenant' | 'stack-wide' } { + return { raw_body: JSON.stringify(payload), verified_via: verifiedVia }; } /** Build a minimal `jira:issue_created` payload with the trigger label @@ -166,6 +174,58 @@ describe('jira-webhook-processor handler', () => { expect(createTaskCoreMock).toHaveBeenCalled(); }); + describe('cloudId trust binding (verified_via)', () => { + // The receiver only proves cloudId↔secret binding on the per-tenant + // path. On stack-wide verification the body's cloudId is attacker- + // controllable (anyone holding the shared secret can claim any tenant), + // so the processor must resolve the tenant itself. + + test('stack-wide verification ignores body cloudId and binds to the sole active tenant', async () => { + // Payload claims cloud-1; the registry's sole active tenant IS + // cloud-1, so the delivery proceeds under that tenant. + ddbSend + .mockResolvedValueOnce({ Items: [{ jira_cloud_id: 'cloud-1', status: 'active' }] }) // Scan + .mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active', label_filter: 'bgagent' } }) + .mockResolvedValueOnce({ Item: { platform_user_id: 'user-1', status: 'active' } }); + createTaskCoreMock.mockResolvedValue({ statusCode: 201, body: '{}' }); + await handler(eventWith(issue(), 'stack-wide')); + expect(createTaskCoreMock).toHaveBeenCalled(); + // First DDB call must be the registry Scan — i.e. the processor did + // NOT shortcut to the payload cloudId. + expect(ddbSend.mock.calls[0][0]._type).toBe('Scan'); + }); + + test('stack-wide verification drops a payload whose cloudId names a DIFFERENT tenant', async () => { + // The CRITICAL test: a holder of the stack-wide secret claims + // cloud-evil while the sole active tenant is cloud-1. The processor + // must drop rather than steer mappings/OAuth at cloud-evil. + const payload = issue({ cloudId: 'cloud-evil' }); + ddbSend.mockResolvedValueOnce({ Items: [{ jira_cloud_id: 'cloud-1', status: 'active' }] }); + await handler(eventWith(payload, 'stack-wide')); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + expect(reportIssueFailureMock).not.toHaveBeenCalled(); + }); + + test('stack-wide verification with multiple active tenants drops even when body has a cloudId', async () => { + ddbSend.mockResolvedValueOnce({ + Items: [ + { jira_cloud_id: 'cloud-1', status: 'active' }, + { jira_cloud_id: 'cloud-2', status: 'active' }, + ], + }); + await handler(eventWith(issue(), 'stack-wide')); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + }); + + test('missing verified_via (in-flight deliveries from an older receiver) is treated as untrusted', async () => { + // Back-compat path: an event enqueued by the pre-binding receiver has + // no verified_via. It must get stack-wide (untrusted) semantics. + ddbSend.mockResolvedValueOnce({ Items: [{ jira_cloud_id: 'cloud-other', status: 'active' }] }); + await handler({ raw_body: JSON.stringify(issue()) }); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + }); + }); + test('skips when project is not onboarded', async () => { ddbSend.mockResolvedValueOnce({ Item: undefined }); await handler(eventWith(issue())); @@ -370,7 +430,7 @@ describe('jira-webhook-processor handler', () => { const [, issueKey, message] = reportIssueFailureMock.mock.calls[0]; expect(issueKey).toBe('ENG-42'); expect(message).toContain("isn't onboarded"); - expect(message).toContain('bgagent jira onboard-project'); + expect(message).toContain('bgagent jira map'); }); test('posts feedback when project mapping is removed', async () => { diff --git a/cdk/test/handlers/orchestrate-task-feedback.test.ts b/cdk/test/handlers/orchestrate-task-feedback.test.ts index 6a89a846..d4b35069 100644 --- a/cdk/test/handlers/orchestrate-task-feedback.test.ts +++ b/cdk/test/handlers/orchestrate-task-feedback.test.ts @@ -30,6 +30,11 @@ jest.mock('../../src/handlers/shared/linear-feedback', () => ({ reportIssueFailure: (...args: unknown[]) => reportIssueFailureMock(...args), })); +const reportJiraIssueFailureMock = jest.fn(); +jest.mock('../../src/handlers/shared/jira-feedback', () => ({ + reportIssueFailure: (...args: unknown[]) => reportJiraIssueFailureMock(...args), +})); + // Stub the unused-but-imported orchestrator helpers so module-init side // effects don't try to talk to AWS. jest.mock('../../src/handlers/shared/orchestrator', () => ({ @@ -51,8 +56,9 @@ jest.mock('../../src/handlers/shared/compute-strategy', () => ({ })); process.env.LINEAR_WORKSPACE_REGISTRY_TABLE_NAME = 'LinearWorkspaceRegistry'; +process.env.JIRA_WORKSPACE_REGISTRY_TABLE_NAME = 'JiraWorkspaceRegistry'; -import { notifyLinearOnConcurrencyCap } from '../../src/handlers/orchestrate-task'; +import { notifyJiraOnConcurrencyCap, notifyLinearOnConcurrencyCap } from '../../src/handlers/orchestrate-task'; import type { TaskRecord } from '../../src/handlers/shared/types'; function task(overrides: Partial = {}): TaskRecord { @@ -157,3 +163,77 @@ describe('notifyLinearOnConcurrencyCap', () => { expect(reportIssueFailureMock).toHaveBeenCalledTimes(1); }); }); + +describe('notifyJiraOnConcurrencyCap', () => { + beforeEach(() => { + reportJiraIssueFailureMock.mockReset(); + reportJiraIssueFailureMock.mockResolvedValue(undefined); + }); + + test('posts Jira comment when channel_source is jira and issue key + cloudId are set', async () => { + await notifyJiraOnConcurrencyCap(task({ + channel_source: 'jira', + channel_metadata: { + jira_issue_key: 'ENG-42', + jira_cloud_id: 'cloud-1', + }, + })); + + expect(reportJiraIssueFailureMock).toHaveBeenCalledTimes(1); + const [ctx, issueKey, message] = reportJiraIssueFailureMock.mock.calls[0]; + expect(ctx).toEqual({ + cloudId: 'cloud-1', + registryTableName: process.env.JIRA_WORKSPACE_REGISTRY_TABLE_NAME, + }); + expect(issueKey).toBe('ENG-42'); + expect(message).toContain('concurrency limit'); + }); + + test('no-ops on non-Jira channels (api / webhook / slack / linear)', async () => { + for (const source of ['api', 'webhook', 'slack', 'linear'] as const) { + reportJiraIssueFailureMock.mockClear(); + await notifyJiraOnConcurrencyCap(task({ + channel_source: source, + channel_metadata: { jira_issue_key: 'ENG-42', jira_cloud_id: 'cloud-1' }, + })); + expect(reportJiraIssueFailureMock).not.toHaveBeenCalled(); + } + }); + + test('no-ops when channel_metadata is missing the issue key or cloudId (defensive)', async () => { + await notifyJiraOnConcurrencyCap(task({ + channel_source: 'jira', + channel_metadata: { jira_cloud_id: 'cloud-1' }, // no jira_issue_key + })); + await notifyJiraOnConcurrencyCap(task({ + channel_source: 'jira', + channel_metadata: { jira_issue_key: 'ENG-42' }, // no jira_cloud_id + })); + expect(reportJiraIssueFailureMock).not.toHaveBeenCalled(); + }); + + test('no-ops when JIRA_WORKSPACE_REGISTRY_TABLE_NAME env is not set (logs warn)', async () => { + const saved = process.env.JIRA_WORKSPACE_REGISTRY_TABLE_NAME; + delete process.env.JIRA_WORKSPACE_REGISTRY_TABLE_NAME; + try { + await notifyJiraOnConcurrencyCap(task({ + channel_source: 'jira', + channel_metadata: { jira_issue_key: 'ENG-42', jira_cloud_id: 'cloud-1' }, + })); + expect(reportJiraIssueFailureMock).not.toHaveBeenCalled(); + } finally { + process.env.JIRA_WORKSPACE_REGISTRY_TABLE_NAME = saved; + } + }); + + test('reportIssueFailure rejection is swallowed (best-effort, never blocks rejection path)', async () => { + reportJiraIssueFailureMock.mockRejectedValue(new Error('boom')); + await expect( + notifyJiraOnConcurrencyCap(task({ + channel_source: 'jira', + channel_metadata: { jira_issue_key: 'ENG-42', jira_cloud_id: 'cloud-1' }, + })), + ).resolves.toBeUndefined(); + expect(reportJiraIssueFailureMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/cdk/test/handlers/shared/jira-feedback.test.ts b/cdk/test/handlers/shared/jira-feedback.test.ts new file mode 100644 index 00000000..9a88e8bd --- /dev/null +++ b/cdk/test/handlers/shared/jira-feedback.test.ts @@ -0,0 +1,153 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +const resolveJiraOauthTokenMock = jest.fn(); +jest.mock('../../../src/handlers/shared/jira-oauth-resolver', () => ({ + resolveJiraOauthToken: (...args: unknown[]) => resolveJiraOauthTokenMock(...args), +})); + +const fetchMock = jest.fn(); +// `fetch` is a global on Node 24; reassign for test isolation. +(globalThis as unknown as { fetch: jest.Mock }).fetch = fetchMock; + +import { + type JiraFeedbackContext, + postIssueComment, + reportIssueFailure, +} from '../../../src/handlers/shared/jira-feedback'; + +const CTX: JiraFeedbackContext = { + cloudId: 'cloud-1', + registryTableName: 'TestJiraWorkspaceRegistry', +}; +const ISSUE_KEY = 'ENG-42'; +const TOKEN = 'jira_oauth_TESTTOKEN'; + +function jsonResponse(body: unknown, status: number = 201): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + } as unknown as Response; +} + +describe('jira-feedback', () => { + beforeEach(() => { + resolveJiraOauthTokenMock.mockReset(); + fetchMock.mockReset(); + resolveJiraOauthTokenMock.mockResolvedValue({ + accessToken: TOKEN, + scope: 'read:jira-work write:jira-work', + siteUrl: 'https://acme.atlassian.net', + oauthSecretArn: 'arn:secret:acme', + }); + fetchMock.mockResolvedValue(jsonResponse({ id: '12345' })); + }); + + describe('postIssueComment', () => { + test('POSTs an ADF document body to the REST v3 comment endpoint', async () => { + const ok = await postIssueComment(CTX, ISSUE_KEY, '❌ blocked'); + + expect(ok).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`https://acme.atlassian.net/rest/api/3/issue/${ISSUE_KEY}/comment`); + expect(init.method).toBe('POST'); + expect(init.headers).toMatchObject({ + 'Authorization': `Bearer ${TOKEN}`, + 'Content-Type': 'application/json', + }); + // Jira REST v3 400s on non-ADF comment bodies — pin the exact shape. + const body = JSON.parse(init.body as string) as { body: Record }; + expect(body.body).toEqual({ + type: 'doc', + version: 1, + content: [ + { type: 'paragraph', content: [{ type: 'text', text: '❌ blocked' }] }, + ], + }); + }); + + test('strips a trailing slash from the stored siteUrl', async () => { + resolveJiraOauthTokenMock.mockResolvedValue({ + accessToken: TOKEN, + scope: 'write:jira-work', + siteUrl: 'https://acme.atlassian.net/', + oauthSecretArn: 'arn:secret:acme', + }); + await postIssueComment(CTX, ISSUE_KEY, 'msg'); + expect(fetchMock.mock.calls[0][0]).toBe( + `https://acme.atlassian.net/rest/api/3/issue/${ISSUE_KEY}/comment`, + ); + }); + + test('URL-encodes the issue key', async () => { + await postIssueComment(CTX, 'ENG 42/x', 'msg'); + expect(fetchMock.mock.calls[0][0]).toBe( + 'https://acme.atlassian.net/rest/api/3/issue/ENG%2042%2Fx/comment', + ); + }); + + test('returns false on non-2xx without throwing', async () => { + fetchMock.mockResolvedValue(jsonResponse({ errorMessages: ['nope'] }, 400)); + const ok = await postIssueComment(CTX, ISSUE_KEY, 'msg'); + expect(ok).toBe(false); + }); + + test('returns false on network failure without throwing', async () => { + fetchMock.mockRejectedValue(new Error('ECONNRESET')); + const ok = await postIssueComment(CTX, ISSUE_KEY, 'msg'); + expect(ok).toBe(false); + }); + + test('returns false on request timeout (AbortError) without throwing', async () => { + const abortErr = new Error('This operation was aborted'); + abortErr.name = 'AbortError'; + fetchMock.mockRejectedValue(abortErr); + const ok = await postIssueComment(CTX, ISSUE_KEY, 'msg'); + expect(ok).toBe(false); + }); + + test('returns false when the tenant token cannot be resolved', async () => { + resolveJiraOauthTokenMock.mockResolvedValue(null); + const ok = await postIssueComment(CTX, ISSUE_KEY, 'msg'); + expect(ok).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('returns false when the token resolver throws (never propagates)', async () => { + resolveJiraOauthTokenMock.mockRejectedValue(new Error('DDB throttle')); + const ok = await postIssueComment(CTX, ISSUE_KEY, 'msg'); + expect(ok).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + + describe('reportIssueFailure', () => { + test('posts the message and resolves void on success', async () => { + await expect(reportIssueFailure(CTX, ISSUE_KEY, '❌ failed')).resolves.toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test('never rejects even when everything fails', async () => { + resolveJiraOauthTokenMock.mockRejectedValue(new Error('boom')); + await expect(reportIssueFailure(CTX, ISSUE_KEY, '❌ failed')).resolves.toBeUndefined(); + }); + }); +}); diff --git a/cli/src/commands/jira.ts b/cli/src/commands/jira.ts index 698b2b0f..3f824821 100644 --- a/cli/src/commands/jira.ts +++ b/cli/src/commands/jira.ts @@ -495,55 +495,51 @@ export function makeJiraCommand(): Command { })); console.log(' ✓ Recorded tenant in registry'); - // ─── Step 6: Webhook signing secret (per-tenant + stack-wide) ───── + // ─── Step 6: Webhook signing secret (per-tenant first) ─────────── // // 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). + // operator-chosen at webhook-create time in the Jira admin UI. Each + // tenant gets its OWN secret, stored on the per-tenant OAuth bundle + // (the primary verification path). The stack-wide secret is only + // populated when still unset (first tenant) — it exists for + // Settings-UI webhooks, whose payloads omit `cloudId` and therefore + // can't be verified per-tenant. It is deliberately NEVER copied + // from the stack-wide value into per-tenant bundles: doing so would + // let one shared secret verify *as any tenant*, breaking the + // binding between the verified secret and the payload's cloudId. + const apiBaseUrl = config.api_url.replace(/\/+$/, ''); + console.log(); + console.log(' Webhook signing secret needed for this tenant.'); + console.log(' In Jira → Settings → System → Webhooks → Create a Webhook:'); + console.log(` URL: ${apiBaseUrl}/jira/webhook`); + console.log(' Events: Issue: created, updated'); + console.log(' Secret: choose a strong random value (e.g. `openssl rand -hex 32`)'); + console.log(); + const webhookSecret = (await promptSecret('Webhook signing secret: ')).trim(); + if (!webhookSecret) { + throw new CliError('Webhook signing secret is required.'); + } + + const merged: StoredJiraOauthToken = { + ...stored, + webhook_signing_secret: webhookSecret, + updated_at: new Date().toISOString(), + }; + await upsertOauthSecret(sm, secretName, merged, cloudId); + console.log(' ✓ Stored signing secret on the per-tenant OAuth bundle'); + 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.'); - } + if (!stackWideAlreadyConfigured) { 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'); + console.log(' ✓ Stored webhook signing secret stack-wide (covers Settings-UI webhooks, which omit cloudId)'); + } else { + console.log(' ✓ Stack-wide webhook secret already configured — left unchanged.'); + console.log(' Multi-tenant note: Settings-UI webhooks omit cloudId and verify only against the'); + console.log(' stack-wide secret (first tenant). Additional tenants must use a webhook that'); + console.log(' carries its own cloudId (e.g. an OAuth-app registered dynamic webhook).'); } // ─── Done ───────────────────────────────────────────────────────── @@ -552,7 +548,7 @@ export function makeJiraCommand(): Command { 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(` bgagent jira map ${cloudId} --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.)'); diff --git a/cli/test/jira-oauth.test.ts b/cli/test/jira-oauth.test.ts new file mode 100644 index 00000000..fd00f881 --- /dev/null +++ b/cli/test/jira-oauth.test.ts @@ -0,0 +1,307 @@ +/** + * 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 { CliError } from '../src/errors'; +import { + buildAuthorizationUrl, + computeExpiresAt, + exchangeAuthorizationCode, + fetchAccessibleResources, + generatePkce, + isAccessTokenExpiring, + JIRA_AUTHORIZE_ENDPOINT, + JIRA_OAUTH_SCOPES, + JIRA_TOKEN_ENDPOINT, + jiraOauthSecretName, + refreshAccessToken, +} from '../src/jira-oauth'; + +describe('jiraOauthSecretName', () => { + test('prefixes with bgagent-jira-oauth-', () => { + expect(jiraOauthSecretName('cloud-1')).toBe('bgagent-jira-oauth-cloud-1'); + expect(jiraOauthSecretName('11112222-3333-4444-5555-666677778888')) + .toBe('bgagent-jira-oauth-11112222-3333-4444-5555-666677778888'); + }); +}); + +describe('JIRA_OAUTH_SCOPES', () => { + test('matches the documented v1 scope set including offline_access', () => { + // Locked: dropping offline_access means no refresh_token and the + // integration cannot self-renew (setup hard-fails on its absence). + expect(JIRA_OAUTH_SCOPES).toEqual([ + 'read:jira-work', + 'write:jira-work', + 'read:jira-user', + 'offline_access', + ]); + }); +}); + +describe('generatePkce', () => { + test('produces base64url-encoded verifier and SHA-256 challenge', () => { + const { codeVerifier, codeChallenge } = generatePkce(); + expect(codeVerifier).toMatch(/^[A-Za-z0-9_-]+$/); + expect(codeChallenge).toMatch(/^[A-Za-z0-9_-]+$/); + // base64url-encoded SHA-256 = 43 chars (256 bits / 6 bits per char, no padding) + expect(codeChallenge.length).toBe(43); + }); + + test('generates fresh values on each call', () => { + const a = generatePkce(); + const b = generatePkce(); + expect(a.codeVerifier).not.toBe(b.codeVerifier); + expect(a.codeChallenge).not.toBe(b.codeChallenge); + }); +}); + +describe('buildAuthorizationUrl', () => { + const base = { + clientId: 'client-1', + redirectUri: 'http://localhost:8080/oauth/callback', + state: 'state-xyz', + codeChallenge: 'challenge-abc', + }; + + test('targets the Atlassian authorize endpoint with the required params', () => { + const url = new URL(buildAuthorizationUrl(base)); + expect(`${url.origin}${url.pathname}`).toBe(JIRA_AUTHORIZE_ENDPOINT); + // `audience` is Atlassian-specific and REQUIRED — omitting it yields a + // confusing invalid_client at the consent screen. + expect(url.searchParams.get('audience')).toBe('api.atlassian.com'); + expect(url.searchParams.get('client_id')).toBe('client-1'); + expect(url.searchParams.get('redirect_uri')).toBe(base.redirectUri); + expect(url.searchParams.get('response_type')).toBe('code'); + expect(url.searchParams.get('state')).toBe('state-xyz'); + expect(url.searchParams.get('code_challenge')).toBe('challenge-abc'); + expect(url.searchParams.get('code_challenge_method')).toBe('S256'); + expect(url.searchParams.get('prompt')).toBe('consent'); + }); + + test('defaults to the full scope set (space-joined)', () => { + const url = new URL(buildAuthorizationUrl(base)); + expect(url.searchParams.get('scope')).toBe(JIRA_OAUTH_SCOPES.join(' ')); + }); + + test('honors a custom scope list', () => { + const url = new URL(buildAuthorizationUrl({ ...base, scopes: ['read:jira-work'] })); + expect(url.searchParams.get('scope')).toBe('read:jira-work'); + }); +}); + +describe('isAccessTokenExpiring', () => { + test('false when expiry is comfortably in the future', () => { + const future = new Date(Date.now() + 3600 * 1000).toISOString(); + expect(isAccessTokenExpiring(future)).toBe(false); + }); + + test('true when expiry is inside the 60s threshold', () => { + const soon = new Date(Date.now() + 30 * 1000).toISOString(); + expect(isAccessTokenExpiring(soon)).toBe(true); + }); + + test('true when expiry is in the past', () => { + const past = new Date(Date.now() - 1000).toISOString(); + expect(isAccessTokenExpiring(past)).toBe(true); + }); + + test('true (fail-safe) on an unparsable timestamp', () => { + expect(isAccessTokenExpiring('not-a-date')).toBe(true); + }); +}); + +describe('computeExpiresAt', () => { + test('adds expires_in seconds to now', () => { + const now = new Date('2026-06-11T00:00:00.000Z'); + expect(computeExpiresAt(3600, now)).toBe('2026-06-11T01:00:00.000Z'); + }); +}); + +function jsonResponse(body: unknown, status: number = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + } as unknown as Response; +} + +const TOKEN_OK = { + access_token: 'jira_at_1', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'jira_rt_1', + scope: 'read:jira-work write:jira-work read:jira-user offline_access', +}; + +describe('exchangeAuthorizationCode', () => { + test('POSTs a JSON body (NOT form-encoded — the Atlassian divergence from Linear)', async () => { + const fetchImpl = jest.fn().mockResolvedValue(jsonResponse(TOKEN_OK)); + const result = await exchangeAuthorizationCode({ + code: 'auth-code', + codeVerifier: 'verifier', + redirectUri: 'http://localhost:8080/oauth/callback', + clientId: 'cid', + clientSecret: 'csec', + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + expect(result).toEqual(TOKEN_OK); + const [url, init] = fetchImpl.mock.calls[0]; + expect(url).toBe(JIRA_TOKEN_ENDPOINT); + expect(init.method).toBe('POST'); + expect(init.headers).toMatchObject({ 'Content-Type': 'application/json' }); + const body = JSON.parse(init.body as string) as Record; + expect(body).toEqual({ + grant_type: 'authorization_code', + code: 'auth-code', + code_verifier: 'verifier', + redirect_uri: 'http://localhost:8080/oauth/callback', + client_id: 'cid', + client_secret: 'csec', + }); + }); + + test('throws CliError with error_description on a 4xx token response', async () => { + const fetchImpl = jest.fn().mockResolvedValue(jsonResponse( + { error: 'invalid_grant', error_description: 'authorization code expired' }, + 400, + )); + await expect(exchangeAuthorizationCode({ + code: 'stale', + codeVerifier: 'v', + redirectUri: 'r', + clientId: 'c', + clientSecret: 's', + fetchImpl: fetchImpl as unknown as typeof fetch, + })).rejects.toThrow(/invalid_grant: authorization code expired/); + }); + + test('throws CliError on a non-JSON response', async () => { + const fetchImpl = jest.fn().mockResolvedValue({ + ok: false, + status: 502, + json: async () => { throw new Error('not json'); }, + } as unknown as Response); + await expect(exchangeAuthorizationCode({ + code: 'c', + codeVerifier: 'v', + redirectUri: 'r', + clientId: 'c', + clientSecret: 's', + fetchImpl: fetchImpl as unknown as typeof fetch, + })).rejects.toThrow(CliError); + }); + + test('throws CliError when the response shape is missing access_token', async () => { + const fetchImpl = jest.fn().mockResolvedValue(jsonResponse({ token_type: 'Bearer' })); + await expect(exchangeAuthorizationCode({ + code: 'c', + codeVerifier: 'v', + redirectUri: 'r', + clientId: 'c', + clientSecret: 's', + fetchImpl: fetchImpl as unknown as typeof fetch, + })).rejects.toThrow(/unexpected shape/); + }); + + test('tolerates a missing refresh_token at this layer (setup enforces it later)', async () => { + // The exchange itself returns whatever Atlassian sent; the hard + // requirement for refresh_token is enforced by `bgagent jira setup` + // so the error message can explain the offline_access fix. + const { refresh_token: _refreshToken, ...withoutRefresh } = TOKEN_OK; + const fetchImpl = jest.fn().mockResolvedValue(jsonResponse(withoutRefresh)); + const result = await exchangeAuthorizationCode({ + code: 'c', + codeVerifier: 'v', + redirectUri: 'r', + clientId: 'c', + clientSecret: 's', + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(result.refresh_token).toBeUndefined(); + }); +}); + +describe('refreshAccessToken', () => { + test('POSTs a refresh_token grant as JSON', async () => { + const rotated = { ...TOKEN_OK, access_token: 'jira_at_2', refresh_token: 'jira_rt_2' }; + const fetchImpl = jest.fn().mockResolvedValue(jsonResponse(rotated)); + const result = await refreshAccessToken({ + refreshToken: 'jira_rt_1', + clientId: 'cid', + clientSecret: 'csec', + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + // Atlassian ROTATES refresh tokens on every refresh — callers must + // persist the new one. Pin that the rotated value comes through. + expect(result.access_token).toBe('jira_at_2'); + expect(result.refresh_token).toBe('jira_rt_2'); + const [url, init] = fetchImpl.mock.calls[0]; + expect(url).toBe(JIRA_TOKEN_ENDPOINT); + const body = JSON.parse(init.body as string) as Record; + expect(body).toEqual({ + grant_type: 'refresh_token', + refresh_token: 'jira_rt_1', + client_id: 'cid', + client_secret: 'csec', + }); + }); + + test('surfaces invalid_grant (revoked/expired refresh token) as CliError', async () => { + const fetchImpl = jest.fn().mockResolvedValue(jsonResponse( + { error: 'invalid_grant', error_description: 'refresh token is invalid' }, + 403, + )); + await expect(refreshAccessToken({ + refreshToken: 'revoked', + clientId: 'c', + clientSecret: 's', + fetchImpl: fetchImpl as unknown as typeof fetch, + })).rejects.toThrow(/invalid_grant/); + }); +}); + +describe('fetchAccessibleResources', () => { + const SITES = [ + { id: 'cloud-1', url: 'https://acme.atlassian.net', name: 'acme', scopes: ['read:jira-work'] }, + { id: 'cloud-2', url: 'https://other.atlassian.net', name: 'other', scopes: ['read:jira-work'] }, + ]; + + test('GETs the accessible-resources endpoint with the bearer token', async () => { + const fetchImpl = jest.fn().mockResolvedValue(jsonResponse(SITES)); + const result = await fetchAccessibleResources('jira_at_1', fetchImpl as unknown as typeof fetch); + + expect(result).toEqual(SITES); + const [url, init] = fetchImpl.mock.calls[0]; + expect(url).toBe('https://api.atlassian.com/oauth/token/accessible-resources'); + expect(init.headers).toMatchObject({ Authorization: 'Bearer jira_at_1' }); + }); + + test('throws CliError on a non-2xx response', async () => { + const fetchImpl = jest.fn().mockResolvedValue(jsonResponse({}, 401)); + await expect(fetchAccessibleResources('bad', fetchImpl as unknown as typeof fetch)) + .rejects.toThrow(/accessible-resources query failed/); + }); + + test('throws CliError when the response is not an array', async () => { + const fetchImpl = jest.fn().mockResolvedValue(jsonResponse({ sites: [] })); + await expect(fetchAccessibleResources('t', fetchImpl as unknown as typeof fetch)) + .rejects.toThrow(/unexpected shape/); + }); +}); diff --git a/docs/decisions/ADR-014-jira-integration.md b/docs/decisions/ADR-015-jira-integration.md similarity index 50% rename from docs/decisions/ADR-014-jira-integration.md rename to docs/decisions/ADR-015-jira-integration.md index cd5a94b6..e88b0d7b 100644 --- a/docs/decisions/ADR-014-jira-integration.md +++ b/docs/decisions/ADR-015-jira-integration.md @@ -1,7 +1,7 @@ -# ADR-014: Jira Cloud integration via label trigger, OAuth 3LO, and MCP outbound +# ADR-015: Jira Cloud integration via label trigger, OAuth 3LO, and REST outbound -**Status:** proposed -**Date:** 2026-06-08 +**Status:** accepted +**Date:** 2026-06-08 (amended 2026-06-11: renumbered from ADR-014 after #296 landed its own ADR-014; outbound pivoted from Atlassian Remote MCP to a REST shim — see "Outbound path" below) ## Context @@ -16,34 +16,45 @@ Build a **parity-level Jira Cloud integration** that mirrors Linear file-for-fil - **Jira Cloud only.** Jira Server / Data Center, and Forge/Connect app distribution, are out of scope. The integration targets REST v3 and Atlassian Cloud webhooks. - **Per-tenant OAuth 3LO**, stored in Secrets Manager as `bgagent-jira-oauth-`, mirroring `bgagent-linear-oauth-`. `cloudId` (the Atlassian tenant UUID) is the tenant key across all tables and secrets — not the site domain or name. - **Label trigger** (default `bgagent`), parity with Linear. No status-transition or comment-command triggers in v1. -- **Outbound via the Atlassian Remote MCP server** only (`https://mcp.atlassian.com/v1/sse`), registered into `.mcp.json` when `channel_source == "jira"`. No `jira_reactions.py` REST module unless MCP coverage proves insufficient. +- **Outbound via the Jira REST v3 API** (`agent/src/jira_reactions.py`): the pipeline posts a "starting" comment when it picks up a Jira-origin task and a terminal "succeeded / failed (+ PR link)" comment at the end, via `POST /rest/api/3/issue/{key}/comment` on the cross-region `api.atlassian.com/ex/jira/{cloudId}` base, authorized by the stored per-tenant OAuth token. - **Inbound-only adapter.** No DynamoDB Streams consumer and no outbound-notify Lambda, matching Linear's stance. -The channel selection in `agent/src/channel_mcp.py` becomes a small dispatch registry (`{"linear": ..., "jira": ...}`) rather than a hardcoded Linear gate, so adding a channel is an entry, not a rewrite. +The channel selection in `agent/src/channel_mcp.py` becomes a small dispatch registry rather than a hardcoded Linear gate, so adding a channel MCP is an entry, not a rewrite. Jira deliberately has **no** entry in that registry (see "Outbound path" below). + +### Outbound path: REST shim, not the Atlassian Remote MCP + +This ADR originally specified outbound via the **Atlassian Remote MCP server** (`https://mcp.atlassian.com/v1/sse`), with a REST shim noted as Plan B. Implementation falsified the MCP plan: the hosted Remote MCP requires an **interactive, browser-based OAuth 2.1 flow with dynamic client registration** and does not accept the stored Jira REST OAuth token as a `Bearer` header — a headless background agent cannot complete the handshake, and the server fails to connect from the runtime (`claude mcp list` → "Failed to connect"). The Jira REST API accepts the same stored token (it carries `write:jira-work`), so Plan B is the implemented path: + +- `agent/src/jira_reactions.py` posts the start/terminal comments (with an auth-failure circuit breaker mirroring `linear_reactions.py`); all errors are advisory — logged and swallowed, never gating the pipeline. +- `agent/src/channel_mcp.py` writes no Jira MCP entry, so Jira tasks don't log a confusing "Failed to connect" on every run. +- Lambda-side pre-container feedback (unmapped project, concurrency cap, guardrail) uses the same REST surface via `cdk/src/handlers/shared/jira-feedback.ts`. + +If Atlassian ships a server-to-server auth path for the Remote MCP, the registry entry can be restored and the agent given interactive Jira tools; the REST shim stays regardless as the deterministic pipeline-level fallback. ### Where Jira forced divergence from the Linear copy These are the points where blindly copying Linear would have been wrong: 1. **Label-add detection on updates.** Jira's `jira:issue_updated` payload reports label changes in `changelog.items[]` (`field: "labels"`, `fromString` / `toString`) — it does *not* re-send the full label list. The processor diffs the changelog, not `issue.fields.labels`, so re-saving an issue that already carries the label does not re-trigger. -2. **Webhook signing secret is operator-chosen.** Atlassian does not auto-generate a per-subscription signing secret the way Linear does. The operator picks one at webhook-create time and pastes it during `bgagent jira setup`; ABCA stores it on the per-tenant OAuth bundle with a stack-wide fallback for older installs. +2. **Webhook signing secret is operator-chosen.** Atlassian does not auto-generate a per-subscription signing secret the way Linear does. The operator picks one at webhook-create time and pastes it during `bgagent jira setup`; ABCA stores it on the per-tenant OAuth bundle. The stack-wide secret exists only for Settings-UI webhooks (whose payloads omit `cloudId` and can't be verified per-tenant) and is **never mirrored into per-tenant bundles** — a payload verified only by the stack-wide secret carries no binding between the secret and the body's `cloudId`, so the processor refuses a body-supplied `cloudId` on that path and binds the delivery to the sole active tenant instead (dropping it when that's ambiguous). 3. **Signature scheme.** Atlassian signs with HMAC-SHA256 over the *raw* request body, delivered as `X-Hub-Signature: sha256=`. Verification uses a constant-time compare over the unparsed bytes. 4. **ADF descriptions.** Jira issue descriptions are Atlassian Document Format, not markdown. The processor extracts text/headings/lists into markdown for the task description rather than rolling a full ADF converter. -5. **Dedup key.** `{issueKey}#{webhookEventTimestamp}` with an 8-hour TTL, rather than keying on event type — so two distinct label-adds in quick succession aren't collapsed. Jira retries far less aggressively than Linear, so 8 hours is safe parity. +5. **Dedup key.** `{issueKey}#{webhookEvent}#{timestamp}` with an 8-hour TTL — retries of the same delivery (same queued-at timestamp) collapse, while distinct events (including two label-adds in quick succession, or a created + an updated event in the same millisecond) do not. Jira retries far less aggressively than Linear, so 8 hours is safe parity. Deliveries without a `timestamp` collapse to a single `…#unknown` key per `(issueKey, webhookEvent)` within the TTL window — a deliberate conservative choice, logged at the receiver. ## Consequences - (+) Teams on Jira Cloud get the same label → PR → progress-comment loop as Linear, with no new operational concepts. -- (+) The MCP-only outbound path means no bespoke Jira REST client to maintain; the agent uses Atlassian's own tools. +- (+) The REST outbound shim is deterministic and pipeline-owned: comments fire at exactly start/finish, regardless of agent behavior, and there's no dependency on the Atlassian Remote MCP's rollout or auth contract. - (+) Per-tenant credential isolation and the changelog-diff trigger keep the trust and re-trigger semantics correct for multi-tenant installs. -- (-) Dependence on the Atlassian Remote MCP server. If it is gated/preview or changes its contract, outbound progress comments break until a `jira_reactions.py` REST fallback (Plan B) is written. +- (-) Unlike Linear, the agent has no interactive Jira tools mid-run (no issue search, no state transitions) — outbound is limited to the fixed start/terminal comments until a usable MCP auth path ships. - (-) ADF→markdown is lossy by design (text/headings/lists only); rich content in descriptions is flattened. - (!) `cloudId` must be used consistently as the tenant key. Indexing on domain or site name anywhere would break tenant resolution. - (!) The webhook signing secret lives on the per-tenant OAuth bundle; rotating it in Jira without re-running `bgagent jira setup` causes silent 401s on every delivery. +- (!) Settings-UI webhooks (no `cloudId` in payload) only work for single-tenant installs; multi-tenant operators must use webhooks that carry their own `cloudId` (e.g. OAuth-app registered dynamic webhooks). ## References - Issue: [#288 — Jira Cloud integration (parity with Linear)](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/288) - [JIRA_SETUP_GUIDE.md](../guides/JIRA_SETUP_GUIDE.md) — operational walkthrough - [LINEAR_SETUP_GUIDE.md](../guides/LINEAR_SETUP_GUIDE.md) — the analog integration this mirrors -- Reference implementation: `cdk/src/constructs/jira-integration.ts`, `cdk/src/handlers/jira-*.ts`, `agent/src/channel_mcp.py` +- Reference implementation: `cdk/src/constructs/jira-integration.ts`, `cdk/src/handlers/jira-*.ts`, `agent/src/jira_reactions.py` diff --git a/docs/guides/JIRA_SETUP_GUIDE.md b/docs/guides/JIRA_SETUP_GUIDE.md index 09aea033..193aef13 100644 --- a/docs/guides/JIRA_SETUP_GUIDE.md +++ b/docs/guides/JIRA_SETUP_GUIDE.md @@ -13,7 +13,7 @@ Set up the ABCA Jira Cloud integration so that adding a label to a Jira issue tr ## How it works -A Jira-site admin creates an Atlassian OAuth 2.0 (3LO) app and authorizes it on the site. The OAuth token bundle is stored in a per-tenant Secrets Manager secret (`bgagent-jira-oauth-`). When a user adds the trigger label to a Jira issue, Jira fires a webhook to ABCA; the receiver verifies the `X-Hub-Signature` HMAC, dedupes, and async-invokes the processor, which resolves the tenant, looks up the project→repo mapping, and creates a task. The agent clones the repo, opens a PR, and comments on the Jira issue via the Atlassian Remote MCP server. +A Jira-site admin creates an Atlassian OAuth 2.0 (3LO) app and authorizes it on the site. The OAuth token bundle is stored in a per-tenant Secrets Manager secret (`bgagent-jira-oauth-`). When a user adds the trigger label to a Jira issue, Jira fires a webhook to ABCA; the receiver verifies the `X-Hub-Signature` HMAC, dedupes, and async-invokes the processor, which resolves the tenant, looks up the project→repo mapping, and creates a task. The agent clones the repo, opens a PR, and posts progress comments on the Jira issue via the Jira REST v3 API. **Tenant key.** Everything is indexed on `cloudId` — the Atlassian tenant UUID, *not* the site domain or name. Webhook payloads and the OAuth flow both surface `cloudId`; it is the join key across the project-mapping, user-mapping, and workspace-registry tables. @@ -28,17 +28,18 @@ Jira Cloud webhook → existing orchestrator pipeline (unchanged) ``` -Outbound (Agent → Jira) — MCP only: +Outbound (Agent → Jira) — REST: ``` -runner picks task with channel_source="jira" - → channel_mcp writes a `jira-server` entry into .mcp.json - (Atlassian Remote MCP at https://mcp.atlassian.com/v1/sse, - OAuth token resolved from bgagent-jira-oauth-) - → Claude Agent SDK exposes Jira tools (mcp__jira-server__*) - → agent posts comments / transitions / links the PR via MCP tools +pipeline picks task with channel_source="jira" + → config resolves the OAuth token from bgagent-jira-oauth- + → jira_reactions posts a "starting" comment at task start and a + terminal "succeeded / failed (+ PR link)" comment at the end + (POST /rest/api/3/issue/{key}/comment on api.atlassian.com/ex/jira/{cloudId}) ``` +> **Why REST, not MCP?** Atlassian's Remote MCP server requires an interactive, browser-based OAuth 2.1 flow that a headless agent can't complete, so the agent has no Jira MCP tools — progress comments are posted out-of-band by the pipeline. See [ADR-015](../decisions/ADR-015-jira-integration.md). + There is no DynamoDB Streams consumer and no outbound-notify Lambda — this is an inbound-only adapter, matching Linear. ## Setup walkthrough @@ -83,7 +84,7 @@ This runs the OAuth 3LO dance: - **Events** — *Issue: created* and *Issue: updated* - **Secret** — a strong random value, e.g. `openssl rand -hex 32` -Paste that same secret value back at the `Webhook signing secret:` prompt. ABCA stores it on the per-tenant OAuth bundle (and mirrors it stack-wide), and the receiver looks it up to verify `X-Hub-Signature` on each delivery. +Paste that same secret value back at the `Webhook signing secret:` prompt. ABCA stores it on the per-tenant OAuth bundle, and the receiver looks it up to verify `X-Hub-Signature` on each delivery. For the **first** tenant only, the same value also populates the stack-wide `JiraWebhookSecret` — that fallback covers Settings-UI webhooks, whose payloads omit `cloudId` and therefore can't be matched to a tenant's own secret. Additional tenants keep their own secrets and are never mirrored stack-wide. ### 4. Map a project to a repository @@ -119,7 +120,7 @@ Add the trigger label (`bgagent` by default) to a Jira issue in a mapped project Atlassian signs each delivery with HMAC-SHA256 over the **raw request body**, delivered as `X-Hub-Signature: sha256=`. The receiver: 1. Computes `HMAC-SHA256(rawBody, secret)` and compares it constant-time against the header value (tolerating a pasted value with or without the `sha256=` prefix). -2. Prefers the **per-tenant** signing secret stored on `bgagent-jira-oauth-`; falls back to the stack-wide `JiraWebhookSecret` for installs that predate per-tenant storage. +2. Prefers the **per-tenant** signing secret stored on `bgagent-jira-oauth-`; falls back to the stack-wide `JiraWebhookSecret` when the payload carries no `cloudId` (Settings-UI webhooks) or the tenant has no per-tenant secret. A delivery verified only by the stack-wide secret is bound to the **sole active tenant** in the registry — the processor refuses a body-supplied `cloudId` on that path and drops the event when zero or multiple tenants are active. 3. Rejects with 401 on mismatch. The body must be verified as the *raw unparsed bytes* — never parsed-and-restringified JSON, which would change the byte sequence and break the HMAC. @@ -132,7 +133,7 @@ The body must be verified as the *raw unparsed bytes* — never parsed-and-restr ## Webhook dedup -The receiver dedupes on `{issueKey}#{webhookEventTimestamp}` with an 8-hour TTL. Using the event timestamp (rather than event type) means two distinct label-adds in quick succession are not collapsed. Jira retries far less aggressively than Linear, so 8 hours is safe parity. +The receiver dedupes on `{issueKey}#{webhookEvent}#{timestamp}` with an 8-hour TTL. Retries of the same delivery (same queued-at timestamp) collapse; distinct events — including two label-adds in quick succession — do not. Jira retries far less aggressively than Linear, so 8 hours is safe parity. ## Usage @@ -164,7 +165,7 @@ aws secretsmanager get-secret-value \ - Verify the per-tenant OAuth secret exists: `aws secretsmanager describe-secret --secret-id bgagent-jira-oauth-`. - Verify the registry row's `oauth_secret_arn` matches and `status = 'active'`. -- Check the agent container logs for the `jira-server` MCP entry being written. Absence means `channel_source` wasn't `jira` on the task, or the tenant OAuth lookup failed. +- Check the agent container logs for `jira_reactions:` lines. `JIRA_API_TOKEN not set` means the per-tenant OAuth lookup failed (or `channel_source` wasn't `jira` on the task); `auth circuit OPEN` means Atlassian rejected the token repeatedly. - A `401` from Atlassian usually means the refresh token was revoked tenant-side — re-run `bgagent jira setup`. ## Limits and quotas diff --git a/docs/guides/ROADMAP.md b/docs/guides/ROADMAP.md index b89e11ff..501431b1 100644 --- a/docs/guides/ROADMAP.md +++ b/docs/guides/ROADMAP.md @@ -84,7 +84,7 @@ What's shipped and what's coming next. - [x] **Slack integration** - @mention task submission, `bgagent slack link` / `setup`, file attachments on submit, threaded progress notifications. See [SLACK_SETUP_GUIDE.md](./SLACK_SETUP_GUIDE.md) - [x] **Linear integration** - Label-triggered tasks, `bgagent linear setup` / `link`, progress comments on issues. See [LINEAR_SETUP_GUIDE.md](./LINEAR_SETUP_GUIDE.md) -- [x] **Jira integration** - Label-triggered tasks on Jira Cloud, `bgagent jira setup` / `map` / `link`, progress comments via Atlassian Remote MCP. See [JIRA_SETUP_GUIDE.md](./JIRA_SETUP_GUIDE.md) +- [x] **Jira integration** - Label-triggered tasks on Jira Cloud, `bgagent jira setup` / `map` / `link`, progress comments via the Jira REST API ([ADR-015](../decisions/ADR-015-jira-integration.md)). See [JIRA_SETUP_GUIDE.md](./JIRA_SETUP_GUIDE.md) ### Observability diff --git a/docs/guides/USER_GUIDE.md b/docs/guides/USER_GUIDE.md index 81255ebe..833a1b42 100644 --- a/docs/guides/USER_GUIDE.md +++ b/docs/guides/USER_GUIDE.md @@ -4,14 +4,14 @@ ABCA is a platform for running autonomous background coding agents on AWS. You submit a task (a GitHub repository + a task description or issue number), an agent works autonomously in an isolated environment, and delivers a pull request when done. This guide covers how to submit coding tasks, monitor their progress, and get the most out of the platform. -There are five ways to interact with the platform. You can use them independently or combine them for different workflows: +There are six ways to interact with the platform. You can use them independently or combine them for different workflows: 1. **CLI** (recommended) - The `bgagent` CLI authenticates via Cognito and calls the Task API. Best for individual developers submitting tasks from the terminal. Handles login, token caching, and output formatting. 2. **REST API** (direct) - Call the Task API endpoints directly with a JWT token. Best for building custom integrations, dashboards, or internal tools on top of the platform. Full validation, audit logging, and idempotency support. 3. **Webhook** - External systems (CI pipelines, GitHub Actions) can create tasks via HMAC-authenticated HTTP requests. Best for automated workflows where tasks should be triggered by events (e.g., a new issue is labeled, a PR needs review). No Cognito credentials needed; uses a shared secret per integration. 4. **Slack** - Submit tasks by @mentioning the bot and receive threaded progress notifications with reaction-based status. See the [Slack setup guide](./SLACK_SETUP_GUIDE.md). 5. **Linear** - Apply a label to a Linear issue to trigger a task; the agent posts progress comments back on the issue via Linear's MCP server. See the [Linear setup guide](./LINEAR_SETUP_GUIDE.md). -6. **Jira** - Add a label to a Jira Cloud issue to trigger a task; the agent posts progress comments back on the issue via Atlassian's Remote MCP server. See the [Jira setup guide](./JIRA_SETUP_GUIDE.md). +6. **Jira** - Add a label to a Jira Cloud issue to trigger a task; the agent posts progress comments back on the issue via the Jira REST API. See the [Jira setup guide](./JIRA_SETUP_GUIDE.md). For example, a team might use the **CLI** for ad-hoc tasks, **webhooks** to auto-trigger `coding/pr-review-v1` on every new PR via GitHub Actions, **Slack** for quick team-wide requests, **Linear** or **Jira** for tickets that already live in the PM tool, and the **REST API** to build a dashboard that tracks task status across repositories. diff --git a/docs/src/content/docs/decisions/Adr-014-jira-integration.md b/docs/src/content/docs/decisions/Adr-014-jira-integration.md deleted file mode 100644 index 0d0d1fe7..00000000 --- a/docs/src/content/docs/decisions/Adr-014-jira-integration.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -title: Adr 014 jira integration ---- - -# ADR-014: Jira Cloud integration via label trigger, OAuth 3LO, and MCP outbound - -**Status:** proposed -**Date:** 2026-06-08 - -## Context - -ABCA ingests coding tasks from CLI, GitHub webhooks, Slack, and Linear, then opens PRs autonomously. Linear was the only issue-tracker channel, but many teams use Jira instead. We want parity: a Jira issue gets a `bgagent` label → ABCA picks it up → an agent run produces a PR → status flows back into the Jira issue. - -The Linear integration (`cdk/src/constructs/linear-integration.ts` + sibling handlers + `agent/src/channel_mcp.py`) is the established pattern for an issue-tracker channel, and the forces here are the same: per-tenant credential isolation, webhook authenticity, a low-friction trigger, and an outbound path for the agent to report progress. Jira differs from Linear in a few concrete ways that shape the decision — most notably how label changes and issue descriptions are represented, and how webhook signing secrets are provisioned. - -## Decision - -Build a **parity-level Jira Cloud integration** that mirrors Linear file-for-file where the shape is the same, and diverges only where Jira's API forces it. Specifically: - -- **Jira Cloud only.** Jira Server / Data Center, and Forge/Connect app distribution, are out of scope. The integration targets REST v3 and Atlassian Cloud webhooks. -- **Per-tenant OAuth 3LO**, stored in Secrets Manager as `bgagent-jira-oauth-`, mirroring `bgagent-linear-oauth-`. `cloudId` (the Atlassian tenant UUID) is the tenant key across all tables and secrets — not the site domain or name. -- **Label trigger** (default `bgagent`), parity with Linear. No status-transition or comment-command triggers in v1. -- **Outbound via the Atlassian Remote MCP server** only (`https://mcp.atlassian.com/v1/sse`), registered into `.mcp.json` when `channel_source == "jira"`. No `jira_reactions.py` REST module unless MCP coverage proves insufficient. -- **Inbound-only adapter.** No DynamoDB Streams consumer and no outbound-notify Lambda, matching Linear's stance. - -The channel selection in `agent/src/channel_mcp.py` becomes a small dispatch registry (`{"linear": ..., "jira": ...}`) rather than a hardcoded Linear gate, so adding a channel is an entry, not a rewrite. - -### Where Jira forced divergence from the Linear copy - -These are the points where blindly copying Linear would have been wrong: - -1. **Label-add detection on updates.** Jira's `jira:issue_updated` payload reports label changes in `changelog.items[]` (`field: "labels"`, `fromString` / `toString`) — it does *not* re-send the full label list. The processor diffs the changelog, not `issue.fields.labels`, so re-saving an issue that already carries the label does not re-trigger. -2. **Webhook signing secret is operator-chosen.** Atlassian does not auto-generate a per-subscription signing secret the way Linear does. The operator picks one at webhook-create time and pastes it during `bgagent jira setup`; ABCA stores it on the per-tenant OAuth bundle with a stack-wide fallback for older installs. -3. **Signature scheme.** Atlassian signs with HMAC-SHA256 over the *raw* request body, delivered as `X-Hub-Signature: sha256=`. Verification uses a constant-time compare over the unparsed bytes. -4. **ADF descriptions.** Jira issue descriptions are Atlassian Document Format, not markdown. The processor extracts text/headings/lists into markdown for the task description rather than rolling a full ADF converter. -5. **Dedup key.** `{issueKey}#{webhookEventTimestamp}` with an 8-hour TTL, rather than keying on event type — so two distinct label-adds in quick succession aren't collapsed. Jira retries far less aggressively than Linear, so 8 hours is safe parity. - -## Consequences - -- (+) Teams on Jira Cloud get the same label → PR → progress-comment loop as Linear, with no new operational concepts. -- (+) The MCP-only outbound path means no bespoke Jira REST client to maintain; the agent uses Atlassian's own tools. -- (+) Per-tenant credential isolation and the changelog-diff trigger keep the trust and re-trigger semantics correct for multi-tenant installs. -- (-) Dependence on the Atlassian Remote MCP server. If it is gated/preview or changes its contract, outbound progress comments break until a `jira_reactions.py` REST fallback (Plan B) is written. -- (-) ADF→markdown is lossy by design (text/headings/lists only); rich content in descriptions is flattened. -- (!) `cloudId` must be used consistently as the tenant key. Indexing on domain or site name anywhere would break tenant resolution. -- (!) The webhook signing secret lives on the per-tenant OAuth bundle; rotating it in Jira without re-running `bgagent jira setup` causes silent 401s on every delivery. - -## References - -- Issue: [#288 — Jira Cloud integration (parity with Linear)](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/288) -- [JIRA_SETUP_GUIDE.md](/using/jira-setup-guide) — operational walkthrough -- [LINEAR_SETUP_GUIDE.md](/using/linear-setup-guide) — the analog integration this mirrors -- Reference implementation: `cdk/src/constructs/jira-integration.ts`, `cdk/src/handlers/jira-*.ts`, `agent/src/channel_mcp.py` diff --git a/docs/src/content/docs/decisions/Adr-015-jira-integration.md b/docs/src/content/docs/decisions/Adr-015-jira-integration.md new file mode 100644 index 00000000..9ed1846a --- /dev/null +++ b/docs/src/content/docs/decisions/Adr-015-jira-integration.md @@ -0,0 +1,64 @@ +--- +title: Adr 015 jira integration +--- + +# ADR-015: Jira Cloud integration via label trigger, OAuth 3LO, and REST outbound + +**Status:** accepted +**Date:** 2026-06-08 (amended 2026-06-11: renumbered from ADR-014 after #296 landed its own ADR-014; outbound pivoted from Atlassian Remote MCP to a REST shim — see "Outbound path" below) + +## Context + +ABCA ingests coding tasks from CLI, GitHub webhooks, Slack, and Linear, then opens PRs autonomously. Linear was the only issue-tracker channel, but many teams use Jira instead. We want parity: a Jira issue gets a `bgagent` label → ABCA picks it up → an agent run produces a PR → status flows back into the Jira issue. + +The Linear integration (`cdk/src/constructs/linear-integration.ts` + sibling handlers + `agent/src/channel_mcp.py`) is the established pattern for an issue-tracker channel, and the forces here are the same: per-tenant credential isolation, webhook authenticity, a low-friction trigger, and an outbound path for the agent to report progress. Jira differs from Linear in a few concrete ways that shape the decision — most notably how label changes and issue descriptions are represented, and how webhook signing secrets are provisioned. + +## Decision + +Build a **parity-level Jira Cloud integration** that mirrors Linear file-for-file where the shape is the same, and diverges only where Jira's API forces it. Specifically: + +- **Jira Cloud only.** Jira Server / Data Center, and Forge/Connect app distribution, are out of scope. The integration targets REST v3 and Atlassian Cloud webhooks. +- **Per-tenant OAuth 3LO**, stored in Secrets Manager as `bgagent-jira-oauth-`, mirroring `bgagent-linear-oauth-`. `cloudId` (the Atlassian tenant UUID) is the tenant key across all tables and secrets — not the site domain or name. +- **Label trigger** (default `bgagent`), parity with Linear. No status-transition or comment-command triggers in v1. +- **Outbound via the Jira REST v3 API** (`agent/src/jira_reactions.py`): the pipeline posts a "starting" comment when it picks up a Jira-origin task and a terminal "succeeded / failed (+ PR link)" comment at the end, via `POST /rest/api/3/issue/{key}/comment` on the cross-region `api.atlassian.com/ex/jira/{cloudId}` base, authorized by the stored per-tenant OAuth token. +- **Inbound-only adapter.** No DynamoDB Streams consumer and no outbound-notify Lambda, matching Linear's stance. + +The channel selection in `agent/src/channel_mcp.py` becomes a small dispatch registry rather than a hardcoded Linear gate, so adding a channel MCP is an entry, not a rewrite. Jira deliberately has **no** entry in that registry (see "Outbound path" below). + +### Outbound path: REST shim, not the Atlassian Remote MCP + +This ADR originally specified outbound via the **Atlassian Remote MCP server** (`https://mcp.atlassian.com/v1/sse`), with a REST shim noted as Plan B. Implementation falsified the MCP plan: the hosted Remote MCP requires an **interactive, browser-based OAuth 2.1 flow with dynamic client registration** and does not accept the stored Jira REST OAuth token as a `Bearer` header — a headless background agent cannot complete the handshake, and the server fails to connect from the runtime (`claude mcp list` → "Failed to connect"). The Jira REST API accepts the same stored token (it carries `write:jira-work`), so Plan B is the implemented path: + +- `agent/src/jira_reactions.py` posts the start/terminal comments (with an auth-failure circuit breaker mirroring `linear_reactions.py`); all errors are advisory — logged and swallowed, never gating the pipeline. +- `agent/src/channel_mcp.py` writes no Jira MCP entry, so Jira tasks don't log a confusing "Failed to connect" on every run. +- Lambda-side pre-container feedback (unmapped project, concurrency cap, guardrail) uses the same REST surface via `cdk/src/handlers/shared/jira-feedback.ts`. + +If Atlassian ships a server-to-server auth path for the Remote MCP, the registry entry can be restored and the agent given interactive Jira tools; the REST shim stays regardless as the deterministic pipeline-level fallback. + +### Where Jira forced divergence from the Linear copy + +These are the points where blindly copying Linear would have been wrong: + +1. **Label-add detection on updates.** Jira's `jira:issue_updated` payload reports label changes in `changelog.items[]` (`field: "labels"`, `fromString` / `toString`) — it does *not* re-send the full label list. The processor diffs the changelog, not `issue.fields.labels`, so re-saving an issue that already carries the label does not re-trigger. +2. **Webhook signing secret is operator-chosen.** Atlassian does not auto-generate a per-subscription signing secret the way Linear does. The operator picks one at webhook-create time and pastes it during `bgagent jira setup`; ABCA stores it on the per-tenant OAuth bundle. The stack-wide secret exists only for Settings-UI webhooks (whose payloads omit `cloudId` and can't be verified per-tenant) and is **never mirrored into per-tenant bundles** — a payload verified only by the stack-wide secret carries no binding between the secret and the body's `cloudId`, so the processor refuses a body-supplied `cloudId` on that path and binds the delivery to the sole active tenant instead (dropping it when that's ambiguous). +3. **Signature scheme.** Atlassian signs with HMAC-SHA256 over the *raw* request body, delivered as `X-Hub-Signature: sha256=`. Verification uses a constant-time compare over the unparsed bytes. +4. **ADF descriptions.** Jira issue descriptions are Atlassian Document Format, not markdown. The processor extracts text/headings/lists into markdown for the task description rather than rolling a full ADF converter. +5. **Dedup key.** `{issueKey}#{webhookEvent}#{timestamp}` with an 8-hour TTL — retries of the same delivery (same queued-at timestamp) collapse, while distinct events (including two label-adds in quick succession, or a created + an updated event in the same millisecond) do not. Jira retries far less aggressively than Linear, so 8 hours is safe parity. Deliveries without a `timestamp` collapse to a single `…#unknown` key per `(issueKey, webhookEvent)` within the TTL window — a deliberate conservative choice, logged at the receiver. + +## Consequences + +- (+) Teams on Jira Cloud get the same label → PR → progress-comment loop as Linear, with no new operational concepts. +- (+) The REST outbound shim is deterministic and pipeline-owned: comments fire at exactly start/finish, regardless of agent behavior, and there's no dependency on the Atlassian Remote MCP's rollout or auth contract. +- (+) Per-tenant credential isolation and the changelog-diff trigger keep the trust and re-trigger semantics correct for multi-tenant installs. +- (-) Unlike Linear, the agent has no interactive Jira tools mid-run (no issue search, no state transitions) — outbound is limited to the fixed start/terminal comments until a usable MCP auth path ships. +- (-) ADF→markdown is lossy by design (text/headings/lists only); rich content in descriptions is flattened. +- (!) `cloudId` must be used consistently as the tenant key. Indexing on domain or site name anywhere would break tenant resolution. +- (!) The webhook signing secret lives on the per-tenant OAuth bundle; rotating it in Jira without re-running `bgagent jira setup` causes silent 401s on every delivery. +- (!) Settings-UI webhooks (no `cloudId` in payload) only work for single-tenant installs; multi-tenant operators must use webhooks that carry their own `cloudId` (e.g. OAuth-app registered dynamic webhooks). + +## References + +- Issue: [#288 — Jira Cloud integration (parity with Linear)](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/288) +- [JIRA_SETUP_GUIDE.md](/using/jira-setup-guide) — operational walkthrough +- [LINEAR_SETUP_GUIDE.md](/using/linear-setup-guide) — the analog integration this mirrors +- Reference implementation: `cdk/src/constructs/jira-integration.ts`, `cdk/src/handlers/jira-*.ts`, `agent/src/jira_reactions.py` diff --git a/docs/src/content/docs/roadmap/Roadmap.md b/docs/src/content/docs/roadmap/Roadmap.md index 43045da0..bc9d2fcf 100644 --- a/docs/src/content/docs/roadmap/Roadmap.md +++ b/docs/src/content/docs/roadmap/Roadmap.md @@ -88,7 +88,7 @@ What's shipped and what's coming next. - [x] **Slack integration** - @mention task submission, `bgagent slack link` / `setup`, file attachments on submit, threaded progress notifications. See [SLACK_SETUP_GUIDE.md](/using/slack-setup-guide) - [x] **Linear integration** - Label-triggered tasks, `bgagent linear setup` / `link`, progress comments on issues. See [LINEAR_SETUP_GUIDE.md](/using/linear-setup-guide) -- [x] **Jira integration** - Label-triggered tasks on Jira Cloud, `bgagent jira setup` / `map` / `link`, progress comments via Atlassian Remote MCP. See [JIRA_SETUP_GUIDE.md](/using/jira-setup-guide) +- [x] **Jira integration** - Label-triggered tasks on Jira Cloud, `bgagent jira setup` / `map` / `link`, progress comments via the Jira REST API ([ADR-015](/architecture/adr-015-jira-integration)). See [JIRA_SETUP_GUIDE.md](/using/jira-setup-guide) ### Observability diff --git a/docs/src/content/docs/using/Jira-setup-guide.md b/docs/src/content/docs/using/Jira-setup-guide.md index 74949afd..3c19ad62 100644 --- a/docs/src/content/docs/using/Jira-setup-guide.md +++ b/docs/src/content/docs/using/Jira-setup-guide.md @@ -17,7 +17,7 @@ Set up the ABCA Jira Cloud integration so that adding a label to a Jira issue tr ## How it works -A Jira-site admin creates an Atlassian OAuth 2.0 (3LO) app and authorizes it on the site. The OAuth token bundle is stored in a per-tenant Secrets Manager secret (`bgagent-jira-oauth-`). When a user adds the trigger label to a Jira issue, Jira fires a webhook to ABCA; the receiver verifies the `X-Hub-Signature` HMAC, dedupes, and async-invokes the processor, which resolves the tenant, looks up the project→repo mapping, and creates a task. The agent clones the repo, opens a PR, and comments on the Jira issue via the Atlassian Remote MCP server. +A Jira-site admin creates an Atlassian OAuth 2.0 (3LO) app and authorizes it on the site. The OAuth token bundle is stored in a per-tenant Secrets Manager secret (`bgagent-jira-oauth-`). When a user adds the trigger label to a Jira issue, Jira fires a webhook to ABCA; the receiver verifies the `X-Hub-Signature` HMAC, dedupes, and async-invokes the processor, which resolves the tenant, looks up the project→repo mapping, and creates a task. The agent clones the repo, opens a PR, and posts progress comments on the Jira issue via the Jira REST v3 API. **Tenant key.** Everything is indexed on `cloudId` — the Atlassian tenant UUID, *not* the site domain or name. Webhook payloads and the OAuth flow both surface `cloudId`; it is the join key across the project-mapping, user-mapping, and workspace-registry tables. @@ -32,17 +32,18 @@ Jira Cloud webhook → existing orchestrator pipeline (unchanged) ``` -Outbound (Agent → Jira) — MCP only: +Outbound (Agent → Jira) — REST: ``` -runner picks task with channel_source="jira" - → channel_mcp writes a `jira-server` entry into .mcp.json - (Atlassian Remote MCP at https://mcp.atlassian.com/v1/sse, - OAuth token resolved from bgagent-jira-oauth-) - → Claude Agent SDK exposes Jira tools (mcp__jira-server__*) - → agent posts comments / transitions / links the PR via MCP tools +pipeline picks task with channel_source="jira" + → config resolves the OAuth token from bgagent-jira-oauth- + → jira_reactions posts a "starting" comment at task start and a + terminal "succeeded / failed (+ PR link)" comment at the end + (POST /rest/api/3/issue/{key}/comment on api.atlassian.com/ex/jira/{cloudId}) ``` +> **Why REST, not MCP?** Atlassian's Remote MCP server requires an interactive, browser-based OAuth 2.1 flow that a headless agent can't complete, so the agent has no Jira MCP tools — progress comments are posted out-of-band by the pipeline. See [ADR-015](/architecture/adr-015-jira-integration). + There is no DynamoDB Streams consumer and no outbound-notify Lambda — this is an inbound-only adapter, matching Linear. ## Setup walkthrough @@ -87,7 +88,7 @@ This runs the OAuth 3LO dance: - **Events** — *Issue: created* and *Issue: updated* - **Secret** — a strong random value, e.g. `openssl rand -hex 32` -Paste that same secret value back at the `Webhook signing secret:` prompt. ABCA stores it on the per-tenant OAuth bundle (and mirrors it stack-wide), and the receiver looks it up to verify `X-Hub-Signature` on each delivery. +Paste that same secret value back at the `Webhook signing secret:` prompt. ABCA stores it on the per-tenant OAuth bundle, and the receiver looks it up to verify `X-Hub-Signature` on each delivery. For the **first** tenant only, the same value also populates the stack-wide `JiraWebhookSecret` — that fallback covers Settings-UI webhooks, whose payloads omit `cloudId` and therefore can't be matched to a tenant's own secret. Additional tenants keep their own secrets and are never mirrored stack-wide. ### 4. Map a project to a repository @@ -123,7 +124,7 @@ Add the trigger label (`bgagent` by default) to a Jira issue in a mapped project Atlassian signs each delivery with HMAC-SHA256 over the **raw request body**, delivered as `X-Hub-Signature: sha256=`. The receiver: 1. Computes `HMAC-SHA256(rawBody, secret)` and compares it constant-time against the header value (tolerating a pasted value with or without the `sha256=` prefix). -2. Prefers the **per-tenant** signing secret stored on `bgagent-jira-oauth-`; falls back to the stack-wide `JiraWebhookSecret` for installs that predate per-tenant storage. +2. Prefers the **per-tenant** signing secret stored on `bgagent-jira-oauth-`; falls back to the stack-wide `JiraWebhookSecret` when the payload carries no `cloudId` (Settings-UI webhooks) or the tenant has no per-tenant secret. A delivery verified only by the stack-wide secret is bound to the **sole active tenant** in the registry — the processor refuses a body-supplied `cloudId` on that path and drops the event when zero or multiple tenants are active. 3. Rejects with 401 on mismatch. The body must be verified as the *raw unparsed bytes* — never parsed-and-restringified JSON, which would change the byte sequence and break the HMAC. @@ -136,7 +137,7 @@ The body must be verified as the *raw unparsed bytes* — never parsed-and-restr ## Webhook dedup -The receiver dedupes on `{issueKey}#{webhookEventTimestamp}` with an 8-hour TTL. Using the event timestamp (rather than event type) means two distinct label-adds in quick succession are not collapsed. Jira retries far less aggressively than Linear, so 8 hours is safe parity. +The receiver dedupes on `{issueKey}#{webhookEvent}#{timestamp}` with an 8-hour TTL. Retries of the same delivery (same queued-at timestamp) collapse; distinct events — including two label-adds in quick succession — do not. Jira retries far less aggressively than Linear, so 8 hours is safe parity. ## Usage @@ -168,7 +169,7 @@ aws secretsmanager get-secret-value \ - Verify the per-tenant OAuth secret exists: `aws secretsmanager describe-secret --secret-id bgagent-jira-oauth-`. - Verify the registry row's `oauth_secret_arn` matches and `status = 'active'`. -- Check the agent container logs for the `jira-server` MCP entry being written. Absence means `channel_source` wasn't `jira` on the task, or the tenant OAuth lookup failed. +- Check the agent container logs for `jira_reactions:` lines. `JIRA_API_TOKEN not set` means the per-tenant OAuth lookup failed (or `channel_source` wasn't `jira` on the task); `auth circuit OPEN` means Atlassian rejected the token repeatedly. - A `401` from Atlassian usually means the refresh token was revoked tenant-side — re-run `bgagent jira setup`. ## Limits and quotas diff --git a/docs/src/content/docs/using/Overview.md b/docs/src/content/docs/using/Overview.md index 796b7d24..8e2e241a 100644 --- a/docs/src/content/docs/using/Overview.md +++ b/docs/src/content/docs/using/Overview.md @@ -4,13 +4,13 @@ title: Overview ABCA is a platform for running autonomous background coding agents on AWS. You submit a task (a GitHub repository + a task description or issue number), an agent works autonomously in an isolated environment, and delivers a pull request when done. This guide covers how to submit coding tasks, monitor their progress, and get the most out of the platform. -There are five ways to interact with the platform. You can use them independently or combine them for different workflows: +There are six ways to interact with the platform. You can use them independently or combine them for different workflows: 1. **CLI** (recommended) - The `bgagent` CLI authenticates via Cognito and calls the Task API. Best for individual developers submitting tasks from the terminal. Handles login, token caching, and output formatting. 2. **REST API** (direct) - Call the Task API endpoints directly with a JWT token. Best for building custom integrations, dashboards, or internal tools on top of the platform. Full validation, audit logging, and idempotency support. 3. **Webhook** - External systems (CI pipelines, GitHub Actions) can create tasks via HMAC-authenticated HTTP requests. Best for automated workflows where tasks should be triggered by events (e.g., a new issue is labeled, a PR needs review). No Cognito credentials needed; uses a shared secret per integration. 4. **Slack** - Submit tasks by @mentioning the bot and receive threaded progress notifications with reaction-based status. See the [Slack setup guide](/using/slack-setup-guide). 5. **Linear** - Apply a label to a Linear issue to trigger a task; the agent posts progress comments back on the issue via Linear's MCP server. See the [Linear setup guide](/using/linear-setup-guide). -6. **Jira** - Add a label to a Jira Cloud issue to trigger a task; the agent posts progress comments back on the issue via Atlassian's Remote MCP server. See the [Jira setup guide](/using/jira-setup-guide). +6. **Jira** - Add a label to a Jira Cloud issue to trigger a task; the agent posts progress comments back on the issue via the Jira REST API. See the [Jira setup guide](/using/jira-setup-guide). For example, a team might use the **CLI** for ad-hoc tasks, **webhooks** to auto-trigger `coding/pr-review-v1` on every new PR via GitHub Actions, **Slack** for quick team-wide requests, **Linear** or **Jira** for tickets that already live in the PM tool, and the **REST API** to build a dashboard that tracks task status across repositories. \ No newline at end of file