Skip to content

Commit 89bfea4

Browse files
committed
fix(jira,linear): board state on build failure + re-open revert (review blocker 9)
9a (Jira board lies): pipeline.py moved the Jira card to In Review the moment a PR opened — but ensure_pr deliberately opens a PR even on a FAILED build, so a red build was reported as ready-for-review. Gate transition_pr_opened on build_passed, mirroring the Linear twin (react_task_finished transitions only on success). A build-failed PR now leaves the card In Progress with the failure comment. +2 tests (fail → no transition, pass → transition). 9b (Linear re-open revert was a silent no-op): the rollup's re-opened-epic revert (In Review → In Progress) never fired because transitionIssueState's backward-move guard blocks a within-type regression (both are 'started' type; In Progress sits at a lower position). Added an allowSameTypeRegression opt-in that relaxes ONLY the same-type position tiebreak — cross-type demotion (Done → In Progress) stays blocked. The rollup re-open passes it. +3 tests (blocked by default, allowed with flag, cross-type still blocked). agent + cdk gates green.
1 parent 134a2dc commit 89bfea4

5 files changed

Lines changed: 191 additions & 10 deletions

File tree

agent/src/pipeline.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1343,13 +1343,26 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None:
13431343
if pr_url:
13441344
progress.write_agent_milestone("pr_created", pr_url)
13451345
# Move the Jira card In Progress → In Review now that a PR is
1346-
# open (issue #572). Only fires when a PR was actually opened —
1347-
# failed / no-PR tasks leave the card where humans can see the
1348-
# failure comment. No-op for non-Jira tasks; best-effort.
1349-
transition_pr_opened(
1350-
config.channel_source,
1351-
config.channel_metadata,
1352-
)
1346+
# open (issue #572) — but ONLY when the build passed. ensure_pr
1347+
# deliberately opens a PR even on a FAILED build (so the human
1348+
# sees the broken diff), so gating on pr_url alone moved the card
1349+
# to In Review on a red build, telling the board the work is ready
1350+
# for review when it isn't (review blocker #9a). Gate on build_ok
1351+
# to mirror the Linear twin, which only transitions on success
1352+
# (react_task_finished: `if transition_state and success`). A
1353+
# build-failed PR leaves the card In Progress with the failure
1354+
# comment. No-op for non-Jira tasks; best-effort.
1355+
if build_passed:
1356+
transition_pr_opened(
1357+
config.channel_source,
1358+
config.channel_metadata,
1359+
)
1360+
else:
1361+
log(
1362+
"TASK",
1363+
"Jira card NOT moved to In Review — PR opened with a FAILED build "
1364+
"(build_ok=False); leaving it In Progress with the failure comment (#9a).",
1365+
)
13531366

13541367
# Memory write — capture task episode and repo learnings
13551368
memory_written = False

