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.
Files changed (96) hide show
  1. qaas/adapters/__init__.py +19 -0
  2. qaas/adapters/tracker.py +1783 -0
  3. qaas/adapters/vcs.py +555 -0
  4. qaas/cli.py +1757 -0
  5. qaas/config.py +409 -0
  6. qaas/defaults/config/agents/api.yaml +18 -0
  7. qaas/defaults/config/agents/architect.yaml +21 -0
  8. qaas/defaults/config/agents/auditor.yaml +19 -0
  9. qaas/defaults/config/agents/browser.yaml +15 -0
  10. qaas/defaults/config/agents/dba.yaml +20 -0
  11. qaas/defaults/config/agents/fixer.yaml +55 -0
  12. qaas/defaults/config/agents/guide.yaml +23 -0
  13. qaas/defaults/config/agents/load.yaml +26 -0
  14. qaas/defaults/config/agents/mapper.yaml +19 -0
  15. qaas/defaults/config/agents/reporter.yaml +19 -0
  16. qaas/defaults/config/agents/reproducer.yaml +21 -0
  17. qaas/defaults/config/agents/reviewer.yaml +18 -0
  18. qaas/defaults/config/agents/socket.yaml +23 -0
  19. qaas/defaults/config/agents/triage.yaml +20 -0
  20. qaas/defaults/config/agents/verifier.yaml +20 -0
  21. qaas/defaults/config/system.yaml +64 -0
  22. qaas/discover.py +242 -0
  23. qaas/envelope.py +318 -0
  24. qaas/envfile.py +100 -0
  25. qaas/guardrails.py +589 -0
  26. qaas/mcp/__init__.py +0 -0
  27. qaas/mcp/context.py +78 -0
  28. qaas/mcp/contract_diff.py +1011 -0
  29. qaas/mcp/defect_memory.py +495 -0
  30. qaas/mcp/env_control.py +925 -0
  31. qaas/mcp/envelope_server.py +463 -0
  32. qaas/mcp/test_runner.py +842 -0
  33. qaas/mcp/tracker.py +420 -0
  34. qaas/mcp/vcs.py +501 -0
  35. qaas/paths.py +317 -0
  36. qaas/plugin/.claude-plugin/plugin.json +9 -0
  37. qaas/plugin/skills/a11y-audit/SKILL.md +34 -0
  38. qaas/plugin/skills/adversarial-review/SKILL.md +120 -0
  39. qaas/plugin/skills/api-surface-extraction/SKILL.md +38 -0
  40. qaas/plugin/skills/authz-matrix-check/SKILL.md +46 -0
  41. qaas/plugin/skills/console-error-triage/SKILL.md +39 -0
  42. qaas/plugin/skills/contract-test-generation/SKILL.md +36 -0
  43. qaas/plugin/skills/dedupe-strategy/SKILL.md +39 -0
  44. qaas/plugin/skills/environment-pinning/SKILL.md +35 -0
  45. qaas/plugin/skills/error-taxonomy/SKILL.md +42 -0
  46. qaas/plugin/skills/exploratory-ui-walk/SKILL.md +46 -0
  47. qaas/plugin/skills/failing-test-authoring/SKILL.md +47 -0
  48. qaas/plugin/skills/flake-detection/SKILL.md +39 -0
  49. qaas/plugin/skills/form-state-probe/SKILL.md +36 -0
  50. qaas/plugin/skills/minimal-diff-discipline/SKILL.md +70 -0
  51. qaas/plugin/skills/openapi-diff/SKILL.md +45 -0
  52. qaas/plugin/skills/ownership-resolution/SKILL.md +31 -0
  53. qaas/plugin/skills/product-task-graph/SKILL.md +35 -0
  54. qaas/plugin/skills/regression-risk-scoring/SKILL.md +59 -0
  55. qaas/plugin/skills/regression-suite-selection/SKILL.md +36 -0
  56. qaas/plugin/skills/repo-cartography/SKILL.md +38 -0
  57. qaas/plugin/skills/repro-minimisation/SKILL.md +41 -0
  58. qaas/plugin/skills/rollback-plan-authoring/SKILL.md +81 -0
  59. qaas/plugin/skills/root-cause-vs-symptom/SKILL.md +67 -0
  60. qaas/plugin/skills/routing-rules/SKILL.md +34 -0
  61. qaas/plugin/skills/severity-rubric/SKILL.md +42 -0
  62. qaas/plugin/skills/test-first-fix/SKILL.md +66 -0
  63. qaas/plugin/skills/test-quality-audit/SKILL.md +58 -0
  64. qaas/plugin/skills/ticket-writer/SKILL.md +40 -0
  65. qaas/plugin/skills/verdict-reporting/SKILL.md +35 -0
  66. qaas/plugin/skills/verification-protocol/SKILL.md +39 -0
  67. qaas/prompts/API.md +44 -0
  68. qaas/prompts/ARCHITECT.md +80 -0
  69. qaas/prompts/AUDITOR.md +62 -0
  70. qaas/prompts/BROWSER.md +46 -0
  71. qaas/prompts/DBA.md +59 -0
  72. qaas/prompts/FIXER.md +55 -0
  73. qaas/prompts/GUIDE.md +94 -0
  74. qaas/prompts/LOAD.md +109 -0
  75. qaas/prompts/MAPPER.md +46 -0
  76. qaas/prompts/REPORTER.md +61 -0
  77. qaas/prompts/REPRODUCER.md +43 -0
  78. qaas/prompts/REVIEWER.md +53 -0
  79. qaas/prompts/SOCKET.md +100 -0
  80. qaas/prompts/TRIAGE.md +45 -0
  81. qaas/prompts/VERIFIER.md +41 -0
  82. qaas/prompts/_shared.md +45 -0
  83. qaas/registry.py +496 -0
  84. qaas/router.py +581 -0
  85. qaas/runner.py +210 -0
  86. qaas/scorecard.py +448 -0
  87. qaas/sdk_compat.py +52 -0
  88. qaas/store.py +323 -0
  89. qaas/target.py +287 -0
  90. qaas/tasks.py +438 -0
  91. qaas/trace.py +342 -0
  92. qaas_python-0.0.1.dist-info/METADATA +429 -0
  93. qaas_python-0.0.1.dist-info/RECORD +96 -0
  94. qaas_python-0.0.1.dist-info/WHEEL +4 -0
  95. qaas_python-0.0.1.dist-info/entry_points.txt +2 -0
  96. qaas_python-0.0.1.dist-info/licenses/LICENSE +21 -0
@@ -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
+ ]