feat(webhook): pull reject reason from latest comment
handle_verdict(rejected): the reason is now pulled from the issue latest Plane comment (_latest_comment_reason: GET comments, newest by created_at, HTML stripped) instead of a fixed stub. Slava writes the reason in a comment before flipping the status to Rejected. Falls back to a fixed note when there is no comment / the API call fails. tests: add test_status_only_verdict.py (test_inreview_comment_does_not_revert [bug 3 root], test_any_comment_no_pipeline_action, test_approved_status_advances_without_inprogress_reset, test_rejected_status_pulls_reason_from_comment) and test_inprogress_from_needs_input_relaunches_analyst in test_status_trigger.py. Rewrote the comment-based tests (test_verdict_status, test_plane_approved/ rejected in test_webhooks) under the status-only model: comments are no-ops, verdicts come from status changes.
This commit is contained in:
@@ -2,8 +2,9 @@
|
||||
|
||||
* work_item.created / issue created -> NO task, NO branch, NO analyst.
|
||||
* issue updated -> In Progress (from backlog) -> task created + analyst enqueued.
|
||||
* a second In Progress update for the same issue -> NO duplicate, NO restart
|
||||
(protects handle_comment, which also flips issues to In Progress).
|
||||
* a second In Progress update while the agent is busy -> NO duplicate, NO
|
||||
restart (busy-guard).
|
||||
* In Progress returned from Needs Input (agent idle) -> agent RELAUNCHED.
|
||||
|
||||
launcher / Gitea network are mocked. Real FastAPI endpoint via TestClient.
|
||||
"""
|
||||
@@ -125,15 +126,34 @@ def test_in_progress_starts_pipeline(mock_seq, mock_branch, mock_docs, mock_enqu
|
||||
@patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock)
|
||||
@patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock)
|
||||
@patch("src.plane_sync.fetch_issue_sequence_id", return_value=5)
|
||||
def test_repeat_in_progress_is_idempotent(mock_seq, mock_branch, mock_docs, mock_enqueue):
|
||||
def test_repeat_in_progress_while_job_active_does_not_relaunch(
|
||||
mock_seq, mock_branch, mock_docs, mock_enqueue
|
||||
):
|
||||
"""Status-only model busy-guard: a duplicate In Progress webhook that arrives
|
||||
while the stage agent still has a queued/running job must NOT relaunch the
|
||||
agent (no double launch).
|
||||
"""
|
||||
mock_enqueue.return_value = 1
|
||||
_to_in_progress("st-2")
|
||||
assert _count("st-2") == 1
|
||||
assert mock_enqueue.call_count == 1
|
||||
|
||||
# Second In Progress update (e.g. handle_comment re-set the status). Use a
|
||||
# DISTINCT body (different activity old_value) so webhook dedup does NOT
|
||||
# short-circuit it — this exercises the existing-task idempotency guard in
|
||||
# enqueue_job is mocked above, so no real job row exists. Seed an ACTIVE
|
||||
# (queued) job for the task so has_active_job_for_task() reports the agent as
|
||||
# busy -> the busy-guard fires.
|
||||
conn = get_db()
|
||||
task_id = conn.execute(
|
||||
"SELECT id FROM tasks WHERE plane_id='st-2'"
|
||||
).fetchone()[0]
|
||||
conn.execute(
|
||||
"INSERT INTO jobs (agent, repo, task_id, status) VALUES (?, ?, ?, 'queued')",
|
||||
("analyst", "enduro-trails", task_id),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Second In Progress update. DISTINCT body (different activity old_value) so
|
||||
# webhook dedup does NOT short-circuit it — this exercises the busy-guard in
|
||||
# handle_status_start, not the delivery-dedup layer.
|
||||
resp = client.post("/webhook/plane", json={
|
||||
"event": "issue", "action": "updated",
|
||||
@@ -147,4 +167,77 @@ def test_repeat_in_progress_is_idempotent(mock_seq, mock_branch, mock_docs, mock
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert _count("st-2") == 1 # still exactly one task
|
||||
assert mock_enqueue.call_count == 1 # analyst NOT re-enqueued
|
||||
assert mock_enqueue.call_count == 1 # analyst NOT re-enqueued (busy-guard)
|
||||
|
||||
|
||||
@patch("src.webhooks.plane.add_comment", create=True)
|
||||
@patch("src.webhooks.plane.enqueue_job")
|
||||
@patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock)
|
||||
@patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock)
|
||||
@patch("src.plane_sync.fetch_issue_sequence_id", return_value=5)
|
||||
def test_inprogress_from_needs_input_relaunches_analyst(
|
||||
mock_seq, mock_branch, mock_docs, mock_enqueue, mock_comment
|
||||
):
|
||||
"""Status-only answer-to-questions flow: an existing analysis task whose agent
|
||||
is IDLE (no active job — it went to Needs Input) is returned to In Progress
|
||||
-> the analyst is relaunched to read Slava's fresh comments.
|
||||
|
||||
+ double-webhook protection: a second In Progress while the relaunch job is
|
||||
active does NOT relaunch again.
|
||||
"""
|
||||
mock_enqueue.return_value = 1
|
||||
# First In Progress: starts the pipeline (creates task + enqueues analyst).
|
||||
_to_in_progress("st-ni")
|
||||
assert _count("st-ni") == 1
|
||||
assert mock_enqueue.call_count == 1
|
||||
|
||||
# The analyst finished and asked questions -> Needs Input. In our model that
|
||||
# means NO active job for the task (enqueue_job is mocked, so no job row).
|
||||
conn = get_db()
|
||||
task_id = conn.execute(
|
||||
"SELECT id FROM tasks WHERE plane_id='st-ni'"
|
||||
).fetchone()[0]
|
||||
has_job = conn.execute(
|
||||
"SELECT COUNT(*) FROM jobs WHERE task_id=? AND status IN ('queued','running')",
|
||||
(task_id,),
|
||||
).fetchone()[0]
|
||||
conn.close()
|
||||
assert has_job == 0 # agent idle
|
||||
|
||||
# Slava answers + returns the issue to In Progress (distinct body).
|
||||
resp = client.post("/webhook/plane", json={
|
||||
"event": "issue", "action": "updated",
|
||||
"data": {
|
||||
"id": "st-ni", "name": "A valid backlog item title",
|
||||
"description_stripped": "A sufficiently long description for QG-0.",
|
||||
"project": ENDURO_PLANE_ID,
|
||||
"state": {"id": IN_PROGRESS, "name": "In Progress", "group": "started"},
|
||||
},
|
||||
"activity": {"field": "state", "new_value": IN_PROGRESS, "old_value": "needs-input"},
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert _count("st-ni") == 1 # no duplicate task
|
||||
assert mock_enqueue.call_count == 2 # analyst RELAUNCHED
|
||||
assert mock_enqueue.call_args.args[0] == "analyst"
|
||||
|
||||
# Seed an active job for the relaunch, then a SECOND In Progress webhook must
|
||||
# NOT relaunch again (busy-guard against double webhooks).
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"INSERT INTO jobs (agent, repo, task_id, status) VALUES (?, ?, ?, 'running')",
|
||||
("analyst", "enduro-trails", task_id),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
resp2 = client.post("/webhook/plane", json={
|
||||
"event": "issue", "action": "updated",
|
||||
"data": {
|
||||
"id": "st-ni", "name": "A valid backlog item title",
|
||||
"description_stripped": "A sufficiently long description for QG-0.",
|
||||
"project": ENDURO_PLANE_ID,
|
||||
"state": {"id": IN_PROGRESS, "name": "In Progress", "group": "started"},
|
||||
},
|
||||
"activity": {"field": "state", "new_value": IN_PROGRESS, "old_value": "x-y-z"},
|
||||
})
|
||||
assert resp2.status_code == 200
|
||||
assert mock_enqueue.call_count == 2 # still 2 — busy-guard held
|
||||
|
||||
Reference in New Issue
Block a user