alissa-tools-github-revloop 0.16.7__tar.gz → 0.16.8__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.
Files changed (32) hide show
  1. {alissa_tools_github_revloop-0.16.7/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.16.8}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.8}/src/main/alissa/tools/github/revloop/loop.py +36 -15
  3. {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.8}/src/main/alissa/tools/github/revloop/state.py +81 -21
  4. alissa_tools_github_revloop-0.16.8/src/main/alissa/tools/github/revloop/version +1 -0
  5. {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.8/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  6. alissa_tools_github_revloop-0.16.7/src/main/alissa/tools/github/revloop/version +0 -1
  7. {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.8}/LICENSE +0 -0
  8. {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.8}/MANIFEST.in +0 -0
  9. {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.8}/NOTICE +0 -0
  10. {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.8}/README.md +0 -0
  11. {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.8}/requirements.txt +0 -0
  12. {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.8}/setup.cfg +0 -0
  13. {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.8}/setup.py +0 -0
  14. {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.8}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  15. {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.8}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
  16. {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.8}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
  17. {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.8}/src/main/alissa/tools/github/revloop/config.py +0 -0
  18. {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.8}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  19. {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.8}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  20. {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.8}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  21. {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.8}/src/main/alissa/tools/github/revloop/version.py +0 -0
  22. {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.8}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  23. {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.8}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  24. {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.8}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  25. {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.8}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
  26. {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.8}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
  27. {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.8}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
  28. {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.8}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  29. {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.8}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
  30. {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.8}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  31. {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.8}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  32. {alissa_tools_github_revloop-0.16.7 → alissa_tools_github_revloop-0.16.8}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.16.7
3
+ Version: 0.16.8
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -181,8 +181,9 @@ CHECKS_UNSETTLED_LEAD = (
181
181
  "concluded.**\n\n"
182
182
  "{detail}\n\n"
183
183
  "This round's verdict was held for {waited} min waiting for the head's "
184
- "checks to settle ({bound} min bound) and they did not, so it is recorded "
185
- "as a comment: an approve would claim a head this loop never saw go green. "
184
+ "checks to settle ({bound} min bound){total_note} and they did not, so it is "
185
+ "recorded as a comment: an approve would claim a head this loop never saw go "
186
+ "green. "
186
187
  "Nothing about the review itself changed — the verdict below is the round's "
187
188
  "own.\n\n"
188
189
  "Submitting this review consumes the pending review request, so the daemon "
@@ -221,6 +222,13 @@ CHECKS_UNSETTLED_PAGE = (
221
222
  "No label was touched and no further round is queued."
222
223
  )
223
224
 
225
+ # Appended to `{bound}` above only when the hold was PROMOTED -- an unreadable
226
+ # wait that became a genuine pending one restarts the clock, so the bound the
227
+ # operator configured applies per condition and the round can be held up to
228
+ # twice it. Saying "held 30 min (30 min bound)" after 60 real minutes is the
229
+ # report being wrong about the one number an operator tunes.
230
+ CHECKS_TOTAL_HELD = ", {total} min in total across both waits,"
231
+
224
232
  # The `{detail}` above, per reason the rollup did not settle.
225
233
  CHECKS_STILL_RUNNING = "Still running at the bound: {names}."
226
234
  CHECKS_UNREADABLE = (
@@ -1165,22 +1173,29 @@ class ReviewWatcher:
1165
1173
  # never "restart whenever the state changes" -- a reader flapping between
1166
1174
  # the two would then push the bound out forever, which is precisely the
1167
1175
  # unbounded hold this bound exists to prevent.
1168
- held_at, held_state = self.state.checks_hold(pr.full_name, pr.number, round_)
1169
- promoted = held_state == CHECKS_UNKNOWN and rollup.state == CHECKS_PENDING
1170
- if held_at is None or promoted:
1171
- held_at = self.state.record_checks_hold(
1176
+ hold = self.state.checks_hold(pr.full_name, pr.number, round_)
1177
+ promoted = hold.condition == CHECKS_UNKNOWN and rollup.state == CHECKS_PENDING
1178
+ if hold.since is None or promoted:
1179
+ self.state.record_checks_hold(
1172
1180
  pr.full_name, pr.number, round_, rollup.state
1173
1181
  )
1174
- waited = max(time.time() - held_at, 0)
1182
+ hold = self.state.checks_hold(pr.full_name, pr.number, round_)
1183
+ # Two numbers, both reported: `waited` is the wait THIS condition has had
1184
+ # and is what the bound applies to; `held` is how long the round has been
1185
+ # held at all. They differ by up to a full bound once a hold has been
1186
+ # promoted, so a report that shows only the first tells an operator who
1187
+ # set 30 minutes that a 60-minute hold waited 30.
1188
+ waited = max(time.time() - (hold.since or time.time()), 0)
1189
+ held = max(time.time() - (hold.first_at or time.time()), 0)
1175
1190
  bound = self.config.checks_wait_seconds
1176
1191
  if waited < bound:
1177
1192
  # One line per poll, one activity note per held round; see
1178
1193
  # checks_hold_kind.
1179
1194
  log.info(
1180
- "%s round %d: holding its %s — CI rollup at %s is %s (%dm of the "
1181
- "%dm bound waited)",
1195
+ "%s round %d: holding its %s — CI rollup at %s is %s (%dm on this "
1196
+ "condition of the %dm bound; %dm held in total)",
1182
1197
  pr.slug, round_, VERDICT_APPROVE, judged[:8], rollup.summary,
1183
- waited // 60, bound // 60,
1198
+ waited // 60, bound // 60, held // 60,
1184
1199
  )
1185
1200
  self._note_checks_hold(pr, round_, judged, rollup)
1186
1201
  return ChecksGate(
@@ -1188,7 +1203,8 @@ class ReviewWatcher:
1188
1203
  Action.AWAITING_POST,
1189
1204
  f"round {round_} holds its {VERDICT_APPROVE} — the CI rollup "
1190
1205
  f"at {judged[:8]} is {rollup.summary}; "
1191
- f"{int((bound - waited) // 60)}m of the wait bound left",
1206
+ f"{int((bound - waited) // 60)}m of the wait bound left "
1207
+ f"({int(held // 60)}m held in total)",
1192
1208
  round_,
1193
1209
  task_ref=task.ref,
1194
1210
  ),
@@ -1196,10 +1212,10 @@ class ReviewWatcher:
1196
1212
  )
1197
1213
 
1198
1214
  log.warning(
1199
- "%s round %d: the CI rollup at %s is still %s after %dm — recording "
1200
- "the %s envelope as a %s review, never an APPROVE on an unverified "
1201
- "head",
1202
- pr.slug, round_, judged[:8], rollup.summary, waited // 60,
1215
+ "%s round %d: the CI rollup at %s is still %s after %dm on this "
1216
+ "condition (%dm held in total) — recording the %s envelope as a %s "
1217
+ "review, never an APPROVE on an unverified head",
1218
+ pr.slug, round_, judged[:8], rollup.summary, waited // 60, held // 60,
1203
1219
  VERDICT_APPROVE, EVENT_COMMENT,
1204
1220
  )
1205
1221
  detail = (
@@ -1216,6 +1232,11 @@ class ReviewWatcher:
1216
1232
  detail=detail,
1217
1233
  waited=int(waited // 60),
1218
1234
  bound=int(bound // 60),
1235
+ total_note=(
1236
+ CHECKS_TOTAL_HELD.format(total=int(held // 60))
1237
+ if hold.promoted
1238
+ else ""
1239
+ ),
1219
1240
  ),
1220
1241
  state=rollup.state,
1221
1242
  detail=detail,
@@ -29,6 +29,7 @@ import json
29
29
  import sqlite3
30
30
  import time
31
31
  from collections.abc import Iterable
32
+ from dataclasses import dataclass
32
33
  from pathlib import Path
33
34
 
34
35
  # Poll-snapshot retention: the newest N rows are kept, older ones pruned on
@@ -108,10 +109,9 @@ CREATE TABLE IF NOT EXISTS verdict_posts (
108
109
  -- then released rather than held open forever; see loop._abandon_verdict.
109
110
  abandoned_at INTEGER,
110
111
  -- When this round's APPROVE was FIRST held back because the judged head's
111
- -- CI rollup had not settled. The wait bound is measured from here, so a
112
- -- rollup that never concludes degrades the verdict to a comment at a fixed
113
- -- distance from the first observation rather than from whenever the last
114
- -- poll happened to land. NULL means the gate never held this round.
112
+ -- CI rollup had not settled -- and it is never overwritten afterwards, so
113
+ -- "how long has this round really been held?" always has an answer. NULL
114
+ -- means the gate never held this round.
115
115
  checks_held_at INTEGER,
116
116
  -- WHICH unsettled condition that stamp belongs to ('pending' -- checks are
117
117
  -- genuinely running -- or 'unknown' -- the rollup could not be read). The
@@ -121,6 +121,13 @@ CREATE TABLE IF NOT EXISTS verdict_posts (
121
121
  -- 'pending' one. The policy lives in loop._gate_on_checks; this column is
122
122
  -- what lets it be decided from the ledger instead of from memory.
123
123
  checks_held_state TEXT,
124
+ -- When that promotion happened: the stamp the bound is measured from once
125
+ -- the wait is on checks that are genuinely running. Separate from
126
+ -- `checks_held_at` rather than replacing it, because the two answer
127
+ -- different questions and a report that conflates them says a promoted hold
128
+ -- waited one bound when it waited two. NULL until (and unless) the promotion
129
+ -- happens; the bound then reads `checks_pending_at or checks_held_at`.
130
+ checks_pending_at INTEGER,
124
131
  review_url TEXT,
125
132
  last_error TEXT,
126
133
  PRIMARY KEY (repo, number, round)
@@ -163,10 +170,42 @@ _ADDED_COLUMNS = {
163
170
  "verdict_posts": (
164
171
  ("checks_held_at", "INTEGER"),
165
172
  ("checks_held_state", "TEXT"),
173
+ ("checks_pending_at", "INTEGER"),
166
174
  ),
167
175
  }
168
176
 
169
177
 
178
+ @dataclass(frozen=True)
179
+ class ChecksHold:
180
+ """One round's CI hold, as the ledger remembers it.
181
+
182
+ Two stamps, because the gate has two honest numbers to report and they can
183
+ differ by a whole wait bound:
184
+
185
+ * `first_at` -- when the round was first held at all, on whatever condition;
186
+ * `pending_at` -- when an unreadable hold was promoted to a genuinely
187
+ pending one, which restarts the clock the bound is measured from (see
188
+ loop._gate_on_checks for why exactly once).
189
+
190
+ `since` is the one the bound uses. `first_at` is the one an operator means by
191
+ "how long has this been held?", and reporting only `since` after a promotion
192
+ understates it by up to the full bound.
193
+ """
194
+
195
+ first_at: int | None = None
196
+ condition: str | None = None
197
+ pending_at: int | None = None
198
+
199
+ @property
200
+ def since(self) -> int | None:
201
+ """The stamp the wait bound is measured from."""
202
+ return self.pending_at or self.first_at
203
+
204
+ @property
205
+ def promoted(self) -> bool:
206
+ return self.pending_at is not None
207
+
208
+
170
209
  class State:
171
210
  def __init__(self, path: Path, *, read_only: bool = False):
172
211
  """Open the ledger. The daemon opens it read-write (creating the file,
@@ -509,38 +548,59 @@ class State:
509
548
  row = self.get_verdict_post(repo, number, round_)
510
549
  return int(row["attempts"]) if row else 0
511
550
 
512
- def checks_hold(
513
- self, repo: str, number: int, round_: int
514
- ) -> "tuple[int | None, str | None]":
515
- """When this round's approve was first held on CI, and on WHICH
516
- condition -- (None, None) if it has never been held.
551
+ def checks_hold(self, repo: str, number: int, round_: int) -> "ChecksHold":
552
+ """This round's CI hold: when it began, what it is waiting on, and when
553
+ that became a genuine `pending` -- an all-None ChecksHold if it has never
554
+ been held.
517
555
 
518
556
  A read, deliberately: whether an existing stamp still applies is a
519
557
  policy question about CI (see loop._gate_on_checks), and this table's job
520
- is to remember the answer, not to make it.
558
+ is to remember the answer, not to make it. It remembers BOTH stamps
559
+ because the two answer different questions -- `since` bounds the wait,
560
+ `first_at` is how long the round has really been held -- and a report
561
+ that conflates them tells an operator a promoted hold waited 30 minutes
562
+ when it waited 60.
521
563
  """
522
564
  row = self.get_verdict_post(repo, number, round_)
523
565
  if row is None or not row["checks_held_at"]:
524
- return None, None
525
- return int(row["checks_held_at"]), (row["checks_held_state"] or None)
566
+ return ChecksHold()
567
+ pending_at = row["checks_pending_at"]
568
+ return ChecksHold(
569
+ first_at=int(row["checks_held_at"]),
570
+ condition=(row["checks_held_state"] or None),
571
+ pending_at=int(pending_at) if pending_at else None,
572
+ )
526
573
 
527
574
  def record_checks_hold(
528
575
  self, repo: str, number: int, round_: int, condition: str
529
576
  ) -> int:
530
- """Start (or restart) the hold clock for this round; return the stamp.
577
+ """Record that this round is held on `condition`; return the stamp the
578
+ bound is measured from.
531
579
 
532
580
  The caller decides WHEN to call this -- once when the hold begins, and at
533
581
  most once more when an unreadable hold is promoted to a genuinely pending
534
582
  one, because the bound is defined against the first observation of the
535
- condition actually being waited on. Unconditional here so that policy
536
- stays in one place instead of being half-expressed as a WHERE clause.
583
+ condition actually being waited on (loop._gate_on_checks owns that rule).
584
+
585
+ The promotion NO LONGER overwrites `checks_held_at`: it fills
586
+ `checks_pending_at` instead, so the ledger keeps when the round was first
587
+ held as well as when its current wait started. Nothing about the bound
588
+ changes; what changes is that the daemon can now say both numbers out
589
+ loud, which the operator-facing report needs.
537
590
  """
538
591
  now = int(time.time())
539
- self._db.execute(
540
- "UPDATE verdict_posts SET checks_held_at = ?, checks_held_state = ? "
541
- "WHERE repo=? AND number=? AND round=?",
542
- (now, condition, repo, number, round_),
543
- )
592
+ if self.checks_hold(repo, number, round_).first_at is None:
593
+ self._db.execute(
594
+ "UPDATE verdict_posts SET checks_held_at = ?, checks_held_state = ? "
595
+ "WHERE repo=? AND number=? AND round=?",
596
+ (now, condition, repo, number, round_),
597
+ )
598
+ else:
599
+ self._db.execute(
600
+ "UPDATE verdict_posts SET checks_pending_at = ?, "
601
+ "checks_held_state = ? WHERE repo=? AND number=? AND round=?",
602
+ (now, condition, repo, number, round_),
603
+ )
544
604
  self._db.commit()
545
605
  return now
546
606
 
@@ -603,7 +663,7 @@ class State:
603
663
  return self._read_rows(
604
664
  "SELECT repo, number, round, first_seen_at, head_sha, attempts, "
605
665
  "last_attempt_at, posted_at, abandoned_at, checks_held_at, "
606
- "checks_held_state, review_url, last_error "
666
+ "checks_held_state, checks_pending_at, review_url, last_error "
607
667
  "FROM verdict_posts "
608
668
  "ORDER BY first_seen_at DESC, number DESC",
609
669
  limit,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.16.7
3
+ Version: 0.16.8
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera