qaas-python 0.1.0__py3-none-any.whl

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