qaas-python 0.3.1__py3-none-any.whl → 0.4.2__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/tracker.py +348 -1
- qaas/cli.py +183 -2
- qaas/discover.py +15 -0
- qaas/envfile.py +92 -0
- qaas/mcp/tracker.py +8 -0
- qaas/target.py +1 -0
- qaas/trace.py +74 -2
- {qaas_python-0.3.1.dist-info → qaas_python-0.4.2.dist-info}/METADATA +84 -37
- {qaas_python-0.3.1.dist-info → qaas_python-0.4.2.dist-info}/RECORD +12 -11
- {qaas_python-0.3.1.dist-info → qaas_python-0.4.2.dist-info}/WHEEL +0 -0
- {qaas_python-0.3.1.dist-info → qaas_python-0.4.2.dist-info}/entry_points.txt +0 -0
- {qaas_python-0.3.1.dist-info → qaas_python-0.4.2.dist-info}/licenses/LICENSE +0 -0
qaas/adapters/tracker.py
CHANGED
|
@@ -19,6 +19,7 @@ import urllib.parse
|
|
|
19
19
|
import urllib.request
|
|
20
20
|
from abc import ABC, abstractmethod
|
|
21
21
|
from collections.abc import Mapping
|
|
22
|
+
from dataclasses import dataclass, replace
|
|
22
23
|
from datetime import datetime, timezone
|
|
23
24
|
from pathlib import Path
|
|
24
25
|
from typing import Any
|
|
@@ -469,6 +470,13 @@ def adf_to_text(node: Any) -> str:
|
|
|
469
470
|
JIRA_API_TOKEN_URL = "https://id.atlassian.com/manage-profile/security/api-tokens"
|
|
470
471
|
|
|
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
|
+
|
|
472
480
|
JIRA_TIMEOUT_S = 30.0
|
|
473
481
|
#: Reads are retried on 429; see `JiraTracker._request` for why writes are not.
|
|
474
482
|
JIRA_READ_ATTEMPTS = 3
|
|
@@ -481,6 +489,12 @@ SEVERITY_LABEL_PREFIX = "severity-"
|
|
|
481
489
|
ENVELOPE_LABEL_PREFIX = "qaas-envelope-"
|
|
482
490
|
FINGERPRINT_LABEL_PREFIX = "qaas-fp-"
|
|
483
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
|
+
|
|
484
498
|
#: House status -> the Jira workflow names it plausibly means. Jira workflows
|
|
485
499
|
#: are per-project and unknowable from here, so this is a set of candidates to
|
|
486
500
|
#: try, never an assertion; a miss returns the real transition list (see
|
|
@@ -534,6 +548,55 @@ def _label_safe(value: str | None, prefix: str = "") -> str | None:
|
|
|
534
548
|
return candidate
|
|
535
549
|
|
|
536
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
|
+
@dataclass(frozen=True)
|
|
572
|
+
class BoardInfo:
|
|
573
|
+
"""What a per-repository board provisioning attempt produced.
|
|
574
|
+
|
|
575
|
+
`board_id` is None when Jira refused to create a board — which happens on
|
|
576
|
+
team-managed projects, where boards belong to the project and cannot be
|
|
577
|
+
made over an arbitrary filter. That is not a failure of the run: the filter
|
|
578
|
+
still exists, the tickets still carry the label, and `url` still points at
|
|
579
|
+
something a human can open. `note` says which of the two they got.
|
|
580
|
+
"""
|
|
581
|
+
|
|
582
|
+
slug: str
|
|
583
|
+
label: str
|
|
584
|
+
jql: str
|
|
585
|
+
filter_id: int | None = None
|
|
586
|
+
filter_name: str = ""
|
|
587
|
+
board_id: int | None = None
|
|
588
|
+
board_name: str = ""
|
|
589
|
+
#: A link that opens. The board when there is a usable one, the filter
|
|
590
|
+
#: otherwise — never a constructed guess, because a link that 404s is worse
|
|
591
|
+
#: than no link: it reads as "the tool is broken" rather than "your Jira
|
|
592
|
+
#: does not do that".
|
|
593
|
+
url: str = ""
|
|
594
|
+
filter_url: str = ""
|
|
595
|
+
created_filter: bool = False
|
|
596
|
+
created_board: bool = False
|
|
597
|
+
note: str | None = None
|
|
598
|
+
|
|
599
|
+
|
|
537
600
|
class JiraTracker(TrackerAdapter):
|
|
538
601
|
"""Jira Cloud, over REST API v3, with credentials from the environment.
|
|
539
602
|
|
|
@@ -603,6 +666,7 @@ class JiraTracker(TrackerAdapter):
|
|
|
603
666
|
# environment the tracker was configured in rather than per call.
|
|
604
667
|
self._opener = urllib.request.build_opener()
|
|
605
668
|
self._link_type_cache: list[dict[str, Any]] | None = None
|
|
669
|
+
self._account_id_cache: str | None = None
|
|
606
670
|
|
|
607
671
|
@classmethod
|
|
608
672
|
def _missing_env_message(cls, missing: list[str]) -> str:
|
|
@@ -652,6 +716,7 @@ class JiraTracker(TrackerAdapter):
|
|
|
652
716
|
body: dict[str, Any] | None = None,
|
|
653
717
|
params: dict[str, str] | None = None,
|
|
654
718
|
retry_on_429: bool = False,
|
|
719
|
+
api_base: str = JIRA_API_BASE,
|
|
655
720
|
) -> Any:
|
|
656
721
|
"""One Jira call. `retry_on_429` is only ever true for reads.
|
|
657
722
|
|
|
@@ -660,7 +725,7 @@ class JiraTracker(TrackerAdapter):
|
|
|
660
725
|
Jira has already created the issue, so the second attempt files it
|
|
661
726
|
twice. Reads are idempotent and honour `Retry-After`.
|
|
662
727
|
"""
|
|
663
|
-
url = f"{self.base_url}{
|
|
728
|
+
url = f"{self.base_url}{api_base}{path}"
|
|
664
729
|
if params:
|
|
665
730
|
url = f"{url}?{urllib.parse.urlencode(params)}"
|
|
666
731
|
payload = json.dumps(body).encode("utf-8") if body is not None else None
|
|
@@ -924,6 +989,288 @@ class JiraTracker(TrackerAdapter):
|
|
|
924
989
|
)
|
|
925
990
|
return mapped
|
|
926
991
|
|
|
992
|
+
# -- boards -----------------------------------------------------------
|
|
993
|
+
#
|
|
994
|
+
# A board per repository, without a project per repository. Creating a Jira
|
|
995
|
+
# *project* needs administrator rights that a bot account normally does not
|
|
996
|
+
# have, and a project per repository is unmanageable by the tenth repo.
|
|
997
|
+
# Creating a saved *filter* needs no special grant, and a board can be built
|
|
998
|
+
# over a filter — so every repository gets its own board inside one project,
|
|
999
|
+
# and the tickets are separated by a label rather than by a project key.
|
|
1000
|
+
|
|
1001
|
+
def find_filter(self, name: str) -> dict[str, Any] | None:
|
|
1002
|
+
"""A filter owned by this account with exactly this name, or None.
|
|
1003
|
+
|
|
1004
|
+
Matched on the account's own filters rather than on all visible ones:
|
|
1005
|
+
`filter/search` returns other people's filters too, and adopting a
|
|
1006
|
+
stranger's filter as the run's board would silently repoint it.
|
|
1007
|
+
"""
|
|
1008
|
+
params = {"filterName": name, "expand": "jql", "maxResults": "50"}
|
|
1009
|
+
account = self._account_id()
|
|
1010
|
+
if account:
|
|
1011
|
+
# Omitted rather than sent empty: Jira answers an empty accountId
|
|
1012
|
+
# with a 400, which would read as "the filter API is broken".
|
|
1013
|
+
params["accountId"] = account
|
|
1014
|
+
data = self._request("GET", "/filter/search", params=params, retry_on_429=True)
|
|
1015
|
+
for entry in data.get("values") or []:
|
|
1016
|
+
if str(entry.get("name") or "").strip() == name:
|
|
1017
|
+
return entry
|
|
1018
|
+
return None
|
|
1019
|
+
|
|
1020
|
+
def update_filter_jql(self, filter_id: int, *, name: str, jql: str) -> dict[str, Any]:
|
|
1021
|
+
"""Repoint an existing filter at new JQL.
|
|
1022
|
+
|
|
1023
|
+
A filter is found by name and reused, and the name does not encode the
|
|
1024
|
+
project. Repointing `JIRA_PROJECT_KEY` at a different project therefore
|
|
1025
|
+
left the filter still scoped to the old one: tickets filed correctly,
|
|
1026
|
+
into the new project, and did not appear on the view someone had been
|
|
1027
|
+
told to watch. That is the same "files fine, invisible" failure the
|
|
1028
|
+
`repo-` label exists to prevent, arriving by a different road.
|
|
1029
|
+
"""
|
|
1030
|
+
return self._request("PUT", f"/filter/{filter_id}", body={"name": name, "jql": jql})
|
|
1031
|
+
|
|
1032
|
+
def create_filter(self, *, name: str, jql: str, description: str = "") -> dict[str, Any]:
|
|
1033
|
+
"""A saved filter, shared with authenticated users.
|
|
1034
|
+
|
|
1035
|
+
The share permission is not optional decoration: Jira refuses to build
|
|
1036
|
+
a board over a private filter, and the refusal arrives as a 400 on the
|
|
1037
|
+
*board* call, two steps away from the cause.
|
|
1038
|
+
"""
|
|
1039
|
+
return self._request(
|
|
1040
|
+
"POST",
|
|
1041
|
+
"/filter",
|
|
1042
|
+
body={
|
|
1043
|
+
"name": name,
|
|
1044
|
+
"jql": jql,
|
|
1045
|
+
"description": description,
|
|
1046
|
+
"favourite": True,
|
|
1047
|
+
"sharePermissions": [{"type": "authenticated"}],
|
|
1048
|
+
},
|
|
1049
|
+
)
|
|
1050
|
+
|
|
1051
|
+
def is_team_managed(self, key: str) -> bool:
|
|
1052
|
+
"""Whether `key` is a team-managed (next-gen) project.
|
|
1053
|
+
|
|
1054
|
+
Team-managed projects own their boards. The Agile API will still accept
|
|
1055
|
+
`POST /board` over a filter and hand back an id — and the resulting
|
|
1056
|
+
board has no `location`, which means the Jira UI has no page for it:
|
|
1057
|
+
both `/jira/software/boards/<id>` and
|
|
1058
|
+
`/jira/software/c/projects/<KEY>/boards/<id>` answer 404. So the board
|
|
1059
|
+
exists, is unreachable, and the link handed to a human is broken. Ask
|
|
1060
|
+
first, and make only the filter.
|
|
1061
|
+
"""
|
|
1062
|
+
try:
|
|
1063
|
+
info = self.project_info(key)
|
|
1064
|
+
except TrackerError:
|
|
1065
|
+
return False # unknown: try, and fall back on the answer
|
|
1066
|
+
return str(info.get("style") or "").lower() == "next-gen" or bool(info.get("simplified"))
|
|
1067
|
+
|
|
1068
|
+
def find_board(self, name: str) -> dict[str, Any] | None:
|
|
1069
|
+
"""A board with exactly this name, or None. Agile API."""
|
|
1070
|
+
data = self._request(
|
|
1071
|
+
"GET",
|
|
1072
|
+
"/board",
|
|
1073
|
+
params={"name": name, "maxResults": "50"},
|
|
1074
|
+
retry_on_429=True,
|
|
1075
|
+
api_base=JIRA_AGILE_BASE,
|
|
1076
|
+
)
|
|
1077
|
+
for entry in data.get("values") or []:
|
|
1078
|
+
if str(entry.get("name") or "").strip() == name:
|
|
1079
|
+
return entry
|
|
1080
|
+
return None
|
|
1081
|
+
|
|
1082
|
+
def create_board(self, *, name: str, filter_id: int, board_type: str = "kanban") -> dict[str, Any]:
|
|
1083
|
+
return self._request(
|
|
1084
|
+
"POST",
|
|
1085
|
+
"/board",
|
|
1086
|
+
body={"name": name, "type": board_type, "filterId": filter_id},
|
|
1087
|
+
api_base=JIRA_AGILE_BASE,
|
|
1088
|
+
)
|
|
1089
|
+
|
|
1090
|
+
#: The one board URL that is never wrong. Jira redirects it to whichever
|
|
1091
|
+
#: canonical form this site actually uses — `/jira/software/c/projects/...`
|
|
1092
|
+
#: for a company-managed project, `/jira/software/projects/...` for a
|
|
1093
|
+
#: team-managed one, and neither for a board with no project location at
|
|
1094
|
+
#: all. Constructing the canonical form by hand produced a link that
|
|
1095
|
+
#: returned Jira's generic error page, because the guess omitted the `/c/`.
|
|
1096
|
+
BOARD_PATH = "/secure/RapidBoard.jspa?rapidView={id}"
|
|
1097
|
+
|
|
1098
|
+
def board_url(self, board_id: int) -> str:
|
|
1099
|
+
"""A link to the board. Resolved with Jira rather than assembled.
|
|
1100
|
+
|
|
1101
|
+
Falls back to the redirecting form, which works everywhere but reads
|
|
1102
|
+
like an internal URL — worth one HTTP call to avoid handing someone a
|
|
1103
|
+
link they will not recognise.
|
|
1104
|
+
"""
|
|
1105
|
+
redirecting = f"{self.base_url}{self.BOARD_PATH.format(id=board_id)}"
|
|
1106
|
+
return self._resolve_url(redirecting) or redirecting
|
|
1107
|
+
|
|
1108
|
+
def _resolve_url(self, url: str) -> str | None:
|
|
1109
|
+
"""Where a browser would land, following Jira's own redirect. None on failure.
|
|
1110
|
+
|
|
1111
|
+
Deliberately not `_request`: this asks for a UI page, not JSON, and its
|
|
1112
|
+
answer is the final URL rather than the body. A failure here is never
|
|
1113
|
+
fatal — the caller keeps the redirecting URL, which works.
|
|
1114
|
+
"""
|
|
1115
|
+
request = urllib.request.Request(
|
|
1116
|
+
url, method="GET", headers={**self._headers(), "Accept": "text/html"}
|
|
1117
|
+
)
|
|
1118
|
+
try:
|
|
1119
|
+
with self._opener.open(request, timeout=self.timeout) as response:
|
|
1120
|
+
resolved = response.geturl()
|
|
1121
|
+
except (urllib.error.URLError, TimeoutError, OSError):
|
|
1122
|
+
return None
|
|
1123
|
+
return resolved if resolved and resolved != url else None
|
|
1124
|
+
|
|
1125
|
+
def filter_url(self, filter_id: int) -> str:
|
|
1126
|
+
return f"{self.base_url}/issues/?filter={filter_id}"
|
|
1127
|
+
|
|
1128
|
+
def _account_id(self) -> str | None:
|
|
1129
|
+
"""This credential's Atlassian account id, fetched once.
|
|
1130
|
+
|
|
1131
|
+
Cached because `find_filter` is called on every run and `/myself` is
|
|
1132
|
+
the same answer every time.
|
|
1133
|
+
"""
|
|
1134
|
+
if self._account_id_cache is None:
|
|
1135
|
+
try:
|
|
1136
|
+
self._account_id_cache = str(self.whoami().get("accountId") or "")
|
|
1137
|
+
except TrackerError:
|
|
1138
|
+
self._account_id_cache = ""
|
|
1139
|
+
return self._account_id_cache or None
|
|
1140
|
+
|
|
1141
|
+
def ensure_repo_board(
|
|
1142
|
+
self,
|
|
1143
|
+
slug: str,
|
|
1144
|
+
*,
|
|
1145
|
+
display: str | None = None,
|
|
1146
|
+
project: str | None = None,
|
|
1147
|
+
) -> BoardInfo:
|
|
1148
|
+
"""Find or create the board for one repository. Idempotent.
|
|
1149
|
+
|
|
1150
|
+
Called at the top of every run, so it must be safe to call when
|
|
1151
|
+
everything already exists — the second run against a repository reuses
|
|
1152
|
+
the board rather than making `repo QA (2)`.
|
|
1153
|
+
|
|
1154
|
+
A board this could not create is reported, not raised. The run's job is
|
|
1155
|
+
to find defects and file them; a missing board makes the tickets harder
|
|
1156
|
+
to look at, and nothing else. Losing the findings over it would be the
|
|
1157
|
+
larger failure.
|
|
1158
|
+
"""
|
|
1159
|
+
label = repo_label(slug)
|
|
1160
|
+
if label is None:
|
|
1161
|
+
raise TrackerError(
|
|
1162
|
+
f"'{slug}' cannot become a Jira label, so no per-repository board can be "
|
|
1163
|
+
"built for it. Give the target a simpler name with `qaas init --name`."
|
|
1164
|
+
)
|
|
1165
|
+
key = project or self._project
|
|
1166
|
+
jql = f'project = "{key}" AND labels = "{label}" ORDER BY created DESC'
|
|
1167
|
+
name = f"{display or _label_slug(slug)} — QA (qaas)"
|
|
1168
|
+
|
|
1169
|
+
info = BoardInfo(slug=_label_slug(slug) or slug, label=label, jql=jql)
|
|
1170
|
+
|
|
1171
|
+
existing = self.find_filter(name)
|
|
1172
|
+
if existing is None:
|
|
1173
|
+
created = self.create_filter(
|
|
1174
|
+
name=name,
|
|
1175
|
+
jql=jql,
|
|
1176
|
+
description=(
|
|
1177
|
+
f"Defects filed automatically by qaas against {slug}. "
|
|
1178
|
+
f"Every ticket carries the label {label}."
|
|
1179
|
+
),
|
|
1180
|
+
)
|
|
1181
|
+
info = replace(info, filter_id=int(created["id"]), filter_name=name, created_filter=True)
|
|
1182
|
+
else:
|
|
1183
|
+
info = replace(info, filter_id=int(existing["id"]), filter_name=name)
|
|
1184
|
+
# A reused filter is only the right filter if it still asks the
|
|
1185
|
+
# right question. `expand=jql` on the search is what makes this
|
|
1186
|
+
# checkable without a second round trip.
|
|
1187
|
+
if str(existing.get("jql") or "").strip() != jql:
|
|
1188
|
+
self.update_filter_jql(info.filter_id, name=name, jql=jql)
|
|
1189
|
+
info = replace(info, created_filter=True)
|
|
1190
|
+
|
|
1191
|
+
filter_link = self.filter_url(info.filter_id)
|
|
1192
|
+
info = replace(info, url=filter_link, filter_url=filter_link)
|
|
1193
|
+
|
|
1194
|
+
if self.is_team_managed(key):
|
|
1195
|
+
# Not a failure and not worth attempting: the POST would succeed and
|
|
1196
|
+
# produce a board with no UI page. The filter is the deliverable
|
|
1197
|
+
# here, and it is a good one — named, starred, scoped to the label.
|
|
1198
|
+
return replace(
|
|
1199
|
+
info,
|
|
1200
|
+
note=(
|
|
1201
|
+
f"'{key}' is a team-managed project, which owns its own board and "
|
|
1202
|
+
"cannot have a second one built over a filter. The saved filter "
|
|
1203
|
+
f"'{name}' is the per-repository view instead: every ticket carries "
|
|
1204
|
+
f"{label}, and the link above opens exactly this repository's defects."
|
|
1205
|
+
),
|
|
1206
|
+
)
|
|
1207
|
+
|
|
1208
|
+
board = self.find_board(name)
|
|
1209
|
+
if board is not None:
|
|
1210
|
+
return self._with_board(info, int(board["id"]), name, created=False)
|
|
1211
|
+
|
|
1212
|
+
try:
|
|
1213
|
+
made = self.create_board(name=name, filter_id=int(info.filter_id))
|
|
1214
|
+
except TrackerError as exc:
|
|
1215
|
+
# An account without "Create shared objects" lands here. The filter
|
|
1216
|
+
# is still usable, which is why this returns rather than raises.
|
|
1217
|
+
return replace(
|
|
1218
|
+
info,
|
|
1219
|
+
note=(
|
|
1220
|
+
f"Jira would not create a board over the filter ({exc}). The filter "
|
|
1221
|
+
f"exists and every ticket carries {label}, so open the link above, or "
|
|
1222
|
+
"create a board from it by hand in Jira."
|
|
1223
|
+
),
|
|
1224
|
+
)
|
|
1225
|
+
return self._with_board(info, int(made["id"]), name, created=True)
|
|
1226
|
+
|
|
1227
|
+
def _with_board(self, info: BoardInfo, board_id: int, name: str, *, created: bool) -> BoardInfo:
|
|
1228
|
+
"""Attach a board to the result — but only if the UI can actually show it.
|
|
1229
|
+
|
|
1230
|
+
`is_team_managed` is the authoritative gate and it runs before any of
|
|
1231
|
+
this. The `location` check below is a second, weaker one, and it is
|
|
1232
|
+
applied **only to a board that already existed**: Jira populates
|
|
1233
|
+
`location` asynchronously, so a board read back immediately after
|
|
1234
|
+
creation reports `location: None` whatever its project. Gating a fresh
|
|
1235
|
+
board on that field rejected perfectly good boards in a company-managed
|
|
1236
|
+
project — a false negative, observed, not theorised.
|
|
1237
|
+
"""
|
|
1238
|
+
if not created and not self._board_is_reachable(board_id):
|
|
1239
|
+
return replace(
|
|
1240
|
+
info,
|
|
1241
|
+
board_id=board_id,
|
|
1242
|
+
board_name=name,
|
|
1243
|
+
note=(
|
|
1244
|
+
f"Jira created board {board_id} but gave it no project location, so it "
|
|
1245
|
+
"has no page in the Jira UI. The saved filter above is the working "
|
|
1246
|
+
"per-repository view."
|
|
1247
|
+
),
|
|
1248
|
+
)
|
|
1249
|
+
return replace(
|
|
1250
|
+
info,
|
|
1251
|
+
board_id=board_id,
|
|
1252
|
+
board_name=name,
|
|
1253
|
+
url=self.board_url(board_id),
|
|
1254
|
+
created_board=created,
|
|
1255
|
+
)
|
|
1256
|
+
|
|
1257
|
+
def _board_is_reachable(self, board_id: int) -> bool:
|
|
1258
|
+
"""Whether an existing board has a project location, and so a UI page.
|
|
1259
|
+
|
|
1260
|
+
Only meaningful for a board that has existed for a while; see
|
|
1261
|
+
`_with_board`. And note that a location is necessary, not sufficient —
|
|
1262
|
+
a team-managed project's API-made board eventually reports one and the
|
|
1263
|
+
UI still refuses to render it, which is why `is_team_managed` and not
|
|
1264
|
+
this is the real gate.
|
|
1265
|
+
"""
|
|
1266
|
+
try:
|
|
1267
|
+
board = self._request(
|
|
1268
|
+
"GET", f"/board/{board_id}", retry_on_429=True, api_base=JIRA_AGILE_BASE
|
|
1269
|
+
)
|
|
1270
|
+
except TrackerError:
|
|
1271
|
+
return False
|
|
1272
|
+
return bool(board.get("location"))
|
|
1273
|
+
|
|
927
1274
|
# -- operations -------------------------------------------------------
|
|
928
1275
|
|
|
929
1276
|
@staticmethod
|
qaas/cli.py
CHANGED
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
import re
|
|
7
7
|
import subprocess
|
|
8
|
+
import sys
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from typing import Any
|
|
10
11
|
|
|
@@ -281,6 +282,21 @@ def _provision_target(
|
|
|
281
282
|
app = typer.Typer(add_completion=False, help="Multi-agent QA & remediation system.")
|
|
282
283
|
console = Console()
|
|
283
284
|
|
|
285
|
+
|
|
286
|
+
@app.callback()
|
|
287
|
+
def _bootstrap() -> None:
|
|
288
|
+
"""Runs before every command. Loads credentials from `.env`, if there is one.
|
|
289
|
+
|
|
290
|
+
Credentials come from the environment and never from `config/`, which is
|
|
291
|
+
committed -- that rule stands. This only makes it liveable: four exports in
|
|
292
|
+
every new shell is how a real token ends up pasted into a config file. An
|
|
293
|
+
already-exported variable always wins, so nothing here can override what an
|
|
294
|
+
operator typed on the command line.
|
|
295
|
+
"""
|
|
296
|
+
from qaas.envfile import load_env_file
|
|
297
|
+
|
|
298
|
+
load_env_file()
|
|
299
|
+
|
|
284
300
|
#: None means "let the workspace decide" -- an explicit --config, then the
|
|
285
301
|
#: project, then the defaults that shipped in the wheel. A literal "config"
|
|
286
302
|
#: default meant every command outside this repo died on a missing directory.
|
|
@@ -922,6 +938,49 @@ def show(run_id: str, root: Path = Root) -> None:
|
|
|
922
938
|
console.print(f"\n[dim]{len(entries)} ledger entries — qaas trace {run_id}[/dim]")
|
|
923
939
|
|
|
924
940
|
|
|
941
|
+
def _follow(store, *, agent: str | None, kinds, as_json: bool, quiet: bool = False) -> None:
|
|
942
|
+
"""Stream a live run's ledger until it finishes or the operator stops.
|
|
943
|
+
|
|
944
|
+
Deliberately line-by-line rather than a redrawn table: a run lasts minutes,
|
|
945
|
+
the interesting lines are denials and verdicts, and they must survive being
|
|
946
|
+
scrolled past, piped, and pasted into a bug report. A `rich.Live` view that
|
|
947
|
+
repaints would lose all three.
|
|
948
|
+
"""
|
|
949
|
+
from qaas.store import LedgerKind
|
|
950
|
+
|
|
951
|
+
wanted = set(kinds) if kinds else None
|
|
952
|
+
name = agent.upper() if agent else None
|
|
953
|
+
console.print(f"[dim]following {store.run_id} — ctrl-c to stop[/dim]")
|
|
954
|
+
hidden = trace_mod.QUIET_KINDS if quiet else frozenset()
|
|
955
|
+
seen = 0
|
|
956
|
+
try:
|
|
957
|
+
for entry in trace_mod.tail(store):
|
|
958
|
+
if entry.kind in hidden:
|
|
959
|
+
continue
|
|
960
|
+
if wanted is not None and entry.kind not in wanted:
|
|
961
|
+
continue
|
|
962
|
+
if name is not None and (entry.agent or "").upper() != name:
|
|
963
|
+
continue
|
|
964
|
+
seen += 1
|
|
965
|
+
if as_json:
|
|
966
|
+
# Newline-delimited, flushed per line: `--follow --json` exists
|
|
967
|
+
# to be piped into something that reacts, and a buffered pipe
|
|
968
|
+
# that only speaks at the end is not a live feed.
|
|
969
|
+
typer.echo(json.dumps(entry.model_dump(mode="json")), nl=True)
|
|
970
|
+
sys.stdout.flush()
|
|
971
|
+
continue
|
|
972
|
+
style = KIND_STYLE.get(str(entry.kind), "white")
|
|
973
|
+
console.print(
|
|
974
|
+
f"[dim]{entry.at.strftime('%H:%M:%S')}[/dim] "
|
|
975
|
+
f"[cyan]{(entry.agent or '-'):<12}[/cyan] "
|
|
976
|
+
f"[{style}]{str(entry.kind):<16}[/] {trace_mod.describe(entry)}"
|
|
977
|
+
)
|
|
978
|
+
if entry.kind == LedgerKind.RUN_FINISHED:
|
|
979
|
+
console.print("[dim]run finished[/dim]")
|
|
980
|
+
except KeyboardInterrupt:
|
|
981
|
+
console.print(f"\n[dim]stopped following after {seen} entries[/dim]")
|
|
982
|
+
|
|
983
|
+
|
|
925
984
|
@app.command()
|
|
926
985
|
def trace(
|
|
927
986
|
run_id: str,
|
|
@@ -929,10 +988,12 @@ def trace(
|
|
|
929
988
|
agent: str | None = typer.Option(None, "--agent", "-a", help="Only this agent's entries."),
|
|
930
989
|
kind: list[str] = typer.Option(None, "--kind", "-k", help="Only these ledger kinds (repeatable)."),
|
|
931
990
|
as_json: bool = typer.Option(False, "--json", help="Emit the filtered entries as JSON."),
|
|
991
|
+
follow: bool = typer.Option(False, "--follow", "-f", help="Stream new entries as the run produces them."),
|
|
992
|
+
quiet: bool = typer.Option(False, "--quiet", "-q", help="Drop tool_call lines and show only what an agent decided."),
|
|
932
993
|
) -> None:
|
|
933
994
|
"""Print one run's ledger as a timeline: dispatches, tools, denials, verdicts, cost."""
|
|
934
995
|
store = RunStore(run_id, root)
|
|
935
|
-
if not store.ledger_path.exists():
|
|
996
|
+
if not store.ledger_path.exists() and not follow:
|
|
936
997
|
console.print(f"[red]no ledger for run {run_id}[/red] — try `qaas runs`")
|
|
937
998
|
raise typer.Exit(1)
|
|
938
999
|
|
|
@@ -942,7 +1003,11 @@ def trace(
|
|
|
942
1003
|
console.print(f"[red]{exc}[/red]")
|
|
943
1004
|
raise typer.Exit(2) from None
|
|
944
1005
|
|
|
945
|
-
|
|
1006
|
+
if follow:
|
|
1007
|
+
_follow(store, agent=agent, kinds=kinds, as_json=as_json, quiet=quiet)
|
|
1008
|
+
return
|
|
1009
|
+
|
|
1010
|
+
entries = trace_mod.select(trace_mod.read_ledger(store), agent=agent, kinds=kinds, quiet=quiet)
|
|
946
1011
|
|
|
947
1012
|
if as_json:
|
|
948
1013
|
# Plain stdout, not `console.print_json`: rich soft-wraps at the console
|
|
@@ -1097,6 +1162,10 @@ def run(
|
|
|
1097
1162
|
console.print(f" prompt: {d['prompt_chars']} chars")
|
|
1098
1163
|
return
|
|
1099
1164
|
|
|
1165
|
+
# Before the first agent, not after the first ticket: the board is what
|
|
1166
|
+
# someone watches a run *on*, and one that appears at the end is a report.
|
|
1167
|
+
board_info = _ensure_board(cfg)
|
|
1168
|
+
|
|
1100
1169
|
def on_event(kind: str, detail: dict) -> None:
|
|
1101
1170
|
if kind == "agent_started":
|
|
1102
1171
|
console.print(f"[dim]->[/dim] {detail.get('agent')}")
|
|
@@ -1113,6 +1182,9 @@ def run(
|
|
|
1113
1182
|
|
|
1114
1183
|
console.print()
|
|
1115
1184
|
console.print_json(data=report.summary())
|
|
1185
|
+
console.print(f"\n[dim]watch it back:[/dim] qaas trace {report.run_id}")
|
|
1186
|
+
if board_info is not None and board_info.url:
|
|
1187
|
+
console.print(f"[dim]board:[/dim] {board_info.url}")
|
|
1116
1188
|
if report.failed or report.stopped_early:
|
|
1117
1189
|
raise typer.Exit(1)
|
|
1118
1190
|
|
|
@@ -1501,6 +1573,115 @@ def _tracker_check_jira(dry_run_ticket: bool) -> tuple[list[str], list[str]]:
|
|
|
1501
1573
|
return problems, warnings
|
|
1502
1574
|
|
|
1503
1575
|
|
|
1576
|
+
def _ensure_board(cfg, *, create: bool = True):
|
|
1577
|
+
"""Find or create the Jira board for this run's target. Never fatal.
|
|
1578
|
+
|
|
1579
|
+
Called at the top of every Jira-backed run so that a person told to "watch
|
|
1580
|
+
the board" has one to watch before the first ticket lands, rather than
|
|
1581
|
+
after. Returns a `BoardInfo`, or None when there is nothing to do or Jira
|
|
1582
|
+
could not be reached.
|
|
1583
|
+
|
|
1584
|
+
Failures are printed and swallowed. A run that found nine defects and could
|
|
1585
|
+
not create a board has still done its job; a run that refused to start
|
|
1586
|
+
because of a board has thrown the findings away.
|
|
1587
|
+
"""
|
|
1588
|
+
from qaas.adapters.tracker import JiraTracker, TrackerError, repo_label
|
|
1589
|
+
from qaas.mcp.tracker import dry_run_enabled
|
|
1590
|
+
|
|
1591
|
+
if cfg.tracker != "jira" or not cfg.target:
|
|
1592
|
+
return None
|
|
1593
|
+
|
|
1594
|
+
# The rehearsal rail sends nothing. A board is a container rather than a
|
|
1595
|
+
# ticket, but "QAAS_TRACKER_DRY_RUN=1 wrote to my Jira" is exactly the
|
|
1596
|
+
# sentence that rail exists to make impossible.
|
|
1597
|
+
if create and dry_run_enabled():
|
|
1598
|
+
console.print("[dim]dry run: no board was created[/dim]")
|
|
1599
|
+
create = False
|
|
1600
|
+
|
|
1601
|
+
label = repo_label(cfg.target)
|
|
1602
|
+
if label is None:
|
|
1603
|
+
console.print(
|
|
1604
|
+
f"[yellow]note:[/yellow] target name '{cfg.target}' cannot be a Jira label, so "
|
|
1605
|
+
"tickets will not be grouped onto a per-repository board."
|
|
1606
|
+
)
|
|
1607
|
+
return None
|
|
1608
|
+
|
|
1609
|
+
try:
|
|
1610
|
+
tracker = JiraTracker()
|
|
1611
|
+
except TrackerError as exc:
|
|
1612
|
+
console.print(f"[yellow]no board:[/yellow] {exc}")
|
|
1613
|
+
return None
|
|
1614
|
+
|
|
1615
|
+
if not create:
|
|
1616
|
+
from qaas.adapters.tracker import BoardInfo
|
|
1617
|
+
|
|
1618
|
+
return BoardInfo(
|
|
1619
|
+
slug=cfg.target,
|
|
1620
|
+
label=label,
|
|
1621
|
+
jql=f'project = "{tracker.default_project}" AND labels = "{label}" ORDER BY created DESC',
|
|
1622
|
+
)
|
|
1623
|
+
|
|
1624
|
+
try:
|
|
1625
|
+
info = tracker.ensure_repo_board(cfg.target)
|
|
1626
|
+
except TrackerError as exc:
|
|
1627
|
+
console.print(f"[yellow]could not provision a board:[/yellow] {exc}")
|
|
1628
|
+
return None
|
|
1629
|
+
|
|
1630
|
+
what = "board" if info.board_id and info.url != info.filter_url else "filter"
|
|
1631
|
+
verb = "created" if (info.created_board or info.created_filter) else "reused"
|
|
1632
|
+
console.print(f"[bold]{what} {verb}[/bold] — {info.url}")
|
|
1633
|
+
console.print(f"[dim]every ticket from this run carries the label {info.label}[/dim]")
|
|
1634
|
+
if info.note:
|
|
1635
|
+
console.print(f"[dim]{info.note}[/dim]")
|
|
1636
|
+
return info
|
|
1637
|
+
|
|
1638
|
+
|
|
1639
|
+
@app.command()
|
|
1640
|
+
def board(
|
|
1641
|
+
config_dir: Path | None = ConfigDir,
|
|
1642
|
+
target: str = typer.Option(None, "--target", "-t", help="Target profile. Defaults to the configured one."),
|
|
1643
|
+
create: bool = typer.Option(True, "--create/--no-create", help="Create the filter and board if they are missing."),
|
|
1644
|
+
) -> None:
|
|
1645
|
+
"""Show — or create — the Jira board that collects this repository's defects.
|
|
1646
|
+
|
|
1647
|
+
One board per repository, inside one Jira project. The board is a saved
|
|
1648
|
+
filter over the label `repo-<target>`, which every ticket the system files
|
|
1649
|
+
carries. That is why it needs no administrator rights: creating a Jira
|
|
1650
|
+
*project* per repository does, creating a filter does not.
|
|
1651
|
+
"""
|
|
1652
|
+
cfg = load_config(config_dir, target=target)
|
|
1653
|
+
if target:
|
|
1654
|
+
cfg = cfg.model_copy(update={"target": target, "profile": _load_target(target, config_dir)})
|
|
1655
|
+
|
|
1656
|
+
if cfg.tracker != "jira":
|
|
1657
|
+
console.print(
|
|
1658
|
+
f"[yellow]tracker is '{cfg.tracker}', not 'jira'[/yellow] — boards are a Jira "
|
|
1659
|
+
"feature. Tickets are written to .qaas/tickets/ instead. Set QAAS_TRACKER=jira "
|
|
1660
|
+
"(and the JIRA_* variables) to file into Jira."
|
|
1661
|
+
)
|
|
1662
|
+
raise typer.Exit(1)
|
|
1663
|
+
if not cfg.target:
|
|
1664
|
+
console.print("[red]no target configured[/red] — run `qaas init <repo>` first.")
|
|
1665
|
+
raise typer.Exit(1)
|
|
1666
|
+
|
|
1667
|
+
info = _ensure_board(cfg, create=create)
|
|
1668
|
+
if info is None:
|
|
1669
|
+
raise typer.Exit(1)
|
|
1670
|
+
|
|
1671
|
+
table = Table(header_style="bold", box=None, pad_edge=False)
|
|
1672
|
+
table.add_column("field", style="dim")
|
|
1673
|
+
table.add_column("value", overflow="fold")
|
|
1674
|
+
table.add_row("target", cfg.target)
|
|
1675
|
+
table.add_row("label", info.label)
|
|
1676
|
+
table.add_row("jql", info.jql)
|
|
1677
|
+
table.add_row("filter", str(info.filter_id or "not created"))
|
|
1678
|
+
table.add_row("board", str(info.board_id or "not created"))
|
|
1679
|
+
table.add_row("url", info.url or "-")
|
|
1680
|
+
console.print(table)
|
|
1681
|
+
if info.note:
|
|
1682
|
+
console.print(f"\n[dim]{info.note}[/dim]")
|
|
1683
|
+
|
|
1684
|
+
|
|
1504
1685
|
@app.command("tracker-check")
|
|
1505
1686
|
def tracker_check(
|
|
1506
1687
|
config_dir: Path | None = ConfigDir,
|
qaas/discover.py
CHANGED
|
@@ -42,6 +42,12 @@ FRONTEND_HINTS = re.compile(r'"(react|vue|svelte|@angular/core|next|nuxt|solid-j
|
|
|
42
42
|
TEST_DIR_NAMES = {"tests", "test", "__tests__", "spec", "e2e", "integration_tests"}
|
|
43
43
|
MIGRATION_DIR_NAMES = {"migrations", "migrate", "alembic", "db/migrate", "prisma/migrations"}
|
|
44
44
|
|
|
45
|
+
#: `Layout.docs` existed and nothing ever filled it, so a documentation-heavy
|
|
46
|
+
#: repository profiled as having no documentation at all and KEYSTONE was told
|
|
47
|
+
#: to go and find it. Prose is a real surface: specs that contradict the code,
|
|
48
|
+
#: tickets that describe behaviour nobody built, standards nothing follows.
|
|
49
|
+
DOC_DIR_NAMES = {"docs", "doc", "documentation", "adr", "rfcs", "specs"}
|
|
50
|
+
|
|
45
51
|
|
|
46
52
|
@dataclass
|
|
47
53
|
class Discovery:
|
|
@@ -101,6 +107,7 @@ def inspect(root: Path) -> Discovery:
|
|
|
101
107
|
frontend: list[str] = []
|
|
102
108
|
tests: list[str] = []
|
|
103
109
|
migrations: list[str] = []
|
|
110
|
+
docs: list[str] = []
|
|
104
111
|
|
|
105
112
|
for directory, children in _walk(root, excludes):
|
|
106
113
|
names = {c.name for c in children}
|
|
@@ -128,6 +135,13 @@ def inspect(root: Path) -> Discovery:
|
|
|
128
135
|
tests.append(rel)
|
|
129
136
|
if directory.name in MIGRATION_DIR_NAMES and rel not in migrations:
|
|
130
137
|
migrations.append(rel)
|
|
138
|
+
# Only the top of a documentation tree: `docs` and `docs/specs` and
|
|
139
|
+
# `docs/tickets` are one surface, and listing all three says nothing
|
|
140
|
+
# the first does not.
|
|
141
|
+
if directory.name in DOC_DIR_NAMES and not any(
|
|
142
|
+
rel == d or rel.startswith(f"{d}/") for d in docs
|
|
143
|
+
):
|
|
144
|
+
docs.append(rel)
|
|
131
145
|
|
|
132
146
|
# A source directory with .tsx/.jsx in it is a frontend even without a
|
|
133
147
|
# package.json of its own — monorepos often hoist dependencies.
|
|
@@ -181,6 +195,7 @@ def inspect(root: Path) -> Discovery:
|
|
|
181
195
|
frontend=sorted(set(frontend))[:4],
|
|
182
196
|
tests=sorted(set(tests))[:4],
|
|
183
197
|
migrations=sorted(set(migrations))[:3],
|
|
198
|
+
docs=sorted(set(docs))[:4],
|
|
184
199
|
spec=spec,
|
|
185
200
|
ownership=ownership,
|
|
186
201
|
exclude=list(DEFAULT_EXCLUDES),
|
qaas/envfile.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Reading credentials out of a `.env` file, without a dependency.
|
|
2
|
+
|
|
3
|
+
Every credential this system needs is an environment variable, deliberately:
|
|
4
|
+
`config/` is committed, and a token in a committed file is a token that leaks.
|
|
5
|
+
That rule is right and it made the tool tedious to use — four exports in every
|
|
6
|
+
new shell, and a run that dies on the fourth because one was forgotten.
|
|
7
|
+
|
|
8
|
+
So: a `.env` next to the project, read once, at CLI start. Two rules keep it
|
|
9
|
+
from becoming a second configuration system:
|
|
10
|
+
|
|
11
|
+
* **The real environment always wins.** A value already exported is never
|
|
12
|
+
overwritten, so `JIRA_PROJECT_KEY=OTHER qaas run` still means what it says,
|
|
13
|
+
and a stale `.env` cannot silently redirect a run.
|
|
14
|
+
* **It is only ever read for credentials.** Nothing in `config/` is looked up
|
|
15
|
+
here. A `.env` that sets `QAAS_TRACKER` works because that is an environment
|
|
16
|
+
override that already existed, not because this file is config.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
#: Where to look, in order. The state directory first, because `.qaas/` is
|
|
25
|
+
#: already gitignored — a credential written there cannot be committed by
|
|
26
|
+
#: accident, which is not true of a `.env` at the root of someone's repository.
|
|
27
|
+
CANDIDATES = (".qaas/.env", ".env")
|
|
28
|
+
|
|
29
|
+
#: Overrides the search. A path names the file to read; an empty value turns
|
|
30
|
+
#: the whole mechanism off. Both are needed by real callers: CI passes real
|
|
31
|
+
#: environment variables and must not have a stray `.env` in a checkout
|
|
32
|
+
#: override them, and the test suite must be hermetic against whatever the
|
|
33
|
+
#: developer happens to have on disk.
|
|
34
|
+
ENV_FILE_VAR = "QAAS_ENV_FILE"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_env(text: str) -> dict[str, str]:
|
|
38
|
+
"""`KEY=value` lines to a dict. Comments, blanks and `export ` tolerated.
|
|
39
|
+
|
|
40
|
+
Quotes are stripped only when they wrap the whole value: a token that
|
|
41
|
+
genuinely contains a quote character is more likely than a caller who meant
|
|
42
|
+
to keep the wrapping ones.
|
|
43
|
+
"""
|
|
44
|
+
values: dict[str, str] = {}
|
|
45
|
+
for raw in text.splitlines():
|
|
46
|
+
line = raw.strip()
|
|
47
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
48
|
+
continue
|
|
49
|
+
if line.startswith("export "):
|
|
50
|
+
line = line[len("export ") :].lstrip()
|
|
51
|
+
key, _, value = line.partition("=")
|
|
52
|
+
key = key.strip()
|
|
53
|
+
if not key:
|
|
54
|
+
continue
|
|
55
|
+
value = value.strip()
|
|
56
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
57
|
+
value = value[1:-1]
|
|
58
|
+
values[key] = value
|
|
59
|
+
return values
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load_env_file(start: Path | str | None = None) -> tuple[Path | None, list[str]]:
|
|
63
|
+
"""Load the first `.env` found, without clobbering the real environment.
|
|
64
|
+
|
|
65
|
+
Returns `(path, names_set)` — the file that was used and the variables it
|
|
66
|
+
actually contributed. Names already present in the environment are reported
|
|
67
|
+
as not set by the file, because that is the fact an operator debugging a
|
|
68
|
+
wrong project key needs.
|
|
69
|
+
"""
|
|
70
|
+
override = os.environ.get(ENV_FILE_VAR)
|
|
71
|
+
if override is not None and not override.strip():
|
|
72
|
+
return None, []
|
|
73
|
+
|
|
74
|
+
base = Path(start).expanduser() if start else Path.cwd()
|
|
75
|
+
candidates = (Path(override).expanduser(),) if override else tuple(base / n for n in CANDIDATES)
|
|
76
|
+
for path in candidates:
|
|
77
|
+
if not path.is_file():
|
|
78
|
+
continue
|
|
79
|
+
try:
|
|
80
|
+
values = parse_env(path.read_text(encoding="utf-8", errors="replace"))
|
|
81
|
+
except OSError:
|
|
82
|
+
# An unreadable .env is not worth killing a command over; the
|
|
83
|
+
# missing variable will produce a far clearer error downstream.
|
|
84
|
+
return None, []
|
|
85
|
+
applied = []
|
|
86
|
+
for key, value in values.items():
|
|
87
|
+
if os.environ.get(key):
|
|
88
|
+
continue
|
|
89
|
+
os.environ[key] = value
|
|
90
|
+
applied.append(key)
|
|
91
|
+
return path, applied
|
|
92
|
+
return None, []
|
qaas/mcp/tracker.py
CHANGED
|
@@ -29,6 +29,7 @@ from qaas.adapters.tracker import (
|
|
|
29
29
|
TrackerError,
|
|
30
30
|
build_tracker,
|
|
31
31
|
issue_summary,
|
|
32
|
+
repo_label,
|
|
32
33
|
)
|
|
33
34
|
from qaas.envelope import DefectClass, DefectEnvelope
|
|
34
35
|
from qaas.mcp.context import ToolContext, err, ok
|
|
@@ -202,6 +203,13 @@ def build_tools(ctx: ToolContext) -> list:
|
|
|
202
203
|
labels.append("agent-found")
|
|
203
204
|
if restricted and "security" not in labels:
|
|
204
205
|
labels.append("security")
|
|
206
|
+
# Which repository this defect is in, stamped by the system rather than
|
|
207
|
+
# asked of the agent. It is the only thing that puts the ticket on that
|
|
208
|
+
# repository's board, and an agent that forgot it would file a ticket
|
|
209
|
+
# that exists and is invisible to the person watching.
|
|
210
|
+
repo = repo_label(ctx.config.target)
|
|
211
|
+
if repo and repo not in labels:
|
|
212
|
+
labels.append(repo)
|
|
205
213
|
|
|
206
214
|
if dry_run:
|
|
207
215
|
# The cap is still consumed: a rehearsal that ignores the rate limit
|
qaas/target.py
CHANGED
qaas/trace.py
CHANGED
|
@@ -17,7 +17,7 @@ from __future__ import annotations
|
|
|
17
17
|
|
|
18
18
|
from dataclasses import dataclass, field
|
|
19
19
|
from datetime import datetime
|
|
20
|
-
from typing import Any, Iterable, Sequence
|
|
20
|
+
from typing import Any, Iterable, Iterator, Sequence
|
|
21
21
|
|
|
22
22
|
from qaas.store import LedgerEntry, LedgerKind, RunStore
|
|
23
23
|
|
|
@@ -66,6 +66,68 @@ def read_ledger(store: RunStore) -> list[LedgerEntry]:
|
|
|
66
66
|
return list(store.ledger())
|
|
67
67
|
|
|
68
68
|
|
|
69
|
+
#: How often `tail` looks for new ledger lines. A run writes a line every few
|
|
70
|
+
#: seconds at most, so polling faster buys nothing and spins a CPU; polling
|
|
71
|
+
#: slower makes `--follow` feel broken while an agent is thinking.
|
|
72
|
+
POLL_INTERVAL_S = 0.5
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def tail(
|
|
76
|
+
store: RunStore,
|
|
77
|
+
*,
|
|
78
|
+
from_start: bool = True,
|
|
79
|
+
poll: float = POLL_INTERVAL_S,
|
|
80
|
+
stop_on_finish: bool = True,
|
|
81
|
+
timeout_s: float | None = None,
|
|
82
|
+
) -> Iterator[LedgerEntry]:
|
|
83
|
+
"""Yield ledger entries as they are appended, for `qaas trace --follow`.
|
|
84
|
+
|
|
85
|
+
The ledger is append-only, which is what makes this safe: a reader can hold
|
|
86
|
+
a byte offset and never be wrong about it. Two rules follow from that and
|
|
87
|
+
both matter.
|
|
88
|
+
|
|
89
|
+
Only whole lines are parsed. A run can be mid-`write` when this reads, and
|
|
90
|
+
half a JSON object is not an entry -- the offset advances to the last
|
|
91
|
+
newline, so the remainder is picked up on the next poll rather than raising.
|
|
92
|
+
|
|
93
|
+
The file may not exist yet. Following a run that is still starting is the
|
|
94
|
+
normal case, not an error, so a missing ledger is waited for.
|
|
95
|
+
"""
|
|
96
|
+
import time
|
|
97
|
+
|
|
98
|
+
deadline = None if timeout_s is None else time.monotonic() + timeout_s
|
|
99
|
+
offset = 0
|
|
100
|
+
if not from_start and store.ledger_path.exists():
|
|
101
|
+
offset = store.ledger_path.stat().st_size
|
|
102
|
+
pending = ""
|
|
103
|
+
|
|
104
|
+
while True:
|
|
105
|
+
if store.ledger_path.exists():
|
|
106
|
+
with store.ledger_path.open("r", encoding="utf-8", errors="replace") as handle:
|
|
107
|
+
handle.seek(offset)
|
|
108
|
+
chunk = handle.read()
|
|
109
|
+
offset = handle.tell()
|
|
110
|
+
pending += chunk
|
|
111
|
+
lines = pending.split("\n")
|
|
112
|
+
pending = lines.pop() # the tail with no newline yet: not an entry
|
|
113
|
+
for line in lines:
|
|
114
|
+
if not line.strip():
|
|
115
|
+
continue
|
|
116
|
+
try:
|
|
117
|
+
entry = LedgerEntry.model_validate_json(line)
|
|
118
|
+
except Exception:
|
|
119
|
+
# A line this reader cannot parse is a line a future version
|
|
120
|
+
# wrote. Skipping it keeps the follow alive; killing the
|
|
121
|
+
# view over one unknown entry would not.
|
|
122
|
+
continue
|
|
123
|
+
yield entry
|
|
124
|
+
if stop_on_finish and entry.kind == LedgerKind.RUN_FINISHED:
|
|
125
|
+
return
|
|
126
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
127
|
+
return
|
|
128
|
+
time.sleep(poll)
|
|
129
|
+
|
|
130
|
+
|
|
69
131
|
def parse_kinds(names: Iterable[str]) -> list[LedgerKind]:
|
|
70
132
|
"""Validate `--kind` arguments against the enum.
|
|
71
133
|
|
|
@@ -83,18 +145,28 @@ def parse_kinds(names: Iterable[str]) -> list[LedgerKind]:
|
|
|
83
145
|
return kinds
|
|
84
146
|
|
|
85
147
|
|
|
148
|
+
#: What `--quiet` drops. A real run logs ~2400 `tool_call` lines against ~150 of
|
|
149
|
+
#: everything else, and every one of them is an agent reading a file. Dropping
|
|
150
|
+
#: them leaves what an agent *decided*: what it found, what it was refused, what
|
|
151
|
+
#: it filed, what it verdicted. Never dropped when asked for by `--kind`.
|
|
152
|
+
QUIET_KINDS = frozenset({LedgerKind.TOOL_CALL, LedgerKind.DRY_RUN})
|
|
153
|
+
|
|
154
|
+
|
|
86
155
|
def select(
|
|
87
156
|
entries: Sequence[LedgerEntry],
|
|
88
157
|
*,
|
|
89
158
|
agent: str | None = None,
|
|
90
159
|
kinds: Sequence[LedgerKind] | None = None,
|
|
160
|
+
quiet: bool = False,
|
|
91
161
|
) -> list[LedgerEntry]:
|
|
92
162
|
"""Filter in memory. Agent match is case-insensitive; agent names are shouted."""
|
|
93
163
|
wanted = set(kinds) if kinds else None
|
|
94
164
|
name = agent.upper() if agent else None
|
|
165
|
+
hidden = QUIET_KINDS if quiet else frozenset()
|
|
95
166
|
return [
|
|
96
167
|
e for e in entries
|
|
97
|
-
if
|
|
168
|
+
if e.kind not in hidden
|
|
169
|
+
and (wanted is None or e.kind in wanted)
|
|
98
170
|
and (name is None or (e.agent or "").upper() == name)
|
|
99
171
|
]
|
|
100
172
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: qaas-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: A multi-agent QA system: finds real defects, reproduces them, files tickets, fixes them, and proves the fix
|
|
5
5
|
Project-URL: Homepage, https://github.com/allaabdella2-us/qa-multi-agent-system
|
|
6
6
|
Project-URL: Repository, https://github.com/allaabdella2-us/qa-multi-agent-system
|
|
@@ -40,7 +40,7 @@ Description-Content-Type: text/markdown
|
|
|
40
40
|
[](https://pypi.org/project/qaas-python/)
|
|
41
41
|
[](LICENSE)
|
|
42
42
|
[](https://github.com/allaabdella2-us/qa-multi-agent-system/actions/workflows/ci.yml)
|
|
43
|
-
[](#-contributing)
|
|
44
44
|
[](https://docs.claude.com/en/api/agent-sdk/overview)
|
|
45
45
|
|
|
46
46
|
[Quickstart](#-quickstart-in-60-seconds) · [Your repo](#-point-it-at-your-repository) · [Jira](#-file-into-jira) · [Architecture](ARCHITECTURE.md)
|
|
@@ -75,7 +75,7 @@ Nothing crosses between the loops except a ticket — which is also the audit tr
|
|
|
75
75
|
|
|
76
76
|
| | |
|
|
77
77
|
|---|---|
|
|
78
|
-
| 🧠 **The orchestrator is code, not a prompt** | A model cannot enforce a budget it is spending. Phase ordering, concurrency, retries and the loop breakers live in `conductor.py`. That is also why **
|
|
78
|
+
| 🧠 **The orchestrator is code, not a prompt** | A model cannot enforce a budget it is spending. Phase ordering, concurrency, retries and the loop breakers live in `conductor.py`. That is also why **686 tests run offline, free, with no API key.** |
|
|
79
79
|
| 🧱 **Every agent is its own `query()`** | Not subagents of a shared parent. Each gets a real context boundary, an enforceable tool allowlist, and its own cost number. |
|
|
80
80
|
| 🔬 **Evidence or it did not happen** | `has_evidence()` and `is_fileable()` are methods on the envelope model, not requests in a prompt. An agent cannot talk its way past them. |
|
|
81
81
|
| 📊 **Measured, not asserted** | A deliberately buggy demo app ships with a golden ledger of **16 seeded defects + 4 planted non-defects**. `qaas score` reports recall *and* precision, so a prompt change has a number attached. |
|
|
@@ -189,6 +189,10 @@ QAAS_TRACKER=jira qaas run --mode nightly
|
|
|
189
189
|
maps your workflow statuses, and prints the exact JSON it *would* POST. **It
|
|
190
190
|
creates nothing.**
|
|
191
191
|
|
|
192
|
+
Tired of four exports in every new shell? Put them in a `.env` — `.qaas/.env` is
|
|
193
|
+
already gitignored — and every command reads it. **Anything you export wins over
|
|
194
|
+
the file**, so a stale `.env` can never redirect a run.
|
|
195
|
+
|
|
192
196
|
- 🔁 **Dedupe across runs** — tickets carry a `qaas-fp-<fingerprint>` label, so the next run recognises an already-filed defect and increments its occurrence count instead of filing again.
|
|
193
197
|
- 🔐 **Security findings are refused** unless `JIRA_SECURITY_PROJECT_KEY` is set. A vulnerability in a project the whole company can read is a disclosure with no undo.
|
|
194
198
|
|
|
@@ -197,6 +201,46 @@ creates nothing.**
|
|
|
197
201
|
> `.qaas/tickets/` so you can read what *would* be filed. Switch per shell with
|
|
198
202
|
> `QAAS_TRACKER=jira`.
|
|
199
203
|
|
|
204
|
+
### 📌 A view per repository, made for you
|
|
205
|
+
|
|
206
|
+
Point it at a new repository and it provisions that repository's own Jira view
|
|
207
|
+
before the first agent starts — so there is something to watch *during* the run,
|
|
208
|
+
not a report afterwards.
|
|
209
|
+
|
|
210
|
+
```console
|
|
211
|
+
$ QAAS_TRACKER=jira qaas run --repo https://github.com/acme/checkout.git --mode nightly
|
|
212
|
+
target: checkout (none)
|
|
213
|
+
filter created — https://you.atlassian.net/issues/?filter=10001
|
|
214
|
+
every ticket from this run carries the label repo-checkout
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Every ticket the system files carries `repo-<target>`, stamped in code rather
|
|
218
|
+
than asked of an agent. A **saved filter** over exactly that label is the
|
|
219
|
+
per-repository view, and on a company-managed project a **board** is built over
|
|
220
|
+
the filter too.
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
qaas board # find or create this target's view
|
|
224
|
+
qaas board --no-create # show the label and JQL, touch nothing
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
> [!NOTE]
|
|
228
|
+
> **Not a project per repository.** Creating a Jira project needs administrator
|
|
229
|
+
> rights a bot account rarely has, and a project per repository is unmanageable
|
|
230
|
+
> by the tenth one. A filter needs no special grant.
|
|
231
|
+
>
|
|
232
|
+
> **Not always a board, either.** Team-managed (next-gen) projects own their own
|
|
233
|
+
> board and cannot have a second one built over a filter — Jira's API will
|
|
234
|
+
> happily create one and give it no page in the UI. So the project's style is
|
|
235
|
+
> checked first, and on a team-managed project you get the filter alone. You are
|
|
236
|
+
> told which you got, and the link always opens. Point `JIRA_PROJECT_KEY` at a
|
|
237
|
+
> **company-managed** project and you get a real board per repository, with
|
|
238
|
+
> To Do / In Progress / Done.
|
|
239
|
+
|
|
240
|
+
Run it twice on the same repository and it **reuses** what is there. A run is
|
|
241
|
+
never failed over this: a run that found nine defects and could not make a view
|
|
242
|
+
has still done its job.
|
|
243
|
+
|
|
200
244
|
---
|
|
201
245
|
|
|
202
246
|
## 🔌 Bring your own MCP servers
|
|
@@ -281,12 +325,31 @@ $ qaas trace run-20260908T182034-c6ed26
|
|
|
281
325
|
```
|
|
282
326
|
|
|
283
327
|
```bash
|
|
328
|
+
qaas trace <run-id> --follow # watch a run as it happens
|
|
329
|
+
qaas trace <run-id> --quiet # decisions only, no file reads
|
|
284
330
|
qaas trace <run-id> --agent proof --kind verdict # filter
|
|
285
331
|
qaas trace <run-id> --json # export
|
|
286
332
|
qaas show <run-id> # mode, commit, tickets, escalations
|
|
287
333
|
qaas runs # everything that ever ran
|
|
288
334
|
```
|
|
289
335
|
|
|
336
|
+
**Watch a run live.** `--follow` tails the ledger of a run in progress — start it
|
|
337
|
+
in a second terminal the moment a run begins, or even before, and it waits.
|
|
338
|
+
|
|
339
|
+
```console
|
|
340
|
+
$ qaas trace run-20260909T163240-83b11c --follow --quiet
|
|
341
|
+
following run-20260909T163240-83b11c — ctrl-c to stop
|
|
342
|
+
16:32:40 - run_started mode=pr-check agents=[8] target_sha=da19f406
|
|
343
|
+
16:32:40 CARTOGRAPHER agent_started model=claude-sonnet-5
|
|
344
|
+
16:32:46 CARTOGRAPHER denial tool=Bash reason=Bash is not in CARTOGRAPHER's
|
|
345
|
+
tool allowlist (Read, Grep, Glob).
|
|
346
|
+
16:36:11 KEYSTONE envelope severity=major domain=architecture
|
|
347
|
+
16:41:03 CLERK ticket action=created key=QA-118 severity=major
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Drop `--quiet` to see every file the agents read, one line each. Combine with
|
|
351
|
+
`--agent` and `--kind` to watch one agent, or only the verdicts.
|
|
352
|
+
|
|
290
353
|
Runs are pinned to the **commit of the target** they examined, so a finding can
|
|
291
354
|
be replayed against the tree that produced it.
|
|
292
355
|
|
|
@@ -310,44 +373,28 @@ the refusal and adapts.
|
|
|
310
373
|
|
|
311
374
|
---
|
|
312
375
|
|
|
313
|
-
## 📈
|
|
376
|
+
## 📈 How you know it works
|
|
314
377
|
|
|
315
|
-
|
|
378
|
+
`qaas` ships with a **deliberately buggy demo app** and a golden ledger recording
|
|
379
|
+
every defect seeded into it — plus **planted non-defects**: correct-but-suspicious
|
|
380
|
+
code that a careless reader would report.
|
|
316
381
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
| 🎯 recall | **81%** — 13 of 16 | **69%** — 11 of 16 |
|
|
322
|
-
| 🔇 precision | **100%** — 0 FP | **92%** — 1 FP |
|
|
323
|
-
| 🏷️ severity agreement | **100%** | **100%** |
|
|
324
|
-
|
|
325
|
-
**Both numbers are shown on purpose.** A single figure would be the flattering
|
|
326
|
-
one, and it would not survive contact with a second run. These are stochastic
|
|
327
|
-
agents: the two runs did not find the same 11–13 defects — run B caught a
|
|
328
|
-
contrast failure run A missed, and missed three run A found. Expect variance of
|
|
329
|
-
this order.
|
|
382
|
+
```bash
|
|
383
|
+
cd target-app && docker compose up -d
|
|
384
|
+
qaas run --mode nightly && qaas score
|
|
385
|
+
```
|
|
330
386
|
|
|
331
|
-
|
|
332
|
-
|
|
387
|
+
`qaas score` reports recall, precision and severity agreement against that
|
|
388
|
+
ledger. That matters more than any number this page could print: it means a
|
|
389
|
+
change to a prompt, a threshold or a model has **a measurement attached** rather
|
|
390
|
+
than an opinion.
|
|
333
391
|
|
|
334
392
|
> [!CAUTION]
|
|
335
|
-
> **
|
|
336
|
-
>
|
|
337
|
-
>
|
|
338
|
-
>
|
|
339
|
-
|
|
340
|
-
---
|
|
341
|
-
|
|
342
|
-
## 📋 Status
|
|
343
|
-
|
|
344
|
-
Honest about what exists:
|
|
345
|
-
|
|
346
|
-
- ✅ **All 16 agents in the design are built.**
|
|
347
|
-
- ✅ **Adding one needs a prompt file and a YAML file — no Python.** Six were added that way, which is how the claim got tested.
|
|
348
|
-
- ✅ The fix loop has closed end to end on a real defect: `NOT_FIXED → MENDER → ARBITER APPROVE → VERIFIED`.
|
|
349
|
-
- ✅ 30 skills, 7 in-process MCP servers, 649 offline tests.
|
|
350
|
-
- ⚠️ Running the bundled demo needs `export CORVID_PASSWORD=password123` — credentials come from the environment, including the demo's.
|
|
393
|
+
> **Seeded defects are easier than real ones**, and this system was calibrated
|
|
394
|
+
> against them. A benchmark shows the loop works end to end and does not spray
|
|
395
|
+
> false positives. It does not show that it will find the hard bug in your
|
|
396
|
+
> codebase. Run it on something you know well and judge it on what it finds
|
|
397
|
+
> there.
|
|
351
398
|
|
|
352
399
|
---
|
|
353
400
|
|
|
@@ -358,7 +405,7 @@ git clone https://github.com/allaabdella2-us/qa-multi-agent-system
|
|
|
358
405
|
cd qa-multi-agent-system
|
|
359
406
|
uv venv && uv pip install -e ".[dev]"
|
|
360
407
|
|
|
361
|
-
pytest #
|
|
408
|
+
pytest # 686 tests, offline, free — keep it that way
|
|
362
409
|
pytest -m docker # needs: cd target-app && docker compose up -d
|
|
363
410
|
qaas validate
|
|
364
411
|
```
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
qaas/cli.py,sha256=
|
|
1
|
+
qaas/cli.py,sha256=cmbO2PGpQbv0RwAaAh0_3k3h6uMNIqnAd09cYTnREM0,72613
|
|
2
2
|
qaas/conductor.py,sha256=Vl71d4JAL8Fpk0swSnDnPALR5Pq2oHiftb8S6hdgATc,25855
|
|
3
3
|
qaas/config.py,sha256=bYJuErdUutD6oFAMIFPtskShQtwol5hLKkqYA2Q8Gmo,17545
|
|
4
|
-
qaas/discover.py,sha256=
|
|
4
|
+
qaas/discover.py,sha256=I_SYo9rPjwrJ_JOyD8vkuQrIlnjVQk2InZX3zJIQI58,9316
|
|
5
5
|
qaas/envelope.py,sha256=IiqyOy96CHZw2A0pSDWBNIg6yQ2MzfcwkpQWmgMNvzY,9095
|
|
6
|
+
qaas/envfile.py,sha256=TArHLDbt7qx04c6VRIUa8CTZtL0KIvG_UnzCLaxPV58,3867
|
|
6
7
|
qaas/guardrails.py,sha256=4JIQGFnQpQeIU92c2_E7PQFwXBnQnzolurwp6Zf-Cnk,18797
|
|
7
8
|
qaas/paths.py,sha256=NdW0kDqLXBSASIhp5xFSmMijYPU5tNknKdLj1AUp2to,12703
|
|
8
9
|
qaas/registry.py,sha256=V6-405Ka-ntRMhFHwMeMKq1jlrLqwiore_CKGBBaytw,20770
|
|
@@ -10,11 +11,11 @@ qaas/runner.py,sha256=ED0arzgx0_Y6R_mcG6OXqzh3GeSOpqVUNa4NJ0QcRk8,7417
|
|
|
10
11
|
qaas/scorecard.py,sha256=aUU7g5OtXIW4752A4NxpIZH-OdC8bpNtzJUvCi41JYg,15809
|
|
11
12
|
qaas/sdk_compat.py,sha256=ftE6PK0jZY85zkYqS7UJTGYwRkYDlH4NspiKbKVfVXQ,1678
|
|
12
13
|
qaas/store.py,sha256=aDJmeCog17zo_Dw2Pw_JHTcWnDQlDW3QKzNnUttVvg8,11038
|
|
13
|
-
qaas/target.py,sha256=
|
|
14
|
+
qaas/target.py,sha256=LUXChBZeYoLRUATqwTasxP4c8AGEXPWxhlZSBDgVss8,11759
|
|
14
15
|
qaas/tasks.py,sha256=6ZyxHs1zAFY5cz-a9hkt8_3ns4Y1zSRysvOYKoM33gI,18666
|
|
15
|
-
qaas/trace.py,sha256=
|
|
16
|
+
qaas/trace.py,sha256=kGNgaggD9ibDu27upo5l-98JpMQXVQ_gXF6Vvhx-gTo,14398
|
|
16
17
|
qaas/adapters/__init__.py,sha256=bw2pqtDqhZGP730gwV28BJ-8TF-rhanwCEjdIizjP6A,882
|
|
17
|
-
qaas/adapters/tracker.py,sha256=
|
|
18
|
+
qaas/adapters/tracker.py,sha256=ECnV7On_Tf3kVGVK9N0YSJOFozEueR8E0xv-qKf1GRM,69985
|
|
18
19
|
qaas/adapters/vcs.py,sha256=9su-4QLLxR6yTyLZWCJAky9BROJci80M5jV6nGo6pjg,19430
|
|
19
20
|
qaas/defaults/config/system.yaml,sha256=dweciSWteCpO6C9tOEYwbrP8va4FDjp5eQDLNJdE4y8,2745
|
|
20
21
|
qaas/defaults/config/agents/arbiter.yaml,sha256=nRajKdxgis7YnJq6SOJ6t6Ics2SdF67D5rHfWjQjAUo,719
|
|
@@ -39,7 +40,7 @@ qaas/mcp/defect_memory.py,sha256=IFs6s0zYjUv6KvCaaXNgnQn9HAl8y-zDFVCxeWkEwuM,198
|
|
|
39
40
|
qaas/mcp/env_control.py,sha256=w19W9KHBqxFidi_et0Z5V8OGy1_b6uTCxUedWEIbk7E,38442
|
|
40
41
|
qaas/mcp/envelope_server.py,sha256=RErNtlt1_SddQNBrtc8rttZu6oVMGgZFC4DqOK9_5Dc,20371
|
|
41
42
|
qaas/mcp/test_runner.py,sha256=QysNYAkHoukJH13nroHLAnt19-m5fiRzvhwfWlMRCg0,30562
|
|
42
|
-
qaas/mcp/tracker.py,sha256=
|
|
43
|
+
qaas/mcp/tracker.py,sha256=llCt96fl-KRcqZ_3AmcCrp-1qJ8MQCLtjARi6KQ0pWA,18633
|
|
43
44
|
qaas/mcp/vcs.py,sha256=oo9uWZrfFyFy6bYELe_8DTWubhI4VrcfrO3nkT27-i4,20757
|
|
44
45
|
qaas/plugin/.claude-plugin/plugin.json,sha256=XbzYID6aMD3dVPvJxOP9X_0iJR5isqIfKgGkZmn38BU,263
|
|
45
46
|
qaas/plugin/skills/a11y-audit/SKILL.md,sha256=hTpKJkKTYqxkl2kQRqHrsKU1YfhntilzCykkToBnWyY,2634
|
|
@@ -88,8 +89,8 @@ qaas/prompts/USHER.md,sha256=HfJxJMQym81qHHMy3Tyv5YeeTbvU9iTFWCMgeasyzpM,5144
|
|
|
88
89
|
qaas/prompts/VAULT.md,sha256=Ansowimx-wMbinQkn9_gGgZFmvmMFMc9v_N8s2Ahws4,2915
|
|
89
90
|
qaas/prompts/WARDEN.md,sha256=39KsbwWRwe7mtjYEik1owBIDgu4qyD52UHG_T_7gTMw,2950
|
|
90
91
|
qaas/prompts/_shared.md,sha256=lN_s_rAmakhGuyRuWItC--iyy-Er6ohT_dzGeE_g-fo,2620
|
|
91
|
-
qaas_python-0.
|
|
92
|
-
qaas_python-0.
|
|
93
|
-
qaas_python-0.
|
|
94
|
-
qaas_python-0.
|
|
95
|
-
qaas_python-0.
|
|
92
|
+
qaas_python-0.4.2.dist-info/METADATA,sha256=io2YL7QtRTISUkJb0jgUV7o9kBi6-BkkurcS6oH9mDc,18981
|
|
93
|
+
qaas_python-0.4.2.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
94
|
+
qaas_python-0.4.2.dist-info/entry_points.txt,sha256=6UScfruyhP9N_xGx3tXJGkaoAiB36dkINuKyOH6OkK4,38
|
|
95
|
+
qaas_python-0.4.2.dist-info/licenses/LICENSE,sha256=pHWke5oMtv7PLjIQbN6hRa31J0AKj51VCd5TCTUbbX0,1069
|
|
96
|
+
qaas_python-0.4.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|