Skip to content

Commit 7c7ba9e

Browse files
Merge branch 'main' into github-actions/upgrade-main
2 parents 05e5ddd + 65e96a8 commit 7c7ba9e

29 files changed

Lines changed: 1463 additions & 156 deletions

agent/src/jira_reactions.py

Lines changed: 17 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
"""Jira issue-comment helper for Jira-origin tasks.
22
3-
Posts a "starting" comment on the originating Jira issue at task start and a
4-
terminal "succeeded / failed (+ PR link)" comment at the end — the Jira
5-
analogue of ``linear_reactions`` (Linear uses emoji reactions; Jira's REST
6-
API has no lightweight reaction primitive, so comments are the right tool).
3+
Posts a "starting" comment on the originating Jira issue at task start — the
4+
Jira analogue of ``linear_reactions`` (Linear uses emoji reactions; Jira's
5+
REST API has no lightweight reaction primitive, so comments are the right
6+
tool).
7+
8+
The *terminal* status comment is NOT posted from here: since issue #573 the
9+
deterministic fan-out plane (``cdk/src/handlers/fanout-task-events.ts``
10+
``dispatchToJira``) owns it, so it carries cost/turns/duration and fires even
11+
when this agent crashes before completing. This module only owns the start
12+
comment.
713
814
Why a direct REST call instead of MCP: Atlassian's Remote MCP
915
(``mcp.atlassian.com``) requires an interactive, browser-based OAuth 2.1
@@ -182,32 +188,13 @@ def comment_task_started(
182188
log("TASK", f"jira_reactions: comment_task_started issue={issue_key} ok={ok}")
183189

184190

185-
def comment_task_finished(
186-
channel_source: str,
187-
channel_metadata: dict[str, str] | None,
188-
success: bool,
189-
pr_url: str | None = None,
190-
) -> None:
191-
"""Post a terminal status comment on the Jira issue. No-op for non-Jira tasks."""
192-
target = _enabled(channel_source, channel_metadata)
193-
if not target:
194-
return
195-
cloud_id, issue_key = target
196-
if success:
197-
text = "✅ ABCA finished this task."
198-
if pr_url:
199-
text += f" Pull request: {pr_url}"
200-
else:
201-
text += " No pull request was opened."
202-
else:
203-
text = "❌ ABCA could not complete this task. Check the agent logs for details."
204-
if pr_url:
205-
text += f" A pull request was opened anyway: {pr_url}"
206-
ok = _post_comment(cloud_id, issue_key, text)
207-
log(
208-
"TASK",
209-
f"jira_reactions: comment_task_finished issue={issue_key} success={success} ok={ok}",
210-
)
191+
# NOTE: there is deliberately no ``comment_task_finished`` here. Since issue
192+
# #573 the deterministic fan-out plane
193+
# (``cdk/src/handlers/fanout-task-events.ts`` ``dispatchToJira``) owns the Jira
194+
# terminal comment — it carries cost/turns/duration and, crucially, fires even
195+
# when the agent crashes before completing (max-turns, OOM). The agent only
196+
# posts the *start* comment (``comment_task_started`` above); posting a terminal
197+
# comment here too would double-comment on the issue.
211198

212199

213200
def _reset_state_for_testing() -> None:

agent/src/pipeline.py

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
resolve_linear_api_token,
2424
)
2525
from context import assemble_prompt, fetch_github_issue
26-
from jira_reactions import comment_task_finished, comment_task_started
26+
from jira_reactions import comment_task_started
2727
from linear_reactions import react_task_finished, react_task_started
2828
from models import AgentResult, HydratedContext, RepoSetup, TaskConfig, TaskResult
2929
from observability import current_otel_trace_id, task_span
@@ -1106,14 +1106,14 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None:
11061106
started_reaction_id=linear_eyes_reaction_id,
11071107
)
11081108

1109-
# Terminal status comment on the Jira issue (REST shim, with the
1110-
# PR link when one was opened). No-op for non-Jira tasks.
1111-
comment_task_finished(
1112-
config.channel_source,
1113-
config.channel_metadata,
1114-
success=(overall_status == "success"),
1115-
pr_url=pr_url,
1116-
)
1109+
# NOTE: the terminal status comment on the Jira issue is NOT posted
1110+
# here. Since issue #573 the deterministic fan-out plane
1111+
# (``cdk/src/handlers/fanout-task-events.ts`` ``dispatchToJira``)
1112+
# owns the Jira final-status comment — it carries cost/turns/
1113+
# duration and, crucially, fires even if this agent crashes before
1114+
# reaching this point (max-turns, OOM). Posting here too would
1115+
# double-comment. The agent still posts the *start* comment
1116+
# (``comment_task_started`` above) for in-flight progress.
11171117

11181118
# --trace trajectory S3 upload (design §10.1). Runs AFTER
11191119
# post-hooks but BEFORE ``write_terminal`` so the resulting
@@ -1244,14 +1244,11 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None:
12441244
success=False,
12451245
started_reaction_id=linear_eyes_reaction_id,
12461246
)
1247-
# Best-effort failure comment on the Jira issue. No-op for
1248-
# non-Jira tasks; network failures are swallowed.
1249-
comment_task_finished(
1250-
config.channel_source,
1251-
config.channel_metadata,
1252-
success=False,
1253-
pr_url=None,
1254-
)
1247+
# NOTE: no Jira failure comment here — the fan-out plane's
1248+
# ``dispatchToJira`` (issue #573) owns the Jira terminal comment
1249+
# and fires on the platform side even when this crash path runs,
1250+
# so posting here would double-comment. (Contrast the Linear ❌
1251+
# reaction above, which the fan-out plane does not replicate.)
12551252
raise
12561253

12571254

agent/tests/test_jira_reactions.py

Lines changed: 9 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import pytest
88

99
import jira_reactions
10-
from jira_reactions import comment_task_finished, comment_task_started
10+
from jira_reactions import comment_task_started
1111

1212
JIRA_META = {"jira_cloud_id": "cloud-1", "jira_issue_key": "KAN-1"}
1313

@@ -36,7 +36,6 @@ def test_non_jira_source_is_noop(self, monkeypatch):
3636
monkeypatch.setenv("JIRA_API_TOKEN", "jira_at")
3737
with patch("jira_reactions.requests.post") as post:
3838
comment_task_started("linear", JIRA_META)
39-
comment_task_finished("linear", JIRA_META, success=True)
4039
post.assert_not_called()
4140

4241
def test_empty_metadata_is_noop(self, monkeypatch):
@@ -74,31 +73,14 @@ def test_skips_when_token_missing(self, monkeypatch):
7473
post.assert_not_called()
7574

7675

77-
class TestFinishComment:
78-
def test_success_with_pr_includes_pr_url(self, monkeypatch):
79-
monkeypatch.setenv("JIRA_API_TOKEN", "jira_at")
80-
with patch("jira_reactions.requests.post", return_value=_resp(201)) as post:
81-
comment_task_finished(
82-
"jira", JIRA_META, success=True, pr_url="https://github.com/o/r/pull/7"
83-
)
84-
text = post.call_args[1]["json"]["body"]["content"][0]["content"][0]["text"]
85-
assert "✅" in text
86-
assert "https://github.com/o/r/pull/7" in text
87-
88-
def test_success_without_pr_notes_no_pr(self, monkeypatch):
89-
monkeypatch.setenv("JIRA_API_TOKEN", "jira_at")
90-
with patch("jira_reactions.requests.post", return_value=_resp(201)) as post:
91-
comment_task_finished("jira", JIRA_META, success=True, pr_url=None)
92-
text = post.call_args[1]["json"]["body"]["content"][0]["content"][0]["text"]
93-
assert "✅" in text
94-
assert "No pull request" in text
76+
class TestTerminalCommentDemoted:
77+
"""Since issue #573 the fan-out plane (``dispatchToJira``) owns the Jira
78+
terminal comment, so the agent no longer exposes ``comment_task_finished``.
79+
This pins the demotion so a future refactor can't silently re-introduce a
80+
duplicate terminal comment on the agent side."""
9581

96-
def test_failure_comment(self, monkeypatch):
97-
monkeypatch.setenv("JIRA_API_TOKEN", "jira_at")
98-
with patch("jira_reactions.requests.post", return_value=_resp(201)) as post:
99-
comment_task_finished("jira", JIRA_META, success=False, pr_url=None)
100-
text = post.call_args[1]["json"]["body"]["content"][0]["content"][0]["text"]
101-
assert "❌" in text
82+
def test_comment_task_finished_is_gone(self):
83+
assert not hasattr(jira_reactions, "comment_task_finished")
10284

10385

10486
class TestFailureIsSwallowed:
@@ -107,7 +89,6 @@ def test_http_500_does_not_raise(self, monkeypatch):
10789
with patch("jira_reactions.requests.post", return_value=_resp(500, "boom")):
10890
# Must not raise.
10991
comment_task_started("jira", JIRA_META)
110-
comment_task_finished("jira", JIRA_META, success=True)
11192

11293
def test_request_exception_does_not_raise(self, monkeypatch):
11394
import requests
@@ -130,7 +111,7 @@ def test_opens_after_threshold_consecutive_401s(self, monkeypatch):
130111
calls_after_open = post.call_count
131112
# Further calls short-circuit without hitting the network.
132113
comment_task_started("jira", JIRA_META)
133-
comment_task_finished("jira", JIRA_META, success=True)
114+
comment_task_started("jira", JIRA_META)
134115
assert post.call_count == calls_after_open
135116

136117
def test_2xx_resets_failure_counter(self, monkeypatch):

cdk/bootstrap/bootstrap-template.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -930,6 +930,7 @@ Resources:
930930
- route53resolver:UpdateFirewallDomains
931931
- route53resolver:AssociateFirewallRuleGroup
932932
- route53resolver:DisassociateFirewallRuleGroup
933+
- route53resolver:UpdateFirewallRuleGroupAssociation
933934
- route53resolver:GetFirewallRuleGroupAssociation
934935
- route53resolver:ListFirewallRuleGroupAssociations
935936
- route53resolver:UpdateFirewallConfig
@@ -1191,6 +1192,7 @@ Resources:
11911192
- cloudwatch:TagResource
11921193
- cloudwatch:UntagResource
11931194
- logs:CreateDelivery
1195+
- logs:UpdateDeliveryConfiguration
11941196
- logs:DescribeDeliveries
11951197
- logs:GetDelivery
11961198
- logs:GetDeliveryDestination

cdk/bootstrap/policies/infrastructure.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@
153153
"route53resolver:UpdateFirewallDomains",
154154
"route53resolver:AssociateFirewallRuleGroup",
155155
"route53resolver:DisassociateFirewallRuleGroup",
156+
"route53resolver:UpdateFirewallRuleGroupAssociation",
156157
"route53resolver:GetFirewallRuleGroupAssociation",
157158
"route53resolver:ListFirewallRuleGroupAssociations",
158159
"route53resolver:UpdateFirewallConfig",

cdk/bootstrap/policies/observability.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"cloudwatch:TagResource",
4545
"cloudwatch:UntagResource",
4646
"logs:CreateDelivery",
47+
"logs:UpdateDeliveryConfiguration",
4748
"logs:DescribeDeliveries",
4849
"logs:GetDelivery",
4950
"logs:GetDeliveryDestination",

cdk/src/bootstrap/policies/infrastructure.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ export function infrastructurePolicy(): iam.PolicyDocument {
190190
'route53resolver:UpdateFirewallDomains',
191191
'route53resolver:AssociateFirewallRuleGroup',
192192
'route53resolver:DisassociateFirewallRuleGroup',
193+
'route53resolver:UpdateFirewallRuleGroupAssociation',
193194
'route53resolver:GetFirewallRuleGroupAssociation',
194195
'route53resolver:ListFirewallRuleGroupAssociations',
195196
'route53resolver:UpdateFirewallConfig',

cdk/src/bootstrap/policies/observability.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export function observabilityPolicy(): iam.PolicyDocument {
7979
'cloudwatch:TagResource',
8080
'cloudwatch:UntagResource',
8181
'logs:CreateDelivery',
82+
'logs:UpdateDeliveryConfiguration',
8283
'logs:DescribeDeliveries',
8384
'logs:GetDelivery',
8485
'logs:GetDeliveryDestination',

cdk/src/constructs/fanout-consumer.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,27 @@ export interface FanOutConsumerProps {
106106
*/
107107
readonly linearOauthSecretArnPattern?: string;
108108

109+
/**
110+
* JiraWorkspaceRegistryTable — the Jira dispatcher reads this to resolve
111+
* per-tenant OAuth tokens at comment-post time (issue #573). Optional:
112+
* when omitted, the dispatcher logs and skips so a deployment without
113+
* Jira onboarding doesn't accumulate dangling IAM grants. Mirrors
114+
* ``linearWorkspaceRegistryTable``.
115+
*/
116+
readonly jiraWorkspaceRegistryTable?: dynamodb.ITable;
117+
118+
/**
119+
* Secrets Manager ARN-prefix pattern for per-tenant Jira OAuth bundles.
120+
* Mirrors ``linearOauthSecretArnPattern`` — typically
121+
* ``bgagent-jira-oauth-*``. Required when ``jiraWorkspaceRegistryTable``
122+
* is set; without it the dispatcher would resolve the registry row but
123+
* fail at the SM GetSecretValue call. GetSecretValue + PutSecretValue
124+
* because the resolver rotates an expiring token in place (the fan-out
125+
* Lambda is trusted stack code, same as the orchestrator + webhook
126+
* processor).
127+
*/
128+
readonly jiraOauthSecretArnPattern?: string;
129+
109130
/**
110131
* Maximum batch size delivered to the Lambda per invocation.
111132
*
@@ -231,6 +252,29 @@ export class FanOutConsumer extends Construct {
231252
}));
232253
}
233254

255+
// Jira dispatcher plumbing (issue #573). Same guarded shape as Linear:
256+
// a deployment without Jira onboarding gets no IAM grants and the
257+
// dispatcher logs-and-skips on missing env. The registry table resolves
258+
// the per-tenant OAuth-secret ARN; the secret holds the access token
259+
// ``postIssueCommentAdf`` uses to POST the final-status comment via the
260+
// Jira REST v3 API.
261+
if (props.jiraWorkspaceRegistryTable) {
262+
props.jiraWorkspaceRegistryTable.grantReadData(this.fn);
263+
this.fn.addEnvironment(
264+
'JIRA_WORKSPACE_REGISTRY_TABLE_NAME',
265+
props.jiraWorkspaceRegistryTable.tableName,
266+
);
267+
}
268+
if (props.jiraOauthSecretArnPattern) {
269+
this.fn.addToRolePolicy(new iam.PolicyStatement({
270+
// GetSecretValue + PutSecretValue: resolving an expiring token
271+
// refreshes it in place — same grants the orchestrator + Jira
272+
// webhook-processor Lambdas hold.
273+
actions: ['secretsmanager:GetSecretValue', 'secretsmanager:PutSecretValue'],
274+
resources: [props.jiraOauthSecretArnPattern],
275+
}));
276+
}
277+
234278
// Alarm on any record landing in the DLQ. Notifications are
235279
// best-effort by design, so individual failures don't fail the
236280
// batch — which means the DLQ is the ONLY persistent signal of a
@@ -244,7 +288,7 @@ export class FanOutConsumer extends Construct {
244288
threshold: 1,
245289
evaluationPeriods: 1,
246290
alarmDescription:
247-
'Fan-out DLQ has undelivered task-event records — Slack/GitHub/Linear notifications are failing',
291+
'Fan-out DLQ has undelivered task-event records — Slack/GitHub/Linear/Jira notifications are failing',
248292
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
249293
});
250294

0 commit comments

Comments
 (0)