qaas-python 0.0.1__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 +1783 -0
- qaas/adapters/vcs.py +555 -0
- qaas/cli.py +1757 -0
- qaas/config.py +409 -0
- qaas/defaults/config/agents/api.yaml +18 -0
- qaas/defaults/config/agents/architect.yaml +21 -0
- qaas/defaults/config/agents/auditor.yaml +19 -0
- qaas/defaults/config/agents/browser.yaml +15 -0
- qaas/defaults/config/agents/dba.yaml +20 -0
- qaas/defaults/config/agents/fixer.yaml +55 -0
- qaas/defaults/config/agents/guide.yaml +23 -0
- qaas/defaults/config/agents/load.yaml +26 -0
- qaas/defaults/config/agents/mapper.yaml +19 -0
- qaas/defaults/config/agents/reporter.yaml +19 -0
- qaas/defaults/config/agents/reproducer.yaml +21 -0
- qaas/defaults/config/agents/reviewer.yaml +18 -0
- qaas/defaults/config/agents/socket.yaml +23 -0
- qaas/defaults/config/agents/triage.yaml +20 -0
- qaas/defaults/config/agents/verifier.yaml +20 -0
- qaas/defaults/config/system.yaml +64 -0
- qaas/discover.py +242 -0
- qaas/envelope.py +318 -0
- qaas/envfile.py +100 -0
- qaas/guardrails.py +589 -0
- qaas/mcp/__init__.py +0 -0
- qaas/mcp/context.py +78 -0
- qaas/mcp/contract_diff.py +1011 -0
- qaas/mcp/defect_memory.py +495 -0
- qaas/mcp/env_control.py +925 -0
- qaas/mcp/envelope_server.py +463 -0
- qaas/mcp/test_runner.py +842 -0
- qaas/mcp/tracker.py +420 -0
- qaas/mcp/vcs.py +501 -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/API.md +44 -0
- qaas/prompts/ARCHITECT.md +80 -0
- qaas/prompts/AUDITOR.md +62 -0
- qaas/prompts/BROWSER.md +46 -0
- qaas/prompts/DBA.md +59 -0
- qaas/prompts/FIXER.md +55 -0
- qaas/prompts/GUIDE.md +94 -0
- qaas/prompts/LOAD.md +109 -0
- qaas/prompts/MAPPER.md +46 -0
- qaas/prompts/REPORTER.md +61 -0
- qaas/prompts/REPRODUCER.md +43 -0
- qaas/prompts/REVIEWER.md +53 -0
- qaas/prompts/SOCKET.md +100 -0
- qaas/prompts/TRIAGE.md +45 -0
- qaas/prompts/VERIFIER.md +41 -0
- qaas/prompts/_shared.md +45 -0
- qaas/registry.py +496 -0
- qaas/router.py +581 -0
- qaas/runner.py +210 -0
- qaas/scorecard.py +448 -0
- qaas/sdk_compat.py +52 -0
- qaas/store.py +323 -0
- qaas/target.py +287 -0
- qaas/tasks.py +438 -0
- qaas/trace.py +342 -0
- qaas_python-0.0.1.dist-info/METADATA +429 -0
- qaas_python-0.0.1.dist-info/RECORD +96 -0
- qaas_python-0.0.1.dist-info/WHEEL +4 -0
- qaas_python-0.0.1.dist-info/entry_points.txt +2 -0
- qaas_python-0.0.1.dist-info/licenses/LICENSE +21 -0
qaas/adapters/tracker.py
ADDED
|
@@ -0,0 +1,1783 @@
|
|
|
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 dataclasses import dataclass, replace
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
28
|
+
|
|
29
|
+
# The house project keys. `SECURITY_PROJECT` is the restricted one (§4.12 step 5,
|
|
30
|
+
# §10 "security findings leak into public tickets"); it is defined here so the
|
|
31
|
+
# adapter and the server cannot drift apart about what "restricted" means.
|
|
32
|
+
DEFAULT_PROJECT = "CORVID"
|
|
33
|
+
SECURITY_PROJECT = "CORVID-SEC"
|
|
34
|
+
|
|
35
|
+
# A closed vocabulary, so an agent that invents a status gets the list back and
|
|
36
|
+
# retries rather than writing a state nothing downstream can interpret.
|
|
37
|
+
STATUSES = ("open", "in_progress", "in_review", "resolved", "closed", "wont_fix", "duplicate")
|
|
38
|
+
LINK_TYPES = ("duplicates", "relates", "blocks", "blocked-by", "regression-of", "caused-by")
|
|
39
|
+
|
|
40
|
+
_KEY_RE = re.compile(r"^(?P<project>[A-Z][A-Z0-9-]*)-(?P<number>\d+)$")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _utcnow() -> datetime:
|
|
44
|
+
return datetime.now(timezone.utc)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TrackerError(Exception):
|
|
48
|
+
"""Anything the caller could have avoided: bad key, bad status, bad link."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class UnknownIssue(TrackerError):
|
|
52
|
+
"""The referenced issue key does not exist in this tracker."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TrackerConfigError(TrackerError):
|
|
56
|
+
"""The tracker itself is misconfigured — wrong credentials, missing settings.
|
|
57
|
+
|
|
58
|
+
Raised at construction, never mid-run. A tracker that only discovers it
|
|
59
|
+
cannot reach Jira after twenty findings have been produced has thrown away
|
|
60
|
+
the run: the findings are gone from context and nobody can act on them.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Transition(BaseModel):
|
|
65
|
+
model_config = ConfigDict(extra="forbid")
|
|
66
|
+
|
|
67
|
+
at: datetime = Field(default_factory=_utcnow)
|
|
68
|
+
status: str
|
|
69
|
+
by: str | None = None
|
|
70
|
+
comment: str = ""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class IssueLink(BaseModel):
|
|
74
|
+
model_config = ConfigDict(extra="forbid")
|
|
75
|
+
|
|
76
|
+
type: str
|
|
77
|
+
to: str
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Issue(BaseModel):
|
|
81
|
+
"""One tracker issue, in the shape both backends agree to speak."""
|
|
82
|
+
|
|
83
|
+
model_config = ConfigDict(extra="forbid")
|
|
84
|
+
|
|
85
|
+
key: str
|
|
86
|
+
project: str
|
|
87
|
+
title: str
|
|
88
|
+
body: str = ""
|
|
89
|
+
status: str = "open"
|
|
90
|
+
labels: list[str] = Field(default_factory=list)
|
|
91
|
+
severity: str | None = None
|
|
92
|
+
envelope_id: str | None = None
|
|
93
|
+
fingerprint: str | None = None
|
|
94
|
+
reporter: str | None = None
|
|
95
|
+
links: list[IssueLink] = Field(default_factory=list)
|
|
96
|
+
history: list[Transition] = Field(default_factory=list)
|
|
97
|
+
created_at: datetime = Field(default_factory=_utcnow)
|
|
98
|
+
updated_at: datetime = Field(default_factory=_utcnow)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def number(self) -> int:
|
|
102
|
+
match = _KEY_RE.match(self.key)
|
|
103
|
+
return int(match.group("number")) if match else 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class TrackerAdapter(ABC):
|
|
107
|
+
"""The five operations the system needs from any tracker."""
|
|
108
|
+
|
|
109
|
+
@abstractmethod
|
|
110
|
+
def create_issue(
|
|
111
|
+
self,
|
|
112
|
+
*,
|
|
113
|
+
project: str,
|
|
114
|
+
title: str,
|
|
115
|
+
body: str = "",
|
|
116
|
+
labels: list[str] | None = None,
|
|
117
|
+
severity: str | None = None,
|
|
118
|
+
envelope_id: str | None = None,
|
|
119
|
+
fingerprint: str | None = None,
|
|
120
|
+
reporter: str | None = None,
|
|
121
|
+
) -> Issue:
|
|
122
|
+
"""File a new issue and return it, key assigned."""
|
|
123
|
+
|
|
124
|
+
@abstractmethod
|
|
125
|
+
def transition(self, key: str, status: str, *, by: str | None = None, comment: str = "") -> Issue:
|
|
126
|
+
"""Move an issue to a new status, recording who and why."""
|
|
127
|
+
|
|
128
|
+
@abstractmethod
|
|
129
|
+
def link(self, key: str, to: str, link_type: str = "relates") -> Issue:
|
|
130
|
+
"""Relate two existing issues."""
|
|
131
|
+
|
|
132
|
+
@abstractmethod
|
|
133
|
+
def search(
|
|
134
|
+
self,
|
|
135
|
+
*,
|
|
136
|
+
text: str | None = None,
|
|
137
|
+
project: str | None = None,
|
|
138
|
+
status: str | None = None,
|
|
139
|
+
label: str | None = None,
|
|
140
|
+
envelope_id: str | None = None,
|
|
141
|
+
fingerprint: str | None = None,
|
|
142
|
+
limit: int = 20,
|
|
143
|
+
) -> list[Issue]:
|
|
144
|
+
"""Return matching issues, newest first."""
|
|
145
|
+
|
|
146
|
+
@abstractmethod
|
|
147
|
+
def get(self, key: str) -> Issue | None:
|
|
148
|
+
"""One issue by key, or None."""
|
|
149
|
+
|
|
150
|
+
# -- routing surface ---------------------------------------------------
|
|
151
|
+
# The MCP server decides *whether* a finding is restricted (§4.12 step 5);
|
|
152
|
+
# the adapter decides *what the projects are called*, because a real Jira's
|
|
153
|
+
# keys come from the deployment, not from a constant in this file. Asking
|
|
154
|
+
# the adapter keeps one routing path for both backends instead of two.
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def default_project(self) -> str:
|
|
158
|
+
"""Where an ordinary finding is filed when the caller names no project."""
|
|
159
|
+
return DEFAULT_PROJECT
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def security_project(self) -> str | None:
|
|
163
|
+
"""The restricted project, or None if this backend has none configured.
|
|
164
|
+
|
|
165
|
+
None is not "file it somewhere else": it means the caller must refuse.
|
|
166
|
+
A security finding in a public project cannot be un-disclosed (§10).
|
|
167
|
+
"""
|
|
168
|
+
return SECURITY_PROJECT
|
|
169
|
+
|
|
170
|
+
# -- shared validation ------------------------------------------------
|
|
171
|
+
# Kept on the base class so every backend rejects the same inputs with the
|
|
172
|
+
# same message; an agent should not have to learn two dialects.
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def _check_status(status: str) -> str:
|
|
176
|
+
if status not in STATUSES:
|
|
177
|
+
raise TrackerError(f"unknown status '{status}'; use one of: {', '.join(STATUSES)}")
|
|
178
|
+
return status
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def _check_link_type(link_type: str) -> str:
|
|
182
|
+
if link_type not in LINK_TYPES:
|
|
183
|
+
raise TrackerError(
|
|
184
|
+
f"unknown link type '{link_type}'; use one of: {', '.join(LINK_TYPES)}"
|
|
185
|
+
)
|
|
186
|
+
return link_type
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class LocalTracker(TrackerAdapter):
|
|
190
|
+
"""Issues as JSON files under `<root>/tickets/`.
|
|
191
|
+
|
|
192
|
+
The key sequence is shared across projects and derived from what is already
|
|
193
|
+
on disk, so keys stay monotonic and unique across runs and across processes
|
|
194
|
+
without a database. Reusing a key would silently rewrite a ticket an
|
|
195
|
+
engineer may already be looking at, so allocation never counts down.
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
def __init__(self, root: Path | str):
|
|
199
|
+
self.dir = Path(root) / "tickets"
|
|
200
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
201
|
+
|
|
202
|
+
# -- storage ----------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
def _path(self, key: str) -> Path:
|
|
205
|
+
if not _KEY_RE.match(key):
|
|
206
|
+
raise TrackerError(f"'{key}' is not an issue key; expected e.g. {DEFAULT_PROJECT}-1")
|
|
207
|
+
return self.dir / f"{key}.json"
|
|
208
|
+
|
|
209
|
+
def _write(self, issue: Issue) -> Issue:
|
|
210
|
+
self._path(issue.key).write_text(issue.model_dump_json(indent=2), encoding="utf-8")
|
|
211
|
+
return issue
|
|
212
|
+
|
|
213
|
+
def get(self, key: str) -> Issue | None:
|
|
214
|
+
path = self._path(key)
|
|
215
|
+
return Issue.model_validate_json(path.read_text(encoding="utf-8")) if path.exists() else None
|
|
216
|
+
|
|
217
|
+
def _require(self, key: str) -> Issue:
|
|
218
|
+
issue = self.get(key)
|
|
219
|
+
if issue is None:
|
|
220
|
+
raise UnknownIssue(f"no issue '{key}' in the tracker")
|
|
221
|
+
return issue
|
|
222
|
+
|
|
223
|
+
def issues(self) -> list[Issue]:
|
|
224
|
+
found = []
|
|
225
|
+
for path in self.dir.glob("*.json"):
|
|
226
|
+
if _KEY_RE.match(path.stem):
|
|
227
|
+
found.append(Issue.model_validate_json(path.read_text(encoding="utf-8")))
|
|
228
|
+
return sorted(found, key=lambda i: i.number)
|
|
229
|
+
|
|
230
|
+
def _next_key(self, project: str) -> str:
|
|
231
|
+
highest = max((i.number for i in self.issues()), default=0)
|
|
232
|
+
return f"{project}-{highest + 1}"
|
|
233
|
+
|
|
234
|
+
# -- operations -------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
def create_issue(
|
|
237
|
+
self,
|
|
238
|
+
*,
|
|
239
|
+
project: str,
|
|
240
|
+
title: str,
|
|
241
|
+
body: str = "",
|
|
242
|
+
labels: list[str] | None = None,
|
|
243
|
+
severity: str | None = None,
|
|
244
|
+
envelope_id: str | None = None,
|
|
245
|
+
fingerprint: str | None = None,
|
|
246
|
+
reporter: str | None = None,
|
|
247
|
+
) -> Issue:
|
|
248
|
+
if not title.strip():
|
|
249
|
+
raise TrackerError("an issue needs a title")
|
|
250
|
+
issue = Issue(
|
|
251
|
+
key=self._next_key(project),
|
|
252
|
+
project=project,
|
|
253
|
+
title=title.strip(),
|
|
254
|
+
body=body,
|
|
255
|
+
labels=sorted(set(labels or [])),
|
|
256
|
+
severity=severity,
|
|
257
|
+
envelope_id=envelope_id,
|
|
258
|
+
fingerprint=fingerprint,
|
|
259
|
+
reporter=reporter,
|
|
260
|
+
history=[Transition(status="open", by=reporter, comment="filed")],
|
|
261
|
+
)
|
|
262
|
+
return self._write(issue)
|
|
263
|
+
|
|
264
|
+
def transition(self, key: str, status: str, *, by: str | None = None, comment: str = "") -> Issue:
|
|
265
|
+
self._check_status(status)
|
|
266
|
+
issue = self._require(key)
|
|
267
|
+
if issue.status == status:
|
|
268
|
+
raise TrackerError(f"{key} is already '{status}'")
|
|
269
|
+
issue.status = status
|
|
270
|
+
issue.updated_at = _utcnow()
|
|
271
|
+
issue.history.append(Transition(status=status, by=by, comment=comment))
|
|
272
|
+
return self._write(issue)
|
|
273
|
+
|
|
274
|
+
def link(self, key: str, to: str, link_type: str = "relates") -> Issue:
|
|
275
|
+
self._check_link_type(link_type)
|
|
276
|
+
if key == to:
|
|
277
|
+
raise TrackerError("an issue cannot be linked to itself")
|
|
278
|
+
issue = self._require(key)
|
|
279
|
+
self._require(to) # refuse dangling links: a link to nothing is worse than none
|
|
280
|
+
if not any(link.type == link_type and link.to == to for link in issue.links):
|
|
281
|
+
issue.links.append(IssueLink(type=link_type, to=to))
|
|
282
|
+
issue.updated_at = _utcnow()
|
|
283
|
+
return self._write(issue)
|
|
284
|
+
|
|
285
|
+
def search(
|
|
286
|
+
self,
|
|
287
|
+
*,
|
|
288
|
+
text: str | None = None,
|
|
289
|
+
project: str | None = None,
|
|
290
|
+
status: str | None = None,
|
|
291
|
+
label: str | None = None,
|
|
292
|
+
envelope_id: str | None = None,
|
|
293
|
+
fingerprint: str | None = None,
|
|
294
|
+
limit: int = 20,
|
|
295
|
+
) -> list[Issue]:
|
|
296
|
+
needle = (text or "").lower().strip()
|
|
297
|
+
found = []
|
|
298
|
+
for issue in self.issues():
|
|
299
|
+
if project and issue.project != project:
|
|
300
|
+
continue
|
|
301
|
+
if status and issue.status != status:
|
|
302
|
+
continue
|
|
303
|
+
if label and label not in issue.labels:
|
|
304
|
+
continue
|
|
305
|
+
if envelope_id and issue.envelope_id != envelope_id:
|
|
306
|
+
continue
|
|
307
|
+
if fingerprint and issue.fingerprint != fingerprint:
|
|
308
|
+
continue
|
|
309
|
+
if needle and needle not in f"{issue.title}\n{issue.body}".lower():
|
|
310
|
+
continue
|
|
311
|
+
found.append(issue)
|
|
312
|
+
found.reverse() # newest first
|
|
313
|
+
return found[: max(1, limit)]
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# -- Atlassian Document Format ---------------------------------------------
|
|
317
|
+
|
|
318
|
+
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+)$")
|
|
319
|
+
_BULLET_RE = re.compile(r"^\s*[-*+]\s+(.+)$")
|
|
320
|
+
_FENCE_RE = re.compile(r"^```\s*([A-Za-z0-9_+#.-]*)\s*$")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _adf_text(text: str) -> list[dict[str, Any]]:
|
|
324
|
+
"""ADF forbids an empty text node, so an empty string yields no children."""
|
|
325
|
+
return [{"type": "text", "text": text}] if text else []
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _adf_paragraph(text: str) -> dict[str, Any]:
|
|
329
|
+
node: dict[str, Any] = {"type": "paragraph"}
|
|
330
|
+
children = _adf_text(text)
|
|
331
|
+
if children:
|
|
332
|
+
node["content"] = children
|
|
333
|
+
return node
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def markdown_to_adf(text: str) -> dict[str, Any]:
|
|
337
|
+
"""Convert the markdown subset the house ticket format uses into ADF.
|
|
338
|
+
|
|
339
|
+
Jira Cloud's REST v3 rejects a plain string for `description` — it wants an
|
|
340
|
+
Atlassian Document Format node tree — so something has to do this. Rather
|
|
341
|
+
than take a markdown dependency for one field, this handles exactly the
|
|
342
|
+
block constructs the house ticket format actually emits, and is explicit
|
|
343
|
+
about the rest.
|
|
344
|
+
|
|
345
|
+
Supported:
|
|
346
|
+
* ATX headings `#` through `######` -> heading nodes, levels 1-6
|
|
347
|
+
* blank-line separated paragraphs -> paragraph nodes
|
|
348
|
+
* `-`, `*` or `+` bullet lists -> bulletList / listItem
|
|
349
|
+
* ``` fenced code, optional language -> codeBlock
|
|
350
|
+
|
|
351
|
+
NOT supported, deliberately. These are left as literal characters in the
|
|
352
|
+
text rather than dropped, because a reader can still understand
|
|
353
|
+
`**blocker**` or `[log](artifact://x)`, but cannot understand a body with
|
|
354
|
+
pieces silently missing:
|
|
355
|
+
* inline marks — bold, italic, inline code, links
|
|
356
|
+
* ordered lists, nested lists, task lists
|
|
357
|
+
* tables, block quotes, images, horizontal rules, footnotes
|
|
358
|
+
* raw HTML
|
|
359
|
+
|
|
360
|
+
A ticket that needs richer rendering should link to an artifact instead.
|
|
361
|
+
"""
|
|
362
|
+
lines = (text or "").replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
|
363
|
+
content: list[dict[str, Any]] = []
|
|
364
|
+
paragraph: list[str] = []
|
|
365
|
+
bullets: list[str] = []
|
|
366
|
+
|
|
367
|
+
def flush_paragraph() -> None:
|
|
368
|
+
if paragraph:
|
|
369
|
+
content.append(_adf_paragraph("\n".join(paragraph)))
|
|
370
|
+
paragraph.clear()
|
|
371
|
+
|
|
372
|
+
def flush_bullets() -> None:
|
|
373
|
+
if bullets:
|
|
374
|
+
content.append(
|
|
375
|
+
{
|
|
376
|
+
"type": "bulletList",
|
|
377
|
+
"content": [
|
|
378
|
+
{"type": "listItem", "content": [_adf_paragraph(item)]} for item in bullets
|
|
379
|
+
],
|
|
380
|
+
}
|
|
381
|
+
)
|
|
382
|
+
bullets.clear()
|
|
383
|
+
|
|
384
|
+
index = 0
|
|
385
|
+
while index < len(lines):
|
|
386
|
+
line = lines[index]
|
|
387
|
+
fence = _FENCE_RE.match(line.strip())
|
|
388
|
+
if fence:
|
|
389
|
+
flush_paragraph()
|
|
390
|
+
flush_bullets()
|
|
391
|
+
index += 1
|
|
392
|
+
body: list[str] = []
|
|
393
|
+
while index < len(lines) and not _FENCE_RE.match(lines[index].strip()):
|
|
394
|
+
body.append(lines[index])
|
|
395
|
+
index += 1
|
|
396
|
+
index += 1 # step over the closing fence, or past the end if unterminated
|
|
397
|
+
node: dict[str, Any] = {"type": "codeBlock"}
|
|
398
|
+
if fence.group(1):
|
|
399
|
+
node["attrs"] = {"language": fence.group(1)}
|
|
400
|
+
children = _adf_text("\n".join(body))
|
|
401
|
+
if children:
|
|
402
|
+
node["content"] = children
|
|
403
|
+
content.append(node)
|
|
404
|
+
continue
|
|
405
|
+
|
|
406
|
+
index += 1
|
|
407
|
+
heading = _HEADING_RE.match(line.strip())
|
|
408
|
+
if heading:
|
|
409
|
+
flush_paragraph()
|
|
410
|
+
flush_bullets()
|
|
411
|
+
content.append(
|
|
412
|
+
{
|
|
413
|
+
"type": "heading",
|
|
414
|
+
"attrs": {"level": len(heading.group(1))},
|
|
415
|
+
"content": _adf_text(heading.group(2).strip()),
|
|
416
|
+
}
|
|
417
|
+
)
|
|
418
|
+
continue
|
|
419
|
+
|
|
420
|
+
bullet = _BULLET_RE.match(line)
|
|
421
|
+
if bullet:
|
|
422
|
+
flush_paragraph()
|
|
423
|
+
bullets.append(bullet.group(1).strip())
|
|
424
|
+
continue
|
|
425
|
+
|
|
426
|
+
if not line.strip():
|
|
427
|
+
flush_paragraph()
|
|
428
|
+
flush_bullets()
|
|
429
|
+
continue
|
|
430
|
+
|
|
431
|
+
flush_bullets()
|
|
432
|
+
paragraph.append(line.rstrip())
|
|
433
|
+
|
|
434
|
+
flush_paragraph()
|
|
435
|
+
flush_bullets()
|
|
436
|
+
if not content:
|
|
437
|
+
content.append({"type": "paragraph"})
|
|
438
|
+
return {"type": "doc", "version": 1, "content": content}
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def adf_to_text(node: Any) -> str:
|
|
442
|
+
"""Flatten an ADF tree back to plain text, best effort.
|
|
443
|
+
|
|
444
|
+
Only used when reading an issue back out of Jira, so that `Issue.body` is
|
|
445
|
+
something an agent can grep. It is lossy on purpose — round-tripping ADF is
|
|
446
|
+
not a goal, and pretending otherwise would invite callers to trust it.
|
|
447
|
+
"""
|
|
448
|
+
if isinstance(node, str):
|
|
449
|
+
return node
|
|
450
|
+
if isinstance(node, list):
|
|
451
|
+
return "\n".join(part for part in (adf_to_text(child) for child in node) if part)
|
|
452
|
+
if not isinstance(node, dict):
|
|
453
|
+
return ""
|
|
454
|
+
kind = node.get("type")
|
|
455
|
+
if kind == "text":
|
|
456
|
+
return str(node.get("text", ""))
|
|
457
|
+
if kind == "hardBreak":
|
|
458
|
+
return "\n"
|
|
459
|
+
inner = adf_to_text(node.get("content", []))
|
|
460
|
+
if kind in ("paragraph", "heading", "codeBlock", "listItem", "blockquote"):
|
|
461
|
+
return inner
|
|
462
|
+
return inner
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
# -- Jira Cloud -------------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
#: Where a human goes to mint the credential this adapter needs. Named in the
|
|
468
|
+
#: configuration error, because "JIRA_API_TOKEN is missing" without this line
|
|
469
|
+
#: sends the reader to a search engine.
|
|
470
|
+
JIRA_API_TOKEN_URL = "https://id.atlassian.com/manage-profile/security/api-tokens"
|
|
471
|
+
|
|
472
|
+
JIRA_API_BASE = "/rest/api/3"
|
|
473
|
+
|
|
474
|
+
#: The Agile (board and sprint) API. A different base path, not a different
|
|
475
|
+
#: host — boards simply do not exist under `/rest/api/3`, and asking for one
|
|
476
|
+
#: there returns a 404 that reads like a missing board rather than a missing
|
|
477
|
+
#: endpoint.
|
|
478
|
+
JIRA_AGILE_BASE = "/rest/agile/1.0"
|
|
479
|
+
|
|
480
|
+
JIRA_TIMEOUT_S = 30.0
|
|
481
|
+
#: Reads are retried on 429; see `JiraTracker._request` for why writes are not.
|
|
482
|
+
JIRA_READ_ATTEMPTS = 3
|
|
483
|
+
JIRA_MAX_RETRY_WAIT_S = 30.0
|
|
484
|
+
|
|
485
|
+
#: House metadata rides on labels because they are the only field guaranteed to
|
|
486
|
+
#: exist in every Jira project. Custom fields differ per instance and screen, so
|
|
487
|
+
#: writing to one is the fastest way to a 400 on someone else's Jira.
|
|
488
|
+
SEVERITY_LABEL_PREFIX = "severity-"
|
|
489
|
+
ENVELOPE_LABEL_PREFIX = "qaas-envelope-"
|
|
490
|
+
FINGERPRINT_LABEL_PREFIX = "qaas-fp-"
|
|
491
|
+
|
|
492
|
+
#: Every ticket a run files carries `repo-<target>`. It is what makes a
|
|
493
|
+
#: per-repository board possible without a per-repository *project*: the board
|
|
494
|
+
#: is a saved filter over this label, and creating a filter needs no
|
|
495
|
+
#: administrator rights while creating a project does.
|
|
496
|
+
REPO_LABEL_PREFIX = "repo-"
|
|
497
|
+
|
|
498
|
+
#: House status -> the Jira workflow names it plausibly means. Jira workflows
|
|
499
|
+
#: are per-project and unknowable from here, so this is a set of candidates to
|
|
500
|
+
#: try, never an assertion; a miss returns the real transition list (see
|
|
501
|
+
#: `transition`) rather than guessing.
|
|
502
|
+
JIRA_STATUS_ALIASES: dict[str, tuple[str, ...]] = {
|
|
503
|
+
"open": ("open", "to do", "todo", "backlog", "new", "reopened"),
|
|
504
|
+
"in_progress": ("in progress", "in development", "doing", "start progress"),
|
|
505
|
+
"in_review": ("in review", "code review", "review", "in code review"),
|
|
506
|
+
"resolved": ("resolved", "done", "fixed", "resolve issue"),
|
|
507
|
+
"closed": ("closed", "done", "close issue"),
|
|
508
|
+
"wont_fix": ("won't fix", "wont fix", "will not do", "won't do", "declined"),
|
|
509
|
+
"duplicate": ("duplicate", "duplicated", "closed as duplicate"),
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
#: House link type -> (Jira link type names to try in order, direction). The
|
|
513
|
+
#: direction says which end of the Jira link `key` sits on: "outward" means the
|
|
514
|
+
#: link reads `key <outward phrase> to`.
|
|
515
|
+
JIRA_LINK_TYPES: dict[str, tuple[tuple[str, ...], str]] = {
|
|
516
|
+
"duplicates": (("Duplicate", "Duplicates"), "outward"),
|
|
517
|
+
"relates": (("Relates", "Related"), "outward"),
|
|
518
|
+
"blocks": (("Blocks", "Blocker"), "outward"),
|
|
519
|
+
"blocked-by": (("Blocks", "Blocker"), "inward"),
|
|
520
|
+
"regression-of": (("Problem/Incident", "Causes", "Relates"), "inward"),
|
|
521
|
+
"caused-by": (("Problem/Incident", "Causes", "Relates"), "inward"),
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _fingerprint_label_value(fingerprint: str | None) -> str | None:
|
|
526
|
+
"""The digest half of a fingerprint, e.g. `sha256:ab12…` -> `ab12…`.
|
|
527
|
+
|
|
528
|
+
The algorithm prefix is dropped because a colon in a Jira label is not
|
|
529
|
+
worth betting a 400 on. The digest itself is kept whole: dedupe matches on
|
|
530
|
+
exact equality, and a truncated fingerprint would quietly collide.
|
|
531
|
+
"""
|
|
532
|
+
digest = (fingerprint or "").split(":")[-1].strip()
|
|
533
|
+
return digest or None
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _label_safe(value: str | None, prefix: str = "") -> str | None:
|
|
537
|
+
"""A Jira label, or None if the value cannot be one.
|
|
538
|
+
|
|
539
|
+
Jira rejects labels containing whitespace, and caps them at 255 characters.
|
|
540
|
+
Silently mangling an id would produce a label that never matches on the way
|
|
541
|
+
back out, so an unusable value produces no label at all.
|
|
542
|
+
"""
|
|
543
|
+
if not value:
|
|
544
|
+
return None
|
|
545
|
+
candidate = f"{prefix}{value.strip()}"
|
|
546
|
+
if not candidate or any(char.isspace() for char in candidate) or len(candidate) > 255:
|
|
547
|
+
return None
|
|
548
|
+
return candidate
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def repo_label(slug: str | None) -> str | None:
|
|
552
|
+
"""The label every ticket from a run against `slug` carries, or None.
|
|
553
|
+
|
|
554
|
+
None when the slug cannot survive being a Jira label at all (whitespace,
|
|
555
|
+
empty, too long). Returning None rather than a mangled value is deliberate:
|
|
556
|
+
a mangled label would never match the board filter, so the tickets would
|
|
557
|
+
file successfully and then be invisible on the board someone was told to
|
|
558
|
+
watch — the worst of the three outcomes.
|
|
559
|
+
"""
|
|
560
|
+
return _label_safe(_label_slug(slug), REPO_LABEL_PREFIX)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _label_slug(value: str | None) -> str | None:
|
|
564
|
+
"""`My Repo.git` -> `my-repo`. The same shape `qaas init` gives a target."""
|
|
565
|
+
if not value:
|
|
566
|
+
return None
|
|
567
|
+
slug = re.sub(r"[^a-z0-9-]+", "-", value.strip().lower()).strip("-")[:64]
|
|
568
|
+
return slug or None
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
|
|
572
|
+
"""Drop `Authorization` when a redirect leaves the site it was minted for.
|
|
573
|
+
|
|
574
|
+
urllib strips credentials across hosts only for the auth *handlers*. A
|
|
575
|
+
header set by hand on the `Request` — which is how every call here sends
|
|
576
|
+
`Basic <email:api_token>` — is copied onto the redirected request verbatim:
|
|
577
|
+
`HTTPRedirectHandler.redirect_request` filters out `content-length` and
|
|
578
|
+
`content-type` and nothing else.
|
|
579
|
+
|
|
580
|
+
That matters because `_resolve_url` deliberately follows Jira's redirects to
|
|
581
|
+
learn where a board actually lives, and it runs at the top of every
|
|
582
|
+
Jira-backed run via `cli._ensure_board`. On an SSO-enforced site
|
|
583
|
+
`/secure/RapidBoard.jspa?rapidView=<id>` answers 302 to the identity
|
|
584
|
+
provider — a different host — and the bot's API token went with it. The same
|
|
585
|
+
exposure sits on `_request`, which follows redirects too; both share this
|
|
586
|
+
one opener, so both are fixed here rather than at either call site.
|
|
587
|
+
|
|
588
|
+
Scheme counts as well as host: an https -> http redirect would put the token
|
|
589
|
+
on the wire in clear.
|
|
590
|
+
"""
|
|
591
|
+
|
|
592
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
|
|
593
|
+
new = super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
594
|
+
if new is None:
|
|
595
|
+
return None
|
|
596
|
+
before = urllib.parse.urlsplit(req.full_url)
|
|
597
|
+
after = urllib.parse.urlsplit(new.full_url)
|
|
598
|
+
if (before.hostname, before.scheme) != (after.hostname, after.scheme):
|
|
599
|
+
# `Request.headers` is capitalised; `unredirected_hdrs` holds the
|
|
600
|
+
# ones urllib adds itself. Clear from both so nothing puts it back.
|
|
601
|
+
new.headers = {k: v for k, v in new.headers.items() if k.lower() != "authorization"}
|
|
602
|
+
new.unredirected_hdrs = {
|
|
603
|
+
k: v for k, v in new.unredirected_hdrs.items() if k.lower() != "authorization"
|
|
604
|
+
}
|
|
605
|
+
return new
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
@dataclass(frozen=True)
|
|
609
|
+
class BoardInfo:
|
|
610
|
+
"""What a per-repository board provisioning attempt produced.
|
|
611
|
+
|
|
612
|
+
`board_id` is None when Jira refused to create a board — which happens on
|
|
613
|
+
team-managed projects, where boards belong to the project and cannot be
|
|
614
|
+
made over an arbitrary filter. That is not a failure of the run: the filter
|
|
615
|
+
still exists, the tickets still carry the label, and `url` still points at
|
|
616
|
+
something a human can open. `note` says which of the two they got.
|
|
617
|
+
"""
|
|
618
|
+
|
|
619
|
+
slug: str
|
|
620
|
+
label: str
|
|
621
|
+
jql: str
|
|
622
|
+
filter_id: int | None = None
|
|
623
|
+
filter_name: str = ""
|
|
624
|
+
board_id: int | None = None
|
|
625
|
+
board_name: str = ""
|
|
626
|
+
#: A link that opens. The board when there is a usable one, the filter
|
|
627
|
+
#: otherwise — never a constructed guess, because a link that 404s is worse
|
|
628
|
+
#: than no link: it reads as "the tool is broken" rather than "your Jira
|
|
629
|
+
#: does not do that".
|
|
630
|
+
url: str = ""
|
|
631
|
+
filter_url: str = ""
|
|
632
|
+
created_filter: bool = False
|
|
633
|
+
created_board: bool = False
|
|
634
|
+
note: str | None = None
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
class JiraTracker(TrackerAdapter):
|
|
638
|
+
"""Jira Cloud, over REST API v3, with credentials from the environment.
|
|
639
|
+
|
|
640
|
+
Everything this adapter needs is read from environment variables at
|
|
641
|
+
construction and validated there: a tracker that only discovers it cannot
|
|
642
|
+
authenticate after a run has produced twenty findings has destroyed the
|
|
643
|
+
run, because the findings live in an agent's context and the context is
|
|
644
|
+
gone. Credentials never come from `config/` — that directory is committed.
|
|
645
|
+
|
|
646
|
+
Required:
|
|
647
|
+
* ``JIRA_BASE_URL`` — e.g. ``https://acme.atlassian.net``
|
|
648
|
+
* ``JIRA_EMAIL`` — the bot account's Atlassian account email
|
|
649
|
+
* ``JIRA_API_TOKEN`` — an API token, not a password (Jira Cloud uses
|
|
650
|
+
HTTP Basic with email + token)
|
|
651
|
+
* ``JIRA_PROJECT_KEY``— the default project, e.g. ``CORVID``
|
|
652
|
+
|
|
653
|
+
Optional:
|
|
654
|
+
* ``JIRA_SECURITY_PROJECT_KEY`` — the restricted project. When it is
|
|
655
|
+
unset, `security_project` is None and the MCP server refuses to file
|
|
656
|
+
security findings at all. That refusal is the point: filing a
|
|
657
|
+
vulnerability into a project the whole company can read is a
|
|
658
|
+
disclosure, and there is no undo (§4.12 step 5, §10).
|
|
659
|
+
* ``JIRA_ISSUE_TYPE`` — the issue type to create, default ``Bug``. Not
|
|
660
|
+
every project has a type called Bug.
|
|
661
|
+
|
|
662
|
+
Jira Server / Data Center is a different product with different auth; see
|
|
663
|
+
`JiraDataCenterTracker` (§5.1).
|
|
664
|
+
"""
|
|
665
|
+
|
|
666
|
+
REQUIRED_ENV = (
|
|
667
|
+
"JIRA_BASE_URL",
|
|
668
|
+
"JIRA_EMAIL",
|
|
669
|
+
"JIRA_API_TOKEN",
|
|
670
|
+
"JIRA_PROJECT_KEY",
|
|
671
|
+
)
|
|
672
|
+
SECURITY_ENV = "JIRA_SECURITY_PROJECT_KEY"
|
|
673
|
+
ISSUE_TYPE_ENV = "JIRA_ISSUE_TYPE"
|
|
674
|
+
|
|
675
|
+
def __init__(
|
|
676
|
+
self,
|
|
677
|
+
*,
|
|
678
|
+
env: Mapping[str, str] | None = None,
|
|
679
|
+
timeout: float = JIRA_TIMEOUT_S,
|
|
680
|
+
):
|
|
681
|
+
source: Mapping[str, str] = os.environ if env is None else env
|
|
682
|
+
values = {name: (source.get(name) or "").strip() for name in self.REQUIRED_ENV}
|
|
683
|
+
missing = [name for name, value in values.items() if not value]
|
|
684
|
+
if missing:
|
|
685
|
+
raise TrackerConfigError(self._missing_env_message(missing))
|
|
686
|
+
|
|
687
|
+
base_url = values["JIRA_BASE_URL"].rstrip("/")
|
|
688
|
+
if not base_url.startswith(("http://", "https://")):
|
|
689
|
+
raise TrackerConfigError(
|
|
690
|
+
f"JIRA_BASE_URL is '{base_url}', which is not a URL. It must include the "
|
|
691
|
+
"scheme and be your Jira site root, e.g. https://acme.atlassian.net "
|
|
692
|
+
"(no /jira, no /rest/api path)."
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
self.base_url = base_url
|
|
696
|
+
self.email = values["JIRA_EMAIL"]
|
|
697
|
+
self._token = values["JIRA_API_TOKEN"]
|
|
698
|
+
self._project = values["JIRA_PROJECT_KEY"]
|
|
699
|
+
self._security_project = (source.get(self.SECURITY_ENV) or "").strip() or None
|
|
700
|
+
self.issue_type = (source.get(self.ISSUE_TYPE_ENV) or "").strip() or "Bug"
|
|
701
|
+
self.timeout = timeout
|
|
702
|
+
# Built once, at construction, so proxy settings are read from the
|
|
703
|
+
# environment the tracker was configured in rather than per call.
|
|
704
|
+
# `_StripAuthOnRedirect` is not optional: see its docstring.
|
|
705
|
+
self._opener = urllib.request.build_opener(_StripAuthOnRedirect)
|
|
706
|
+
self._link_type_cache: list[dict[str, Any]] | None = None
|
|
707
|
+
self._account_id_cache: str | None = None
|
|
708
|
+
|
|
709
|
+
@classmethod
|
|
710
|
+
def _missing_env_message(cls, missing: list[str]) -> str:
|
|
711
|
+
"""Name every missing variable, and say where the token comes from.
|
|
712
|
+
|
|
713
|
+
Listing only the first missing variable turns one restart into four.
|
|
714
|
+
"""
|
|
715
|
+
verb = "is" if len(missing) == 1 else "are"
|
|
716
|
+
return (
|
|
717
|
+
f"JiraTracker is not configured: {', '.join(missing)} {verb} unset or empty in "
|
|
718
|
+
f"the environment. Set all of {', '.join(cls.REQUIRED_ENV)} — JIRA_BASE_URL is "
|
|
719
|
+
"your site root (https://acme.atlassian.net), JIRA_EMAIL is the bot account's "
|
|
720
|
+
"Atlassian email, JIRA_API_TOKEN is an API token created at "
|
|
721
|
+
f"{JIRA_API_TOKEN_URL} (a password will not work), and JIRA_PROJECT_KEY is the "
|
|
722
|
+
f"default project key (e.g. CORVID). Set {cls.SECURITY_ENV} as well to a "
|
|
723
|
+
"restricted project, or security findings will be refused rather than filed "
|
|
724
|
+
"into a public one (§4.12, §10). These are credentials: they come from the "
|
|
725
|
+
"environment, never from config/. Or run with tracker: local."
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
# -- routing ----------------------------------------------------------
|
|
729
|
+
|
|
730
|
+
@property
|
|
731
|
+
def default_project(self) -> str:
|
|
732
|
+
return self._project
|
|
733
|
+
|
|
734
|
+
@property
|
|
735
|
+
def security_project(self) -> str | None:
|
|
736
|
+
return self._security_project
|
|
737
|
+
|
|
738
|
+
# -- HTTP -------------------------------------------------------------
|
|
739
|
+
|
|
740
|
+
def _headers(self) -> dict[str, str]:
|
|
741
|
+
credential = base64.b64encode(f"{self.email}:{self._token}".encode()).decode()
|
|
742
|
+
return {
|
|
743
|
+
"Authorization": f"Basic {credential}",
|
|
744
|
+
"Accept": "application/json",
|
|
745
|
+
"Content-Type": "application/json",
|
|
746
|
+
"User-Agent": "qaas-tracker/1.0",
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
def _request(
|
|
750
|
+
self,
|
|
751
|
+
method: str,
|
|
752
|
+
path: str,
|
|
753
|
+
*,
|
|
754
|
+
body: dict[str, Any] | None = None,
|
|
755
|
+
params: dict[str, str] | None = None,
|
|
756
|
+
retry_on_429: bool = False,
|
|
757
|
+
api_base: str = JIRA_API_BASE,
|
|
758
|
+
) -> Any:
|
|
759
|
+
"""One Jira call. `retry_on_429` is only ever true for reads.
|
|
760
|
+
|
|
761
|
+
A retried POST /issue is a duplicate ticket, and duplicate tickets are
|
|
762
|
+
precisely what this system exists to prevent: a 429 can arrive after
|
|
763
|
+
Jira has already created the issue, so the second attempt files it
|
|
764
|
+
twice. Reads are idempotent and honour `Retry-After`.
|
|
765
|
+
"""
|
|
766
|
+
url = f"{self.base_url}{api_base}{path}"
|
|
767
|
+
if params:
|
|
768
|
+
url = f"{url}?{urllib.parse.urlencode(params)}"
|
|
769
|
+
payload = json.dumps(body).encode("utf-8") if body is not None else None
|
|
770
|
+
attempts = JIRA_READ_ATTEMPTS if retry_on_429 else 1
|
|
771
|
+
|
|
772
|
+
for attempt in range(1, attempts + 1):
|
|
773
|
+
request = urllib.request.Request(
|
|
774
|
+
url, data=payload, method=method, headers=self._headers()
|
|
775
|
+
)
|
|
776
|
+
try:
|
|
777
|
+
with self._opener.open(request, timeout=self.timeout) as response:
|
|
778
|
+
raw = response.read()
|
|
779
|
+
return json.loads(raw.decode("utf-8")) if raw.strip() else {}
|
|
780
|
+
except urllib.error.HTTPError as exc:
|
|
781
|
+
if exc.code == 429 and attempt < attempts:
|
|
782
|
+
time.sleep(self._retry_after(exc))
|
|
783
|
+
continue
|
|
784
|
+
raise self._http_error(exc, method, path) from None
|
|
785
|
+
except urllib.error.URLError as exc:
|
|
786
|
+
raise TrackerError(
|
|
787
|
+
f"could not reach Jira at {self.base_url} ({exc.reason}); check "
|
|
788
|
+
"JIRA_BASE_URL, your network, and any proxy settings"
|
|
789
|
+
) from None
|
|
790
|
+
except TimeoutError:
|
|
791
|
+
raise TrackerError(
|
|
792
|
+
f"Jira did not answer {method} {path} within {self.timeout:g}s; "
|
|
793
|
+
"it may be degraded — retry, or check status.atlassian.com"
|
|
794
|
+
) from None
|
|
795
|
+
except json.JSONDecodeError:
|
|
796
|
+
raise TrackerError(
|
|
797
|
+
f"Jira returned a non-JSON body for {method} {path}; the URL in "
|
|
798
|
+
f"JIRA_BASE_URL ({self.base_url}) may point at a proxy or login page "
|
|
799
|
+
"rather than at a Jira site"
|
|
800
|
+
) from None
|
|
801
|
+
raise TrackerError(f"Jira rate-limited {method} {path} after {attempts} attempts")
|
|
802
|
+
|
|
803
|
+
@staticmethod
|
|
804
|
+
def _retry_after(exc: urllib.error.HTTPError) -> float:
|
|
805
|
+
"""Seconds to wait, from the server's own header where it gives one."""
|
|
806
|
+
raw = (exc.headers.get("Retry-After") if exc.headers else None) or ""
|
|
807
|
+
try:
|
|
808
|
+
wait = float(raw.strip())
|
|
809
|
+
except ValueError:
|
|
810
|
+
wait = 1.0
|
|
811
|
+
return max(0.0, min(wait, JIRA_MAX_RETRY_WAIT_S))
|
|
812
|
+
|
|
813
|
+
def _http_error(self, exc: urllib.error.HTTPError, method: str, path: str) -> TrackerError:
|
|
814
|
+
"""Turn a status code into something the reader can act on.
|
|
815
|
+
|
|
816
|
+
Generic "HTTP 403" tells an operator nothing about which of the four
|
|
817
|
+
plausible causes they have.
|
|
818
|
+
"""
|
|
819
|
+
detail = self._error_detail(exc)
|
|
820
|
+
suffix = f" Jira said: {detail}" if detail else ""
|
|
821
|
+
if exc.code == 401:
|
|
822
|
+
return TrackerError(
|
|
823
|
+
"Jira rejected the credentials (401). Check JIRA_EMAIL / JIRA_API_TOKEN: "
|
|
824
|
+
"the email must be the Atlassian account the token was minted for, and the "
|
|
825
|
+
f"token must be an API token from {JIRA_API_TOKEN_URL}, not a password. "
|
|
826
|
+
f"Revoked and expired tokens also return 401.{suffix}"
|
|
827
|
+
)
|
|
828
|
+
if exc.code == 403:
|
|
829
|
+
return TrackerError(
|
|
830
|
+
f"Jira refused the request (403) for {method} {path}. The account "
|
|
831
|
+
f"{self.email} is authenticated but lacks permission — check that it has "
|
|
832
|
+
f"Browse Projects and Create Issues on {self.default_project}"
|
|
833
|
+
+ (f" and {self.security_project}" if self.security_project else "")
|
|
834
|
+
+ f", and that the project has not been archived.{suffix}"
|
|
835
|
+
)
|
|
836
|
+
if exc.code == 404:
|
|
837
|
+
return TrackerError(
|
|
838
|
+
f"Jira has no such resource (404) for {method} {path}. If this was a "
|
|
839
|
+
f"create, check JIRA_PROJECT_KEY='{self.default_project}'"
|
|
840
|
+
+ (
|
|
841
|
+
f" / {self.SECURITY_ENV}='{self.security_project}'"
|
|
842
|
+
if self.security_project
|
|
843
|
+
else ""
|
|
844
|
+
)
|
|
845
|
+
+ " — a project key that does not exist, or that this account cannot "
|
|
846
|
+
f"browse, both surface as 404.{suffix}"
|
|
847
|
+
)
|
|
848
|
+
if exc.code == 429:
|
|
849
|
+
return TrackerError(
|
|
850
|
+
f"Jira rate-limited {method} {path} (429) and this call is not safe to "
|
|
851
|
+
"retry automatically. Slow the run down or lower the per-run ticket cap "
|
|
852
|
+
f"(§4.12).{suffix}"
|
|
853
|
+
)
|
|
854
|
+
if 500 <= exc.code < 600:
|
|
855
|
+
return TrackerError(
|
|
856
|
+
f"Jira returned {exc.code} for {method} {path}. This is Jira's side, not "
|
|
857
|
+
f"the request's; retry later.{suffix}"
|
|
858
|
+
)
|
|
859
|
+
return TrackerError(f"Jira rejected {method} {path} with HTTP {exc.code}.{suffix}")
|
|
860
|
+
|
|
861
|
+
@staticmethod
|
|
862
|
+
def _error_detail(exc: urllib.error.HTTPError) -> str:
|
|
863
|
+
"""Jira's own explanation, which is usually the useful half."""
|
|
864
|
+
try:
|
|
865
|
+
payload = json.loads(exc.read().decode("utf-8"))
|
|
866
|
+
except Exception: # noqa: BLE001 - an unreadable error body must not mask the status
|
|
867
|
+
return ""
|
|
868
|
+
if not isinstance(payload, dict):
|
|
869
|
+
return ""
|
|
870
|
+
parts = [str(message) for message in payload.get("errorMessages", []) or []]
|
|
871
|
+
errors = payload.get("errors")
|
|
872
|
+
if isinstance(errors, dict):
|
|
873
|
+
parts += [f"{field}: {message}" for field, message in errors.items()]
|
|
874
|
+
return "; ".join(parts)[:500]
|
|
875
|
+
|
|
876
|
+
# -- mapping ----------------------------------------------------------
|
|
877
|
+
|
|
878
|
+
@staticmethod
|
|
879
|
+
def _parse_time(value: Any) -> datetime:
|
|
880
|
+
"""Jira timestamps look like 2024-05-01T09:15:00.000+0000."""
|
|
881
|
+
if isinstance(value, str):
|
|
882
|
+
try:
|
|
883
|
+
return datetime.fromisoformat(value)
|
|
884
|
+
except ValueError:
|
|
885
|
+
pass
|
|
886
|
+
return _utcnow()
|
|
887
|
+
|
|
888
|
+
def _issue_from_jira(self, data: dict[str, Any]) -> Issue:
|
|
889
|
+
"""Map a Jira issue onto the house `Issue`.
|
|
890
|
+
|
|
891
|
+
`status` carries Jira's own status name, not one of `STATUSES`: the
|
|
892
|
+
workflow belongs to the project, and rewriting "Awaiting QA" into
|
|
893
|
+
"in_review" would be this adapter inventing facts.
|
|
894
|
+
"""
|
|
895
|
+
fields = data.get("fields") or {}
|
|
896
|
+
labels = [str(label) for label in fields.get("labels") or []]
|
|
897
|
+
|
|
898
|
+
def unprefixed(prefix: str) -> str | None:
|
|
899
|
+
"""The house metadata hidden in a label, on the way back out."""
|
|
900
|
+
return next(
|
|
901
|
+
(label[len(prefix) :] for label in labels if label.startswith(prefix)), None
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
severity = unprefixed(SEVERITY_LABEL_PREFIX)
|
|
905
|
+
envelope_id = unprefixed(ENVELOPE_LABEL_PREFIX)
|
|
906
|
+
# Put the algorithm prefix back, so a fingerprint read out of Jira
|
|
907
|
+
# compares equal to one an envelope computes (`DefectEnvelope.fingerprint`).
|
|
908
|
+
fingerprint = unprefixed(FINGERPRINT_LABEL_PREFIX)
|
|
909
|
+
if fingerprint and len(fingerprint) == 64 and all(c in "0123456789abcdef" for c in fingerprint):
|
|
910
|
+
fingerprint = f"sha256:{fingerprint}"
|
|
911
|
+
status = ((fields.get("status") or {}).get("name")) or "open"
|
|
912
|
+
project = ((fields.get("project") or {}).get("key")) or self.default_project
|
|
913
|
+
reporter = (fields.get("reporter") or {}).get("displayName")
|
|
914
|
+
return Issue(
|
|
915
|
+
key=str(data.get("key", "")),
|
|
916
|
+
project=str(project),
|
|
917
|
+
title=str(fields.get("summary") or ""),
|
|
918
|
+
body=adf_to_text(fields.get("description")),
|
|
919
|
+
status=str(status),
|
|
920
|
+
labels=sorted(labels),
|
|
921
|
+
severity=severity,
|
|
922
|
+
envelope_id=envelope_id,
|
|
923
|
+
fingerprint=fingerprint,
|
|
924
|
+
reporter=reporter,
|
|
925
|
+
links=self._links_from_jira(fields.get("issuelinks") or []),
|
|
926
|
+
created_at=self._parse_time(fields.get("created")),
|
|
927
|
+
updated_at=self._parse_time(fields.get("updated")),
|
|
928
|
+
)
|
|
929
|
+
|
|
930
|
+
@staticmethod
|
|
931
|
+
def _links_from_jira(raw_links: list[dict[str, Any]]) -> list[IssueLink]:
|
|
932
|
+
"""Map Jira's link vocabulary back to the house one, best effort."""
|
|
933
|
+
reverse: dict[tuple[str, str], str] = {}
|
|
934
|
+
for house, (names, direction) in JIRA_LINK_TYPES.items():
|
|
935
|
+
reverse.setdefault((names[0].lower(), direction), house)
|
|
936
|
+
links: list[IssueLink] = []
|
|
937
|
+
for entry in raw_links:
|
|
938
|
+
name = str(((entry.get("type") or {}).get("name") or "")).lower()
|
|
939
|
+
if entry.get("outwardIssue"):
|
|
940
|
+
other, direction = entry["outwardIssue"], "outward"
|
|
941
|
+
elif entry.get("inwardIssue"):
|
|
942
|
+
other, direction = entry["inwardIssue"], "inward"
|
|
943
|
+
else:
|
|
944
|
+
continue
|
|
945
|
+
key = other.get("key")
|
|
946
|
+
if not key:
|
|
947
|
+
continue
|
|
948
|
+
links.append(IssueLink(type=reverse.get((name, direction), "relates"), to=str(key)))
|
|
949
|
+
return links
|
|
950
|
+
|
|
951
|
+
_FIELDS = "summary,status,labels,description,issuelinks,reporter,project,created,updated"
|
|
952
|
+
|
|
953
|
+
# -- configuration checks ---------------------------------------------
|
|
954
|
+
# Read-only calls an operator can make before a run files anything real.
|
|
955
|
+
# They live here rather than in the CLI because knowing which endpoint
|
|
956
|
+
# answers "can this account create issues in this project" is Jira
|
|
957
|
+
# knowledge, and this module is where Jira knowledge is allowed to be.
|
|
958
|
+
|
|
959
|
+
#: The four project permissions this system needs, in Jira's own
|
|
960
|
+
#: vocabulary. Anything less and the failure arrives mid-run: Browse to read
|
|
961
|
+
#: an issue back, Create to file, Transition to close, Link to dedupe.
|
|
962
|
+
PROJECT_PERMISSIONS = ("BROWSE_PROJECTS", "CREATE_ISSUES", "TRANSITION_ISSUES", "LINK_ISSUES")
|
|
963
|
+
|
|
964
|
+
def whoami(self) -> dict[str, Any]:
|
|
965
|
+
"""The account these credentials belong to. Proves auth without writing."""
|
|
966
|
+
return self._request("GET", "/myself", retry_on_429=True)
|
|
967
|
+
|
|
968
|
+
def project_info(self, key: str) -> dict[str, Any]:
|
|
969
|
+
"""One project's metadata. A 404 here means the key is wrong or unreadable."""
|
|
970
|
+
return self._request("GET", f"/project/{urllib.parse.quote(key)}", retry_on_429=True)
|
|
971
|
+
|
|
972
|
+
def project_permissions(self, key: str) -> dict[str, bool]:
|
|
973
|
+
"""Which of `PROJECT_PERMISSIONS` this account actually holds on `key`.
|
|
974
|
+
|
|
975
|
+
Asked of Jira rather than inferred from a successful read: browsing a
|
|
976
|
+
project and being able to file into it are different grants, and the
|
|
977
|
+
gap between them is where a first live run dies.
|
|
978
|
+
"""
|
|
979
|
+
data = self._request(
|
|
980
|
+
"GET",
|
|
981
|
+
"/mypermissions",
|
|
982
|
+
params={"projectKey": key, "permissions": ",".join(self.PROJECT_PERMISSIONS)},
|
|
983
|
+
retry_on_429=True,
|
|
984
|
+
)
|
|
985
|
+
granted = data.get("permissions") or {}
|
|
986
|
+
return {
|
|
987
|
+
name: bool((granted.get(name) or {}).get("havePermission"))
|
|
988
|
+
for name in self.PROJECT_PERMISSIONS
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
def _jira_status_name(self, status: str, project: str | None = None) -> str:
|
|
992
|
+
"""A house status translated into this project's own vocabulary.
|
|
993
|
+
|
|
994
|
+
The `search` tool advertises `{"enum": list(STATUSES)}` — the house names
|
|
995
|
+
— and forwarded them to Jira verbatim, which validates status names and
|
|
996
|
+
answers 400. So TRIAGE following its own schema to dedupe
|
|
997
|
+
(`search(status="open", fingerprint=...)`) errored on every call, dedupe
|
|
998
|
+
degraded silently, and the duplicate ticket this system exists to prevent
|
|
999
|
+
got filed. `transition` goes to real trouble to translate via
|
|
1000
|
+
`JIRA_STATUS_ALIASES`; this did not, and `_issue_from_jira` deliberately
|
|
1001
|
+
stores Jira's own status name, so the house vocabulary could never match.
|
|
1002
|
+
|
|
1003
|
+
A name that is already this project's own is passed through untouched:
|
|
1004
|
+
the one existing test for this path searches `status="Done"`, which is
|
|
1005
|
+
Jira vocabulary rather than anything in `STATUSES`.
|
|
1006
|
+
"""
|
|
1007
|
+
if status not in STATUSES:
|
|
1008
|
+
return status
|
|
1009
|
+
key = project or self._project
|
|
1010
|
+
try:
|
|
1011
|
+
names = sorted({n for group in self.project_statuses(key).values() for n in group})
|
|
1012
|
+
except TrackerError:
|
|
1013
|
+
# The workflow is unreadable; the caller's word is the best we have,
|
|
1014
|
+
# and a 400 from a search is not worth failing a run over.
|
|
1015
|
+
return status
|
|
1016
|
+
return self.map_house_statuses(names).get(status) or status
|
|
1017
|
+
|
|
1018
|
+
def project_statuses(self, key: str) -> dict[str, list[str]]:
|
|
1019
|
+
"""Issue type name -> the status names its workflow contains.
|
|
1020
|
+
|
|
1021
|
+
This is the only read-only view of a project's workflow vocabulary.
|
|
1022
|
+
`/issue/{key}/transitions` is more precise but needs an issue that
|
|
1023
|
+
already exists, and shows only the edges out of that one issue's
|
|
1024
|
+
current status — useless for "will this project ever accept 'closed'".
|
|
1025
|
+
"""
|
|
1026
|
+
data = self._request(
|
|
1027
|
+
"GET", f"/project/{urllib.parse.quote(key)}/statuses", retry_on_429=True
|
|
1028
|
+
)
|
|
1029
|
+
entries = data if isinstance(data, list) else []
|
|
1030
|
+
return {
|
|
1031
|
+
str(entry.get("name") or ""): [
|
|
1032
|
+
str((status or {}).get("name") or "") for status in entry.get("statuses") or []
|
|
1033
|
+
]
|
|
1034
|
+
for entry in entries
|
|
1035
|
+
if isinstance(entry, dict)
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
@staticmethod
|
|
1039
|
+
def map_house_statuses(status_names: list[str]) -> dict[str, str | None]:
|
|
1040
|
+
"""House status -> the project status it will resolve to, or None.
|
|
1041
|
+
|
|
1042
|
+
Mirrors `_match_transition`'s candidate order, so what this predicts is
|
|
1043
|
+
what a transition will actually do. A None is a silent failure waiting
|
|
1044
|
+
to happen: VERIFIER asks for 'closed', nothing matches, and the ticket sits
|
|
1045
|
+
open while the run reports success.
|
|
1046
|
+
"""
|
|
1047
|
+
available = {name.strip().lower(): name for name in status_names if name.strip()}
|
|
1048
|
+
mapped: dict[str, str | None] = {}
|
|
1049
|
+
for house in STATUSES:
|
|
1050
|
+
candidates = [house, house.replace("_", " "), house.replace("-", " ")]
|
|
1051
|
+
candidates += list(JIRA_STATUS_ALIASES.get(house, ()))
|
|
1052
|
+
mapped[house] = next(
|
|
1053
|
+
(available[candidate] for candidate in candidates if candidate in available), None
|
|
1054
|
+
)
|
|
1055
|
+
return mapped
|
|
1056
|
+
|
|
1057
|
+
# -- boards -----------------------------------------------------------
|
|
1058
|
+
#
|
|
1059
|
+
# A board per repository, without a project per repository. Creating a Jira
|
|
1060
|
+
# *project* needs administrator rights that a bot account normally does not
|
|
1061
|
+
# have, and a project per repository is unmanageable by the tenth repo.
|
|
1062
|
+
# Creating a saved *filter* needs no special grant, and a board can be built
|
|
1063
|
+
# over a filter — so every repository gets its own board inside one project,
|
|
1064
|
+
# and the tickets are separated by a label rather than by a project key.
|
|
1065
|
+
|
|
1066
|
+
def find_filter(self, name: str) -> dict[str, Any] | None:
|
|
1067
|
+
"""A filter owned by this account with exactly this name, or None.
|
|
1068
|
+
|
|
1069
|
+
Matched on the account's own filters rather than on all visible ones:
|
|
1070
|
+
`filter/search` returns other people's filters too, and adopting a
|
|
1071
|
+
stranger's filter as the run's board would silently repoint it.
|
|
1072
|
+
"""
|
|
1073
|
+
params = {"filterName": name, "expand": "jql,owner", "maxResults": "50"}
|
|
1074
|
+
account = self._account_id()
|
|
1075
|
+
if account:
|
|
1076
|
+
# Omitted rather than sent empty: Jira answers an empty accountId
|
|
1077
|
+
# with a 400, which would read as "the filter API is broken".
|
|
1078
|
+
params["accountId"] = account
|
|
1079
|
+
data = self._request("GET", "/filter/search", params=params, retry_on_429=True)
|
|
1080
|
+
for entry in data.get("values") or []:
|
|
1081
|
+
if str(entry.get("name") or "").strip() != name:
|
|
1082
|
+
continue
|
|
1083
|
+
# With no account id the scoping parameter was omitted, so the search
|
|
1084
|
+
# returned everyone's filters and the first name match was adopted --
|
|
1085
|
+
# a stranger's filter, which `ensure_repo_board` now PUTs new JQL
|
|
1086
|
+
# onto. Failing open was survivable while this only read; since the
|
|
1087
|
+
# repair landed it rewrites someone else's saved filter. When the
|
|
1088
|
+
# owner cannot be established, decline to reuse and make our own.
|
|
1089
|
+
owner = (entry.get("owner") or {}).get("accountId")
|
|
1090
|
+
if account and owner and owner != account:
|
|
1091
|
+
continue
|
|
1092
|
+
if not account and owner:
|
|
1093
|
+
continue
|
|
1094
|
+
return entry
|
|
1095
|
+
return None
|
|
1096
|
+
|
|
1097
|
+
def update_filter_jql(self, filter_id: int, *, name: str, jql: str) -> dict[str, Any]:
|
|
1098
|
+
"""Repoint an existing filter at new JQL.
|
|
1099
|
+
|
|
1100
|
+
A filter is found by name and reused, and the name does not encode the
|
|
1101
|
+
project. Repointing `JIRA_PROJECT_KEY` at a different project therefore
|
|
1102
|
+
left the filter still scoped to the old one: tickets filed correctly,
|
|
1103
|
+
into the new project, and did not appear on the view someone had been
|
|
1104
|
+
told to watch. That is the same "files fine, invisible" failure the
|
|
1105
|
+
`repo-` label exists to prevent, arriving by a different road.
|
|
1106
|
+
"""
|
|
1107
|
+
# `sharePermissions` is sent again on purpose. A PUT replaces the
|
|
1108
|
+
# filter, so omitting it un-shares the filter this call just repaired --
|
|
1109
|
+
# and Jira refuses to render a board over a private filter, which is the
|
|
1110
|
+
# same "files fine, invisible" outcome two calls later. `create_filter`
|
|
1111
|
+
# below records why the share matters.
|
|
1112
|
+
return self._request(
|
|
1113
|
+
"PUT",
|
|
1114
|
+
f"/filter/{filter_id}",
|
|
1115
|
+
body={"name": name, "jql": jql, "sharePermissions": [{"type": "authenticated"}]},
|
|
1116
|
+
)
|
|
1117
|
+
|
|
1118
|
+
def create_filter(self, *, name: str, jql: str, description: str = "") -> dict[str, Any]:
|
|
1119
|
+
"""A saved filter, shared with authenticated users.
|
|
1120
|
+
|
|
1121
|
+
The share permission is not optional decoration: Jira refuses to build
|
|
1122
|
+
a board over a private filter, and the refusal arrives as a 400 on the
|
|
1123
|
+
*board* call, two steps away from the cause.
|
|
1124
|
+
"""
|
|
1125
|
+
return self._request(
|
|
1126
|
+
"POST",
|
|
1127
|
+
"/filter",
|
|
1128
|
+
body={
|
|
1129
|
+
"name": name,
|
|
1130
|
+
"jql": jql,
|
|
1131
|
+
"description": description,
|
|
1132
|
+
"favourite": True,
|
|
1133
|
+
"sharePermissions": [{"type": "authenticated"}],
|
|
1134
|
+
},
|
|
1135
|
+
)
|
|
1136
|
+
|
|
1137
|
+
def is_team_managed(self, key: str) -> bool:
|
|
1138
|
+
"""Whether `key` is a team-managed (next-gen) project.
|
|
1139
|
+
|
|
1140
|
+
Team-managed projects own their boards. The Agile API will still accept
|
|
1141
|
+
`POST /board` over a filter and hand back an id — and the resulting
|
|
1142
|
+
board has no `location`, which means the Jira UI has no page for it:
|
|
1143
|
+
both `/jira/software/boards/<id>` and
|
|
1144
|
+
`/jira/software/c/projects/<KEY>/boards/<id>` answer 404. So the board
|
|
1145
|
+
exists, is unreachable, and the link handed to a human is broken. Ask
|
|
1146
|
+
first, and make only the filter.
|
|
1147
|
+
"""
|
|
1148
|
+
try:
|
|
1149
|
+
info = self.project_info(key)
|
|
1150
|
+
except TrackerError:
|
|
1151
|
+
return False # unknown: try, and fall back on the answer
|
|
1152
|
+
return str(info.get("style") or "").lower() == "next-gen" or bool(info.get("simplified"))
|
|
1153
|
+
|
|
1154
|
+
def find_board(self, name: str) -> dict[str, Any] | None:
|
|
1155
|
+
"""A board with exactly this name, or None. Agile API."""
|
|
1156
|
+
data = self._request(
|
|
1157
|
+
"GET",
|
|
1158
|
+
"/board",
|
|
1159
|
+
params={"name": name, "maxResults": "50"},
|
|
1160
|
+
retry_on_429=True,
|
|
1161
|
+
api_base=JIRA_AGILE_BASE,
|
|
1162
|
+
)
|
|
1163
|
+
for entry in data.get("values") or []:
|
|
1164
|
+
if str(entry.get("name") or "").strip() == name:
|
|
1165
|
+
return entry
|
|
1166
|
+
return None
|
|
1167
|
+
|
|
1168
|
+
def create_board(self, *, name: str, filter_id: int, board_type: str = "kanban") -> dict[str, Any]:
|
|
1169
|
+
return self._request(
|
|
1170
|
+
"POST",
|
|
1171
|
+
"/board",
|
|
1172
|
+
body={"name": name, "type": board_type, "filterId": filter_id},
|
|
1173
|
+
api_base=JIRA_AGILE_BASE,
|
|
1174
|
+
)
|
|
1175
|
+
|
|
1176
|
+
#: The one board URL that is never wrong. Jira redirects it to whichever
|
|
1177
|
+
#: canonical form this site actually uses — `/jira/software/c/projects/...`
|
|
1178
|
+
#: for a company-managed project, `/jira/software/projects/...` for a
|
|
1179
|
+
#: team-managed one, and neither for a board with no project location at
|
|
1180
|
+
#: all. Constructing the canonical form by hand produced a link that
|
|
1181
|
+
#: returned Jira's generic error page, because the guess omitted the `/c/`.
|
|
1182
|
+
BOARD_PATH = "/secure/RapidBoard.jspa?rapidView={id}"
|
|
1183
|
+
|
|
1184
|
+
def board_url(self, board_id: int) -> str:
|
|
1185
|
+
"""A link to the board. Resolved with Jira rather than assembled.
|
|
1186
|
+
|
|
1187
|
+
Falls back to the redirecting form, which works everywhere but reads
|
|
1188
|
+
like an internal URL — worth one HTTP call to avoid handing someone a
|
|
1189
|
+
link they will not recognise.
|
|
1190
|
+
"""
|
|
1191
|
+
redirecting = f"{self.base_url}{self.BOARD_PATH.format(id=board_id)}"
|
|
1192
|
+
return self._resolve_url(redirecting) or redirecting
|
|
1193
|
+
|
|
1194
|
+
def _resolve_url(self, url: str) -> str | None:
|
|
1195
|
+
"""Where a browser would land, following Jira's own redirect. None on failure.
|
|
1196
|
+
|
|
1197
|
+
Deliberately not `_request`: this asks for a UI page, not JSON, and its
|
|
1198
|
+
answer is the final URL rather than the body. A failure here is never
|
|
1199
|
+
fatal — the caller keeps the redirecting URL, which works.
|
|
1200
|
+
"""
|
|
1201
|
+
request = urllib.request.Request(
|
|
1202
|
+
url, method="GET", headers={**self._headers(), "Accept": "text/html"}
|
|
1203
|
+
)
|
|
1204
|
+
try:
|
|
1205
|
+
with self._opener.open(request, timeout=self.timeout) as response:
|
|
1206
|
+
resolved = response.geturl()
|
|
1207
|
+
except (urllib.error.URLError, TimeoutError, OSError):
|
|
1208
|
+
return None
|
|
1209
|
+
return resolved if resolved and resolved != url else None
|
|
1210
|
+
|
|
1211
|
+
def filter_url(self, filter_id: int) -> str:
|
|
1212
|
+
return f"{self.base_url}/issues/?filter={filter_id}"
|
|
1213
|
+
|
|
1214
|
+
def _account_id(self) -> str | None:
|
|
1215
|
+
"""This credential's Atlassian account id, fetched once.
|
|
1216
|
+
|
|
1217
|
+
Cached because `find_filter` is called on every run and `/myself` is
|
|
1218
|
+
the same answer every time.
|
|
1219
|
+
"""
|
|
1220
|
+
if self._account_id_cache is None:
|
|
1221
|
+
try:
|
|
1222
|
+
self._account_id_cache = str(self.whoami().get("accountId") or "")
|
|
1223
|
+
except TrackerError:
|
|
1224
|
+
self._account_id_cache = ""
|
|
1225
|
+
return self._account_id_cache or None
|
|
1226
|
+
|
|
1227
|
+
def ensure_repo_board(
|
|
1228
|
+
self,
|
|
1229
|
+
slug: str,
|
|
1230
|
+
*,
|
|
1231
|
+
display: str | None = None,
|
|
1232
|
+
project: str | None = None,
|
|
1233
|
+
) -> BoardInfo:
|
|
1234
|
+
"""Find or create the board for one repository. Idempotent.
|
|
1235
|
+
|
|
1236
|
+
Called at the top of every run, so it must be safe to call when
|
|
1237
|
+
everything already exists — the second run against a repository reuses
|
|
1238
|
+
the board rather than making `repo QA (2)`.
|
|
1239
|
+
|
|
1240
|
+
A board this could not create is reported, not raised. The run's job is
|
|
1241
|
+
to find defects and file them; a missing board makes the tickets harder
|
|
1242
|
+
to look at, and nothing else. Losing the findings over it would be the
|
|
1243
|
+
larger failure.
|
|
1244
|
+
"""
|
|
1245
|
+
label = repo_label(slug)
|
|
1246
|
+
if label is None:
|
|
1247
|
+
raise TrackerError(
|
|
1248
|
+
f"'{slug}' cannot become a Jira label, so no per-repository board can be "
|
|
1249
|
+
"built for it. Give the target a simpler name with `qaas init --name`."
|
|
1250
|
+
)
|
|
1251
|
+
key = project or self._project
|
|
1252
|
+
jql = f'project = "{key}" AND labels = "{label}" ORDER BY created DESC'
|
|
1253
|
+
name = f"{display or _label_slug(slug)} — QA (qaas)"
|
|
1254
|
+
|
|
1255
|
+
info = BoardInfo(slug=_label_slug(slug) or slug, label=label, jql=jql)
|
|
1256
|
+
|
|
1257
|
+
existing = self.find_filter(name)
|
|
1258
|
+
if existing is None:
|
|
1259
|
+
created = self.create_filter(
|
|
1260
|
+
name=name,
|
|
1261
|
+
jql=jql,
|
|
1262
|
+
description=(
|
|
1263
|
+
f"Defects filed automatically by qaas against {slug}. "
|
|
1264
|
+
f"Every ticket carries the label {label}."
|
|
1265
|
+
),
|
|
1266
|
+
)
|
|
1267
|
+
info = replace(info, filter_id=int(created["id"]), filter_name=name, created_filter=True)
|
|
1268
|
+
else:
|
|
1269
|
+
info = replace(info, filter_id=int(existing["id"]), filter_name=name)
|
|
1270
|
+
# A reused filter is only the right filter if it still asks the
|
|
1271
|
+
# right question. `expand=jql` on the search is what makes this
|
|
1272
|
+
# checkable without a second round trip.
|
|
1273
|
+
if str(existing.get("jql") or "").strip() != jql:
|
|
1274
|
+
self.update_filter_jql(info.filter_id, name=name, jql=jql)
|
|
1275
|
+
info = replace(info, created_filter=True)
|
|
1276
|
+
|
|
1277
|
+
filter_link = self.filter_url(info.filter_id)
|
|
1278
|
+
info = replace(info, url=filter_link, filter_url=filter_link)
|
|
1279
|
+
|
|
1280
|
+
if self.is_team_managed(key):
|
|
1281
|
+
# Not a failure and not worth attempting: the POST would succeed and
|
|
1282
|
+
# produce a board with no UI page. The filter is the deliverable
|
|
1283
|
+
# here, and it is a good one — named, starred, scoped to the label.
|
|
1284
|
+
return replace(
|
|
1285
|
+
info,
|
|
1286
|
+
note=(
|
|
1287
|
+
f"'{key}' is a team-managed project, which owns its own board and "
|
|
1288
|
+
"cannot have a second one built over a filter. The saved filter "
|
|
1289
|
+
f"'{name}' is the per-repository view instead: every ticket carries "
|
|
1290
|
+
f"{label}, and the link above opens exactly this repository's defects."
|
|
1291
|
+
),
|
|
1292
|
+
)
|
|
1293
|
+
|
|
1294
|
+
board = self.find_board(name)
|
|
1295
|
+
if board is not None:
|
|
1296
|
+
return self._with_board(info, int(board["id"]), name, created=False)
|
|
1297
|
+
|
|
1298
|
+
try:
|
|
1299
|
+
made = self.create_board(name=name, filter_id=int(info.filter_id))
|
|
1300
|
+
except TrackerError as exc:
|
|
1301
|
+
# An account without "Create shared objects" lands here. The filter
|
|
1302
|
+
# is still usable, which is why this returns rather than raises.
|
|
1303
|
+
return replace(
|
|
1304
|
+
info,
|
|
1305
|
+
note=(
|
|
1306
|
+
f"Jira would not create a board over the filter ({exc}). The filter "
|
|
1307
|
+
f"exists and every ticket carries {label}, so open the link above, or "
|
|
1308
|
+
"create a board from it by hand in Jira."
|
|
1309
|
+
),
|
|
1310
|
+
)
|
|
1311
|
+
return self._with_board(info, int(made["id"]), name, created=True)
|
|
1312
|
+
|
|
1313
|
+
def _with_board(self, info: BoardInfo, board_id: int, name: str, *, created: bool) -> BoardInfo:
|
|
1314
|
+
"""Attach a board to the result — but only if the UI can actually show it.
|
|
1315
|
+
|
|
1316
|
+
`is_team_managed` is the authoritative gate and it runs before any of
|
|
1317
|
+
this. The `location` check below is a second, weaker one, and it is
|
|
1318
|
+
applied **only to a board that already existed**: Jira populates
|
|
1319
|
+
`location` asynchronously, so a board read back immediately after
|
|
1320
|
+
creation reports `location: None` whatever its project. Gating a fresh
|
|
1321
|
+
board on that field rejected perfectly good boards in a company-managed
|
|
1322
|
+
project — a false negative, observed, not theorised.
|
|
1323
|
+
"""
|
|
1324
|
+
if not created and not self._board_is_reachable(board_id):
|
|
1325
|
+
return replace(
|
|
1326
|
+
info,
|
|
1327
|
+
board_id=board_id,
|
|
1328
|
+
board_name=name,
|
|
1329
|
+
note=(
|
|
1330
|
+
f"Jira created board {board_id} but gave it no project location, so it "
|
|
1331
|
+
"has no page in the Jira UI. The saved filter above is the working "
|
|
1332
|
+
"per-repository view."
|
|
1333
|
+
),
|
|
1334
|
+
)
|
|
1335
|
+
return replace(
|
|
1336
|
+
info,
|
|
1337
|
+
board_id=board_id,
|
|
1338
|
+
board_name=name,
|
|
1339
|
+
url=self.board_url(board_id),
|
|
1340
|
+
created_board=created,
|
|
1341
|
+
)
|
|
1342
|
+
|
|
1343
|
+
def _board_is_reachable(self, board_id: int) -> bool:
|
|
1344
|
+
"""Whether an existing board has a project location, and so a UI page.
|
|
1345
|
+
|
|
1346
|
+
Only meaningful for a board that has existed for a while; see
|
|
1347
|
+
`_with_board`. And note that a location is necessary, not sufficient —
|
|
1348
|
+
a team-managed project's API-made board eventually reports one and the
|
|
1349
|
+
UI still refuses to render it, which is why `is_team_managed` and not
|
|
1350
|
+
this is the real gate.
|
|
1351
|
+
"""
|
|
1352
|
+
try:
|
|
1353
|
+
board = self._request(
|
|
1354
|
+
"GET", f"/board/{board_id}", retry_on_429=True, api_base=JIRA_AGILE_BASE
|
|
1355
|
+
)
|
|
1356
|
+
except TrackerError:
|
|
1357
|
+
return False
|
|
1358
|
+
return bool(board.get("location"))
|
|
1359
|
+
|
|
1360
|
+
# -- operations -------------------------------------------------------
|
|
1361
|
+
|
|
1362
|
+
@staticmethod
|
|
1363
|
+
def _description(body: str, reporter: str | None) -> str:
|
|
1364
|
+
"""The ticket body with the filing agent named inside it.
|
|
1365
|
+
|
|
1366
|
+
Jira sets `reporter` from the credential whatever we send, so the agent
|
|
1367
|
+
that found the defect has to be recorded somewhere that stays true.
|
|
1368
|
+
"""
|
|
1369
|
+
if not reporter:
|
|
1370
|
+
return body
|
|
1371
|
+
return (
|
|
1372
|
+
f"{body}\n\nFiled by {reporter} (automated QA)."
|
|
1373
|
+
if body
|
|
1374
|
+
else f"Filed by {reporter} (automated QA)."
|
|
1375
|
+
)
|
|
1376
|
+
|
|
1377
|
+
@staticmethod
|
|
1378
|
+
def _labels_for(
|
|
1379
|
+
labels: list[str] | None,
|
|
1380
|
+
severity: str | None,
|
|
1381
|
+
envelope_id: str | None,
|
|
1382
|
+
fingerprint: str | None,
|
|
1383
|
+
) -> list[str]:
|
|
1384
|
+
"""Caller labels plus the house metadata labels, deduped and sorted."""
|
|
1385
|
+
all_labels = set(labels or [])
|
|
1386
|
+
for value, prefix in (
|
|
1387
|
+
(severity, SEVERITY_LABEL_PREFIX),
|
|
1388
|
+
(envelope_id, ENVELOPE_LABEL_PREFIX),
|
|
1389
|
+
(_fingerprint_label_value(fingerprint), FINGERPRINT_LABEL_PREFIX),
|
|
1390
|
+
):
|
|
1391
|
+
label = _label_safe(value, prefix)
|
|
1392
|
+
if label:
|
|
1393
|
+
all_labels.add(label)
|
|
1394
|
+
return sorted(all_labels)
|
|
1395
|
+
|
|
1396
|
+
def create_payload(
|
|
1397
|
+
self,
|
|
1398
|
+
*,
|
|
1399
|
+
project: str,
|
|
1400
|
+
title: str,
|
|
1401
|
+
body: str = "",
|
|
1402
|
+
labels: list[str] | None = None,
|
|
1403
|
+
severity: str | None = None,
|
|
1404
|
+
envelope_id: str | None = None,
|
|
1405
|
+
fingerprint: str | None = None,
|
|
1406
|
+
reporter: str | None = None,
|
|
1407
|
+
) -> dict[str, Any]:
|
|
1408
|
+
"""The exact JSON body `create_issue` would POST to `/issue`.
|
|
1409
|
+
|
|
1410
|
+
Split out so `qaas tracker-check --dry-run-ticket` can show an operator
|
|
1411
|
+
the ADF and the labels before a real ticket lands in front of real
|
|
1412
|
+
people. It must be the same code path: a preview that *reconstructs*
|
|
1413
|
+
the payload is correct only until the day it drifts, and it would be
|
|
1414
|
+
trusted either way.
|
|
1415
|
+
"""
|
|
1416
|
+
if not title.strip():
|
|
1417
|
+
raise TrackerError("an issue needs a title")
|
|
1418
|
+
return {
|
|
1419
|
+
"fields": {
|
|
1420
|
+
"project": {"key": project},
|
|
1421
|
+
"summary": title.strip()[:255], # Jira's summary limit; a 400 here is silly
|
|
1422
|
+
"description": markdown_to_adf(self._description(body, reporter)),
|
|
1423
|
+
"issuetype": {"name": self.issue_type},
|
|
1424
|
+
"labels": self._labels_for(labels, severity, envelope_id, fingerprint),
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
def create_issue(
|
|
1429
|
+
self,
|
|
1430
|
+
*,
|
|
1431
|
+
project: str,
|
|
1432
|
+
title: str,
|
|
1433
|
+
body: str = "",
|
|
1434
|
+
labels: list[str] | None = None,
|
|
1435
|
+
severity: str | None = None,
|
|
1436
|
+
envelope_id: str | None = None,
|
|
1437
|
+
fingerprint: str | None = None,
|
|
1438
|
+
reporter: str | None = None,
|
|
1439
|
+
) -> Issue:
|
|
1440
|
+
payload = self.create_payload(
|
|
1441
|
+
project=project,
|
|
1442
|
+
title=title,
|
|
1443
|
+
body=body,
|
|
1444
|
+
labels=labels,
|
|
1445
|
+
severity=severity,
|
|
1446
|
+
envelope_id=envelope_id,
|
|
1447
|
+
fingerprint=fingerprint,
|
|
1448
|
+
reporter=reporter,
|
|
1449
|
+
)
|
|
1450
|
+
created = self._request("POST", "/issue", body=payload, retry_on_429=False)
|
|
1451
|
+
key = str(created.get("key") or "")
|
|
1452
|
+
if not key:
|
|
1453
|
+
raise TrackerError(f"Jira accepted the issue but returned no key: {created!r}")
|
|
1454
|
+
|
|
1455
|
+
fallback = self._local_issue(
|
|
1456
|
+
key, project, title, self._description(body, reporter),
|
|
1457
|
+
list(payload["fields"]["labels"]),
|
|
1458
|
+
severity, envelope_id, fingerprint, reporter,
|
|
1459
|
+
)
|
|
1460
|
+
# Reading the issue back is a convenience, not the record. The ticket
|
|
1461
|
+
# exists the moment Jira answered the POST, so a failure here must not
|
|
1462
|
+
# lose the key — a filed ticket nobody can name is a stranded ticket.
|
|
1463
|
+
try:
|
|
1464
|
+
return self.get(key) or fallback
|
|
1465
|
+
except TrackerError:
|
|
1466
|
+
return fallback
|
|
1467
|
+
|
|
1468
|
+
@staticmethod
|
|
1469
|
+
def _local_issue(
|
|
1470
|
+
key: str,
|
|
1471
|
+
project: str,
|
|
1472
|
+
title: str,
|
|
1473
|
+
body: str,
|
|
1474
|
+
labels: list[str],
|
|
1475
|
+
severity: str | None,
|
|
1476
|
+
envelope_id: str | None,
|
|
1477
|
+
fingerprint: str | None,
|
|
1478
|
+
reporter: str | None,
|
|
1479
|
+
) -> Issue:
|
|
1480
|
+
"""What we know about an issue Jira created but would not read back."""
|
|
1481
|
+
return Issue(
|
|
1482
|
+
key=key,
|
|
1483
|
+
project=project,
|
|
1484
|
+
title=title.strip(),
|
|
1485
|
+
body=body,
|
|
1486
|
+
labels=labels,
|
|
1487
|
+
severity=severity,
|
|
1488
|
+
envelope_id=envelope_id,
|
|
1489
|
+
fingerprint=fingerprint,
|
|
1490
|
+
reporter=reporter,
|
|
1491
|
+
history=[Transition(status="open", by=reporter, comment="filed")],
|
|
1492
|
+
)
|
|
1493
|
+
|
|
1494
|
+
def get(self, key: str) -> Issue | None:
|
|
1495
|
+
if not _KEY_RE.match(key):
|
|
1496
|
+
raise TrackerError(f"'{key}' is not an issue key; expected e.g. {self._project}-1")
|
|
1497
|
+
try:
|
|
1498
|
+
data = self._request(
|
|
1499
|
+
"GET", f"/issue/{urllib.parse.quote(key)}",
|
|
1500
|
+
params={"fields": self._FIELDS}, retry_on_429=True,
|
|
1501
|
+
)
|
|
1502
|
+
except TrackerError as exc:
|
|
1503
|
+
if "(404)" in str(exc):
|
|
1504
|
+
return None
|
|
1505
|
+
raise
|
|
1506
|
+
return self._issue_from_jira(data)
|
|
1507
|
+
|
|
1508
|
+
def _require(self, key: str) -> Issue:
|
|
1509
|
+
issue = self.get(key)
|
|
1510
|
+
if issue is None:
|
|
1511
|
+
raise UnknownIssue(f"no issue '{key}' in the tracker")
|
|
1512
|
+
return issue
|
|
1513
|
+
|
|
1514
|
+
def transition(self, key: str, status: str, *, by: str | None = None, comment: str = "") -> Issue:
|
|
1515
|
+
"""Move an issue by *status name*, resolved against the real workflow.
|
|
1516
|
+
|
|
1517
|
+
Jira's API takes a transition id, and ids are per-project and unstable,
|
|
1518
|
+
so the name has to be resolved every time. A name that does not resolve
|
|
1519
|
+
raises with the transitions that do exist: doing nothing quietly is the
|
|
1520
|
+
worst outcome here, because the caller believes the ticket moved.
|
|
1521
|
+
"""
|
|
1522
|
+
issue = self._require(key)
|
|
1523
|
+
available = (
|
|
1524
|
+
self._request(
|
|
1525
|
+
"GET", f"/issue/{urllib.parse.quote(key)}/transitions", retry_on_429=True
|
|
1526
|
+
).get("transitions")
|
|
1527
|
+
or []
|
|
1528
|
+
)
|
|
1529
|
+
chosen = self._match_transition(status, available)
|
|
1530
|
+
if chosen is None:
|
|
1531
|
+
offered = ", ".join(
|
|
1532
|
+
f"'{t.get('name')}' (-> {(t.get('to') or {}).get('name', '?')})"
|
|
1533
|
+
for t in available
|
|
1534
|
+
) or "none at all"
|
|
1535
|
+
raise TrackerError(
|
|
1536
|
+
f"cannot move {key} to '{status}': no such transition from its current "
|
|
1537
|
+
f"status '{issue.status}'. Jira offers: {offered}. Jira workflows are "
|
|
1538
|
+
"per-project — use one of those names, not a house status."
|
|
1539
|
+
)
|
|
1540
|
+
|
|
1541
|
+
self._request(
|
|
1542
|
+
"POST", f"/issue/{urllib.parse.quote(key)}/transitions",
|
|
1543
|
+
body={"transition": {"id": str(chosen["id"])}}, retry_on_429=False,
|
|
1544
|
+
)
|
|
1545
|
+
|
|
1546
|
+
if comment or by:
|
|
1547
|
+
note = f"{by or 'qaas'}: {comment}" if comment else f"Transitioned by {by}."
|
|
1548
|
+
try:
|
|
1549
|
+
self._request(
|
|
1550
|
+
"POST", f"/issue/{urllib.parse.quote(key)}/comment",
|
|
1551
|
+
body={"body": markdown_to_adf(note)}, retry_on_429=False,
|
|
1552
|
+
)
|
|
1553
|
+
except TrackerError as exc:
|
|
1554
|
+
# The transition already applied; saying nothing would leave the
|
|
1555
|
+
# caller believing the audit trail is complete when it is not.
|
|
1556
|
+
raise TrackerError(
|
|
1557
|
+
f"{key} was transitioned to '{chosen.get('name')}', but the comment "
|
|
1558
|
+
f"could not be added: {exc}"
|
|
1559
|
+
) from None
|
|
1560
|
+
|
|
1561
|
+
return self._require(key)
|
|
1562
|
+
|
|
1563
|
+
@staticmethod
|
|
1564
|
+
def _match_transition(
|
|
1565
|
+
status: str, available: list[dict[str, Any]]
|
|
1566
|
+
) -> dict[str, Any] | None:
|
|
1567
|
+
"""Match a requested status against transition and target names.
|
|
1568
|
+
|
|
1569
|
+
Case-insensitive, and it tries the house aliases (`in_progress` ->
|
|
1570
|
+
"In Progress", "Doing", ...) so callers speaking the house vocabulary
|
|
1571
|
+
work against an ordinary Jira workflow without a mapping file.
|
|
1572
|
+
"""
|
|
1573
|
+
wanted = status.strip().lower()
|
|
1574
|
+
candidates = [wanted, wanted.replace("_", " "), wanted.replace("-", " ")]
|
|
1575
|
+
candidates += list(JIRA_STATUS_ALIASES.get(wanted, ()))
|
|
1576
|
+
seen: list[str] = []
|
|
1577
|
+
for candidate in candidates:
|
|
1578
|
+
if candidate in seen:
|
|
1579
|
+
continue
|
|
1580
|
+
seen.append(candidate)
|
|
1581
|
+
for entry in available:
|
|
1582
|
+
names = {
|
|
1583
|
+
str(entry.get("name") or "").strip().lower(),
|
|
1584
|
+
str((entry.get("to") or {}).get("name") or "").strip().lower(),
|
|
1585
|
+
}
|
|
1586
|
+
if candidate in names and entry.get("id") is not None:
|
|
1587
|
+
return entry
|
|
1588
|
+
return None
|
|
1589
|
+
|
|
1590
|
+
def _link_type_names(self) -> list[dict[str, Any]]:
|
|
1591
|
+
if self._link_type_cache is None:
|
|
1592
|
+
self._link_type_cache = (
|
|
1593
|
+
self._request("GET", "/issueLinkType", retry_on_429=True).get("issueLinkTypes")
|
|
1594
|
+
or []
|
|
1595
|
+
)
|
|
1596
|
+
return self._link_type_cache
|
|
1597
|
+
|
|
1598
|
+
def link(self, key: str, to: str, link_type: str = "relates") -> Issue:
|
|
1599
|
+
self._check_link_type(link_type)
|
|
1600
|
+
if key == to:
|
|
1601
|
+
raise TrackerError("an issue cannot be linked to itself")
|
|
1602
|
+
self._require(key)
|
|
1603
|
+
self._require(to) # refuse dangling links: a link to nothing is worse than none
|
|
1604
|
+
|
|
1605
|
+
names, direction = JIRA_LINK_TYPES[link_type]
|
|
1606
|
+
installed = {str(entry.get("name") or ""): entry for entry in self._link_type_names()}
|
|
1607
|
+
chosen = next((name for name in names if name in installed), None)
|
|
1608
|
+
if chosen is None:
|
|
1609
|
+
# Relates exists in every stock Jira; if even that is gone, say what
|
|
1610
|
+
# this instance does have rather than sending an unusable name.
|
|
1611
|
+
chosen = next((name for name in installed if name.lower() == "relates"), None)
|
|
1612
|
+
if chosen is None:
|
|
1613
|
+
raise TrackerError(
|
|
1614
|
+
f"this Jira has no link type usable for '{link_type}'; it offers: "
|
|
1615
|
+
f"{', '.join(sorted(installed)) or 'none'}"
|
|
1616
|
+
)
|
|
1617
|
+
|
|
1618
|
+
# Jira reads a link as "<outwardIssue> <outward phrase> <inwardIssue>",
|
|
1619
|
+
# so `key` sits at whichever end the house link type names. Getting this
|
|
1620
|
+
# backwards silently inverts the meaning of every link filed.
|
|
1621
|
+
near, far = ("outwardIssue", "inwardIssue") if direction == "outward" else (
|
|
1622
|
+
"inwardIssue", "outwardIssue"
|
|
1623
|
+
)
|
|
1624
|
+
self._request(
|
|
1625
|
+
"POST", "/issueLink",
|
|
1626
|
+
body={"type": {"name": chosen}, near: {"key": key}, far: {"key": to}},
|
|
1627
|
+
retry_on_429=False,
|
|
1628
|
+
)
|
|
1629
|
+
return self._require(key)
|
|
1630
|
+
|
|
1631
|
+
def search(
|
|
1632
|
+
self,
|
|
1633
|
+
*,
|
|
1634
|
+
text: str | None = None,
|
|
1635
|
+
project: str | None = None,
|
|
1636
|
+
status: str | None = None,
|
|
1637
|
+
label: str | None = None,
|
|
1638
|
+
envelope_id: str | None = None,
|
|
1639
|
+
fingerprint: str | None = None,
|
|
1640
|
+
limit: int = 20,
|
|
1641
|
+
) -> list[Issue]:
|
|
1642
|
+
"""Search by JQL, scoped to the configured projects by default.
|
|
1643
|
+
|
|
1644
|
+
Unscoped, this would sweep every project the bot can see — slow, and it
|
|
1645
|
+
drags unrelated tickets into an agent's context where they read as
|
|
1646
|
+
prior art. The scope is the projects this system files into.
|
|
1647
|
+
"""
|
|
1648
|
+
clauses: list[str] = []
|
|
1649
|
+
if project:
|
|
1650
|
+
clauses.append(f"project = {self._jql_value(project)}")
|
|
1651
|
+
else:
|
|
1652
|
+
scope = [self._project] + ([self._security_project] if self._security_project else [])
|
|
1653
|
+
clauses.append(
|
|
1654
|
+
"project in (" + ", ".join(self._jql_value(p) for p in scope) + ")"
|
|
1655
|
+
)
|
|
1656
|
+
if status:
|
|
1657
|
+
clauses.append(f"status = {self._jql_value(self._jira_status_name(status, project))}")
|
|
1658
|
+
for value, prefix in (
|
|
1659
|
+
(label, ""),
|
|
1660
|
+
(envelope_id, ENVELOPE_LABEL_PREFIX),
|
|
1661
|
+
(_fingerprint_label_value(fingerprint), FINGERPRINT_LABEL_PREFIX),
|
|
1662
|
+
):
|
|
1663
|
+
if value:
|
|
1664
|
+
clauses.append(f"labels = {self._jql_value(f'{prefix}{value}')}")
|
|
1665
|
+
if text and text.strip():
|
|
1666
|
+
clauses.append(f"text ~ {self._jql_value(text.strip())}")
|
|
1667
|
+
|
|
1668
|
+
jql = " AND ".join(clauses) + " ORDER BY created DESC"
|
|
1669
|
+
# POST /search/jql, not GET /search: Atlassian deprecated the latter,
|
|
1670
|
+
# which is exactly the trap §5.1 warns about.
|
|
1671
|
+
data = self._request(
|
|
1672
|
+
"POST", "/search/jql",
|
|
1673
|
+
body={
|
|
1674
|
+
"jql": jql,
|
|
1675
|
+
"maxResults": max(1, min(int(limit), 100)),
|
|
1676
|
+
"fields": self._FIELDS.split(","),
|
|
1677
|
+
},
|
|
1678
|
+
retry_on_429=True,
|
|
1679
|
+
)
|
|
1680
|
+
return [self._issue_from_jira(entry) for entry in data.get("issues") or []]
|
|
1681
|
+
|
|
1682
|
+
@staticmethod
|
|
1683
|
+
def _jql_value(value: str) -> str:
|
|
1684
|
+
"""Quote a JQL literal. Unescaped quotes are an injection, not a typo."""
|
|
1685
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
1686
|
+
return f'"{escaped}"'
|
|
1687
|
+
|
|
1688
|
+
|
|
1689
|
+
class JiraDataCenterTracker(TrackerAdapter):
|
|
1690
|
+
"""Not implemented: Jira Server / Data Center is a different integration.
|
|
1691
|
+
|
|
1692
|
+
§5.1 flags this as a decision to take before wiring anything. Data Center
|
|
1693
|
+
is not Cloud with a different hostname: it authenticates with a Personal
|
|
1694
|
+
Access Token (`Authorization: Bearer <pat>`) rather than email + API token,
|
|
1695
|
+
it serves REST API v2 rather than v3, and v2 takes a plain-text or wiki
|
|
1696
|
+
description where v3 requires ADF — so `JiraTracker`'s converter is not
|
|
1697
|
+
just unnecessary there, it is wrong. Atlassian's official hosted MCP server
|
|
1698
|
+
is Cloud-only; the self-hosted route is the community
|
|
1699
|
+
`sooperset/mcp-atlassian` server.
|
|
1700
|
+
|
|
1701
|
+
Implementing this by subclassing `JiraTracker` would be a mistake: it would
|
|
1702
|
+
inherit the ADF conversion and the v3 paths, and fail in ways that look
|
|
1703
|
+
like Jira being broken rather than the adapter being wrong.
|
|
1704
|
+
"""
|
|
1705
|
+
|
|
1706
|
+
REQUIRED_ENV = ("JIRA_BASE_URL", "JIRA_PERSONAL_ACCESS_TOKEN", "JIRA_PROJECT_KEY")
|
|
1707
|
+
|
|
1708
|
+
def __init__(self, *_args: Any, **_kwargs: Any):
|
|
1709
|
+
raise NotImplementedError(
|
|
1710
|
+
"JiraDataCenterTracker is a stub. Jira Server/Data Center uses PAT auth "
|
|
1711
|
+
"(Authorization: Bearer, from "
|
|
1712
|
+
f"{', '.join(self.REQUIRED_ENV)}) against REST API v2, whose description "
|
|
1713
|
+
"field is plain text or wiki markup rather than ADF — so this is a separate "
|
|
1714
|
+
"adapter, not a flag on JiraTracker (§5.1). Use tracker: jira for Jira Cloud, "
|
|
1715
|
+
"or tracker: local."
|
|
1716
|
+
)
|
|
1717
|
+
|
|
1718
|
+
def create_issue(self, **_kwargs: Any) -> Issue: # pragma: no cover - unreachable stub
|
|
1719
|
+
raise NotImplementedError
|
|
1720
|
+
|
|
1721
|
+
def transition(self, key: str, status: str, **_kwargs: Any) -> Issue: # pragma: no cover
|
|
1722
|
+
raise NotImplementedError
|
|
1723
|
+
|
|
1724
|
+
def link(self, key: str, to: str, link_type: str = "relates") -> Issue: # pragma: no cover
|
|
1725
|
+
raise NotImplementedError
|
|
1726
|
+
|
|
1727
|
+
def search(self, **_kwargs: Any) -> list[Issue]: # pragma: no cover
|
|
1728
|
+
raise NotImplementedError
|
|
1729
|
+
|
|
1730
|
+
def get(self, key: str) -> Issue | None: # pragma: no cover
|
|
1731
|
+
raise NotImplementedError
|
|
1732
|
+
|
|
1733
|
+
|
|
1734
|
+
def build_tracker(backend: str, root: Path | str) -> TrackerAdapter:
|
|
1735
|
+
"""Pick the adapter named by `config.tracker`.
|
|
1736
|
+
|
|
1737
|
+
`jira` builds a live Jira Cloud client, which validates its environment
|
|
1738
|
+
here and now: a misconfigured tracker must fail before an agent starts
|
|
1739
|
+
finding things, not after.
|
|
1740
|
+
"""
|
|
1741
|
+
if backend == "local":
|
|
1742
|
+
return LocalTracker(root)
|
|
1743
|
+
if backend == "jira":
|
|
1744
|
+
return JiraTracker()
|
|
1745
|
+
raise ValueError(f"unknown tracker backend '{backend}'; expected 'local' or 'jira'")
|
|
1746
|
+
|
|
1747
|
+
|
|
1748
|
+
def issue_summary(issue: Issue) -> dict[str, object]:
|
|
1749
|
+
"""The compact view returned to an agent; full bodies would bloat context."""
|
|
1750
|
+
return {
|
|
1751
|
+
"key": issue.key,
|
|
1752
|
+
"project": issue.project,
|
|
1753
|
+
"title": issue.title,
|
|
1754
|
+
"status": issue.status,
|
|
1755
|
+
"severity": issue.severity,
|
|
1756
|
+
"labels": issue.labels,
|
|
1757
|
+
"envelope_id": issue.envelope_id,
|
|
1758
|
+
"links": [link.model_dump() for link in issue.links],
|
|
1759
|
+
"updated_at": issue.updated_at.isoformat(),
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
|
|
1763
|
+
__all__ = [
|
|
1764
|
+
"DEFAULT_PROJECT",
|
|
1765
|
+
"SECURITY_PROJECT",
|
|
1766
|
+
"STATUSES",
|
|
1767
|
+
"LINK_TYPES",
|
|
1768
|
+
"Issue",
|
|
1769
|
+
"IssueLink",
|
|
1770
|
+
"Transition",
|
|
1771
|
+
"TrackerAdapter",
|
|
1772
|
+
"TrackerError",
|
|
1773
|
+
"TrackerConfigError",
|
|
1774
|
+
"UnknownIssue",
|
|
1775
|
+
"LocalTracker",
|
|
1776
|
+
"JiraTracker",
|
|
1777
|
+
"JiraDataCenterTracker",
|
|
1778
|
+
"JIRA_API_TOKEN_URL",
|
|
1779
|
+
"markdown_to_adf",
|
|
1780
|
+
"adf_to_text",
|
|
1781
|
+
"build_tracker",
|
|
1782
|
+
"issue_summary",
|
|
1783
|
+
]
|