qaas-python 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qaas/adapters/__init__.py +19 -0
- qaas/adapters/tracker.py +1350 -0
- qaas/adapters/vcs.py +494 -0
- qaas/cli.py +1564 -0
- qaas/conductor.py +527 -0
- qaas/config.py +407 -0
- qaas/defaults/config/agents/arbiter.yaml +19 -0
- qaas/defaults/config/agents/cartographer.yaml +20 -0
- qaas/defaults/config/agents/clerk.yaml +21 -0
- qaas/defaults/config/agents/conduit.yaml +19 -0
- qaas/defaults/config/agents/forge.yaml +22 -0
- qaas/defaults/config/agents/mender.yaml +56 -0
- qaas/defaults/config/agents/proof.yaml +21 -0
- qaas/defaults/config/agents/surface.yaml +16 -0
- qaas/defaults/config/system.yaml +69 -0
- qaas/discover.py +227 -0
- qaas/envelope.py +290 -0
- qaas/guardrails.py +431 -0
- qaas/mcp/__init__.py +0 -0
- qaas/mcp/context.py +70 -0
- qaas/mcp/contract_diff.py +937 -0
- qaas/mcp/defect_memory.py +495 -0
- qaas/mcp/env_control.py +905 -0
- qaas/mcp/envelope_server.py +463 -0
- qaas/mcp/test_runner.py +773 -0
- qaas/mcp/tracker.py +412 -0
- qaas/mcp/vcs.py +506 -0
- qaas/paths.py +317 -0
- qaas/plugin/.claude-plugin/plugin.json +9 -0
- qaas/plugin/skills/a11y-audit/SKILL.md +34 -0
- qaas/plugin/skills/adversarial-review/SKILL.md +120 -0
- qaas/plugin/skills/api-surface-extraction/SKILL.md +38 -0
- qaas/plugin/skills/authz-matrix-check/SKILL.md +46 -0
- qaas/plugin/skills/console-error-triage/SKILL.md +39 -0
- qaas/plugin/skills/contract-test-generation/SKILL.md +36 -0
- qaas/plugin/skills/dedupe-strategy/SKILL.md +39 -0
- qaas/plugin/skills/environment-pinning/SKILL.md +35 -0
- qaas/plugin/skills/error-taxonomy/SKILL.md +42 -0
- qaas/plugin/skills/exploratory-ui-walk/SKILL.md +46 -0
- qaas/plugin/skills/failing-test-authoring/SKILL.md +47 -0
- qaas/plugin/skills/flake-detection/SKILL.md +39 -0
- qaas/plugin/skills/form-state-probe/SKILL.md +36 -0
- qaas/plugin/skills/minimal-diff-discipline/SKILL.md +70 -0
- qaas/plugin/skills/openapi-diff/SKILL.md +45 -0
- qaas/plugin/skills/ownership-resolution/SKILL.md +31 -0
- qaas/plugin/skills/product-task-graph/SKILL.md +35 -0
- qaas/plugin/skills/regression-risk-scoring/SKILL.md +59 -0
- qaas/plugin/skills/regression-suite-selection/SKILL.md +36 -0
- qaas/plugin/skills/repo-cartography/SKILL.md +38 -0
- qaas/plugin/skills/repro-minimisation/SKILL.md +41 -0
- qaas/plugin/skills/rollback-plan-authoring/SKILL.md +81 -0
- qaas/plugin/skills/root-cause-vs-symptom/SKILL.md +67 -0
- qaas/plugin/skills/routing-rules/SKILL.md +34 -0
- qaas/plugin/skills/severity-rubric/SKILL.md +42 -0
- qaas/plugin/skills/test-first-fix/SKILL.md +66 -0
- qaas/plugin/skills/test-quality-audit/SKILL.md +58 -0
- qaas/plugin/skills/ticket-writer/SKILL.md +40 -0
- qaas/plugin/skills/verdict-reporting/SKILL.md +35 -0
- qaas/plugin/skills/verification-protocol/SKILL.md +39 -0
- qaas/prompts/ARBITER.md +53 -0
- qaas/prompts/CARTOGRAPHER.md +46 -0
- qaas/prompts/CLERK.md +45 -0
- qaas/prompts/CONDUIT.md +44 -0
- qaas/prompts/FORGE.md +43 -0
- qaas/prompts/MENDER.md +55 -0
- qaas/prompts/PROOF.md +41 -0
- qaas/prompts/SURFACE.md +46 -0
- qaas/prompts/_shared.md +45 -0
- qaas/registry.py +465 -0
- qaas/runner.py +192 -0
- qaas/scorecard.py +425 -0
- qaas/sdk_compat.py +52 -0
- qaas/store.py +290 -0
- qaas/target.py +261 -0
- qaas/tasks.py +361 -0
- qaas/trace.py +270 -0
- qaas_python-0.1.0.dist-info/METADATA +388 -0
- qaas_python-0.1.0.dist-info/RECORD +81 -0
- qaas_python-0.1.0.dist-info/WHEEL +4 -0
- qaas_python-0.1.0.dist-info/entry_points.txt +2 -0
- qaas_python-0.1.0.dist-info/licenses/LICENSE +21 -0
qaas/mcp/tracker.py
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""The `tracker` MCP server — every ticket the system files passes through here.
|
|
2
|
+
|
|
3
|
+
The rules below are code, not prompt text, and that is deliberate. §8.1 says
|
|
4
|
+
only CLERK creates and only CLERK and PROOF transition; §4.12 caps tickets per
|
|
5
|
+
run and requires that hitting the cap escalates instead of filing; §10 lists
|
|
6
|
+
"security findings leak into public tickets" as a named failure mode. A prompt
|
|
7
|
+
can be argued with, misread, or dropped from a truncated context. A refusal
|
|
8
|
+
returned from the tool cannot.
|
|
9
|
+
|
|
10
|
+
Which backend actually stores the ticket is `config.tracker`'s business — see
|
|
11
|
+
`qaas.adapters.tracker`. Policy lives here so it holds identically for local
|
|
12
|
+
files and for real Jira.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from collections.abc import Mapping
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from claude_agent_sdk import create_sdk_mcp_server, tool
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
|
|
24
|
+
from qaas.adapters.tracker import (
|
|
25
|
+
DEFAULT_PROJECT,
|
|
26
|
+
LINK_TYPES,
|
|
27
|
+
SECURITY_PROJECT,
|
|
28
|
+
STATUSES,
|
|
29
|
+
TrackerError,
|
|
30
|
+
build_tracker,
|
|
31
|
+
issue_summary,
|
|
32
|
+
)
|
|
33
|
+
from qaas.envelope import DefectClass, DefectEnvelope
|
|
34
|
+
from qaas.mcp.context import ToolContext, err, ok
|
|
35
|
+
|
|
36
|
+
#: Set to 1 to rehearse every tracker write instead of performing it. This is
|
|
37
|
+
#: the safety rail for first contact with a real Jira: the policy above still
|
|
38
|
+
#: runs, the payload is still assembled, the ledger still records what would
|
|
39
|
+
#: have happened — and nothing is sent. Reads (`search`) stay live, because a
|
|
40
|
+
#: dedupe check that cannot see the real backlog rehearses the wrong run.
|
|
41
|
+
TRACKER_DRY_RUN_ENV = "QAAS_TRACKER_DRY_RUN"
|
|
42
|
+
|
|
43
|
+
#: Prefixed to every dry-run tool result. An agent that is told "filed CORVID-1"
|
|
44
|
+
#: will report a filed ticket, and a human will go looking for it; the notice
|
|
45
|
+
#: has to be the first thing in the result, not a footnote.
|
|
46
|
+
DRY_RUN_NOTICE = (
|
|
47
|
+
f"DRY RUN — NOTHING WAS FILED. {TRACKER_DRY_RUN_ENV}=1 is set, so the tracker rehearsed "
|
|
48
|
+
"this call and sent nothing to the backend. The key below is a placeholder and does not "
|
|
49
|
+
"exist. Do not report this as a filed ticket."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
#: Dry-run lines go to stderr so an operator watching a run sees them without
|
|
53
|
+
#: them landing in anything that parses stdout.
|
|
54
|
+
_console = Console(stderr=True)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def dry_run_enabled(env: Mapping[str, str] | None = None) -> bool:
|
|
58
|
+
"""Whether tracker writes are rehearsed rather than performed.
|
|
59
|
+
|
|
60
|
+
Read from the environment rather than config/ so that turning the rail off
|
|
61
|
+
is a deliberate act in the shell that starts the run, and so that a
|
|
62
|
+
committed config file can never quietly disable it.
|
|
63
|
+
"""
|
|
64
|
+
source: Mapping[str, str] = os.environ if env is None else env
|
|
65
|
+
return (source.get(TRACKER_DRY_RUN_ENV) or "").strip().lower() in {"1", "true", "yes", "on"}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def is_restricted(envelope: DefectEnvelope) -> bool:
|
|
69
|
+
"""Whether this finding may only be filed into the restricted project.
|
|
70
|
+
|
|
71
|
+
Two independent triggers, because either alone is enough to make a public
|
|
72
|
+
ticket a disclosure: the reporter flagged security impact, or the defect is
|
|
73
|
+
classified as a vulnerability.
|
|
74
|
+
"""
|
|
75
|
+
return envelope.impact.security_relevant or envelope.defect_class == DefectClass.VULNERABILITY
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
CREATE_SCHEMA: dict[str, Any] = {
|
|
79
|
+
"type": "object",
|
|
80
|
+
"required": ["title", "body"],
|
|
81
|
+
"properties": {
|
|
82
|
+
"title": {"type": "string", "description": "Names the defect, not the symptom."},
|
|
83
|
+
"body": {
|
|
84
|
+
"type": "string",
|
|
85
|
+
"description": "House format: repro steps, evidence links, impact, acceptance criteria.",
|
|
86
|
+
},
|
|
87
|
+
"envelope_id": {
|
|
88
|
+
"type": "string",
|
|
89
|
+
"description": "The envelope this ticket files. Supply it: routing and severity are read from it.",
|
|
90
|
+
},
|
|
91
|
+
"project": {
|
|
92
|
+
"type": "string",
|
|
93
|
+
"description": f"Defaults to {DEFAULT_PROJECT}. Security findings are forced to {SECURITY_PROJECT}.",
|
|
94
|
+
},
|
|
95
|
+
"labels": {"type": "array", "items": {"type": "string"}},
|
|
96
|
+
"severity": {"type": "string", "enum": ["blocker", "critical", "major", "minor", "trivial"]},
|
|
97
|
+
"security_relevant": {
|
|
98
|
+
"type": "boolean",
|
|
99
|
+
"description": "Force restricted routing when no envelope carries the flag.",
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def build_tools(ctx: ToolContext) -> list:
|
|
106
|
+
"""The tracker tools, bound to one agent's run context.
|
|
107
|
+
|
|
108
|
+
Split from `build` so tests can call the handlers directly without standing
|
|
109
|
+
up an MCP transport.
|
|
110
|
+
"""
|
|
111
|
+
# Built even in dry-run mode: constructing a JiraTracker is what validates
|
|
112
|
+
# the credentials, and a rehearsal against a configuration that could never
|
|
113
|
+
# have worked proves nothing.
|
|
114
|
+
tracker = build_tracker(ctx.config.tracker, ctx.store.root)
|
|
115
|
+
policy = ctx.agent.policy
|
|
116
|
+
dry_run = dry_run_enabled()
|
|
117
|
+
|
|
118
|
+
def deny(tool_name: str, reason: str) -> dict[str, Any]:
|
|
119
|
+
"""Refuse, and leave a trace. A denial nobody can see is not a guardrail."""
|
|
120
|
+
ctx.store.log("denial", agent=ctx.agent.name, tool=tool_name, reason=reason)
|
|
121
|
+
return err(reason)
|
|
122
|
+
|
|
123
|
+
def rehearse(tool_name: str, line: str, **detail: Any) -> None:
|
|
124
|
+
"""Record a write that was not performed, on the ledger and on stderr.
|
|
125
|
+
|
|
126
|
+
The ledger kind is `dry_run`, never `ticket`: anything counting filed
|
|
127
|
+
tickets must not count these, and a flag on a `ticket` entry is one
|
|
128
|
+
missed `if` away from being counted.
|
|
129
|
+
"""
|
|
130
|
+
ctx.store.log("dry_run", agent=ctx.agent.name, tool=tool_name, **detail)
|
|
131
|
+
_console.print(f"[yellow]dry run[/yellow] {ctx.agent.name}: {line} [dim](not sent)[/dim]")
|
|
132
|
+
|
|
133
|
+
@tool(
|
|
134
|
+
"create_issue",
|
|
135
|
+
"File one tracker issue. Dedupe first — a duplicate costs the team more than a miss. "
|
|
136
|
+
"Security findings are routed to the restricted project automatically.",
|
|
137
|
+
CREATE_SCHEMA,
|
|
138
|
+
)
|
|
139
|
+
async def create_issue(args: dict[str, Any]) -> dict[str, Any]:
|
|
140
|
+
if not policy.may_create_tickets:
|
|
141
|
+
return deny(
|
|
142
|
+
"create_issue",
|
|
143
|
+
f"{ctx.agent.name} may not create tickets (§8.1: CLERK only). "
|
|
144
|
+
"Emit your finding as an envelope; CLERK files it.",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
cap = policy.max_tickets_per_run
|
|
148
|
+
if ctx.count("tickets") >= cap:
|
|
149
|
+
ctx.store.log(
|
|
150
|
+
"escalation", agent=ctx.agent.name,
|
|
151
|
+
reason="ticket cap reached", cap=cap, tool="create_issue",
|
|
152
|
+
)
|
|
153
|
+
return deny(
|
|
154
|
+
"create_issue",
|
|
155
|
+
f"Ticket cap reached ({cap} for this run). Filing is disabled for the rest "
|
|
156
|
+
"of the run. Hitting the cap means something upstream is wrong, and forty "
|
|
157
|
+
"more tickets will not fix it: this is an escalation, not a filing problem. "
|
|
158
|
+
"Summarise what remains unfiled in your final message and stop.",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
envelope = None
|
|
162
|
+
if args.get("envelope_id"):
|
|
163
|
+
envelope = ctx.store.get_envelope(args["envelope_id"])
|
|
164
|
+
if envelope is None:
|
|
165
|
+
return err(f"No envelope '{args['envelope_id']}' in this run. Emit it first.")
|
|
166
|
+
|
|
167
|
+
restricted = bool(args.get("security_relevant")) or (envelope is not None and is_restricted(envelope))
|
|
168
|
+
requested = (args.get("project") or "").strip() or None
|
|
169
|
+
|
|
170
|
+
# The project *names* come from the adapter, because a real Jira's keys
|
|
171
|
+
# are set by the deployment (JIRA_PROJECT_KEY / JIRA_SECURITY_PROJECT_KEY)
|
|
172
|
+
# rather than by the house constants. The routing *rule* is here, once,
|
|
173
|
+
# so it holds identically for local files and for Jira.
|
|
174
|
+
restricted_project = tracker.security_project
|
|
175
|
+
|
|
176
|
+
if restricted:
|
|
177
|
+
if restricted_project is None:
|
|
178
|
+
return deny(
|
|
179
|
+
"create_issue",
|
|
180
|
+
"Refused: this is a security-relevant finding and this tracker has no "
|
|
181
|
+
"restricted project configured (set JIRA_SECURITY_PROJECT_KEY to a "
|
|
182
|
+
f"project with restricted visibility). Filing it into "
|
|
183
|
+
f"'{tracker.default_project}' would be a disclosure, and there is no "
|
|
184
|
+
"undo (§4.12, §10). Escalate this finding to a human through a private "
|
|
185
|
+
"channel instead — do not file it anywhere.",
|
|
186
|
+
)
|
|
187
|
+
if requested and requested != restricted_project:
|
|
188
|
+
return deny(
|
|
189
|
+
"create_issue",
|
|
190
|
+
f"Refused: this is a security-relevant finding and '{requested}' is not the "
|
|
191
|
+
f"restricted project. Security findings never go to a public project (§4.12, "
|
|
192
|
+
f"§10) — file it into {restricted_project}, or omit `project` and it is routed "
|
|
193
|
+
"there for you.",
|
|
194
|
+
)
|
|
195
|
+
project = restricted_project
|
|
196
|
+
else:
|
|
197
|
+
project = requested or tracker.default_project
|
|
198
|
+
|
|
199
|
+
severity = args.get("severity") or (envelope.severity.value if envelope else None)
|
|
200
|
+
labels = list(args.get("labels") or [])
|
|
201
|
+
if "agent-found" not in labels:
|
|
202
|
+
labels.append("agent-found")
|
|
203
|
+
if restricted and "security" not in labels:
|
|
204
|
+
labels.append("security")
|
|
205
|
+
|
|
206
|
+
if dry_run:
|
|
207
|
+
# The cap is still consumed: a rehearsal that ignores the rate limit
|
|
208
|
+
# is not a rehearsal of the run you are about to do.
|
|
209
|
+
n = ctx.bump("tickets")
|
|
210
|
+
key = f"{project}-{9000 + n}"
|
|
211
|
+
rehearse(
|
|
212
|
+
"create_issue",
|
|
213
|
+
f"would file '{args['title']}' in {project} as {key}",
|
|
214
|
+
action="create_issue", key=key, project=project, title=args["title"],
|
|
215
|
+
severity=severity, labels=sorted(labels), restricted=restricted,
|
|
216
|
+
envelope_id=envelope.id if envelope else None, count=n, cap=cap,
|
|
217
|
+
)
|
|
218
|
+
return ok(
|
|
219
|
+
f"{DRY_RUN_NOTICE} It would have filed '{args['title']}' into {project} "
|
|
220
|
+
f"(placeholder key {key}, severity {severity or 'unset'}, labels "
|
|
221
|
+
f"{', '.join(sorted(labels))}) — {n}/{cap} for this run.",
|
|
222
|
+
key=key, project=project, title=args["title"], severity=severity,
|
|
223
|
+
labels=sorted(labels), restricted=restricted,
|
|
224
|
+
envelope_id=envelope.id if envelope else None,
|
|
225
|
+
tickets_filed=n, tickets_cap=cap, dry_run=True, filed=False,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
issue = tracker.create_issue(
|
|
230
|
+
project=project,
|
|
231
|
+
title=args["title"],
|
|
232
|
+
body=args["body"],
|
|
233
|
+
labels=labels,
|
|
234
|
+
severity=severity,
|
|
235
|
+
envelope_id=envelope.id if envelope else None,
|
|
236
|
+
fingerprint=envelope.fingerprint() if envelope else None,
|
|
237
|
+
reporter=ctx.agent.name,
|
|
238
|
+
)
|
|
239
|
+
except TrackerError as exc:
|
|
240
|
+
return err(f"Tracker rejected the issue: {exc}")
|
|
241
|
+
|
|
242
|
+
# Stamp the key back onto the envelope. The two loops of this system meet
|
|
243
|
+
# at the ticket (§1) and this write is that junction: without it the
|
|
244
|
+
# remediation half can never find anything to work on, because it selects
|
|
245
|
+
# by `envelope.jira.key`. A whole full-loop run reached CLERK, filed ten
|
|
246
|
+
# tickets, and then skipped verification entirely for exactly this reason.
|
|
247
|
+
if envelope is not None:
|
|
248
|
+
ctx.store.put_envelope(
|
|
249
|
+
envelope.model_copy(
|
|
250
|
+
update={
|
|
251
|
+
"jira": envelope.jira.model_copy(
|
|
252
|
+
update={"key": issue.key, "project": project, "status": issue.status}
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
n = ctx.bump("tickets")
|
|
259
|
+
ctx.store.log(
|
|
260
|
+
"ticket", agent=ctx.agent.name, action="created", key=issue.key,
|
|
261
|
+
project=project, severity=severity, restricted=restricted,
|
|
262
|
+
envelope_id=issue.envelope_id, count=n, cap=cap,
|
|
263
|
+
)
|
|
264
|
+
routed = " Routed to the restricted project because it is security-relevant." if restricted else ""
|
|
265
|
+
return ok(
|
|
266
|
+
f"Filed {issue.key} in {project} ({n}/{cap} this run).{routed}",
|
|
267
|
+
**issue_summary(issue),
|
|
268
|
+
tickets_filed=n,
|
|
269
|
+
tickets_cap=cap,
|
|
270
|
+
restricted=restricted,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
@tool(
|
|
274
|
+
"transition",
|
|
275
|
+
"Move an issue to a new status. Say why in the comment — the history is the audit trail.",
|
|
276
|
+
{
|
|
277
|
+
"type": "object",
|
|
278
|
+
"required": ["key", "status"],
|
|
279
|
+
"properties": {
|
|
280
|
+
"key": {"type": "string", "description": f"e.g. {DEFAULT_PROJECT}-12"},
|
|
281
|
+
"status": {"type": "string", "enum": list(STATUSES)},
|
|
282
|
+
"comment": {"type": "string", "description": "Why. Verdicts without reasons are not reviewable."},
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
)
|
|
286
|
+
async def transition(args: dict[str, Any]) -> dict[str, Any]:
|
|
287
|
+
if not policy.may_transition_tickets:
|
|
288
|
+
return deny(
|
|
289
|
+
"transition",
|
|
290
|
+
f"{ctx.agent.name} may not transition tickets (§8.1: CLERK and PROOF only).",
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
if dry_run:
|
|
294
|
+
# The issue is not read back either: in a rehearsal nothing was ever
|
|
295
|
+
# filed, so the key the agent holds is a placeholder and a lookup
|
|
296
|
+
# would fail for a reason that has nothing to do with the workflow.
|
|
297
|
+
rehearse(
|
|
298
|
+
"transition",
|
|
299
|
+
f"would move {args['key']} to '{args['status']}'",
|
|
300
|
+
action="transition", key=args["key"], status=args["status"],
|
|
301
|
+
comment=args.get("comment", ""),
|
|
302
|
+
)
|
|
303
|
+
return ok(
|
|
304
|
+
f"{DRY_RUN_NOTICE} It would have moved {args['key']} to '{args['status']}'"
|
|
305
|
+
+ (f" with the comment: {args['comment']}" if args.get("comment") else "")
|
|
306
|
+
+ ". The ticket's real status is unchanged.",
|
|
307
|
+
key=args["key"], status=args["status"], dry_run=True, filed=False,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
issue = tracker.transition(
|
|
312
|
+
args["key"], args["status"], by=ctx.agent.name, comment=args.get("comment", "")
|
|
313
|
+
)
|
|
314
|
+
except TrackerError as exc:
|
|
315
|
+
return err(f"Transition refused: {exc}")
|
|
316
|
+
|
|
317
|
+
ctx.store.log(
|
|
318
|
+
"ticket", agent=ctx.agent.name, action="transitioned",
|
|
319
|
+
key=issue.key, status=issue.status, comment=args.get("comment", ""),
|
|
320
|
+
)
|
|
321
|
+
return ok(f"{issue.key} is now '{issue.status}'.", **issue_summary(issue))
|
|
322
|
+
|
|
323
|
+
@tool(
|
|
324
|
+
"link",
|
|
325
|
+
"Link two existing issues — 'duplicates' for a repeat, 'regression-of' when a resolved "
|
|
326
|
+
"defect has come back.",
|
|
327
|
+
{
|
|
328
|
+
"type": "object",
|
|
329
|
+
"required": ["key", "to"],
|
|
330
|
+
"properties": {
|
|
331
|
+
"key": {"type": "string"},
|
|
332
|
+
"to": {"type": "string"},
|
|
333
|
+
"type": {"type": "string", "enum": list(LINK_TYPES), "default": "relates"},
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
)
|
|
337
|
+
async def link(args: dict[str, Any]) -> dict[str, Any]:
|
|
338
|
+
if not (policy.may_create_tickets or policy.may_transition_tickets):
|
|
339
|
+
return deny(
|
|
340
|
+
"link",
|
|
341
|
+
f"{ctx.agent.name} has no tracker write access, and a link is a write (§8.1).",
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
link_type = args.get("type") or "relates"
|
|
345
|
+
if dry_run:
|
|
346
|
+
rehearse(
|
|
347
|
+
"link",
|
|
348
|
+
f"would link {args['key']} {link_type} {args['to']}",
|
|
349
|
+
action="link", key=args["key"], to=args["to"], type=link_type,
|
|
350
|
+
)
|
|
351
|
+
return ok(
|
|
352
|
+
f"{DRY_RUN_NOTICE} It would have linked {args['key']} {link_type} "
|
|
353
|
+
f"{args['to']}. No link exists.",
|
|
354
|
+
key=args["key"], to=args["to"], type=link_type, dry_run=True, filed=False,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
try:
|
|
358
|
+
issue = tracker.link(args["key"], args["to"], link_type)
|
|
359
|
+
except TrackerError as exc:
|
|
360
|
+
return err(f"Link refused: {exc}")
|
|
361
|
+
ctx.store.log(
|
|
362
|
+
"ticket", agent=ctx.agent.name, action="linked",
|
|
363
|
+
key=args["key"], to=args["to"], type=link_type,
|
|
364
|
+
)
|
|
365
|
+
return ok(f"{issue.key} {link_type} {args['to']}.", **issue_summary(issue))
|
|
366
|
+
|
|
367
|
+
@tool(
|
|
368
|
+
"search",
|
|
369
|
+
"Find existing issues before filing a new one, or to pick up work.",
|
|
370
|
+
{
|
|
371
|
+
"type": "object",
|
|
372
|
+
"properties": {
|
|
373
|
+
"text": {"type": "string", "description": "Substring of title or body."},
|
|
374
|
+
"project": {"type": "string"},
|
|
375
|
+
"status": {"type": "string", "enum": list(STATUSES)},
|
|
376
|
+
"label": {"type": "string"},
|
|
377
|
+
"envelope_id": {"type": "string"},
|
|
378
|
+
"fingerprint": {"type": "string"},
|
|
379
|
+
"limit": {"type": "integer", "minimum": 1, "maximum": 50},
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
)
|
|
383
|
+
async def search(args: dict[str, Any]) -> dict[str, Any]:
|
|
384
|
+
# Deliberately live even in dry-run mode: dedupe against an imaginary
|
|
385
|
+
# backlog would rehearse a run that files things the real one would not.
|
|
386
|
+
try:
|
|
387
|
+
issues = tracker.search(
|
|
388
|
+
text=args.get("text"),
|
|
389
|
+
project=args.get("project"),
|
|
390
|
+
status=args.get("status"),
|
|
391
|
+
label=args.get("label"),
|
|
392
|
+
envelope_id=args.get("envelope_id"),
|
|
393
|
+
fingerprint=args.get("fingerprint"),
|
|
394
|
+
limit=int(args.get("limit") or 20),
|
|
395
|
+
)
|
|
396
|
+
except TrackerError as exc:
|
|
397
|
+
return err(f"Search refused: {exc}")
|
|
398
|
+
if not issues:
|
|
399
|
+
return ok("No issues match.", issues=[], count=0)
|
|
400
|
+
lines = [f" {i.key} [{i.status}] {i.severity or '-'} {i.title}" for i in issues]
|
|
401
|
+
return ok(
|
|
402
|
+
f"{len(issues)} issue(s):\n" + "\n".join(lines),
|
|
403
|
+
issues=[issue_summary(i) for i in issues],
|
|
404
|
+
count=len(issues),
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
return [create_issue, transition, link, search]
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def build(ctx: ToolContext):
|
|
411
|
+
"""Construct the tracker MCP server bound to one agent's run context."""
|
|
412
|
+
return create_sdk_mcp_server(name="tracker", version="1.0.0", tools=build_tools(ctx))
|