@qa-gentic/stlc-agents 1.0.25 → 1.0.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/generate-test-cases/SKILL.md +5 -0
- package/src/cli/cmd-cost.js +61 -30
- package/src/cli/cmd-init.js +88 -8
- package/src/stlc_agents/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/__pycache__/server.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/ado_gherkin.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/__pycache__/server.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/server.py +41 -6
- package/src/stlc_agents/agent_helix_writer/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/tools/__pycache__/boilerplate.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/tools/__pycache__/helix_write.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/__pycache__/server.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/server.py +419 -213
- package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/ado_attach.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/__pycache__/server.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/server.py +12 -0
- package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/ado_workitem.cpython-314.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/tools/ado_workitem.py +65 -1
- package/src/stlc_agents/shared/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/auth.cpython-314.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/cost_tracker.cpython-314.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/pricing.cpython-314.pyc +0 -0
- package/src/stlc_agents/shared/cost_tracker.py +378 -70
- package/src/stlc_agents/shared/pricing.py +115 -24
- package/src/stlc_agents/webhook_orchestrator/__init__.py +0 -0
- package/src/stlc_agents/webhook_orchestrator/agent_runner.py +599 -0
- package/src/stlc_agents/webhook_orchestrator/main.py +43 -0
- package/src/stlc_agents/webhook_orchestrator/models.py +63 -0
- package/src/stlc_agents/webhook_orchestrator/orchestrator.py +103 -0
- package/src/stlc_agents/webhook_orchestrator/pipelines/__init__.py +0 -0
- package/src/stlc_agents/webhook_orchestrator/pipelines/_base.py +57 -0
- package/src/stlc_agents/webhook_orchestrator/pipelines/ado_test_cases.py +55 -0
- package/src/stlc_agents/webhook_orchestrator/pipelines/full_pipeline.py +202 -0
- package/src/stlc_agents/webhook_orchestrator/pipelines/gherkin_playwright.py +156 -0
- package/src/stlc_agents/webhook_orchestrator/pipelines/jira_test_cases.py +48 -0
- package/src/stlc_agents/webhook_orchestrator/webhook_bridge.py +368 -0
- package/src/stlc_agents/agent_gherkin_generator/__pycache__/server.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/__pycache__/server.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_jira_manager/__pycache__/server.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/__pycache__/server.cpython-310.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/cost_tracker.cpython-310.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/pricing.cpython-310.pyc +0 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
jira_test_cases.py — Agent 5 pipeline: generate and link test cases for a Jira issue.
|
|
3
|
+
|
|
4
|
+
Delegates entirely to qa-jira-manager.
|
|
5
|
+
The agent server owns all test-case generation logic; this module only defines
|
|
6
|
+
the task and the stop conditions the LLM must respect.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
from ..agent_runner import run_agent_loop
|
|
13
|
+
from ..models import PipelineResult, WebhookEvent
|
|
14
|
+
from ._base import event_env, mcp_servers
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("stlc.pipeline.jira_test_cases")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _task_prompt(event: WebhookEvent) -> str:
|
|
20
|
+
return (
|
|
21
|
+
f"You are a QA automation agent running in webhook mode — auto-confirm all prompts.\n\n"
|
|
22
|
+
f"Task: Generate and link test cases for Jira issue {event.work_item_id}.\n"
|
|
23
|
+
f" Cloud ID: {event.cloud_id}\n"
|
|
24
|
+
f" Project: {event.project_key}\n"
|
|
25
|
+
f" Type: {event.work_item_type}\n"
|
|
26
|
+
f" Status: {event.state}\n\n"
|
|
27
|
+
f"Use the qa-jira-manager tools to complete this task.\n\n"
|
|
28
|
+
f"Stop immediately if:\n"
|
|
29
|
+
f" - The issue type is Epic (tool returns epic_not_supported)\n"
|
|
30
|
+
f" - Linked test cases already exist (get_linked_test_cases returns non-empty list)\n"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def run(event: WebhookEvent) -> PipelineResult:
|
|
35
|
+
label = f"jira-test-cases: wi={event.work_item_id}"
|
|
36
|
+
success, error = await run_agent_loop(
|
|
37
|
+
mcp_config=mcp_servers("qa-jira-manager"),
|
|
38
|
+
prompt=_task_prompt(event),
|
|
39
|
+
llm_provider=event.llm_provider,
|
|
40
|
+
llm_model=event.llm_model,
|
|
41
|
+
llm_api_key=event.llm_api_key,
|
|
42
|
+
llm_base_url=event.llm_base_url,
|
|
43
|
+
env=event_env(event),
|
|
44
|
+
work_item_id=event.work_item_id,
|
|
45
|
+
)
|
|
46
|
+
if not success:
|
|
47
|
+
return PipelineResult(success=False, steps=[label], error=error)
|
|
48
|
+
return PipelineResult(success=True, steps=[label, "completed"])
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""
|
|
2
|
+
webhook_bridge.py — FastAPI HTTP bridge for the stlc-agents webhook orchestrator.
|
|
3
|
+
|
|
4
|
+
Receives webhook POST requests from Azure DevOps or Jira Cloud and routes them
|
|
5
|
+
to the correct agent pipeline via the orchestrator.
|
|
6
|
+
|
|
7
|
+
Endpoints
|
|
8
|
+
─────────
|
|
9
|
+
POST /webhook/ado — Azure DevOps Service Hook (work item updated)
|
|
10
|
+
POST /webhook/jira — Jira Cloud webhook (issue updated)
|
|
11
|
+
GET /health — Liveness check
|
|
12
|
+
|
|
13
|
+
Configuration (env vars or .env)
|
|
14
|
+
─────────────────────────────────
|
|
15
|
+
# Required: ADO
|
|
16
|
+
ADO_ORGANIZATION_URL https://dev.azure.com/yourorg
|
|
17
|
+
ADO_PROJECT_NAME YourProject
|
|
18
|
+
|
|
19
|
+
# Required: Jira
|
|
20
|
+
JIRA_CLOUD_ID your-cloud-id
|
|
21
|
+
|
|
22
|
+
# LLM provider (default: anthropic)
|
|
23
|
+
LLM_PROVIDER anthropic|openai|copilot|ollama|grok|deepseek
|
|
24
|
+
LLM_MODEL (optional) provider-specific model name
|
|
25
|
+
LLM_API_KEY API key (or use provider-specific env var)
|
|
26
|
+
LLM_BASE_URL (optional) base URL for ollama / deepseek
|
|
27
|
+
|
|
28
|
+
# Helix-QA
|
|
29
|
+
HELIX_PROJECT_ROOT /path/to/helix-qa-project (leave blank to skip Agent 4)
|
|
30
|
+
|
|
31
|
+
# Webhook security
|
|
32
|
+
ADO_WEBHOOK_SECRET optional shared secret for ADO service hooks
|
|
33
|
+
JIRA_WEBHOOK_SECRET optional shared secret for Jira webhooks
|
|
34
|
+
|
|
35
|
+
# Optional Agent 3 overrides
|
|
36
|
+
HEALING_STRATEGY role-label-text-ai | role-text-ai | ai-first
|
|
37
|
+
AUTH_HOOK microsoft-sso | azure-keyvault-mfa | none
|
|
38
|
+
ENABLE_VISUAL_REGRESSION true | false
|
|
39
|
+
ENABLE_TIMING_HEALING true | false
|
|
40
|
+
|
|
41
|
+
Run:
|
|
42
|
+
uvicorn stlc_agents.webhook_orchestrator.webhook_bridge:app --port 8080 --reload
|
|
43
|
+
"""
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import hashlib
|
|
47
|
+
import hmac
|
|
48
|
+
import json
|
|
49
|
+
import logging
|
|
50
|
+
import os
|
|
51
|
+
from typing import Any
|
|
52
|
+
|
|
53
|
+
from dotenv import load_dotenv
|
|
54
|
+
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request, status
|
|
55
|
+
from fastapi.responses import JSONResponse
|
|
56
|
+
|
|
57
|
+
from stlc_agents.webhook_orchestrator.orchestrator import (
|
|
58
|
+
Platform,
|
|
59
|
+
WebhookEvent,
|
|
60
|
+
run_pipeline,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
load_dotenv()
|
|
64
|
+
|
|
65
|
+
logging.basicConfig(
|
|
66
|
+
level=logging.INFO,
|
|
67
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
68
|
+
)
|
|
69
|
+
logger = logging.getLogger("stlc.bridge")
|
|
70
|
+
|
|
71
|
+
app = FastAPI(title="STLC Agents Webhook Bridge", version="1.0.0")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
75
|
+
# Config helpers
|
|
76
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
def _env(key: str, default: str = "") -> str:
|
|
79
|
+
return os.environ.get(key, default).strip()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _bool_env(key: str, default: bool = True) -> bool:
|
|
83
|
+
v = os.environ.get(key, "").strip().lower()
|
|
84
|
+
if not v:
|
|
85
|
+
return default
|
|
86
|
+
return v not in ("false", "0", "no", "off")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _llm_kwargs() -> dict:
|
|
90
|
+
return {
|
|
91
|
+
"llm_provider": _env("LLM_PROVIDER", "anthropic"),
|
|
92
|
+
"llm_model": _env("LLM_MODEL"),
|
|
93
|
+
"llm_api_key": _env("LLM_API_KEY"),
|
|
94
|
+
"llm_base_url": _env("LLM_BASE_URL"),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _agent3_extra() -> dict:
|
|
99
|
+
return {
|
|
100
|
+
"healing_strategy": _env("HEALING_STRATEGY", "role-label-text-ai"),
|
|
101
|
+
"auth_hook": _env("AUTH_HOOK", "microsoft-sso"),
|
|
102
|
+
"enable_visual_regression": _bool_env("ENABLE_VISUAL_REGRESSION", True),
|
|
103
|
+
"enable_timing_healing": _bool_env("ENABLE_TIMING_HEALING", True),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
108
|
+
# Webhook signature verification
|
|
109
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
def _verify_hmac(
|
|
112
|
+
body: bytes, signature_header: str | None, secret: str, prefix: str = "sha256="
|
|
113
|
+
) -> bool:
|
|
114
|
+
if not secret:
|
|
115
|
+
return True
|
|
116
|
+
if not signature_header:
|
|
117
|
+
return False
|
|
118
|
+
expected = prefix + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
|
119
|
+
return hmac.compare_digest(expected, signature_header)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
123
|
+
# ADO payload parser
|
|
124
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
def _parse_ado_webhook(payload: dict) -> WebhookEvent | None:
|
|
127
|
+
event_type = payload.get("eventType", "")
|
|
128
|
+
if event_type not in (
|
|
129
|
+
"workitem.updated",
|
|
130
|
+
"workitem.created",
|
|
131
|
+
"workitem.commented",
|
|
132
|
+
"ms.vss-work.workitem-updated-event",
|
|
133
|
+
):
|
|
134
|
+
logger.debug("ADO event type '%s' not handled", event_type)
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
resource = payload.get("resource") or {}
|
|
138
|
+
fields: dict[str, Any] = resource.get("fields", {})
|
|
139
|
+
revision_fields: dict[str, Any] = (resource.get("revision") or {}).get("fields", {})
|
|
140
|
+
|
|
141
|
+
# Extract state; for updates the field is a dict with oldValue/newValue
|
|
142
|
+
previous_state = ""
|
|
143
|
+
state_field = fields.get("System.State") or {}
|
|
144
|
+
if isinstance(state_field, dict):
|
|
145
|
+
old_val = (state_field.get("oldValue") or "").strip()
|
|
146
|
+
new_val = (state_field.get("newValue") or state_field.get("value") or "").strip()
|
|
147
|
+
# Skip no-op transitions — avoids reprocessing when only other fields changed
|
|
148
|
+
if old_val and new_val and old_val.lower() == new_val.lower():
|
|
149
|
+
logger.debug("ADO webhook: state unchanged (%s) — skipping", new_val)
|
|
150
|
+
return None
|
|
151
|
+
state = new_val
|
|
152
|
+
previous_state = old_val
|
|
153
|
+
else:
|
|
154
|
+
state = str(state_field).strip()
|
|
155
|
+
|
|
156
|
+
if not state:
|
|
157
|
+
state = str(revision_fields.get("System.State", "")).strip()
|
|
158
|
+
|
|
159
|
+
if not state:
|
|
160
|
+
logger.debug("ADO webhook: could not determine state")
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
wi_id = str(resource.get("workItemId") or resource.get("id", ""))
|
|
164
|
+
if not wi_id:
|
|
165
|
+
logger.debug("ADO webhook: no work item ID")
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
wi_type_field = fields.get("System.WorkItemType") or {}
|
|
169
|
+
if isinstance(wi_type_field, dict):
|
|
170
|
+
wi_type = wi_type_field.get("newValue") or wi_type_field.get("value") or ""
|
|
171
|
+
else:
|
|
172
|
+
wi_type = str(wi_type_field)
|
|
173
|
+
if not wi_type:
|
|
174
|
+
wi_type = revision_fields.get("System.WorkItemType", "")
|
|
175
|
+
|
|
176
|
+
title = revision_fields.get("System.Title", "")
|
|
177
|
+
|
|
178
|
+
# Extract org URL and project from payload first; fall back to env vars.
|
|
179
|
+
# ADO service hooks include these in resourceContainers.
|
|
180
|
+
resource_containers = payload.get("resourceContainers") or {}
|
|
181
|
+
org_url = (
|
|
182
|
+
(resource_containers.get("account") or {}).get("baseUrl", "").rstrip("/")
|
|
183
|
+
or _env("ADO_ORGANIZATION_URL")
|
|
184
|
+
)
|
|
185
|
+
project_name = (
|
|
186
|
+
(resource_containers.get("project") or {}).get("name", "")
|
|
187
|
+
or _env("ADO_PROJECT_NAME")
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
return WebhookEvent(
|
|
191
|
+
platform=Platform.ADO,
|
|
192
|
+
work_item_id=wi_id,
|
|
193
|
+
state=state,
|
|
194
|
+
previous_state=previous_state,
|
|
195
|
+
work_item_type=wi_type,
|
|
196
|
+
title=title,
|
|
197
|
+
organization_url=org_url,
|
|
198
|
+
project_name=project_name,
|
|
199
|
+
helix_project_root=_env("HELIX_PROJECT_ROOT"),
|
|
200
|
+
extra=_agent3_extra(),
|
|
201
|
+
**_llm_kwargs(),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
206
|
+
# Jira payload parser
|
|
207
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
def _parse_jira_webhook(payload: dict) -> WebhookEvent | None:
|
|
210
|
+
event_type = payload.get("webhookEvent", "")
|
|
211
|
+
if event_type not in ("jira:issue_updated", "jira:issue_created"):
|
|
212
|
+
logger.debug("Jira event type '%s' not handled", event_type)
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
issue = payload.get("issue", {})
|
|
216
|
+
if not issue:
|
|
217
|
+
logger.debug("Jira webhook: no issue in payload")
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
issue_key = issue.get("key", "")
|
|
221
|
+
if not issue_key:
|
|
222
|
+
logger.debug("Jira webhook: no issue key")
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
fields = issue.get("fields", {})
|
|
226
|
+
|
|
227
|
+
state = (fields.get("status") or {}).get("name", "")
|
|
228
|
+
if not state:
|
|
229
|
+
logger.debug("Jira webhook: could not determine status")
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
# For updates, require an actual status transition in the changelog.
|
|
233
|
+
# This prevents re-running pipelines when only comments or other fields changed.
|
|
234
|
+
previous_state = ""
|
|
235
|
+
if event_type == "jira:issue_updated":
|
|
236
|
+
changelog = payload.get("changelog") or {}
|
|
237
|
+
status_item: dict | None = None
|
|
238
|
+
for item in changelog.get("items", []):
|
|
239
|
+
if item.get("field") == "status":
|
|
240
|
+
status_item = item
|
|
241
|
+
break
|
|
242
|
+
if status_item is None:
|
|
243
|
+
logger.debug("Jira webhook: no status field in changelog — skipping")
|
|
244
|
+
return None
|
|
245
|
+
from_str = (status_item.get("fromString") or "").strip()
|
|
246
|
+
to_str = (status_item.get("toString") or "").strip()
|
|
247
|
+
if from_str.lower() == to_str.lower():
|
|
248
|
+
logger.debug("Jira webhook: status unchanged (%s) — skipping", to_str)
|
|
249
|
+
return None
|
|
250
|
+
previous_state = from_str
|
|
251
|
+
|
|
252
|
+
wi_type = (fields.get("issuetype") or {}).get("name", "")
|
|
253
|
+
title = fields.get("summary", "")
|
|
254
|
+
proj_key = (fields.get("project") or {}).get("key", "")
|
|
255
|
+
# Derive project key from issue key when not present in fields (e.g. minimal payloads)
|
|
256
|
+
if not proj_key and "-" in issue_key:
|
|
257
|
+
proj_key = issue_key.split("-")[0]
|
|
258
|
+
|
|
259
|
+
return WebhookEvent(
|
|
260
|
+
platform=Platform.JIRA,
|
|
261
|
+
work_item_id=issue_key,
|
|
262
|
+
state=state,
|
|
263
|
+
previous_state=previous_state,
|
|
264
|
+
work_item_type=wi_type,
|
|
265
|
+
title=title,
|
|
266
|
+
cloud_id=_env("JIRA_CLOUD_ID"),
|
|
267
|
+
project_key=proj_key,
|
|
268
|
+
helix_project_root=_env("HELIX_PROJECT_ROOT"),
|
|
269
|
+
extra=_agent3_extra(),
|
|
270
|
+
**_llm_kwargs(),
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
275
|
+
# Routes
|
|
276
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
async def _pipeline_bg(event: WebhookEvent) -> None:
|
|
279
|
+
try:
|
|
280
|
+
result = await run_pipeline(event)
|
|
281
|
+
if result.success:
|
|
282
|
+
logger.info("Pipeline done: wi=%s steps=%s", event.work_item_id, result.steps)
|
|
283
|
+
else:
|
|
284
|
+
logger.error(
|
|
285
|
+
"Pipeline failed: wi=%s error=%s steps=%s",
|
|
286
|
+
event.work_item_id, result.error, result.steps,
|
|
287
|
+
)
|
|
288
|
+
except Exception:
|
|
289
|
+
logger.exception("Unhandled pipeline error for wi=%s", event.work_item_id)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@app.get("/health")
|
|
293
|
+
async def health() -> dict:
|
|
294
|
+
return {"status": "ok", "service": "stlc-webhook-bridge"}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@app.post("/webhook/ado")
|
|
298
|
+
async def webhook_ado(request: Request, background_tasks: BackgroundTasks) -> JSONResponse:
|
|
299
|
+
body = await request.body()
|
|
300
|
+
sig = request.headers.get("X-Hub-Signature-256") or request.headers.get("X-ADO-Signature")
|
|
301
|
+
if not _verify_hmac(body, sig, _env("ADO_WEBHOOK_SECRET")):
|
|
302
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid signature")
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
payload = json.loads(body)
|
|
306
|
+
except json.JSONDecodeError:
|
|
307
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid JSON")
|
|
308
|
+
|
|
309
|
+
event = _parse_ado_webhook(payload)
|
|
310
|
+
if event is None:
|
|
311
|
+
return JSONResponse({"accepted": False, "reason": "event not actionable"})
|
|
312
|
+
|
|
313
|
+
missing = []
|
|
314
|
+
if not event.organization_url:
|
|
315
|
+
missing.append("ADO_ORGANIZATION_URL")
|
|
316
|
+
if not event.project_name:
|
|
317
|
+
missing.append("ADO_PROJECT_NAME")
|
|
318
|
+
if missing:
|
|
319
|
+
msg = f"Missing required env var(s): {', '.join(missing)}. Set them in .env and restart."
|
|
320
|
+
logger.error("ADO webhook config error: %s", msg)
|
|
321
|
+
return JSONResponse(
|
|
322
|
+
{"accepted": False, "error": msg},
|
|
323
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
logger.info(
|
|
327
|
+
"ADO webhook: work_item=%s state=%s type=%s",
|
|
328
|
+
event.work_item_id, event.state, event.work_item_type,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
# Acknowledge immediately — ADO retries on 5xx or slow responses.
|
|
332
|
+
# The pipeline runs in the background; results are logged.
|
|
333
|
+
background_tasks.add_task(_pipeline_bg, event)
|
|
334
|
+
return JSONResponse({"accepted": True, "work_item": event.work_item_id, "stage": "queued"})
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@app.post("/webhook/jira")
|
|
338
|
+
async def webhook_jira(request: Request, background_tasks: BackgroundTasks) -> JSONResponse:
|
|
339
|
+
body = await request.body()
|
|
340
|
+
sig = request.headers.get("X-Hub-Signature-256") or request.headers.get("X-Jira-Signature")
|
|
341
|
+
if not _verify_hmac(body, sig, _env("JIRA_WEBHOOK_SECRET")):
|
|
342
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid signature")
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
payload = json.loads(body)
|
|
346
|
+
except json.JSONDecodeError:
|
|
347
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid JSON")
|
|
348
|
+
|
|
349
|
+
event = _parse_jira_webhook(payload)
|
|
350
|
+
if event is None:
|
|
351
|
+
return JSONResponse({"accepted": False, "reason": "event not actionable"})
|
|
352
|
+
|
|
353
|
+
if not event.cloud_id:
|
|
354
|
+
msg = "Missing required env var: JIRA_CLOUD_ID. Set it in .env and restart."
|
|
355
|
+
logger.error("Jira webhook config error: %s", msg)
|
|
356
|
+
return JSONResponse(
|
|
357
|
+
{"accepted": False, "error": msg},
|
|
358
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
logger.info(
|
|
362
|
+
"Jira webhook: issue=%s state=%s type=%s",
|
|
363
|
+
event.work_item_id, event.state, event.work_item_type,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
# Acknowledge immediately — Jira retries on 5xx or slow responses.
|
|
367
|
+
background_tasks.add_task(_pipeline_bg, event)
|
|
368
|
+
return JSONResponse({"accepted": True, "issue": event.work_item_id, "stage": "queued"})
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|