Skip to content

Commit f136d89

Browse files
mayakostbgagentkrokokoisadeks
authored
feat(jira): Jira Cloud integration -- parity with Linear (#288) (#302)
* docs(guides): note supported Node version range in Quick Start prerequisites * feat(types): add 'jira' to ChannelSource union Phase 1 of Jira Cloud integration (#288). Extends the ChannelSource discriminant on both sides of the wire and updates the agent-side comment so the runtime knows 'jira' is a recognized channel value; no behavior changes yet. * feat(jira): add DDB table constructs for projects, users, workspaces Phase 2 of Jira Cloud integration (#288). Mirrors the Linear constructs file-for-file. Composite PKs use cloudId as the tenant prefix (`{cloudId}#{projectKey}`, `{cloudId}#{accountId}`) so the same project key or account id stays unambiguous across distinct Atlassian tenants. Tables are unwired until Phase 4 — JiraIntegration instantiates and grants them. * feat(jira): add webhook, processor, link Lambdas + shared helpers Phase 3 of Jira Cloud integration (#288). Mirrors Linear's adapter shape: per-tenant OAuth resolver (auth.atlassian.com), X-Hub-Signature HMAC verify with per-tenant + stack-wide fallback, REST-based feedback poster (ADF-wrapped, no reaction primitive — marker folded into text), and three Lambdas (webhook, processor, link). Non-trivial bit: the processor diffs `changelog.items[]` where `field === 'labels'` and tokenizes the space-separated `fromString` / `toString` to detect a label add — Atlassian's diff format differs from Linear's `updatedFrom.labelIds`. Includes a minimal ADF→markdown walker for issue descriptions. Handlers reference JIRA_* env vars set by the JiraIntegration construct in Phase 4; they don't deploy yet. * feat(jira): add JiraIntegration construct + stack wiring Phase 4 of Jira Cloud integration (#288). Mirrors LinearIntegration: 3 DDB tables, dedup table (8h TTL), 3 Lambdas (webhook/processor/link), API routes under /jira/*, per-tenant `bgagent-jira-oauth-*` IAM grants, cdk-nag suppressions. Stack wiring grants the agent runtime GetSecretValue on the per-tenant prefix and pipes the workspace registry table + Get/Put grant into the orchestrator (matches Linear's path for pre-container failure feedback). Synth confirms clean CloudFormation + no nag findings. * feat(jira): wire agent-side MCP + OAuth resolver for jira channel Phase 5 of Jira Cloud integration (#288). Refactors channel_mcp.py from a single-channel gate to a CHANNEL_MCP_BUILDERS dispatch dict so adding future channels stays one-entry. Adds resolve_jira_oauth_token() to config.py mirroring the Linear resolver — same race-handling, same fail-closed semantics; only differences are the endpoint (auth.atlassian.com, JSON body) and the env-var name (JIRA_API_TOKEN). Pipeline now dispatches to the right resolver based on channel_source. JIRA_MCP_URL is flagged in-source as needs-verification — Atlassian's Remote MCP may still be preview-gated; if so, fall back to a REST shim in a future jira_reactions.py module (Plan B). Tests: 6 new Jira test cases in test_channel_mcp.py; full agent suite remains green (825 passed). * feat(cli): add bgagent jira commands (app-template, setup, link, map) Phase 6 of Jira Cloud integration (#288). Minimal v1 surface (4 of 10 Linear subcommands), per scoping decision. Mirrors the Linear CLI shape where the contracts are similar: - jira-oauth.ts ports linear-oauth.ts. Atlassian's token endpoint takes JSON (Linear takes form-encoded). offline_access scope is required for a refresh_token. fetchAccessibleResources() resolves cloudId + siteUrl post-consent. - commands/jira.ts: app-template prints dev-console values; setup drives the OAuth dance + writes the per-tenant secret + registry row + webhook signing secret; link does dry-run preview UX; map writes the project → repo row. Deferred to follow-ups: add-workspace, update-webhook-secret, invite-user (with self-link picker), list-projects. * test(jira): add webhook, processor, and link handler tests Covers signature verify pass/fail, dedup, event filtering, label-add detection (create vs update changelog), and Cognito-authenticated linking. 56 tests, mirrors the Linear handler test surface. * docs(jira): add setup guide, ADR-014, and integration listings - docs/guides/JIRA_SETUP_GUIDE.md — OAuth 3LO app, scopes, webhook registration, label trigger, project mapping, troubleshooting - docs/decisions/ADR-014-jira-integration.md — Jira Cloud only, OAuth 3LO, label trigger, MCP outbound; documents the Jira-vs-Linear divergences - README, USER_GUIDE, ROADMAP — add Jira to channel listings - sync-starlight.mjs + astro.config.mjs — register the Jira guide mirror; regenerate Starlight content under docs/src/content/docs/ Completes the docs phase of #288. * test(jira): close build/coverage gaps for jira integration Bring `mise run build` green on the jira integration branch: - check-types-sync: allowlist JiraLinkResponse as CLI-only, matching SlackLinkResponse/LinearLinkResponse (link responses are inlined server-side; no CDK source-of-truth type) - channel_mcp.py: move Callable into a TYPE_CHECKING block (ruff TC003; safe under `from __future__ import annotations`) - agent.test.ts: bump expected DynamoDB table count 13 -> 17 for the four new Jira tables (project/user/workspace-registry/webhook-dedup) - test_config.py: cover resolve_jira_oauth_token (cache, fallback, refresh, concurrent-refresh, malformed/expiry paths); agent coverage 70.41% -> 72.91% - jira-oauth-resolver.test.ts: new suite (32 tests) mirroring the Linear resolver tests; clears the CDK statement/line/function/branch gates - jira.ts / jira-oauth.ts: ESLint --fix cosmetic edits (quote-props, redundant template literals) Tests: 294 CLI + 837 agent + 1896 CDK, all passing. * fix(jira): resolve cloudId from sole tenant when webhook omits it Jira webhooks created via the Settings → System → Webhooks UI do not include a top-level `cloudId` in their payload (only app/OAuth-registered dynamic webhooks do). Without it the processor can't resolve the tenant, so it dropped the event and never created a task — the inbound trigger silently failed for the common single-tenant, UI-webhook setup. Add a safe fallback: when `payload.cloudId` is absent, scan the workspace registry and use the sole `active` tenant. Deliberately refuses to guess when zero or multiple active tenants exist (returns undefined → event dropped), so the multi-tenant design is preserved — a multi-tenant operator must use a webhook that carries its own cloudId. `grantReadData` on the registry table already covers the Scan, so no IAM change is needed. Adds tests for: sole-tenant recovery (task created), empty registry (drop), and multiple active tenants (ambiguous → drop). * feat(jira): post issue progress comments via REST shim Jira-origin tasks now comment on the originating issue at start ("🤖 picked up…") and on completion ("✅ finished — PR: <url>" / "❌ …"), matching the Linear integration's progress UX. Why a REST shim instead of the Atlassian Remote MCP: the hosted MCP (mcp.atlassian.com) requires an interactive, browser-based OAuth 2.1 flow with dynamic client registration — it does NOT accept the stored Jira REST OAuth token as a Bearer header, so it fails to connect from a headless agent ("claude mcp list" → Failed to connect; no mcp__jira-server__* tools load). The Jira REST API accepts the same stored token (it carries write:jira-work), so comments go via POST /rest/api/3/issue/{key}/comment on the cross-region api.atlassian.com/ex/jira/{cloudId} base. - New `jira_reactions.py`: gated by channel_source=='jira' + required metadata; swallows all network/auth errors (comments are advisory, never gate the pipeline); auth circuit-breaker mirrors linear_reactions. - Wired into pipeline.py at task start, normal finish (with PR url), and the crash path — parallel to the existing Linear reaction hooks. - prompt_builder: Jira tasks now get NO MCP-comment addendum (the earlier Linear-only gate already skipped them); instructing the agent to use the non-loading MCP tools would just waste turns. Comments are out-of-band. Adds test_jira_reactions.py (gate, ADF body, success/failure/PR variants, error-swallowing, auth circuit breaker) and channel-addendum tests. * fix(jira): repair botched merge in test_config.py imports The 'Merge branch main' commit (c84cc66) left an invalid import block: a missing comma after PR_WORKFLOW_IDS (syntax error) plus a stale PR_TASK_TYPES import that main's #248 removed from config.py. ruff rejected the file, aborting the agentcore build. Drop the orphaned PR_TASK_TYPES import and fix the comma. * fix(jira): type _config base dict so ty accepts TaskConfig(**base) test_prompts.py:_config built base from a homogeneous str literal, so ty inferred dict[str, str] and rejected the spread into TaskConfig's bool/int/list fields (17 invalid-argument-type errors). Annotate base as dict[str, Any], matching the existing helper in test_runner.py. This failure was previously masked by the lint syntax error that aborted the build before typecheck ran. * fix(jira): apply ruff format and resync stale docs mirrors Three more issues that were masked behind the earlier lint/typecheck failures, all surfaced once the build progressed to its 'fail on mutation' gate: - ruff format reflowed two long boolean/string lines in jira_reactions.py and test_jira_reactions.py that were committed unformatted. - USER_GUIDE.md still referenced the retired `pr_review` task_type on the intro 'For example' line (a #248 merge leftover); the rest of the guide uses `coding/pr-review-v1`. Fixed the source and regenerated the Starlight mirror (using/Overview.md). - Quick-start mirror was missing the Node.js prerequisite line present in the QUICK_START.md source; docs-sync adds it. Full `mise run build` now completes with no working-tree mutation. * fix(jira): address PR #302 review — security binding, token refresh, ADR/docs Blocking (krokoko): - Multi-tenant signature binding: webhook receiver flags stack-wide verification to the processor, which then ignores the body cloudId and binds to the sole active tenant (drops when ambiguous). CLI no longer mirrors the stack-wide secret into new per-tenant bundles; stack-wide is seeded once from the first tenant. Missing-timestamp replay skip is logged. - Renumber ADR-014 -> ADR-015 (collides with workflow-driven-tasks); rewrite to the implemented REST-outbound reality (status accepted), correct dedup key, binding + refresh-ownership sections. Reconcile JIRA_SETUP_GUIDE, USER_GUIDE ("six ways"), ROADMAP, channel_mcp.py (placeholder + in-band log), jira-webhook-processor.ts, jira-integration.ts, agent.ts. - Fix non-existent CLI command in feedback: onboard-project -> map <cloud-id> <project-key> --repo (processor + CLI next-steps hint). - Implement notifyJiraOnConcurrencyCap (Linear parity) so the orchestrator IAM grant is used and Jira users aren't silently dropped on the cap. Significant (ayushtr): - Agent never refreshes the Jira token (Atlassian rotates refresh_tokens; agent has GetSecretValue only). Use the Lambda-written token verbatim and fail closed when expiring; Lambda path owns all refreshes. - ADF media nodes (external images) now render to markdown so attachment extraction works; ADF->markdown computed once and reused. Minor: one-sided clock-skew-tolerant timestamp freshness, base64-body guard, replay window 24h->1h, resolveSoleTenantCloudId Scan comment. Tests: agent no-refresh/fail-closed suite, multi-tenant binding tests, base64/missing-timestamp/stack-wide-flag webhook tests, ADF media-node test, notifyJiraOnConcurrencyCap parity suite. Docs mirrors regenerated. Relates to #288 * fix(docs): repair botched main-merge in sync-starlight.mjs The 'Merge branch main' commit (0f47343) dropped the closing ');' on the Jira mirrorMarkdownFile() call where it interleaved with main's new 'Deploy preview screenshots' mirror block, producing a SyntaxError that broke the //docs:sync build step (and thus the whole build job). * fix(cdk): repair botched main-merge in agent.ts The 'Merge branch main' (0f47343) dropped the closing '});' on the JiraWorkspaceRegistryTableName CfnOutput where main's new GitHubScreenshotIntegration block was spliced in, cascading into ~40 TS1005 errors and breaking //cdk:compile. * fix(cdk): correct DynamoDB table-count assertion + import ordering Collapse the duplicated 17/14 table-count assertions left by an earlier botched main-merge into a single correct count of 18 (17 enumerated tables incl. the 4 Jira tables, plus github-webhook-dedup from GitHubScreenshotIntegration). Also fix import ordering (github before jira) flagged by eslint import/order. * fix(jira): post Lambda-side feedback to api.atlassian.com gateway base The Lambda-side Jira feedback helper built its REST URL from the tenant site host (`*.atlassian.net`), but the per-tenant 3LO token is minted with `audience=api.atlassian.com` and is only valid against the gateway base `https://api.atlassian.com/ex/jira/{cloudId}/rest/...`. Every pre-container feedback comment (unmapped project, unlinked user, concurrency-cap rejection, createTaskCore non-201) therefore 401'd silently, since these comments are best-effort and swallow errors. Build the URL from `cloudId` (already on `JiraFeedbackContext`) against the gateway base — matching the agent-side path in jira_reactions.py — and drop the unused `siteUrl`. Correct the misleading jira-workspace-registry-table comment that called site_url a "REST base". Adds jira-feedback.test.ts asserting the request host is api.atlassian.com (never atlassian.net), plus encoding and never-throws coverage. * fix(jira): extract magic numbers into named constants Resolves @typescript-eslint/no-magic-numbers eslint errors that were failing the cdk:eslint build step. Mirrors the named-constant convention already used by the Linear integration siblings. * fix(jira): address review — HMAC empty-secret guard, verify tests, doc fixes Resolves the blocking items and nits from the PR #302 review. Blocking: - B1: route the Jira webhook signing secret through isUsableHmacSecret in both getJiraSecret (on fetch) and verifyJiraSignature (defense-in-depth), matching the Linear/Slack/GitHub invariant. A whitespace-only per-tenant secret was previously truthy and made HMAC(' ', body) forgeable. - B2: add cdk/test/handlers/shared/jira-verify.test.ts (30 cases) covering verifyJiraRequestForTenant's four outcomes (verified/mismatch/revoked/ no-per-tenant-secret), the strict-lookup rethrow, verifyJiraRequest rotation-refetch, empty/whitespace-secret rejection, and the one-sided timestamp freshness window (stale / far-future / within-skew). Mirrors linear-verify.test.ts; the multi-tenant trust boundary had no coverage. - B3: rewrite the JiraWorkspaceRegistryTable docstring (and the matching jira-integration.ts comments) to describe the real oauth_secret_arn / Secrets Manager resolution path instead of the never-implemented provider_name / AgentCore Identity model. Nits: - Reword the stale "MCP token" comment in jira-webhook-processor.ts to the REST-outbound reality. - Add ts-/py-silent-success-masking nosemgrep justifications to the best-effort swallows in jira-verify.ts, jira-oauth-resolver.ts (3), and jira_reactions.py, matching the Linear annotations. - On a refresh PutSecretValue failure, no longer cache the rotated token (SM holds a stale refresh_token); invalidate the cache and escalate the log so the breakage surfaces promptly. - URL-encode the cloudId path segment in jira-feedback.ts and both cloud_id and issue_key in jira_reactions.py (defense-in-depth; issueKey was already encoded on the TS side). - Re-extract the inlined 512 into WEBHOOK_PROCESSOR_MEMORY_MB (AI007). mise run build green: 2177 CDK + 355 CLI + 1090 agent tests passing. Relates to #288 --------- Co-authored-by: bgagent <bgagent@noreply.github.com> Co-authored-by: Alain Krok <alkrok@amazon.com> Co-authored-by: Sphia Sadek <isadeks@gmail.com>
1 parent 6844256 commit f136d89

52 files changed

Lines changed: 7718 additions & 48 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
## What is ABCA
2121

22-
**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.
22+
**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.
2323

2424
## Why it matters
2525

@@ -31,7 +31,7 @@
3131

3232
## The Use Case
3333

34-
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.
34+
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.
3535

3636
Key characteristics:
3737

agent/src/channel_mcp.py

Lines changed: 96 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,46 @@
11
"""Channel-specific MCP configuration for the agent container.
22
3-
For Linear-origin tasks we write (or merge into) ``.mcp.json`` in the cloned
4-
repo ``cwd`` so the Claude Agent SDK — configured with
5-
``setting_sources=["project"]`` — picks up the Linear MCP at session start
6-
and exposes ``mcp__linear-server__*`` tools.
3+
For inbound channel sources that have a hosted MCP we write (or merge into)
4+
``.mcp.json`` in the cloned repo ``cwd`` so the Claude Agent SDK — configured
5+
with ``setting_sources=["project"]`` — picks up the channel MCP at session
6+
start and exposes the server's tools.
7+
8+
Currently wired channels:
9+
- ``linear`` → Linear hosted MCP (``mcp__linear-server__*`` tools) — functional.
10+
- ``jira`` → Atlassian Remote MCP entry — a NON-FUNCTIONAL placeholder. It
11+
is written for forward-compatibility but cannot connect from a headless
12+
agent (interactive OAuth 2.1 only); live outbound Jira comments go through
13+
the REST shim in ``jira_reactions.py``. See ``JIRA_MCP_URL`` below + ADR-015.
714
815
For all other channel sources this is a no-op: no MCP is written, and the
9-
SDK sees no Linear tools. That's the gate keeping Slack/API/webhook tasks
10-
from touching Linear.
16+
SDK sees no channel-specific tools.
1117
12-
See: cdk/src/handlers/linear-webhook-processor.ts (inbound), runner.py
13-
(SDK invocation), plans at ~/.claude/plans/linear-mcp-findings.md.
18+
See: cdk/src/handlers/{linear,jira}-webhook-processor.ts (inbound),
19+
runner.py (SDK invocation).
1420
"""
1521

1622
from __future__ import annotations
1723

1824
import json
1925
import os
20-
from typing import Any
26+
from typing import TYPE_CHECKING, Any
2127

2228
from shell import log
2329

30+
if TYPE_CHECKING:
31+
from collections.abc import Callable
32+
33+
# ─── Linear ──────────────────────────────────────────────────────────────────
34+
2435
#: Linear MCP endpoint — hosted by Linear, Streamable HTTP transport.
2536
LINEAR_MCP_URL = "https://mcp.linear.app/mcp"
2637

2738
#: Key name inside ``mcpServers``. Tools surface as
28-
#: ``mcp__linear-server__*`` in the Agent SDK (verified in findings).
39+
#: ``mcp__linear-server__*`` in the Agent SDK.
2940
LINEAR_MCP_SERVER_KEY = "linear-server"
3041

3142
#: Env var name the MCP server entry reads via ``${LINEAR_API_TOKEN}``
32-
#: placeholder expansion. Populated from ``LinearApiTokenSecret`` by run.sh.
43+
#: placeholder expansion. Populated from the OAuth secret by config.py.
3344
LINEAR_API_TOKEN_ENV = "LINEAR_API_TOKEN" # noqa: S105 — env var *name*, not a secret value
3445

3546

@@ -44,11 +55,62 @@ def _linear_server_entry() -> dict[str, Any]:
4455
}
4556

4657

58+
# ─── Jira (Atlassian Remote MCP — NON-FUNCTIONAL PLACEHOLDER) ────────────────
59+
60+
#: Atlassian Remote MCP endpoint — Streamable HTTP transport.
61+
#:
62+
#: IMPORTANT: this entry does NOT work from a headless agent and is retained
63+
#: only as a forward-looking placeholder. The hosted Atlassian MCP requires an
64+
#: interactive, browser-based OAuth 2.1 flow with dynamic client registration
65+
#: and will NOT accept the stored REST OAuth token as a Bearer header, so it
66+
#: fails to connect in the runtime (``claude mcp list`` → "Failed to connect").
67+
#:
68+
#: The LIVE outbound path is the REST shim in ``agent/src/jira_reactions.py``
69+
#: (the "Plan B" that became Plan A), which posts comments via the Jira REST
70+
#: v3 API using the same stored OAuth token. See ADR-015 and
71+
#: ``agent/src/prompt_builder.py``. If Atlassian ever ships a token-compatible
72+
#: MCP, this entry can be promoted and the REST shim retired.
73+
JIRA_MCP_URL = "https://mcp.atlassian.com/v1/sse"
74+
75+
#: Key name inside ``mcpServers``. Tools surface as ``mcp__jira-server__*``
76+
#: in the Agent SDK. If this changes the agent prompt's channel addendum
77+
#: must be updated in lockstep.
78+
JIRA_MCP_SERVER_KEY = "jira-server"
79+
80+
#: Env var name the Jira MCP server entry reads via ``${JIRA_API_TOKEN}``
81+
#: placeholder expansion. Populated from the per-tenant OAuth secret by
82+
#: config.resolve_jira_oauth_token.
83+
JIRA_API_TOKEN_ENV = "JIRA_API_TOKEN" # noqa: S105 — env var *name*, not a secret value
84+
85+
86+
def _jira_server_entry() -> dict[str, Any]:
87+
"""Build the `mcpServers` entry for Atlassian's Remote MCP."""
88+
return {
89+
"type": "http",
90+
"url": JIRA_MCP_URL,
91+
"headers": {
92+
"Authorization": f"Bearer ${{{JIRA_API_TOKEN_ENV}}}",
93+
},
94+
}
95+
96+
97+
# ─── Dispatch ────────────────────────────────────────────────────────────────
98+
99+
#: Per-channel ``mcpServers`` entry builder. The channel_source values mirror
100+
#: ``ChannelSource`` in cdk/src/handlers/shared/types.ts. Sources that don't
101+
#: have a hosted MCP (api, webhook, slack) intentionally have no entry here —
102+
#: the gate in ``configure_channel_mcp`` short-circuits on missing keys.
103+
CHANNEL_MCP_BUILDERS: dict[str, tuple[str, Callable[[], dict[str, Any]]]] = {
104+
"linear": (LINEAR_MCP_SERVER_KEY, _linear_server_entry),
105+
"jira": (JIRA_MCP_SERVER_KEY, _jira_server_entry),
106+
}
107+
108+
47109
def _read_existing_mcp_config(path: str) -> dict[str, Any]:
48110
"""Return the parsed .mcp.json at ``path``, or an empty dict if absent/invalid.
49111
50112
Malformed JSON is logged and treated as absent — we prefer to overlay a
51-
valid Linear entry than to crash the agent because a user committed a
113+
valid channel entry than to crash the agent because a user committed a
52114
broken .mcp.json to their repo.
53115
"""
54116
if not os.path.isfile(path):
@@ -67,23 +129,26 @@ def _read_existing_mcp_config(path: str) -> dict[str, Any]:
67129
def configure_channel_mcp(repo_dir: str, channel_source: str) -> bool:
68130
"""Write or merge a channel-specific ``.mcp.json`` into ``repo_dir``.
69131
70-
Gated on ``channel_source``:
71-
* ``'linear'`` → ensure the ``linear-server`` entry is present in
132+
Looks up ``channel_source`` in :data:`CHANNEL_MCP_BUILDERS`:
133+
* present → ensure the corresponding ``mcpServers`` entry is in
72134
``.mcp.json`` (merges into any existing config without clobbering
73135
other servers). Returns True.
74-
* anything else → no-op. Returns False.
136+
* absent → no-op. Returns False.
75137
76138
Args:
77139
repo_dir: the cloned-repo working directory the SDK will use as ``cwd``.
78140
channel_source: inbound channel (``TaskConfig.channel_source``).
79141
80142
Returns:
81-
True if a Linear MCP entry was (re)written into ``repo_dir/.mcp.json``,
82-
False otherwise (including any non-Linear channel or missing repo_dir).
143+
True if a channel MCP entry was (re)written, False otherwise (channel
144+
unmapped, missing repo_dir, or write failure).
83145
"""
84-
if channel_source != "linear":
146+
builder_entry = CHANNEL_MCP_BUILDERS.get(channel_source)
147+
if builder_entry is None:
85148
return False
86149

150+
server_key, build_entry = builder_entry
151+
87152
if not repo_dir or not os.path.isdir(repo_dir):
88153
log("WARN", f"configure_channel_mcp: repo_dir missing or not a directory: {repo_dir!r}")
89154
return False
@@ -94,19 +159,29 @@ def configure_channel_mcp(repo_dir: str, channel_source: str) -> bool:
94159
servers = config.get("mcpServers")
95160
if not isinstance(servers, dict):
96161
servers = {}
97-
servers[LINEAR_MCP_SERVER_KEY] = _linear_server_entry()
162+
servers[server_key] = build_entry()
98163
config["mcpServers"] = servers
99164

100165
try:
101166
with open(mcp_path, "w", encoding="utf-8") as f:
102167
json.dump(config, f, indent=2)
103168
f.write("\n")
104169
except OSError as e:
105-
log("ERROR", f"Failed to write Linear MCP config to {mcp_path}: {e}")
170+
log("ERROR", f"Failed to write {channel_source} MCP config to {mcp_path}: {e}")
106171
return False
107172

108173
log(
109174
"TASK",
110-
f"Linear MCP configured at {mcp_path} (server key: {LINEAR_MCP_SERVER_KEY})",
175+
f"{channel_source} MCP configured at {mcp_path} (server key: {server_key})",
111176
)
177+
if channel_source == "jira":
178+
# The Jira MCP entry is a non-functional placeholder (see JIRA_MCP_URL
179+
# docstring + ADR-015). Log it in-band so a "Failed to connect" line in
180+
# the agent logs isn't mistaken for the cause of a missing comment —
181+
# the live outbound path is the REST shim in jira_reactions.py.
182+
log(
183+
"TASK",
184+
"jira MCP entry is a placeholder and is EXPECTED to fail to connect; "
185+
"outbound Jira comments use the REST shim (jira_reactions.py), not MCP",
186+
)
112187
return True

agent/src/config.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,123 @@ def _refresh(current: dict) -> dict | None:
330330
return access
331331

332332

333+
def resolve_jira_oauth_token(channel_metadata: dict[str, str] | None = None) -> str:
334+
"""Resolve the Jira Cloud OAuth access token from Secrets Manager.
335+
336+
The orchestrator stamps ``jira_oauth_secret_arn`` into the task
337+
record's ``channel_metadata`` at task-creation time. We fetch the
338+
per-tenant secret, parse the token JSON, and cache the access_token in
339+
``JIRA_API_TOKEN`` so the agent-side Jira REST calls
340+
(``jira_reactions``) can authorize.
341+
342+
**The agent never refreshes the token.** Unlike Linear, Atlassian
343+
*rotates the refresh_token on every use* — a successful refresh
344+
invalidates the stored refresh_token and returns a new one. The agent
345+
runtime has ``secretsmanager:GetSecretValue`` ONLY (no ``PutSecretValue``;
346+
a compromised agent must not be able to overwrite any tenant's OAuth
347+
bundle), so it cannot persist the rotated token. If the agent refreshed,
348+
it would consume the stored refresh_token, keep the replacement only in
349+
memory for this one task, and leave Secrets Manager holding a dead
350+
refresh_token — the next Lambda/agent resolve would get ``invalid_grant``
351+
and the tenant would require re-onboarding. So we deliberately do NOT
352+
refresh here: the trusted Lambda path (``jira-oauth-resolver.ts``, which
353+
has ``PutSecretValue``) owns all refreshes, and the agent uses whatever
354+
access_token the Lambdas have most-recently written.
355+
356+
If the stored token is already expiring/expired, we fail closed — return
357+
an empty string and let the advisory Jira comments no-op. The
358+
orchestrator resolves (and refreshes) the token just before starting the
359+
session, so in practice the agent reads a freshly-written token with a
360+
full lifetime ahead of it.
361+
362+
For local development, a pre-set ``JIRA_API_TOKEN`` env var
363+
short-circuits the lookup so the agent can run outside the runtime.
364+
365+
This function is only called when ``channel_source == 'jira'``.
366+
"""
367+
cached = os.environ.get("JIRA_API_TOKEN", "")
368+
if cached:
369+
return cached
370+
371+
secret_arn = ""
372+
if channel_metadata:
373+
secret_arn = channel_metadata.get("jira_oauth_secret_arn", "")
374+
if not secret_arn:
375+
secret_arn = os.environ.get("JIRA_OAUTH_SECRET_ARN", "")
376+
if not secret_arn:
377+
return ""
378+
379+
region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
380+
if not region:
381+
log("WARN", "resolve_jira_oauth_token: AWS_REGION not set; cannot resolve token")
382+
return ""
383+
384+
try:
385+
import json
386+
from datetime import datetime
387+
388+
import boto3
389+
from botocore.exceptions import BotoCoreError, ClientError
390+
except ImportError as e:
391+
log("WARN", f"resolve_jira_oauth_token: boto3 unavailable ({e}); skipping")
392+
return ""
393+
394+
sm = boto3.client("secretsmanager", region_name=region)
395+
396+
def _fetch_token() -> dict | None:
397+
resp = sm.get_secret_value(SecretId=secret_arn)
398+
try:
399+
return json.loads(resp["SecretString"])
400+
except (json.JSONDecodeError, KeyError, TypeError) as e:
401+
log(
402+
"ERROR",
403+
f"resolve_jira_oauth_token: secret '{secret_arn}' is not valid JSON "
404+
f"({type(e).__name__}: {e}); tenant requires re-onboarding",
405+
)
406+
return None
407+
408+
def _is_expiring(expires_at_iso: str, threshold_seconds: int = 60) -> bool:
409+
try:
410+
expiry = datetime.fromisoformat(expires_at_iso.replace("Z", "+00:00"))
411+
except ValueError:
412+
log(
413+
"WARN",
414+
f"_is_expiring: malformed expires_at '{expires_at_iso}'; treating as expiring",
415+
)
416+
return True
417+
return (expiry - datetime.now(UTC)).total_seconds() < threshold_seconds
418+
419+
try:
420+
token_obj = _fetch_token()
421+
except (ClientError, BotoCoreError) as e:
422+
code = ""
423+
if hasattr(e, "response"):
424+
code = getattr(e, "response", {}).get("Error", {}).get("Code", "") or ""
425+
is_hard_failure = code in ("AccessDeniedException", "ResourceNotFoundException")
426+
severity = "ERROR" if is_hard_failure else "WARN"
427+
log(severity, f"resolve_jira_oauth_token failed: {type(e).__name__}: {e}")
428+
return ""
429+
if token_obj is None:
430+
return ""
431+
432+
# Fail closed if the stored token is expiring — the agent cannot refresh
433+
# without burning Atlassian's rotating refresh_token (see docstring). The
434+
# Lambda path owns refresh; advisory Jira comments simply no-op here.
435+
if _is_expiring(token_obj.get("expires_at", "")):
436+
log(
437+
"WARN",
438+
"resolve_jira_oauth_token: stored token is expiring and the agent does not "
439+
"refresh (Atlassian rotates refresh_tokens; agent lacks PutSecretValue). "
440+
"Failing closed — Jira comments will be skipped for this task.",
441+
)
442+
return ""
443+
444+
access = token_obj.get("access_token", "")
445+
if access:
446+
os.environ["JIRA_API_TOKEN"] = access
447+
return access
448+
449+
333450
def build_config(
334451
repo_url: str = "",
335452
task_description: str = "",

0 commit comments

Comments
 (0)