qaas-python 0.1.0__py3-none-any.whl
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.
- qaas/adapters/__init__.py +19 -0
- qaas/adapters/tracker.py +1350 -0
- qaas/adapters/vcs.py +494 -0
- qaas/cli.py +1564 -0
- qaas/conductor.py +527 -0
- qaas/config.py +407 -0
- qaas/defaults/config/agents/arbiter.yaml +19 -0
- qaas/defaults/config/agents/cartographer.yaml +20 -0
- qaas/defaults/config/agents/clerk.yaml +21 -0
- qaas/defaults/config/agents/conduit.yaml +19 -0
- qaas/defaults/config/agents/forge.yaml +22 -0
- qaas/defaults/config/agents/mender.yaml +56 -0
- qaas/defaults/config/agents/proof.yaml +21 -0
- qaas/defaults/config/agents/surface.yaml +16 -0
- qaas/defaults/config/system.yaml +69 -0
- qaas/discover.py +227 -0
- qaas/envelope.py +290 -0
- qaas/guardrails.py +431 -0
- qaas/mcp/__init__.py +0 -0
- qaas/mcp/context.py +70 -0
- qaas/mcp/contract_diff.py +937 -0
- qaas/mcp/defect_memory.py +495 -0
- qaas/mcp/env_control.py +905 -0
- qaas/mcp/envelope_server.py +463 -0
- qaas/mcp/test_runner.py +773 -0
- qaas/mcp/tracker.py +412 -0
- qaas/mcp/vcs.py +506 -0
- qaas/paths.py +317 -0
- qaas/plugin/.claude-plugin/plugin.json +9 -0
- qaas/plugin/skills/a11y-audit/SKILL.md +34 -0
- qaas/plugin/skills/adversarial-review/SKILL.md +120 -0
- qaas/plugin/skills/api-surface-extraction/SKILL.md +38 -0
- qaas/plugin/skills/authz-matrix-check/SKILL.md +46 -0
- qaas/plugin/skills/console-error-triage/SKILL.md +39 -0
- qaas/plugin/skills/contract-test-generation/SKILL.md +36 -0
- qaas/plugin/skills/dedupe-strategy/SKILL.md +39 -0
- qaas/plugin/skills/environment-pinning/SKILL.md +35 -0
- qaas/plugin/skills/error-taxonomy/SKILL.md +42 -0
- qaas/plugin/skills/exploratory-ui-walk/SKILL.md +46 -0
- qaas/plugin/skills/failing-test-authoring/SKILL.md +47 -0
- qaas/plugin/skills/flake-detection/SKILL.md +39 -0
- qaas/plugin/skills/form-state-probe/SKILL.md +36 -0
- qaas/plugin/skills/minimal-diff-discipline/SKILL.md +70 -0
- qaas/plugin/skills/openapi-diff/SKILL.md +45 -0
- qaas/plugin/skills/ownership-resolution/SKILL.md +31 -0
- qaas/plugin/skills/product-task-graph/SKILL.md +35 -0
- qaas/plugin/skills/regression-risk-scoring/SKILL.md +59 -0
- qaas/plugin/skills/regression-suite-selection/SKILL.md +36 -0
- qaas/plugin/skills/repo-cartography/SKILL.md +38 -0
- qaas/plugin/skills/repro-minimisation/SKILL.md +41 -0
- qaas/plugin/skills/rollback-plan-authoring/SKILL.md +81 -0
- qaas/plugin/skills/root-cause-vs-symptom/SKILL.md +67 -0
- qaas/plugin/skills/routing-rules/SKILL.md +34 -0
- qaas/plugin/skills/severity-rubric/SKILL.md +42 -0
- qaas/plugin/skills/test-first-fix/SKILL.md +66 -0
- qaas/plugin/skills/test-quality-audit/SKILL.md +58 -0
- qaas/plugin/skills/ticket-writer/SKILL.md +40 -0
- qaas/plugin/skills/verdict-reporting/SKILL.md +35 -0
- qaas/plugin/skills/verification-protocol/SKILL.md +39 -0
- qaas/prompts/ARBITER.md +53 -0
- qaas/prompts/CARTOGRAPHER.md +46 -0
- qaas/prompts/CLERK.md +45 -0
- qaas/prompts/CONDUIT.md +44 -0
- qaas/prompts/FORGE.md +43 -0
- qaas/prompts/MENDER.md +55 -0
- qaas/prompts/PROOF.md +41 -0
- qaas/prompts/SURFACE.md +46 -0
- qaas/prompts/_shared.md +45 -0
- qaas/registry.py +465 -0
- qaas/runner.py +192 -0
- qaas/scorecard.py +425 -0
- qaas/sdk_compat.py +52 -0
- qaas/store.py +290 -0
- qaas/target.py +261 -0
- qaas/tasks.py +361 -0
- qaas/trace.py +270 -0
- qaas_python-0.1.0.dist-info/METADATA +388 -0
- qaas_python-0.1.0.dist-info/RECORD +81 -0
- qaas_python-0.1.0.dist-info/WHEEL +4 -0
- qaas_python-0.1.0.dist-info/entry_points.txt +2 -0
- qaas_python-0.1.0.dist-info/licenses/LICENSE +21 -0
qaas/adapters/tracker.py
ADDED
|
@@ -0,0 +1,1350 @@
|
|
|
1
|
+
"""Tracker adapters — the issue tracker behind one interface.
|
|
2
|
+
|
|
3
|
+
The MCP server in `qaas.mcp.tracker` holds the policy (who may file, how many,
|
|
4
|
+
where security findings go). This module holds only storage mechanics, so that
|
|
5
|
+
pointing the system at real Jira is a new subclass and nothing else. That split
|
|
6
|
+
matters: guardrails that live in the adapter would have to be re-implemented,
|
|
7
|
+
and re-audited, for every backend.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import time
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.parse
|
|
19
|
+
import urllib.request
|
|
20
|
+
from abc import ABC, abstractmethod
|
|
21
|
+
from collections.abc import Mapping
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
27
|
+
|
|
28
|
+
# The house project keys. `SECURITY_PROJECT` is the restricted one (§4.12 step 5,
|
|
29
|
+
# §10 "security findings leak into public tickets"); it is defined here so the
|
|
30
|
+
# adapter and the server cannot drift apart about what "restricted" means.
|
|
31
|
+
DEFAULT_PROJECT = "CORVID"
|
|
32
|
+
SECURITY_PROJECT = "CORVID-SEC"
|
|
33
|
+
|
|
34
|
+
# A closed vocabulary, so an agent that invents a status gets the list back and
|
|
35
|
+
# retries rather than writing a state nothing downstream can interpret.
|
|
36
|
+
STATUSES = ("open", "in_progress", "in_review", "resolved", "closed", "wont_fix", "duplicate")
|
|
37
|
+
LINK_TYPES = ("duplicates", "relates", "blocks", "blocked-by", "regression-of", "caused-by")
|
|
38
|
+
|
|
39
|
+
_KEY_RE = re.compile(r"^(?P<project>[A-Z][A-Z0-9-]*)-(?P<number>\d+)$")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _utcnow() -> datetime:
|
|
43
|
+
return datetime.now(timezone.utc)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TrackerError(Exception):
|
|
47
|
+
"""Anything the caller could have avoided: bad key, bad status, bad link."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class UnknownIssue(TrackerError):
|
|
51
|
+
"""The referenced issue key does not exist in this tracker."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TrackerConfigError(TrackerError):
|
|
55
|
+
"""The tracker itself is misconfigured — wrong credentials, missing settings.
|
|
56
|
+
|
|
57
|
+
Raised at construction, never mid-run. A tracker that only discovers it
|
|
58
|
+
cannot reach Jira after twenty findings have been produced has thrown away
|
|
59
|
+
the run: the findings are gone from context and nobody can act on them.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Transition(BaseModel):
|
|
64
|
+
model_config = ConfigDict(extra="forbid")
|
|
65
|
+
|
|
66
|
+
at: datetime = Field(default_factory=_utcnow)
|
|
67
|
+
status: str
|
|
68
|
+
by: str | None = None
|
|
69
|
+
comment: str = ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class IssueLink(BaseModel):
|
|
73
|
+
model_config = ConfigDict(extra="forbid")
|
|
74
|
+
|
|
75
|
+
type: str
|
|
76
|
+
to: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Issue(BaseModel):
|
|
80
|
+
"""One tracker issue, in the shape both backends agree to speak."""
|
|
81
|
+
|
|
82
|
+
model_config = ConfigDict(extra="forbid")
|
|
83
|
+
|
|
84
|
+
key: str
|
|
85
|
+
project: str
|
|
86
|
+
title: str
|
|
87
|
+
body: str = ""
|
|
88
|
+
status: str = "open"
|
|
89
|
+
labels: list[str] = Field(default_factory=list)
|
|
90
|
+
severity: str | None = None
|
|
91
|
+
envelope_id: str | None = None
|
|
92
|
+
fingerprint: str | None = None
|
|
93
|
+
reporter: str | None = None
|
|
94
|
+
links: list[IssueLink] = Field(default_factory=list)
|
|
95
|
+
history: list[Transition] = Field(default_factory=list)
|
|
96
|
+
created_at: datetime = Field(default_factory=_utcnow)
|
|
97
|
+
updated_at: datetime = Field(default_factory=_utcnow)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def number(self) -> int:
|
|
101
|
+
match = _KEY_RE.match(self.key)
|
|
102
|
+
return int(match.group("number")) if match else 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class TrackerAdapter(ABC):
|
|
106
|
+
"""The five operations the system needs from any tracker."""
|
|
107
|
+
|
|
108
|
+
@abstractmethod
|
|
109
|
+
def create_issue(
|
|
110
|
+
self,
|
|
111
|
+
*,
|
|
112
|
+
project: str,
|
|
113
|
+
title: str,
|
|
114
|
+
body: str = "",
|
|
115
|
+
labels: list[str] | None = None,
|
|
116
|
+
severity: str | None = None,
|
|
117
|
+
envelope_id: str | None = None,
|
|
118
|
+
fingerprint: str | None = None,
|
|
119
|
+
reporter: str | None = None,
|
|
120
|
+
) -> Issue:
|
|
121
|
+
"""File a new issue and return it, key assigned."""
|
|
122
|
+
|
|
123
|
+
@abstractmethod
|
|
124
|
+
def transition(self, key: str, status: str, *, by: str | None = None, comment: str = "") -> Issue:
|
|
125
|
+
"""Move an issue to a new status, recording who and why."""
|
|
126
|
+
|
|
127
|
+
@abstractmethod
|
|
128
|
+
def link(self, key: str, to: str, link_type: str = "relates") -> Issue:
|
|
129
|
+
"""Relate two existing issues."""
|
|
130
|
+
|
|
131
|
+
@abstractmethod
|
|
132
|
+
def search(
|
|
133
|
+
self,
|
|
134
|
+
*,
|
|
135
|
+
text: str | None = None,
|
|
136
|
+
project: str | None = None,
|
|
137
|
+
status: str | None = None,
|
|
138
|
+
label: str | None = None,
|
|
139
|
+
envelope_id: str | None = None,
|
|
140
|
+
fingerprint: str | None = None,
|
|
141
|
+
limit: int = 20,
|
|
142
|
+
) -> list[Issue]:
|
|
143
|
+
"""Return matching issues, newest first."""
|
|
144
|
+
|
|
145
|
+
@abstractmethod
|
|
146
|
+
def get(self, key: str) -> Issue | None:
|
|
147
|
+
"""One issue by key, or None."""
|
|
148
|
+
|
|
149
|
+
# -- routing surface ---------------------------------------------------
|
|
150
|
+
# The MCP server decides *whether* a finding is restricted (§4.12 step 5);
|
|
151
|
+
# the adapter decides *what the projects are called*, because a real Jira's
|
|
152
|
+
# keys come from the deployment, not from a constant in this file. Asking
|
|
153
|
+
# the adapter keeps one routing path for both backends instead of two.
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def default_project(self) -> str:
|
|
157
|
+
"""Where an ordinary finding is filed when the caller names no project."""
|
|
158
|
+
return DEFAULT_PROJECT
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def security_project(self) -> str | None:
|
|
162
|
+
"""The restricted project, or None if this backend has none configured.
|
|
163
|
+
|
|
164
|
+
None is not "file it somewhere else": it means the caller must refuse.
|
|
165
|
+
A security finding in a public project cannot be un-disclosed (§10).
|
|
166
|
+
"""
|
|
167
|
+
return SECURITY_PROJECT
|
|
168
|
+
|
|
169
|
+
# -- shared validation ------------------------------------------------
|
|
170
|
+
# Kept on the base class so every backend rejects the same inputs with the
|
|
171
|
+
# same message; an agent should not have to learn two dialects.
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def _check_status(status: str) -> str:
|
|
175
|
+
if status not in STATUSES:
|
|
176
|
+
raise TrackerError(f"unknown status '{status}'; use one of: {', '.join(STATUSES)}")
|
|
177
|
+
return status
|
|
178
|
+
|
|
179
|
+
@staticmethod
|
|
180
|
+
def _check_link_type(link_type: str) -> str:
|
|
181
|
+
if link_type not in LINK_TYPES:
|
|
182
|
+
raise TrackerError(
|
|
183
|
+
f"unknown link type '{link_type}'; use one of: {', '.join(LINK_TYPES)}"
|
|
184
|
+
)
|
|
185
|
+
return link_type
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class LocalTracker(TrackerAdapter):
|
|
189
|
+
"""Issues as JSON files under `<root>/tickets/`.
|
|
190
|
+
|
|
191
|
+
The key sequence is shared across projects and derived from what is already
|
|
192
|
+
on disk, so keys stay monotonic and unique across runs and across processes
|
|
193
|
+
without a database. Reusing a key would silently rewrite a ticket an
|
|
194
|
+
engineer may already be looking at, so allocation never counts down.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
def __init__(self, root: Path | str):
|
|
198
|
+
self.dir = Path(root) / "tickets"
|
|
199
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
200
|
+
|
|
201
|
+
# -- storage ----------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
def _path(self, key: str) -> Path:
|
|
204
|
+
if not _KEY_RE.match(key):
|
|
205
|
+
raise TrackerError(f"'{key}' is not an issue key; expected e.g. {DEFAULT_PROJECT}-1")
|
|
206
|
+
return self.dir / f"{key}.json"
|
|
207
|
+
|
|
208
|
+
def _write(self, issue: Issue) -> Issue:
|
|
209
|
+
self._path(issue.key).write_text(issue.model_dump_json(indent=2))
|
|
210
|
+
return issue
|
|
211
|
+
|
|
212
|
+
def get(self, key: str) -> Issue | None:
|
|
213
|
+
path = self._path(key)
|
|
214
|
+
return Issue.model_validate_json(path.read_text()) if path.exists() else None
|
|
215
|
+
|
|
216
|
+
def _require(self, key: str) -> Issue:
|
|
217
|
+
issue = self.get(key)
|
|
218
|
+
if issue is None:
|
|
219
|
+
raise UnknownIssue(f"no issue '{key}' in the tracker")
|
|
220
|
+
return issue
|
|
221
|
+
|
|
222
|
+
def issues(self) -> list[Issue]:
|
|
223
|
+
found = []
|
|
224
|
+
for path in self.dir.glob("*.json"):
|
|
225
|
+
if _KEY_RE.match(path.stem):
|
|
226
|
+
found.append(Issue.model_validate_json(path.read_text()))
|
|
227
|
+
return sorted(found, key=lambda i: i.number)
|
|
228
|
+
|
|
229
|
+
def _next_key(self, project: str) -> str:
|
|
230
|
+
highest = max((i.number for i in self.issues()), default=0)
|
|
231
|
+
return f"{project}-{highest + 1}"
|
|
232
|
+
|
|
233
|
+
# -- operations -------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
def create_issue(
|
|
236
|
+
self,
|
|
237
|
+
*,
|
|
238
|
+
project: str,
|
|
239
|
+
title: str,
|
|
240
|
+
body: str = "",
|
|
241
|
+
labels: list[str] | None = None,
|
|
242
|
+
severity: str | None = None,
|
|
243
|
+
envelope_id: str | None = None,
|
|
244
|
+
fingerprint: str | None = None,
|
|
245
|
+
reporter: str | None = None,
|
|
246
|
+
) -> Issue:
|
|
247
|
+
if not title.strip():
|
|
248
|
+
raise TrackerError("an issue needs a title")
|
|
249
|
+
issue = Issue(
|
|
250
|
+
key=self._next_key(project),
|
|
251
|
+
project=project,
|
|
252
|
+
title=title.strip(),
|
|
253
|
+
body=body,
|
|
254
|
+
labels=sorted(set(labels or [])),
|
|
255
|
+
severity=severity,
|
|
256
|
+
envelope_id=envelope_id,
|
|
257
|
+
fingerprint=fingerprint,
|
|
258
|
+
reporter=reporter,
|
|
259
|
+
history=[Transition(status="open", by=reporter, comment="filed")],
|
|
260
|
+
)
|
|
261
|
+
return self._write(issue)
|
|
262
|
+
|
|
263
|
+
def transition(self, key: str, status: str, *, by: str | None = None, comment: str = "") -> Issue:
|
|
264
|
+
self._check_status(status)
|
|
265
|
+
issue = self._require(key)
|
|
266
|
+
if issue.status == status:
|
|
267
|
+
raise TrackerError(f"{key} is already '{status}'")
|
|
268
|
+
issue.status = status
|
|
269
|
+
issue.updated_at = _utcnow()
|
|
270
|
+
issue.history.append(Transition(status=status, by=by, comment=comment))
|
|
271
|
+
return self._write(issue)
|
|
272
|
+
|
|
273
|
+
def link(self, key: str, to: str, link_type: str = "relates") -> Issue:
|
|
274
|
+
self._check_link_type(link_type)
|
|
275
|
+
if key == to:
|
|
276
|
+
raise TrackerError("an issue cannot be linked to itself")
|
|
277
|
+
issue = self._require(key)
|
|
278
|
+
self._require(to) # refuse dangling links: a link to nothing is worse than none
|
|
279
|
+
if not any(link.type == link_type and link.to == to for link in issue.links):
|
|
280
|
+
issue.links.append(IssueLink(type=link_type, to=to))
|
|
281
|
+
issue.updated_at = _utcnow()
|
|
282
|
+
return self._write(issue)
|
|
283
|
+
|
|
284
|
+
def search(
|
|
285
|
+
self,
|
|
286
|
+
*,
|
|
287
|
+
text: str | None = None,
|
|
288
|
+
project: str | None = None,
|
|
289
|
+
status: str | None = None,
|
|
290
|
+
label: str | None = None,
|
|
291
|
+
envelope_id: str | None = None,
|
|
292
|
+
fingerprint: str | None = None,
|
|
293
|
+
limit: int = 20,
|
|
294
|
+
) -> list[Issue]:
|
|
295
|
+
needle = (text or "").lower().strip()
|
|
296
|
+
found = []
|
|
297
|
+
for issue in self.issues():
|
|
298
|
+
if project and issue.project != project:
|
|
299
|
+
continue
|
|
300
|
+
if status and issue.status != status:
|
|
301
|
+
continue
|
|
302
|
+
if label and label not in issue.labels:
|
|
303
|
+
continue
|
|
304
|
+
if envelope_id and issue.envelope_id != envelope_id:
|
|
305
|
+
continue
|
|
306
|
+
if fingerprint and issue.fingerprint != fingerprint:
|
|
307
|
+
continue
|
|
308
|
+
if needle and needle not in f"{issue.title}\n{issue.body}".lower():
|
|
309
|
+
continue
|
|
310
|
+
found.append(issue)
|
|
311
|
+
found.reverse() # newest first
|
|
312
|
+
return found[: max(1, limit)]
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# -- Atlassian Document Format ---------------------------------------------
|
|
316
|
+
|
|
317
|
+
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+)$")
|
|
318
|
+
_BULLET_RE = re.compile(r"^\s*[-*+]\s+(.+)$")
|
|
319
|
+
_FENCE_RE = re.compile(r"^```\s*([A-Za-z0-9_+#.-]*)\s*$")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _adf_text(text: str) -> list[dict[str, Any]]:
|
|
323
|
+
"""ADF forbids an empty text node, so an empty string yields no children."""
|
|
324
|
+
return [{"type": "text", "text": text}] if text else []
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _adf_paragraph(text: str) -> dict[str, Any]:
|
|
328
|
+
node: dict[str, Any] = {"type": "paragraph"}
|
|
329
|
+
children = _adf_text(text)
|
|
330
|
+
if children:
|
|
331
|
+
node["content"] = children
|
|
332
|
+
return node
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def markdown_to_adf(text: str) -> dict[str, Any]:
|
|
336
|
+
"""Convert the markdown subset the house ticket format uses into ADF.
|
|
337
|
+
|
|
338
|
+
Jira Cloud's REST v3 rejects a plain string for `description` — it wants an
|
|
339
|
+
Atlassian Document Format node tree — so something has to do this. Rather
|
|
340
|
+
than take a markdown dependency for one field, this handles exactly the
|
|
341
|
+
block constructs the house ticket format actually emits, and is explicit
|
|
342
|
+
about the rest.
|
|
343
|
+
|
|
344
|
+
Supported:
|
|
345
|
+
* ATX headings `#` through `######` -> heading nodes, levels 1-6
|
|
346
|
+
* blank-line separated paragraphs -> paragraph nodes
|
|
347
|
+
* `-`, `*` or `+` bullet lists -> bulletList / listItem
|
|
348
|
+
* ``` fenced code, optional language -> codeBlock
|
|
349
|
+
|
|
350
|
+
NOT supported, deliberately. These are left as literal characters in the
|
|
351
|
+
text rather than dropped, because a reader can still understand
|
|
352
|
+
`**blocker**` or `[log](artifact://x)`, but cannot understand a body with
|
|
353
|
+
pieces silently missing:
|
|
354
|
+
* inline marks — bold, italic, inline code, links
|
|
355
|
+
* ordered lists, nested lists, task lists
|
|
356
|
+
* tables, block quotes, images, horizontal rules, footnotes
|
|
357
|
+
* raw HTML
|
|
358
|
+
|
|
359
|
+
A ticket that needs richer rendering should link to an artifact instead.
|
|
360
|
+
"""
|
|
361
|
+
lines = (text or "").replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
|
362
|
+
content: list[dict[str, Any]] = []
|
|
363
|
+
paragraph: list[str] = []
|
|
364
|
+
bullets: list[str] = []
|
|
365
|
+
|
|
366
|
+
def flush_paragraph() -> None:
|
|
367
|
+
if paragraph:
|
|
368
|
+
content.append(_adf_paragraph("\n".join(paragraph)))
|
|
369
|
+
paragraph.clear()
|
|
370
|
+
|
|
371
|
+
def flush_bullets() -> None:
|
|
372
|
+
if bullets:
|
|
373
|
+
content.append(
|
|
374
|
+
{
|
|
375
|
+
"type": "bulletList",
|
|
376
|
+
"content": [
|
|
377
|
+
{"type": "listItem", "content": [_adf_paragraph(item)]} for item in bullets
|
|
378
|
+
],
|
|
379
|
+
}
|
|
380
|
+
)
|
|
381
|
+
bullets.clear()
|
|
382
|
+
|
|
383
|
+
index = 0
|
|
384
|
+
while index < len(lines):
|
|
385
|
+
line = lines[index]
|
|
386
|
+
fence = _FENCE_RE.match(line.strip())
|
|
387
|
+
if fence:
|
|
388
|
+
flush_paragraph()
|
|
389
|
+
flush_bullets()
|
|
390
|
+
index += 1
|
|
391
|
+
body: list[str] = []
|
|
392
|
+
while index < len(lines) and not _FENCE_RE.match(lines[index].strip()):
|
|
393
|
+
body.append(lines[index])
|
|
394
|
+
index += 1
|
|
395
|
+
index += 1 # step over the closing fence, or past the end if unterminated
|
|
396
|
+
node: dict[str, Any] = {"type": "codeBlock"}
|
|
397
|
+
if fence.group(1):
|
|
398
|
+
node["attrs"] = {"language": fence.group(1)}
|
|
399
|
+
children = _adf_text("\n".join(body))
|
|
400
|
+
if children:
|
|
401
|
+
node["content"] = children
|
|
402
|
+
content.append(node)
|
|
403
|
+
continue
|
|
404
|
+
|
|
405
|
+
index += 1
|
|
406
|
+
heading = _HEADING_RE.match(line.strip())
|
|
407
|
+
if heading:
|
|
408
|
+
flush_paragraph()
|
|
409
|
+
flush_bullets()
|
|
410
|
+
content.append(
|
|
411
|
+
{
|
|
412
|
+
"type": "heading",
|
|
413
|
+
"attrs": {"level": len(heading.group(1))},
|
|
414
|
+
"content": _adf_text(heading.group(2).strip()),
|
|
415
|
+
}
|
|
416
|
+
)
|
|
417
|
+
continue
|
|
418
|
+
|
|
419
|
+
bullet = _BULLET_RE.match(line)
|
|
420
|
+
if bullet:
|
|
421
|
+
flush_paragraph()
|
|
422
|
+
bullets.append(bullet.group(1).strip())
|
|
423
|
+
continue
|
|
424
|
+
|
|
425
|
+
if not line.strip():
|
|
426
|
+
flush_paragraph()
|
|
427
|
+
flush_bullets()
|
|
428
|
+
continue
|
|
429
|
+
|
|
430
|
+
flush_bullets()
|
|
431
|
+
paragraph.append(line.rstrip())
|
|
432
|
+
|
|
433
|
+
flush_paragraph()
|
|
434
|
+
flush_bullets()
|
|
435
|
+
if not content:
|
|
436
|
+
content.append({"type": "paragraph"})
|
|
437
|
+
return {"type": "doc", "version": 1, "content": content}
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def adf_to_text(node: Any) -> str:
|
|
441
|
+
"""Flatten an ADF tree back to plain text, best effort.
|
|
442
|
+
|
|
443
|
+
Only used when reading an issue back out of Jira, so that `Issue.body` is
|
|
444
|
+
something an agent can grep. It is lossy on purpose — round-tripping ADF is
|
|
445
|
+
not a goal, and pretending otherwise would invite callers to trust it.
|
|
446
|
+
"""
|
|
447
|
+
if isinstance(node, str):
|
|
448
|
+
return node
|
|
449
|
+
if isinstance(node, list):
|
|
450
|
+
return "\n".join(part for part in (adf_to_text(child) for child in node) if part)
|
|
451
|
+
if not isinstance(node, dict):
|
|
452
|
+
return ""
|
|
453
|
+
kind = node.get("type")
|
|
454
|
+
if kind == "text":
|
|
455
|
+
return str(node.get("text", ""))
|
|
456
|
+
if kind == "hardBreak":
|
|
457
|
+
return "\n"
|
|
458
|
+
inner = adf_to_text(node.get("content", []))
|
|
459
|
+
if kind in ("paragraph", "heading", "codeBlock", "listItem", "blockquote"):
|
|
460
|
+
return inner
|
|
461
|
+
return inner
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
# -- Jira Cloud -------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
#: Where a human goes to mint the credential this adapter needs. Named in the
|
|
467
|
+
#: configuration error, because "JIRA_API_TOKEN is missing" without this line
|
|
468
|
+
#: sends the reader to a search engine.
|
|
469
|
+
JIRA_API_TOKEN_URL = "https://id.atlassian.com/manage-profile/security/api-tokens"
|
|
470
|
+
|
|
471
|
+
JIRA_API_BASE = "/rest/api/3"
|
|
472
|
+
JIRA_TIMEOUT_S = 30.0
|
|
473
|
+
#: Reads are retried on 429; see `JiraTracker._request` for why writes are not.
|
|
474
|
+
JIRA_READ_ATTEMPTS = 3
|
|
475
|
+
JIRA_MAX_RETRY_WAIT_S = 30.0
|
|
476
|
+
|
|
477
|
+
#: House metadata rides on labels because they are the only field guaranteed to
|
|
478
|
+
#: exist in every Jira project. Custom fields differ per instance and screen, so
|
|
479
|
+
#: writing to one is the fastest way to a 400 on someone else's Jira.
|
|
480
|
+
SEVERITY_LABEL_PREFIX = "severity-"
|
|
481
|
+
ENVELOPE_LABEL_PREFIX = "qaas-envelope-"
|
|
482
|
+
FINGERPRINT_LABEL_PREFIX = "qaas-fp-"
|
|
483
|
+
|
|
484
|
+
#: House status -> the Jira workflow names it plausibly means. Jira workflows
|
|
485
|
+
#: are per-project and unknowable from here, so this is a set of candidates to
|
|
486
|
+
#: try, never an assertion; a miss returns the real transition list (see
|
|
487
|
+
#: `transition`) rather than guessing.
|
|
488
|
+
JIRA_STATUS_ALIASES: dict[str, tuple[str, ...]] = {
|
|
489
|
+
"open": ("open", "to do", "todo", "backlog", "new", "reopened"),
|
|
490
|
+
"in_progress": ("in progress", "in development", "doing", "start progress"),
|
|
491
|
+
"in_review": ("in review", "code review", "review", "in code review"),
|
|
492
|
+
"resolved": ("resolved", "done", "fixed", "resolve issue"),
|
|
493
|
+
"closed": ("closed", "done", "close issue"),
|
|
494
|
+
"wont_fix": ("won't fix", "wont fix", "will not do", "won't do", "declined"),
|
|
495
|
+
"duplicate": ("duplicate", "duplicated", "closed as duplicate"),
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
#: House link type -> (Jira link type names to try in order, direction). The
|
|
499
|
+
#: direction says which end of the Jira link `key` sits on: "outward" means the
|
|
500
|
+
#: link reads `key <outward phrase> to`.
|
|
501
|
+
JIRA_LINK_TYPES: dict[str, tuple[tuple[str, ...], str]] = {
|
|
502
|
+
"duplicates": (("Duplicate", "Duplicates"), "outward"),
|
|
503
|
+
"relates": (("Relates", "Related"), "outward"),
|
|
504
|
+
"blocks": (("Blocks", "Blocker"), "outward"),
|
|
505
|
+
"blocked-by": (("Blocks", "Blocker"), "inward"),
|
|
506
|
+
"regression-of": (("Problem/Incident", "Causes", "Relates"), "inward"),
|
|
507
|
+
"caused-by": (("Problem/Incident", "Causes", "Relates"), "inward"),
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _fingerprint_label_value(fingerprint: str | None) -> str | None:
|
|
512
|
+
"""The digest half of a fingerprint, e.g. `sha256:ab12…` -> `ab12…`.
|
|
513
|
+
|
|
514
|
+
The algorithm prefix is dropped because a colon in a Jira label is not
|
|
515
|
+
worth betting a 400 on. The digest itself is kept whole: dedupe matches on
|
|
516
|
+
exact equality, and a truncated fingerprint would quietly collide.
|
|
517
|
+
"""
|
|
518
|
+
digest = (fingerprint or "").split(":")[-1].strip()
|
|
519
|
+
return digest or None
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _label_safe(value: str | None, prefix: str = "") -> str | None:
|
|
523
|
+
"""A Jira label, or None if the value cannot be one.
|
|
524
|
+
|
|
525
|
+
Jira rejects labels containing whitespace, and caps them at 255 characters.
|
|
526
|
+
Silently mangling an id would produce a label that never matches on the way
|
|
527
|
+
back out, so an unusable value produces no label at all.
|
|
528
|
+
"""
|
|
529
|
+
if not value:
|
|
530
|
+
return None
|
|
531
|
+
candidate = f"{prefix}{value.strip()}"
|
|
532
|
+
if not candidate or any(char.isspace() for char in candidate) or len(candidate) > 255:
|
|
533
|
+
return None
|
|
534
|
+
return candidate
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
class JiraTracker(TrackerAdapter):
|
|
538
|
+
"""Jira Cloud, over REST API v3, with credentials from the environment.
|
|
539
|
+
|
|
540
|
+
Everything this adapter needs is read from environment variables at
|
|
541
|
+
construction and validated there: a tracker that only discovers it cannot
|
|
542
|
+
authenticate after a run has produced twenty findings has destroyed the
|
|
543
|
+
run, because the findings live in an agent's context and the context is
|
|
544
|
+
gone. Credentials never come from `config/` — that directory is committed.
|
|
545
|
+
|
|
546
|
+
Required:
|
|
547
|
+
* ``JIRA_BASE_URL`` — e.g. ``https://acme.atlassian.net``
|
|
548
|
+
* ``JIRA_EMAIL`` — the bot account's Atlassian account email
|
|
549
|
+
* ``JIRA_API_TOKEN`` — an API token, not a password (Jira Cloud uses
|
|
550
|
+
HTTP Basic with email + token)
|
|
551
|
+
* ``JIRA_PROJECT_KEY``— the default project, e.g. ``CORVID``
|
|
552
|
+
|
|
553
|
+
Optional:
|
|
554
|
+
* ``JIRA_SECURITY_PROJECT_KEY`` — the restricted project. When it is
|
|
555
|
+
unset, `security_project` is None and the MCP server refuses to file
|
|
556
|
+
security findings at all. That refusal is the point: filing a
|
|
557
|
+
vulnerability into a project the whole company can read is a
|
|
558
|
+
disclosure, and there is no undo (§4.12 step 5, §10).
|
|
559
|
+
* ``JIRA_ISSUE_TYPE`` — the issue type to create, default ``Bug``. Not
|
|
560
|
+
every project has a type called Bug.
|
|
561
|
+
|
|
562
|
+
Jira Server / Data Center is a different product with different auth; see
|
|
563
|
+
`JiraDataCenterTracker` (§5.1).
|
|
564
|
+
"""
|
|
565
|
+
|
|
566
|
+
REQUIRED_ENV = (
|
|
567
|
+
"JIRA_BASE_URL",
|
|
568
|
+
"JIRA_EMAIL",
|
|
569
|
+
"JIRA_API_TOKEN",
|
|
570
|
+
"JIRA_PROJECT_KEY",
|
|
571
|
+
)
|
|
572
|
+
SECURITY_ENV = "JIRA_SECURITY_PROJECT_KEY"
|
|
573
|
+
ISSUE_TYPE_ENV = "JIRA_ISSUE_TYPE"
|
|
574
|
+
|
|
575
|
+
def __init__(
|
|
576
|
+
self,
|
|
577
|
+
*,
|
|
578
|
+
env: Mapping[str, str] | None = None,
|
|
579
|
+
timeout: float = JIRA_TIMEOUT_S,
|
|
580
|
+
):
|
|
581
|
+
source: Mapping[str, str] = os.environ if env is None else env
|
|
582
|
+
values = {name: (source.get(name) or "").strip() for name in self.REQUIRED_ENV}
|
|
583
|
+
missing = [name for name, value in values.items() if not value]
|
|
584
|
+
if missing:
|
|
585
|
+
raise TrackerConfigError(self._missing_env_message(missing))
|
|
586
|
+
|
|
587
|
+
base_url = values["JIRA_BASE_URL"].rstrip("/")
|
|
588
|
+
if not base_url.startswith(("http://", "https://")):
|
|
589
|
+
raise TrackerConfigError(
|
|
590
|
+
f"JIRA_BASE_URL is '{base_url}', which is not a URL. It must include the "
|
|
591
|
+
"scheme and be your Jira site root, e.g. https://acme.atlassian.net "
|
|
592
|
+
"(no /jira, no /rest/api path)."
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
self.base_url = base_url
|
|
596
|
+
self.email = values["JIRA_EMAIL"]
|
|
597
|
+
self._token = values["JIRA_API_TOKEN"]
|
|
598
|
+
self._project = values["JIRA_PROJECT_KEY"]
|
|
599
|
+
self._security_project = (source.get(self.SECURITY_ENV) or "").strip() or None
|
|
600
|
+
self.issue_type = (source.get(self.ISSUE_TYPE_ENV) or "").strip() or "Bug"
|
|
601
|
+
self.timeout = timeout
|
|
602
|
+
# Built once, at construction, so proxy settings are read from the
|
|
603
|
+
# environment the tracker was configured in rather than per call.
|
|
604
|
+
self._opener = urllib.request.build_opener()
|
|
605
|
+
self._link_type_cache: list[dict[str, Any]] | None = None
|
|
606
|
+
|
|
607
|
+
@classmethod
|
|
608
|
+
def _missing_env_message(cls, missing: list[str]) -> str:
|
|
609
|
+
"""Name every missing variable, and say where the token comes from.
|
|
610
|
+
|
|
611
|
+
Listing only the first missing variable turns one restart into four.
|
|
612
|
+
"""
|
|
613
|
+
verb = "is" if len(missing) == 1 else "are"
|
|
614
|
+
return (
|
|
615
|
+
f"JiraTracker is not configured: {', '.join(missing)} {verb} unset or empty in "
|
|
616
|
+
f"the environment. Set all of {', '.join(cls.REQUIRED_ENV)} — JIRA_BASE_URL is "
|
|
617
|
+
"your site root (https://acme.atlassian.net), JIRA_EMAIL is the bot account's "
|
|
618
|
+
"Atlassian email, JIRA_API_TOKEN is an API token created at "
|
|
619
|
+
f"{JIRA_API_TOKEN_URL} (a password will not work), and JIRA_PROJECT_KEY is the "
|
|
620
|
+
f"default project key (e.g. CORVID). Set {cls.SECURITY_ENV} as well to a "
|
|
621
|
+
"restricted project, or security findings will be refused rather than filed "
|
|
622
|
+
"into a public one (§4.12, §10). These are credentials: they come from the "
|
|
623
|
+
"environment, never from config/. Or run with tracker: local."
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
# -- routing ----------------------------------------------------------
|
|
627
|
+
|
|
628
|
+
@property
|
|
629
|
+
def default_project(self) -> str:
|
|
630
|
+
return self._project
|
|
631
|
+
|
|
632
|
+
@property
|
|
633
|
+
def security_project(self) -> str | None:
|
|
634
|
+
return self._security_project
|
|
635
|
+
|
|
636
|
+
# -- HTTP -------------------------------------------------------------
|
|
637
|
+
|
|
638
|
+
def _headers(self) -> dict[str, str]:
|
|
639
|
+
credential = base64.b64encode(f"{self.email}:{self._token}".encode()).decode()
|
|
640
|
+
return {
|
|
641
|
+
"Authorization": f"Basic {credential}",
|
|
642
|
+
"Accept": "application/json",
|
|
643
|
+
"Content-Type": "application/json",
|
|
644
|
+
"User-Agent": "qaas-tracker/1.0",
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
def _request(
|
|
648
|
+
self,
|
|
649
|
+
method: str,
|
|
650
|
+
path: str,
|
|
651
|
+
*,
|
|
652
|
+
body: dict[str, Any] | None = None,
|
|
653
|
+
params: dict[str, str] | None = None,
|
|
654
|
+
retry_on_429: bool = False,
|
|
655
|
+
) -> Any:
|
|
656
|
+
"""One Jira call. `retry_on_429` is only ever true for reads.
|
|
657
|
+
|
|
658
|
+
A retried POST /issue is a duplicate ticket, and duplicate tickets are
|
|
659
|
+
precisely what this system exists to prevent: a 429 can arrive after
|
|
660
|
+
Jira has already created the issue, so the second attempt files it
|
|
661
|
+
twice. Reads are idempotent and honour `Retry-After`.
|
|
662
|
+
"""
|
|
663
|
+
url = f"{self.base_url}{JIRA_API_BASE}{path}"
|
|
664
|
+
if params:
|
|
665
|
+
url = f"{url}?{urllib.parse.urlencode(params)}"
|
|
666
|
+
payload = json.dumps(body).encode("utf-8") if body is not None else None
|
|
667
|
+
attempts = JIRA_READ_ATTEMPTS if retry_on_429 else 1
|
|
668
|
+
|
|
669
|
+
for attempt in range(1, attempts + 1):
|
|
670
|
+
request = urllib.request.Request(
|
|
671
|
+
url, data=payload, method=method, headers=self._headers()
|
|
672
|
+
)
|
|
673
|
+
try:
|
|
674
|
+
with self._opener.open(request, timeout=self.timeout) as response:
|
|
675
|
+
raw = response.read()
|
|
676
|
+
return json.loads(raw.decode("utf-8")) if raw.strip() else {}
|
|
677
|
+
except urllib.error.HTTPError as exc:
|
|
678
|
+
if exc.code == 429 and attempt < attempts:
|
|
679
|
+
time.sleep(self._retry_after(exc))
|
|
680
|
+
continue
|
|
681
|
+
raise self._http_error(exc, method, path) from None
|
|
682
|
+
except urllib.error.URLError as exc:
|
|
683
|
+
raise TrackerError(
|
|
684
|
+
f"could not reach Jira at {self.base_url} ({exc.reason}); check "
|
|
685
|
+
"JIRA_BASE_URL, your network, and any proxy settings"
|
|
686
|
+
) from None
|
|
687
|
+
except TimeoutError:
|
|
688
|
+
raise TrackerError(
|
|
689
|
+
f"Jira did not answer {method} {path} within {self.timeout:g}s; "
|
|
690
|
+
"it may be degraded — retry, or check status.atlassian.com"
|
|
691
|
+
) from None
|
|
692
|
+
except json.JSONDecodeError:
|
|
693
|
+
raise TrackerError(
|
|
694
|
+
f"Jira returned a non-JSON body for {method} {path}; the URL in "
|
|
695
|
+
f"JIRA_BASE_URL ({self.base_url}) may point at a proxy or login page "
|
|
696
|
+
"rather than at a Jira site"
|
|
697
|
+
) from None
|
|
698
|
+
raise TrackerError(f"Jira rate-limited {method} {path} after {attempts} attempts")
|
|
699
|
+
|
|
700
|
+
@staticmethod
|
|
701
|
+
def _retry_after(exc: urllib.error.HTTPError) -> float:
|
|
702
|
+
"""Seconds to wait, from the server's own header where it gives one."""
|
|
703
|
+
raw = (exc.headers.get("Retry-After") if exc.headers else None) or ""
|
|
704
|
+
try:
|
|
705
|
+
wait = float(raw.strip())
|
|
706
|
+
except ValueError:
|
|
707
|
+
wait = 1.0
|
|
708
|
+
return max(0.0, min(wait, JIRA_MAX_RETRY_WAIT_S))
|
|
709
|
+
|
|
710
|
+
def _http_error(self, exc: urllib.error.HTTPError, method: str, path: str) -> TrackerError:
|
|
711
|
+
"""Turn a status code into something the reader can act on.
|
|
712
|
+
|
|
713
|
+
Generic "HTTP 403" tells an operator nothing about which of the four
|
|
714
|
+
plausible causes they have.
|
|
715
|
+
"""
|
|
716
|
+
detail = self._error_detail(exc)
|
|
717
|
+
suffix = f" Jira said: {detail}" if detail else ""
|
|
718
|
+
if exc.code == 401:
|
|
719
|
+
return TrackerError(
|
|
720
|
+
"Jira rejected the credentials (401). Check JIRA_EMAIL / JIRA_API_TOKEN: "
|
|
721
|
+
"the email must be the Atlassian account the token was minted for, and the "
|
|
722
|
+
f"token must be an API token from {JIRA_API_TOKEN_URL}, not a password. "
|
|
723
|
+
f"Revoked and expired tokens also return 401.{suffix}"
|
|
724
|
+
)
|
|
725
|
+
if exc.code == 403:
|
|
726
|
+
return TrackerError(
|
|
727
|
+
f"Jira refused the request (403) for {method} {path}. The account "
|
|
728
|
+
f"{self.email} is authenticated but lacks permission — check that it has "
|
|
729
|
+
f"Browse Projects and Create Issues on {self.default_project}"
|
|
730
|
+
+ (f" and {self.security_project}" if self.security_project else "")
|
|
731
|
+
+ f", and that the project has not been archived.{suffix}"
|
|
732
|
+
)
|
|
733
|
+
if exc.code == 404:
|
|
734
|
+
return TrackerError(
|
|
735
|
+
f"Jira has no such resource (404) for {method} {path}. If this was a "
|
|
736
|
+
f"create, check JIRA_PROJECT_KEY='{self.default_project}'"
|
|
737
|
+
+ (
|
|
738
|
+
f" / {self.SECURITY_ENV}='{self.security_project}'"
|
|
739
|
+
if self.security_project
|
|
740
|
+
else ""
|
|
741
|
+
)
|
|
742
|
+
+ " — a project key that does not exist, or that this account cannot "
|
|
743
|
+
f"browse, both surface as 404.{suffix}"
|
|
744
|
+
)
|
|
745
|
+
if exc.code == 429:
|
|
746
|
+
return TrackerError(
|
|
747
|
+
f"Jira rate-limited {method} {path} (429) and this call is not safe to "
|
|
748
|
+
"retry automatically. Slow the run down or lower the per-run ticket cap "
|
|
749
|
+
f"(§4.12).{suffix}"
|
|
750
|
+
)
|
|
751
|
+
if 500 <= exc.code < 600:
|
|
752
|
+
return TrackerError(
|
|
753
|
+
f"Jira returned {exc.code} for {method} {path}. This is Jira's side, not "
|
|
754
|
+
f"the request's; retry later.{suffix}"
|
|
755
|
+
)
|
|
756
|
+
return TrackerError(f"Jira rejected {method} {path} with HTTP {exc.code}.{suffix}")
|
|
757
|
+
|
|
758
|
+
@staticmethod
|
|
759
|
+
def _error_detail(exc: urllib.error.HTTPError) -> str:
|
|
760
|
+
"""Jira's own explanation, which is usually the useful half."""
|
|
761
|
+
try:
|
|
762
|
+
payload = json.loads(exc.read().decode("utf-8"))
|
|
763
|
+
except Exception: # noqa: BLE001 - an unreadable error body must not mask the status
|
|
764
|
+
return ""
|
|
765
|
+
if not isinstance(payload, dict):
|
|
766
|
+
return ""
|
|
767
|
+
parts = [str(message) for message in payload.get("errorMessages", []) or []]
|
|
768
|
+
errors = payload.get("errors")
|
|
769
|
+
if isinstance(errors, dict):
|
|
770
|
+
parts += [f"{field}: {message}" for field, message in errors.items()]
|
|
771
|
+
return "; ".join(parts)[:500]
|
|
772
|
+
|
|
773
|
+
# -- mapping ----------------------------------------------------------
|
|
774
|
+
|
|
775
|
+
@staticmethod
|
|
776
|
+
def _parse_time(value: Any) -> datetime:
|
|
777
|
+
"""Jira timestamps look like 2024-05-01T09:15:00.000+0000."""
|
|
778
|
+
if isinstance(value, str):
|
|
779
|
+
try:
|
|
780
|
+
return datetime.fromisoformat(value)
|
|
781
|
+
except ValueError:
|
|
782
|
+
pass
|
|
783
|
+
return _utcnow()
|
|
784
|
+
|
|
785
|
+
def _issue_from_jira(self, data: dict[str, Any]) -> Issue:
|
|
786
|
+
"""Map a Jira issue onto the house `Issue`.
|
|
787
|
+
|
|
788
|
+
`status` carries Jira's own status name, not one of `STATUSES`: the
|
|
789
|
+
workflow belongs to the project, and rewriting "Awaiting QA" into
|
|
790
|
+
"in_review" would be this adapter inventing facts.
|
|
791
|
+
"""
|
|
792
|
+
fields = data.get("fields") or {}
|
|
793
|
+
labels = [str(label) for label in fields.get("labels") or []]
|
|
794
|
+
|
|
795
|
+
def unprefixed(prefix: str) -> str | None:
|
|
796
|
+
"""The house metadata hidden in a label, on the way back out."""
|
|
797
|
+
return next(
|
|
798
|
+
(label[len(prefix) :] for label in labels if label.startswith(prefix)), None
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
severity = unprefixed(SEVERITY_LABEL_PREFIX)
|
|
802
|
+
envelope_id = unprefixed(ENVELOPE_LABEL_PREFIX)
|
|
803
|
+
# Put the algorithm prefix back, so a fingerprint read out of Jira
|
|
804
|
+
# compares equal to one an envelope computes (`DefectEnvelope.fingerprint`).
|
|
805
|
+
fingerprint = unprefixed(FINGERPRINT_LABEL_PREFIX)
|
|
806
|
+
if fingerprint and len(fingerprint) == 64 and all(c in "0123456789abcdef" for c in fingerprint):
|
|
807
|
+
fingerprint = f"sha256:{fingerprint}"
|
|
808
|
+
status = ((fields.get("status") or {}).get("name")) or "open"
|
|
809
|
+
project = ((fields.get("project") or {}).get("key")) or self.default_project
|
|
810
|
+
reporter = (fields.get("reporter") or {}).get("displayName")
|
|
811
|
+
return Issue(
|
|
812
|
+
key=str(data.get("key", "")),
|
|
813
|
+
project=str(project),
|
|
814
|
+
title=str(fields.get("summary") or ""),
|
|
815
|
+
body=adf_to_text(fields.get("description")),
|
|
816
|
+
status=str(status),
|
|
817
|
+
labels=sorted(labels),
|
|
818
|
+
severity=severity,
|
|
819
|
+
envelope_id=envelope_id,
|
|
820
|
+
fingerprint=fingerprint,
|
|
821
|
+
reporter=reporter,
|
|
822
|
+
links=self._links_from_jira(fields.get("issuelinks") or []),
|
|
823
|
+
created_at=self._parse_time(fields.get("created")),
|
|
824
|
+
updated_at=self._parse_time(fields.get("updated")),
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
@staticmethod
|
|
828
|
+
def _links_from_jira(raw_links: list[dict[str, Any]]) -> list[IssueLink]:
|
|
829
|
+
"""Map Jira's link vocabulary back to the house one, best effort."""
|
|
830
|
+
reverse: dict[tuple[str, str], str] = {}
|
|
831
|
+
for house, (names, direction) in JIRA_LINK_TYPES.items():
|
|
832
|
+
reverse.setdefault((names[0].lower(), direction), house)
|
|
833
|
+
links: list[IssueLink] = []
|
|
834
|
+
for entry in raw_links:
|
|
835
|
+
name = str(((entry.get("type") or {}).get("name") or "")).lower()
|
|
836
|
+
if entry.get("outwardIssue"):
|
|
837
|
+
other, direction = entry["outwardIssue"], "outward"
|
|
838
|
+
elif entry.get("inwardIssue"):
|
|
839
|
+
other, direction = entry["inwardIssue"], "inward"
|
|
840
|
+
else:
|
|
841
|
+
continue
|
|
842
|
+
key = other.get("key")
|
|
843
|
+
if not key:
|
|
844
|
+
continue
|
|
845
|
+
links.append(IssueLink(type=reverse.get((name, direction), "relates"), to=str(key)))
|
|
846
|
+
return links
|
|
847
|
+
|
|
848
|
+
_FIELDS = "summary,status,labels,description,issuelinks,reporter,project,created,updated"
|
|
849
|
+
|
|
850
|
+
# -- configuration checks ---------------------------------------------
|
|
851
|
+
# Read-only calls an operator can make before a run files anything real.
|
|
852
|
+
# They live here rather than in the CLI because knowing which endpoint
|
|
853
|
+
# answers "can this account create issues in this project" is Jira
|
|
854
|
+
# knowledge, and this module is where Jira knowledge is allowed to be.
|
|
855
|
+
|
|
856
|
+
#: The four project permissions this system needs, in Jira's own
|
|
857
|
+
#: vocabulary. Anything less and the failure arrives mid-run: Browse to read
|
|
858
|
+
#: an issue back, Create to file, Transition to close, Link to dedupe.
|
|
859
|
+
PROJECT_PERMISSIONS = ("BROWSE_PROJECTS", "CREATE_ISSUES", "TRANSITION_ISSUES", "LINK_ISSUES")
|
|
860
|
+
|
|
861
|
+
def whoami(self) -> dict[str, Any]:
|
|
862
|
+
"""The account these credentials belong to. Proves auth without writing."""
|
|
863
|
+
return self._request("GET", "/myself", retry_on_429=True)
|
|
864
|
+
|
|
865
|
+
def project_info(self, key: str) -> dict[str, Any]:
|
|
866
|
+
"""One project's metadata. A 404 here means the key is wrong or unreadable."""
|
|
867
|
+
return self._request("GET", f"/project/{urllib.parse.quote(key)}", retry_on_429=True)
|
|
868
|
+
|
|
869
|
+
def project_permissions(self, key: str) -> dict[str, bool]:
|
|
870
|
+
"""Which of `PROJECT_PERMISSIONS` this account actually holds on `key`.
|
|
871
|
+
|
|
872
|
+
Asked of Jira rather than inferred from a successful read: browsing a
|
|
873
|
+
project and being able to file into it are different grants, and the
|
|
874
|
+
gap between them is where a first live run dies.
|
|
875
|
+
"""
|
|
876
|
+
data = self._request(
|
|
877
|
+
"GET",
|
|
878
|
+
"/mypermissions",
|
|
879
|
+
params={"projectKey": key, "permissions": ",".join(self.PROJECT_PERMISSIONS)},
|
|
880
|
+
retry_on_429=True,
|
|
881
|
+
)
|
|
882
|
+
granted = data.get("permissions") or {}
|
|
883
|
+
return {
|
|
884
|
+
name: bool((granted.get(name) or {}).get("havePermission"))
|
|
885
|
+
for name in self.PROJECT_PERMISSIONS
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
def project_statuses(self, key: str) -> dict[str, list[str]]:
|
|
889
|
+
"""Issue type name -> the status names its workflow contains.
|
|
890
|
+
|
|
891
|
+
This is the only read-only view of a project's workflow vocabulary.
|
|
892
|
+
`/issue/{key}/transitions` is more precise but needs an issue that
|
|
893
|
+
already exists, and shows only the edges out of that one issue's
|
|
894
|
+
current status — useless for "will this project ever accept 'closed'".
|
|
895
|
+
"""
|
|
896
|
+
data = self._request(
|
|
897
|
+
"GET", f"/project/{urllib.parse.quote(key)}/statuses", retry_on_429=True
|
|
898
|
+
)
|
|
899
|
+
entries = data if isinstance(data, list) else []
|
|
900
|
+
return {
|
|
901
|
+
str(entry.get("name") or ""): [
|
|
902
|
+
str((status or {}).get("name") or "") for status in entry.get("statuses") or []
|
|
903
|
+
]
|
|
904
|
+
for entry in entries
|
|
905
|
+
if isinstance(entry, dict)
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
@staticmethod
|
|
909
|
+
def map_house_statuses(status_names: list[str]) -> dict[str, str | None]:
|
|
910
|
+
"""House status -> the project status it will resolve to, or None.
|
|
911
|
+
|
|
912
|
+
Mirrors `_match_transition`'s candidate order, so what this predicts is
|
|
913
|
+
what a transition will actually do. A None is a silent failure waiting
|
|
914
|
+
to happen: PROOF asks for 'closed', nothing matches, and the ticket sits
|
|
915
|
+
open while the run reports success.
|
|
916
|
+
"""
|
|
917
|
+
available = {name.strip().lower(): name for name in status_names if name.strip()}
|
|
918
|
+
mapped: dict[str, str | None] = {}
|
|
919
|
+
for house in STATUSES:
|
|
920
|
+
candidates = [house, house.replace("_", " "), house.replace("-", " ")]
|
|
921
|
+
candidates += list(JIRA_STATUS_ALIASES.get(house, ()))
|
|
922
|
+
mapped[house] = next(
|
|
923
|
+
(available[candidate] for candidate in candidates if candidate in available), None
|
|
924
|
+
)
|
|
925
|
+
return mapped
|
|
926
|
+
|
|
927
|
+
# -- operations -------------------------------------------------------
|
|
928
|
+
|
|
929
|
+
@staticmethod
|
|
930
|
+
def _description(body: str, reporter: str | None) -> str:
|
|
931
|
+
"""The ticket body with the filing agent named inside it.
|
|
932
|
+
|
|
933
|
+
Jira sets `reporter` from the credential whatever we send, so the agent
|
|
934
|
+
that found the defect has to be recorded somewhere that stays true.
|
|
935
|
+
"""
|
|
936
|
+
if not reporter:
|
|
937
|
+
return body
|
|
938
|
+
return (
|
|
939
|
+
f"{body}\n\nFiled by {reporter} (automated QA)."
|
|
940
|
+
if body
|
|
941
|
+
else f"Filed by {reporter} (automated QA)."
|
|
942
|
+
)
|
|
943
|
+
|
|
944
|
+
@staticmethod
|
|
945
|
+
def _labels_for(
|
|
946
|
+
labels: list[str] | None,
|
|
947
|
+
severity: str | None,
|
|
948
|
+
envelope_id: str | None,
|
|
949
|
+
fingerprint: str | None,
|
|
950
|
+
) -> list[str]:
|
|
951
|
+
"""Caller labels plus the house metadata labels, deduped and sorted."""
|
|
952
|
+
all_labels = set(labels or [])
|
|
953
|
+
for value, prefix in (
|
|
954
|
+
(severity, SEVERITY_LABEL_PREFIX),
|
|
955
|
+
(envelope_id, ENVELOPE_LABEL_PREFIX),
|
|
956
|
+
(_fingerprint_label_value(fingerprint), FINGERPRINT_LABEL_PREFIX),
|
|
957
|
+
):
|
|
958
|
+
label = _label_safe(value, prefix)
|
|
959
|
+
if label:
|
|
960
|
+
all_labels.add(label)
|
|
961
|
+
return sorted(all_labels)
|
|
962
|
+
|
|
963
|
+
def create_payload(
|
|
964
|
+
self,
|
|
965
|
+
*,
|
|
966
|
+
project: str,
|
|
967
|
+
title: str,
|
|
968
|
+
body: str = "",
|
|
969
|
+
labels: list[str] | None = None,
|
|
970
|
+
severity: str | None = None,
|
|
971
|
+
envelope_id: str | None = None,
|
|
972
|
+
fingerprint: str | None = None,
|
|
973
|
+
reporter: str | None = None,
|
|
974
|
+
) -> dict[str, Any]:
|
|
975
|
+
"""The exact JSON body `create_issue` would POST to `/issue`.
|
|
976
|
+
|
|
977
|
+
Split out so `qaas tracker-check --dry-run-ticket` can show an operator
|
|
978
|
+
the ADF and the labels before a real ticket lands in front of real
|
|
979
|
+
people. It must be the same code path: a preview that *reconstructs*
|
|
980
|
+
the payload is correct only until the day it drifts, and it would be
|
|
981
|
+
trusted either way.
|
|
982
|
+
"""
|
|
983
|
+
if not title.strip():
|
|
984
|
+
raise TrackerError("an issue needs a title")
|
|
985
|
+
return {
|
|
986
|
+
"fields": {
|
|
987
|
+
"project": {"key": project},
|
|
988
|
+
"summary": title.strip()[:255], # Jira's summary limit; a 400 here is silly
|
|
989
|
+
"description": markdown_to_adf(self._description(body, reporter)),
|
|
990
|
+
"issuetype": {"name": self.issue_type},
|
|
991
|
+
"labels": self._labels_for(labels, severity, envelope_id, fingerprint),
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
def create_issue(
|
|
996
|
+
self,
|
|
997
|
+
*,
|
|
998
|
+
project: str,
|
|
999
|
+
title: str,
|
|
1000
|
+
body: str = "",
|
|
1001
|
+
labels: list[str] | None = None,
|
|
1002
|
+
severity: str | None = None,
|
|
1003
|
+
envelope_id: str | None = None,
|
|
1004
|
+
fingerprint: str | None = None,
|
|
1005
|
+
reporter: str | None = None,
|
|
1006
|
+
) -> Issue:
|
|
1007
|
+
payload = self.create_payload(
|
|
1008
|
+
project=project,
|
|
1009
|
+
title=title,
|
|
1010
|
+
body=body,
|
|
1011
|
+
labels=labels,
|
|
1012
|
+
severity=severity,
|
|
1013
|
+
envelope_id=envelope_id,
|
|
1014
|
+
fingerprint=fingerprint,
|
|
1015
|
+
reporter=reporter,
|
|
1016
|
+
)
|
|
1017
|
+
created = self._request("POST", "/issue", body=payload, retry_on_429=False)
|
|
1018
|
+
key = str(created.get("key") or "")
|
|
1019
|
+
if not key:
|
|
1020
|
+
raise TrackerError(f"Jira accepted the issue but returned no key: {created!r}")
|
|
1021
|
+
|
|
1022
|
+
fallback = self._local_issue(
|
|
1023
|
+
key, project, title, self._description(body, reporter),
|
|
1024
|
+
list(payload["fields"]["labels"]),
|
|
1025
|
+
severity, envelope_id, fingerprint, reporter,
|
|
1026
|
+
)
|
|
1027
|
+
# Reading the issue back is a convenience, not the record. The ticket
|
|
1028
|
+
# exists the moment Jira answered the POST, so a failure here must not
|
|
1029
|
+
# lose the key — a filed ticket nobody can name is a stranded ticket.
|
|
1030
|
+
try:
|
|
1031
|
+
return self.get(key) or fallback
|
|
1032
|
+
except TrackerError:
|
|
1033
|
+
return fallback
|
|
1034
|
+
|
|
1035
|
+
@staticmethod
|
|
1036
|
+
def _local_issue(
|
|
1037
|
+
key: str,
|
|
1038
|
+
project: str,
|
|
1039
|
+
title: str,
|
|
1040
|
+
body: str,
|
|
1041
|
+
labels: list[str],
|
|
1042
|
+
severity: str | None,
|
|
1043
|
+
envelope_id: str | None,
|
|
1044
|
+
fingerprint: str | None,
|
|
1045
|
+
reporter: str | None,
|
|
1046
|
+
) -> Issue:
|
|
1047
|
+
"""What we know about an issue Jira created but would not read back."""
|
|
1048
|
+
return Issue(
|
|
1049
|
+
key=key,
|
|
1050
|
+
project=project,
|
|
1051
|
+
title=title.strip(),
|
|
1052
|
+
body=body,
|
|
1053
|
+
labels=labels,
|
|
1054
|
+
severity=severity,
|
|
1055
|
+
envelope_id=envelope_id,
|
|
1056
|
+
fingerprint=fingerprint,
|
|
1057
|
+
reporter=reporter,
|
|
1058
|
+
history=[Transition(status="open", by=reporter, comment="filed")],
|
|
1059
|
+
)
|
|
1060
|
+
|
|
1061
|
+
def get(self, key: str) -> Issue | None:
|
|
1062
|
+
if not _KEY_RE.match(key):
|
|
1063
|
+
raise TrackerError(f"'{key}' is not an issue key; expected e.g. {self._project}-1")
|
|
1064
|
+
try:
|
|
1065
|
+
data = self._request(
|
|
1066
|
+
"GET", f"/issue/{urllib.parse.quote(key)}",
|
|
1067
|
+
params={"fields": self._FIELDS}, retry_on_429=True,
|
|
1068
|
+
)
|
|
1069
|
+
except TrackerError as exc:
|
|
1070
|
+
if "(404)" in str(exc):
|
|
1071
|
+
return None
|
|
1072
|
+
raise
|
|
1073
|
+
return self._issue_from_jira(data)
|
|
1074
|
+
|
|
1075
|
+
def _require(self, key: str) -> Issue:
|
|
1076
|
+
issue = self.get(key)
|
|
1077
|
+
if issue is None:
|
|
1078
|
+
raise UnknownIssue(f"no issue '{key}' in the tracker")
|
|
1079
|
+
return issue
|
|
1080
|
+
|
|
1081
|
+
def transition(self, key: str, status: str, *, by: str | None = None, comment: str = "") -> Issue:
|
|
1082
|
+
"""Move an issue by *status name*, resolved against the real workflow.
|
|
1083
|
+
|
|
1084
|
+
Jira's API takes a transition id, and ids are per-project and unstable,
|
|
1085
|
+
so the name has to be resolved every time. A name that does not resolve
|
|
1086
|
+
raises with the transitions that do exist: doing nothing quietly is the
|
|
1087
|
+
worst outcome here, because the caller believes the ticket moved.
|
|
1088
|
+
"""
|
|
1089
|
+
issue = self._require(key)
|
|
1090
|
+
available = (
|
|
1091
|
+
self._request(
|
|
1092
|
+
"GET", f"/issue/{urllib.parse.quote(key)}/transitions", retry_on_429=True
|
|
1093
|
+
).get("transitions")
|
|
1094
|
+
or []
|
|
1095
|
+
)
|
|
1096
|
+
chosen = self._match_transition(status, available)
|
|
1097
|
+
if chosen is None:
|
|
1098
|
+
offered = ", ".join(
|
|
1099
|
+
f"'{t.get('name')}' (-> {(t.get('to') or {}).get('name', '?')})"
|
|
1100
|
+
for t in available
|
|
1101
|
+
) or "none at all"
|
|
1102
|
+
raise TrackerError(
|
|
1103
|
+
f"cannot move {key} to '{status}': no such transition from its current "
|
|
1104
|
+
f"status '{issue.status}'. Jira offers: {offered}. Jira workflows are "
|
|
1105
|
+
"per-project — use one of those names, not a house status."
|
|
1106
|
+
)
|
|
1107
|
+
|
|
1108
|
+
self._request(
|
|
1109
|
+
"POST", f"/issue/{urllib.parse.quote(key)}/transitions",
|
|
1110
|
+
body={"transition": {"id": str(chosen["id"])}}, retry_on_429=False,
|
|
1111
|
+
)
|
|
1112
|
+
|
|
1113
|
+
if comment or by:
|
|
1114
|
+
note = f"{by or 'qaas'}: {comment}" if comment else f"Transitioned by {by}."
|
|
1115
|
+
try:
|
|
1116
|
+
self._request(
|
|
1117
|
+
"POST", f"/issue/{urllib.parse.quote(key)}/comment",
|
|
1118
|
+
body={"body": markdown_to_adf(note)}, retry_on_429=False,
|
|
1119
|
+
)
|
|
1120
|
+
except TrackerError as exc:
|
|
1121
|
+
# The transition already applied; saying nothing would leave the
|
|
1122
|
+
# caller believing the audit trail is complete when it is not.
|
|
1123
|
+
raise TrackerError(
|
|
1124
|
+
f"{key} was transitioned to '{chosen.get('name')}', but the comment "
|
|
1125
|
+
f"could not be added: {exc}"
|
|
1126
|
+
) from None
|
|
1127
|
+
|
|
1128
|
+
return self._require(key)
|
|
1129
|
+
|
|
1130
|
+
@staticmethod
|
|
1131
|
+
def _match_transition(
|
|
1132
|
+
status: str, available: list[dict[str, Any]]
|
|
1133
|
+
) -> dict[str, Any] | None:
|
|
1134
|
+
"""Match a requested status against transition and target names.
|
|
1135
|
+
|
|
1136
|
+
Case-insensitive, and it tries the house aliases (`in_progress` ->
|
|
1137
|
+
"In Progress", "Doing", ...) so callers speaking the house vocabulary
|
|
1138
|
+
work against an ordinary Jira workflow without a mapping file.
|
|
1139
|
+
"""
|
|
1140
|
+
wanted = status.strip().lower()
|
|
1141
|
+
candidates = [wanted, wanted.replace("_", " "), wanted.replace("-", " ")]
|
|
1142
|
+
candidates += list(JIRA_STATUS_ALIASES.get(wanted, ()))
|
|
1143
|
+
seen: list[str] = []
|
|
1144
|
+
for candidate in candidates:
|
|
1145
|
+
if candidate in seen:
|
|
1146
|
+
continue
|
|
1147
|
+
seen.append(candidate)
|
|
1148
|
+
for entry in available:
|
|
1149
|
+
names = {
|
|
1150
|
+
str(entry.get("name") or "").strip().lower(),
|
|
1151
|
+
str((entry.get("to") or {}).get("name") or "").strip().lower(),
|
|
1152
|
+
}
|
|
1153
|
+
if candidate in names and entry.get("id") is not None:
|
|
1154
|
+
return entry
|
|
1155
|
+
return None
|
|
1156
|
+
|
|
1157
|
+
def _link_type_names(self) -> list[dict[str, Any]]:
|
|
1158
|
+
if self._link_type_cache is None:
|
|
1159
|
+
self._link_type_cache = (
|
|
1160
|
+
self._request("GET", "/issueLinkType", retry_on_429=True).get("issueLinkTypes")
|
|
1161
|
+
or []
|
|
1162
|
+
)
|
|
1163
|
+
return self._link_type_cache
|
|
1164
|
+
|
|
1165
|
+
def link(self, key: str, to: str, link_type: str = "relates") -> Issue:
|
|
1166
|
+
self._check_link_type(link_type)
|
|
1167
|
+
if key == to:
|
|
1168
|
+
raise TrackerError("an issue cannot be linked to itself")
|
|
1169
|
+
self._require(key)
|
|
1170
|
+
self._require(to) # refuse dangling links: a link to nothing is worse than none
|
|
1171
|
+
|
|
1172
|
+
names, direction = JIRA_LINK_TYPES[link_type]
|
|
1173
|
+
installed = {str(entry.get("name") or ""): entry for entry in self._link_type_names()}
|
|
1174
|
+
chosen = next((name for name in names if name in installed), None)
|
|
1175
|
+
if chosen is None:
|
|
1176
|
+
# Relates exists in every stock Jira; if even that is gone, say what
|
|
1177
|
+
# this instance does have rather than sending an unusable name.
|
|
1178
|
+
chosen = next((name for name in installed if name.lower() == "relates"), None)
|
|
1179
|
+
if chosen is None:
|
|
1180
|
+
raise TrackerError(
|
|
1181
|
+
f"this Jira has no link type usable for '{link_type}'; it offers: "
|
|
1182
|
+
f"{', '.join(sorted(installed)) or 'none'}"
|
|
1183
|
+
)
|
|
1184
|
+
|
|
1185
|
+
# Jira reads a link as "<outwardIssue> <outward phrase> <inwardIssue>",
|
|
1186
|
+
# so `key` sits at whichever end the house link type names. Getting this
|
|
1187
|
+
# backwards silently inverts the meaning of every link filed.
|
|
1188
|
+
near, far = ("outwardIssue", "inwardIssue") if direction == "outward" else (
|
|
1189
|
+
"inwardIssue", "outwardIssue"
|
|
1190
|
+
)
|
|
1191
|
+
self._request(
|
|
1192
|
+
"POST", "/issueLink",
|
|
1193
|
+
body={"type": {"name": chosen}, near: {"key": key}, far: {"key": to}},
|
|
1194
|
+
retry_on_429=False,
|
|
1195
|
+
)
|
|
1196
|
+
return self._require(key)
|
|
1197
|
+
|
|
1198
|
+
def search(
|
|
1199
|
+
self,
|
|
1200
|
+
*,
|
|
1201
|
+
text: str | None = None,
|
|
1202
|
+
project: str | None = None,
|
|
1203
|
+
status: str | None = None,
|
|
1204
|
+
label: str | None = None,
|
|
1205
|
+
envelope_id: str | None = None,
|
|
1206
|
+
fingerprint: str | None = None,
|
|
1207
|
+
limit: int = 20,
|
|
1208
|
+
) -> list[Issue]:
|
|
1209
|
+
"""Search by JQL, scoped to the configured projects by default.
|
|
1210
|
+
|
|
1211
|
+
Unscoped, this would sweep every project the bot can see — slow, and it
|
|
1212
|
+
drags unrelated tickets into an agent's context where they read as
|
|
1213
|
+
prior art. The scope is the projects this system files into.
|
|
1214
|
+
"""
|
|
1215
|
+
clauses: list[str] = []
|
|
1216
|
+
if project:
|
|
1217
|
+
clauses.append(f"project = {self._jql_value(project)}")
|
|
1218
|
+
else:
|
|
1219
|
+
scope = [self._project] + ([self._security_project] if self._security_project else [])
|
|
1220
|
+
clauses.append(
|
|
1221
|
+
"project in (" + ", ".join(self._jql_value(p) for p in scope) + ")"
|
|
1222
|
+
)
|
|
1223
|
+
if status:
|
|
1224
|
+
clauses.append(f"status = {self._jql_value(status)}")
|
|
1225
|
+
for value, prefix in (
|
|
1226
|
+
(label, ""),
|
|
1227
|
+
(envelope_id, ENVELOPE_LABEL_PREFIX),
|
|
1228
|
+
(_fingerprint_label_value(fingerprint), FINGERPRINT_LABEL_PREFIX),
|
|
1229
|
+
):
|
|
1230
|
+
if value:
|
|
1231
|
+
clauses.append(f"labels = {self._jql_value(f'{prefix}{value}')}")
|
|
1232
|
+
if text and text.strip():
|
|
1233
|
+
clauses.append(f"text ~ {self._jql_value(text.strip())}")
|
|
1234
|
+
|
|
1235
|
+
jql = " AND ".join(clauses) + " ORDER BY created DESC"
|
|
1236
|
+
# POST /search/jql, not GET /search: Atlassian deprecated the latter,
|
|
1237
|
+
# which is exactly the trap §5.1 warns about.
|
|
1238
|
+
data = self._request(
|
|
1239
|
+
"POST", "/search/jql",
|
|
1240
|
+
body={
|
|
1241
|
+
"jql": jql,
|
|
1242
|
+
"maxResults": max(1, min(int(limit), 100)),
|
|
1243
|
+
"fields": self._FIELDS.split(","),
|
|
1244
|
+
},
|
|
1245
|
+
retry_on_429=True,
|
|
1246
|
+
)
|
|
1247
|
+
return [self._issue_from_jira(entry) for entry in data.get("issues") or []]
|
|
1248
|
+
|
|
1249
|
+
@staticmethod
|
|
1250
|
+
def _jql_value(value: str) -> str:
|
|
1251
|
+
"""Quote a JQL literal. Unescaped quotes are an injection, not a typo."""
|
|
1252
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
1253
|
+
return f'"{escaped}"'
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
class JiraDataCenterTracker(TrackerAdapter):
|
|
1257
|
+
"""Not implemented: Jira Server / Data Center is a different integration.
|
|
1258
|
+
|
|
1259
|
+
§5.1 flags this as a decision to take before wiring anything. Data Center
|
|
1260
|
+
is not Cloud with a different hostname: it authenticates with a Personal
|
|
1261
|
+
Access Token (`Authorization: Bearer <pat>`) rather than email + API token,
|
|
1262
|
+
it serves REST API v2 rather than v3, and v2 takes a plain-text or wiki
|
|
1263
|
+
description where v3 requires ADF — so `JiraTracker`'s converter is not
|
|
1264
|
+
just unnecessary there, it is wrong. Atlassian's official hosted MCP server
|
|
1265
|
+
is Cloud-only; the self-hosted route is the community
|
|
1266
|
+
`sooperset/mcp-atlassian` server.
|
|
1267
|
+
|
|
1268
|
+
Implementing this by subclassing `JiraTracker` would be a mistake: it would
|
|
1269
|
+
inherit the ADF conversion and the v3 paths, and fail in ways that look
|
|
1270
|
+
like Jira being broken rather than the adapter being wrong.
|
|
1271
|
+
"""
|
|
1272
|
+
|
|
1273
|
+
REQUIRED_ENV = ("JIRA_BASE_URL", "JIRA_PERSONAL_ACCESS_TOKEN", "JIRA_PROJECT_KEY")
|
|
1274
|
+
|
|
1275
|
+
def __init__(self, *_args: Any, **_kwargs: Any):
|
|
1276
|
+
raise NotImplementedError(
|
|
1277
|
+
"JiraDataCenterTracker is a stub. Jira Server/Data Center uses PAT auth "
|
|
1278
|
+
"(Authorization: Bearer, from "
|
|
1279
|
+
f"{', '.join(self.REQUIRED_ENV)}) against REST API v2, whose description "
|
|
1280
|
+
"field is plain text or wiki markup rather than ADF — so this is a separate "
|
|
1281
|
+
"adapter, not a flag on JiraTracker (§5.1). Use tracker: jira for Jira Cloud, "
|
|
1282
|
+
"or tracker: local."
|
|
1283
|
+
)
|
|
1284
|
+
|
|
1285
|
+
def create_issue(self, **_kwargs: Any) -> Issue: # pragma: no cover - unreachable stub
|
|
1286
|
+
raise NotImplementedError
|
|
1287
|
+
|
|
1288
|
+
def transition(self, key: str, status: str, **_kwargs: Any) -> Issue: # pragma: no cover
|
|
1289
|
+
raise NotImplementedError
|
|
1290
|
+
|
|
1291
|
+
def link(self, key: str, to: str, link_type: str = "relates") -> Issue: # pragma: no cover
|
|
1292
|
+
raise NotImplementedError
|
|
1293
|
+
|
|
1294
|
+
def search(self, **_kwargs: Any) -> list[Issue]: # pragma: no cover
|
|
1295
|
+
raise NotImplementedError
|
|
1296
|
+
|
|
1297
|
+
def get(self, key: str) -> Issue | None: # pragma: no cover
|
|
1298
|
+
raise NotImplementedError
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
def build_tracker(backend: str, root: Path | str) -> TrackerAdapter:
|
|
1302
|
+
"""Pick the adapter named by `config.tracker`.
|
|
1303
|
+
|
|
1304
|
+
`jira` builds a live Jira Cloud client, which validates its environment
|
|
1305
|
+
here and now: a misconfigured tracker must fail before an agent starts
|
|
1306
|
+
finding things, not after.
|
|
1307
|
+
"""
|
|
1308
|
+
if backend == "local":
|
|
1309
|
+
return LocalTracker(root)
|
|
1310
|
+
if backend == "jira":
|
|
1311
|
+
return JiraTracker()
|
|
1312
|
+
raise ValueError(f"unknown tracker backend '{backend}'; expected 'local' or 'jira'")
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
def issue_summary(issue: Issue) -> dict[str, object]:
|
|
1316
|
+
"""The compact view returned to an agent; full bodies would bloat context."""
|
|
1317
|
+
return {
|
|
1318
|
+
"key": issue.key,
|
|
1319
|
+
"project": issue.project,
|
|
1320
|
+
"title": issue.title,
|
|
1321
|
+
"status": issue.status,
|
|
1322
|
+
"severity": issue.severity,
|
|
1323
|
+
"labels": issue.labels,
|
|
1324
|
+
"envelope_id": issue.envelope_id,
|
|
1325
|
+
"links": [link.model_dump() for link in issue.links],
|
|
1326
|
+
"updated_at": issue.updated_at.isoformat(),
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
__all__ = [
|
|
1331
|
+
"DEFAULT_PROJECT",
|
|
1332
|
+
"SECURITY_PROJECT",
|
|
1333
|
+
"STATUSES",
|
|
1334
|
+
"LINK_TYPES",
|
|
1335
|
+
"Issue",
|
|
1336
|
+
"IssueLink",
|
|
1337
|
+
"Transition",
|
|
1338
|
+
"TrackerAdapter",
|
|
1339
|
+
"TrackerError",
|
|
1340
|
+
"TrackerConfigError",
|
|
1341
|
+
"UnknownIssue",
|
|
1342
|
+
"LocalTracker",
|
|
1343
|
+
"JiraTracker",
|
|
1344
|
+
"JiraDataCenterTracker",
|
|
1345
|
+
"JIRA_API_TOKEN_URL",
|
|
1346
|
+
"markdown_to_adf",
|
|
1347
|
+
"adf_to_text",
|
|
1348
|
+
"build_tracker",
|
|
1349
|
+
"issue_summary",
|
|
1350
|
+
]
|