diff --git a/README.md b/README.md index d376b067..9fa516a3 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ ## What is ABCA -**ABCA (Autonomous Background Coding Agents on AWS)** is a sample of what a self-hosted background coding agents platform might look like on AWS. You submit a coding task (via Slack, Linear, CLI, or webhook), walk away, and come back to a ready-to-review PR. The agent clones the repo, writes code, runs tests, and opens the PR autonomously in an isolated cloud environment. No babysitting, no IDE sessions, no back-and-forth. +**ABCA (Autonomous Background Coding Agents on AWS)** is a sample of what a self-hosted background coding agents platform might look like on AWS. You submit a coding task (via Slack, Linear, Jira, CLI, or webhook), walk away, and come back to a ready-to-review PR. The agent clones the repo, writes code, runs tests, and opens the PR autonomously in an isolated cloud environment. No babysitting, no IDE sessions, no back-and-forth. ## Why it matters @@ -31,7 +31,7 @@ ## The Use Case -Users submit tasks through webhooks, CLI, Slack,... For each task, the orchestrator executes the blueprint: an isolated environment is provisioned, an agent clones the target GitHub repository, creates a branch, works on the task, and opens a pull request. +Users submit tasks through webhooks, CLI, Slack, Linear, Jira,... For each task, the orchestrator executes the blueprint: an isolated environment is provisioned, an agent clones the target GitHub repository, creates a branch, works on the task, and opens a pull request. Key characteristics: diff --git a/agent/src/channel_mcp.py b/agent/src/channel_mcp.py index f9c51c03..b193e925 100644 --- a/agent/src/channel_mcp.py +++ b/agent/src/channel_mcp.py @@ -1,35 +1,46 @@ """Channel-specific MCP configuration for the agent container. -For Linear-origin tasks we write (or merge into) ``.mcp.json`` in the cloned -repo ``cwd`` so the Claude Agent SDK — configured with -``setting_sources=["project"]`` — picks up the Linear MCP at session start -and exposes ``mcp__linear-server__*`` tools. +For inbound channel sources that have a hosted MCP we write (or merge into) +``.mcp.json`` in the cloned repo ``cwd`` so the Claude Agent SDK — configured +with ``setting_sources=["project"]`` — picks up the channel MCP at session +start and exposes the server's tools. + +Currently wired channels: +- ``linear`` → Linear hosted MCP (``mcp__linear-server__*`` tools) — functional. +- ``jira`` → Atlassian Remote MCP entry — a NON-FUNCTIONAL placeholder. It + is written for forward-compatibility but cannot connect from a headless + agent (interactive OAuth 2.1 only); live outbound Jira comments go through + the REST shim in ``jira_reactions.py``. See ``JIRA_MCP_URL`` below + ADR-015. For all other channel sources this is a no-op: no MCP is written, and the -SDK sees no Linear tools. That's the gate keeping Slack/API/webhook tasks -from touching Linear. +SDK sees no channel-specific tools. -See: cdk/src/handlers/linear-webhook-processor.ts (inbound), runner.py -(SDK invocation), plans at ~/.claude/plans/linear-mcp-findings.md. +See: cdk/src/handlers/{linear,jira}-webhook-processor.ts (inbound), +runner.py (SDK invocation). """ from __future__ import annotations import json import os -from typing import Any +from typing import TYPE_CHECKING, Any from shell import log +if TYPE_CHECKING: + from collections.abc import Callable + +# ─── Linear ────────────────────────────────────────────────────────────────── + #: Linear MCP endpoint — hosted by Linear, Streamable HTTP transport. LINEAR_MCP_URL = "https://mcp.linear.app/mcp" #: Key name inside ``mcpServers``. Tools surface as -#: ``mcp__linear-server__*`` in the Agent SDK (verified in findings). +#: ``mcp__linear-server__*`` in the Agent SDK. LINEAR_MCP_SERVER_KEY = "linear-server" #: Env var name the MCP server entry reads via ``${LINEAR_API_TOKEN}`` -#: placeholder expansion. Populated from ``LinearApiTokenSecret`` by run.sh. +#: placeholder expansion. Populated from the OAuth secret by config.py. LINEAR_API_TOKEN_ENV = "LINEAR_API_TOKEN" # noqa: S105 — env var *name*, not a secret value @@ -44,11 +55,62 @@ def _linear_server_entry() -> dict[str, Any]: } +# ─── Jira (Atlassian Remote MCP — NON-FUNCTIONAL PLACEHOLDER) ──────────────── + +#: Atlassian Remote MCP endpoint — Streamable HTTP transport. +#: +#: IMPORTANT: this entry does NOT work from a headless agent and is retained +#: only as a forward-looking placeholder. The hosted Atlassian MCP requires an +#: interactive, browser-based OAuth 2.1 flow with dynamic client registration +#: and will NOT accept the stored REST OAuth token as a Bearer header, so it +#: fails to connect in the runtime (``claude mcp list`` → "Failed to connect"). +#: +#: The LIVE outbound path is the REST shim in ``agent/src/jira_reactions.py`` +#: (the "Plan B" that became Plan A), which posts comments via the Jira REST +#: v3 API using the same stored OAuth token. See ADR-015 and +#: ``agent/src/prompt_builder.py``. If Atlassian ever ships a token-compatible +#: MCP, this entry can be promoted and the REST shim retired. +JIRA_MCP_URL = "https://mcp.atlassian.com/v1/sse" + +#: Key name inside ``mcpServers``. Tools surface as ``mcp__jira-server__*`` +#: in the Agent SDK. If this changes the agent prompt's channel addendum +#: must be updated in lockstep. +JIRA_MCP_SERVER_KEY = "jira-server" + +#: Env var name the Jira MCP server entry reads via ``${JIRA_API_TOKEN}`` +#: placeholder expansion. Populated from the per-tenant OAuth secret by +#: config.resolve_jira_oauth_token. +JIRA_API_TOKEN_ENV = "JIRA_API_TOKEN" # noqa: S105 — env var *name*, not a secret value + + +def _jira_server_entry() -> dict[str, Any]: + """Build the `mcpServers` entry for Atlassian's Remote MCP.""" + return { + "type": "http", + "url": JIRA_MCP_URL, + "headers": { + "Authorization": f"Bearer ${{{JIRA_API_TOKEN_ENV}}}", + }, + } + + +# ─── Dispatch ──────────────────────────────────────────────────────────────── + +#: Per-channel ``mcpServers`` entry builder. The channel_source values mirror +#: ``ChannelSource`` in cdk/src/handlers/shared/types.ts. Sources that don't +#: have a hosted MCP (api, webhook, slack) intentionally have no entry here — +#: the gate in ``configure_channel_mcp`` short-circuits on missing keys. +CHANNEL_MCP_BUILDERS: dict[str, tuple[str, Callable[[], dict[str, Any]]]] = { + "linear": (LINEAR_MCP_SERVER_KEY, _linear_server_entry), + "jira": (JIRA_MCP_SERVER_KEY, _jira_server_entry), +} + + def _read_existing_mcp_config(path: str) -> dict[str, Any]: """Return the parsed .mcp.json at ``path``, or an empty dict if absent/invalid. Malformed JSON is logged and treated as absent — we prefer to overlay a - valid Linear entry than to crash the agent because a user committed a + valid channel entry than to crash the agent because a user committed a broken .mcp.json to their repo. """ if not os.path.isfile(path): @@ -67,23 +129,26 @@ def _read_existing_mcp_config(path: str) -> dict[str, Any]: def configure_channel_mcp(repo_dir: str, channel_source: str) -> bool: """Write or merge a channel-specific ``.mcp.json`` into ``repo_dir``. - Gated on ``channel_source``: - * ``'linear'`` → ensure the ``linear-server`` entry is present in + Looks up ``channel_source`` in :data:`CHANNEL_MCP_BUILDERS`: + * present → ensure the corresponding ``mcpServers`` entry is in ``.mcp.json`` (merges into any existing config without clobbering other servers). Returns True. - * anything else → no-op. Returns False. + * absent → no-op. Returns False. Args: repo_dir: the cloned-repo working directory the SDK will use as ``cwd``. channel_source: inbound channel (``TaskConfig.channel_source``). Returns: - True if a Linear MCP entry was (re)written into ``repo_dir/.mcp.json``, - False otherwise (including any non-Linear channel or missing repo_dir). + True if a channel MCP entry was (re)written, False otherwise (channel + unmapped, missing repo_dir, or write failure). """ - if channel_source != "linear": + builder_entry = CHANNEL_MCP_BUILDERS.get(channel_source) + if builder_entry is None: return False + server_key, build_entry = builder_entry + if not repo_dir or not os.path.isdir(repo_dir): log("WARN", f"configure_channel_mcp: repo_dir missing or not a directory: {repo_dir!r}") return False @@ -94,7 +159,7 @@ def configure_channel_mcp(repo_dir: str, channel_source: str) -> bool: servers = config.get("mcpServers") if not isinstance(servers, dict): servers = {} - servers[LINEAR_MCP_SERVER_KEY] = _linear_server_entry() + servers[server_key] = build_entry() config["mcpServers"] = servers try: @@ -102,11 +167,21 @@ def configure_channel_mcp(repo_dir: str, channel_source: str) -> bool: json.dump(config, f, indent=2) f.write("\n") except OSError as e: - log("ERROR", f"Failed to write Linear MCP config to {mcp_path}: {e}") + log("ERROR", f"Failed to write {channel_source} MCP config to {mcp_path}: {e}") return False log( "TASK", - f"Linear MCP configured at {mcp_path} (server key: {LINEAR_MCP_SERVER_KEY})", + f"{channel_source} MCP configured at {mcp_path} (server key: {server_key})", ) + if channel_source == "jira": + # The Jira MCP entry is a non-functional placeholder (see JIRA_MCP_URL + # docstring + ADR-015). Log it in-band so a "Failed to connect" line in + # the agent logs isn't mistaken for the cause of a missing comment — + # the live outbound path is the REST shim in jira_reactions.py. + log( + "TASK", + "jira MCP entry is a placeholder and is EXPECTED to fail to connect; " + "outbound Jira comments use the REST shim (jira_reactions.py), not MCP", + ) return True diff --git a/agent/src/config.py b/agent/src/config.py index b7ee4e17..6f337ab2 100644 --- a/agent/src/config.py +++ b/agent/src/config.py @@ -326,6 +326,123 @@ def _refresh(current: dict) -> dict | None: return access +def resolve_jira_oauth_token(channel_metadata: dict[str, str] | None = None) -> str: + """Resolve the Jira Cloud OAuth access token from Secrets Manager. + + The orchestrator stamps ``jira_oauth_secret_arn`` into the task + record's ``channel_metadata`` at task-creation time. We fetch the + per-tenant secret, parse the token JSON, and cache the access_token in + ``JIRA_API_TOKEN`` so the agent-side Jira REST calls + (``jira_reactions``) can authorize. + + **The agent never refreshes the token.** Unlike Linear, Atlassian + *rotates the refresh_token on every use* — a successful refresh + invalidates the stored refresh_token and returns a new one. The agent + runtime has ``secretsmanager:GetSecretValue`` ONLY (no ``PutSecretValue``; + a compromised agent must not be able to overwrite any tenant's OAuth + bundle), so it cannot persist the rotated token. If the agent refreshed, + it would consume the stored refresh_token, keep the replacement only in + memory for this one task, and leave Secrets Manager holding a dead + refresh_token — the next Lambda/agent resolve would get ``invalid_grant`` + and the tenant would require re-onboarding. So we deliberately do NOT + refresh here: the trusted Lambda path (``jira-oauth-resolver.ts``, which + has ``PutSecretValue``) owns all refreshes, and the agent uses whatever + access_token the Lambdas have most-recently written. + + If the stored token is already expiring/expired, we fail closed — return + an empty string and let the advisory Jira comments no-op. The + orchestrator resolves (and refreshes) the token just before starting the + session, so in practice the agent reads a freshly-written token with a + full lifetime ahead of it. + + For local development, a pre-set ``JIRA_API_TOKEN`` env var + short-circuits the lookup so the agent can run outside the runtime. + + This function is only called when ``channel_source == 'jira'``. + """ + cached = os.environ.get("JIRA_API_TOKEN", "") + if cached: + return cached + + secret_arn = "" + if channel_metadata: + secret_arn = channel_metadata.get("jira_oauth_secret_arn", "") + if not secret_arn: + secret_arn = os.environ.get("JIRA_OAUTH_SECRET_ARN", "") + if not secret_arn: + return "" + + region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") + if not region: + log("WARN", "resolve_jira_oauth_token: AWS_REGION not set; cannot resolve token") + return "" + + try: + import json + from datetime import datetime + + import boto3 + from botocore.exceptions import BotoCoreError, ClientError + except ImportError as e: + log("WARN", f"resolve_jira_oauth_token: boto3 unavailable ({e}); skipping") + return "" + + sm = boto3.client("secretsmanager", region_name=region) + + def _fetch_token() -> dict | None: + resp = sm.get_secret_value(SecretId=secret_arn) + try: + return json.loads(resp["SecretString"]) + except (json.JSONDecodeError, KeyError, TypeError) as e: + log( + "ERROR", + f"resolve_jira_oauth_token: secret '{secret_arn}' is not valid JSON " + f"({type(e).__name__}: {e}); tenant requires re-onboarding", + ) + return None + + def _is_expiring(expires_at_iso: str, threshold_seconds: int = 60) -> bool: + try: + expiry = datetime.fromisoformat(expires_at_iso.replace("Z", "+00:00")) + except ValueError: + log( + "WARN", + f"_is_expiring: malformed expires_at '{expires_at_iso}'; treating as expiring", + ) + return True + return (expiry - datetime.now(UTC)).total_seconds() < threshold_seconds + + try: + token_obj = _fetch_token() + except (ClientError, BotoCoreError) as e: + code = "" + if hasattr(e, "response"): + code = getattr(e, "response", {}).get("Error", {}).get("Code", "") or "" + is_hard_failure = code in ("AccessDeniedException", "ResourceNotFoundException") + severity = "ERROR" if is_hard_failure else "WARN" + log(severity, f"resolve_jira_oauth_token failed: {type(e).__name__}: {e}") + return "" + if token_obj is None: + return "" + + # Fail closed if the stored token is expiring — the agent cannot refresh + # without burning Atlassian's rotating refresh_token (see docstring). The + # Lambda path owns refresh; advisory Jira comments simply no-op here. + if _is_expiring(token_obj.get("expires_at", "")): + log( + "WARN", + "resolve_jira_oauth_token: stored token is expiring and the agent does not " + "refresh (Atlassian rotates refresh_tokens; agent lacks PutSecretValue). " + "Failing closed — Jira comments will be skipped for this task.", + ) + return "" + + access = token_obj.get("access_token", "") + if access: + os.environ["JIRA_API_TOKEN"] = access + return access + + def build_config( repo_url: str = "", task_description: str = "", diff --git a/agent/src/jira_reactions.py b/agent/src/jira_reactions.py new file mode 100644 index 00000000..9910f661 --- /dev/null +++ b/agent/src/jira_reactions.py @@ -0,0 +1,209 @@ +"""Jira issue-comment helper for Jira-origin tasks. + +Posts a "starting" comment on the originating Jira issue at task start and a +terminal "succeeded / failed (+ PR link)" comment at the end — the Jira +analogue of ``linear_reactions`` (Linear uses emoji reactions; Jira's REST +API has no lightweight reaction primitive, so comments are the right tool). + +Why a direct REST call instead of MCP: Atlassian's Remote MCP +(``mcp.atlassian.com``) requires an interactive, browser-based OAuth 2.1 +authorization flow with dynamic client registration — it does NOT accept the +stored Jira REST OAuth token as a ``Bearer`` header, and a headless background +agent cannot complete the interactive handshake. The MCP path therefore fails +to connect in the runtime (``claude mcp list`` → "Failed to connect"). The +Jira *REST* API, by contrast, accepts the same stored OAuth access token (it +carries ``write:jira-work``), so we post comments via +``POST /rest/api/3/issue/{key}/comment`` on the cross-region +``api.atlassian.com/ex/jira/{cloudId}`` base. This is the "Plan B REST shim" +the ``channel_mcp`` module's comments anticipated. + +Gating: every function is a no-op unless ``channel_source == 'jira'`` and the +issue key + cloud id are present in ``channel_metadata``. All network / auth +errors are logged and swallowed — a transient Jira API failure must never fail +the task itself (comments are advisory UX, not load-bearing). + +See: ``agent/src/channel_mcp.py`` for the (non-functional) MCP gate, and +``agent/src/linear_reactions.py`` for the parallel Linear shim. +""" + +from __future__ import annotations + +import os +import threading +from typing import Any + +import requests + +from shell import log + +#: Atlassian cross-region REST base. The ``{cloudId}`` segment scopes the +#: call to the tenant; ``JIRA_API_TOKEN`` (populated by +#: ``config.resolve_jira_oauth_token``) authorizes it. +JIRA_API_BASE = "https://api.atlassian.com/ex/jira" + +#: Request timeout — comments are fire-and-forget status UX; never block the +#: task pipeline for more than a couple of seconds. +REQUEST_TIMEOUT_SECONDS = 5.0 + +#: Auth-failure circuit breaker. Mirrors ``linear_reactions``: after this many +#: consecutive 401/403s the breaker opens and later calls short-circuit +#: without hitting the network (avoids flooding CloudWatch when a token is +#: revoked). A successful 2xx resets the counter. +_AUTH_FAILURE_THRESHOLD = 3 +_consecutive_auth_failures: int = 0 +_auth_circuit_open: bool = False +_auth_state_lock = threading.Lock() + + +def _enabled( + channel_source: str, + channel_metadata: dict[str, str] | None, +) -> tuple[str, str] | None: + """Return ``(cloud_id, issue_key)`` if comments should fire, else None. + + Gating mirrors ``channel_mcp.configure_channel_mcp`` — the same + ``channel_source == 'jira'`` check, plus a metadata presence check so we + don't fire REST calls we can't address. + """ + if channel_source != "jira": + return None + if not channel_metadata: + return None + cloud_id = channel_metadata.get("jira_cloud_id") + issue_key = channel_metadata.get("jira_issue_key") + if not cloud_id or not issue_key: + return None + return cloud_id, issue_key + + +def _adf(text: str) -> dict[str, Any]: + """Wrap plain text in a minimal Atlassian Document Format comment body.""" + return { + "type": "doc", + "version": 1, + "content": [ + {"type": "paragraph", "content": [{"type": "text", "text": text}]}, + ], + } + + +def _post_comment(cloud_id: str, issue_key: str, text: str) -> bool: + """POST a comment to the issue. Return True on success, False on any failure. + + Swallows network / auth / schema errors with a WARN log — comments are + advisory and never gate the pipeline. After ``_AUTH_FAILURE_THRESHOLD`` + consecutive auth failures the module-level circuit breaker opens and later + calls short-circuit without hitting the network. + """ + global _consecutive_auth_failures, _auth_circuit_open + + with _auth_state_lock: + circuit_open = _auth_circuit_open + if circuit_open: + log("DEBUG", "jira_reactions: auth circuit still open; short-circuiting call") + return False + + token = os.environ.get("JIRA_API_TOKEN", "") + if not token: + log("WARN", "jira_reactions: JIRA_API_TOKEN not set; skipping comment") + return False + + url = f"{JIRA_API_BASE}/{cloud_id}/rest/api/3/issue/{issue_key}/comment" + try: + resp = requests.post( + url, + json={"body": _adf(text)}, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Accept": "application/json", + }, + timeout=REQUEST_TIMEOUT_SECONDS, + ) + except requests.RequestException as e: + log("WARN", f"jira_reactions: request failed ({type(e).__name__}): {e}") + return False + + if resp.status_code in (401, 403): + with _auth_state_lock: + _consecutive_auth_failures += 1 + opened = ( + _consecutive_auth_failures >= _AUTH_FAILURE_THRESHOLD and not _auth_circuit_open + ) + if opened: + _auth_circuit_open = True + failures = _consecutive_auth_failures + if opened: + log( + "ERROR", + "jira_reactions: auth circuit OPEN after " + f"{failures} consecutive {resp.status_code}s — Jira token likely " + "revoked/expired without a working refresh. Suppressing further " + "Jira calls for this container.", + ) + else: + log("WARN", f"jira_reactions: HTTP {resp.status_code} from Jira (auth)") + return False + + # Jira returns 201 Created on a successful comment. + if resp.status_code not in (200, 201): + log("WARN", f"jira_reactions: HTTP {resp.status_code} from Jira: {resp.text[:200]}") + return False + + with _auth_state_lock: + _consecutive_auth_failures = 0 + return True + + +def comment_task_started( + channel_source: str, + channel_metadata: dict[str, str] | None, +) -> None: + """Post a "starting" comment on the Jira issue. No-op for non-Jira tasks.""" + target = _enabled(channel_source, channel_metadata) + if not target: + return + cloud_id, issue_key = target + ok = _post_comment( + cloud_id, + issue_key, + "🤖 ABCA picked up this issue and started working on it. " + "I'll comment again when the pull request is ready.", + ) + log("TASK", f"jira_reactions: comment_task_started issue={issue_key} ok={ok}") + + +def comment_task_finished( + channel_source: str, + channel_metadata: dict[str, str] | None, + success: bool, + pr_url: str | None = None, +) -> None: + """Post a terminal status comment on the Jira issue. No-op for non-Jira tasks.""" + target = _enabled(channel_source, channel_metadata) + if not target: + return + cloud_id, issue_key = target + if success: + text = "✅ ABCA finished this task." + if pr_url: + text += f" Pull request: {pr_url}" + else: + text += " No pull request was opened." + else: + text = "❌ ABCA could not complete this task. Check the agent logs for details." + if pr_url: + text += f" A pull request was opened anyway: {pr_url}" + ok = _post_comment(cloud_id, issue_key, text) + log( + "TASK", + f"jira_reactions: comment_task_finished issue={issue_key} success={success} ok={ok}", + ) + + +def _reset_state_for_testing() -> None: + """Test-only: reset the auth circuit-breaker module state.""" + global _consecutive_auth_failures, _auth_circuit_open + with _auth_state_lock: + _consecutive_auth_failures = 0 + _auth_circuit_open = False diff --git a/agent/src/models.py b/agent/src/models.py index 88f668eb..0d0286b1 100644 --- a/agent/src/models.py +++ b/agent/src/models.py @@ -165,8 +165,9 @@ class TaskConfig(BaseModel): pr_number: str = "" task_id: str = "" # Inbound channel the task was submitted from (mirrors ChannelSource in - # cdk/src/handlers/shared/types.ts). Gates channel-specific MCP wiring and - # prompt additions. Empty string means "no channel context" (legacy / local). + # cdk/src/handlers/shared/types.ts: api | webhook | slack | linear | jira). + # Gates channel-specific MCP wiring and prompt additions. Empty string means + # "no channel context" (legacy / local). channel_source: str = "" channel_metadata: dict[str, str] = Field(default_factory=dict) # Platform user_id (Cognito ``sub``) threaded from the orchestrator diff --git a/agent/src/pipeline.py b/agent/src/pipeline.py index 2135e8d6..e6dfa0c0 100644 --- a/agent/src/pipeline.py +++ b/agent/src/pipeline.py @@ -15,8 +15,15 @@ import memory as agent_memory import task_state from channel_mcp import configure_channel_mcp -from config import AGENT_WORKSPACE, build_config, get_config, resolve_linear_api_token +from config import ( + AGENT_WORKSPACE, + build_config, + get_config, + resolve_jira_oauth_token, + resolve_linear_api_token, +) from context import assemble_prompt, fetch_github_issue +from jira_reactions import comment_task_finished, comment_task_started from linear_reactions import react_task_finished, react_task_started from models import AgentResult, HydratedContext, RepoSetup, TaskConfig, TaskResult from observability import task_span @@ -795,13 +802,16 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: system_prompt = build_system_prompt(config, setup, hc, system_prompt_overrides) - # Channel-specific MCP wiring (Linear only, for v1). Must happen - # before discover_project_config so the scan picks up the file we - # just wrote. Resolve the API token from Secrets Manager *before* - # writing .mcp.json so the child SDK process inherits the env var - # that the MCP server entry references via ${LINEAR_API_TOKEN}. + # Channel-specific MCP wiring. Must happen before + # discover_project_config so the scan picks up the file we just + # wrote. Resolve the per-channel access token from Secrets + # Manager *before* writing .mcp.json so the child SDK process + # inherits the env var that the MCP server entry references + # (${LINEAR_API_TOKEN} / ${JIRA_API_TOKEN}). if config.channel_source == "linear": resolve_linear_api_token(config.channel_metadata) + elif config.channel_source == "jira": + resolve_jira_oauth_token(config.channel_metadata) configure_channel_mcp(setup.repo_dir, config.channel_source) # 👀 on the Linear issue — acknowledges the task is picked up. @@ -813,6 +823,14 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: config.channel_metadata, ) + # "Starting" comment on the Jira issue (REST shim — the Atlassian + # Remote MCP can't be used from a headless agent). No-op for + # non-Jira tasks. Best-effort; failures are logged, never block. + comment_task_started( + config.channel_source, + config.channel_metadata, + ) + # Download attachments from S3 (version-pinned, integrity-verified) prepared_attachments: list = [] if config.attachments: @@ -1057,6 +1075,15 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: started_reaction_id=linear_eyes_reaction_id, ) + # Terminal status comment on the Jira issue (REST shim, with the + # PR link when one was opened). No-op for non-Jira tasks. + comment_task_finished( + config.channel_source, + config.channel_metadata, + success=(overall_status == "success"), + pr_url=pr_url, + ) + # --trace trajectory S3 upload (design §10.1). Runs AFTER # post-hooks but BEFORE ``write_terminal`` so the resulting # ``trace_s3_uri`` can be persisted atomically with the @@ -1178,6 +1205,14 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: success=False, started_reaction_id=linear_eyes_reaction_id, ) + # Best-effort failure comment on the Jira issue. No-op for + # non-Jira tasks; network failures are swallowed. + comment_task_finished( + config.channel_source, + config.channel_metadata, + success=False, + pr_url=None, + ) raise diff --git a/agent/src/prompt_builder.py b/agent/src/prompt_builder.py index 6d1489aa..dd9a7ca9 100644 --- a/agent/src/prompt_builder.py +++ b/agent/src/prompt_builder.py @@ -135,6 +135,12 @@ def _channel_prompt_addendum(config: TaskConfig) -> str: For Linear-origin tasks, instruct the agent to post progress comments and transition state using the already-loaded Linear MCP tools. The tool names are stated explicitly so the agent doesn't grope for them. + + Jira-origin tasks intentionally get NO addendum: Atlassian's Remote MCP + requires an interactive OAuth flow a headless agent can't complete, so the + MCP tools never load. Instructing the agent to use them just wastes turns. + Jira progress comments are posted out-of-band by ``jira_reactions`` (a REST + shim wired into the pipeline), not by the agent. """ if config.channel_source != "linear": return "" diff --git a/agent/src/server.py b/agent/src/server.py index 77421c2b..ca2c680e 100644 --- a/agent/src/server.py +++ b/agent/src/server.py @@ -50,7 +50,7 @@ def _redact_cached_credentials(text: str) -> str: """Remove cached env secrets from debug text before stdout / CloudWatch.""" out = text - for env_key in ("GITHUB_TOKEN", "LINEAR_API_TOKEN"): + for env_key in ("GITHUB_TOKEN", "LINEAR_API_TOKEN", "JIRA_API_TOKEN"): secret = os.environ.get(env_key) or "" if len(secret) >= _MIN_REDACTABLE_SECRET_LEN: out = out.replace(secret, f"<{env_key}_REDACTED>") diff --git a/agent/tests/test_channel_mcp.py b/agent/tests/test_channel_mcp.py index 9ef4c221..d7d3321a 100644 --- a/agent/tests/test_channel_mcp.py +++ b/agent/tests/test_channel_mcp.py @@ -1,4 +1,4 @@ -"""Unit tests for channel_mcp.configure_channel_mcp — Linear MCP gating + merge.""" +"""Unit tests for channel_mcp.configure_channel_mcp — Linear/Jira MCP gating + merge.""" from __future__ import annotations @@ -6,6 +6,9 @@ import os from channel_mcp import ( + JIRA_API_TOKEN_ENV, + JIRA_MCP_SERVER_KEY, + JIRA_MCP_URL, LINEAR_API_TOKEN_ENV, LINEAR_MCP_SERVER_KEY, LINEAR_MCP_URL, @@ -139,3 +142,71 @@ def test_missing_repo_dir(self, tmp_path): def test_empty_repo_dir_string(self): wrote = configure_channel_mcp("", "linear") assert wrote is False + + +class TestJiraWrite: + """channel_source=='jira' writes .mcp.json with the jira-server entry.""" + + def test_creates_mcp_json_with_jira_server_key(self, tmp_path): + wrote = configure_channel_mcp(str(tmp_path), "jira") + assert wrote is True + config = _read_mcp(str(tmp_path)) + assert JIRA_MCP_SERVER_KEY in config["mcpServers"] + + def test_renders_jira_url_and_token_placeholder(self, tmp_path): + configure_channel_mcp(str(tmp_path), "jira") + entry = _read_mcp(str(tmp_path))["mcpServers"][JIRA_MCP_SERVER_KEY] + assert entry["type"] == "http" + assert entry["url"] == JIRA_MCP_URL + assert entry["headers"]["Authorization"] == f"Bearer ${{{JIRA_API_TOKEN_ENV}}}" + + def test_server_key_is_jira_server(self): + # If this changes, tools surface under a different mcp__ prefix and + # the agent prompt addendum must be updated in lockstep. + assert JIRA_MCP_SERVER_KEY == "jira-server" + + +class TestJiraMerge: + """Jira entry must coexist with other servers and overwrite stale jira entries.""" + + def test_preserves_existing_mcp_servers(self, tmp_path): + existing = { + "mcpServers": { + "other-server": {"type": "stdio", "command": "/usr/bin/my-mcp"}, + }, + } + (tmp_path / ".mcp.json").write_text(json.dumps(existing)) + + configure_channel_mcp(str(tmp_path), "jira") + merged = _read_mcp(str(tmp_path)) + assert "other-server" in merged["mcpServers"] + assert merged["mcpServers"]["other-server"]["command"] == "/usr/bin/my-mcp" + assert JIRA_MCP_SERVER_KEY in merged["mcpServers"] + + def test_overwrites_existing_jira_server_entry(self, tmp_path): + existing = { + "mcpServers": { + JIRA_MCP_SERVER_KEY: { + "type": "http", + "url": "https://stale.example", + "headers": {"Authorization": "Bearer stale"}, + }, + }, + } + (tmp_path / ".mcp.json").write_text(json.dumps(existing)) + + configure_channel_mcp(str(tmp_path), "jira") + entry = _read_mcp(str(tmp_path))["mcpServers"][JIRA_MCP_SERVER_KEY] + assert entry["url"] == JIRA_MCP_URL + assert "stale" not in entry["headers"]["Authorization"] + + def test_linear_and_jira_can_coexist(self, tmp_path): + # Belt-and-braces: a repo that committed a Linear entry and then + # gets onboarded to Jira (or vice-versa) must keep both. The current + # code path only writes one channel per run, but this test guards + # against a future refactor that writes the wrong key. + configure_channel_mcp(str(tmp_path), "linear") + configure_channel_mcp(str(tmp_path), "jira") + merged = _read_mcp(str(tmp_path)) + assert LINEAR_MCP_SERVER_KEY in merged["mcpServers"] + assert JIRA_MCP_SERVER_KEY in merged["mcpServers"] diff --git a/agent/tests/test_config.py b/agent/tests/test_config.py index 44967057..a66c1e71 100644 --- a/agent/tests/test_config.py +++ b/agent/tests/test_config.py @@ -5,7 +5,12 @@ import pytest -from config import PR_WORKFLOW_IDS, build_config, resolve_linear_api_token +from config import ( + PR_WORKFLOW_IDS, + build_config, + resolve_jira_oauth_token, + resolve_linear_api_token, +) from models import TaskConfig @@ -488,3 +493,240 @@ def test_corrupted_secret_json_returns_empty_with_error_log(self, monkeypatch): with patch("boto3.client", return_value=mock_sm): assert resolve_linear_api_token({"linear_oauth_secret_arn": "arn:t"}) == "" monkeypatch.delenv("LINEAR_API_TOKEN", raising=False) + + +class TestResolveJiraOauthToken: + """Jira Cloud OAuth token resolves from per-tenant Secrets Manager. + + 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 and caches the access_token in `JIRA_API_TOKEN` + for the agent-side Jira REST calls (jira_reactions). + + Unlike the Linear resolver, the agent NEVER refreshes the Jira token: + Atlassian rotates the refresh_token on every use and the agent role has + GetSecretValue only (no Put), so a refresh would burn the stored + refresh_token and brick the tenant. See TestResolveJiraOauthTokenNoRefresh. + """ + + def test_returns_cached_value_without_calling_secrets_manager(self, monkeypatch): + """Fast-path: if JIRA_API_TOKEN is already set, no SDK call fires.""" + monkeypatch.setenv("JIRA_API_TOKEN", "jira_oauth_cached") + with patch("boto3.client") as mock_boto: + assert resolve_jira_oauth_token() == "jira_oauth_cached" + mock_boto.assert_not_called() + + def test_returns_empty_when_secret_arn_missing(self, monkeypatch): + """Without channel_metadata.jira_oauth_secret_arn or env, no source — empty.""" + monkeypatch.delenv("JIRA_API_TOKEN", raising=False) + monkeypatch.delenv("JIRA_OAUTH_SECRET_ARN", raising=False) + with patch("boto3.client") as mock_boto: + assert resolve_jira_oauth_token() == "" + mock_boto.assert_not_called() + + def test_returns_empty_when_region_missing(self, monkeypatch): + """No region → can't construct boto3 client → empty + WARN, no SDK call.""" + monkeypatch.delenv("JIRA_API_TOKEN", raising=False) + monkeypatch.delenv("AWS_REGION", raising=False) + monkeypatch.delenv("AWS_DEFAULT_REGION", raising=False) + with patch("boto3.client") as mock_boto: + assert resolve_jira_oauth_token({"jira_oauth_secret_arn": "arn:test"}) == "" + mock_boto.assert_not_called() + + def test_resolves_from_secrets_manager_and_caches_in_env(self, monkeypatch): + """Happy path: channel_metadata carries the ARN, secret has access_token + future expiry.""" + from datetime import datetime, timedelta + + monkeypatch.delenv("JIRA_API_TOKEN", raising=False) + monkeypatch.setenv("AWS_REGION", "us-east-1") + future = (datetime.now(UTC) + timedelta(hours=12)).isoformat().replace("+00:00", "Z") + token_payload = { + "access_token": "jira_oauth_fresh", + "refresh_token": "jira_refresh_xyz", + "expires_at": future, + "scope": "read:jira-work write:jira-work offline_access", + "client_id": "cid", + "client_secret": "csec", + "cloud_id": "cloud-uuid", + "site_url": "https://acme.atlassian.net", + "installed_at": "2026-05-19T08:00:00Z", + "updated_at": "2026-05-19T08:00:00Z", + "installed_by_platform_user_id": "cog-sub", + } + mock_sm = MagicMock() + mock_sm.get_secret_value.return_value = { + "SecretString": __import__("json").dumps(token_payload), + } + with patch("boto3.client", return_value=mock_sm): + resolved = resolve_jira_oauth_token({"jira_oauth_secret_arn": "arn:test"}) + assert resolved == "jira_oauth_fresh" + + # Cached for subsequent reads. + import os as _os + + assert _os.environ.get("JIRA_API_TOKEN") == "jira_oauth_fresh" + # Reset for other tests. + monkeypatch.delenv("JIRA_API_TOKEN", raising=False) + + def test_returns_empty_on_secrets_manager_access_denied(self, monkeypatch): + """ClientError surfaces as empty + ERROR log, never crashes the agent.""" + from botocore.exceptions import ClientError + + monkeypatch.delenv("JIRA_API_TOKEN", raising=False) + monkeypatch.setenv("AWS_REGION", "us-east-1") + mock_sm = MagicMock() + mock_sm.get_secret_value.side_effect = ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "no perms"}}, + "GetSecretValue", + ) + with patch("boto3.client", return_value=mock_sm): + assert resolve_jira_oauth_token({"jira_oauth_secret_arn": "arn:test"}) == "" + + def test_falls_back_to_env_var_when_channel_metadata_omits_arn(self, monkeypatch): + """JIRA_OAUTH_SECRET_ARN env var is the back-compat fallback.""" + from datetime import datetime, timedelta + + monkeypatch.delenv("JIRA_API_TOKEN", raising=False) + monkeypatch.setenv("AWS_REGION", "us-east-1") + monkeypatch.setenv("JIRA_OAUTH_SECRET_ARN", "arn:from-env") + future = (datetime.now(UTC) + timedelta(hours=12)).isoformat().replace("+00:00", "Z") + mock_sm = MagicMock() + mock_sm.get_secret_value.return_value = { + "SecretString": __import__("json").dumps( + { + "access_token": "jira_oauth_envpath", + "refresh_token": "rt", + "expires_at": future, + "scope": "read:jira-work", + "client_id": "c", + "client_secret": "s", + "cloud_id": "cl", + "site_url": "https://x.atlassian.net", + "installed_at": "x", + "updated_at": "x", + "installed_by_platform_user_id": "u", + } + ), + } + with patch("boto3.client", return_value=mock_sm): + assert resolve_jira_oauth_token() == "jira_oauth_envpath" + monkeypatch.delenv("JIRA_API_TOKEN", raising=False) + + +class TestResolveJiraOauthTokenNoRefresh: + """The agent NEVER refreshes the Jira OAuth token. + + Atlassian rotates the refresh_token on every use, and the agent role has + `secretsmanager:GetSecretValue` only (no Put). If the agent refreshed it + would consume the stored refresh_token, keep the rotated replacement only + in memory for this task, and leave Secrets Manager holding a dead + refresh_token — bricking the tenant on the next resolve. So the resolver + uses a still-valid stored token verbatim and fails CLOSED (empty string, + advisory comments no-op) when the stored token is expiring. The trusted + Lambda path (jira-oauth-resolver.ts, which has PutSecretValue) owns all + refreshes. + """ + + @staticmethod + def _stored(**overrides): + from datetime import datetime, timedelta + + # Default: token expires in 30s so _is_expiring returns True. + soon = (datetime.now(UTC) + timedelta(seconds=30)).isoformat().replace("+00:00", "Z") + base = { + "access_token": "jira_old", + "refresh_token": "rt-old", + "expires_at": soon, + "scope": "read:jira-work write:jira-work", + "client_id": "cid", + "client_secret": "csec", + "cloud_id": "cloud-uuid", + "site_url": "https://acme.atlassian.net", + "installed_at": "2026-05-19T08:00:00Z", + "updated_at": "2026-05-19T08:00:00Z", + "installed_by_platform_user_id": "cog", + } + base.update(overrides) + return base + + def test_expiring_token_fails_closed_without_network_call(self, monkeypatch): + """Expiring stored token → empty string, and NO /oauth/token POST. + + This is the regression guard for the rotating-refresh-token bug: the + agent must not consume the stored refresh_token. + """ + import json + from unittest.mock import patch as upatch + + monkeypatch.delenv("JIRA_API_TOKEN", raising=False) + monkeypatch.setenv("AWS_REGION", "us-east-1") + + mock_sm = MagicMock() + mock_sm.get_secret_value.return_value = {"SecretString": json.dumps(self._stored())} + + with ( + patch("boto3.client", return_value=mock_sm), + upatch("urllib.request.urlopen") as urlopen_mock, + ): + assert resolve_jira_oauth_token({"jira_oauth_secret_arn": "arn:t"}) == "" + urlopen_mock.assert_not_called() + # Nothing cached on the fail-closed path. + import os as _os + + assert _os.environ.get("JIRA_API_TOKEN") in (None, "") + monkeypatch.delenv("JIRA_API_TOKEN", raising=False) + + def test_valid_token_used_verbatim_without_network_call(self, monkeypatch): + """A still-valid stored token is returned as-is, with no refresh POST.""" + import json + from datetime import datetime, timedelta + from unittest.mock import patch as upatch + + monkeypatch.delenv("JIRA_API_TOKEN", raising=False) + monkeypatch.setenv("AWS_REGION", "us-east-1") + + future = (datetime.now(UTC) + timedelta(hours=12)).isoformat().replace("+00:00", "Z") + valid = self._stored(access_token="jira_valid", expires_at=future) + mock_sm = MagicMock() + mock_sm.get_secret_value.return_value = {"SecretString": json.dumps(valid)} + + with ( + patch("boto3.client", return_value=mock_sm), + upatch("urllib.request.urlopen") as urlopen_mock, + ): + assert resolve_jira_oauth_token({"jira_oauth_secret_arn": "arn:t"}) == "jira_valid" + urlopen_mock.assert_not_called() + monkeypatch.delenv("JIRA_API_TOKEN", raising=False) + + def test_malformed_expires_at_fails_closed(self, monkeypatch): + """Unparseable expires_at is treated as expiring → fail closed, no network call.""" + import json + from unittest.mock import patch as upatch + + monkeypatch.delenv("JIRA_API_TOKEN", raising=False) + monkeypatch.setenv("AWS_REGION", "us-east-1") + + bad = self._stored(expires_at="this is not a date") + mock_sm = MagicMock() + mock_sm.get_secret_value.return_value = {"SecretString": json.dumps(bad)} + + with ( + patch("boto3.client", return_value=mock_sm), + upatch("urllib.request.urlopen") as urlopen_mock, + ): + assert resolve_jira_oauth_token({"jira_oauth_secret_arn": "arn:t"}) == "" + urlopen_mock.assert_not_called() + monkeypatch.delenv("JIRA_API_TOKEN", raising=False) + + def test_corrupted_secret_json_returns_empty_with_error_log(self, monkeypatch): + """Corrupted SM payload → empty string return, no traceback.""" + monkeypatch.delenv("JIRA_API_TOKEN", raising=False) + monkeypatch.setenv("AWS_REGION", "us-east-1") + + mock_sm = MagicMock() + mock_sm.get_secret_value.return_value = { + "SecretString": "this is { not } valid json", + } + with patch("boto3.client", return_value=mock_sm): + assert resolve_jira_oauth_token({"jira_oauth_secret_arn": "arn:t"}) == "" + monkeypatch.delenv("JIRA_API_TOKEN", raising=False) diff --git a/agent/tests/test_jira_reactions.py b/agent/tests/test_jira_reactions.py new file mode 100644 index 00000000..4f7b9c29 --- /dev/null +++ b/agent/tests/test_jira_reactions.py @@ -0,0 +1,147 @@ +"""Unit tests for the Jira issue-comment REST shim (jira_reactions).""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +import jira_reactions +from jira_reactions import comment_task_finished, comment_task_started + +JIRA_META = {"jira_cloud_id": "cloud-1", "jira_issue_key": "KAN-1"} + + +def _resp(status_code: int = 201, text: str = "") -> MagicMock: + r = MagicMock() + r.status_code = status_code + r.text = text + return r + + +@pytest.fixture(autouse=True) +def _reset_circuit(): + """Each test starts with a closed (healthy) auth circuit. + + Autouse so it applies to module-level functions AND methods inside test + classes (a module-level ``setup_function`` does not run for class methods). + """ + jira_reactions._reset_state_for_testing() + yield + jira_reactions._reset_state_for_testing() + + +class TestChannelGate: + def test_non_jira_source_is_noop(self, monkeypatch): + monkeypatch.setenv("JIRA_API_TOKEN", "jira_at") + with patch("jira_reactions.requests.post") as post: + comment_task_started("linear", JIRA_META) + comment_task_finished("linear", JIRA_META, success=True) + post.assert_not_called() + + def test_empty_metadata_is_noop(self, monkeypatch): + monkeypatch.setenv("JIRA_API_TOKEN", "jira_at") + with patch("jira_reactions.requests.post") as post: + comment_task_started("jira", None) + comment_task_started("jira", {}) + post.assert_not_called() + + def test_missing_issue_key_is_noop(self, monkeypatch): + monkeypatch.setenv("JIRA_API_TOKEN", "jira_at") + with patch("jira_reactions.requests.post") as post: + comment_task_started("jira", {"jira_cloud_id": "cloud-1"}) + post.assert_not_called() + + +class TestStartComment: + def test_posts_adf_comment_to_correct_url(self, monkeypatch): + monkeypatch.setenv("JIRA_API_TOKEN", "jira_at") + with patch("jira_reactions.requests.post", return_value=_resp(201)) as post: + comment_task_started("jira", JIRA_META) + assert post.call_count == 1 + url = post.call_args[0][0] + assert url == ("https://api.atlassian.com/ex/jira/cloud-1/rest/api/3/issue/KAN-1/comment") + body = post.call_args[1]["json"]["body"] + assert body["type"] == "doc" + assert body["content"][0]["content"][0]["text"].startswith("🤖") + headers = post.call_args[1]["headers"] + assert headers["Authorization"] == "Bearer jira_at" + + def test_skips_when_token_missing(self, monkeypatch): + monkeypatch.delenv("JIRA_API_TOKEN", raising=False) + with patch("jira_reactions.requests.post") as post: + comment_task_started("jira", JIRA_META) + post.assert_not_called() + + +class TestFinishComment: + def test_success_with_pr_includes_pr_url(self, monkeypatch): + monkeypatch.setenv("JIRA_API_TOKEN", "jira_at") + with patch("jira_reactions.requests.post", return_value=_resp(201)) as post: + comment_task_finished( + "jira", JIRA_META, success=True, pr_url="https://github.com/o/r/pull/7" + ) + text = post.call_args[1]["json"]["body"]["content"][0]["content"][0]["text"] + assert "✅" in text + assert "https://github.com/o/r/pull/7" in text + + def test_success_without_pr_notes_no_pr(self, monkeypatch): + monkeypatch.setenv("JIRA_API_TOKEN", "jira_at") + with patch("jira_reactions.requests.post", return_value=_resp(201)) as post: + comment_task_finished("jira", JIRA_META, success=True, pr_url=None) + text = post.call_args[1]["json"]["body"]["content"][0]["content"][0]["text"] + assert "✅" in text + assert "No pull request" in text + + def test_failure_comment(self, monkeypatch): + monkeypatch.setenv("JIRA_API_TOKEN", "jira_at") + with patch("jira_reactions.requests.post", return_value=_resp(201)) as post: + comment_task_finished("jira", JIRA_META, success=False, pr_url=None) + text = post.call_args[1]["json"]["body"]["content"][0]["content"][0]["text"] + assert "❌" in text + + +class TestFailureIsSwallowed: + def test_http_500_does_not_raise(self, monkeypatch): + monkeypatch.setenv("JIRA_API_TOKEN", "jira_at") + with patch("jira_reactions.requests.post", return_value=_resp(500, "boom")): + # Must not raise. + comment_task_started("jira", JIRA_META) + comment_task_finished("jira", JIRA_META, success=True) + + def test_request_exception_does_not_raise(self, monkeypatch): + import requests + + monkeypatch.setenv("JIRA_API_TOKEN", "jira_at") + with patch( + "jira_reactions.requests.post", + side_effect=requests.RequestException("network down"), + ): + comment_task_started("jira", JIRA_META) + + +class TestAuthCircuitBreaker: + def test_opens_after_threshold_consecutive_401s(self, monkeypatch): + monkeypatch.setenv("JIRA_API_TOKEN", "jira_at") + with patch("jira_reactions.requests.post", return_value=_resp(401)) as post: + # Three consecutive 401s open the breaker. + for _ in range(jira_reactions._AUTH_FAILURE_THRESHOLD): + comment_task_started("jira", JIRA_META) + calls_after_open = post.call_count + # Further calls short-circuit without hitting the network. + comment_task_started("jira", JIRA_META) + comment_task_finished("jira", JIRA_META, success=True) + assert post.call_count == calls_after_open + + def test_2xx_resets_failure_counter(self, monkeypatch): + monkeypatch.setenv("JIRA_API_TOKEN", "jira_at") + # Two 401s (below threshold), then a success resets, so the breaker + # never opens. + responses = [_resp(401), _resp(401), _resp(201), _resp(401)] + with patch("jira_reactions.requests.post", side_effect=responses) as post: + comment_task_started("jira", JIRA_META) + comment_task_started("jira", JIRA_META) + comment_task_started("jira", JIRA_META) # 201 → reset + comment_task_started("jira", JIRA_META) # 401, count back to 1 + assert post.call_count == 4 + assert jira_reactions._auth_circuit_open is False diff --git a/agent/tests/test_prompts.py b/agent/tests/test_prompts.py index 83a612d2..b26e13aa 100644 --- a/agent/tests/test_prompts.py +++ b/agent/tests/test_prompts.py @@ -1,12 +1,60 @@ """Unit tests for the prompts module and sanitization.""" +from typing import Any + import pytest -from prompt_builder import sanitize_memory_content +from models import TaskConfig +from prompt_builder import _channel_prompt_addendum, sanitize_memory_content from prompts import get_system_prompt from sanitization import sanitize_external_content +def _config(**overrides) -> TaskConfig: + # Use an explicitly typed dict so ty can see the heterogenous field + # types across the TaskConfig signature (``bool`` for ``dry_run``, + # ``int`` for ``max_turns``, etc.) rather than inferring ``dict[str, str]`` + # from the homogeneous base literal. + base: dict[str, Any] = { + "repo_url": "owner/repo", + "github_token": "ghp_test", + "aws_region": "us-west-2", + } + base.update(overrides) + return TaskConfig(**base) + + +class TestChannelPromptAddendum: + def test_no_channel_returns_empty(self): + assert _channel_prompt_addendum(_config()) == "" + + def test_api_channel_returns_empty(self): + assert _channel_prompt_addendum(_config(channel_source="api")) == "" + + def test_linear_channel_includes_linear_tools(self): + addendum = _channel_prompt_addendum( + _config( + channel_source="linear", + channel_metadata={"linear_issue_identifier": "ABC-42"}, + ) + ) + assert "Linear issue progress updates" in addendum + assert "mcp__linear-server__save_comment" in addendum + assert "ABC-42" in addendum + + def test_jira_channel_gets_no_addendum(self): + # Jira comments are posted out-of-band by jira_reactions (REST shim); + # the Atlassian MCP can't load in a headless agent, so instructing the + # agent to use it would just waste turns. No prompt addendum. + addendum = _channel_prompt_addendum( + _config( + channel_source="jira", + channel_metadata={"jira_issue_key": "KAN-1"}, + ) + ) + assert addendum == "" + + class TestGetSystemPrompt: def test_new_task_returns_prompt_with_create_pr(self): prompt = get_system_prompt("coding/new-task-v1") diff --git a/cdk/src/constructs/jira-integration.ts b/cdk/src/constructs/jira-integration.ts new file mode 100644 index 00000000..1091736c --- /dev/null +++ b/cdk/src/constructs/jira-integration.ts @@ -0,0 +1,360 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as path from 'path'; +import { ArnFormat, Duration, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import * as apigw from 'aws-cdk-lib/aws-apigateway'; +import * as cognito from 'aws-cdk-lib/aws-cognito'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import { Runtime, Architecture } from 'aws-cdk-lib/aws-lambda'; +import * as lambda from 'aws-cdk-lib/aws-lambda-nodejs'; +import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; +import { NagSuppressions } from 'cdk-nag'; +import { Construct } from 'constructs'; +import { JiraProjectMappingTable } from './jira-project-mapping-table'; +import { JiraUserMappingTable } from './jira-user-mapping-table'; +import { JiraWorkspaceRegistryTable } from './jira-workspace-registry-table'; + +/** + * Properties for JiraIntegration construct. + */ +export interface JiraIntegrationProps { + /** The existing REST API to add Jira routes to. */ + readonly api: apigw.RestApi; + + /** Cognito user pool for the /jira/link endpoint (Cognito-authenticated). */ + readonly userPool: cognito.IUserPool; + + /** The DynamoDB task table. */ + readonly taskTable: dynamodb.ITable; + + /** The DynamoDB task events table. */ + readonly taskEventsTable: dynamodb.ITable; + + /** The DynamoDB repo config table (optional — for repo onboarding checks). */ + readonly repoTable?: dynamodb.ITable; + + /** Orchestrator Lambda function ARN for async task invocation. */ + readonly orchestratorFunctionArn?: string; + + /** Bedrock Guardrail ID for input screening. */ + readonly guardrailId?: string; + + /** Bedrock Guardrail version for input screening. */ + readonly guardrailVersion?: string; + + /** Task retention in days for TTL computation. */ + readonly taskRetentionDays?: number; + + /** Removal policy for Jira DynamoDB tables. */ + readonly removalPolicy?: RemovalPolicy; +} + +/** + * CDK construct that adds Jira Cloud integration to the ABCA platform. + * + * Inbound-only adapter: Jira → webhook → task creation. Outbound progress + * updates happen agent-side via the Jira REST v3 API (see + * agent/src/jira_reactions.py; ADR-015 explains why outbound is REST and not + * the Atlassian Remote MCP), so there is NO DynamoDB Streams consumer and NO + * outbound-notify Lambda here. Mirrors the Linear adapter shape. + * + * Creates: + * - JiraProjectMappingTable (`{cloudId}#{projectKey}` → GitHub repo) + * - JiraUserMappingTable (`{cloudId}#{accountId}` → platform user; with + * GSI for reverse lookup and `pending#{code}` link rows) + * - JiraWorkspaceRegistryTable (`cloudId` → AgentCore credential provider). + * Webhook receiver and processor look up the per-tenant signing/OAuth + * secret here from the inbound webhook's `cloudId`. + * - JiraWebhookDedupTable (8h TTL dedup for webhook retries) + * - Lambda handlers for the webhook receiver, async processor, and account linking + * - API Gateway routes under /jira/* + * - Webhook signing-secret placeholder (populated by `bgagent jira setup`) + */ +export class JiraIntegration extends Construct { + /** Jira `{cloudId}#{projectKey}` → repo mapping table. */ + public readonly projectMappingTable: dynamodb.Table; + + /** Jira `{cloudId}#{accountId}` → platform user mapping table. */ + public readonly userMappingTable: dynamodb.Table; + + /** + * Registry of Jira tenants that have completed OAuth onboarding. + * Lookup `provider_name` (AgentCore credential provider) by `cloudId` + * from the inbound webhook. + */ + public readonly workspaceRegistryTable: dynamodb.Table; + + /** Webhook dedup table — `{issueKey}#{webhookEvent}#{timestamp}` keys with 8h TTL. */ + public readonly webhookDedupTable: dynamodb.Table; + + /** Jira webhook signing secret (placeholder — populated by `bgagent jira setup`). */ + public readonly webhookSecret: secretsmanager.Secret; + + constructor(scope: Construct, id: string, props: JiraIntegrationProps) { + super(scope, id); + + const removalPolicy = props.removalPolicy ?? RemovalPolicy.DESTROY; + + // --- DynamoDB tables --- + const projectMapping = new JiraProjectMappingTable(this, 'ProjectMappingTable', { removalPolicy }); + const userMapping = new JiraUserMappingTable(this, 'UserMappingTable', { removalPolicy }); + const workspaceRegistry = new JiraWorkspaceRegistryTable(this, 'WorkspaceRegistryTable', { removalPolicy }); + this.projectMappingTable = projectMapping.table; + this.userMappingTable = userMapping.table; + this.workspaceRegistryTable = workspaceRegistry.table; + + // Dedup table: Jira webhook retries collapse to a single processor invoke + // within the 8h TTL window. Keyed on `{issueKey}#{webhookEvent}#{timestamp}`. + this.webhookDedupTable = new dynamodb.Table(this, 'WebhookDedupTable', { + partitionKey: { name: 'dedup_key', type: dynamodb.AttributeType.STRING }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + timeToLiveAttribute: 'ttl', + pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true }, + removalPolicy, + }); + + // --- Webhook signing secret (placeholder, populated by `bgagent jira setup`) --- + // Per-tenant OAuth tokens live in `bgagent-jira-oauth-` secrets + // created by the CLI at runtime — not here. This stack-wide secret is + // a back-compat fallback for single-tenant installs predating per- + // tenant signing. + this.webhookSecret = new secretsmanager.Secret(this, 'WebhookSecret', { + description: 'Jira webhook signing secret — populate via `bgagent jira setup`', + removalPolicy, + }); + + // --- Shared Lambda configuration --- + const handlersDir = path.join(__dirname, '..', 'handlers'); + const commonBundling: lambda.BundlingOptions = { + externalModules: ['@aws-sdk/*'], + }; + + // --- Task creation environment (matches LinearIntegration / SlackIntegration pattern) --- + const createTaskEnv: Record = { + TASK_TABLE_NAME: props.taskTable.tableName, + TASK_EVENTS_TABLE_NAME: props.taskEventsTable.tableName, + TASK_RETENTION_DAYS: String(props.taskRetentionDays ?? 90), + }; + if (props.repoTable) { + createTaskEnv.REPO_TABLE_NAME = props.repoTable.tableName; + } + if (props.orchestratorFunctionArn) { + createTaskEnv.ORCHESTRATOR_FUNCTION_ARN = props.orchestratorFunctionArn; + } + if (props.guardrailId && props.guardrailVersion) { + createTaskEnv.GUARDRAIL_ID = props.guardrailId; + createTaskEnv.GUARDRAIL_VERSION = props.guardrailVersion; + } + + // --- Cognito Authorizer (for /jira/link) --- + const cognitoAuthorizer = new apigw.CognitoUserPoolsAuthorizer(this, 'JiraCognitoAuthorizer', { + cognitoUserPools: [props.userPool], + }); + const cognitoAuthOptions: apigw.MethodOptions = { + authorizer: cognitoAuthorizer, + authorizationType: apigw.AuthorizationType.COGNITO, + }; + const noneAuthOptions: apigw.MethodOptions = { + authorizationType: apigw.AuthorizationType.NONE, + }; + + // ═══════════════════════════════════════════════════════════════════════════ + // Lambda Handlers + // ═══════════════════════════════════════════════════════════════════════════ + + // --- Webhook processor (async, invoked by receiver) --- + const webhookProcessorFn = new lambda.NodejsFunction(this, 'WebhookProcessorFn', { + entry: path.join(handlersDir, 'jira-webhook-processor.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + timeout: Duration.seconds(30), + // 512 MB matches the Linear processor — same attachment-screening + // path bundles the same pdf-parse + URL-resolver libs alongside the + // SDK, and Atlassian's ADF→markdown walker adds a small additional + // working set. Keeps p99 cold-start under the API Gateway 30s deadline. + memorySize: 512, + environment: { + ...createTaskEnv, + JIRA_PROJECT_MAPPING_TABLE_NAME: this.projectMappingTable.tableName, + JIRA_USER_MAPPING_TABLE_NAME: this.userMappingTable.tableName, + JIRA_WORKSPACE_REGISTRY_TABLE_NAME: this.workspaceRegistryTable.tableName, + }, + bundling: commonBundling, + }); + this.projectMappingTable.grantReadData(webhookProcessorFn); + this.userMappingTable.grantReadData(webhookProcessorFn); + this.workspaceRegistryTable.grantReadData(webhookProcessorFn); + // Per-tenant OAuth token secrets are created by the CLI at setup time + // (`bgagent-jira-oauth-`), not by CDK. Grant the processor + // Get + Put on the prefix so it can read tokens and write back rotated + // refresh-token JSON during expiring-token refresh. + webhookProcessorFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['secretsmanager:GetSecretValue', 'secretsmanager:PutSecretValue'], + resources: [ + Stack.of(this).formatArn({ + service: 'secretsmanager', + resource: 'secret', + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + resourceName: 'bgagent-jira-oauth-*', + }), + ], + })); + props.taskTable.grantReadWriteData(webhookProcessorFn); + props.taskEventsTable.grantReadWriteData(webhookProcessorFn); + if (props.repoTable) { + props.repoTable.grantReadData(webhookProcessorFn); + } + if (props.orchestratorFunctionArn) { + webhookProcessorFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['lambda:InvokeFunction'], + resources: [props.orchestratorFunctionArn], + })); + } + if (props.guardrailId) { + webhookProcessorFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['bedrock:ApplyGuardrail'], + resources: [ + Stack.of(this).formatArn({ + service: 'bedrock', + resource: 'guardrail', + resourceName: props.guardrailId, + }), + ], + })); + } + + // --- Webhook receiver (verifies HMAC, dedups, invokes processor) --- + const webhookFn = new lambda.NodejsFunction(this, 'WebhookFn', { + entry: path.join(handlersDir, 'jira-webhook.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + timeout: Duration.seconds(10), + environment: { + JIRA_WEBHOOK_SECRET_ARN: this.webhookSecret.secretArn, + JIRA_WEBHOOK_DEDUP_TABLE_NAME: this.webhookDedupTable.tableName, + JIRA_WEBHOOK_PROCESSOR_FUNCTION_NAME: webhookProcessorFn.functionName, + // Per-tenant signing-secret lookup — selects the right tenant's + // `webhook_signing_secret` from the OAuth secret bundle so multi- + // tenant installs verify correctly. Receiver falls back to + // JIRA_WEBHOOK_SECRET_ARN when this lookup misses (back-compat for + // single-tenant installs). + JIRA_WORKSPACE_REGISTRY_TABLE_NAME: this.workspaceRegistryTable.tableName, + }, + bundling: commonBundling, + }); + this.webhookSecret.grantRead(webhookFn); + this.webhookDedupTable.grantReadWriteData(webhookFn); + this.workspaceRegistryTable.grantReadData(webhookFn); + // Read-only on the per-tenant OAuth secret prefix — we extract + // `webhook_signing_secret` for verification but never mutate; the + // CLI owns the lifecycle of these secrets. + webhookFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['secretsmanager:GetSecretValue'], + resources: [ + Stack.of(this).formatArn({ + service: 'secretsmanager', + resource: 'secret', + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + resourceName: 'bgagent-jira-oauth-*', + }), + ], + })); + webhookProcessorFn.grantInvoke(webhookFn); + + // --- Account linking (Cognito-authenticated) --- + const linkFn = new lambda.NodejsFunction(this, 'LinkFn', { + entry: path.join(handlersDir, 'jira-link.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + timeout: Duration.seconds(10), + environment: { + JIRA_USER_MAPPING_TABLE_NAME: this.userMappingTable.tableName, + }, + bundling: commonBundling, + }); + this.userMappingTable.grantReadWriteData(linkFn); + + // ═══════════════════════════════════════════════════════════════════════════ + // API Gateway Routes + // ═══════════════════════════════════════════════════════════════════════════ + + const jira = props.api.root.addResource('jira'); + + // POST /v1/jira/webhook — HMAC-verified; no Cognito. + const webhookResource = jira.addResource('webhook'); + const webhookMethod = webhookResource.addMethod( + 'POST', + new apigw.LambdaIntegration(webhookFn), + noneAuthOptions, + ); + + // POST /v1/jira/link — Cognito-authenticated. + const linkResource = jira.addResource('link'); + linkResource.addMethod( + 'POST', + new apigw.LambdaIntegration(linkFn), + cognitoAuthOptions, + ); + + // ═══════════════════════════════════════════════════════════════════════════ + // cdk-nag suppressions + // ═══════════════════════════════════════════════════════════════════════════ + + NagSuppressions.addResourceSuppressions(webhookMethod, [ + { + id: 'AwsSolutions-APIG4', + reason: 'Jira webhook endpoint uses X-Hub-Signature HMAC verification instead of Cognito — by design for Jira webhook integration', + }, + { + id: 'AwsSolutions-COG4', + reason: 'Jira webhook endpoint uses X-Hub-Signature HMAC verification instead of Cognito — by design for Jira webhook integration', + }, + ]); + + NagSuppressions.addResourceSuppressions(this.webhookSecret, [ + { + id: 'AwsSolutions-SMG4', + reason: 'Jira webhook signing secret is managed externally (Atlassian admin UI) — automatic rotation is not applicable', + }, + ]); + + const allFunctions = [webhookFn, webhookProcessorFn, linkFn]; + for (const fn of allFunctions) { + NagSuppressions.addResourceSuppressions(fn, [ + { + id: 'AwsSolutions-IAM4', + reason: 'AWSLambdaBasicExecutionRole is the AWS-recommended managed policy for Lambda functions', + }, + { + id: 'AwsSolutions-IAM5', + reason: + 'Wildcards cover (a) DynamoDB index ARN patterns from CDK grant helpers, ' + + 'and (b) the Secrets Manager `bgagent-jira-oauth-*` prefix grant — ' + + 'the per-tenant OAuth secret name is not known at synth time ' + + '(operators add tenants by cloudId at runtime via `bgagent jira setup`).', + }, + ], true); + } + } +} diff --git a/cdk/src/constructs/jira-project-mapping-table.ts b/cdk/src/constructs/jira-project-mapping-table.ts new file mode 100644 index 00000000..b85c9b78 --- /dev/null +++ b/cdk/src/constructs/jira-project-mapping-table.ts @@ -0,0 +1,86 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { RemovalPolicy } from 'aws-cdk-lib'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import { Construct } from 'constructs'; + +/** + * Properties for JiraProjectMappingTable construct. + */ +export interface JiraProjectMappingTableProps { + /** + * Optional table name override. + * @default - auto-generated by CloudFormation + */ + readonly tableName?: string; + + /** + * Removal policy for the table. + * @default RemovalPolicy.DESTROY + */ + readonly removalPolicy?: RemovalPolicy; + + /** + * Whether to enable point-in-time recovery. + * @default true + */ + readonly pointInTimeRecovery?: boolean; +} + +/** + * DynamoDB table mapping Jira Cloud projects to GitHub repositories. + * + * Schema: jira_project_identity (PK) — composite key `{cloudId}#{projectKey}`. + * `cloudId` is the Atlassian tenant identifier (returned by OAuth + present on + * every webhook payload); `projectKey` is the human project key (e.g. `ENG`). + * The composite key keeps the same project key across distinct tenants + * unambiguous. + * + * Fields: + * - repo — `owner/repo` + * - cloud_id — duplicated from the PK for filtering by tenant + * - project_key — duplicated from the PK + * - label_filter — Jira issue label that triggers a task (default `bgagent`) + * - status — 'active' | 'removed' + * - onboarded_at, updated_at — ISO timestamps + */ +export class JiraProjectMappingTable extends Construct { + /** + * The underlying DynamoDB table. + */ + public readonly table: dynamodb.Table; + + constructor(scope: Construct, id: string, props: JiraProjectMappingTableProps = {}) { + super(scope, id); + + this.table = new dynamodb.Table(this, 'Table', { + tableName: props.tableName, + partitionKey: { + name: 'jira_project_identity', + type: dynamodb.AttributeType.STRING, + }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + pointInTimeRecoverySpecification: { + pointInTimeRecoveryEnabled: props.pointInTimeRecovery ?? true, + }, + removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY, + }); + } +} diff --git a/cdk/src/constructs/jira-user-mapping-table.ts b/cdk/src/constructs/jira-user-mapping-table.ts new file mode 100644 index 00000000..1b8485f0 --- /dev/null +++ b/cdk/src/constructs/jira-user-mapping-table.ts @@ -0,0 +1,94 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { RemovalPolicy } from 'aws-cdk-lib'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import { Construct } from 'constructs'; + +/** + * Properties for JiraUserMappingTable construct. + */ +export interface JiraUserMappingTableProps { + /** + * Optional table name override. + * @default - auto-generated by CloudFormation + */ + readonly tableName?: string; + + /** + * Removal policy for the table. + * @default RemovalPolicy.DESTROY + */ + readonly removalPolicy?: RemovalPolicy; + + /** + * Whether to enable point-in-time recovery. + * @default true + */ + readonly pointInTimeRecovery?: boolean; +} + +/** + * DynamoDB table for mapping Jira user identities to platform user IDs. + * + * Schema: jira_identity (PK) — composite key `{cloudId}#{accountId}` for + * confirmed mappings, `pending#{code}` for in-flight link codes (with TTL). + * `accountId` is Atlassian's stable per-tenant user identifier returned by + * the OAuth identity endpoint and present on issue events. + * + * GSIs: + * - PlatformUserIndex (PK: platform_user_id, SK: linked_at) — list linked Jira accounts for a user + */ +export class JiraUserMappingTable extends Construct { + /** + * GSI name for querying mappings by platform user. + * PK: platform_user_id, SK: linked_at. + */ + public static readonly PLATFORM_USER_INDEX = 'PlatformUserIndex'; + + /** + * The underlying DynamoDB table. + */ + public readonly table: dynamodb.Table; + + constructor(scope: Construct, id: string, props: JiraUserMappingTableProps = {}) { + super(scope, id); + + this.table = new dynamodb.Table(this, 'Table', { + tableName: props.tableName, + partitionKey: { + name: 'jira_identity', + type: dynamodb.AttributeType.STRING, + }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + timeToLiveAttribute: 'ttl', + pointInTimeRecoverySpecification: { + pointInTimeRecoveryEnabled: props.pointInTimeRecovery ?? true, + }, + removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY, + }); + + this.table.addGlobalSecondaryIndex({ + indexName: JiraUserMappingTable.PLATFORM_USER_INDEX, + partitionKey: { name: 'platform_user_id', type: dynamodb.AttributeType.STRING }, + sortKey: { name: 'linked_at', type: dynamodb.AttributeType.STRING }, + projectionType: dynamodb.ProjectionType.ALL, + }); + } +} diff --git a/cdk/src/constructs/jira-workspace-registry-table.ts b/cdk/src/constructs/jira-workspace-registry-table.ts new file mode 100644 index 00000000..41bad938 --- /dev/null +++ b/cdk/src/constructs/jira-workspace-registry-table.ts @@ -0,0 +1,94 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { RemovalPolicy } from 'aws-cdk-lib'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import { Construct } from 'constructs'; + +/** + * Properties for JiraWorkspaceRegistryTable construct. + */ +export interface JiraWorkspaceRegistryTableProps { + /** + * Optional table name override. + * @default - auto-generated by CloudFormation + */ + readonly tableName?: string; + + /** + * Removal policy for the table. + * @default RemovalPolicy.DESTROY + */ + readonly removalPolicy?: RemovalPolicy; + + /** + * Whether to enable point-in-time recovery. + * @default true + */ + readonly pointInTimeRecovery?: boolean; +} + +/** + * DynamoDB table tracking Jira Cloud tenants that have completed OAuth onboarding. + * + * Schema: jira_cloud_id (PK) — Atlassian's tenant identifier (UUID), the stable + * key returned from `accessible-resources` and present on every webhook payload. + * + * Fields: + * - site_url — `https://.atlassian.net` (display only; NOT a + * valid REST base for the 3LO token, which is minted with + * `audience=api.atlassian.com` and must call the + * `https://api.atlassian.com/ex/jira/` gateway base instead) + * - provider_name — full AgentCore credential provider name + * (`bgagent-jira-oauth-`), the lookup key for resolving the + * tenant's OAuth token via AgentCore Identity + * - installed_by_platform_user_id — Cognito sub of the admin who ran + * `bgagent jira setup` (audit only; runtime callers do not need this) + * - installed_at, updated_at — ISO timestamps + * - status — 'active' | 'revoked' + * + * The webhook processor and orchestrator look up `provider_name` here from + * the inbound webhook's `cloudId`, then call AgentCore Identity with + * `userId='jira-tenant-'` to retrieve the tenant's OAuth token. + * Token sharing is intentional — one bgagent[bot] identity per tenant, + * used for all members' triggered tasks (parity with the Linear adapter). + */ +export class JiraWorkspaceRegistryTable extends Construct { + /** + * The underlying DynamoDB table. + */ + public readonly table: dynamodb.Table; + + constructor(scope: Construct, id: string, props: JiraWorkspaceRegistryTableProps = {}) { + super(scope, id); + + this.table = new dynamodb.Table(this, 'Table', { + tableName: props.tableName, + partitionKey: { + name: 'jira_cloud_id', + type: dynamodb.AttributeType.STRING, + }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + pointInTimeRecoverySpecification: { + pointInTimeRecoveryEnabled: props.pointInTimeRecovery ?? true, + }, + removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY, + }); + } +} diff --git a/cdk/src/handlers/jira-link.ts b/cdk/src/handlers/jira-link.ts new file mode 100644 index 00000000..febdc987 --- /dev/null +++ b/cdk/src/handlers/jira-link.ts @@ -0,0 +1,136 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient, GetCommand, PutCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { ulid } from 'ulid'; +import { extractUserId } from './shared/gateway'; +import { logger } from './shared/logger'; +import { ErrorCode, errorResponse, successResponse } from './shared/response'; +import { parseBody } from './shared/validation'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); + +const USER_MAPPING_TABLE = process.env.JIRA_USER_MAPPING_TABLE_NAME!; + +interface LinkRequest { + readonly code: string; + /** Preview-only: return what would be linked without writing. */ + readonly dry_run?: boolean; +} + +/** + * POST /v1/jira/link — Complete Jira account linking, or preview it. + * + * Called from the CLI (`bgagent jira link `) with a Cognito JWT. + * Looks up the pending link record. With `dry_run: true`, returns the + * Jira identity attached to the code without writing — the CLI uses + * this to render a "you're about to link X" preview before the user + * confirms. Without `dry_run`, writes the mapping and deletes the + * pending record. + */ +export async function handler(event: APIGatewayProxyEvent): Promise { + const requestId = ulid(); + + try { + const userId = extractUserId(event); + if (!userId) { + return errorResponse(401, ErrorCode.UNAUTHORIZED, 'Authentication required.', requestId); + } + + const body = parseBody(event.body ?? null); + if (!body?.code) { + return errorResponse(400, ErrorCode.VALIDATION_ERROR, 'Request body must include a "code" field.', requestId); + } + + // Codes from `bgagent jira invite-user` are case-sensitive (kebab-case + // with a lowercase hex suffix); don't uppercase the incoming value. + const code = body.code.trim(); + + const pending = await ddb.send(new GetCommand({ + TableName: USER_MAPPING_TABLE, + Key: { jira_identity: `pending#${code}` }, + })); + + if (!pending.Item || pending.Item.status !== 'pending') { + return errorResponse(404, ErrorCode.VALIDATION_ERROR, 'Invalid or expired link code.', requestId); + } + + const cloudId = pending.Item.jira_cloud_id as string; + const siteUrl = (pending.Item.jira_site_url as string | undefined) ?? ''; + const jiraAccountId = pending.Item.jira_account_id as string; + const jiraUserName = (pending.Item.jira_user_name as string | undefined) ?? ''; + const jiraUserEmail = (pending.Item.jira_user_email as string | undefined) ?? ''; + + // Dry-run preview: return identity without writing. + if (body.dry_run === true) { + return successResponse(200, { + dry_run: true, + jira_cloud_id: cloudId, + jira_site_url: siteUrl, + jira_account_id: jiraAccountId, + jira_user_name: jiraUserName, + jira_user_email: jiraUserEmail, + }, requestId); + } + + const now = new Date().toISOString(); + + await ddb.send(new PutCommand({ + TableName: USER_MAPPING_TABLE, + Item: { + jira_identity: `${cloudId}#${jiraAccountId}`, + platform_user_id: userId, + jira_cloud_id: cloudId, + jira_account_id: jiraAccountId, + linked_at: now, + status: 'active', + link_method: 'cli', + }, + })); + + await ddb.send(new DeleteCommand({ + TableName: USER_MAPPING_TABLE, + Key: { jira_identity: `pending#${code}` }, + })); + + logger.info('Jira account linked', { + platform_user_id: userId, + jira_cloud_id: cloudId, + jira_account_id: jiraAccountId, + }); + + return successResponse(200, { + message: 'Jira account linked successfully.', + jira_cloud_id: cloudId, + jira_site_url: siteUrl, + jira_account_id: jiraAccountId, + jira_user_name: jiraUserName, + jira_user_email: jiraUserEmail, + linked_at: now, + }, requestId); + } catch (err) { + logger.error('Jira link handler failed', { + error: err instanceof Error ? err.message : String(err), + request_id: requestId, + }); + return errorResponse(500, ErrorCode.INTERNAL_ERROR, 'Internal server error.', requestId); + } +} diff --git a/cdk/src/handlers/jira-webhook-processor.ts b/cdk/src/handlers/jira-webhook-processor.ts new file mode 100644 index 00000000..fee61a6e --- /dev/null +++ b/cdk/src/handlers/jira-webhook-processor.ts @@ -0,0 +1,656 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as crypto from 'crypto'; +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient, GetCommand, ScanCommand } from '@aws-sdk/lib-dynamodb'; +import { createTaskCore } from './shared/create-task-core'; +import { reportIssueFailure } from './shared/jira-feedback'; +import { resolveJiraOauthToken } from './shared/jira-oauth-resolver'; +import { logger } from './shared/logger'; +import type { Attachment } from './shared/types'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); + +const PROJECT_MAPPING_TABLE = process.env.JIRA_PROJECT_MAPPING_TABLE_NAME!; +const USER_MAPPING_TABLE = process.env.JIRA_USER_MAPPING_TABLE_NAME!; +const WORKSPACE_REGISTRY_TABLE = process.env.JIRA_WORKSPACE_REGISTRY_TABLE_NAME; +const DEFAULT_LABEL_FILTER = 'bgagent'; + +/** + * Post a Jira comment without ever propagating an error. Mirrors the + * Linear `safeReportIssueFailure` contract — feedback is best-effort, + * advisory, and must never gate task-rejection logic. + */ +async function safeReportIssueFailure( + issueIdOrKey: string, + cloudId: string | undefined, + message: string, +): Promise { + if (!WORKSPACE_REGISTRY_TABLE) { + logger.warn('Skipping Jira feedback: JIRA_WORKSPACE_REGISTRY_TABLE_NAME not set', { + issue_id_or_key: issueIdOrKey, + }); + return; + } + if (!cloudId) { + logger.warn('Skipping Jira feedback: webhook payload missing cloudId', { + issue_id_or_key: issueIdOrKey, + }); + return; + } + try { + await reportIssueFailure( + { cloudId, registryTableName: WORKSPACE_REGISTRY_TABLE }, + issueIdOrKey, + message, + ); + } catch (err) { + logger.warn('Jira feedback failed (non-fatal)', { + issue_id_or_key: issueIdOrKey, + jira_cloud_id: cloudId, + error: err instanceof Error ? err.message : String(err), + }); + } +} + +/** + * Safe single-tenant fallback for `cloudId`. + * + * Webhooks created through the Jira **Settings → System → Webhooks** UI do + * not include a top-level `cloudId` in their payload (only app/OAuth- + * registered dynamic webhooks do). Without `cloudId` the processor can't + * resolve the tenant. For the common single-tenant install we recover by + * reading the workspace registry: if **exactly one** `active` tenant is + * registered, that must be the sender, so we use it. + * + * This deliberately does NOT guess when multiple active tenants exist — + * doing so could mis-route an event from site B to site A's repo/user. + * In that case we return `undefined` and the caller drops the event, so + * the multi-tenant design is preserved: a multi-tenant operator must use a + * webhook that carries its own `cloudId`. + */ +async function resolveSoleTenantCloudId(): Promise { + if (!WORKSPACE_REGISTRY_TABLE) return undefined; + // Full-table Scan: the workspace registry holds one row per OAuth-installed + // tenant and is expected to stay small (tens of rows at most), so a Scan is + // cheap. The >1-active-tenant short-circuit below caps the work regardless. + // If this table ever grows large, add a GSI on `status` and Query it. + let activeCloudIds: string[] = []; + let lastKey: Record | undefined; + do { + const page = await ddb.send(new ScanCommand({ + TableName: WORKSPACE_REGISTRY_TABLE, + ProjectionExpression: 'jira_cloud_id, #s', + ExpressionAttributeNames: { '#s': 'status' }, + ExclusiveStartKey: lastKey, + })); + for (const item of page.Items ?? []) { + if (item.status === 'active' && typeof item.jira_cloud_id === 'string') { + activeCloudIds.push(item.jira_cloud_id); + } + } + lastKey = page.LastEvaluatedKey; + // Short-circuit: once we've seen more than one active tenant the + // fallback is ambiguous, so stop scanning. + if (activeCloudIds.length > 1) break; + } while (lastKey); + + if (activeCloudIds.length === 1) return activeCloudIds[0]; + logger.warn('Cannot infer cloudId: registry does not have exactly one active tenant', { + active_tenant_count: activeCloudIds.length, + }); + return undefined; +} + +/** + * Subset of the Jira Cloud `jira:issue_*` webhook payload we depend on. + * Undocumented fields are tolerated. + */ +interface JiraIssueEvent { + readonly webhookEvent: 'jira:issue_created' | 'jira:issue_updated' | string; + readonly timestamp?: number; + readonly cloudId?: string; + readonly user?: { + readonly accountId?: string; + readonly displayName?: string; + }; + readonly issue?: { + readonly id: string; + readonly key: string; + readonly fields?: { + readonly summary?: string; + readonly description?: unknown; // ADF document + readonly labels?: string[]; + readonly creator?: { readonly accountId?: string }; + readonly reporter?: { readonly accountId?: string }; + readonly project?: { + readonly id?: string; + readonly key?: string; + }; + readonly [key: string]: unknown; + }; + }; + readonly changelog?: { + readonly items?: Array<{ + readonly field?: string; + readonly fieldId?: string; + readonly fromString?: string | null; + readonly toString?: string | null; + }>; + }; +} + +interface ProcessorEvent { + readonly raw_body: string; + /** + * True when the receiver verified this delivery against the stack-wide + * fallback secret rather than a per-tenant signing secret. The stack-wide + * secret is not bound to any `cloudId`, so a body-supplied `cloudId` on + * such a delivery is untrusted — the processor ignores it and binds the + * event to the sole active tenant instead (dropping when that's ambiguous). + * Absent/false means the signature was per-tenant, so `payload.cloudId` + * is trustworthy for routing. + */ + readonly verified_via_stack_wide?: boolean; +} + +/** + * Async processor for verified Jira webhooks. + * + * Responsibilities: + * - Parse the issue payload. + * - Detect whether the configured trigger label was added on creation OR + * added by an `issue_updated` event whose changelog shows a `labels` + * diff with the label newly present (Atlassian's label diff format + * differs from Linear's). + * - Resolve `(cloudId, projectKey)` → repo mapping. + * - Resolve `(cloudId, accountId)` → platform user mapping. + * - Call `createTaskCore` with `channelSource: 'jira'` and metadata the + * agent uses to address the originating issue via the Jira REST v3 API + * (`jira_reactions.py`; see ADR-015 for why outbound is REST, not MCP). + */ +export async function handler(event: ProcessorEvent): Promise { + if (!event.raw_body) { + logger.error('Jira webhook processor invoked without raw_body'); + return; + } + + let payload: JiraIssueEvent; + try { + payload = JSON.parse(event.raw_body) as JiraIssueEvent; + } catch (err) { + logger.error('Jira webhook processor could not parse raw_body', { + error: err instanceof Error ? err.message : String(err), + }); + return; + } + + if ( + payload.webhookEvent !== 'jira:issue_created' && + payload.webhookEvent !== 'jira:issue_updated' + ) { + logger.info('Jira processor skipping non-issue event', { webhookEvent: payload.webhookEvent }); + return; + } + + const issue = payload.issue; + if (!issue || !issue.id || !issue.key) { + logger.warn('Jira issue payload missing id or key', { webhookEvent: payload.webhookEvent }); + return; + } + + // Resolve the tenant `cloudId`, honoring the signature's trust boundary: + // + // - Per-tenant signature (`verified_via_stack_wide` false/absent): the + // sender proved knowledge of *this* tenant's secret, so the body-supplied + // `payload.cloudId` is trustworthy. Fall back to the sole-active-tenant + // lookup only when the body omits it (Settings-UI webhooks). + // - Stack-wide fallback signature: the secret is not bound to any tenant, + // so a body-supplied `cloudId` is attacker-controllable. We IGNORE it and + // bind the delivery to the sole active tenant; `resolveSoleTenantCloudId` + // returns undefined (→ drop) when zero or multiple tenants are active, so + // a stack-wide secret can never steer an event at a chosen tenant. + let cloudId: string | undefined; + if (event.verified_via_stack_wide) { + cloudId = await resolveSoleTenantCloudId(); + if (payload.cloudId && payload.cloudId !== cloudId) { + logger.warn('Ignoring body cloudId on stack-wide-verified webhook; binding to sole active tenant', { + body_cloud_id: payload.cloudId, + bound_cloud_id: cloudId, + issue_key: issue.key, + }); + } + } else { + cloudId = payload.cloudId ?? (await resolveSoleTenantCloudId()); + } + const projectKey = issue.fields?.project?.key; + if (!projectKey) { + logger.info('Jira issue has no project.key — skipping (cannot route to a repo)', { + issue_key: issue.key, + }); + await safeReportIssueFailure( + issue.key, + cloudId, + "❌ This Jira issue isn't in a project — ABCA needs a Jira project to route the task to a repo. Move the issue into a project and re-apply the trigger label.", + ); + return; + } + + if (!cloudId) { + // No cloudId in the payload AND the single-tenant fallback couldn't + // resolve one (zero or multiple active tenants). Without it we can't + // look up the project mapping (composite PK is `{cloudId}#{projectKey}`) + // or post feedback. Log and drop. + logger.warn('Jira webhook missing cloudId and no sole active tenant — cannot resolve tenant', { + issue_key: issue.key, + project_key: projectKey, + }); + return; + } + + const projectIdentity = `${cloudId}#${projectKey}`; + const mapping = await ddb.send(new GetCommand({ + TableName: PROJECT_MAPPING_TABLE, + Key: { jira_project_identity: projectIdentity }, + })); + if (!mapping.Item || mapping.Item.status !== 'active') { + logger.info('Jira project is not onboarded or is removed — skipping', { + jira_project_identity: projectIdentity, + issue_key: issue.key, + }); + await safeReportIssueFailure( + issue.key, + cloudId, + `❌ This Jira project isn't onboarded to ABCA. An admin can onboard it with \`bgagent jira map ${cloudId} ${projectKey} --repo /\` (add \`--label \` to change the trigger label).`, + ); + return; + } + const repo = mapping.Item.repo as string; + const labelFilter = (mapping.Item.label_filter as string | undefined) ?? DEFAULT_LABEL_FILTER; + + if (!shouldTrigger(payload, labelFilter)) { + logger.info('Jira webhook does not match trigger criteria', { + webhookEvent: payload.webhookEvent, + issue_key: issue.key, + label_filter: labelFilter, + current_labels: issue.fields?.labels, + changelog_label_items: payload.changelog?.items?.filter((i) => i?.field === 'labels'), + }); + return; + } + + const accountId = payload.user?.accountId + ?? issue.fields?.reporter?.accountId + ?? issue.fields?.creator?.accountId; + if (!accountId) { + logger.warn('Jira webhook missing user.accountId — cannot attribute task', { + issue_key: issue.key, + jira_cloud_id: cloudId, + }); + await safeReportIssueFailure( + issue.key, + cloudId, + "❌ Jira webhook is missing the user accountId — ABCA can't attribute this task to a user. This is unusual; please report it to your ABCA admin.", + ); + return; + } + + const platformUserId = await lookupPlatformUser(cloudId, accountId); + if (!platformUserId) { + logger.warn('Jira account has no linked platform user — skipping task creation', { + jira_cloud_id: cloudId, + jira_account_id: accountId, + issue_key: issue.key, + }); + await safeReportIssueFailure( + issue.key, + cloudId, + "❌ This Jira user isn't linked to a platform user. Run `bgagent jira link ` from a Cognito-authenticated CLI session to complete linking.", + ); + return; + } + + // Convert the ADF description to markdown once and reuse it for both the + // task body and image-attachment extraction. + const descriptionMarkdown = extractDescriptionMarkdown(issue.fields?.description); + const taskDescription = buildTaskDescription(issue, descriptionMarkdown); + + const channelMetadata: Record = { + jira_cloud_id: cloudId, + jira_project_key: projectKey, + jira_issue_id: issue.id, + jira_issue_key: issue.key, + }; + + // Stash the resolved OAuth secret ARN on the task so the agent runtime + // doesn't have to re-do the registry lookup. Also blocks tasks from + // tenants that only verified via the stack-wide fallback (workspace + // unknown to the registry) — we'd burn agent quota with no MCP token. + if (WORKSPACE_REGISTRY_TABLE) { + const resolved = await resolveJiraOauthToken(cloudId, WORKSPACE_REGISTRY_TABLE); + if (!resolved) { + logger.warn('Jira tenant not resolvable from registry — dropping event', { + jira_cloud_id: cloudId, + issue_key: issue.key, + }); + return; + } + channelMetadata.jira_oauth_secret_arn = resolved.oauthSecretArn; + channelMetadata.jira_site_url = resolved.siteUrl; + } + + const attachments = extractImageUrlAttachments(descriptionMarkdown); + + const requestId = crypto.randomUUID(); + const result = await createTaskCore( + { + repo, + task_description: taskDescription, + ...(attachments.length > 0 && { attachments }), + }, + { + userId: platformUserId, + channelSource: 'jira', + channelMetadata, + }, + requestId, + ); + + if (result.statusCode !== 201) { + logger.warn('Jira-triggered task creation returned non-201', { + status: result.statusCode, + body: result.body, + issue_key: issue.key, + }); + await safeReportIssueFailure( + issue.key, + cloudId, + buildCreateTaskFailureMessage(result.statusCode, result.body), + ); + return; + } + + logger.info('Jira-triggered task created', { + issue_key: issue.key, + issue_id: issue.id, + repo, + request_id: requestId, + }); +} + +/** + * Decide whether a Jira issue event should trigger a task. + * + * Two trigger paths: + * - `jira:issue_created` with the trigger label already present. + * - `jira:issue_updated` whose `changelog.items[]` contains a labels + * change where the trigger label is in `toString` but NOT in + * `fromString` (i.e. it was newly added). Atlassian's label diff is + * delivered as space-separated strings, not arrays, so we tokenize. + */ +function shouldTrigger(payload: JiraIssueEvent, labelFilter: string): boolean { + const filter = labelFilter.toLowerCase(); + const currentLabels = (payload.issue?.fields?.labels ?? []).map((l) => l.toLowerCase()); + const hasLabel = currentLabels.includes(filter); + + if (payload.webhookEvent === 'jira:issue_created') { + return hasLabel; + } + + if (payload.webhookEvent === 'jira:issue_updated') { + if (!hasLabel) return false; + const items = payload.changelog?.items ?? []; + // Match the labels change item. Atlassian uses `field === 'labels'` + // (or sometimes `fieldId === 'labels'`) for the labels system field. + const labelsItem = items.find( + (i) => i?.field === 'labels' || i?.fieldId === 'labels', + ); + if (!labelsItem) return false; + const previous = tokenizeLabelString(labelsItem.fromString); + const next = tokenizeLabelString(labelsItem.toString); + // Trigger only if the label is newly present. + return next.includes(filter) && !previous.includes(filter); + } + + return false; +} + +/** + * Atlassian delivers the labels-field change as a space-separated string + * (e.g. `"bug" → "bug bgagent"`). Tokenize and lowercase for comparison. + * Empty / null inputs return an empty list. + */ +function tokenizeLabelString(value: string | null | undefined): string[] { + if (!value) return []; + return value + .split(/\s+/) + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); +} + +/** + * Translate a `createTaskCore` non-201 response into a user-facing Jira + * comment. Mirrors the Linear-side helper. + */ +function buildCreateTaskFailureMessage(statusCode: number, rawBody: string): string { + let detail = ''; + try { + if (rawBody) { + const parsed = JSON.parse(rawBody) as { error?: { code?: string; message?: string } }; + const message = parsed.error?.message; + if (typeof message === 'string' && message.trim()) { + detail = message.trim(); + } + } + } catch { + // fall through to the generic message + } + + if (statusCode === 400 && detail) { + return `❌ ABCA couldn't accept this task: ${detail}`; + } + if (statusCode === 503) { + return `❌ ABCA is temporarily unavailable (status ${statusCode}). Please re-apply the trigger label in a few minutes.`; + } + if (detail) { + return `❌ ABCA couldn't create this task (status ${statusCode}): ${detail}`; + } + return `❌ ABCA couldn't create this task (status ${statusCode}). Check the ABCA admin logs for details.`; +} + +function buildTaskDescription( + issue: NonNullable, + descriptionMarkdown: string, +): string { + const parts: string[] = []; + const summary = issue.fields?.summary?.trim(); + if (summary) { + parts.push(`${issue.key}: ${summary}`); + } else { + parts.push(issue.key); + } + if (descriptionMarkdown.trim()) { + parts.push(''); + parts.push(descriptionMarkdown.trim()); + } + return parts.join('\n'); +} + +/** + * Convert a Jira ADF (Atlassian Document Format) document into best-effort + * markdown. Intentionally minimal — extract paragraphs, headings, and + * list items as plain text. Anything else (panels, tables, embeds) is + * collapsed to its textual content. + * + * The full ADF spec has dozens of node types; rolling a complete converter + * here would dwarf the rest of the integration and add a new dependency + * surface. The agent gets the issue title + a coherent text rendering of + * the description; richer rendering (tables, mentions, attachments) can + * land in a follow-up. + */ +function extractDescriptionMarkdown(description: unknown): string { + if (!description) return ''; + if (typeof description === 'string') return description; + if (typeof description !== 'object') return ''; + + const lines: string[] = []; + walkAdf(description as AdfNode, lines, 0); + return lines.join('\n').replace(/\n{3,}/g, '\n\n').trim(); +} + +interface AdfNode { + readonly type?: string; + readonly text?: string; + readonly attrs?: { + readonly level?: number; + /** `media` node: `"external"` carries a direct `url`; `"file"`/`"link"` + * carry an attachment `id` that needs a Jira API call to resolve. */ + readonly type?: string; + readonly url?: string; + readonly alt?: string; + }; + readonly content?: AdfNode[]; +} + +function walkAdf(node: AdfNode | undefined, out: string[], depth: number): void { + if (!node) return; + switch (node.type) { + case 'doc': + (node.content ?? []).forEach((c) => walkAdf(c, out, depth)); + return; + case 'paragraph': { + const text = (node.content ?? []).map(textOf).join(''); + if (text) { + out.push(text); + out.push(''); + } + return; + } + case 'heading': { + const level = node.attrs?.level ?? 1; + const prefix = '#'.repeat(Math.max(1, Math.min(6, level))); + const text = (node.content ?? []).map(textOf).join(''); + if (text) { + out.push(`${prefix} ${text}`); + out.push(''); + } + return; + } + case 'bulletList': + case 'orderedList': { + (node.content ?? []).forEach((item, idx) => { + const itemText = (item.content ?? []) + .flatMap((sub) => collectInlineLines(sub)) + .join(' ') + .trim(); + if (!itemText) return; + const bullet = node.type === 'orderedList' ? `${idx + 1}.` : '-'; + out.push(`${' '.repeat(depth * 2)}${bullet} ${itemText}`); + }); + out.push(''); + return; + } + case 'codeBlock': { + const text = (node.content ?? []).map(textOf).join(''); + out.push('```'); + out.push(text); + out.push('```'); + out.push(''); + return; + } + case 'mediaSingle': + case 'mediaGroup': + // Container nodes — descend to the `media` children below. + (node.content ?? []).forEach((c) => walkAdf(c, out, depth)); + return; + case 'media': { + // Jira embeds images as `media` nodes (not markdown image text). Only + // `external` media carry a directly-usable URL; `file`/`link` media + // reference an attachment `id` that needs a Jira API round-trip to + // resolve — out of scope for this minimal converter, so we skip those. + const url = node.attrs?.url; + if (node.attrs?.type === 'external' && typeof url === 'string' && url.startsWith('https://')) { + const alt = node.attrs?.alt ?? ''; + out.push(`![${alt}](${url})`); + out.push(''); + } + return; + } + case 'text': + if (node.text) out.push(node.text); + return; + default: + // Unknown node — descend into its content if any so embedded text + // (e.g. inside a panel or quote) isn't lost. + (node.content ?? []).forEach((c) => walkAdf(c, out, depth)); + } +} + +function textOf(node: AdfNode): string { + if (node.type === 'text' && node.text) return node.text; + if (node.content) return node.content.map(textOf).join(''); + return ''; +} + +function collectInlineLines(node: AdfNode): string[] { + if (node.type === 'paragraph') { + return [(node.content ?? []).map(textOf).join('')]; + } + if (node.type === 'text' && node.text) { + return [node.text]; + } + return []; +} + +/** + * Extract image URLs from the rendered description markdown. Same limits + * as the Linear processor: HTTPS only, capped at 10. + */ +function extractImageUrlAttachments(description: string | undefined): Attachment[] { + if (!description) return []; + + const imagePattern = /!\[[^\]]*\]\((https:\/\/[^)]+)\)/g; + const attachments: Attachment[] = []; + let match: RegExpExecArray | null; + + while ((match = imagePattern.exec(description)) !== null) { + if (attachments.length >= 10) break; + const url = match[1]; + attachments.push({ type: 'url', url }); + } + + if (attachments.length > 0) { + logger.info('Extracted image URL attachments from Jira issue description', { + count: attachments.length, + }); + } + + return attachments; +} + +async function lookupPlatformUser(cloudId: string, accountId: string): Promise { + const key = `${cloudId}#${accountId}`; + const result = await ddb.send(new GetCommand({ + TableName: USER_MAPPING_TABLE, + Key: { jira_identity: key }, + })); + if (!result.Item || result.Item.status === 'pending') return null; + return (result.Item.platform_user_id as string) ?? null; +} diff --git a/cdk/src/handlers/jira-webhook.ts b/cdk/src/handlers/jira-webhook.ts new file mode 100644 index 00000000..c8a88dc5 --- /dev/null +++ b/cdk/src/handlers/jira-webhook.ts @@ -0,0 +1,273 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { ConditionalCheckFailedException, DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; +import { DeleteCommand, DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { + isWebhookTimestampFresh, + verifyJiraRequest, + verifyJiraRequestForTenant, +} from './shared/jira-verify'; +import { logger } from './shared/logger'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const lambdaClient = new LambdaClient({}); + +const WEBHOOK_SECRET_ARN = process.env.JIRA_WEBHOOK_SECRET_ARN!; +const DEDUP_TABLE_NAME = process.env.JIRA_WEBHOOK_DEDUP_TABLE_NAME!; +const PROCESSOR_FUNCTION_NAME = process.env.JIRA_WEBHOOK_PROCESSOR_FUNCTION_NAME!; +/** Optional. When unset, the per-tenant signing-secret path is skipped + * and only the stack-wide secret is consulted (back-compat). */ +const WORKSPACE_REGISTRY_TABLE = process.env.JIRA_WORKSPACE_REGISTRY_TABLE_NAME; + +/** + * Dedup window (seconds). Atlassian retries failed deliveries far less + * aggressively than Linear, but we keep an 8-hour window to cover + * delayed retries on transient outages and clock skew. + */ +const DEDUP_TTL_SECONDS = 8 * 60 * 60; + +/** + * Top-level shape of the Jira webhook envelope we care about for dedup + + * routing. Other fields are forwarded to the processor as part of the raw + * body — the processor parses its own copy. + */ +interface JiraWebhookEnvelope { + readonly webhookEvent?: string; + readonly timestamp?: number; + readonly issue?: { + readonly id?: string; + readonly key?: string; + readonly fields?: { readonly project?: { readonly id?: string; readonly key?: string } }; + }; + /** `cloudId` is delivered as a top-level field on Atlassian Cloud webhooks. */ + readonly matchedWebhookIds?: number[]; + readonly user?: { readonly accountId?: string }; +} + +/** + * Atlassian's webhook payload doesn't always include `cloudId` at the top + * level — older delivery payloads omit it, and self-hosted webhook + * configurations don't carry it. We require it for tenant-scoped + * verification; the receiver passes whatever it can extract through to + * the processor and lets that step report a clear error if absent. + */ +interface JiraEnvelopeWithCloud extends JiraWebhookEnvelope { + readonly cloudId?: string; +} + +/** + * POST /v1/jira/webhook — Jira Cloud webhook receiver. + * + * Verifies the `X-Hub-Signature` HMAC over the raw body, dedups on + * `(issueKey, webhookEvent, timestamp)` with an 8h TTL, and async-invokes + * the processor Lambda so we can ack quickly. Atlassian sends the + * algorithm prefix (`sha256=…`) — `verifyJiraSignature` strips it before + * comparison. + */ +export async function handler(event: APIGatewayProxyEvent): Promise { + try { + if (!event.body) { + return jsonResponse(400, { error: 'Request body is required' }); + } + + // HMAC is computed over the raw `event.body` string. If API Gateway is + // ever configured with binary media types it can deliver the body + // base64-encoded (`isBase64Encoded: true`), in which case both the JSON + // parse and the signature comparison would be over the wrong bytes. We + // assume a UTF-8 JSON body (Atlassian sends `application/json`); reject + // loudly rather than silently failing verification on the encoded form. + if (event.isBase64Encoded) { + logger.error('Jira webhook delivered base64-encoded; expected raw JSON body'); + return jsonResponse(400, { error: 'Unexpected body encoding' }); + } + + const signature = event.headers['X-Hub-Signature'] ?? event.headers['x-hub-signature'] ?? ''; + if (!signature) { + logger.warn('Jira webhook missing X-Hub-Signature header'); + return jsonResponse(401, { error: 'Missing signature' }); + } + + let payload: JiraEnvelopeWithCloud; + try { + payload = JSON.parse(event.body) as JiraEnvelopeWithCloud; + } catch (err) { + logger.warn('Jira webhook body is not valid JSON', { + error: err instanceof Error ? err.message : String(err), + }); + return jsonResponse(400, { error: 'Invalid JSON' }); + } + + // Per-tenant verification first. Falls through to stack-wide if (a) registry + // table not configured, (b) no cloudId in body, (c) tenant not in registry, + // or (d) tenant's stored secret lacks `webhook_signing_secret`. + // Per-tenant MISMATCH and REVOKED are fatal — no fallback. + // + // `verifiedViaStackWide` is propagated to the processor: a per-tenant + // signature proves the sender knows *that* tenant's secret (so the + // body-supplied `cloudId` is trustworthy for routing), whereas the + // stack-wide secret is not bound to any tenant. The processor refuses + // to route a stack-wide-verified delivery to a body-chosen `cloudId`, + // binding it to the sole active tenant instead. + let verified = false; + let verifiedViaStackWide = false; + if (WORKSPACE_REGISTRY_TABLE && payload.cloudId) { + const result = await verifyJiraRequestForTenant( + WORKSPACE_REGISTRY_TABLE, + payload.cloudId, + signature, + event.body, + ); + if (result === 'verified') { + verified = true; + } else if (result === 'mismatch') { + logger.warn('Jira webhook signature mismatch against per-tenant secret', { + jira_cloud_id: payload.cloudId, + }); + return jsonResponse(401, { error: 'Invalid signature' }); + } else if (result === 'revoked') { + logger.warn('Jira webhook from revoked tenant — rejecting without stack-wide fallback', { + jira_cloud_id: payload.cloudId, + }); + return jsonResponse(401, { error: 'Tenant not active' }); + } + // 'no-per-tenant-secret' falls through to stack-wide. + } + + if (!verified) { + if (!await verifyJiraRequest(WEBHOOK_SECRET_ARN, signature, event.body)) { + logger.warn('Invalid Jira webhook signature', { + jira_cloud_id: payload.cloudId, + }); + return jsonResponse(401, { error: 'Invalid signature' }); + } + verifiedViaStackWide = true; + logger.info('Jira webhook verified via stack-wide fallback secret', { + jira_cloud_id: payload.cloudId, + per_tenant_registry_configured: Boolean(WORKSPACE_REGISTRY_TABLE), + }); + } + + // Advisory replay window. The dedup table catches the common retry case; + // this guards against very old replays. Atlassian's `timestamp` is only + // advisory (it isn't part of the signed material), so a missing value + // can't be rejected — but we log it so the skipped check is observable + // rather than a silent fail-open. + if (payload.timestamp === undefined) { + logger.warn('Jira webhook has no timestamp — replay-window check skipped', { + jira_cloud_id: payload.cloudId, + }); + } else if (!isWebhookTimestampFresh(payload.timestamp)) { + logger.warn('Jira webhook timestamp outside replay window', { + timestamp: payload.timestamp, + }); + return jsonResponse(401, { error: 'Stale webhook timestamp' }); + } + + const webhookEvent = payload.webhookEvent; + if (webhookEvent !== 'jira:issue_created' && webhookEvent !== 'jira:issue_updated') { + // Silent 200 so Atlassian doesn't retry — every non-issue event is acked. + logger.info('Ignoring non-Issue Jira webhook', { webhookEvent }); + return jsonResponse(200, { ok: true }); + } + + const issue = payload.issue; + const issueId = issue?.id; + const issueKey = issue?.key; + if (!issueId || !issueKey) { + logger.warn('Jira Issue webhook missing issue.id or issue.key', { webhookEvent }); + return jsonResponse(400, { error: 'Missing issue identifier' }); + } + + // Dedup via conditional PutItem. + // + // Atlassian doesn't expose a per-delivery message ID we can rely on. The + // payload's top-level `timestamp` (UNIX ms) is set when the event was + // queued and remains stable across retries of the same delivery. + // Composing `${issueKey}#${webhookEvent}#${timestamp}` collapses retries + // (same timestamp) without merging distinct events. + const dedupKey = `${issueKey}#${webhookEvent}#${payload.timestamp ?? 'unknown'}`; + const nowSeconds = Math.floor(Date.now() / 1000); + try { + await ddb.send(new PutCommand({ + TableName: DEDUP_TABLE_NAME, + Item: { + dedup_key: dedupKey, + created_at: new Date().toISOString(), + ttl: nowSeconds + DEDUP_TTL_SECONDS, + }, + ConditionExpression: 'attribute_not_exists(dedup_key)', + })); + } catch (err) { + if (err instanceof ConditionalCheckFailedException) { + logger.info('Jira webhook dedup hit — skipping reprocess', { dedup_key: dedupKey }); + return jsonResponse(200, { ok: true, deduped: true }); + } + throw err; + } + + try { + await lambdaClient.send(new InvokeCommand({ + FunctionName: PROCESSOR_FUNCTION_NAME, + InvocationType: 'Event', + Payload: new TextEncoder().encode( + JSON.stringify({ raw_body: event.body, verified_via_stack_wide: verifiedViaStackWide }), + ), + })); + } catch (invokeErr) { + logger.error('Failed to invoke Jira webhook processor', { + error: invokeErr instanceof Error ? invokeErr.message : String(invokeErr), + issue_id: issueId, + issue_key: issueKey, + webhookEvent, + }); + // Roll back the dedup row so a future Atlassian retry can dispatch. + // Without this, all retries hit the dedup TTL and silently drop. + try { + await ddb.send(new DeleteCommand({ + TableName: DEDUP_TABLE_NAME, + Key: { dedup_key: dedupKey }, + })); + } catch (cleanupErr) { + logger.warn('Failed to roll back Jira webhook dedup row after invoke failure', { + error: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr), + dedup_key: dedupKey, + }); + } + return jsonResponse(500, { error: 'Dispatch failed' }); + } + + return jsonResponse(200, { ok: true }); + } catch (err) { + logger.error('Jira webhook handler failed', { + error: err instanceof Error ? err.message : String(err), + }); + return jsonResponse(500, { error: 'Internal server error' }); + } +} + +function jsonResponse(statusCode: number, body: Record): APIGatewayProxyResult { + return { + statusCode, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }; +} diff --git a/cdk/src/handlers/orchestrate-task.ts b/cdk/src/handlers/orchestrate-task.ts index 508ce152..e15ce419 100644 --- a/cdk/src/handlers/orchestrate-task.ts +++ b/cdk/src/handlers/orchestrate-task.ts @@ -20,6 +20,7 @@ import { withDurableExecution, type DurableExecutionHandler } from '@aws/durable-execution-sdk-js'; import { TaskStatus, TERMINAL_STATUSES } from '../constructs/task-status'; import { resolveComputeStrategy } from './shared/compute-strategy'; +import { reportIssueFailure as reportJiraIssueFailure } from './shared/jira-feedback'; import { reportIssueFailure } from './shared/linear-feedback'; import { logger } from './shared/logger'; import { @@ -76,7 +77,7 @@ const durableHandler: DurableExecutionHandler = 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); @@ -86,6 +87,14 @@ const durableHandler: DurableExecutionHandler = asyn error: err instanceof Error ? err.message : String(err), }); } + try { + await notifyJiraOnConcurrencyCap(task); + } catch (err) { + logger.warn('Jira concurrency-cap feedback failed (non-fatal)', { + task_id: taskId, + error: err instanceof Error ? err.message : String(err), + }); + } } return result; }); @@ -342,3 +351,46 @@ export async function notifyLinearOnConcurrencyCap(task: TaskRecord): Promise { + if (task.channel_source !== 'jira') return; + const cloudId = task.channel_metadata?.jira_cloud_id; + const issueKey = task.channel_metadata?.jira_issue_key; + if (!cloudId || !issueKey) 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; + } + 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 new file mode 100644 index 00000000..206348e4 --- /dev/null +++ b/cdk/src/handlers/shared/jira-feedback.ts @@ -0,0 +1,165 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { resolveJiraOauthToken } from './jira-oauth-resolver'; +import { logger } from './logger'; + +/** + * Lambda-side helper for posting comments onto Jira Cloud issues via the + * Atlassian REST v3 API. Used by the webhook processor to give users + * feedback on pre-container failures (guardrail block, concurrency cap, + * unmapped project, etc.) — paths where the agent never starts and the + * agent-side Jira MCP cannot run. + * + * Unlike Linear, Jira has no "reaction" primitive. The failure marker + * (❌) is folded into the comment text instead of attached as a separate + * reaction call. + * + * All calls are best-effort. Errors are logged at WARN and swallowed — + * Jira feedback is advisory and must never gate task-rejection logic. + */ + +const REQUEST_TIMEOUT_MS = 5000; + +/** + * Atlassian cross-region REST gateway base. The per-tenant OAuth token is + * minted with `audience=api.atlassian.com` (see `cli/src/jira-oauth.ts`), so + * it is only valid against this gateway host scoped by `{cloudId}` — NOT + * against the raw `*.atlassian.net` site host, which 401s such a token. The + * agent-side path (`agent/src/jira_reactions.py`) uses the same base. + */ +const JIRA_API_BASE = 'https://api.atlassian.com/ex/jira'; + +/** + * Wrap a plain message string in Atlassian Document Format. Jira REST v3 + * comments require ADF, not markdown. We keep this minimal — a single + * paragraph with the raw text — because the messages are short, user- + * facing strings written by the processor (no embedded markdown to + * preserve). + */ +function toAdfDocument(message: string): Record { + return { + type: 'doc', + version: 1, + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: message }], + }, + ], + }; +} + +async function postComment( + accessToken: string, + cloudId: string, + issueIdOrKey: string, + message: string, +): Promise { + // The 3LO token (audience=api.atlassian.com) is only valid against the + // gateway base scoped by cloudId — see JIRA_API_BASE. Posting to the raw + // site host (`*.atlassian.net`) would 401. + const url = `${JIRA_API_BASE}/${cloudId}/rest/api/3/issue/${encodeURIComponent(issueIdOrKey)}/comment`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const resp = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify({ body: toAdfDocument(message) }), + signal: controller.signal, + }); + if (!resp.ok) { + logger.warn('Jira feedback REST non-2xx', { status: resp.status, url }); + return false; + } + return true; + } catch (err) { + logger.warn('Jira feedback request failed', { + error: err instanceof Error ? err.message : String(err), + url, + }); + return false; + } finally { + clearTimeout(timer); + } +} + +/** + * Tenant-scoped feedback context. Resolved once per task by the caller + * (webhook processor / orchestrator) and threaded through to the + * post-comment helper, so the OAuth resolver runs once per task instead + * of once per Jira API call. + */ +export interface JiraFeedbackContext { + /** Atlassian tenant identifier (`cloudId`) — registry key. */ + readonly cloudId: string; + /** Name of JiraWorkspaceRegistryTable, from CDK stack output. */ + readonly registryTableName: string; +} + +async function resolveTenantToken( + ctx: JiraFeedbackContext, +): Promise<{ accessToken: string } | null> { + try { + const resolved = await resolveJiraOauthToken(ctx.cloudId, ctx.registryTableName); + if (!resolved) return null; + return { accessToken: resolved.accessToken }; + } catch (err) { + logger.warn('Jira feedback could not resolve OAuth token', { + jira_cloud_id: ctx.cloudId, + error: err instanceof Error ? err.message : String(err), + }); + return null; + } +} + +/** + * Post a comment onto a Jira issue. Returns true on success, false on any + * failure (network, auth, REST errors). Never throws — callers proceed + * regardless. + */ +export async function postIssueComment( + ctx: JiraFeedbackContext, + issueIdOrKey: string, + body: string, +): Promise { + const resolved = await resolveTenantToken(ctx); + if (!resolved) return false; + return postComment(resolved.accessToken, ctx.cloudId, issueIdOrKey, body); +} + +/** + * Post a feedback comment with the failure marker (❌) folded into the + * message text. Mirrors `linear-feedback.reportIssueFailure` semantics + * (best-effort, never throws, returns void) so callers don't branch on + * the result. The marker is included in `message` by the caller — this + * helper exists for symmetry with Linear's API surface. + */ +export async function reportIssueFailure( + ctx: JiraFeedbackContext, + issueIdOrKey: string, + message: string, +): Promise { + await Promise.allSettled([postIssueComment(ctx, issueIdOrKey, message)]); +} diff --git a/cdk/src/handlers/shared/jira-oauth-resolver.ts b/cdk/src/handlers/shared/jira-oauth-resolver.ts new file mode 100644 index 00000000..dc35a35a --- /dev/null +++ b/cdk/src/handlers/shared/jira-oauth-resolver.ts @@ -0,0 +1,506 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { + GetSecretValueCommand, + PutSecretValueCommand, + SecretsManagerClient, +} from '@aws-sdk/client-secrets-manager'; +import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb'; +import { logger } from './logger'; + +/** + * Lambda-side resolver for the per-tenant Jira Cloud OAuth token written + * by `bgagent jira setup` (parity with the Linear resolver). + * + * Flow: + * 1. Look up workspace registry by `cloudId` → `oauth_secret_arn`. + * 2. Fetch the secret JSON via Secrets Manager. + * 3. If `expires_at` is within 60s, refresh against Atlassian's + * `/oauth/token` endpoint (with stored `refresh_token`) and write the + * new JSON back to Secrets Manager. + * 4. Return the access token. + * + * Both reads (registry row, secret value) are cached in-memory with a + * short TTL so a hot Lambda doesn't hammer DDB / SM on every invocation. + */ + +const JIRA_TOKEN_ENDPOINT = 'https://auth.atlassian.com/oauth/token'; + +/** Cache TTL for the registry row + secret value lookups, in milliseconds. */ +const REGISTRY_CACHE_TTL_MS = 60_000; +const SECRET_CACHE_TTL_MS = 60_000; + +/** Refresh threshold: refresh tokens with <60s remaining. */ +const REFRESH_THRESHOLD_SECONDS = 60; + +/** Registry row status values. Anything else is treated as `revoked` (fail-closed). */ +type RegistryRowStatus = 'active' | 'revoked'; + +export interface RegistryRow { + readonly jira_cloud_id: string; + readonly site_url: string; + readonly oauth_secret_arn: string; + readonly status: RegistryRowStatus; +} + +export interface StoredOauthToken { + readonly access_token: string; + readonly refresh_token: string; + readonly expires_at: string; + readonly scope: string; + /** Co-located OAuth client credentials so Lambda-side refresh works + * without per-Lambda env vars (parity with the Linear store). */ + readonly client_id: string; + readonly client_secret: string; + readonly cloud_id: string; + readonly site_url: string; + readonly installed_at: string; + readonly updated_at: string; + readonly installed_by_platform_user_id: string; + /** Per-tenant Jira webhook signing secret. + * + * Atlassian's "Generic webhooks" support a per-webhook secret that signs + * events with `X-Hub-Signature: sha256=`. Webhook subscriptions are + * tenant-scoped, so a single stack-wide signing secret cannot verify + * events from multiple tenants. The webhook receiver looks this up by + * `cloudId` at verify time. + * + * Optional for back-compat: tokens written before per-tenant signing + * was wired up won't have it, and the receiver falls back to the + * stack-wide `JIRA_WEBHOOK_SECRET_ARN` for those installs. */ + readonly webhook_signing_secret?: string; +} + +export interface ResolverOptions { + /** AWS region for SDK clients. Falls back to AWS_REGION env. */ + readonly region?: string; + /** Override clients for testing. */ + readonly secretsManagerClient?: SecretsManagerClient; + readonly dynamoDbClient?: DynamoDBDocumentClient; + /** Override fetch for token-endpoint refresh in tests. */ + readonly fetchImpl?: typeof fetch; +} + +interface CacheEntry { + readonly value: T; + readonly expiresAt: number; +} + +const registryCache = new Map>(); +const tokenCache = new Map>(); + +/** + * Drop cached values for a tenant. Used after a refresh so the next caller + * picks up the rotated token. + */ +export function invalidateJiraOauthCache(cloudId: string, oauthSecretArn?: string): void { + registryCache.delete(cloudId); + if (oauthSecretArn) tokenCache.delete(oauthSecretArn); +} + +/** Returns true if `expires_at` is within the refresh threshold. */ +export function isTokenExpiring(expiresAt: string, thresholdSec: number = REFRESH_THRESHOLD_SECONDS): boolean { + const ts = new Date(expiresAt).getTime(); + if (Number.isNaN(ts)) return true; + return Date.now() + thresholdSec * 1000 >= ts; +} + +export interface ResolvedJiraToken { + readonly accessToken: string; + readonly scope: string; + readonly siteUrl: string; + readonly oauthSecretArn: string; +} + +/** + * Resolve a usable Jira Cloud OAuth access token for the given tenant. + * + * On success: returns `{ accessToken, scope, siteUrl, oauthSecretArn }`. + * Refreshes silently if the cached token is expiring. Returns null on any + * failure (registry miss, secret missing, refresh-token revoked) so callers + * can gracefully no-op rather than blowing up. + */ +export async function resolveJiraOauthToken( + cloudId: string, + registryTableName: string, + options: ResolverOptions = {}, +): Promise { + const region = options.region ?? process.env.AWS_REGION ?? 'us-east-1'; + const ddb = options.dynamoDbClient ?? DynamoDBDocumentClient.from(new DynamoDBClient({ region })); + const sm = options.secretsManagerClient ?? new SecretsManagerClient({ region }); + + // ─── Step 1: Registry row ──────────────────────────────────────── + const row = await getRegistryRow(ddb, registryTableName, cloudId); + if (!row) { + logger.warn('Jira tenant not in registry', { jira_cloud_id: cloudId }); + return null; + } + if (row.status !== 'active') { + logger.warn('Jira tenant registry status is not active', { + jira_cloud_id: cloudId, + status: row.status, + }); + return null; + } + + // ─── Step 2: Cached or fresh token JSON ────────────────────────── + const cached = tokenCache.get(row.oauth_secret_arn); + let token: StoredOauthToken; + if (cached && cached.expiresAt > Date.now() && !isTokenExpiring(cached.value.expires_at)) { + token = cached.value; + } else { + const fetched = await getOauthSecret(sm, row.oauth_secret_arn); + if (!fetched) { + logger.error('Jira OAuth secret missing or unreadable', { + oauth_secret_arn: row.oauth_secret_arn, + jira_cloud_id: cloudId, + }); + return null; + } + token = fetched; + } + + // ─── Step 3: Refresh if expiring ───────────────────────────────── + if (isTokenExpiring(token.expires_at)) { + const refreshed = await refreshJiraToken(token, sm, row.oauth_secret_arn, options); + if (!refreshed) { + return null; + } + token = refreshed; + } else { + tokenCache.set(row.oauth_secret_arn, { value: token, expiresAt: Date.now() + SECRET_CACHE_TTL_MS }); + } + + return { + accessToken: token.access_token, + scope: token.scope, + siteUrl: token.site_url, + oauthSecretArn: row.oauth_secret_arn, + }; +} + +/** + * Strict variant of {@link getRegistryRow}: throws on infra error + * (DDB throttle, network) instead of returning null. Use this from the + * webhook signature-verification path where a `null` return would let + * a transient throttle silently downgrade per-tenant verification to + * the stack-wide fallback secret. + */ +export async function getRegistryRowStrict( + ddb: DynamoDBDocumentClient, + tableName: string, + cloudId: string, +): Promise { + const cached = registryCache.get(cloudId); + if (cached && cached.expiresAt > Date.now()) return cached.value; + + const result = await ddb.send(new GetCommand({ + TableName: tableName, + Key: { jira_cloud_id: cloudId }, + })); + return parseRegistryRow(result.Item, cloudId); +} + +export async function getRegistryRow( + ddb: DynamoDBDocumentClient, + tableName: string, + cloudId: string, +): Promise { + const cached = registryCache.get(cloudId); + if (cached && cached.expiresAt > Date.now()) return cached.value; + + let result; + try { + result = await ddb.send(new GetCommand({ + TableName: tableName, + Key: { jira_cloud_id: cloudId }, + })); + } catch (err) { + logger.error('Failed to fetch Jira workspace registry row', { + table_name: tableName, + jira_cloud_id: cloudId, + error: err instanceof Error ? err.message : String(err), + }); + return null; + } + + return parseRegistryRow(result.Item, cloudId); +} + +function parseRegistryRow(rawItem: unknown, cloudId: string): RegistryRow | null { + const item = rawItem as Partial | undefined; + if (!item || !item.oauth_secret_arn || !item.site_url) return null; + + // Fail-closed on the status field: missing or unknown values are treated + // as `revoked`. A partially-written row shouldn't grant access. + const rawStatus = item.status as string | undefined; + const status: RegistryRowStatus = rawStatus === 'active' ? 'active' : 'revoked'; + if (rawStatus !== 'active' && rawStatus !== 'revoked' && rawStatus !== undefined) { + logger.warn('Jira workspace registry row has unknown status — treating as revoked', { + jira_cloud_id: cloudId, + raw_status: rawStatus, + }); + } + + const row: RegistryRow = { + jira_cloud_id: cloudId, + site_url: item.site_url, + oauth_secret_arn: item.oauth_secret_arn, + status, + }; + registryCache.set(cloudId, { value: row, expiresAt: Date.now() + REGISTRY_CACHE_TTL_MS }); + return row; +} + +const STORED_OAUTH_TOKEN_REQUIRED_FIELDS: ReadonlyArray = [ + 'access_token', + 'refresh_token', + 'expires_at', + 'scope', + 'client_id', + 'client_secret', + 'cloud_id', + 'site_url', + 'installed_at', + 'updated_at', + 'installed_by_platform_user_id', +]; + +export async function getOauthSecret( + sm: SecretsManagerClient, + secretArn: string, +): Promise { + try { + const res = await sm.send(new GetSecretValueCommand({ SecretId: secretArn })); + if (!res.SecretString) return null; + return parseOauthSecret(res.SecretString, secretArn); + } catch (err) { + logger.error('Failed to fetch Jira OAuth secret', { + secret_arn: secretArn, + error: err instanceof Error ? err.message : String(err), + }); + return null; + } +} + +/** + * Strict variant of {@link getOauthSecret}: throws on Secrets Manager + * error instead of returning null. Use this from the signature-verification + * path so a transient SM error doesn't silently fall back to stack-wide. + */ +export async function getOauthSecretStrict( + sm: SecretsManagerClient, + secretArn: string, +): Promise { + const res = await sm.send(new GetSecretValueCommand({ SecretId: secretArn })); + if (!res.SecretString) return null; + return parseOauthSecret(res.SecretString, secretArn); +} + +function parseOauthSecret(secretString: string, secretArn: string): StoredOauthToken | null { + let parsed: StoredOauthToken; + try { + parsed = JSON.parse(secretString) as StoredOauthToken; + } catch (err) { + logger.error('Jira OAuth secret value is not valid JSON', { + secret_arn: secretArn, + error: err instanceof Error ? err.message : String(err), + }); + return null; + } + const missing = STORED_OAUTH_TOKEN_REQUIRED_FIELDS.filter( + (f) => typeof parsed[f] !== 'string' || (parsed[f] as string).length === 0, + ); + if (missing.length > 0) { + logger.error('Jira OAuth secret JSON is missing required fields', { + secret_arn: secretArn, + missing_fields: missing, + }); + return null; + } + return parsed; +} + +type RefreshOutcome = + | { kind: 'success'; token: StoredOauthToken } + | { kind: 'invalid_grant' } + | { kind: 'failure' }; + +async function refreshJiraToken( + current: StoredOauthToken, + sm: SecretsManagerClient, + secretArn: string, + options: ResolverOptions, +): Promise { + const first = await tryRefreshOnce(current, sm, secretArn, options); + if (first.kind === 'success') return first.token; + if (first.kind === 'failure') return null; + + // `invalid_grant`: Atlassian rotates refresh_tokens on every use, so a + // concurrent Lambda may have refreshed before us. Re-read the secret + // and retry once if the refresh_token changed. + logger.warn('Jira token refresh got invalid_grant — re-reading secret to check for concurrent refresh', { + secret_arn: secretArn, + cloud_id: current.cloud_id, + }); + + const fresh = await getOauthSecret(sm, secretArn); + if (!fresh) { + invalidateJiraOauthCache(current.cloud_id, secretArn); + return null; + } + if (fresh.refresh_token === current.refresh_token) { + logger.error('Jira token refresh permanently rejected — tenant requires re-onboarding', { + secret_arn: secretArn, + cloud_id: current.cloud_id, + }); + invalidateJiraOauthCache(current.cloud_id, secretArn); + return null; + } + + if (!isTokenExpiring(fresh.expires_at)) { + logger.info('Jira OAuth token was refreshed by a concurrent caller; using freshly-read value', { + secret_arn: secretArn, + cloud_id: fresh.cloud_id, + new_expires_at: fresh.expires_at, + }); + tokenCache.set(secretArn, { value: fresh, expiresAt: Date.now() + SECRET_CACHE_TTL_MS }); + return fresh; + } + + const second = await tryRefreshOnce(fresh, sm, secretArn, options); + if (second.kind === 'success') return second.token; + if (second.kind === 'invalid_grant') { + logger.error('Jira token refresh failed even after re-reading freshly-rotated secret', { + secret_arn: secretArn, + cloud_id: fresh.cloud_id, + }); + } + invalidateJiraOauthCache(current.cloud_id, secretArn); + return null; +} + +async function tryRefreshOnce( + current: StoredOauthToken, + sm: SecretsManagerClient, + secretArn: string, + options: ResolverOptions, +): Promise { + if (!current.client_id || !current.client_secret) { + logger.error('Cannot refresh Jira OAuth token: stored secret is missing client_id/client_secret', { + secret_arn: secretArn, + }); + return { kind: 'failure' }; + } + + const fetchImpl = options.fetchImpl ?? fetch; + const body = JSON.stringify({ + grant_type: 'refresh_token', + client_id: current.client_id, + client_secret: current.client_secret, + refresh_token: current.refresh_token, + }); + + let resp: Response; + try { + resp = await fetchImpl(JIRA_TOKEN_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }); + } catch (err) { + logger.error('Jira token refresh fetch failed', { + error: err instanceof Error ? err.message : String(err), + }); + invalidateJiraOauthCache(current.cloud_id, secretArn); + return { kind: 'failure' }; + } + + let parsed: unknown; + try { + parsed = await resp.json(); + } catch { + logger.error('Jira token refresh returned non-JSON', { status: resp.status }); + return { kind: 'failure' }; + } + + if (!resp.ok) { + const errObj = parsed as { error?: string; error_description?: string }; + logger.error('Jira token refresh rejected', { + status: resp.status, + error: errObj.error, + error_description: errObj.error_description, + }); + invalidateJiraOauthCache(current.cloud_id, secretArn); + if (errObj.error === 'invalid_grant') { + return { kind: 'invalid_grant' }; + } + return { kind: 'failure' }; + } + + const tokenResp = parsed as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + scope?: string; + }; + if (!tokenResp.access_token || !tokenResp.expires_in) { + logger.error('Jira token refresh response missing required fields'); + return { kind: 'failure' }; + } + + const now = new Date(); + const next: StoredOauthToken = { + ...current, + access_token: tokenResp.access_token, + refresh_token: tokenResp.refresh_token ?? current.refresh_token, + expires_at: new Date(now.getTime() + tokenResp.expires_in * 1000).toISOString(), + scope: tokenResp.scope ?? current.scope, + updated_at: now.toISOString(), + }; + + try { + await sm.send(new PutSecretValueCommand({ + SecretId: secretArn, + SecretString: JSON.stringify(next), + })); + } catch (err) { + logger.error('Failed to persist refreshed Jira OAuth token', { + secret_arn: secretArn, + error: err instanceof Error ? err.message : String(err), + }); + } + + logger.info('Jira OAuth token refreshed', { + cloud_id: next.cloud_id, + site_url: next.site_url, + new_expires_at: next.expires_at, + }); + + tokenCache.set(secretArn, { value: next, expiresAt: Date.now() + SECRET_CACHE_TTL_MS }); + return { kind: 'success', token: next }; +} + +/** Test-only: clear all caches. */ +export function _resetCachesForTesting(): void { + registryCache.clear(); + tokenCache.clear(); +} diff --git a/cdk/src/handlers/shared/jira-verify.ts b/cdk/src/handlers/shared/jira-verify.ts new file mode 100644 index 00000000..50e8ba2d --- /dev/null +++ b/cdk/src/handlers/shared/jira-verify.ts @@ -0,0 +1,206 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as crypto from 'crypto'; +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; +import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import { getOauthSecretStrict, getRegistryRowStrict } from './jira-oauth-resolver'; +import { logger } from './logger'; + +const sm = new SecretsManagerClient({}); +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); + +/** Prefix for Jira-related secrets in Secrets Manager. */ +export const JIRA_SECRET_PREFIX = 'bgagent/jira/'; + +const secretCache = new Map(); +const CACHE_TTL_MS = 5 * 60 * 1000; + +/** + * Maximum age of a Jira webhook event timestamp (ms) before it is rejected. + * + * Atlassian's webhook payloads include a top-level `timestamp` field (UNIX ms, + * the moment the event was queued for delivery). Unlike Linear, Atlassian + * doesn't sign over a timestamp header, so the value is only meaningful as an + * advisory check after signature verification has already passed. We still + * enforce it to bound replay windows for delivery jobs that get stuck and + * surface much later — the dedup table handles the more likely retry case. + * + * 1h comfortably covers Atlassian's actual delivery-retry behavior while + * keeping the replay window tight. + */ +export const MAX_WEBHOOK_EVENT_AGE_MS = 60 * 60 * 1000; + +/** + * Tolerance for a webhook timestamp that sits slightly in the future + * relative to this Lambda's clock (sender/receiver skew). Beyond this, a + * future-dated timestamp is rejected rather than accepted. + */ +export const CLOCK_SKEW_ALLOWANCE_MS = 5 * 60 * 1000; + +/** + * Fetch a secret from Secrets Manager with in-memory caching. + */ +export async function getJiraSecret(secretId: string, forceRefresh = false): Promise { + const now = Date.now(); + if (!forceRefresh) { + const cached = secretCache.get(secretId); + if (cached && cached.expiresAt > now) { + return cached.secret; + } + } + + try { + const result = await sm.send(new GetSecretValueCommand({ SecretId: secretId })); + if (!result.SecretString) { + secretCache.delete(secretId); + return null; + } + secretCache.set(secretId, { secret: result.SecretString, expiresAt: now + CACHE_TTL_MS }); + return result.SecretString; + } catch (err) { + const errorName = (err as Error)?.name; + if (errorName === 'ResourceNotFoundException') { + logger.error('Jira secret not found in Secrets Manager', { secret_id: secretId }); + secretCache.delete(secretId); + return null; + } + logger.error('Failed to fetch Jira secret from Secrets Manager', { + secret_id: secretId, + error: err instanceof Error ? err.message : String(err), + }); + throw err; + } +} + +export function invalidateJiraSecretCache(secretId: string): void { + secretCache.delete(secretId); +} + +/** + * Verify a Jira generic-webhook signature. + * + * Atlassian's "Generic webhooks" (configured per-instance in the Jira admin UI) + * sign each delivery with HMAC-SHA256 over the raw request body using the + * instance-configured secret. The signature is delivered as + * `X-Hub-Signature: sha256=` — the `sha256=` prefix is part of the header + * value and must be stripped before timing-safe comparison. + */ +export function verifyJiraSignature( + webhookSecret: string, + signature: string, + body: string, +): boolean { + // Strip the algorithm prefix Atlassian (and most webhook providers using + // X-Hub-Signature) prepend. Be tolerant of operators who paste just the + // hex digest. + const provided = signature.startsWith('sha256=') ? signature.slice('sha256='.length) : signature; + const expected = crypto.createHmac('sha256', webhookSecret).update(body).digest('hex'); + try { + return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided)); + } catch (err) { + logger.warn('Jira signature comparison failed', { + error: err instanceof Error ? err.message : String(err), + expected_length: expected.length, + provided_length: provided.length, + }); + return false; + } +} + +/** + * Check that a Jira webhook event timestamp is within the acceptable window. + * Optional — the receiver only enforces this after signature verification + * succeeds, as a guard against very old replays. + */ +export function isWebhookTimestampFresh(timestamp: number | undefined): boolean { + if (typeof timestamp !== 'number' || !isFinite(timestamp)) { + return false; + } + // One-sided check: reject events older than the window. A small allowance + // for clock skew lets a slightly-future timestamp through, but a far-future + // value (crafted or badly skewed) is rejected rather than silently accepted + // — `Math.abs` would have let any future timestamp pass. + const age = Date.now() - timestamp; + return age <= MAX_WEBHOOK_EVENT_AGE_MS && age >= -CLOCK_SKEW_ALLOWANCE_MS; +} + +/** + * Verify a Jira webhook request, transparently re-fetching the signing + * secret once if the cached copy is rejected. Mirrors the Linear helper so + * a rotated secret picks up within one webhook delivery rather than 5 min + * of cache TTL. + */ +export async function verifyJiraRequest( + secretId: string, + signature: string, + body: string, +): Promise { + const cached = await getJiraSecret(secretId); + if (cached && verifyJiraSignature(cached, signature, body)) { + return true; + } + + invalidateJiraSecretCache(secretId); + const fresh = await getJiraSecret(secretId, true); + if (!fresh) return false; + if (fresh === cached) return false; + return verifyJiraSignature(fresh, signature, body); +} + +/** + * Verify a Jira webhook against the per-tenant signing secret stored + * alongside the tenant's OAuth bundle. The trust model and outcome + * semantics mirror the Linear per-workspace flow: + * + * - `'verified'` — signature matches the per-tenant secret. + * - `'mismatch'` — registry row + secret found, signature wrong. Reject; + * do NOT fall back to stack-wide. + * - `'revoked'` — registry row exists but status is not `active`. + * Reject; do NOT fall back. + * - `'no-per-tenant-secret'` — no registry row, OR the stored secret + * has no `webhook_signing_secret`. Caller should fall back to the + * stack-wide secret for back-compat with single-tenant installs. + * + * Strict lookups (throw on infra errors) are used so a transient DDB or + * SM error doesn't silently downgrade a per-tenant-secured tenant to + * stack-wide verification. + */ +export async function verifyJiraRequestForTenant( + registryTableName: string, + cloudId: string, + signature: string, + body: string, +): Promise<'verified' | 'mismatch' | 'revoked' | 'no-per-tenant-secret'> { + const row = await getRegistryRowStrict(ddb, registryTableName, cloudId); + if (!row) { + return 'no-per-tenant-secret'; + } + if (row.status !== 'active') { + return 'revoked'; + } + const stored = await getOauthSecretStrict(sm, row.oauth_secret_arn); + if (!stored || !stored.webhook_signing_secret) { + return 'no-per-tenant-secret'; + } + return verifyJiraSignature(stored.webhook_signing_secret, signature, body) + ? 'verified' + : 'mismatch'; +} diff --git a/cdk/src/handlers/shared/types.ts b/cdk/src/handlers/shared/types.ts index 2aeab0a1..5ef7283f 100644 --- a/cdk/src/handlers/shared/types.ts +++ b/cdk/src/handlers/shared/types.ts @@ -59,13 +59,14 @@ export type AttachmentDelivery = 'inline' | 'presigned' | 'url_fetch'; * - ``webhook``: HMAC-signed inbound webhook submissions (generic webhook endpoint) * - ``slack``: Slack @mention / slash-command submissions (see SlackIntegration) * - ``linear``: Linear label-triggered submissions (see LinearIntegration) + * - ``jira``: Jira Cloud label-triggered submissions (see JiraIntegration) * * Narrowed from ``string`` so switches and predicates that read * ``channel_source`` get exhaustiveness checking at compile time; matches the * internal ``CreateTaskContext.channelSource`` literal in ``create-task-core.ts``. * Keep in sync with ``cli/src/types.ts::ChannelSource``. */ -export type ChannelSource = 'api' | 'webhook' | 'slack' | 'linear'; +export type ChannelSource = 'api' | 'webhook' | 'slack' | 'linear' | 'jira'; /** * Full task record as stored in DynamoDB. diff --git a/cdk/src/stacks/agent.ts b/cdk/src/stacks/agent.ts index 750c178f..0e7391f1 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -43,6 +43,7 @@ import { DnsFirewall } from '../constructs/dns-firewall'; // import { EcsAgentCluster } from '../constructs/ecs-agent-cluster'; import { FanOutConsumer } from '../constructs/fanout-consumer'; import { GitHubScreenshotIntegration } from '../constructs/github-screenshot-integration'; +import { JiraIntegration } from '../constructs/jira-integration'; import { LinearIntegration } from '../constructs/linear-integration'; import { PendingUploadCleanup } from '../constructs/pending-upload-cleanup'; import { RepoTable } from '../constructs/repo-table'; @@ -818,6 +819,88 @@ export class AgentStack extends Stack { description: 'Name of the DynamoDB Linear workspace registry — `bgagent linear setup` writes a row per OAuth-installed workspace', }); + // --- Jira Cloud integration (inbound webhook + agent-side REST outbound) --- + const jiraIntegration = new JiraIntegration(this, 'JiraIntegration', { + api: taskApi.api, + userPool: taskApi.userPool, + taskTable: taskTable.table, + taskEventsTable: taskEventsTable.table, + repoTable: repoTable.table, + orchestratorFunctionArn: orchestrator.alias.functionArn, + guardrailId: inputGuardrail.guardrailId, + guardrailVersion: inputGuardrail.guardrailVersion, + }); + + // Agent runtime reads the per-tenant Jira OAuth token directly from + // Secrets Manager. The CLI (`bgagent jira setup`) creates + // `bgagent-jira-oauth-` secrets at install time; the secret + // JSON contains access_token, refresh_token, expires_at, and the + // OAuth client_id/client_secret. The orchestrator passes + // `jira_oauth_secret_arn` to the agent via task.channel_metadata, + // so the agent looks up the exact ARN — no discovery needed. + // + // Agent has GetSecretValue ONLY — no Put. Same trust model as the + // Linear adapter: a compromised agent must not be able to overwrite + // any tenant's OAuth bundle. Lambdas (trusted code in this stack) + // own the in-place refresh path; the agent proceeds with whatever + // token Lambdas have most-recently written. + runtime.role.addToPrincipalPolicy(new iam.PolicyStatement({ + actions: ['secretsmanager:GetSecretValue'], + resources: [ + Stack.of(this).formatArn({ + service: 'secretsmanager', + resource: 'secret', + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + resourceName: 'bgagent-jira-oauth-*', + }), + ], + })); + + // Pipe the workspace registry table + per-tenant OAuth-secret-prefix + // grant into the orchestrator so the concurrency-cap rejection path + // (`notifyJiraOnConcurrencyCap` in orchestrate-task.ts) can post a Jira + // comment. The orchestrator only resolves a token when + // `task.channel_source === 'jira'`, but the IAM grant is unconditional + // (per-tenant secrets are created lazily by setup). Put is needed because + // resolving an expiring token refreshes it in place (the orchestrator is + // a trusted Lambda; unlike the agent it owns the rotated-token write-back). + jiraIntegration.workspaceRegistryTable.grantReadData(orchestrator.fn); + orchestrator.fn.addEnvironment( + 'JIRA_WORKSPACE_REGISTRY_TABLE_NAME', + jiraIntegration.workspaceRegistryTable.tableName, + ); + orchestrator.fn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['secretsmanager:GetSecretValue', 'secretsmanager:PutSecretValue'], + resources: [ + Stack.of(this).formatArn({ + service: 'secretsmanager', + resource: 'secret', + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + resourceName: 'bgagent-jira-oauth-*', + }), + ], + })); + + new CfnOutput(this, 'JiraWebhookSecretArn', { + value: jiraIntegration.webhookSecret.secretArn, + description: 'Secrets Manager ARN for the Jira webhook signing secret — populate via `bgagent jira setup`', + }); + + new CfnOutput(this, 'JiraProjectMappingTableName', { + value: jiraIntegration.projectMappingTable.tableName, + description: 'Name of the DynamoDB Jira project → repo mapping table', + }); + + new CfnOutput(this, 'JiraUserMappingTableName', { + value: jiraIntegration.userMappingTable.tableName, + description: 'Name of the DynamoDB Jira user mapping table', + }); + + new CfnOutput(this, 'JiraWorkspaceRegistryTableName', { + value: jiraIntegration.workspaceRegistryTable.tableName, + description: 'Name of the DynamoDB Jira workspace registry — `bgagent jira setup` writes a row per OAuth-installed tenant', + }); + // --- Fan-out plane consumer --- // Consumes TaskEventsTable DynamoDB Streams and dispatches events to // Slack / GitHub / Linear / email per per-channel default filters. diff --git a/cdk/test/handlers/jira-link.test.ts b/cdk/test/handlers/jira-link.test.ts new file mode 100644 index 00000000..75d9d247 --- /dev/null +++ b/cdk/test/handlers/jira-link.test.ts @@ -0,0 +1,167 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { APIGatewayProxyEvent } from 'aws-lambda'; + +const ddbSend = jest.fn(); +jest.mock('@aws-sdk/client-dynamodb', () => ({ DynamoDBClient: jest.fn(() => ({})) })); +jest.mock('@aws-sdk/lib-dynamodb', () => ({ + DynamoDBDocumentClient: { from: jest.fn(() => ({ send: ddbSend })) }, + GetCommand: jest.fn((input: unknown) => ({ _type: 'Get', input })), + PutCommand: jest.fn((input: unknown) => ({ _type: 'Put', input })), + DeleteCommand: jest.fn((input: unknown) => ({ _type: 'Delete', input })), +})); + +jest.mock('ulid', () => ({ ulid: jest.fn(() => 'REQ-ULID') })); + +process.env.JIRA_USER_MAPPING_TABLE_NAME = 'JiraMap'; + +import { handler } from '../../src/handlers/jira-link'; + +function makeEvent(body: unknown, userId?: string): APIGatewayProxyEvent { + return { + body: body === null ? null : JSON.stringify(body), + headers: {}, + multiValueHeaders: {}, + httpMethod: 'POST', + isBase64Encoded: false, + path: '/v1/jira/link', + pathParameters: null, + queryStringParameters: null, + multiValueQueryStringParameters: null, + stageVariables: null, + requestContext: userId + ? ({ authorizer: { claims: { sub: userId } } } as unknown as APIGatewayProxyEvent['requestContext']) + : ({} as APIGatewayProxyEvent['requestContext']), + resource: '', + }; +} + +describe('jira-link handler', () => { + beforeEach(() => { + ddbSend.mockReset(); + }); + + test('401s without a Cognito JWT', async () => { + const result = await handler(makeEvent({ code: 'ABC123' })); + expect(result.statusCode).toBe(401); + }); + + test('400s without a code in the body', async () => { + const result = await handler(makeEvent({}, 'cognito-user-1')); + expect(result.statusCode).toBe(400); + }); + + test('404s when code is not found', async () => { + ddbSend.mockResolvedValueOnce({ Item: undefined }); + const result = await handler(makeEvent({ code: 'XYZ123' }, 'cognito-user-1')); + expect(result.statusCode).toBe(404); + }); + + test('404s when code exists but status is not pending', async () => { + ddbSend.mockResolvedValueOnce({ Item: { jira_identity: 'pending#XYZ', status: 'consumed' } }); + const result = await handler(makeEvent({ code: 'XYZ123' }, 'cognito-user-1')); + expect(result.statusCode).toBe(404); + }); + + test('writes confirmed mapping and deletes pending record on success', async () => { + ddbSend + .mockResolvedValueOnce({ + Item: { + jira_identity: 'pending#link-3f8b4a2c', + status: 'pending', + jira_cloud_id: 'cloud-1', + jira_account_id: 'acc-1', + }, + }) + .mockResolvedValueOnce({}) // Put (confirmed mapping) + .mockResolvedValueOnce({}); // Delete (pending) + + const result = await handler(makeEvent({ code: 'link-3f8b4a2c' }, 'cognito-user-1')); + expect(result.statusCode).toBe(200); + const putCall = ddbSend.mock.calls.find(([cmd]) => cmd._type === 'Put'); + expect(putCall).toBeTruthy(); + expect(putCall![0].input.Item.jira_identity).toBe('cloud-1#acc-1'); + expect(putCall![0].input.Item.platform_user_id).toBe('cognito-user-1'); + expect(putCall![0].input.Item.status).toBe('active'); + + const deleteCall = ddbSend.mock.calls.find(([cmd]) => cmd._type === 'Delete'); + expect(deleteCall).toBeTruthy(); + expect(deleteCall![0].input.Key.jira_identity).toBe('pending#link-3f8b4a2c'); + }); + + test('dry_run returns the linked identity without writing or deleting', async () => { + ddbSend.mockResolvedValueOnce({ + Item: { + jira_identity: 'pending#link-3f8b4a2c', + status: 'pending', + jira_cloud_id: 'cloud-1', + jira_site_url: 'https://acme.atlassian.net', + jira_account_id: 'acc-1', + jira_user_name: 'Ada Lovelace', + jira_user_email: 'ada@example.com', + }, + }); + + const result = await handler(makeEvent({ code: 'link-3f8b4a2c', dry_run: true }, 'cognito-user-1')); + + expect(result.statusCode).toBe(200); + const parsed = JSON.parse(result.body) as { + data: { + dry_run: boolean; + jira_user_email: string; + jira_user_name: string; + jira_site_url: string; + }; + }; + expect(parsed.data.dry_run).toBe(true); + expect(parsed.data.jira_user_email).toBe('ada@example.com'); + expect(parsed.data.jira_user_name).toBe('Ada Lovelace'); + expect(parsed.data.jira_site_url).toBe('https://acme.atlassian.net'); + + // Critical: the dry-run path must not write or delete. + expect(ddbSend.mock.calls.filter(([cmd]) => cmd._type === 'Put')).toHaveLength(0); + expect(ddbSend.mock.calls.filter(([cmd]) => cmd._type === 'Delete')).toHaveLength(0); + }); + + test('dry_run still 404s when the code is invalid (preview must not leak existence)', async () => { + ddbSend.mockResolvedValueOnce({ Item: undefined }); + const result = await handler(makeEvent({ code: 'link-deadbeef', dry_run: true }, 'cognito-user-1')); + expect(result.statusCode).toBe(404); + expect(ddbSend.mock.calls.filter(([cmd]) => cmd._type === 'Put')).toHaveLength(0); + }); + + test('preserves code case and trims whitespace (codes are case-sensitive)', async () => { + ddbSend + .mockResolvedValueOnce({ + Item: { + jira_identity: 'pending#link-3f8b4a2c', + status: 'pending', + jira_cloud_id: 'c', + jira_account_id: 'a', + }, + }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}); + + await handler(makeEvent({ code: ' link-3f8b4a2c ' }, 'cognito-user-1')); + const getCall = ddbSend.mock.calls.find(([cmd]) => cmd._type === 'Get'); + expect(getCall![0].input.Key.jira_identity).toBe('pending#link-3f8b4a2c'); + }); +}); diff --git a/cdk/test/handlers/jira-webhook-processor.test.ts b/cdk/test/handlers/jira-webhook-processor.test.ts new file mode 100644 index 00000000..09ee06f1 --- /dev/null +++ b/cdk/test/handlers/jira-webhook-processor.test.ts @@ -0,0 +1,685 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +const ddbSend = jest.fn(); +jest.mock('@aws-sdk/client-dynamodb', () => ({ DynamoDBClient: jest.fn(() => ({})) })); +jest.mock('@aws-sdk/lib-dynamodb', () => ({ + DynamoDBDocumentClient: { from: jest.fn(() => ({ send: ddbSend })) }, + GetCommand: jest.fn((input: unknown) => ({ _type: 'Get', input })), + ScanCommand: jest.fn((input: unknown) => ({ _type: 'Scan', input })), +})); + +const createTaskCoreMock = jest.fn(); +jest.mock('../../src/handlers/shared/create-task-core', () => ({ + createTaskCore: (...args: unknown[]) => createTaskCoreMock(...args), +})); + +const reportIssueFailureMock = jest.fn(); +jest.mock('../../src/handlers/shared/jira-feedback', () => ({ + reportIssueFailure: (...args: unknown[]) => reportIssueFailureMock(...args), +})); + +const resolveJiraOauthTokenMock = jest.fn(); +jest.mock('../../src/handlers/shared/jira-oauth-resolver', () => ({ + resolveJiraOauthToken: (...args: unknown[]) => resolveJiraOauthTokenMock(...args), +})); + +process.env.JIRA_PROJECT_MAPPING_TABLE_NAME = 'JiraProjects'; +process.env.JIRA_USER_MAPPING_TABLE_NAME = 'JiraUsers'; +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) }; +} + +/** Build a minimal `jira:issue_created` payload with the trigger label + * already applied. Tests override per-case via `overrides`. */ +function issue(overrides: Record = {}): Record { + return { + webhookEvent: 'jira:issue_created', + cloudId: 'cloud-1', + user: { accountId: 'acc-1', displayName: 'Ada' }, + issue: { + id: '10001', + key: 'ENG-42', + fields: { + summary: 'Fix the login bug', + // ADF doc with one paragraph — the processor's walker should + // produce 'Users cannot log in.' as the markdown rendering. + description: { + type: 'doc', + version: 1, + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'Users cannot log in.' }] }, + ], + }, + labels: ['bgagent'], + project: { id: 'p1', key: 'ENG' }, + }, + }, + ...overrides, + }; +} + +describe('jira-webhook-processor handler', () => { + beforeEach(() => { + ddbSend.mockReset(); + createTaskCoreMock.mockReset(); + reportIssueFailureMock.mockReset(); + reportIssueFailureMock.mockResolvedValue(undefined); + resolveJiraOauthTokenMock.mockReset(); + // Default: tenant IS resolvable. Drop-path tests override per-case + // with `.mockResolvedValueOnce(null)`. + resolveJiraOauthTokenMock.mockResolvedValue({ + accessToken: 'jira_at', + scope: 'read:jira-work write:jira-work', + siteUrl: 'https://acme.atlassian.net', + oauthSecretArn: 'arn:aws:secretsmanager:us-east-1:123:secret:bgagent-jira-oauth-cloud-1', + }); + }); + + test('skips missing raw_body', async () => { + await handler({ raw_body: '' }); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + }); + + test('skips malformed JSON', async () => { + await handler({ raw_body: 'not-json-{' }); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + }); + + test('skips non-issue webhookEvent', async () => { + await handler(eventWith({ webhookEvent: 'comment_created', issue: { id: 'x', key: 'X-1' } })); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + }); + + test('skips when issue.id or issue.key is missing', async () => { + await handler(eventWith({ webhookEvent: 'jira:issue_created' })); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + }); + + test('skips when project.key is missing (no routing target)', async () => { + const payload = issue(); + const fields = (payload.issue as { fields: Record }).fields; + delete (fields.project as Record).key; + await handler(eventWith(payload)); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + }); + + test('drops event when cloudId is missing and registry has no active tenant', async () => { + const payload = issue(); + delete payload.cloudId; + // Sole-tenant fallback scans the registry; an empty registry can't + // resolve a tenant, so the event is dropped. + ddbSend.mockResolvedValueOnce({ Items: [] }); + await handler(eventWith(payload)); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + // No feedback either — without cloudId we can't resolve tokens to post. + expect(reportIssueFailureMock).not.toHaveBeenCalled(); + }); + + test('drops event when cloudId is missing and registry has multiple active tenants (ambiguous)', async () => { + const payload = issue(); + delete payload.cloudId; + // Two active tenants → fallback refuses to guess (would risk mis-routing). + ddbSend.mockResolvedValueOnce({ + Items: [ + { jira_cloud_id: 'cloud-1', status: 'active' }, + { jira_cloud_id: 'cloud-2', status: 'active' }, + ], + }); + await handler(eventWith(payload)); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + expect(reportIssueFailureMock).not.toHaveBeenCalled(); + }); + + test('recovers cloudId from sole active tenant when payload omits it (Settings-UI webhook)', async () => { + const payload = issue(); + delete payload.cloudId; + // Registry scan returns exactly one active tenant → use it. Then the + // normal flow proceeds: project mapping (active) + user mapping resolve, + // and a task is created. + ddbSend + .mockResolvedValueOnce({ Items: [{ jira_cloud_id: 'cloud-1', status: 'active' }] }) // Scan + .mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active', label_filter: 'bgagent' } }) // project mapping + .mockResolvedValueOnce({ Item: { platform_user_id: 'user-1', status: 'active' } }); // user mapping + createTaskCoreMock.mockResolvedValue({ task_id: 'T1' }); + await handler(eventWith(payload)); + expect(createTaskCoreMock).toHaveBeenCalled(); + }); + + // ─── Stack-wide-verified deliveries: cloudId is not trusted from the body ── + // + // A delivery verified against the stack-wide fallback secret proves nothing + // about which tenant sent it (the secret is not bound to a cloudId). The + // processor must ignore the body `cloudId` and bind to the sole active + // tenant, dropping when that's ambiguous — otherwise a holder of the + // stack-wide secret could steer a webhook at any tenant's mappings. + describe('stack-wide-verified delivery does not trust body cloudId', () => { + function stackWideEvent(payload: Record): { + raw_body: string; + verified_via_stack_wide: boolean; + } { + return { raw_body: JSON.stringify(payload), verified_via_stack_wide: true }; + } + + test('binds to the sole active tenant, ignoring a different body cloudId', async () => { + // Body claims `cloud-evil`, but the sole active tenant is `cloud-1`. + // Routing must use `cloud-1` (the project mapping is keyed on it). + const payload = issue({ cloudId: 'cloud-evil' }); + ddbSend + .mockResolvedValueOnce({ Items: [{ jira_cloud_id: 'cloud-1', status: 'active' }] }) // Scan + .mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active', label_filter: 'bgagent' } }) // project mapping + .mockResolvedValueOnce({ Item: { platform_user_id: 'user-1', status: 'active' } }); // user mapping + createTaskCoreMock.mockResolvedValue({ statusCode: 201, body: '{}' }); + + await handler(stackWideEvent(payload)); + + // Project mapping was looked up with the SOLE-TENANT cloudId, not the + // attacker-supplied one. + const projectGet = ddbSend.mock.calls[1][0]; + expect(projectGet.input.Key.jira_project_identity).toBe('cloud-1#ENG'); + const [, ctx] = createTaskCoreMock.mock.calls[0]; + expect(ctx.channelMetadata.jira_cloud_id).toBe('cloud-1'); + }); + + test('drops when multiple active tenants make the binding ambiguous', async () => { + const payload = issue({ cloudId: 'cloud-evil' }); + ddbSend.mockResolvedValueOnce({ + Items: [ + { jira_cloud_id: 'cloud-1', status: 'active' }, + { jira_cloud_id: 'cloud-2', status: 'active' }, + ], + }); + + await handler(stackWideEvent(payload)); + + expect(createTaskCoreMock).not.toHaveBeenCalled(); + }); + }); + + test('skips when project is not onboarded', async () => { + ddbSend.mockResolvedValueOnce({ Item: undefined }); + await handler(eventWith(issue())); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + }); + + test('skips when project mapping is removed', async () => { + ddbSend.mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'removed' } }); + await handler(eventWith(issue())); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + }); + + test('skips when trigger label is absent on create', async () => { + ddbSend.mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active' } }); + const payload = issue(); + (payload.issue as { fields: Record }).fields.labels = ['other']; + await handler(eventWith(payload)); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + }); + + test('skips update when changelog has no labels item', async () => { + ddbSend.mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active' } }); + const payload = issue({ + webhookEvent: 'jira:issue_updated', + changelog: { items: [{ field: 'summary', fromString: 'old', toString: 'new' }] }, + }); + await handler(eventWith(payload)); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + }); + + test('skips update when label was already in fromString (was already present)', async () => { + ddbSend.mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active' } }); + const payload = issue({ + webhookEvent: 'jira:issue_updated', + // `bgagent` was already there; user added another label. The diff + // should NOT trigger. + changelog: { + items: [{ field: 'labels', fromString: 'bgagent other', toString: 'bgagent other extra' }], + }, + }); + await handler(eventWith(payload)); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + }); + + test('skips when accountId cannot be resolved', async () => { + ddbSend.mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active' } }); + const payload = issue(); + payload.user = {}; + delete (payload.issue as { fields: Record }).fields.creator; + delete (payload.issue as { fields: Record }).fields.reporter; + await handler(eventWith(payload)); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + }); + + test('skips when accountId has no linked platform user', async () => { + ddbSend + .mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active' } }) + .mockResolvedValueOnce({ Item: undefined }); + await handler(eventWith(issue())); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + }); + + test('creates task with channel_source=jira and jira_* metadata', async () => { + ddbSend + .mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active', label_filter: 'bgagent' } }) + .mockResolvedValueOnce({ + Item: { + jira_identity: 'cloud-1#acc-1', + platform_user_id: 'cognito-user-1', + status: 'active', + }, + }); + createTaskCoreMock.mockResolvedValueOnce({ statusCode: 201, body: JSON.stringify({ data: { task_id: 'T1' } }) }); + + await handler(eventWith(issue())); + + expect(createTaskCoreMock).toHaveBeenCalledTimes(1); + const [reqBody, ctx] = createTaskCoreMock.mock.calls[0]; + expect(reqBody.repo).toBe('org/repo'); + expect(reqBody.task_description).toContain('ENG-42: Fix the login bug'); + expect(reqBody.task_description).toContain('Users cannot log in.'); + expect(ctx.userId).toBe('cognito-user-1'); + expect(ctx.channelSource).toBe('jira'); + expect(ctx.channelMetadata).toMatchObject({ + jira_cloud_id: 'cloud-1', + jira_project_key: 'ENG', + jira_issue_id: '10001', + jira_issue_key: 'ENG-42', + jira_oauth_secret_arn: 'arn:aws:secretsmanager:us-east-1:123:secret:bgagent-jira-oauth-cloud-1', + jira_site_url: 'https://acme.atlassian.net', + }); + }); + + test('uses composite project mapping key {cloudId}#{projectKey}', async () => { + // Two tenants can have the same project key — the composite key + // disambiguates them. Belt-and-braces test that the lookup uses the + // right key shape. + ddbSend + .mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active' } }) + .mockResolvedValueOnce({ Item: { platform_user_id: 'u1', status: 'active' } }); + createTaskCoreMock.mockResolvedValueOnce({ statusCode: 201, body: JSON.stringify({ data: { task_id: 'T1' } }) }); + + await handler(eventWith(issue())); + + const getCall = ddbSend.mock.calls.find(([cmd]) => cmd._type === 'Get'); + expect(getCall![0].input.Key.jira_project_identity).toBe('cloud-1#ENG'); + }); + + test('fires on update when changelog labels diff newly contains the trigger', async () => { + ddbSend + .mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active' } }) + .mockResolvedValueOnce({ Item: { platform_user_id: 'cognito-user-1', status: 'active' } }); + createTaskCoreMock.mockResolvedValueOnce({ statusCode: 201, body: JSON.stringify({ data: { task_id: 'T1' } }) }); + + await handler(eventWith(issue({ + webhookEvent: 'jira:issue_updated', + changelog: { items: [{ field: 'labels', fromString: 'other', toString: 'other bgagent' }] }, + }))); + + expect(createTaskCoreMock).toHaveBeenCalledTimes(1); + }); + + test('fires when label diff comes via fieldId instead of field', async () => { + // Some Atlassian payloads use fieldId rather than field; the trigger + // logic accepts either as long as the labels diff is present. + ddbSend + .mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active' } }) + .mockResolvedValueOnce({ Item: { platform_user_id: 'u1', status: 'active' } }); + createTaskCoreMock.mockResolvedValueOnce({ statusCode: 201, body: JSON.stringify({ data: { task_id: 'T1' } }) }); + + await handler(eventWith(issue({ + webhookEvent: 'jira:issue_updated', + changelog: { items: [{ fieldId: 'labels', fromString: '', toString: 'bgagent' }] }, + }))); + + expect(createTaskCoreMock).toHaveBeenCalledTimes(1); + }); + + test('honors a custom label_filter set on the project mapping', async () => { + ddbSend + .mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active', label_filter: 'triage' } }) + .mockResolvedValueOnce({ Item: { platform_user_id: 'cognito-user-1', status: 'active' } }); + createTaskCoreMock.mockResolvedValueOnce({ statusCode: 201, body: JSON.stringify({ data: { task_id: 'T1' } }) }); + + const payload = issue(); + (payload.issue as { fields: Record }).fields.labels = ['Triage']; + await handler(eventWith(payload)); + + expect(createTaskCoreMock).toHaveBeenCalledTimes(1); + }); + + test('falls back to issue.fields.reporter.accountId when user.accountId is absent', async () => { + ddbSend + .mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active' } }) + .mockResolvedValueOnce({ Item: { platform_user_id: 'cognito-user-1', status: 'active' } }); + createTaskCoreMock.mockResolvedValueOnce({ statusCode: 201, body: JSON.stringify({ data: { task_id: 'T1' } }) }); + + const payload = issue(); + payload.user = {}; + (payload.issue as { fields: Record }).fields.reporter = { accountId: 'reporter-acc' }; + await handler(eventWith(payload)); + + const userGetCall = ddbSend.mock.calls.filter(([cmd]) => cmd._type === 'Get')[1]; + expect(userGetCall[0].input.Key.jira_identity).toBe('cloud-1#reporter-acc'); + }); + + test('drops event when tenant resolves to null (registry miss / inactive / unreadable secret)', async () => { + ddbSend + .mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active' } }) + .mockResolvedValueOnce({ Item: { platform_user_id: 'cognito-user-1', status: 'active' } }); + resolveJiraOauthTokenMock.mockResolvedValueOnce(null); + + await handler(eventWith(issue())); + + expect(createTaskCoreMock).not.toHaveBeenCalled(); + expect(reportIssueFailureMock).not.toHaveBeenCalled(); + }); + + describe('user-visible feedback on silent-failure paths', () => { + test('posts comment when issue has no project.key', async () => { + const payload = issue(); + delete (payload.issue as { fields: { project: Record } }).fields.project.key; + + await handler(eventWith(payload)); + + expect(reportIssueFailureMock).toHaveBeenCalledTimes(1); + const [ctx, issueIdOrKey, message] = reportIssueFailureMock.mock.calls[0]; + expect(ctx).toEqual({ + cloudId: 'cloud-1', + registryTableName: process.env.JIRA_WORKSPACE_REGISTRY_TABLE_NAME, + }); + expect(issueIdOrKey).toBe('ENG-42'); + expect(message).toContain("isn't in a project"); + }); + + test('posts feedback when project is not onboarded', async () => { + ddbSend.mockResolvedValueOnce({ Item: undefined }); + + await handler(eventWith(issue())); + + expect(reportIssueFailureMock).toHaveBeenCalledTimes(1); + const [, issueKey, message] = reportIssueFailureMock.mock.calls[0]; + expect(issueKey).toBe('ENG-42'); + expect(message).toContain("isn't onboarded"); + // The suggested command must be the real one (`map`) with the required + // cloud-id + project-key positionals, not the non-existent + // `onboard-project`. + expect(message).toContain('bgagent jira map cloud-1 ENG --repo'); + expect(message).not.toContain('onboard-project'); + }); + + test('posts feedback when project mapping is removed', async () => { + ddbSend.mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'removed' } }); + + await handler(eventWith(issue())); + + expect(reportIssueFailureMock).toHaveBeenCalledTimes(1); + }); + + test('posts feedback when accountId has no linked platform user', async () => { + ddbSend + .mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active' } }) + .mockResolvedValueOnce({ Item: undefined }); + + await handler(eventWith(issue())); + + expect(reportIssueFailureMock).toHaveBeenCalledTimes(1); + const [, , message] = reportIssueFailureMock.mock.calls[0]; + expect(message).toContain("isn't linked to a platform user"); + expect(message).toContain('bgagent jira link'); + }); + + test('surfaces guardrail block message on createTaskCore 400', async () => { + ddbSend + .mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active' } }) + .mockResolvedValueOnce({ Item: { platform_user_id: 'cognito-user-1', status: 'active' } }); + createTaskCoreMock.mockResolvedValueOnce({ + statusCode: 400, + body: JSON.stringify({ + error: { + code: 'VALIDATION_ERROR', + message: 'Task description was blocked by content policy.', + request_id: 'req-1', + }, + }), + }); + + await handler(eventWith(issue())); + + expect(reportIssueFailureMock).toHaveBeenCalledTimes(1); + const [, , message] = reportIssueFailureMock.mock.calls[0]; + expect(message).toContain('blocked by content policy'); + expect(message).toContain("couldn't accept this task"); + }); + + test('surfaces 503 retry message on createTaskCore service-unavailable', async () => { + ddbSend + .mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active' } }) + .mockResolvedValueOnce({ Item: { platform_user_id: 'cognito-user-1', status: 'active' } }); + createTaskCoreMock.mockResolvedValueOnce({ + statusCode: 503, + body: JSON.stringify({ + error: { + code: 'INTERNAL_ERROR', + message: 'Content screening is temporarily unavailable. Please try again later.', + request_id: 'req-1', + }, + }), + }); + + await handler(eventWith(issue())); + + expect(reportIssueFailureMock).toHaveBeenCalledTimes(1); + const [, , message] = reportIssueFailureMock.mock.calls[0]; + expect(message).toContain('temporarily unavailable'); + expect(message).toContain('re-apply the trigger label'); + }); + + test('does NOT post feedback on the happy 201 path', async () => { + ddbSend + .mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active' } }) + .mockResolvedValueOnce({ Item: { platform_user_id: 'cognito-user-1', status: 'active' } }); + createTaskCoreMock.mockResolvedValueOnce({ statusCode: 201, body: JSON.stringify({ data: { task_id: 'T1' } }) }); + + await handler(eventWith(issue())); + + expect(reportIssueFailureMock).not.toHaveBeenCalled(); + }); + + test('does NOT post feedback on filter-rejected events (label not present)', async () => { + ddbSend.mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active' } }); + const payload = issue(); + (payload.issue as { fields: Record }).fields.labels = ['other']; + + await handler(eventWith(payload)); + + expect(reportIssueFailureMock).not.toHaveBeenCalled(); + }); + + test('safeReportIssueFailure: synchronous throw from reportIssueFailure does not propagate', async () => { + reportIssueFailureMock.mockImplementationOnce(() => { + throw new Error('synthetic synchronous throw'); + }); + const payload = issue(); + delete (payload.issue as { fields: { project: Record } }).fields.project.key; + + await expect(handler(eventWith(payload))).resolves.toBeUndefined(); + expect(reportIssueFailureMock).toHaveBeenCalledTimes(1); + }); + + test('safeReportIssueFailure: async rejection from reportIssueFailure does not propagate', async () => { + reportIssueFailureMock.mockRejectedValueOnce(new Error('async failure')); + const payload = issue(); + delete (payload.issue as { fields: { project: Record } }).fields.project.key; + + await expect(handler(eventWith(payload))).resolves.toBeUndefined(); + expect(reportIssueFailureMock).toHaveBeenCalledTimes(1); + }); + }); + + // ─── ADF → markdown conversion ────────────────────────────────────────────── + + describe('ADF description rendering', () => { + beforeEach(() => { + ddbSend + .mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active' } }) + .mockResolvedValueOnce({ Item: { platform_user_id: 'cognito-user-1', status: 'active' } }); + createTaskCoreMock.mockResolvedValueOnce({ statusCode: 201, body: JSON.stringify({ data: { task_id: 'T1' } }) }); + }); + + test('renders headings, paragraphs, and bullet lists', async () => { + const payload = issue(); + (payload.issue as { fields: Record }).fields.description = { + type: 'doc', + version: 1, + content: [ + { + type: 'heading', + attrs: { level: 2 }, + content: [{ type: 'text', text: 'Repro' }], + }, + { + type: 'paragraph', + content: [{ type: 'text', text: 'Steps to reproduce:' }], + }, + { + type: 'bulletList', + content: [ + { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Open the page' }] }] }, + { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Click submit' }] }] }, + ], + }, + ], + }; + + await handler(eventWith(payload)); + + const [reqBody] = createTaskCoreMock.mock.calls[0]; + expect(reqBody.task_description).toContain('## Repro'); + expect(reqBody.task_description).toContain('Steps to reproduce:'); + expect(reqBody.task_description).toContain('- Open the page'); + expect(reqBody.task_description).toContain('- Click submit'); + }); + + test('falls back to plain string when description is a string (legacy / Connect-style payload)', async () => { + const payload = issue(); + (payload.issue as { fields: Record }).fields.description = 'A plain string description.'; + + await handler(eventWith(payload)); + + const [reqBody] = createTaskCoreMock.mock.calls[0]; + expect(reqBody.task_description).toContain('A plain string description.'); + }); + + test('no description renders only the title line', async () => { + const payload = issue(); + delete (payload.issue as { fields: Record }).fields.description; + + await handler(eventWith(payload)); + + const [reqBody] = createTaskCoreMock.mock.calls[0]; + expect(reqBody.task_description).toBe('ENG-42: Fix the login bug'); + }); + }); + + // ─── Image URL extraction from rendered description ───────────────────────── + + describe('image URL attachment extraction', () => { + beforeEach(() => { + ddbSend + .mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active' } }) + .mockResolvedValueOnce({ Item: { platform_user_id: 'cognito-user-1', status: 'active' } }); + createTaskCoreMock.mockResolvedValueOnce({ statusCode: 201, body: JSON.stringify({ data: { task_id: 'T1' } }) }); + }); + + test('extracts markdown image URLs when description is already markdown', async () => { + const payload = issue(); + (payload.issue as { fields: Record }).fields.description = + 'See:\n\n![screenshot](https://atlassian.net/uploads/img1.png)\n\nAnd ![diagram](https://atlassian.net/uploads/arch.png)'; + + await handler(eventWith(payload)); + + const [reqBody] = createTaskCoreMock.mock.calls[0]; + expect(reqBody.attachments).toHaveLength(2); + expect(reqBody.attachments[0]).toEqual({ type: 'url', url: 'https://atlassian.net/uploads/img1.png' }); + }); + + test('does not extract HTTP (non-HTTPS) URLs', async () => { + const payload = issue(); + (payload.issue as { fields: Record }).fields.description = + '![unsafe](http://evil.com/img.png)'; + + await handler(eventWith(payload)); + + const [reqBody] = createTaskCoreMock.mock.calls[0]; + expect(reqBody.attachments).toBeUndefined(); + }); + + test('caps image extraction at 10 URLs', async () => { + const payload = issue(); + const lines = Array.from({ length: 15 }, (_, i) => `![img${i}](https://cdn.example.com/img${i}.png)`); + (payload.issue as { fields: Record }).fields.description = lines.join('\n'); + + await handler(eventWith(payload)); + + const [reqBody] = createTaskCoreMock.mock.calls[0]; + expect(reqBody.attachments).toHaveLength(10); + }); + + test('extracts an external ADF media node embedded in the description', async () => { + // Real Jira issues embed images as `media` nodes, not markdown image + // text. The walker must render an `external` media node to markdown so + // it surfaces as an attachment. (`file`-type media reference an + // attachment id that needs a Jira API round-trip, so they're skipped.) + const payload = issue(); + (payload.issue as { fields: Record }).fields.description = { + type: 'doc', + version: 1, + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'See the mockup:' }] }, + { + type: 'mediaSingle', + content: [ + { + type: 'media', + attrs: { type: 'external', url: 'https://cdn.example.com/mockup.png', alt: 'mockup' }, + }, + ], + }, + { + type: 'mediaSingle', + content: [ + // file-type media: attachment id, no direct URL — must be skipped. + { type: 'media', attrs: { type: 'file', id: 'att-123' } }, + ], + }, + ], + }; + + await handler(eventWith(payload)); + + const [reqBody] = createTaskCoreMock.mock.calls[0]; + expect(reqBody.attachments).toHaveLength(1); + expect(reqBody.attachments[0]).toEqual({ type: 'url', url: 'https://cdn.example.com/mockup.png' }); + }); + }); +}); diff --git a/cdk/test/handlers/jira-webhook.test.ts b/cdk/test/handlers/jira-webhook.test.ts new file mode 100644 index 00000000..4186ae9a --- /dev/null +++ b/cdk/test/handlers/jira-webhook.test.ts @@ -0,0 +1,313 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as crypto from 'crypto'; +import { ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb'; +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 })), +})); + +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-XYZ'; +process.env.JIRA_WEBHOOK_DEDUP_TABLE_NAME = 'JiraDedup'; +process.env.JIRA_WEBHOOK_PROCESSOR_FUNCTION_NAME = 'jira-processor'; + +import { handler } from '../../src/handlers/jira-webhook'; +import { invalidateJiraSecretCache } from '../../src/handlers/shared/jira-verify'; + +const WEBHOOK_SECRET = 'test-jira-webhook-secret'; + +/** + * Atlassian sends `X-Hub-Signature: sha256=` — verify the signature + * helper accepts both the prefixed form (production) and bare hex + * (defensive). Tests exercise the prefixed form because that's what + * Atlassian actually delivers. + */ +function sign(body: string): string { + const hex = crypto.createHmac('sha256', WEBHOOK_SECRET).update(body).digest('hex'); + return `sha256=${hex}`; +} + +function makeEvent(body: string, signature?: string): APIGatewayProxyEvent { + const headers: Record = {}; + if (signature !== undefined) headers['X-Hub-Signature'] = signature; + return { + body, + headers, + multiValueHeaders: {}, + httpMethod: 'POST', + isBase64Encoded: false, + path: '/v1/jira/webhook', + pathParameters: null, + queryStringParameters: null, + multiValueQueryStringParameters: null, + stageVariables: null, + requestContext: {} as APIGatewayProxyEvent['requestContext'], + resource: '', + }; +} + +function issueCreatePayload(overrides: Record = {}): string { + return JSON.stringify({ + webhookEvent: 'jira:issue_created', + timestamp: Date.now(), + issue: { + id: '10001', + key: 'ENG-42', + fields: { + labels: ['bgagent'], + project: { id: 'p1', key: 'ENG' }, + }, + }, + ...overrides, + }); +} + +describe('jira-webhook handler', () => { + beforeEach(() => { + ddbSend.mockReset(); + lambdaSend.mockReset(); + smSend.mockReset(); + invalidateJiraSecretCache(process.env.JIRA_WEBHOOK_SECRET_ARN!); + smSend.mockResolvedValue({ SecretString: WEBHOOK_SECRET }); + }); + + test('400s when body is missing', async () => { + const result = await handler(makeEvent('', sign(''))); + expect(result.statusCode).toBe(400); + }); + + test('401s when X-Hub-Signature header is missing', async () => { + const body = issueCreatePayload(); + const result = await handler(makeEvent(body)); + expect(result.statusCode).toBe(401); + }); + + test('401s when signature is invalid', async () => { + const body = issueCreatePayload(); + const result = await handler(makeEvent(body, 'sha256=deadbeef')); + expect(result.statusCode).toBe(401); + expect(lambdaSend).not.toHaveBeenCalled(); + }); + + test('ignores non-Issue webhookEvent types with 200', async () => { + const body = JSON.stringify({ + webhookEvent: 'comment_created', + timestamp: Date.now(), + comment: { id: 'c-1' }, + }); + const result = await handler(makeEvent(body, sign(body))); + expect(result.statusCode).toBe(200); + expect(ddbSend).not.toHaveBeenCalled(); + expect(lambdaSend).not.toHaveBeenCalled(); + }); + + test('400s when issue.id or issue.key is missing on a verified Issue event', async () => { + const body = JSON.stringify({ + webhookEvent: 'jira:issue_created', + timestamp: Date.now(), + issue: { fields: {} }, + }); + const result = await handler(makeEvent(body, sign(body))); + expect(result.statusCode).toBe(400); + }); + + test('verified Issue event dedups and invokes processor', async () => { + const FRESH_TS = Date.now(); + const body = issueCreatePayload({ timestamp: FRESH_TS }); + ddbSend.mockResolvedValueOnce({}); // conditional Put succeeds + lambdaSend.mockResolvedValueOnce({}); + + const result = await handler(makeEvent(body, sign(body))); + + expect(result.statusCode).toBe(200); + const putCall = ddbSend.mock.calls.find(([cmd]) => cmd._type === 'Put'); + expect(putCall).toBeTruthy(); + expect(putCall![0].input.Item.dedup_key).toBe(`ENG-42#jira:issue_created#${FRESH_TS}`); + expect(putCall![0].input.ConditionExpression).toContain('attribute_not_exists'); + + // 8h dedup window — comfortably over Atlassian's retry horizon. + const nowSeconds = Math.floor(Date.now() / 1000); + const ttl = putCall![0].input.Item.ttl as number; + expect(ttl - nowSeconds).toBeGreaterThanOrEqual(7 * 60 * 60); + + expect(lambdaSend).toHaveBeenCalledTimes(1); + const invokeCall = lambdaSend.mock.calls[0][0]; + expect(invokeCall._type).toBe('Invoke'); + expect(invokeCall.input.FunctionName).toBe('jira-processor'); + expect(invokeCall.input.InvocationType).toBe('Event'); + const decoded = JSON.parse(new TextDecoder().decode(invokeCall.input.Payload)); + expect(decoded.raw_body).toBe(body); + }); + + test('distinct deliveries for the same issue both dispatch (timestamp distinguishes them)', async () => { + // Atlassian doesn't expose a per-delivery message id; the dedup primitive + // includes the envelope timestamp so a label-off-then-on pair both fire. + const FRESH_TS = Date.now(); + const FRESH_TS_2 = FRESH_TS + 1000; + const body1 = issueCreatePayload({ timestamp: FRESH_TS }); + const body2 = issueCreatePayload({ timestamp: FRESH_TS_2 }); + ddbSend.mockResolvedValue({}); + lambdaSend.mockResolvedValue({}); + + await handler(makeEvent(body1, sign(body1))); + await handler(makeEvent(body2, sign(body2))); + + const putCalls = ddbSend.mock.calls.filter(([cmd]) => cmd._type === 'Put'); + expect(putCalls).toHaveLength(2); + expect(putCalls[0][0].input.Item.dedup_key).toBe(`ENG-42#jira:issue_created#${FRESH_TS}`); + expect(putCalls[1][0].input.Item.dedup_key).toBe(`ENG-42#jira:issue_created#${FRESH_TS_2}`); + expect(lambdaSend).toHaveBeenCalledTimes(2); + }); + + test('400s a base64-encoded body before verifying (HMAC is over the raw string)', async () => { + const body = issueCreatePayload(); + const event = makeEvent(body, sign(body)); + (event as { isBase64Encoded: boolean }).isBase64Encoded = true; + + const result = await handler(event); + + expect(result.statusCode).toBe(400); + expect(lambdaSend).not.toHaveBeenCalled(); + }); + + test('accepts a verified Issue event with no timestamp (replay check skipped, not fail-open-rejected)', async () => { + // Atlassian timestamps are advisory (not signed), so a missing one can't + // be treated as fatal. The delivery still dispatches; the dedup key + // collapses to `…#unknown`. + const body = JSON.stringify({ + webhookEvent: 'jira:issue_created', + issue: { id: '10001', key: 'ENG-42', fields: { labels: ['bgagent'], project: { id: 'p1', key: 'ENG' } } }, + }); + ddbSend.mockResolvedValueOnce({}); + lambdaSend.mockResolvedValueOnce({}); + + const result = await handler(makeEvent(body, sign(body))); + + expect(result.statusCode).toBe(200); + const putCall = ddbSend.mock.calls.find(([cmd]) => cmd._type === 'Put'); + expect(putCall![0].input.Item.dedup_key).toBe('ENG-42#jira:issue_created#unknown'); + expect(lambdaSend).toHaveBeenCalledTimes(1); + }); + + test('flags stack-wide verification to the processor (verified_via_stack_wide:true)', async () => { + // No per-tenant registry configured here → verification rides the + // stack-wide secret, which the processor must not trust for cloudId. + const body = issueCreatePayload(); + ddbSend.mockResolvedValueOnce({}); + lambdaSend.mockResolvedValueOnce({}); + + await handler(makeEvent(body, sign(body))); + + const invokeCall = lambdaSend.mock.calls[0][0]; + const decoded = JSON.parse(new TextDecoder().decode(invokeCall.input.Payload)); + expect(decoded.verified_via_stack_wide).toBe(true); + }); + + test('dedup hit returns 200 with deduped:true', async () => { + const body = issueCreatePayload(); + ddbSend.mockRejectedValueOnce(new ConditionalCheckFailedException({ + $metadata: {}, + message: 'Conditional check failed', + })); + + const result = await handler(makeEvent(body, sign(body))); + expect(result.statusCode).toBe(200); + const parsed = JSON.parse(result.body); + expect(parsed.deduped).toBe(true); + expect(lambdaSend).not.toHaveBeenCalled(); + }); + + test('returns 500 and rolls back dedup row if processor invoke fails', async () => { + const FRESH_TS = Date.now(); + const body = issueCreatePayload({ timestamp: FRESH_TS }); + // 1st ddbSend: PutCommand (dedup reservation) succeeds + // 2nd ddbSend: DeleteCommand (rollback after invoke failure) succeeds + ddbSend.mockResolvedValueOnce({}).mockResolvedValueOnce({}); + lambdaSend.mockRejectedValueOnce(new Error('Lambda throttle')); + + const result = await handler(makeEvent(body, sign(body))); + expect(result.statusCode).toBe(500); + + // Dedup row must be deleted so Atlassian's retry can try again — + // otherwise a transient Lambda failure silently drops the task. + const deleteCalls = ddbSend.mock.calls.filter((c) => c[0]._type === 'Delete'); + expect(deleteCalls).toHaveLength(1); + expect(deleteCalls[0][0].input.TableName).toBe('JiraDedup'); + expect(deleteCalls[0][0].input.Key.dedup_key).toBe(`ENG-42#jira:issue_created#${FRESH_TS}`); + }); + + test('returns 500 even if dedup rollback also fails (does not mask invoke error)', async () => { + const body = issueCreatePayload(); + ddbSend + .mockResolvedValueOnce({}) + .mockRejectedValueOnce(new Error('DDB unavailable')); + lambdaSend.mockRejectedValueOnce(new Error('Lambda throttle')); + + const result = await handler(makeEvent(body, sign(body))); + expect(result.statusCode).toBe(500); + }); + + test('400s on malformed JSON with a valid signature', async () => { + const body = 'not-json-{'; + const result = await handler(makeEvent(body, sign(body))); + expect(result.statusCode).toBe(400); + }); + + test('accepts bare hex signature (no sha256= prefix) for back-compat / non-standard callers', async () => { + // A few Atlassian deployments and proxies strip the algorithm prefix. + // verifyJiraSignature intentionally accepts both shapes; this test + // pins that down so a future refactor doesn't accidentally drop it. + const body = issueCreatePayload(); + const bareHex = crypto.createHmac('sha256', WEBHOOK_SECRET).update(body).digest('hex'); + ddbSend.mockResolvedValueOnce({}); + lambdaSend.mockResolvedValueOnce({}); + + const result = await handler(makeEvent(body, bareHex)); + expect(result.statusCode).toBe(200); + }); +}); diff --git a/cdk/test/handlers/orchestrate-task-feedback.test.ts b/cdk/test/handlers/orchestrate-task-feedback.test.ts index 6a89a846..36aad77f 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,12 @@ 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 +166,73 @@ describe('notifyLinearOnConcurrencyCap', () => { expect(reportIssueFailureMock).toHaveBeenCalledTimes(1); }); }); + +describe('notifyJiraOnConcurrencyCap', () => { + beforeEach(() => { + reportJiraIssueFailureMock.mockReset(); + reportJiraIssueFailureMock.mockResolvedValue(undefined); + }); + + test('posts a Jira comment when channel_source is jira and cloudId + issue key are set', async () => { + await notifyJiraOnConcurrencyCap(task({ + channel_source: 'jira', + channel_metadata: { + jira_cloud_id: 'cloud-1', + jira_issue_key: 'ENG-42', + }, + })); + + 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', async () => { + for (const source of ['api', 'webhook', 'slack', 'linear'] as const) { + reportJiraIssueFailureMock.mockClear(); + await notifyJiraOnConcurrencyCap(task({ + channel_source: source, + channel_metadata: { jira_cloud_id: 'cloud-1', jira_issue_key: 'ENG-42' }, + })); + expect(reportJiraIssueFailureMock).not.toHaveBeenCalled(); + } + }); + + test('no-ops when channel_metadata is missing cloudId or issue key (defensive)', async () => { + await notifyJiraOnConcurrencyCap(task({ + channel_source: 'jira', + channel_metadata: { jira_cloud_id: 'cloud-1' }, // no issue key + })); + expect(reportJiraIssueFailureMock).not.toHaveBeenCalled(); + }); + + test('no-ops when JIRA_WORKSPACE_REGISTRY_TABLE_NAME env is not set', 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_cloud_id: 'cloud-1', jira_issue_key: 'ENG-42' }, + })); + expect(reportJiraIssueFailureMock).not.toHaveBeenCalled(); + } finally { + process.env.JIRA_WORKSPACE_REGISTRY_TABLE_NAME = saved; + } + }); + + test('reportIssueFailure rejection is swallowed (never blocks the rejection path)', async () => { + reportJiraIssueFailureMock.mockRejectedValue(new Error('boom')); + await expect( + notifyJiraOnConcurrencyCap(task({ + channel_source: 'jira', + channel_metadata: { jira_cloud_id: 'cloud-1', jira_issue_key: 'ENG-42' }, + })), + ).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..e7ac4b43 --- /dev/null +++ b/cdk/test/handlers/shared/jira-feedback.test.ts @@ -0,0 +1,121 @@ +/** + * 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), +})); + +import { postIssueComment, reportIssueFailure } from '../../../src/handlers/shared/jira-feedback'; + +const CTX = { cloudId: 'cloud-uuid-1', registryTableName: 'JiraWorkspaceRegistry' }; + +// ``fetch`` is the global transport; each test installs its own mock. +const originalFetch = global.fetch; + +function mockResponse(status: number): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => ({}), + } as unknown as Response; +} + +beforeEach(() => { + resolveJiraOauthTokenMock.mockReset(); + // Default: the resolver hands back a usable token. The `site_url` here is + // deliberately the raw site host — the helper must NOT use it as the REST + // base (that audience is api.atlassian.com only). See assertions below. + resolveJiraOauthTokenMock.mockResolvedValue({ + accessToken: 'jira_oauth_token', + scope: 'write:jira-work', + siteUrl: 'https://acme.atlassian.net', + oauthSecretArn: 'arn:aws:secretsmanager:us-east-1:111:secret:bgagent-jira-oauth-cloud-uuid-1', + }); +}); + +afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); +}); + +describe('jira-feedback: postIssueComment', () => { + test('posts to the api.atlassian.com gateway base scoped by cloudId — NOT the site host', async () => { + const fetchMock = jest.fn().mockResolvedValue(mockResponse(201)); + global.fetch = fetchMock as unknown as typeof fetch; + + const ok = await postIssueComment(CTX, 'ENG-42', 'hello'); + + expect(ok).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + + // The crux of the bug this test guards: the 3LO token is minted with + // audience=api.atlassian.com and 401s against `*.atlassian.net`. + const host = new URL(url as string).host; + expect(host).toBe('api.atlassian.com'); + expect(url).toBe( + 'https://api.atlassian.com/ex/jira/cloud-uuid-1/rest/api/3/issue/ENG-42/comment', + ); + expect(url as string).not.toContain('atlassian.net'); + + const headers = (init as RequestInit).headers as Record; + expect(headers.Authorization).toBe('Bearer jira_oauth_token'); + expect((init as RequestInit).method).toBe('POST'); + }); + + test('url-encodes the issue key', async () => { + const fetchMock = jest.fn().mockResolvedValue(mockResponse(201)); + global.fetch = fetchMock as unknown as typeof fetch; + + await postIssueComment(CTX, 'a/b', 'hi'); + + const [url] = fetchMock.mock.calls[0]; + expect(url).toBe( + 'https://api.atlassian.com/ex/jira/cloud-uuid-1/rest/api/3/issue/a%2Fb/comment', + ); + }); + + test('returns false (never throws) when the resolver yields no token', async () => { + resolveJiraOauthTokenMock.mockResolvedValueOnce(null); + const fetchMock = jest.fn(); + global.fetch = fetchMock as unknown as typeof fetch; + + const ok = await postIssueComment(CTX, 'ENG-42', 'hello'); + + expect(ok).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('returns false on a non-2xx response and swallows the error', async () => { + global.fetch = jest.fn().mockResolvedValue(mockResponse(401)) as unknown as typeof fetch; + + const ok = await postIssueComment(CTX, 'ENG-42', 'hello'); + + expect(ok).toBe(false); + }); + + test('reportIssueFailure never throws even when fetch rejects', async () => { + global.fetch = jest + .fn() + .mockRejectedValue(new Error('network down')) as unknown as typeof fetch; + + await expect(reportIssueFailure(CTX, 'ENG-42', '❌ nope')).resolves.toBeUndefined(); + }); +}); diff --git a/cdk/test/handlers/shared/jira-oauth-resolver.test.ts b/cdk/test/handlers/shared/jira-oauth-resolver.test.ts new file mode 100644 index 00000000..f3baa7e8 --- /dev/null +++ b/cdk/test/handlers/shared/jira-oauth-resolver.test.ts @@ -0,0 +1,669 @@ +/** + * 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 { + _resetCachesForTesting, + getOauthSecret, + getOauthSecretStrict, + getRegistryRow, + getRegistryRowStrict, + invalidateJiraOauthCache, + isTokenExpiring, + resolveJiraOauthToken, + type StoredOauthToken, +} from '../../../src/handlers/shared/jira-oauth-resolver'; + +const REGISTRY_TABLE = 'TestJiraWorkspaceRegistry'; + +function makeStoredToken(overrides: Partial = {}): StoredOauthToken { + const now = new Date(); + const future = new Date(now.getTime() + 12 * 3600 * 1000); + return { + access_token: 'jira_oauth_default', + refresh_token: 'jira_refresh_default', + expires_at: future.toISOString(), + scope: 'read:jira-work write:jira-work offline_access', + client_id: 'cid', + client_secret: 'csec', + cloud_id: 'cloud-uuid-1', + site_url: 'https://acme.atlassian.net', + installed_at: now.toISOString(), + updated_at: now.toISOString(), + installed_by_platform_user_id: 'cog-sub', + ...overrides, + }; +} + +function makeFakeClients(opts: { + registryItem?: Partial<{ + jira_cloud_id: string; + site_url: string; + oauth_secret_arn: string; + status: string; + }> | null; + storedToken?: StoredOauthToken | null; +}) { + const ddbSend = jest.fn().mockImplementation(() => ({ + Item: opts.registryItem === null ? undefined : opts.registryItem, + })); + const smSend = jest.fn().mockImplementation((command: { constructor: { name: string } }) => { + const name = command.constructor.name; + if (name === 'GetSecretValueCommand') { + if (opts.storedToken === null) return { SecretString: undefined }; + return { SecretString: JSON.stringify(opts.storedToken) }; + } + if (name === 'PutSecretValueCommand') { + return {}; + } + return {}; + }); + type Opts = NonNullable[2]>; + return { + dynamoDbClient: { send: ddbSend } as unknown as Opts['dynamoDbClient'], + secretsManagerClient: { send: smSend } as unknown as Opts['secretsManagerClient'], + ddbSend, + smSend, + }; +} + +describe('isTokenExpiring', () => { + test('returns false for a future expiry well past the threshold', () => { + const future = new Date(Date.now() + 3600 * 1000).toISOString(); + expect(isTokenExpiring(future)).toBe(false); + }); + + test('returns true within the 60s threshold', () => { + const soon = new Date(Date.now() + 30 * 1000).toISOString(); + expect(isTokenExpiring(soon)).toBe(true); + }); + + test('returns true for a past expiry', () => { + const past = new Date(Date.now() - 60 * 1000).toISOString(); + expect(isTokenExpiring(past)).toBe(true); + }); + + test('returns true for malformed timestamps (defensive)', () => { + expect(isTokenExpiring('not a date')).toBe(true); + }); +}); + +describe('resolveJiraOauthToken', () => { + beforeEach(() => { + _resetCachesForTesting(); + }); + + test('happy path: returns access token + site url + secret arn', async () => { + const stored = makeStoredToken({ access_token: 'jira_oauth_happy' }); + const clients = makeFakeClients({ + registryItem: { + site_url: 'https://acme.atlassian.net', + oauth_secret_arn: 'arn:secret:acme', + status: 'active', + }, + storedToken: stored, + }); + + const result = await resolveJiraOauthToken('cloud-uuid-1', REGISTRY_TABLE, clients); + + expect(result).toEqual({ + accessToken: 'jira_oauth_happy', + scope: stored.scope, + siteUrl: 'https://acme.atlassian.net', + oauthSecretArn: 'arn:secret:acme', + }); + }); + + test('returns null when tenant is not in the registry', async () => { + const clients = makeFakeClients({ registryItem: null }); + const result = await resolveJiraOauthToken('cloud-not-installed', REGISTRY_TABLE, clients); + expect(result).toBeNull(); + }); + + test('returns null when registry status is not active', async () => { + const clients = makeFakeClients({ + registryItem: { + site_url: 'https://acme.atlassian.net', + oauth_secret_arn: 'arn:secret:acme', + status: 'revoked', + }, + storedToken: makeStoredToken(), + }); + const result = await resolveJiraOauthToken('cloud-uuid-1', REGISTRY_TABLE, clients); + expect(result).toBeNull(); + }); + + test('returns null when secret JSON is missing required fields', async () => { + const clients = makeFakeClients({ + registryItem: { + site_url: 'https://acme.atlassian.net', + oauth_secret_arn: 'arn:secret:acme', + status: 'active', + }, + // Cast: the test deliberately writes a malformed token to assert the + // resolver guards against it. + storedToken: { access_token: 'partial' } as unknown as StoredOauthToken, + }); + const result = await resolveJiraOauthToken('cloud-uuid-1', REGISTRY_TABLE, clients); + expect(result).toBeNull(); + }); + + test('returns null when secret string is absent', async () => { + const clients = makeFakeClients({ + registryItem: { + site_url: 'https://acme.atlassian.net', + oauth_secret_arn: 'arn:secret:acme', + status: 'active', + }, + storedToken: null, + }); + const result = await resolveJiraOauthToken('cloud-uuid-1', REGISTRY_TABLE, clients); + expect(result).toBeNull(); + }); + + test('refreshes token via Atlassian /oauth/token when expiring (JSON body)', async () => { + const expiringSoon = new Date(Date.now() + 10 * 1000).toISOString(); + const stored = makeStoredToken({ + access_token: 'jira_oauth_old', + refresh_token: 'rt-old', + expires_at: expiringSoon, + }); + const clients = makeFakeClients({ + registryItem: { + site_url: 'https://acme.atlassian.net', + oauth_secret_arn: 'arn:secret:acme', + status: 'active', + }, + storedToken: stored, + }); + + const fetchImpl = jest.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'jira_oauth_new', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'rt-new', + scope: 'read:jira-work write:jira-work offline_access', + }), + }); + + const result = await resolveJiraOauthToken('cloud-uuid-1', REGISTRY_TABLE, { + ...clients, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + expect(result?.accessToken).toBe('jira_oauth_new'); + // Atlassian expects a JSON body (NOT form-encoded — the one shape + // difference from Linear). It must carry client creds + refresh_token. + const call = fetchImpl.mock.calls[0]; + expect(call[0]).toBe('https://auth.atlassian.com/oauth/token'); + expect(call[1]!.headers['Content-Type']).toBe('application/json'); + const sent = JSON.parse(call[1]!.body as string); + expect(sent.grant_type).toBe('refresh_token'); + expect(sent.refresh_token).toBe('rt-old'); + expect(sent.client_id).toBe('cid'); + expect(sent.client_secret).toBe('csec'); + // PutSecretValue should have persisted the rotated token. + const putCalls = clients.smSend.mock.calls.filter( + (c) => c[0]!.constructor.name === 'PutSecretValueCommand', + ); + expect(putCalls).toHaveLength(1); + }); + + test('returns null when refresh request fails (invalid_grant, same token)', async () => { + const stored = makeStoredToken({ + refresh_token: 'rt-shared', + expires_at: new Date(Date.now() - 1000).toISOString(), + }); + const clients = makeFakeClients({ + registryItem: { + site_url: 'https://acme.atlassian.net', + oauth_secret_arn: 'arn:secret:acme', + status: 'active', + }, + storedToken: stored, + }); + + const fetchImpl = jest.fn().mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ + error: 'invalid_grant', + error_description: 'refresh token revoked', + }), + }); + + const result = await resolveJiraOauthToken('cloud-uuid-1', REGISTRY_TABLE, { + ...clients, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + expect(result).toBeNull(); + }); + + test('returns null when refresh response is missing access_token', async () => { + const stored = makeStoredToken({ expires_at: new Date(Date.now() - 1000).toISOString() }); + const clients = makeFakeClients({ + registryItem: { + site_url: 'https://acme.atlassian.net', + oauth_secret_arn: 'arn:secret:acme', + status: 'active', + }, + storedToken: stored, + }); + + const fetchImpl = jest.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ token_type: 'Bearer' }), + }); + + const result = await resolveJiraOauthToken('cloud-uuid-1', REGISTRY_TABLE, { + ...clients, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(result).toBeNull(); + }); + + test('returns null when refresh returns non-JSON', async () => { + const stored = makeStoredToken({ expires_at: new Date(Date.now() - 1000).toISOString() }); + const clients = makeFakeClients({ + registryItem: { + site_url: 'https://acme.atlassian.net', + oauth_secret_arn: 'arn:secret:acme', + status: 'active', + }, + storedToken: stored, + }); + + const fetchImpl = jest.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => { + throw new Error('not json'); + }, + }); + + const result = await resolveJiraOauthToken('cloud-uuid-1', REGISTRY_TABLE, { + ...clients, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(result).toBeNull(); + }); + + test('returns null when stored token is missing client credentials (cannot refresh)', async () => { + const stored = { + ...makeStoredToken({ expires_at: new Date(Date.now() - 1000).toISOString() }), + client_id: '', + client_secret: '', + } as StoredOauthToken; + const clients = makeFakeClients({ + registryItem: { + site_url: 'https://acme.atlassian.net', + oauth_secret_arn: 'arn:secret:acme', + status: 'active', + }, + storedToken: stored, + }); + const fetchImpl = jest.fn(); + const result = await resolveJiraOauthToken('cloud-uuid-1', REGISTRY_TABLE, { + ...clients, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + // parseOauthSecret already rejects empty required fields, so the + // resolver returns null before refresh; either way no POST happens. + expect(result).toBeNull(); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + test('invalidateJiraOauthCache clears the cache', async () => { + const stored = makeStoredToken(); + const clients = makeFakeClients({ + registryItem: { + site_url: 'https://acme.atlassian.net', + oauth_secret_arn: 'arn:secret:acme', + status: 'active', + }, + storedToken: stored, + }); + + await resolveJiraOauthToken('cloud-uuid-1', REGISTRY_TABLE, clients); + // Second call hits the cache, doesn't re-query DDB. + await resolveJiraOauthToken('cloud-uuid-1', REGISTRY_TABLE, clients); + const ddbCallsBeforeInvalidate = clients.ddbSend.mock.calls.length; + expect(ddbCallsBeforeInvalidate).toBe(1); + + invalidateJiraOauthCache('cloud-uuid-1', 'arn:secret:acme'); + await resolveJiraOauthToken('cloud-uuid-1', REGISTRY_TABLE, clients); + expect(clients.ddbSend.mock.calls.length).toBe(2); + }); + + test('concurrent-refresh recovery: re-read finds rotated token, skip second /oauth/token POST', async () => { + const expiringSoon = new Date(Date.now() + 10 * 1000).toISOString(); + const wellInFuture = new Date(Date.now() + 12 * 3600 * 1000).toISOString(); + + const stale = makeStoredToken({ + access_token: 'jira_stale', + refresh_token: 'rt-stale', + expires_at: expiringSoon, + }); + const rotated = makeStoredToken({ + access_token: 'jira_concurrent_winner', + refresh_token: 'rt-rotated-by-other-lambda', + expires_at: wellInFuture, + }); + + // First GetSecretValue returns stale; second returns rotated. + const smSend = jest.fn().mockImplementation((command: { constructor: { name: string } }) => { + const name = command.constructor.name; + if (name === 'GetSecretValueCommand') { + const callIdx = + smSend.mock.calls.filter((c) => c[0].constructor.name === 'GetSecretValueCommand').length - 1; + return { SecretString: JSON.stringify(callIdx === 0 ? stale : rotated) }; + } + return {}; + }); + const ddbSend = jest.fn().mockImplementation(() => ({ + Item: { site_url: 'https://acme.atlassian.net', oauth_secret_arn: 'arn:secret:acme', status: 'active' }, + })); + + const fetchImpl = jest.fn().mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ error: 'invalid_grant', error_description: 'token rotated' }), + }); + + type Opts = NonNullable[2]>; + const result = await resolveJiraOauthToken('cloud-uuid-1', REGISTRY_TABLE, { + dynamoDbClient: { send: ddbSend } as unknown as Opts['dynamoDbClient'], + secretsManagerClient: { send: smSend } as unknown as Opts['secretsManagerClient'], + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + expect(result?.accessToken).toBe('jira_concurrent_winner'); + // Exactly ONE /oauth/token POST — no second refresh call. + expect(fetchImpl).toHaveBeenCalledTimes(1); + const getSecretCalls = smSend.mock.calls.filter( + (c) => c[0].constructor.name === 'GetSecretValueCommand', + ); + expect(getSecretCalls).toHaveLength(2); + }); + + test('concurrent-refresh: invalid_grant with same refresh_token on re-read returns null', async () => { + const expiringSoon = new Date(Date.now() + 10 * 1000).toISOString(); + const sameStale = makeStoredToken({ + access_token: 'jira_stale', + refresh_token: 'rt-shared', + expires_at: expiringSoon, + }); + + const smSend = jest.fn().mockImplementation((command: { constructor: { name: string } }) => { + if (command.constructor.name === 'GetSecretValueCommand') { + return { SecretString: JSON.stringify(sameStale) }; + } + return {}; + }); + const ddbSend = jest.fn().mockImplementation(() => ({ + Item: { site_url: 'https://acme.atlassian.net', oauth_secret_arn: 'arn:secret:acme', status: 'active' }, + })); + + const fetchImpl = jest.fn().mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ error: 'invalid_grant' }), + }); + + type Opts = NonNullable[2]>; + const result = await resolveJiraOauthToken('cloud-uuid-1', REGISTRY_TABLE, { + dynamoDbClient: { send: ddbSend } as unknown as Opts['dynamoDbClient'], + secretsManagerClient: { send: smSend } as unknown as Opts['secretsManagerClient'], + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + expect(result).toBeNull(); + // No second /oauth/token POST once the refresh_token is permanently rejected. + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + test('refresh rejected with a non-invalid_grant error returns null (no re-read retry)', async () => { + const stored = makeStoredToken({ expires_at: new Date(Date.now() - 1000).toISOString() }); + const clients = makeFakeClients({ + registryItem: { + site_url: 'https://acme.atlassian.net', + oauth_secret_arn: 'arn:secret:acme', + status: 'active', + }, + storedToken: stored, + }); + + // A server_error (not invalid_grant) is a hard failure — the resolver + // must NOT attempt the concurrent-refresh re-read path. + const fetchImpl = jest.fn().mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => ({ error: 'server_error' }), + }); + + const result = await resolveJiraOauthToken('cloud-uuid-1', REGISTRY_TABLE, { + ...clients, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(result).toBeNull(); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + test('refresh succeeds even when PutSecretValue persistence fails (non-fatal)', async () => { + const stored = makeStoredToken({ expires_at: new Date(Date.now() + 10 * 1000).toISOString() }); + const ddbSend = jest.fn().mockImplementation(() => ({ + Item: { site_url: 'https://acme.atlassian.net', oauth_secret_arn: 'arn:secret:acme', status: 'active' }, + })); + const smSend = jest.fn().mockImplementation((command: { constructor: { name: string } }) => { + const name = command.constructor.name; + if (name === 'GetSecretValueCommand') return { SecretString: JSON.stringify(stored) }; + if (name === 'PutSecretValueCommand') throw new Error('synthetic put failure'); + return {}; + }); + + const fetchImpl = jest.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ access_token: 'jira_persist_fail_ok', refresh_token: 'rt-new', expires_in: 3600 }), + }); + + type Opts = NonNullable[2]>; + const result = await resolveJiraOauthToken('cloud-uuid-1', REGISTRY_TABLE, { + dynamoDbClient: { send: ddbSend } as unknown as Opts['dynamoDbClient'], + secretsManagerClient: { send: smSend } as unknown as Opts['secretsManagerClient'], + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + // Persistence failure is logged but does not fail the resolve — the + // freshly-minted access token is still returned for this invocation. + expect(result?.accessToken).toBe('jira_persist_fail_ok'); + }); + + test('invalid_grant then re-read returns unreadable secret yields null', async () => { + const expiringSoon = new Date(Date.now() + 10 * 1000).toISOString(); + const stale = makeStoredToken({ refresh_token: 'rt-old', expires_at: expiringSoon }); + + const smSend = jest.fn().mockImplementation((command: { constructor: { name: string } }) => { + if (command.constructor.name === 'GetSecretValueCommand') { + const idx = + smSend.mock.calls.filter((c) => c[0].constructor.name === 'GetSecretValueCommand').length - 1; + // First read: stale token. Second (re-read after invalid_grant): + // secret has gone missing → null. + return { SecretString: idx === 0 ? JSON.stringify(stale) : undefined }; + } + return {}; + }); + const ddbSend = jest.fn().mockImplementation(() => ({ + Item: { site_url: 'https://acme.atlassian.net', oauth_secret_arn: 'arn:secret:acme', status: 'active' }, + })); + const fetchImpl = jest.fn().mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ error: 'invalid_grant' }), + }); + + type Opts = NonNullable[2]>; + const result = await resolveJiraOauthToken('cloud-uuid-1', REGISTRY_TABLE, { + dynamoDbClient: { send: ddbSend } as unknown as Opts['dynamoDbClient'], + secretsManagerClient: { send: smSend } as unknown as Opts['secretsManagerClient'], + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(result).toBeNull(); + }); + + test('refresh fetch network failure returns null and invalidates cache', async () => { + const expiringSoon = new Date(Date.now() + 10 * 1000).toISOString(); + const stale = makeStoredToken({ expires_at: expiringSoon }); + const clients = makeFakeClients({ + registryItem: { + site_url: 'https://acme.atlassian.net', + oauth_secret_arn: 'arn:secret:acme', + status: 'active', + }, + storedToken: stale, + }); + + const fetchImpl = jest.fn().mockRejectedValueOnce(new Error('ECONNRESET')); + + const first = await resolveJiraOauthToken('cloud-uuid-1', REGISTRY_TABLE, { + ...clients, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(first).toBeNull(); + + // After the failure the cache should be invalidated — the second call + // re-reads SM rather than looping on the stale cached token. + const fetchImpl2 = jest.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'jira_after_retry', + refresh_token: 'rt-new', + expires_in: 3600, + }), + }); + + const second = await resolveJiraOauthToken('cloud-uuid-1', REGISTRY_TABLE, { + ...clients, + fetchImpl: fetchImpl2 as unknown as typeof fetch, + }); + expect(second?.accessToken).toBe('jira_after_retry'); + const getSecretCalls = clients.smSend.mock.calls.filter( + (c) => c[0].constructor.name === 'GetSecretValueCommand', + ); + expect(getSecretCalls.length).toBeGreaterThanOrEqual(2); + }); +}); + +describe('getRegistryRow / parseRegistryRow', () => { + beforeEach(() => { + _resetCachesForTesting(); + }); + + type Opts = NonNullable[2]>; + const asDdb = (send: jest.Mock) => ({ send }) as unknown as NonNullable; + + test('returns null when DDB returns no item', async () => { + const send = jest.fn().mockResolvedValue({ Item: undefined }); + const row = await getRegistryRow(asDdb(send), REGISTRY_TABLE, 'cloud-x'); + expect(row).toBeNull(); + }); + + test('returns null and logs when DDB throws (non-strict swallows error)', async () => { + const send = jest.fn().mockRejectedValue(new Error('DDB throttle')); + const row = await getRegistryRow(asDdb(send), REGISTRY_TABLE, 'cloud-x'); + expect(row).toBeNull(); + }); + + test('unknown status is treated as revoked (fail-closed)', async () => { + const send = jest.fn().mockResolvedValue({ + Item: { site_url: 'https://x.atlassian.net', oauth_secret_arn: 'arn:s', status: 'weird' }, + }); + const row = await getRegistryRow(asDdb(send), REGISTRY_TABLE, 'cloud-x'); + expect(row?.status).toBe('revoked'); + }); + + test('active status round-trips and caches', async () => { + const send = jest.fn().mockResolvedValue({ + Item: { site_url: 'https://x.atlassian.net', oauth_secret_arn: 'arn:s', status: 'active' }, + }); + const ddb = asDdb(send); + const first = await getRegistryRow(ddb, REGISTRY_TABLE, 'cloud-cache'); + expect(first?.status).toBe('active'); + // Second read should be served from cache (no extra DDB call). + await getRegistryRow(ddb, REGISTRY_TABLE, 'cloud-cache'); + expect(send).toHaveBeenCalledTimes(1); + }); + + test('getRegistryRowStrict propagates DDB errors instead of swallowing them', async () => { + const send = jest.fn().mockRejectedValue(new Error('DDB throttle')); + await expect(getRegistryRowStrict(asDdb(send), REGISTRY_TABLE, 'cloud-x')).rejects.toThrow( + 'DDB throttle', + ); + }); +}); + +describe('getOauthSecret / getOauthSecretStrict', () => { + beforeEach(() => { + _resetCachesForTesting(); + }); + + type Opts = NonNullable[2]>; + const asSm = (send: jest.Mock) => ({ send }) as unknown as NonNullable; + + test('returns parsed token on valid JSON', async () => { + const stored = makeStoredToken(); + const send = jest.fn().mockResolvedValue({ SecretString: JSON.stringify(stored) }); + const token = await getOauthSecret(asSm(send), 'arn:s'); + expect(token?.access_token).toBe(stored.access_token); + }); + + test('returns null on invalid JSON', async () => { + const send = jest.fn().mockResolvedValue({ SecretString: 'not { json' }); + const token = await getOauthSecret(asSm(send), 'arn:s'); + expect(token).toBeNull(); + }); + + test('returns null when SecretString is absent', async () => { + const send = jest.fn().mockResolvedValue({ SecretString: undefined }); + const token = await getOauthSecret(asSm(send), 'arn:s'); + expect(token).toBeNull(); + }); + + test('non-strict swallows SM errors and returns null', async () => { + const send = jest.fn().mockRejectedValue(new Error('SM down')); + const token = await getOauthSecret(asSm(send), 'arn:s'); + expect(token).toBeNull(); + }); + + test('strict variant propagates SM errors', async () => { + const send = jest.fn().mockRejectedValue(new Error('SM down')); + await expect(getOauthSecretStrict(asSm(send), 'arn:s')).rejects.toThrow('SM down'); + }); + + test('strict variant returns null when SecretString is absent', async () => { + const send = jest.fn().mockResolvedValue({ SecretString: undefined }); + const token = await getOauthSecretStrict(asSm(send), 'arn:s'); + expect(token).toBeNull(); + }); +}); diff --git a/cdk/test/stacks/agent.test.ts b/cdk/test/stacks/agent.test.ts index 859fb630..22a295fd 100644 --- a/cdk/test/stacks/agent.test.ts +++ b/cdk/test/stacks/agent.test.ts @@ -36,14 +36,16 @@ describe('AgentStack', () => { expect(template).toBeDefined(); }); - test('creates exactly 14 DynamoDB tables', () => { + test('creates exactly 18 DynamoDB tables', () => { // task, task-events, repo, user-concurrency, webhook, task-nudges, // task-approvals (Cedar HITL V2), // slack-installation, slack-user-mapping, // linear-project-mapping, linear-user-mapping, linear-webhook-dedup, // linear-workspace-registry (added in Phase 2.0b for OAuth bookkeeping), - // github-webhook-dedup (added by GitHubScreenshotIntegration) - template.resourceCountIs('AWS::DynamoDB::Table', 14); + // jira-project-mapping, jira-user-mapping, jira-workspace-registry, + // jira-webhook-dedup (added for the Jira Cloud integration), + // github-webhook-dedup (added by GitHubScreenshotIntegration on main) + template.resourceCountIs('AWS::DynamoDB::Table', 18); }); test('creates TaskApprovalsTable with user_id-status-index GSI', () => { diff --git a/cli/src/api-client.ts b/cli/src/api-client.ts index 687c4bcc..4ba86e06 100644 --- a/cli/src/api-client.ts +++ b/cli/src/api-client.ts @@ -35,6 +35,7 @@ import { ErrorResponse, GetPendingResponse, GetPoliciesResponse, + JiraLinkResponse, LinearLinkResponse, NudgeRequest, NudgeResponse, @@ -433,4 +434,15 @@ export class ApiClient { const res = await this.request>('POST', '/linear/link', body); return res.data; } + + /** POST /jira/link — link a Jira account using a verification code. + * + * `dryRun: true` returns the identity attached to the code without + * writing the mapping. Mirrors linearLink. */ + async jiraLink(code: string, opts: { dryRun?: boolean } = {}): Promise { + const body: Record = { code }; + if (opts.dryRun) body.dry_run = true; + const res = await this.request>('POST', '/jira/link', body); + return res.data; + } } diff --git a/cli/src/bin/bgagent.ts b/cli/src/bin/bgagent.ts index 48f3d36c..29eaa8af 100644 --- a/cli/src/bin/bgagent.ts +++ b/cli/src/bin/bgagent.ts @@ -27,6 +27,7 @@ import { makeConfigureCommand } from '../commands/configure'; import { makeDenyCommand } from '../commands/deny'; import { makeEventsCommand } from '../commands/events'; import { makeGithubCommand } from '../commands/github'; +import { makeJiraCommand } from '../commands/jira'; import { makeLinearCommand } from '../commands/linear'; import { makeListCommand } from '../commands/list'; import { makeLoginCommand } from '../commands/login'; @@ -71,6 +72,7 @@ program.addCommand(makePoliciesCommand()); program.addCommand(makeEventsCommand()); program.addCommand(makeSlackCommand()); program.addCommand(makeLinearCommand()); +program.addCommand(makeJiraCommand()); program.addCommand(makeGithubCommand()); program.addCommand(makeWatchCommand()); program.addCommand(makeTraceCommand()); diff --git a/cli/src/commands/jira.ts b/cli/src/commands/jira.ts new file mode 100644 index 00000000..c459d0c6 --- /dev/null +++ b/cli/src/commands/jira.ts @@ -0,0 +1,660 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { execFile } from 'child_process'; +import * as readline from 'readline'; +import { CloudFormationClient, DescribeStacksCommand } from '@aws-sdk/client-cloudformation'; +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { + CreateSecretCommand, + GetSecretValueCommand, + PutSecretValueCommand, + ResourceExistsException, + SecretsManagerClient, +} from '@aws-sdk/client-secrets-manager'; +import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; +import { Command } from 'commander'; +import { ApiClient } from '../api-client'; +import { loadConfig, loadCredentials } from '../config'; +import { CliError } from '../errors'; +import { formatJson } from '../format'; +import { + buildAuthorizationUrl, + computeExpiresAt, + exchangeAuthorizationCode, + fetchAccessibleResources, + generatePkce, + jiraOauthSecretName, + StoredJiraOauthToken, +} from '../jira-oauth'; +import { awaitOauthCallback, CALLBACK_URL } from '../oauth-callback-server'; + +/** Default label that triggers an ABCA task when applied to a Jira issue. */ +const DEFAULT_LABEL_FILTER = 'bgagent'; + +/** Jira project keys are typically 2–10 uppercase chars, but Atlassian + * allows longer alphanumeric keys (and digits). Accept what Atlassian + * accepts at creation time. */ +const PROJECT_KEY_RE = /^[A-Z][A-Z0-9_]{1,99}$/; + +/** Width of the ═ rule used to frame the setup banner. */ +const BANNER_WIDTH = 72; + +/** + * Render the printable Atlassian developer-console app config. Standalone + * export so `bgagent jira setup` can call it inline. + */ +export interface JiraAppTemplateOptions { + readonly developerName?: string; + readonly description?: string; + readonly callbackUrl?: string; +} + +export function renderJiraAppTemplate(opts: JiraAppTemplateOptions = {}): string { + const developerName = opts.developerName ?? 'ABCA'; + const description = opts.description ?? 'Autonomous Background Coding Agent'; + // Localhost callback works for everyone running setup interactively. + // The redirect_uri value sent to Atlassian MUST byte-match what's + // configured here. + const callbackUrl = opts.callbackUrl ?? CALLBACK_URL; + + const bar = '═'.repeat(BANNER_WIDTH); + return [ + bar, + 'Atlassian OAuth (3LO) app template', + bar, + '', + 'Open https://developer.atlassian.com/console/myapps/ → Create → OAuth 2.0', + 'integration, and enter:', + '', + ` Name: bgagent — ${developerName}`, + ` Description: ${description}`, + '', + 'In the new app, configure:', + '', + ' Permissions → Add APIs:', + ' • Jira API (scopes: read:jira-work, write:jira-work, read:jira-user)', + '', + ' Authorization → OAuth 2.0 (3LO):', + ` Callback URL: ${callbackUrl}`, + '', + ' Distribution: Sharing OFF (private to your developer org)', + '', + 'Save, then open Settings → copy the Client ID and Client Secret and return', + 'here.', + '', + 'Why these specific fields:', + ' • The 3 Jira scopes match what ABCA needs to read issues, post', + ' comments, and resolve account → display name during link preview.', + ' • offline_access is added implicitly by buildAuthorizationUrl — do', + ' not enable it as a scope in the dev console UI; passing it in the', + ' authorize request is sufficient and the dev console doesn\'t list', + ' it as a togglable scope.', + ' • The localhost callback removes the self-signed-cert browser warning', + ' and works without a public hostname on the operator\'s machine.', + bar, + ].join('\n'); +} + +/** + * Spawn the OS-default browser to open the given URL. Returns false on + * failure so callers can fall back to printing the URL. + */ +export function openBrowser(url: string): Promise { + return new Promise((resolve) => { + let opener: { cmd: string; args: string[] }; + if (process.platform === 'darwin') { + opener = { cmd: 'open', args: [url] }; + } else if (process.platform === 'win32') { + opener = { cmd: 'cmd', args: ['/c', 'start', '""', url] }; + } else { + opener = { cmd: 'xdg-open', args: [url] }; + } + execFile(opener.cmd, opener.args, (err) => { + resolve(!err); + }); + }); +} + +/** + * Generate an opaque, URL-safe `state` value for OAuth CSRF protection. + */ +function randomState(): string { + const STATE_BYTES = 32; + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { randomBytes } = require('crypto') as typeof import('crypto'); + return randomBytes(STATE_BYTES).toString('base64url'); +} + +/** + * Idempotent secret upsert: tries CreateSecret first; if the secret + * already exists, falls back to PutSecretValue. Returns the secret ARN + * regardless of which branch ran. + */ +export async function upsertOauthSecret( + client: SecretsManagerClient, + secretName: string, + payload: StoredJiraOauthToken, + cloudId: string, +): Promise { + const secretString = JSON.stringify(payload); + try { + const create = await client.send(new CreateSecretCommand({ + Name: secretName, + Description: `Jira OAuth token for tenant '${cloudId}'`, + SecretString: secretString, + Tags: [ + { Key: 'bgagent:integration', Value: 'jira' }, + { Key: 'bgagent:jira:cloud_id', Value: cloudId }, + ], + })); + if (!create.ARN) { + throw new CliError(`CreateSecret returned no ARN for '${secretName}'.`); + } + return create.ARN; + } catch (err) { + if (err instanceof ResourceExistsException) { + const put = await client.send(new PutSecretValueCommand({ + SecretId: secretName, + SecretString: secretString, + })); + if (!put.ARN) { + throw new CliError(`PutSecretValue returned no ARN for '${secretName}'.`); + } + return put.ARN; + } + throw err; + } +} + +/** + * Check whether the JiraWebhookSecret already holds a real signing secret + * (vs CDK's autogenerated placeholder). Used to decide whether to prompt + * for the webhook secret on subsequent setup runs. + * + * Atlassian's generic-webhook signing secrets are operator-chosen — they + * have no fixed prefix like Linear's `lin_wh_`. We treat the placeholder + * as a JSON-encoded value (CDK's default for autogenerated secrets) and + * everything else as a real value. + */ +async function isWebhookSecretConfigured( + client: SecretsManagerClient, + secretArn: string, +): Promise { + try { + const result = await client.send(new GetSecretValueCommand({ SecretId: secretArn })); + const value = result.SecretString; + if (typeof value !== 'string' || value.length === 0) return false; + // CDK's auto-generated secret is a JSON object string starting with `{` + // — operator-set secrets (the Atlassian-side configured value) are bare + // strings. Anything that doesn't look like the placeholder JSON is real. + return !value.trim().startsWith('{'); + } catch (err) { + const errorName = (err as { name?: string }).name; + if (errorName === 'ResourceNotFoundException') { + return false; + } + const message = err instanceof Error ? err.message : String(err); + throw new CliError( + `Failed to read Jira webhook secret '${secretArn}': ${errorName ?? 'Error'}: ${message}. ` + + 'Likely IAM permission gap — confirm your CLI principal has ' + + '`secretsmanager:GetSecretValue` on this ARN.', + ); + } +} + +function promptSecret(label: string): Promise { + return new Promise((resolve) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + // Mute echo so secrets don't render in the terminal as the user types. + const stdout = process.stdout as unknown as { write: (s: string) => boolean }; + const origWrite = stdout.write.bind(stdout); + let muted = false; + stdout.write = ((str: string) => { + if (muted && str !== label) return true; + return origWrite(str); + }) as typeof stdout.write; + rl.question(label, (answer) => { + stdout.write = origWrite; + rl.close(); + process.stdout.write('\n'); + resolve(answer); + }); + muted = true; + }); +} + +function promptLine(label: string): Promise { + return new Promise((resolve) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + rl.question(label.endsWith(' ') ? label : `${label} `, (answer) => { + rl.close(); + resolve(answer); + }); + }); +} + +function extractCognitoSub(): string { + const creds = loadCredentials(); + if (!creds?.id_token) { + throw new Error('not authenticated — run `bgagent login`'); + } + const JWT_SEGMENTS = 3; // header.payload.signature + const parts = creds.id_token.split('.'); + if (parts.length !== JWT_SEGMENTS) { + throw new Error('malformed id_token in ~/.bgagent/credentials.json'); + } + const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8')) as { sub?: string }; + if (!payload.sub) { + throw new Error('id_token missing `sub` claim'); + } + return payload.sub; +} + +async function getStackOutput(region: string, stackName: string, outputKey: string): Promise { + try { + const cfn = new CloudFormationClient({ region }); + const result = await cfn.send(new DescribeStacksCommand({ StackName: stackName })); + const outputs = result.Stacks?.[0]?.Outputs ?? []; + const output = outputs.find((o) => o.OutputKey === outputKey); + return output?.OutputValue ?? null; + } catch (err) { + const name = (err as Error)?.name ?? ''; + const message = (err as Error)?.message ?? ''; + if (name === 'ValidationError' && /does not exist/i.test(message)) { + return null; + } + throw err; + } +} + +export function makeJiraCommand(): Command { + const jira = new Command('jira') + .description('Manage Jira Cloud integration'); + + // ─── app-template ───────────────────────────────────────────────────────── + jira.addCommand( + new Command('app-template') + .description('Print the field values to paste into Atlassian\'s developer-console OAuth app form') + .option('--developer-name ', 'Developer name shown on Atlassian\'s consent screen') + .option('--description ', 'App description shown on Atlassian\'s consent screen') + .option('--callback-url ', 'OAuth callback URL (defaults to localhost:8080/oauth/callback)') + .action((opts) => { + console.log(renderJiraAppTemplate({ + developerName: opts.developerName, + description: opts.description, + callbackUrl: opts.callbackUrl, + })); + }), + ); + + // ─── setup ──────────────────────────────────────────────────────────────── + jira.addCommand( + new Command('setup') + .description('Authorize a Jira Cloud tenant via OAuth (3LO direct flow, Secrets Manager storage)') + .option('--region ', 'AWS region (defaults to configured region)') + .option('--stack-name ', 'CloudFormation stack name', 'backgroundagent-dev') + .option('--client-id ', 'Atlassian OAuth app Client ID (else prompted)') + .option('--client-secret ', 'Atlassian OAuth app Client Secret (else prompted; prefer interactive)') + .option('--no-browser', 'Print the authorization URL instead of opening a browser (for SSH/headless)') + .action(async (opts) => { + const config = loadConfig(); + const region = opts.region || config.region; + const stackName = opts.stackName; + + // ─── Stack outputs ─────────────────────────────────────────────── + const [ + workspaceRegistryTable, + webhookSecretArn, + ] = await Promise.all([ + getStackOutput(region, stackName, 'JiraWorkspaceRegistryTableName'), + getStackOutput(region, stackName, 'JiraWebhookSecretArn'), + ]); + + const missing: string[] = []; + if (!workspaceRegistryTable) missing.push('JiraWorkspaceRegistryTableName'); + if (!webhookSecretArn) missing.push('JiraWebhookSecretArn'); + if (missing.length > 0) { + throw new CliError( + `Stack '${stackName}' is missing outputs ${missing.join(', ')}. ` + + 'Re-deploy with the JiraIntegration CDK changes (mise //cdk:deploy).', + ); + } + + // ─── Resolve caller identity ───────────────────────────────────── + const creds = loadCredentials(); + if (!creds?.id_token) { + throw new CliError('Not authenticated — run `bgagent login` first.'); + } + let cognitoSub: string; + try { + cognitoSub = extractCognitoSub(); + } catch (err) { + throw new CliError( + `Could not read Cognito sub from cached id_token: ${err instanceof Error ? err.message : String(err)}. ` + + 'Run `bgagent login` to refresh credentials.', + ); + } + + // ─── Atlassian OAuth app credentials ───────────────────────────── + console.log('bgagent jira setup'); + console.log(` region: ${region}`); + console.log( + '\nAtlassian OAuth app credentials needed. If you have not created one, run `bgagent jira app-template`' + + ' for the values to paste into developer.atlassian.com → My apps.\n', + ); + const clientId = (opts.clientId ?? await promptSecret('Atlassian Client ID: ')).trim(); + if (!clientId) { + throw new CliError('Client ID is required.'); + } + const clientSecret = (opts.clientSecret ?? await promptSecret('Atlassian Client Secret: ')).trim(); + if (!clientSecret) { + throw new CliError('Client Secret is required.'); + } + + // ─── Step 1: Generate PKCE + open browser to Atlassian consent ─── + const pkce = generatePkce(); + const state = randomState(); + const authorizationUrl = buildAuthorizationUrl({ + clientId, + redirectUri: CALLBACK_URL, + state, + codeChallenge: pkce.codeChallenge, + }); + + const callbackPromise = awaitOauthCallback(); + + console.log(); + if (opts.browser !== false) { + const opened = await openBrowser(authorizationUrl); + if (opened) { + console.log(' → Opened your browser to the Atlassian consent screen.'); + console.log(' The browser will redirect to a localhost page after you Authorize — that\'s expected.'); + } else { + console.log(' → Could not open browser automatically. Open this URL manually:'); + console.log(` ${authorizationUrl}`); + } + } else { + console.log(' → --no-browser: open this URL manually:'); + console.log(` ${authorizationUrl}`); + } + + process.stdout.write(' → Waiting for browser callback...'); + const callback = await callbackPromise; + console.log(' ✓'); + + if (callback.kind !== 'direct-oauth') { + throw new CliError( + 'Localhost callback returned an AgentCore session_id, not a direct OAuth code. ' + + 'Verify Atlassian\'s redirect URI is set to http://localhost:8080/oauth/callback and re-run.', + ); + } + if (callback.state !== state) { + throw new CliError( + `OAuth state mismatch (expected '${state}', got '${callback.state}'). ` + + 'Possible CSRF attack or stale tab — re-run setup.', + ); + } + + // ─── Step 2: Exchange code for access token ────────────────────── + process.stdout.write(' → Exchanging code for access token...'); + const tokenResponse = await exchangeAuthorizationCode({ + code: callback.code, + codeVerifier: pkce.codeVerifier, + redirectUri: CALLBACK_URL, + clientId, + clientSecret, + }); + console.log(' ✓'); + + if (!tokenResponse.refresh_token) { + throw new CliError( + 'Atlassian did not return a refresh_token. The integration cannot self-renew tokens; ' + + 'verify the OAuth app requested the offline_access scope (re-run with the latest CLI; ' + + 'this is in the default scope list).', + ); + } + + // ─── Step 3: Fetch accessible resources (cloudId + siteUrl) ────── + process.stdout.write(' → Fetching accessible Atlassian sites...'); + const resources = await fetchAccessibleResources(tokenResponse.access_token); + if (resources.length === 0) { + throw new CliError( + 'Atlassian returned no accessible sites for the issued token. ' + + 'The user that authorized may not have access to any Jira sites — verify and re-run.', + ); + } + console.log(` ✓ (${resources.length} site${resources.length === 1 ? '' : 's'})`); + + let chosen = resources[0]; + if (resources.length > 1) { + console.log(); + console.log(' Multiple Atlassian sites are accessible:'); + resources.forEach((r, i) => { + console.log(` [${i + 1}] ${r.name} (${r.url})`); + }); + const pick = (await promptLine(` Select site [1-${resources.length}]:`)).trim(); + const idx = Number.parseInt(pick, 10) - 1; + if (Number.isNaN(idx) || idx < 0 || idx >= resources.length) { + throw new CliError(`Invalid selection '${pick}'.`); + } + chosen = resources[idx]; + } + + const cloudId = chosen.id; + const siteUrl = chosen.url; + console.log(` Selected: ${chosen.name}`); + console.log(` cloud_id: ${cloudId}`); + console.log(` site_url: ${siteUrl}`); + + // ─── Step 4: Persist token to per-tenant Secrets Manager ───────── + process.stdout.write(' → Storing OAuth token...'); + const sm = new SecretsManagerClient({ region }); + const now = new Date().toISOString(); + const stored: StoredJiraOauthToken = { + access_token: tokenResponse.access_token, + refresh_token: tokenResponse.refresh_token, + expires_at: computeExpiresAt(tokenResponse.expires_in), + scope: tokenResponse.scope, + client_id: clientId, + client_secret: clientSecret, + cloud_id: cloudId, + site_url: siteUrl, + installed_at: now, + updated_at: now, + installed_by_platform_user_id: cognitoSub, + }; + const secretName = jiraOauthSecretName(cloudId); + const oauthSecretArn = await upsertOauthSecret(sm, secretName, stored, cloudId); + console.log(` ✓ (${secretName})`); + + // ─── Step 5: Persist registry row ──────────────────────────────── + const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region })); + await ddb.send(new PutCommand({ + TableName: workspaceRegistryTable!, + Item: { + jira_cloud_id: cloudId, + site_url: siteUrl, + oauth_secret_arn: oauthSecretArn, + installed_by_platform_user_id: cognitoSub, + installed_at: now, + updated_at: now, + status: 'active', + }, + })); + console.log(' ✓ Recorded tenant in registry'); + + // ─── Step 6: Webhook signing secret (per-tenant primary) ───────── + // + // Atlassian doesn't auto-generate webhook signing secrets — they're + // operator-chosen at webhook-create time in the Jira admin UI, and + // each tenant's webhook is configured independently with its OWN + // secret. So we always prompt for THIS tenant's secret and store it + // on the per-tenant OAuth bundle — the primary verification path. + // + // We deliberately do NOT copy an existing stack-wide secret into a + // new tenant's bundle (the old behavior): that would make tenant A's + // secret verify per-tenant for tenant B, and a holder of the + // stack-wide secret could then forge per-tenant-signed events for any + // tenant. The stack-wide secret is only seeded once, from the FIRST + // tenant's secret, as the single-tenant back-compat fallback. + 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 webhookSigningSecret = await promptSecret('Webhook signing secret: '); + if (!webhookSigningSecret) { + throw new CliError('Webhook signing secret is required.'); + } + + const merged: StoredJiraOauthToken = { + ...stored, + webhook_signing_secret: webhookSigningSecret, + updated_at: new Date().toISOString(), + }; + await upsertOauthSecret(sm, secretName, merged, cloudId); + console.log(' ✓ Stored signing secret on the per-tenant OAuth bundle'); + + // Seed the stack-wide fallback only if it has never been set, so a + // single-tenant install (no per-tenant routing) still verifies. Once + // a second tenant onboards, its secret is per-tenant only — the + // stack-wide secret stays pinned to the first tenant. + const stackWideAlreadyConfigured = await isWebhookSecretConfigured(sm, webhookSecretArn!); + if (stackWideAlreadyConfigured) { + console.log(' ✓ Stack-wide fallback already configured (leaving as-is)'); + } else { + await sm.send(new PutSecretValueCommand({ + SecretId: webhookSecretArn!, + SecretString: webhookSigningSecret, + })); + console.log(' ✓ Seeded stack-wide fallback for single-tenant back-compat'); + } + + // ─── Done ───────────────────────────────────────────────────────── + console.log(); + console.log('✅ Setup complete.'); + console.log(); + console.log('Next steps:'); + console.log(' 1. Map a Jira project to a GitHub repo:'); + console.log(` bgagent jira map ${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.)'); + console.log(` 3. Add the trigger label '${DEFAULT_LABEL_FILTER}' to a Jira issue in a mapped project.`); + }), + ); + + // ─── link ───────────────────────────────────────────────────────────────── + jira.addCommand( + new Command('link') + .description('Redeem an invite code from `bgagent jira invite-user` to link your Jira identity') + .argument('', 'One-time invite code') + .option('--output ', 'Output format (text or json)', 'text') + .action(async (code: string, opts) => { + const client = new ApiClient(); + + if (opts.output !== 'json') { + const preview = await client.jiraLink(code, { dryRun: true }); + const name = preview.jira_user_name || preview.jira_account_id; + const email = preview.jira_user_email ? ` (${preview.jira_user_email})` : ''; + const tenantLabel = preview.jira_site_url || preview.jira_cloud_id; + console.log('You are about to link the following Jira identity to YOUR ABCA account:'); + console.log(); + console.log(` Jira user: ${name}${email}`); + console.log(` Jira tenant: ${tenantLabel}`); + console.log(); + console.log('After linking, tasks triggered by this Jira user will be attributed to'); + console.log('your platform user (concurrency caps, billing, `bgagent list`).'); + console.log(); + const confirm = (await promptLine('Continue? [Y/n]')).trim().toLowerCase(); + if (confirm && confirm !== 'y' && confirm !== 'yes') { + console.log('Aborted. The invite code is still valid until it expires.'); + return; + } + } + + const result = await client.jiraLink(code); + if (opts.output === 'json') { + console.log(formatJson(result)); + } else { + console.log(); + console.log('✅ Jira account linked.'); + console.log(` Linked at: ${result.linked_at}`); + } + }), + ); + + // ─── map ────────────────────────────────────────────────────────────────── + jira.addCommand( + new Command('map') + .description('Map a Jira project to a GitHub repository (admin IAM required)') + .argument('', 'Atlassian tenant cloudId (UUID)') + .argument('', 'Jira project key (e.g. ENG)') + .requiredOption('--repo ', 'GitHub repository the mapped project should route tasks to') + .option('--label