alissa-tools-github-revloop 0.27.0__tar.gz → 0.28.0__tar.gz
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.
- {alissa_tools_github_revloop-0.27.0/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.28.0}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/__main__.py +28 -0
- alissa_tools_github_revloop-0.28.0/src/main/alissa/tools/github/revloop/alissa_client.py +191 -0
- {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/config.py +123 -6
- {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/loop.py +38 -1
- alissa_tools_github_revloop-0.28.0/src/main/alissa/tools/github/revloop/loop_events.py +518 -0
- {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/state.py +92 -17
- alissa_tools_github_revloop-0.28.0/src/main/alissa/tools/github/revloop/version +1 -0
- {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +2 -0
- alissa_tools_github_revloop-0.27.0/src/main/alissa/tools/github/revloop/version +0 -1
- {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/LICENSE +0 -0
- {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/NOTICE +0 -0
- {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/README.md +0 -0
- {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/setup.py +0 -0
- {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
- {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
- {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
- {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
- {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
- {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
- {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
- {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
- {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
- {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
|
@@ -11,6 +11,7 @@ from pathlib import Path
|
|
|
11
11
|
from .config import (
|
|
12
12
|
HUB_ADD,
|
|
13
13
|
HUB_SKIP,
|
|
14
|
+
LOOP_EVENTS_ENV,
|
|
14
15
|
ON_MISSING_CREATE,
|
|
15
16
|
ON_MISSING_SKIP,
|
|
16
17
|
ON_MISSING_SPAWN,
|
|
@@ -200,6 +201,31 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
200
201
|
f"invisible to this daemon. Overridden by ${TASK_LIST_BOW_ENV}",
|
|
201
202
|
)
|
|
202
203
|
|
|
204
|
+
events = over.add_mutually_exclusive_group()
|
|
205
|
+
events.add_argument(
|
|
206
|
+
"--loop-events",
|
|
207
|
+
dest="loop_events_enabled",
|
|
208
|
+
action="store_true",
|
|
209
|
+
default=None,
|
|
210
|
+
help="push loop telemetry (rounds spawned, verdicts, cap-outs, "
|
|
211
|
+
"stability holds, stalls, checks holds) to Studio's POST "
|
|
212
|
+
"/v1/loop-events once per poll pass — best-effort, never fatal. "
|
|
213
|
+
f"Overridden by ${LOOP_EVENTS_ENV}",
|
|
214
|
+
)
|
|
215
|
+
events.add_argument(
|
|
216
|
+
"--no-loop-events",
|
|
217
|
+
dest="loop_events_enabled",
|
|
218
|
+
action="store_false",
|
|
219
|
+
help="do not push loop telemetry even if the config enables it",
|
|
220
|
+
)
|
|
221
|
+
over.add_argument(
|
|
222
|
+
"--alissa-endpoint",
|
|
223
|
+
dest="alissa_endpoint",
|
|
224
|
+
metavar="URL",
|
|
225
|
+
help="the Alissa API base the loop-events client posts to "
|
|
226
|
+
"(default: https://api.alissa.app)",
|
|
227
|
+
)
|
|
228
|
+
|
|
203
229
|
dry = over.add_mutually_exclusive_group()
|
|
204
230
|
dry.add_argument(
|
|
205
231
|
"--dry-run",
|
|
@@ -242,6 +268,8 @@ def overrides_from(args: argparse.Namespace) -> dict:
|
|
|
242
268
|
"review_task_miss_ttl_polls": args.review_task_miss_ttl_polls,
|
|
243
269
|
"task_list_self_scope": args.task_list_self_scope,
|
|
244
270
|
"task_list_bow_id": args.task_list_bow_id,
|
|
271
|
+
"loop_events_enabled": args.loop_events_enabled,
|
|
272
|
+
"alissa_endpoint": args.alissa_endpoint,
|
|
245
273
|
"dry_run": args.dry_run,
|
|
246
274
|
}
|
|
247
275
|
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Alissa REST access — the one write loop telemetry needs (issue #112).
|
|
2
|
+
|
|
3
|
+
This is the SECOND Alissa adapter in the package, and the split is deliberate.
|
|
4
|
+
`alissa.py` shells out to the `alissa` CLI, which is the daemon's established
|
|
5
|
+
Alissa idiom for everything it does today (the review-task search, the tmux
|
|
6
|
+
queue, the CR6 envelope reads). Loop telemetry needs a thing that idiom cannot
|
|
7
|
+
supply: the CLI has no loop-events command, and it cannot attach the actor
|
|
8
|
+
identity `POST /v1/loop-events` keys its rows by — the API stores events under
|
|
9
|
+
the token's principal user, which is exactly the credential this daemon's
|
|
10
|
+
`ALISSA_API_TOKEN` already carries.
|
|
11
|
+
|
|
12
|
+
So the one write goes over the REST API directly, in the shape devloop's
|
|
13
|
+
`alissa_client.py` adopted (PR #91 there): stdlib `urllib` only — the
|
|
14
|
+
distribution ships no third-party runtime dependency and this must not be the
|
|
15
|
+
change that adds one — a bearer token from the environment, bounded timeouts,
|
|
16
|
+
and errors classified into a small taxonomy instead of leaking raw urllib
|
|
17
|
+
exceptions. The CLI adapter stays untouched for everything else.
|
|
18
|
+
|
|
19
|
+
The caller here is a BEST-EFFORT emitter (`loop_events`): its answer to every
|
|
20
|
+
bucket is the same — warn once and let the pass complete — so the taxonomy
|
|
21
|
+
exists for the log line, which should say "your token is wrong" (permanent,
|
|
22
|
+
operator-fixable) differently from "the API blinked".
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
import urllib.error
|
|
31
|
+
import urllib.request
|
|
32
|
+
|
|
33
|
+
log = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
DEFAULT_ENDPOINT = "https://api.alissa.app"
|
|
36
|
+
|
|
37
|
+
# The env var the `alissa` CLI itself reads, so a daemon whose CLI is already
|
|
38
|
+
# authenticated needs no second secret.
|
|
39
|
+
ENV_TOKEN = "ALISSA_API_TOKEN"
|
|
40
|
+
|
|
41
|
+
# The ingest cap: POST /v1/loop-events takes 1-200 events per call. The
|
|
42
|
+
# EMITTER splits batches at this bound; the client refuses an oversized one
|
|
43
|
+
# rather than silently posting a request the API will 400.
|
|
44
|
+
MAX_EVENTS_PER_POST = 200
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _RefuseRedirects(urllib.request.HTTPRedirectHandler):
|
|
48
|
+
"""Refuse every redirect instead of following it (PR #113 round 1).
|
|
49
|
+
|
|
50
|
+
The default handler copies the request's headers — `Authorization`
|
|
51
|
+
included — onto the redirected request, ACROSS HOSTS, so a 30x from
|
|
52
|
+
whatever `alissa_endpoint` names would hand the bearer token to the
|
|
53
|
+
redirect target (and 301/302/303 would downgrade the POST to a GET that
|
|
54
|
+
ingests nothing while reading as success). A redirected ingest cannot
|
|
55
|
+
succeed anyway, so refusal beats strip-and-follow: returning None makes
|
|
56
|
+
urllib raise the 30x as an HTTPError, which the taxonomy reports like any
|
|
57
|
+
other unexpected status.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# One opener for the module: default handlers with only the redirect
|
|
65
|
+
# behaviour replaced, built once because handler construction is not free
|
|
66
|
+
# and every client shares the same policy.
|
|
67
|
+
_opener = urllib.request.build_opener(_RefuseRedirects())
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AlissaError(Exception):
|
|
71
|
+
"""Base of the taxonomy. `status` is 0 for a transport failure (there was
|
|
72
|
+
no HTTP response to carry one)."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, status: int, detail: object, code: "str | None" = None):
|
|
75
|
+
super().__init__(f"HTTP {status}: {detail}" if status else str(detail))
|
|
76
|
+
self.status = status
|
|
77
|
+
self.detail = detail
|
|
78
|
+
self.code = code
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class AlissaAuthError(AlissaError):
|
|
82
|
+
"""401/403, or no token at all. Permanent and operator-fixable — retrying
|
|
83
|
+
it every pass only writes the same warning again."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class AlissaTransient(AlissaError):
|
|
87
|
+
"""408/429/5xx and every transport failure (DNS, refused, timeout). The
|
|
88
|
+
'the API blinked' bucket — re-emission next pass is the retry, and the
|
|
89
|
+
deterministic dedupe keys are what make it harmless."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class AlissaClient:
|
|
93
|
+
"""The one write, with the transport hidden behind the taxonomy.
|
|
94
|
+
|
|
95
|
+
Reads `ALISSA_API_TOKEN` from the environment; `base` defaults to the
|
|
96
|
+
public API. Both are constructor arguments so a test never needs the
|
|
97
|
+
network and an operator can point a daemon at another deployment
|
|
98
|
+
(`alissa_endpoint` in the config)."""
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
token: "str | None" = None,
|
|
103
|
+
base: "str | None" = None,
|
|
104
|
+
*,
|
|
105
|
+
timeout: int = 30,
|
|
106
|
+
):
|
|
107
|
+
self.base = (base or DEFAULT_ENDPOINT).rstrip("/")
|
|
108
|
+
self._token = token if token is not None else os.environ.get(ENV_TOKEN)
|
|
109
|
+
self._timeout = timeout
|
|
110
|
+
|
|
111
|
+
def _request(self, path: str, payload: dict) -> object:
|
|
112
|
+
"""One POST. Every failure leaves as a taxonomy exception — the caller
|
|
113
|
+
never sees a raw urllib error or an HTTP status."""
|
|
114
|
+
if not self._token:
|
|
115
|
+
# No token at all is an auth condition, not a transport one: the
|
|
116
|
+
# operator must set the env var. Fail the way a 401 would, so the
|
|
117
|
+
# emitter's warning reads as permanent rather than transient.
|
|
118
|
+
raise AlissaAuthError(0, f"{ENV_TOKEN} is not set")
|
|
119
|
+
|
|
120
|
+
req = urllib.request.Request(
|
|
121
|
+
f"{self.base}{path}",
|
|
122
|
+
method="POST",
|
|
123
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
124
|
+
headers={
|
|
125
|
+
"Accept": "application/json",
|
|
126
|
+
"Content-Type": "application/json",
|
|
127
|
+
"Authorization": f"Bearer {self._token}",
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
try:
|
|
131
|
+
with _opener.open(req, timeout=self._timeout) as resp:
|
|
132
|
+
raw = resp.read().decode("utf-8")
|
|
133
|
+
except urllib.error.HTTPError as exc:
|
|
134
|
+
raise self._classify(exc) from None
|
|
135
|
+
except urllib.error.URLError as exc:
|
|
136
|
+
raise AlissaTransient(0, str(exc.reason)) from None
|
|
137
|
+
except (TimeoutError, OSError) as exc: # pragma: no cover - defence
|
|
138
|
+
# A socket timeout on the READ does not arrive as URLError.
|
|
139
|
+
raise AlissaTransient(0, str(exc)) from None
|
|
140
|
+
try:
|
|
141
|
+
return json.loads(raw) if raw.strip() else {}
|
|
142
|
+
except json.JSONDecodeError as exc:
|
|
143
|
+
# A 2xx that is not JSON is a contract violation, not a retry
|
|
144
|
+
# signal -- but it must not escape as a bare ValueError either,
|
|
145
|
+
# because the emitter catches AlissaError and nothing else.
|
|
146
|
+
raise AlissaError(200, f"response was not JSON ({exc})") from None
|
|
147
|
+
|
|
148
|
+
@staticmethod
|
|
149
|
+
def _classify(exc: "urllib.error.HTTPError") -> AlissaError:
|
|
150
|
+
"""Map an HTTP error onto the taxonomy. The API sends JSON error
|
|
151
|
+
bodies (`{"error": CODE, "message": ...}`); the code rides along when
|
|
152
|
+
present, but classification keys on the STATUS — codes are advisory,
|
|
153
|
+
statuses are the contract."""
|
|
154
|
+
detail: object = exc.read().decode("utf-8", "replace")
|
|
155
|
+
code: "str | None" = None
|
|
156
|
+
try:
|
|
157
|
+
parsed = json.loads(detail) # type: ignore[arg-type]
|
|
158
|
+
except (json.JSONDecodeError, TypeError):
|
|
159
|
+
parsed = None
|
|
160
|
+
if isinstance(parsed, dict):
|
|
161
|
+
detail = parsed
|
|
162
|
+
raw_code = parsed.get("error")
|
|
163
|
+
code = raw_code if isinstance(raw_code, str) else None
|
|
164
|
+
|
|
165
|
+
status = exc.code
|
|
166
|
+
if status in (401, 403):
|
|
167
|
+
return AlissaAuthError(status, detail, code)
|
|
168
|
+
if status in (408, 429) or 500 <= status <= 599:
|
|
169
|
+
return AlissaTransient(status, detail, code)
|
|
170
|
+
return AlissaError(status, detail, code)
|
|
171
|
+
|
|
172
|
+
def post_loop_events(self, events: "list[dict]") -> dict:
|
|
173
|
+
"""Ingest one batch of loop events (`POST /v1/loop-events`).
|
|
174
|
+
|
|
175
|
+
The API is idempotent on `(user, dedupeKey)`, so re-posting a batch —
|
|
176
|
+
which is exactly what the emitter does after a failed pass — lands as
|
|
177
|
+
silent duplicates, never as errors or overwrites. Returns the API's
|
|
178
|
+
`{"accepted": N, "duplicates": M}` payload (empty dict when the body
|
|
179
|
+
was empty), for the caller's debug line.
|
|
180
|
+
|
|
181
|
+
An oversized batch is refused HERE, loudly: the API fails the whole
|
|
182
|
+
call at >200 events, and the emitter owns the splitting, so reaching
|
|
183
|
+
this guard is a code defect rather than an operational condition.
|
|
184
|
+
"""
|
|
185
|
+
if len(events) > MAX_EVENTS_PER_POST:
|
|
186
|
+
raise ValueError(
|
|
187
|
+
f"post_loop_events takes at most {MAX_EVENTS_PER_POST} events "
|
|
188
|
+
f"per call, got {len(events)} — the emitter must split"
|
|
189
|
+
)
|
|
190
|
+
payload = self._request("/v1/loop-events", {"events": events})
|
|
191
|
+
return payload if isinstance(payload, dict) else {}
|
|
@@ -6,12 +6,15 @@ Settings come from three layers, later winning over earlier:
|
|
|
6
6
|
2. a JSON config file (see `resolve_config_path`)
|
|
7
7
|
3. CLI arguments
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
(`ALISSA_REVIEW_TASK_BOW`, see `env_task_list_bow_id`)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
9
|
+
Two keys add a fourth layer above them all — the environment. `task_list_bow_id`
|
|
10
|
+
(`ALISSA_REVIEW_TASK_BOW`, see `env_task_list_bow_id`) has it because it is the
|
|
11
|
+
one key a second entry point needs: `alissa-pr-review` builds its own `Alissa`
|
|
12
|
+
client with no config file and no argv of the daemon's, so an id that lives only
|
|
13
|
+
in the file or the flags reaches the poll loop and silently misses that call
|
|
14
|
+
site. `loop_events_enabled` (`ALISSA_REV_LOOP_EVENTS_ENABLED`, see
|
|
15
|
+
`env_loop_events_enabled`) has it because a container deployment toggles
|
|
16
|
+
telemetry with one variable and no config-file edit (issue #112); the env wins
|
|
17
|
+
over both other layers so the two env-backed keys share one precedence story.
|
|
15
18
|
|
|
16
19
|
`workspace_root` is deliberately **not** a config key — it is a property of the
|
|
17
20
|
running process, not of the settings. That lets one config file drive several
|
|
@@ -28,6 +31,7 @@ import re
|
|
|
28
31
|
from dataclasses import dataclass
|
|
29
32
|
from pathlib import Path
|
|
30
33
|
from typing import Any, Mapping
|
|
34
|
+
from urllib.parse import urlsplit
|
|
31
35
|
|
|
32
36
|
# A POSIX-ish environment variable name -- what `reviewer_token_env` must be.
|
|
33
37
|
_ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
@@ -147,6 +151,8 @@ CONFIG_KEYS = (
|
|
|
147
151
|
"review_task_miss_ttl_polls",
|
|
148
152
|
"task_list_self_scope",
|
|
149
153
|
"task_list_bow_id",
|
|
154
|
+
"loop_events_enabled",
|
|
155
|
+
"alissa_endpoint",
|
|
150
156
|
"dry_run",
|
|
151
157
|
)
|
|
152
158
|
|
|
@@ -156,6 +162,81 @@ CONFIG_KEYS = (
|
|
|
156
162
|
TASK_LIST_BOW_ENV = "ALISSA_REVIEW_TASK_BOW"
|
|
157
163
|
|
|
158
164
|
|
|
165
|
+
# The environment variable toggling loop telemetry (issue #112). Like
|
|
166
|
+
# TASK_LIST_BOW_ENV it outranks both the config file and the CLI flag — see the
|
|
167
|
+
# module docstring for the shared precedence story.
|
|
168
|
+
LOOP_EVENTS_ENV = "ALISSA_REV_LOOP_EVENTS_ENABLED"
|
|
169
|
+
|
|
170
|
+
# The default Studio API base the loop-events client posts to. Mirrors
|
|
171
|
+
# alissa_client.DEFAULT_ENDPOINT (a test pins the two together); defined here
|
|
172
|
+
# too so config stays importable without the client module.
|
|
173
|
+
DEFAULT_ALISSA_ENDPOINT = "https://api.alissa.app"
|
|
174
|
+
|
|
175
|
+
# The boolean spellings the env layer accepts, matching the container
|
|
176
|
+
# renderer's contract for its other boolean (ALISSA_TASK_LIST_SELF_SCOPE):
|
|
177
|
+
# anything else is REFUSED rather than read as false, because a silently-false
|
|
178
|
+
# typo is indistinguishable from the default it was trying to change.
|
|
179
|
+
_ENV_TRUE = frozenset({"1", "true", "yes", "on"})
|
|
180
|
+
_ENV_FALSE = frozenset({"0", "false", "no", "off"})
|
|
181
|
+
|
|
182
|
+
# Hosts a cleartext `alissa_endpoint` is allowed to name. The loop-events
|
|
183
|
+
# client sends a bearer token with every POST, so a non-https endpoint puts
|
|
184
|
+
# that token on the wire — refused at load, except toward the machine itself
|
|
185
|
+
# (a local stub or port-forward, which is how the client is tested against a
|
|
186
|
+
# fake ingest).
|
|
187
|
+
_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _validate_alissa_endpoint(endpoint: str) -> str:
|
|
191
|
+
"""`endpoint`, or a ValueError naming why it cannot carry a credential.
|
|
192
|
+
|
|
193
|
+
https is the rule; http is allowed only toward loopback. Anything else —
|
|
194
|
+
a bare host, another scheme, an unparsable value — is refused at load,
|
|
195
|
+
where the operator reads `config error`, rather than discovered as a
|
|
196
|
+
token on a cleartext hop (PR #113 round 1, minor).
|
|
197
|
+
"""
|
|
198
|
+
parts = urlsplit(endpoint)
|
|
199
|
+
# A host is required on BOTH branches (PR #113 round 2, nit): a bare
|
|
200
|
+
# "https://" parses with the right scheme and no host, and would then
|
|
201
|
+
# fail on the wire as a per-pass transient WARN — the exact symptom this
|
|
202
|
+
# load-time check exists to prevent.
|
|
203
|
+
if parts.scheme == "https" and parts.hostname:
|
|
204
|
+
return endpoint
|
|
205
|
+
if parts.scheme == "http" and parts.hostname in _LOOPBACK_HOSTS:
|
|
206
|
+
return endpoint
|
|
207
|
+
raise ValueError(
|
|
208
|
+
f"alissa_endpoint must be an https:// URL with a host (or http:// "
|
|
209
|
+
f"toward loopback — localhost, 127.0.0.1, ::1 — for a local stub): "
|
|
210
|
+
f"the loop-events client sends a bearer token with every request, "
|
|
211
|
+
f"and a cleartext endpoint puts it on the wire. Got {endpoint!r}"
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def env_loop_events_enabled(
|
|
216
|
+
environ: "Mapping[str, str] | None" = None,
|
|
217
|
+
) -> "bool | None":
|
|
218
|
+
"""The loop-events toggle from the environment, or None when unset.
|
|
219
|
+
|
|
220
|
+
None and empty are the SAME answer — an exported-but-empty variable is how
|
|
221
|
+
a container renders "unset" (the Dockerfile bakes empty ENV defaults), and
|
|
222
|
+
it must fall through to the file/CLI layers rather than read as false. A
|
|
223
|
+
non-boolean spelling raises: the startup phase turns a ValueError into
|
|
224
|
+
`config error` + exit 2, which is where a typo belongs.
|
|
225
|
+
"""
|
|
226
|
+
raw = (os.environ if environ is None else environ).get(LOOP_EVENTS_ENV)
|
|
227
|
+
value = (raw or "").strip().lower()
|
|
228
|
+
if not value:
|
|
229
|
+
return None
|
|
230
|
+
if value in _ENV_TRUE:
|
|
231
|
+
return True
|
|
232
|
+
if value in _ENV_FALSE:
|
|
233
|
+
return False
|
|
234
|
+
raise ValueError(
|
|
235
|
+
f"{LOOP_EVENTS_ENV} must be a boolean "
|
|
236
|
+
f"(1/0, true/false, yes/no, on/off), got {raw!r}"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
159
240
|
def env_task_list_bow_id(environ: "Mapping[str, str] | None" = None) -> "str | None":
|
|
160
241
|
"""The review BOW id from the environment, or None when it is not set.
|
|
161
242
|
|
|
@@ -496,6 +577,20 @@ class Config:
|
|
|
496
577
|
# get the id wrong (a repo's `autodev:` feed BOW; a `mirrorInstanceId`).
|
|
497
578
|
task_list_bow_id: str | None = None
|
|
498
579
|
|
|
580
|
+
# Whether the loop pushes its telemetry — rounds spawned, verdicts posted,
|
|
581
|
+
# cap-outs, stability holds, stalls, checks holds, grants, reaps — to
|
|
582
|
+
# Studio's `POST /v1/loop-events` once per poll pass (issue #112). OFF by
|
|
583
|
+
# default: telemetry is an outbound write to an external service, and an
|
|
584
|
+
# existing deployment must not start posting because it upgraded. Toggled
|
|
585
|
+
# by the env var LOOP_EVENTS_ENV above the file and the CLI. Best-effort
|
|
586
|
+
# when on: a failed push is one WARN and the pass completes.
|
|
587
|
+
loop_events_enabled: bool = False
|
|
588
|
+
|
|
589
|
+
# The Alissa/Studio API base the loop-events client posts to. One knob so
|
|
590
|
+
# a staging deployment can point telemetry somewhere else; everything else
|
|
591
|
+
# about the client (its bearer token) comes from the environment.
|
|
592
|
+
alissa_endpoint: str = DEFAULT_ALISSA_ENDPOINT
|
|
593
|
+
|
|
499
594
|
dry_run: bool = False
|
|
500
595
|
|
|
501
596
|
def __post_init__(self) -> None:
|
|
@@ -603,6 +698,26 @@ class Config:
|
|
|
603
698
|
# takes and answers with nobody's tasks.
|
|
604
699
|
bow_id = (bow_id or "").strip() or None
|
|
605
700
|
|
|
701
|
+
# The loop-events toggle's env layer, applied after the CLI overrides
|
|
702
|
+
# like the BOW id's above and for the same reason: the variable is how
|
|
703
|
+
# a container flips telemetry without editing a file on its volume.
|
|
704
|
+
env_events = env_loop_events_enabled(environ)
|
|
705
|
+
if env_events is not None:
|
|
706
|
+
raw["loop_events_enabled"] = env_events
|
|
707
|
+
|
|
708
|
+
endpoint = raw.get("alissa_endpoint", cls.alissa_endpoint)
|
|
709
|
+
if not isinstance(endpoint, str):
|
|
710
|
+
raise ValueError(
|
|
711
|
+
f"alissa_endpoint must be a URL string, got a "
|
|
712
|
+
f"{type(endpoint).__name__}"
|
|
713
|
+
)
|
|
714
|
+
# "" falls back to the default rather than building a client with an
|
|
715
|
+
# empty base — the same unset-means-default reading every optional
|
|
716
|
+
# string key here has.
|
|
717
|
+
endpoint = _validate_alissa_endpoint(
|
|
718
|
+
endpoint.strip() or cls.alissa_endpoint
|
|
719
|
+
)
|
|
720
|
+
|
|
606
721
|
mode = raw.get("on_missing_review_task", ON_MISSING_SPAWN)
|
|
607
722
|
if mode not in _MISSING_MODES:
|
|
608
723
|
raise ValueError(
|
|
@@ -781,6 +896,8 @@ class Config:
|
|
|
781
896
|
review_task_miss_ttl_polls=miss_ttl,
|
|
782
897
|
task_list_self_scope=bool(raw.get("task_list_self_scope", False)),
|
|
783
898
|
task_list_bow_id=bow_id,
|
|
899
|
+
loop_events_enabled=bool(raw.get("loop_events_enabled", False)),
|
|
900
|
+
alissa_endpoint=endpoint,
|
|
784
901
|
dry_run=bool(raw.get("dry_run", False)),
|
|
785
902
|
)
|
|
786
903
|
|
|
@@ -56,6 +56,7 @@ from .ghclient import (
|
|
|
56
56
|
countable_rounds,
|
|
57
57
|
verdict_marker,
|
|
58
58
|
)
|
|
59
|
+
from .loop_events import LoopEventsEmitter, build_emitter
|
|
59
60
|
from .proc import CommandError
|
|
60
61
|
from .state import State
|
|
61
62
|
|
|
@@ -1604,6 +1605,14 @@ class ReviewWatcher:
|
|
|
1604
1605
|
task_list_bow_id=config.task_list_bow_id,
|
|
1605
1606
|
)
|
|
1606
1607
|
self.state = state or State(config.state_db)
|
|
1608
|
+
# The Studio loop-telemetry push (issue #112), or None when the knob
|
|
1609
|
+
# is off — the disabled default costs nothing: no client, no read, no
|
|
1610
|
+
# log line. Built through the seam so tests swap in a fake client.
|
|
1611
|
+
self._loop_events: LoopEventsEmitter | None = (
|
|
1612
|
+
build_emitter(self.state, endpoint=config.alissa_endpoint)
|
|
1613
|
+
if config.loop_events_enabled
|
|
1614
|
+
else None
|
|
1615
|
+
)
|
|
1607
1616
|
# (repo, number, comment id) of every re-entry directive already
|
|
1608
1617
|
# refused in this process -- see _log_ignored_ack.
|
|
1609
1618
|
self._ignored_acks: set[tuple[str, int, int]] = set()
|
|
@@ -2896,7 +2905,9 @@ class ReviewWatcher:
|
|
|
2896
2905
|
else f" — CI gate: the rollup at {judged[:8]} is {gate.state}, so the "
|
|
2897
2906
|
f"{verdict} envelope did not post as an APPROVE"
|
|
2898
2907
|
)
|
|
2899
|
-
self.state.record_verdict_post(
|
|
2908
|
+
self.state.record_verdict_post(
|
|
2909
|
+
pr.full_name, pr.number, round_, url, verdict=verdict
|
|
2910
|
+
)
|
|
2900
2911
|
log.info(
|
|
2901
2912
|
"%s round %d closed: native %s review submitted as %s (%s)%s",
|
|
2902
2913
|
pr.slug, round_, event, self.github.login, url or "no url", gate_note,
|
|
@@ -4810,8 +4821,34 @@ class ReviewWatcher:
|
|
|
4810
4821
|
self._write_snapshot(
|
|
4811
4822
|
results, reaped, duration_ms=int((time.monotonic() - started) * 1000)
|
|
4812
4823
|
)
|
|
4824
|
+
self._emit_loop_events()
|
|
4813
4825
|
return results
|
|
4814
4826
|
|
|
4827
|
+
def _emit_loop_events(self) -> None:
|
|
4828
|
+
"""Push this pass's loop telemetry to Studio, when enabled (issue
|
|
4829
|
+
#112). Last thing in the pass, after the snapshot, so the batch sees
|
|
4830
|
+
every ledger row the pass wrote.
|
|
4831
|
+
|
|
4832
|
+
Skipped in dry-run even when enabled: the ledger holds no new rows
|
|
4833
|
+
from a dry-run pass, and while re-emitting old ones would be harmless
|
|
4834
|
+
(idempotent keys), an outbound POST is still an act — and dry-run's
|
|
4835
|
+
contract is decide-and-log only.
|
|
4836
|
+
|
|
4837
|
+
The emitter itself never raises for an API condition (one WARN, the
|
|
4838
|
+
pass completes); the guard here is the same never-fatal promise held
|
|
4839
|
+
against a code defect, so telemetry can never take down a poll.
|
|
4840
|
+
"""
|
|
4841
|
+
if self._loop_events is None or self.config.dry_run:
|
|
4842
|
+
return
|
|
4843
|
+
try:
|
|
4844
|
+
self._loop_events.emit_once()
|
|
4845
|
+
except Exception as exc:
|
|
4846
|
+
log.warning(
|
|
4847
|
+
"loop-events: emitter failed unexpectedly (%s: %s) — "
|
|
4848
|
+
"telemetry is best-effort, the pass completes",
|
|
4849
|
+
type(exc).__name__, exc,
|
|
4850
|
+
)
|
|
4851
|
+
|
|
4815
4852
|
def _note_ledger_unwritable(self) -> None:
|
|
4816
4853
|
"""Report a pass refused because the ledger cannot record it.
|
|
4817
4854
|
|
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
"""Loop telemetry: derive Studio loop events from the ledger, once per pass.
|
|
2
|
+
|
|
3
|
+
The per-round record this daemon keeps — `verdict_posts` (first seen, attempts,
|
|
4
|
+
posted/abandoned, checks-held state, review URL), `spawns`, `escalations`,
|
|
5
|
+
`pings`, `grants`, `stability_notices`, `spawn_checks_holds`, `reaps` — lives
|
|
6
|
+
only in `.revloop/state.db` on the deployment's volume. The Factory console
|
|
7
|
+
needs rounds-to-approve, verdict mix, cap-outs and stability holds over time,
|
|
8
|
+
and Studio now ingests exactly that (`POST /v1/loop-events`, issue #112). This
|
|
9
|
+
module is the writer: at the end of each poll pass it derives events from the
|
|
10
|
+
ledger and posts ONE batch (split at 200, the ingest cap).
|
|
11
|
+
|
|
12
|
+
Design rules, all load-bearing:
|
|
13
|
+
|
|
14
|
+
* **The ledger is the source, not the pass.** Events carry the LEDGER's
|
|
15
|
+
timestamps (`at` = the row's stamp, in epoch ms) and dedupe keys built from
|
|
16
|
+
the LEDGER's own keys, so however many times a row is read, it derives the
|
|
17
|
+
same event. That is what lets everything else be simple.
|
|
18
|
+
|
|
19
|
+
* **Best-effort, never fatal.** A failed post is one WARN and the pass
|
|
20
|
+
completes; there is no retry queue, because there does not need to be one —
|
|
21
|
+
the watermark below does not advance on failure, so the next pass re-derives
|
|
22
|
+
and re-sends, and the deterministic keys make the re-emission land as silent
|
|
23
|
+
duplicates server-side (idempotent on `(user, dedupeKey)`).
|
|
24
|
+
|
|
25
|
+
* **The watermark is in-memory and starts at zero.** Each successful emission
|
|
26
|
+
advances it to the newest ledger stamp sent. The boundary is INCLUSIVE (two
|
|
27
|
+
rows can share a second, and a strict `>` advanced to the first one's stamp
|
|
28
|
+
would lose the second forever); the standing re-post that inclusion would
|
|
29
|
+
otherwise cause is closed by remembering the dedupe keys already sent at the
|
|
30
|
+
watermark second and dropping them from the next derivation — so a quiet
|
|
31
|
+
ledger derives an EMPTY batch and no request is made. A daemon restart
|
|
32
|
+
resets both and the first pass re-sends the WHOLE ledger, batched: that is
|
|
33
|
+
the backfill, not a bug — Studio dedupes every previously-seen key and
|
|
34
|
+
keeps the history a fresh console needs. Be honest about the bound: NONE of
|
|
35
|
+
the seven tables this module reads is pruned (`poll_snapshots` is the
|
|
36
|
+
ledger's only self-bounding table, and this module does not read it), so
|
|
37
|
+
the backfill — and the per-pass re-derivation the watermark then filters —
|
|
38
|
+
is bounded only by the deployment's actual row counts. At today's volumes
|
|
39
|
+
(hundreds of rows over a deployment's life) that is a fine trade for
|
|
40
|
+
stateless simplicity; if a ledger ever outgrows it, the upgrade path is
|
|
41
|
+
pushing the `since` bound into the readers' SQL, or persisting the
|
|
42
|
+
watermark in the ledger so a restart does not backfill at all.
|
|
43
|
+
|
|
44
|
+
* **Derivation is pure reads.** No GitHub call, no ledger write, no new state
|
|
45
|
+
table. A row the ledger deletes (a spawn-side CI hold whose wait ended
|
|
46
|
+
between passes) is simply not observed; a row enriched from another table
|
|
47
|
+
(`stability_notices` onto a `stability:` ping) reads whatever that table
|
|
48
|
+
says NOW, which is also what the guard itself decides from.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
import logging
|
|
54
|
+
from typing import Any
|
|
55
|
+
|
|
56
|
+
from .alissa_client import (
|
|
57
|
+
AlissaAuthError,
|
|
58
|
+
AlissaClient,
|
|
59
|
+
AlissaError,
|
|
60
|
+
MAX_EVENTS_PER_POST,
|
|
61
|
+
)
|
|
62
|
+
from .state import State
|
|
63
|
+
|
|
64
|
+
log = logging.getLogger(__name__)
|
|
65
|
+
|
|
66
|
+
SEAT = "revloop"
|
|
67
|
+
|
|
68
|
+
# Ping-kind prefixes this module parses back out of the ledger. Kept as local
|
|
69
|
+
# constants rather than imported from `loop` because loop imports THIS module
|
|
70
|
+
# (the emitter is wired into the watcher), and a cycle is a worse trade than a
|
|
71
|
+
# pinned duplicate: a test asserts each against loop's own constant, so a
|
|
72
|
+
# renamed kind fails the suite instead of silently deriving nothing.
|
|
73
|
+
STALLED_PREFIX = "stalled:"
|
|
74
|
+
STABILITY_PREFIX = "stability:"
|
|
75
|
+
CHECKS_UNSETTLED_PREFIX = "checks-unsettled:"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _ms(seconds: "int | float") -> int:
|
|
79
|
+
"""A ledger stamp (epoch seconds) as the API's epoch-ms `at`."""
|
|
80
|
+
return int(seconds) * 1000
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _event(
|
|
84
|
+
kind: str,
|
|
85
|
+
at_s: int,
|
|
86
|
+
dedupe_key: str,
|
|
87
|
+
*,
|
|
88
|
+
repo: "str | None" = None,
|
|
89
|
+
pr: "int | None" = None,
|
|
90
|
+
round_: "int | None" = None,
|
|
91
|
+
session: "str | None" = None,
|
|
92
|
+
reason: "str | None" = None,
|
|
93
|
+
data: "dict[str, Any] | None" = None,
|
|
94
|
+
) -> "tuple[int, dict]":
|
|
95
|
+
"""One (ledger stamp, event payload) pair, with absent fields omitted
|
|
96
|
+
rather than sent as null — the API validates shape per field."""
|
|
97
|
+
payload: dict[str, Any] = {
|
|
98
|
+
"seat": SEAT,
|
|
99
|
+
"kind": kind,
|
|
100
|
+
"at": _ms(at_s),
|
|
101
|
+
"dedupeKey": dedupe_key,
|
|
102
|
+
}
|
|
103
|
+
if repo:
|
|
104
|
+
payload["repo"] = repo
|
|
105
|
+
if pr is not None:
|
|
106
|
+
payload["prNumber"] = int(pr)
|
|
107
|
+
if round_ is not None:
|
|
108
|
+
payload["round"] = int(round_)
|
|
109
|
+
if session:
|
|
110
|
+
payload["session"] = session
|
|
111
|
+
if reason:
|
|
112
|
+
# The API caps `reason` at 2000 chars and refuses (never trims) an
|
|
113
|
+
# over-cap value, which would fail the WHOLE batch — so the trim
|
|
114
|
+
# happens here, where it costs one event's tail instead.
|
|
115
|
+
payload["reason"] = reason[:2000]
|
|
116
|
+
if data:
|
|
117
|
+
payload["data"] = data
|
|
118
|
+
return int(at_s), payload
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _spawn_events(rows: "list[dict]") -> "list[tuple[int, dict]]":
|
|
122
|
+
out = []
|
|
123
|
+
for row in rows:
|
|
124
|
+
repo, number = row["repo"], int(row["number"])
|
|
125
|
+
head = row["head_sha"] or ""
|
|
126
|
+
data: dict[str, Any] = {"headSha": head}
|
|
127
|
+
if row.get("task_ref"):
|
|
128
|
+
data["taskRef"] = row["task_ref"]
|
|
129
|
+
out.append(_event(
|
|
130
|
+
"round.spawned",
|
|
131
|
+
int(row["spawned_at"]),
|
|
132
|
+
f"revloop:round.spawned:{repo}:{number}:{row['round']}:{head}",
|
|
133
|
+
repo=repo,
|
|
134
|
+
pr=number,
|
|
135
|
+
round_=int(row["round"]),
|
|
136
|
+
session=row["session"],
|
|
137
|
+
data=data,
|
|
138
|
+
))
|
|
139
|
+
return out
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _verdict_events(rows: "list[dict]") -> "list[tuple[int, dict]]":
|
|
143
|
+
"""`round.verdict` for posted rows, `round.abandoned` for abandoned ones.
|
|
144
|
+
|
|
145
|
+
A row with neither `posted_at` nor `abandoned_at` is an OPEN obligation —
|
|
146
|
+
the round's native verdict has not landed — and emits nothing until it
|
|
147
|
+
closes one way or the other. `data.verdict` is omitted (never invented)
|
|
148
|
+
on rows that predate the ledger's verdict column.
|
|
149
|
+
|
|
150
|
+
COVERAGE BOUND (PR #113 round 2, major): `verdict_posts` is the
|
|
151
|
+
per-round OBLIGATION record, not the per-round record — a row exists
|
|
152
|
+
only when the reviewer session defaulted and the daemon posted the
|
|
153
|
+
native fallback verdict itself. On a fleet whose sessions post every
|
|
154
|
+
review, these two kinds are expected to be EMPTY; issue #112 maps them
|
|
155
|
+
to this table and puts the round bookkeeping a session-covering source
|
|
156
|
+
needs out of scope, so the widening is TASK-1086576582, and the README's
|
|
157
|
+
telemetry table states the bound where an operator will read it."""
|
|
158
|
+
out = []
|
|
159
|
+
for row in rows:
|
|
160
|
+
repo, number = row["repo"], int(row["number"])
|
|
161
|
+
round_, head = int(row["round"]), row["head_sha"] or ""
|
|
162
|
+
if row.get("posted_at"):
|
|
163
|
+
posted = int(row["posted_at"])
|
|
164
|
+
data: dict[str, Any] = {"headSha": head}
|
|
165
|
+
if row.get("verdict"):
|
|
166
|
+
data["verdict"] = row["verdict"]
|
|
167
|
+
if row.get("review_url"):
|
|
168
|
+
data["reviewUrl"] = row["review_url"]
|
|
169
|
+
data["attempts"] = int(row.get("attempts") or 0)
|
|
170
|
+
if row.get("checks_held_at"):
|
|
171
|
+
held = posted - int(row["checks_held_at"])
|
|
172
|
+
data["checksHeldMs"] = max(held, 0) * 1000
|
|
173
|
+
out.append(_event(
|
|
174
|
+
"round.verdict",
|
|
175
|
+
posted,
|
|
176
|
+
f"revloop:round.verdict:{repo}:{number}:{round_}:{head}",
|
|
177
|
+
repo=repo,
|
|
178
|
+
pr=number,
|
|
179
|
+
round_=round_,
|
|
180
|
+
data=data,
|
|
181
|
+
))
|
|
182
|
+
elif row.get("abandoned_at"):
|
|
183
|
+
out.append(_event(
|
|
184
|
+
"round.abandoned",
|
|
185
|
+
int(row["abandoned_at"]),
|
|
186
|
+
f"revloop:round.abandoned:{repo}:{number}:{round_}:{head}",
|
|
187
|
+
repo=repo,
|
|
188
|
+
pr=number,
|
|
189
|
+
round_=round_,
|
|
190
|
+
reason=row.get("last_error") or None,
|
|
191
|
+
data={"headSha": head},
|
|
192
|
+
))
|
|
193
|
+
return out
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _capped_events(
|
|
197
|
+
rows: "list[dict]", spawns: "list[dict]"
|
|
198
|
+
) -> "list[tuple[int, dict]]":
|
|
199
|
+
"""`round.capped` from the escalation table (CR9 cap-outs).
|
|
200
|
+
|
|
201
|
+
The dedupe key folds the row's own `escalated_at` (PR #113 round 1,
|
|
202
|
+
major): `escalations` is keyed per head and REPLACEd in place, and a
|
|
203
|
+
re-cap-out on the SAME head is a designed path — an operator-granted
|
|
204
|
+
round consumed without an approve is a new decision, which is the whole
|
|
205
|
+
reason `loop.capout_kind` folds the granted total into its ping key. A
|
|
206
|
+
key without the stamp would make Studio swallow the re-cap-out as a
|
|
207
|
+
duplicate and under-report exactly the re-entry case. The stamp, not the
|
|
208
|
+
granted total, because the stamp is ON the row: re-deriving the same row
|
|
209
|
+
always yields the same key, while a granted total read at derive time
|
|
210
|
+
would re-key an old row during a backfill.
|
|
211
|
+
|
|
212
|
+
`escalations` carries no round of its own, so the round is read from the
|
|
213
|
+
newest spawn at or before `escalated_at` — the round in flight when THIS
|
|
214
|
+
cap-out was recorded, which stays true for a row backfilled long after
|
|
215
|
+
the PR moved on — and omitted when no spawn row qualifies."""
|
|
216
|
+
out = []
|
|
217
|
+
for row in rows:
|
|
218
|
+
repo, number = row["repo"], int(row["number"])
|
|
219
|
+
head = row["head_sha"] or ""
|
|
220
|
+
at = int(row["escalated_at"])
|
|
221
|
+
rounds = [
|
|
222
|
+
int(s["round"]) for s in spawns
|
|
223
|
+
if s["repo"] == repo and int(s["number"]) == number
|
|
224
|
+
and int(s["spawned_at"]) <= at
|
|
225
|
+
]
|
|
226
|
+
out.append(_event(
|
|
227
|
+
"round.capped",
|
|
228
|
+
at,
|
|
229
|
+
f"revloop:round.capped:{repo}:{number}:{head}:{at}",
|
|
230
|
+
repo=repo,
|
|
231
|
+
pr=number,
|
|
232
|
+
round_=max(rounds) if rounds else None,
|
|
233
|
+
data={"headSha": head},
|
|
234
|
+
))
|
|
235
|
+
return out
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _ping_events(
|
|
239
|
+
rows: "list[dict]", notices: "dict[tuple[str, int], dict]"
|
|
240
|
+
) -> "list[tuple[int, dict]]":
|
|
241
|
+
"""`stalled`, `stability.hold` and `checks.held` out of the ping ledger.
|
|
242
|
+
|
|
243
|
+
`kind` is free-form text carrying the episode identity, so it is parsed
|
|
244
|
+
by prefix; kinds this module does not report (`activity-deferred:`,
|
|
245
|
+
`capout:`, `checks-hold:`, `verdict-post-failed:` — each the dedupe of a
|
|
246
|
+
GitHub-side comment, not a fact of its own) derive nothing.
|
|
247
|
+
|
|
248
|
+
A stability event's payload is split by provenance, deliberately
|
|
249
|
+
(PR #113 round 1, minor). `data.headSha` and `data.grantsSeen` come from
|
|
250
|
+
the ping KIND itself (`stability:<head>:<base>:<granted>`, where the
|
|
251
|
+
granted total is what the guard had accounted for when it held), so they
|
|
252
|
+
are episode-correct even for a row backfilled long after. `data.rcRounds`
|
|
253
|
+
and `round` come from the `stability_notices` join, and that table is
|
|
254
|
+
REPLACEd per episode — so those two are CURRENT-AT-DERIVATION: a
|
|
255
|
+
backfilled episode-1 ping carries the newest episode's numbers, because
|
|
256
|
+
the ledger keeps nothing episode-scoped for them. Stated here so no
|
|
257
|
+
reader mistakes the join for an episode guarantee."""
|
|
258
|
+
out = []
|
|
259
|
+
for row in rows:
|
|
260
|
+
repo, number = row["repo"], int(row["number"])
|
|
261
|
+
kind, at = str(row["kind"]), int(row["pinged_at"])
|
|
262
|
+
if kind.startswith(STALLED_PREFIX):
|
|
263
|
+
session = kind[len(STALLED_PREFIX):]
|
|
264
|
+
out.append(_event(
|
|
265
|
+
"stalled",
|
|
266
|
+
at,
|
|
267
|
+
f"revloop:stalled:{repo}:{number}:{session}",
|
|
268
|
+
repo=repo,
|
|
269
|
+
pr=number,
|
|
270
|
+
session=session,
|
|
271
|
+
))
|
|
272
|
+
elif kind.startswith(STABILITY_PREFIX):
|
|
273
|
+
tail = kind[len(STABILITY_PREFIX):]
|
|
274
|
+
data: dict[str, Any] = {}
|
|
275
|
+
parts = tail.split(":")
|
|
276
|
+
head = parts[0]
|
|
277
|
+
if head:
|
|
278
|
+
data["headSha"] = head
|
|
279
|
+
# The kind's own granted total — episode-correct, see above.
|
|
280
|
+
if len(parts) == 3 and parts[2].isdigit():
|
|
281
|
+
data["grantsSeen"] = int(parts[2])
|
|
282
|
+
notice = notices.get((repo, number))
|
|
283
|
+
if notice is not None:
|
|
284
|
+
data["rcRounds"] = int(notice["rc_rounds"])
|
|
285
|
+
out.append(_event(
|
|
286
|
+
"stability.hold",
|
|
287
|
+
at,
|
|
288
|
+
f"revloop:stability.hold:{repo}:{number}:{tail}",
|
|
289
|
+
repo=repo,
|
|
290
|
+
pr=number,
|
|
291
|
+
round_=(int(notice["round"]) if notice is not None else None),
|
|
292
|
+
data=data or None,
|
|
293
|
+
))
|
|
294
|
+
elif kind.startswith(CHECKS_UNSETTLED_PREFIX):
|
|
295
|
+
tail = kind[len(CHECKS_UNSETTLED_PREFIX):]
|
|
296
|
+
round_str, _, head = tail.partition(":")
|
|
297
|
+
round_ = int(round_str) if round_str.isdigit() else None
|
|
298
|
+
out.append(_event(
|
|
299
|
+
"checks.held",
|
|
300
|
+
at,
|
|
301
|
+
f"revloop:checks.held:{repo}:{number}:{round_str}:{head}",
|
|
302
|
+
repo=repo,
|
|
303
|
+
pr=number,
|
|
304
|
+
round_=round_,
|
|
305
|
+
data={"headSha": head, "gate": "verdict"},
|
|
306
|
+
))
|
|
307
|
+
return out
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _spawn_hold_events(rows: "list[dict]") -> "list[tuple[int, dict]]":
|
|
311
|
+
"""`checks.held` from the pre-spawn CI gate's in-flight holds.
|
|
312
|
+
|
|
313
|
+
Same kind as the verdict-side hold above — both mean "this round is
|
|
314
|
+
waiting on this head's checks" — but a distinct dedupe key (`:spawn`
|
|
315
|
+
suffix) and `data.gate`, because the two waits are different facts about
|
|
316
|
+
the same round and one must not swallow the other's event."""
|
|
317
|
+
out = []
|
|
318
|
+
for row in rows:
|
|
319
|
+
repo, number = row["repo"], int(row["number"])
|
|
320
|
+
round_, head = int(row["round"]), row["head_sha"] or ""
|
|
321
|
+
out.append(_event(
|
|
322
|
+
"checks.held",
|
|
323
|
+
int(row["first_at"]),
|
|
324
|
+
f"revloop:checks.held:{repo}:{number}:{round_}:{head}:spawn",
|
|
325
|
+
repo=repo,
|
|
326
|
+
pr=number,
|
|
327
|
+
round_=round_,
|
|
328
|
+
data={"headSha": head, "gate": "spawn"},
|
|
329
|
+
))
|
|
330
|
+
return out
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _grant_events(rows: "list[dict]") -> "list[tuple[int, dict]]":
|
|
334
|
+
return [
|
|
335
|
+
_event(
|
|
336
|
+
"grant",
|
|
337
|
+
int(row["granted_at"]),
|
|
338
|
+
f"revloop:grant:{row['repo']}:{row['number']}:{row['comment_id']}",
|
|
339
|
+
repo=row["repo"],
|
|
340
|
+
pr=int(row["number"]),
|
|
341
|
+
data={"author": row["author"], "rounds": int(row["rounds"])},
|
|
342
|
+
)
|
|
343
|
+
for row in rows
|
|
344
|
+
]
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _reap_events(rows: "list[dict]") -> "list[tuple[int, dict]]":
|
|
348
|
+
# `reaped_at` folds into the key (PR #113 round 1, nit): `reaps` is
|
|
349
|
+
# REPLACEd per session name, and while names are nonce-unique per spawn
|
|
350
|
+
# today, the key should not depend on that holding forever — a re-reaped
|
|
351
|
+
# name is a new decision, and the stamp is on the row, so re-derivation
|
|
352
|
+
# stays deterministic.
|
|
353
|
+
return [
|
|
354
|
+
_event(
|
|
355
|
+
"reap",
|
|
356
|
+
int(row["reaped_at"]),
|
|
357
|
+
f"revloop:reap:{row['session']}:{int(row['reaped_at'])}",
|
|
358
|
+
session=row["session"],
|
|
359
|
+
)
|
|
360
|
+
for row in rows
|
|
361
|
+
]
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def derive_events(state: State, *, since: int = 0) -> "list[dict]":
|
|
365
|
+
"""Every loop event the ledger implies whose stamp is >= `since`,
|
|
366
|
+
oldest first.
|
|
367
|
+
|
|
368
|
+
Inclusive on the boundary on purpose: two rows can share a second, and a
|
|
369
|
+
strictly-greater filter advanced to the first one's stamp would lose the
|
|
370
|
+
second forever, while inclusion merely re-sends a key the API dedupes.
|
|
371
|
+
"""
|
|
372
|
+
spawns = state.read_spawns()
|
|
373
|
+
notices = {
|
|
374
|
+
(row["repo"], int(row["number"])): row
|
|
375
|
+
for row in state.read_stability_notices()
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
stamped: "list[tuple[int, dict]]" = []
|
|
379
|
+
stamped += _spawn_events(spawns)
|
|
380
|
+
stamped += _verdict_events(state.read_verdict_posts())
|
|
381
|
+
stamped += _capped_events(state.read_escalations(), spawns)
|
|
382
|
+
stamped += _ping_events(state.read_pings(), notices)
|
|
383
|
+
stamped += _spawn_hold_events(state.read_spawn_checks_holds())
|
|
384
|
+
stamped += _grant_events(state.read_grants())
|
|
385
|
+
stamped += _reap_events(state.read_reaps())
|
|
386
|
+
|
|
387
|
+
stamped = [(at, event) for at, event in stamped if at >= since]
|
|
388
|
+
stamped.sort(key=lambda pair: pair[0])
|
|
389
|
+
return [event for _, event in stamped]
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
class LoopEventsEmitter:
|
|
393
|
+
"""The once-per-pass push, watermarked and best-effort.
|
|
394
|
+
|
|
395
|
+
Owned by the watcher when `loop_events_enabled` is on; `emit_once` is
|
|
396
|
+
called at the end of every poll pass and NEVER raises for an API or
|
|
397
|
+
transport condition — one WARN, the pass completes, and the un-advanced
|
|
398
|
+
watermark is the whole retry story (see the module docstring).
|
|
399
|
+
"""
|
|
400
|
+
|
|
401
|
+
def __init__(self, state: State, client: AlissaClient):
|
|
402
|
+
self._state = state
|
|
403
|
+
self._client = client
|
|
404
|
+
# Epoch seconds of the newest ledger stamp successfully emitted.
|
|
405
|
+
# Zero until the first success, so a fresh process backfills.
|
|
406
|
+
self._since = 0
|
|
407
|
+
# The dedupe keys already sent at exactly the watermark second. The
|
|
408
|
+
# `since` filter is inclusive so a second row written in the boundary
|
|
409
|
+
# second can never be lost — this set is what stops the OTHER edge of
|
|
410
|
+
# that choice, the boundary row being re-posted every pass forever on
|
|
411
|
+
# a ledger that has stopped changing (PR #113 round 1, minor).
|
|
412
|
+
self._sent_at_watermark: "set[str]" = set()
|
|
413
|
+
# Latched True by a permanent auth failure — see emit_once. A
|
|
414
|
+
# process-lifetime latch on purpose: a restart is both how a fixed
|
|
415
|
+
# token takes effect and how the operator re-arms the emitter.
|
|
416
|
+
self._auth_failed = False
|
|
417
|
+
|
|
418
|
+
def emit_once(self) -> bool:
|
|
419
|
+
"""Derive and post this pass's batch. True when everything landed
|
|
420
|
+
(an empty derivation is vacuous success).
|
|
421
|
+
|
|
422
|
+
An `AlissaAuthError` LATCHES the emitter off for the life of the
|
|
423
|
+
process (PR #113 round 1, minor): the taxonomy calls it permanent and
|
|
424
|
+
operator-fixable — a missing `ALISSA_API_TOKEN`, a token the ingest
|
|
425
|
+
403s — so re-trying it would re-derive the whole ledger and write an
|
|
426
|
+
identical WARN every poll interval for as long as the daemon runs.
|
|
427
|
+
One WARN names the fix; later passes return at a DEBUG line, and the
|
|
428
|
+
restart that installs a corrected credential also re-arms this.
|
|
429
|
+
Transient errors keep the re-send behaviour — the watermark stays
|
|
430
|
+
put and the next pass retries.
|
|
431
|
+
"""
|
|
432
|
+
if self._auth_failed:
|
|
433
|
+
log.debug(
|
|
434
|
+
"loop-events: disabled since an authentication failure — "
|
|
435
|
+
"restart the daemon after fixing the credential",
|
|
436
|
+
)
|
|
437
|
+
return False
|
|
438
|
+
try:
|
|
439
|
+
events = derive_events(self._state, since=self._since)
|
|
440
|
+
except Exception as exc:
|
|
441
|
+
# A derivation failure is a ledger read gone wrong — the same
|
|
442
|
+
# best-effort telemetry classification the snapshot writer has:
|
|
443
|
+
# warn and let the pass complete, never kill the poll over it.
|
|
444
|
+
log.warning(
|
|
445
|
+
"loop-events: derivation failed (%s: %s) — skipping this "
|
|
446
|
+
"pass's telemetry; the loop keeps polling",
|
|
447
|
+
type(exc).__name__, exc,
|
|
448
|
+
)
|
|
449
|
+
return False
|
|
450
|
+
events = [
|
|
451
|
+
e for e in events
|
|
452
|
+
if not (
|
|
453
|
+
int(e["at"]) // 1000 == self._since
|
|
454
|
+
and e["dedupeKey"] in self._sent_at_watermark
|
|
455
|
+
)
|
|
456
|
+
]
|
|
457
|
+
if not events:
|
|
458
|
+
return True
|
|
459
|
+
sent = 0
|
|
460
|
+
try:
|
|
461
|
+
for start in range(0, len(events), MAX_EVENTS_PER_POST):
|
|
462
|
+
chunk = events[start:start + MAX_EVENTS_PER_POST]
|
|
463
|
+
result = self._client.post_loop_events(chunk)
|
|
464
|
+
sent += len(chunk)
|
|
465
|
+
log.debug(
|
|
466
|
+
"loop-events: posted %d event(s) (accepted=%s, "
|
|
467
|
+
"duplicates=%s)",
|
|
468
|
+
len(chunk), result.get("accepted"),
|
|
469
|
+
result.get("duplicates"),
|
|
470
|
+
)
|
|
471
|
+
except AlissaAuthError as exc:
|
|
472
|
+
self._auth_failed = True
|
|
473
|
+
log.warning(
|
|
474
|
+
"loop-events: authentication failed (%s) — this is permanent "
|
|
475
|
+
"and operator-fixable (set/rotate the token the emitter's "
|
|
476
|
+
"client reads, then restart the daemon); loop telemetry is "
|
|
477
|
+
"now off for this process, the loop keeps polling",
|
|
478
|
+
exc,
|
|
479
|
+
)
|
|
480
|
+
return False
|
|
481
|
+
except AlissaError as exc:
|
|
482
|
+
# ONE warn per failed pass, naming how far it got; the watermark
|
|
483
|
+
# stays put so the next pass re-derives and re-sends, and the
|
|
484
|
+
# deterministic keys make the overlap land as duplicates.
|
|
485
|
+
log.warning(
|
|
486
|
+
"loop-events: post failed after %d of %d event(s) (%s) — "
|
|
487
|
+
"telemetry is best-effort, the pass completes; the next "
|
|
488
|
+
"pass re-sends (idempotent keys)",
|
|
489
|
+
sent, len(events), exc,
|
|
490
|
+
)
|
|
491
|
+
return False
|
|
492
|
+
newest = max(int(event["at"]) // 1000 for event in events)
|
|
493
|
+
boundary_keys = {
|
|
494
|
+
e["dedupeKey"] for e in events if int(e["at"]) // 1000 == newest
|
|
495
|
+
}
|
|
496
|
+
if newest == self._since:
|
|
497
|
+
# The watermark did not move (a same-second newcomer): the set
|
|
498
|
+
# GROWS, because the earlier boundary sends are still boundary.
|
|
499
|
+
self._sent_at_watermark |= boundary_keys
|
|
500
|
+
else:
|
|
501
|
+
self._since = newest
|
|
502
|
+
self._sent_at_watermark = boundary_keys
|
|
503
|
+
log.info("loop-events: %d event(s) pushed", len(events))
|
|
504
|
+
return True
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def build_emitter(
|
|
508
|
+
state: State, *, endpoint: "str | None" = None
|
|
509
|
+
) -> LoopEventsEmitter:
|
|
510
|
+
"""The emitter the watcher wires in when `loop_events_enabled` is on.
|
|
511
|
+
|
|
512
|
+
A seam, so tests build emitters over fake clients while the watcher's
|
|
513
|
+
call stays one line. The token comes from the environment inside
|
|
514
|
+
`AlissaClient` (the CLI's own `ALISSA_API_TOKEN`); its absence surfaces
|
|
515
|
+
as the emitter's WARN, never at construction — a daemon must boot and
|
|
516
|
+
poll whether or not telemetry can authenticate.
|
|
517
|
+
"""
|
|
518
|
+
return LoopEventsEmitter(state, AlissaClient(base=endpoint))
|
|
@@ -144,6 +144,13 @@ CREATE TABLE IF NOT EXISTS verdict_posts (
|
|
|
144
144
|
-- waited one bound when it waited two. NULL until (and unless) the promotion
|
|
145
145
|
-- happens; the bound then reads `checks_pending_at or checks_held_at`.
|
|
146
146
|
checks_pending_at INTEGER,
|
|
147
|
+
-- WHAT the round's verdict was ('approve' | 'request_changes'), stamped
|
|
148
|
+
-- when the post lands (issue #112). The ledger did not need it -- GitHub's
|
|
149
|
+
-- reviews list answers "what was decided" -- but the loop-events emitter
|
|
150
|
+
-- derives `round.verdict` from THIS table at end of pass, long after the
|
|
151
|
+
-- envelope read that knew the answer. NULL on rows that predate the
|
|
152
|
+
-- column; the emitter omits the field rather than inventing one.
|
|
153
|
+
verdict TEXT,
|
|
147
154
|
review_url TEXT,
|
|
148
155
|
last_error TEXT,
|
|
149
156
|
PRIMARY KEY (repo, number, round)
|
|
@@ -308,6 +315,7 @@ _ADDED_COLUMNS = {
|
|
|
308
315
|
("checks_held_at", "INTEGER"),
|
|
309
316
|
("checks_held_state", "TEXT"),
|
|
310
317
|
("checks_pending_at", "INTEGER"),
|
|
318
|
+
("verdict", "TEXT"),
|
|
311
319
|
),
|
|
312
320
|
}
|
|
313
321
|
|
|
@@ -779,21 +787,79 @@ class State:
|
|
|
779
787
|
(repo, number),
|
|
780
788
|
).fetchone()
|
|
781
789
|
|
|
782
|
-
def read_grants(
|
|
783
|
-
""
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
+
def read_grants(
|
|
791
|
+
self, repo: "str | None" = None, number: "int | None" = None
|
|
792
|
+
) -> list[dict]:
|
|
793
|
+
"""Grant rows, newest first (like every reader here) — one PR's when
|
|
794
|
+
`repo`/`number` are given, every PR's when they are not.
|
|
795
|
+
|
|
796
|
+
The unfiltered form exists for the loop-events emitter (issue #112),
|
|
797
|
+
which derives one `grant` event per honoured ack: grants are the
|
|
798
|
+
rarest rows in this ledger (one per operator comment, ever), so the
|
|
799
|
+
full read is bounded by how often a human types the ack grammar. The
|
|
800
|
+
per-PR form keeps its original callers unchanged.
|
|
801
|
+
|
|
802
|
+
A PARTIAL filter is refused (PR #113 round 2, nit): `repo` without
|
|
803
|
+
`number` would otherwise fall through to the unfiltered read and
|
|
804
|
+
answer with every PR's grants — a superset, not an error, which is
|
|
805
|
+
the quiet kind of wrong. The paired and unfiltered forms are the
|
|
806
|
+
only two forms.
|
|
790
807
|
"""
|
|
791
|
-
|
|
808
|
+
if (repo is None) != (number is None):
|
|
809
|
+
raise ValueError(
|
|
810
|
+
"read_grants takes repo AND number (one PR) or neither "
|
|
811
|
+
"(every PR) — a partial filter would silently read every PR"
|
|
812
|
+
)
|
|
813
|
+
sql = (
|
|
792
814
|
"SELECT repo, number, comment_id, author, rounds, granted_at "
|
|
793
|
-
"FROM grants
|
|
794
|
-
|
|
815
|
+
"FROM grants"
|
|
816
|
+
)
|
|
817
|
+
params: tuple = ()
|
|
818
|
+
if repo is not None and number is not None:
|
|
819
|
+
sql += " WHERE repo=? AND number=?"
|
|
820
|
+
params = (repo, number)
|
|
821
|
+
sql += " ORDER BY granted_at DESC, rowid DESC"
|
|
822
|
+
return self._read_rows(sql, None, params)
|
|
823
|
+
|
|
824
|
+
# -- loop-events readers (issue #112) ----------------------------------
|
|
825
|
+
#
|
|
826
|
+
# Full-table reads for the telemetry emitter, which derives events from
|
|
827
|
+
# the ledger at end of pass and filters by timestamp itself. Each is a
|
|
828
|
+
# plain projection, newest first like every reader here; none takes a
|
|
829
|
+
# `limit`, because the emitter's bound is its own watermark and a SQL
|
|
830
|
+
# truncation would silently drop exactly the oldest rows a fresh daemon's
|
|
831
|
+
# backfill exists to send.
|
|
832
|
+
|
|
833
|
+
def read_reaps(self) -> list[dict]:
|
|
834
|
+
"""Reap rows (one per session the sweep killed), newest first."""
|
|
835
|
+
return self._read_rows(
|
|
836
|
+
"SELECT session, reaped_at FROM reaps "
|
|
837
|
+
"ORDER BY reaped_at DESC, rowid DESC",
|
|
838
|
+
None,
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
def read_stability_notices(self) -> list[dict]:
|
|
842
|
+
"""Stability-notice rows (one per PR, the guard's memory), newest
|
|
843
|
+
first. The emitter joins these onto `stability:` pings for the
|
|
844
|
+
rcRounds/grantsSeen payload; the row is REPLACEd per episode, so the
|
|
845
|
+
join reads the newest episode's numbers — which is also what the
|
|
846
|
+
guard itself decides from."""
|
|
847
|
+
return self._read_rows(
|
|
848
|
+
"SELECT repo, number, round, rc_rounds, grants_seen, noticed_at "
|
|
849
|
+
"FROM stability_notices ORDER BY noticed_at DESC, number DESC",
|
|
850
|
+
None,
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
def read_spawn_checks_holds(self) -> list[dict]:
|
|
854
|
+
"""Pre-spawn CI holds currently in the ledger, newest first. Rows are
|
|
855
|
+
deleted when their wait ends (see `clear_spawn_checks_hold`), so this
|
|
856
|
+
is the waits in flight plus the residue of rounds that never started
|
|
857
|
+
— the emitter reports a hold while it exists, and a hold that begins
|
|
858
|
+
and ends between two passes is simply not observed (best-effort)."""
|
|
859
|
+
return self._read_rows(
|
|
860
|
+
"SELECT repo, number, round, head_sha, first_at "
|
|
861
|
+
"FROM spawn_checks_holds ORDER BY first_at DESC, number DESC",
|
|
795
862
|
None,
|
|
796
|
-
(repo, number),
|
|
797
863
|
)
|
|
798
864
|
|
|
799
865
|
# -- native verdict posts ----------------------------------------------
|
|
@@ -1034,16 +1100,24 @@ class State:
|
|
|
1034
1100
|
return int(row["n"]) if row else 0
|
|
1035
1101
|
|
|
1036
1102
|
def record_verdict_post(
|
|
1037
|
-
self, repo: str, number: int, round_: int, review_url: str
|
|
1103
|
+
self, repo: str, number: int, round_: int, review_url: str,
|
|
1104
|
+
verdict: "str | None" = None,
|
|
1038
1105
|
) -> None:
|
|
1039
1106
|
"""Mark the round's native verdict as landed. Bookkeeping and evidence
|
|
1040
1107
|
only: GitHub's reviews list stays the authority on whether a native
|
|
1041
1108
|
verdict exists, so a row lost behind the daemon's back costs one
|
|
1042
|
-
duplicate post, never a round that silently counts as closed.
|
|
1109
|
+
duplicate post, never a round that silently counts as closed.
|
|
1110
|
+
|
|
1111
|
+
`verdict` is the round's parsed envelope verdict ('approve' or
|
|
1112
|
+
'request_changes'), remembered for the loop-events emitter (issue
|
|
1113
|
+
#112), which reports rounds long after the envelope read that knew the
|
|
1114
|
+
answer. Optional so existing callers and tests keep their shape; None
|
|
1115
|
+
leaves the column NULL and the emitter omits the field."""
|
|
1043
1116
|
self._db.execute(
|
|
1044
|
-
"UPDATE verdict_posts SET posted_at = ?, review_url = ?,
|
|
1117
|
+
"UPDATE verdict_posts SET posted_at = ?, review_url = ?, "
|
|
1118
|
+
"verdict = ?, last_error = NULL "
|
|
1045
1119
|
"WHERE repo=? AND number=? AND round=?",
|
|
1046
|
-
(int(time.time()), review_url, repo, number, round_),
|
|
1120
|
+
(int(time.time()), review_url, verdict, repo, number, round_),
|
|
1047
1121
|
)
|
|
1048
1122
|
self._db.commit()
|
|
1049
1123
|
|
|
@@ -1052,7 +1126,8 @@ class State:
|
|
|
1052
1126
|
return self._read_rows(
|
|
1053
1127
|
"SELECT repo, number, round, first_seen_at, head_sha, attempts, "
|
|
1054
1128
|
"last_attempt_at, posted_at, abandoned_at, checks_held_at, "
|
|
1055
|
-
"checks_held_state, checks_pending_at, review_url,
|
|
1129
|
+
"checks_held_state, checks_pending_at, verdict, review_url, "
|
|
1130
|
+
"last_error "
|
|
1056
1131
|
"FROM verdict_posts "
|
|
1057
1132
|
"ORDER BY first_seen_at DESC, number DESC",
|
|
1058
1133
|
limit,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.28.0
|
|
@@ -7,9 +7,11 @@ setup.py
|
|
|
7
7
|
src/main/alissa/tools/github/revloop/__init__.py
|
|
8
8
|
src/main/alissa/tools/github/revloop/__main__.py
|
|
9
9
|
src/main/alissa/tools/github/revloop/alissa.py
|
|
10
|
+
src/main/alissa/tools/github/revloop/alissa_client.py
|
|
10
11
|
src/main/alissa/tools/github/revloop/config.py
|
|
11
12
|
src/main/alissa/tools/github/revloop/ghclient.py
|
|
12
13
|
src/main/alissa/tools/github/revloop/loop.py
|
|
14
|
+
src/main/alissa/tools/github/revloop/loop_events.py
|
|
13
15
|
src/main/alissa/tools/github/revloop/proc.py
|
|
14
16
|
src/main/alissa/tools/github/revloop/prreview.py
|
|
15
17
|
src/main/alissa/tools/github/revloop/state.py
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.27.0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|