agent/tests/test_pipeline.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -800,6 +800,129 @@ async def fake_run_agent(_prompt, _system_prompt, _config, cwd=None, trajectory=
800800

801801
mock_ensure_pr.assert_called_once()
802802

803+
@patch("runner.run_agent")
804+
@patch("pipeline.build_system_prompt")
805+
@patch("pipeline.discover_project_config")
806+
@patch("repo.setup_repo")
807+
@patch("pipeline.task_span")
808+
def test_jira_card_not_moved_to_in_review_on_build_failure(
809+
self,
810+
mock_task_span,
811+
mock_setup_repo,
812+
_mock_discover,
813+
_mock_build_prompt,
814+
mock_run_agent,
815+
monkeypatch,
816+
):
817+
"""review blocker #9a: ensure_pr opens a PR even on a FAILED build (so the
818+
human sees the broken diff), so the Jira In Progress → In Review transition
819+
must gate on build_passed — not merely on pr_url — or the board lies that
820+
the work is ready for review. Mirrors the Linear success-only twin."""
821+
monkeypatch.setenv("GITHUB_TOKEN", "ghp_test")
822+
monkeypatch.setenv("AWS_REGION", "us-east-1")
823+
mock_setup_repo.return_value = RepoSetup(
824+
repo_dir="/workspace/repo",
825+
branch="bgagent/test/branch",
826+
build_before=True,
827+
)
828+
829+
async def fake_run_agent(_prompt, _system_prompt, _config, cwd=None, trajectory=None):
830+
return AgentResult(status="success", turns=2, cost_usd=0.01, num_turns=2)
831+
832+
mock_run_agent.side_effect = fake_run_agent
833+
834+
mock_span = MagicMock()
835+
mock_span.__enter__ = MagicMock(return_value=mock_span)
836+
mock_span.__exit__ = MagicMock(return_value=False)
837+
mock_task_span.return_value = mock_span
838+
839+
mock_transition = MagicMock()
840+
with (
841+
patch("pipeline.ensure_committed", return_value=False),
842+
# FAILED build — ensure_pr still opens the PR below.
843+
patch("pipeline.verify_build", return_value=VerifyOutcome(passed=False)),
844+
patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)),
845+
patch("pipeline.ensure_pr", return_value="https://github.com/o/r/pull/9"),
846+
patch("pipeline.transition_pr_opened", mock_transition),
847+
patch("pipeline.get_disk_usage", return_value=0),
848+
patch("pipeline.print_metrics"),
849+
patch("pipeline.task_state") as mock_task_state_mod,
850+
):
851+
mock_task_state_mod.get_task = MagicMock(return_value={"status": "RUNNING"})
852+
mock_task_state_mod.TaskFetchError = Exception # type: ignore[attr-defined]
853+
from pipeline import run_task
854+
855+
run_task(
856+
repo_url="o/r",
857+
task_description="x",
858+
github_token="ghp_test",
859+
aws_region="us-east-1",
860+
task_id="t-jira-failbuild",
861+
channel_source="jira",
862+
channel_metadata={"jira_issue_key": "ABC-1"},
863+
)
864+
# PR opened, but the Jira card was NOT advanced to In Review on the red build.
865+
mock_transition.assert_not_called()
866+
867+
@patch("runner.run_agent")
868+
@patch("pipeline.build_system_prompt")
869+
@patch("pipeline.discover_project_config")
870+
@patch("repo.setup_repo")
871+
@patch("pipeline.task_span")
872+
def test_jira_card_moved_to_in_review_on_build_success(
873+
self,
874+
mock_task_span,
875+
mock_setup_repo,
876+
_mock_discover,
877+
_mock_build_prompt,
878+
mock_run_agent,
879+
monkeypatch,
880+
):
881+
"""The positive twin: a PASSING build DOES move the Jira card to In Review."""
882+
monkeypatch.setenv("GITHUB_TOKEN", "ghp_test")
883+
monkeypatch.setenv("AWS_REGION", "us-east-1")
884+
mock_setup_repo.return_value = RepoSetup(
885+
repo_dir="/workspace/repo",
886+
branch="bgagent/test/branch",
887+
build_before=True,
888+
)
889+
890+
async def fake_run_agent(_prompt, _system_prompt, _config, cwd=None, trajectory=None):
891+
return AgentResult(status="success", turns=2, cost_usd=0.01, num_turns=2)
892+
893+
mock_run_agent.side_effect = fake_run_agent
894+
895+
mock_span = MagicMock()
896+
mock_span.__enter__ = MagicMock(return_value=mock_span)
897+
mock_span.__exit__ = MagicMock(return_value=False)
898+
mock_task_span.return_value = mock_span
899+
900+
mock_transition = MagicMock()
901+
with (
902+
patch("pipeline.ensure_committed", return_value=False),
903+
patch("pipeline.verify_build", return_value=VerifyOutcome(passed=True)),
904+
patch("pipeline.verify_lint", return_value=VerifyOutcome(passed=True)),
905+
patch("pipeline.ensure_pr", return_value="https://github.com/o/r/pull/9"),
906+
patch("pipeline.transition_pr_opened", mock_transition),
907+
patch("pipeline.get_disk_usage", return_value=0),
908+
patch("pipeline.print_metrics"),
909+
patch("pipeline.task_state") as mock_task_state_mod,
910+
):
911+
mock_task_state_mod.get_task = MagicMock(return_value={"status": "RUNNING"})
912+
mock_task_state_mod.TaskFetchError = Exception # type: ignore[attr-defined]
913+
from pipeline import run_task
914+
915+
run_task(
916+
repo_url="o/r",
917+
task_description="x",
918+
github_token="ghp_test",
919+
aws_region="us-east-1",
920+
task_id="t-jira-okbuild",
921+
channel_source="jira",
922+
channel_metadata={"jira_issue_key": "ABC-1"},
923+
)
924+
mock_transition.assert_called_once()
925+
803926
@patch("runner.run_agent")
804927
@patch("pipeline.build_system_prompt")
805928
@patch("pipeline.discover_project_config")

cdk/src/handlers/shared/linear-feedback.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -717,6 +717,16 @@ export async function transitionIssueState(
717717
issueId: string,
718718
targetType: 'started' | 'completed',
719719
preferredNames: readonly string[] = [],
720+
/**
721+
* review blocker #9b: allow a WITHIN-TYPE regression (e.g. In Review → In
722+
* Progress, both ``started``). By default the backward-move guard blocks it —
723+
* correct for most callers, but it silently no-op'd the orchestration rollup's
724+
* deliberate re-open (a settled epic getting a new/re-run child must go back
725+
* from In Review to In Progress, and both are ``started`` type so the
726+
* position-tiebreak blocked it). Cross-TYPE demotion (completed → started) is
727+
* still ALWAYS blocked — this only relaxes the same-type position tiebreak.
728+
*/
729+
allowSameTypeRegression = false,
720730
): Promise<boolean> {
721731
const token = await resolveToken(ctx);
722732
if (!token) return false;
@@ -755,12 +765,18 @@ export async function transitionIssueState(
755765
};
756766
const curRank = TYPE_RANK[current.type] ?? 0;
757767
const tgtRank = TYPE_RANK[target.type] ?? 0;
758-
const backward = curRank > tgtRank || (curRank === tgtRank && current.position >= target.position);
768+
const crossTypeDemotion = curRank > tgtRank;
769+
const sameTypeRegression = curRank === tgtRank && current.position >= target.position;
770+
// Cross-type demotion (e.g. completed → started) is NEVER allowed — a human
771+
// or automation advanced it. A same-type regression (In Review → In Progress)
772+
// is allowed ONLY when the caller opts in (#9b: the rollup re-open).
773+
const backward = crossTypeDemotion || (sameTypeRegression && !allowSameTypeRegression);
759774
if (backward) {
760775
logger.info('Linear state transition: skipping backward move', {
761776
issue_id: issueId,
762777
current_state: current.name,
763778
target_state: target.name,
779+
cross_type: crossTypeDemotion,
764780
});
765781
return false;
766782
}

cdk/src/handlers/shared/orchestration-rollup.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -468,8 +468,12 @@ export async function upsertEpicPanel(params: UpsertEpicPanelParams): Promise<st
468468
const anyBad = rows.some((r) => r.child_status === 'failed' || r.child_status === 'skipped');
469469
try {
470470
if (inProgress) {
471-
// Re-opened (or running): back to In Progress + 👀.
472-
await transitionIssueState(params.ctx, params.parentLinearIssueId, 'started', ['In Progress']);
471+
// Re-opened (or running): back to In Progress + 👀. Pass
472+
// allowSameTypeRegression (#9b) — a settled epic is In Review (started),
473+
// and In Progress is ALSO started but at a lower position, so the default
474+
// backward-move guard silently blocked this deliberate re-open. Cross-type
475+
// demotion (a human moved it to Done) is still blocked by the guard.
476+
await transitionIssueState(params.ctx, params.parentLinearIssueId, 'started', ['In Progress'], true);
473477
await swapIssueReaction(params.ctx, params.parentLinearIssueId, 'eyes');
474478
} else if (!anyBad) {
475479
// Clean completion: work done, awaiting human merge → In Review + ✅.

cdk/test/handlers/shared/linear-feedback.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,31 @@ describe('linear-feedback', () => {
503503
expect(fetchMock).toHaveBeenCalledTimes(1);
504504
});
505505

506+
// review blocker #9b: the rollup re-open (In Review → In Progress, BOTH
507+
// 'started') was silently blocked by the same-type position tiebreak.
508+
test('same-type regression In Review → In Progress is BLOCKED by default', async () => {
509+
fetchMock.mockResolvedValueOnce(statesResp(cur('s-inreview')));
510+
const ok = await transitionIssueState(CTX, ISSUE_ID, 'started', ['In Progress']);
511+
expect(ok).toBe(false); // In Progress (pos 2) < In Review (pos 1002) → backward
512+
expect(fetchMock).toHaveBeenCalledTimes(1); // no mutation
513+
});
514+
515+
test('same-type regression In Review → In Progress SUCCEEDS with allowSameTypeRegression (rollup re-open)', async () => {
516+
fetchMock
517+
.mockResolvedValueOnce(statesResp(cur('s-inreview')))
518+
.mockResolvedValueOnce(jsonResponse({ data: { issueUpdate: { success: true } } }));
519+
const ok = await transitionIssueState(CTX, ISSUE_ID, 'started', ['In Progress'], true);
520+
expect(ok).toBe(true);
521+
expect(JSON.parse(fetchMock.mock.calls[1][1].body).variables.stateId).toBe('s-inprogress');
522+
});
523+
524+
test('allowSameTypeRegression does NOT permit a cross-type demotion (Done → In Progress still blocked)', async () => {
525+
fetchMock.mockResolvedValueOnce(statesResp(cur('s-done')));
526+
const ok = await transitionIssueState(CTX, ISSUE_ID, 'started', ['In Progress'], true);
527+
expect(ok).toBe(false); // completed → started is cross-type; still refused
528+
expect(fetchMock).toHaveBeenCalledTimes(1);
529+
});
530+
506531
test('returns false when token cannot be resolved', async () => {
507532
resolveLinearOauthTokenMock.mockResolvedValueOnce(null);
508533
const ok = await transitionIssueState(CTX, ISSUE_ID, 'started', ['In Review']);

0 commit comments

Comments
 (0)