alissa-tools-github-revloop 0.16.3__tar.gz → 0.16.4__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.16.3/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.16.4}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.4}/src/main/alissa/tools/github/revloop/config.py +43 -14
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.4}/src/main/alissa/tools/github/revloop/ghclient.py +28 -19
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.4}/src/main/alissa/tools/github/revloop/loop.py +23 -11
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.4}/src/main/alissa/tools/github/revloop/state.py +23 -2
- alissa_tools_github_revloop-0.16.4/src/main/alissa/tools/github/revloop/version +1 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.4}/src/main/alissa/tools/github/revloop/webui/page.py +2 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.4/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_revloop-0.16.3/src/main/alissa/tools/github/revloop/version +0 -1
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.4}/LICENSE +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.4}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.4}/NOTICE +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.4}/README.md +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.4}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.4}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.4}/setup.py +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.4}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.4}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.4}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.4}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.4}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.4}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.4}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.4}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.4}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.4}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.4}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.4}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.4}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.4}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.4}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.4}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
|
@@ -23,6 +23,10 @@ from typing import Any, Mapping
|
|
|
23
23
|
# A POSIX-ish environment variable name -- what `reviewer_token_env` must be.
|
|
24
24
|
_ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
25
25
|
|
|
26
|
+
# The per-character half of the same rule, used to name the offending
|
|
27
|
+
# characters in an error without echoing the value (see _describe).
|
|
28
|
+
_ENV_CHAR_RE = re.compile(r"[A-Za-z0-9_]")
|
|
29
|
+
|
|
26
30
|
# ...which, on its own, accepts every GitHub credential format: `ghp_`, `gho_`,
|
|
27
31
|
# `ghu_`, `ghs_`, `ghr_` and `github_pat_` tokens are `[A-Za-z0-9_]` throughout
|
|
28
32
|
# and therefore valid identifiers. So the shape has to be rejected explicitly,
|
|
@@ -30,9 +34,13 @@ _ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
|
30
34
|
# its variable's NAME belongs -- sails through.
|
|
31
35
|
_TOKEN_SHAPE_RE = re.compile(r"^(gh[pousr]_|github_pat_)", re.IGNORECASE)
|
|
32
36
|
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
|
|
37
|
+
# Generous on purpose. A tighter ceiling (32 was tried) rejects names an
|
|
38
|
+
# operator can plausibly write -- `REVLOOP_REVIEWER_GITHUB_TOKEN_ENV` is 33 --
|
|
39
|
+
# and this bound is NOT what catches credentials: MIN_SECRET_RUN_LENGTH is,
|
|
40
|
+
# and it catches a 40-character hex token on its shape whatever the ceiling
|
|
41
|
+
# says. So the ceiling exists only to refuse the obviously absurd, and it is
|
|
42
|
+
# deliberately kept OUT of `_is_credential_shaped` (see there).
|
|
43
|
+
MAX_ENV_NAME_LENGTH = 40
|
|
36
44
|
|
|
37
45
|
# Length is not the whole discriminator, because a secret can be short enough
|
|
38
46
|
# to fit under the ceiling. The shape is: a long, undifferentiated run of
|
|
@@ -47,10 +55,14 @@ def _is_credential_shaped(value: str) -> bool:
|
|
|
47
55
|
A heuristic, and deliberately one that errs toward accusing: the cost of a
|
|
48
56
|
false positive is renaming a variable, and the cost of a false negative is
|
|
49
57
|
a live credential sitting in a config file on a shared volume.
|
|
58
|
+
|
|
59
|
+
Length is deliberately NOT one of the clauses. It was, and it made a merely
|
|
60
|
+
LONG name get classified as a credential -- rejected, redacted, and told to
|
|
61
|
+
rotate itself. Over-length is a separate refusal with its own message; only
|
|
62
|
+
an actual credential SHAPE drives redaction.
|
|
50
63
|
"""
|
|
51
64
|
return bool(
|
|
52
65
|
_TOKEN_SHAPE_RE.match(value)
|
|
53
|
-
or len(value) > MAX_ENV_NAME_LENGTH
|
|
54
66
|
or (
|
|
55
67
|
len(value) >= MIN_SECRET_RUN_LENGTH
|
|
56
68
|
and "_" not in value
|
|
@@ -60,18 +72,30 @@ def _is_credential_shaped(value: str) -> bool:
|
|
|
60
72
|
|
|
61
73
|
|
|
62
74
|
def _describe(value: str) -> str:
|
|
63
|
-
"""
|
|
75
|
+
"""Say what is wrong with a rejected value without reprinting a secret.
|
|
64
76
|
|
|
65
77
|
`__main__` prints this to stderr as `config error: …`, straight into the
|
|
66
|
-
container log, so a pasted
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
78
|
+
container log, so a pasted credential must never be echoed there. But the
|
|
79
|
+
other way to fail this check is an ordinary typo
|
|
80
|
+
(`REVLOOP-REVIEWER-GH-TOKEN`), and there the value IS the diagnostic.
|
|
81
|
+
|
|
82
|
+
Redacting everything loses the typo case; echoing whatever the GitHub-shape
|
|
83
|
+
heuristic does not recognise hands back the container-log disclosure for
|
|
84
|
+
every non-GitHub secret (`sk-proj-…`, `xoxb-…`, `glpat-…` all carry
|
|
85
|
+
punctuation and match no clause above). So neither branch prints the value:
|
|
86
|
+
what an operator needs is WHICH CHARACTERS were rejected, and those can be
|
|
87
|
+
named with their positions and nothing else.
|
|
71
88
|
"""
|
|
72
89
|
if _is_credential_shaped(value):
|
|
73
|
-
return f
|
|
74
|
-
|
|
90
|
+
return f"a {len(value)}-character value that looks like a credential"
|
|
91
|
+
offenders = sorted({c for c in value if not _ENV_CHAR_RE.match(c)})
|
|
92
|
+
if offenders:
|
|
93
|
+
where = ", ".join(
|
|
94
|
+
f"{c!r} at {', '.join(str(i) for i, ch in enumerate(value) if ch == c)}"
|
|
95
|
+
for c in offenders
|
|
96
|
+
)
|
|
97
|
+
return f"a {len(value)}-character value containing {where}"
|
|
98
|
+
return f"a {len(value)}-character value"
|
|
75
99
|
|
|
76
100
|
|
|
77
101
|
# What to do when a PR has a pending review request but no matching Alissa
|
|
@@ -322,7 +346,11 @@ class Config:
|
|
|
322
346
|
# rather than exposed. So the check is two-sided: it must LOOK like
|
|
323
347
|
# a name, and it must not look like a credential. The value itself
|
|
324
348
|
# is never echoed back (see _redact).
|
|
325
|
-
if
|
|
349
|
+
if (
|
|
350
|
+
not _ENV_NAME_RE.match(token_env)
|
|
351
|
+
or _is_credential_shaped(token_env)
|
|
352
|
+
or len(token_env) > MAX_ENV_NAME_LENGTH
|
|
353
|
+
):
|
|
326
354
|
rotate = (
|
|
327
355
|
" If that is a credential, rotate it: it is now in a config file."
|
|
328
356
|
if _is_credential_shaped(token_env)
|
|
@@ -330,7 +358,8 @@ class Config:
|
|
|
330
358
|
)
|
|
331
359
|
raise ValueError(
|
|
332
360
|
f"reviewer_token_env must be an environment variable NAME "
|
|
333
|
-
f"(e.g. 'REVLOOP_REVIEWER_GH_TOKEN')
|
|
361
|
+
f"(e.g. 'REVLOOP_REVIEWER_GH_TOKEN') of at most "
|
|
362
|
+
f"{MAX_ENV_NAME_LENGTH} characters, not a value or a "
|
|
334
363
|
f"token — got {_describe(token_env)}.{rotate}"
|
|
335
364
|
)
|
|
336
365
|
|
|
@@ -41,11 +41,15 @@ SUBMITTED_STATES = {"APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"}
|
|
|
41
41
|
PER_PAGE = 100
|
|
42
42
|
COMMENT_PAGE_LIMIT = 20
|
|
43
43
|
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
|
|
44
|
+
# GitHub's OWN cap on the pull-request commits endpoint: it "lists a maximum of
|
|
45
|
+
# 250 commits" and refers callers with more to the repository commits endpoint.
|
|
46
|
+
# That, not a page count of ours, is where absence stops being provable -- a
|
|
47
|
+
# 250-entry answer looks complete (the last page is short) while saying nothing
|
|
48
|
+
# about commit 251. And the direction is hostile: the endpoint returns commits
|
|
49
|
+
# OLDEST first, so on a longer PR the 250 returned are the oldest and a recent
|
|
50
|
+
# `judged` head is absent from every read. Unguarded, the "is the pinned commit
|
|
51
|
+
# gone?" probe would answer yes on every large PR and abandon real verdicts.
|
|
52
|
+
PR_COMMIT_CAP = 250
|
|
49
53
|
|
|
50
54
|
# Environment variables `gh` reads a token from. Both are cleared before an
|
|
51
55
|
# explicitly-routed call, so an inherited implementer credential cannot win by
|
|
@@ -432,14 +436,15 @@ class GitHub:
|
|
|
432
436
|
this" from "this can never succeed", so it is worth a call, but only
|
|
433
437
|
there; nothing on the common path reads it.
|
|
434
438
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
439
|
+
Raises `TruncatedListing` rather than returning a possibly-incomplete
|
|
440
|
+
list, because the caller uses ABSENCE as evidence and a short answer
|
|
441
|
+
cannot support that. The bound is GitHub's own 250 (PR_COMMIT_CAP), not
|
|
442
|
+
a page count of ours: at 250 the last page is short and the listing
|
|
443
|
+
looks complete while saying nothing about the commits past it.
|
|
440
444
|
"""
|
|
441
445
|
out: list[str] = []
|
|
442
|
-
|
|
446
|
+
page = 1
|
|
447
|
+
while True:
|
|
443
448
|
data = (
|
|
444
449
|
self._api(
|
|
445
450
|
"-X",
|
|
@@ -453,16 +458,20 @@ class GitHub:
|
|
|
453
458
|
or []
|
|
454
459
|
)
|
|
455
460
|
out.extend(str(c.get("sha") or "") for c in data)
|
|
461
|
+
if len(out) >= PR_COMMIT_CAP:
|
|
462
|
+
log.warning(
|
|
463
|
+
"%s/%s#%d reached GitHub's %d-commit listing cap — the "
|
|
464
|
+
"commit list is not complete, so a missing SHA cannot be "
|
|
465
|
+
"read as absent",
|
|
466
|
+
owner, repo, number, PR_COMMIT_CAP,
|
|
467
|
+
)
|
|
468
|
+
raise TruncatedListing(
|
|
469
|
+
f"{owner}/{repo}#{number} reached GitHub's {PR_COMMIT_CAP}-commit "
|
|
470
|
+
f"listing cap; absence cannot be proven from it"
|
|
471
|
+
)
|
|
456
472
|
if len(data) < PER_PAGE:
|
|
457
473
|
return out
|
|
458
|
-
|
|
459
|
-
"%s/%s#%d has more than %d commits — the list is truncated, so a "
|
|
460
|
-
"missing SHA cannot be read as absent",
|
|
461
|
-
owner, repo, number, COMMIT_PAGE_LIMIT * PER_PAGE,
|
|
462
|
-
)
|
|
463
|
-
raise TruncatedListing(
|
|
464
|
-
f"{owner}/{repo}#{number} has more than {COMMIT_PAGE_LIMIT * PER_PAGE} commits"
|
|
465
|
-
)
|
|
474
|
+
page += 1
|
|
466
475
|
|
|
467
476
|
def my_reviews(self, owner: str, repo: str, number: int) -> list[Review]:
|
|
468
477
|
"""My substantive submitted reviews, oldest first -- one per round.
|
|
@@ -508,6 +508,11 @@ class Action(str, Enum):
|
|
|
508
508
|
# window, or the post failed and is being retried. Either way the round is
|
|
509
509
|
# NOT closed and nothing downstream of it runs.
|
|
510
510
|
AWAITING_POST = "awaiting-post"
|
|
511
|
+
# The round's verdict can never be posted -- the head it judged is gone
|
|
512
|
+
# from the PR. Distinct from AWAITING_POST on purpose: that one retries,
|
|
513
|
+
# this one has given up, and the poll snapshot and console aggregate the
|
|
514
|
+
# action rather than the reason.
|
|
515
|
+
ABANDONED = "abandoned"
|
|
511
516
|
|
|
512
517
|
|
|
513
518
|
@dataclass(frozen=True)
|
|
@@ -617,16 +622,21 @@ class ReviewWatcher:
|
|
|
617
622
|
# purpose: an approve envelope with no native APPROVE behind it would
|
|
618
623
|
# otherwise close the loop with no verdict of record on GitHub and the
|
|
619
624
|
# review request still dangling -- the studio #298 failure.
|
|
620
|
-
# ...
|
|
621
|
-
# round judged is gone from the PR. There is then no
|
|
622
|
-
# record that verdict against, and holding the round
|
|
623
|
-
# the PR outside the loop forever; the round is
|
|
624
|
-
# fresh one is owed against the new head
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
625
|
+
# ...discounting rounds whose post was ABANDONED, which happens only
|
|
626
|
+
# when the head the round judged is gone from the PR. There is then no
|
|
627
|
+
# commit left to record that verdict against, and holding the round
|
|
628
|
+
# open would strand the PR outside the loop forever; the round is
|
|
629
|
+
# released instead and a fresh one is owed against the new head (see
|
|
630
|
+
# _abandon_verdict).
|
|
631
|
+
#
|
|
632
|
+
# The discount is a SUBTRACTION, not just a check on the newest round.
|
|
633
|
+
# An abandoned round's envelope stays on the task forever while its
|
|
634
|
+
# review record never exists, so it leaves a permanent hole between the
|
|
635
|
+
# two counts -- and an uncorrected hole reads, on the NEXT round, as
|
|
636
|
+
# "that round has no native verdict", producing a duplicate post over a
|
|
637
|
+
# session that closed its own round correctly.
|
|
638
|
+
owed = completed - self.state.abandoned_rounds(pr.full_name, number)
|
|
639
|
+
if task is not None and owed > native:
|
|
630
640
|
# Terminal for this pass either way. On a landed post the review
|
|
631
641
|
# request it consumed drops the PR out of the search, so
|
|
632
642
|
# convergence and the next round belong to the next pass, decided
|
|
@@ -737,6 +747,7 @@ class ReviewWatcher:
|
|
|
737
747
|
pr.full_name, pr.number, round_, self._judged_head(pr, round_)
|
|
738
748
|
)
|
|
739
749
|
attempts = int(row["attempts"])
|
|
750
|
+
# `left` decides; `reason` only ever reaches a caller when left > 0.
|
|
740
751
|
if attempts == 0:
|
|
741
752
|
# The grace window: measured from the first observation, because it
|
|
742
753
|
# is about the round's own session getting its chance, not about
|
|
@@ -907,7 +918,7 @@ class ReviewWatcher:
|
|
|
907
918
|
f"A fresh round is owed against `{pr.head_sha[:8]}`.",
|
|
908
919
|
)
|
|
909
920
|
return Decision(
|
|
910
|
-
Action.
|
|
921
|
+
Action.ABANDONED,
|
|
911
922
|
f"round {round_}'s native verdict was abandoned — {why}",
|
|
912
923
|
round_,
|
|
913
924
|
task_ref=task.ref,
|
|
@@ -2154,6 +2165,7 @@ class ReviewWatcher:
|
|
|
2154
2165
|
reaped=reaped,
|
|
2155
2166
|
posted=counts[Action.POSTED],
|
|
2156
2167
|
awaiting_post=counts[Action.AWAITING_POST],
|
|
2168
|
+
abandoned=counts[Action.ABANDONED],
|
|
2157
2169
|
stages=stages,
|
|
2158
2170
|
)
|
|
2159
2171
|
|
|
@@ -128,6 +128,7 @@ CREATE TABLE IF NOT EXISTS poll_snapshots (
|
|
|
128
128
|
reaped INTEGER NOT NULL,
|
|
129
129
|
posted INTEGER NOT NULL DEFAULT 0,
|
|
130
130
|
awaiting_post INTEGER NOT NULL DEFAULT 0,
|
|
131
|
+
abandoned INTEGER NOT NULL DEFAULT 0,
|
|
131
132
|
stages_json TEXT NOT NULL
|
|
132
133
|
);
|
|
133
134
|
"""
|
|
@@ -140,6 +141,7 @@ CREATE TABLE IF NOT EXISTS poll_snapshots (
|
|
|
140
141
|
_SNAPSHOT_ADDED_COLUMNS = (
|
|
141
142
|
("posted", "INTEGER NOT NULL DEFAULT 0"),
|
|
142
143
|
("awaiting_post", "INTEGER NOT NULL DEFAULT 0"),
|
|
144
|
+
("abandoned", "INTEGER NOT NULL DEFAULT 0"),
|
|
143
145
|
)
|
|
144
146
|
|
|
145
147
|
|
|
@@ -509,6 +511,23 @@ class State:
|
|
|
509
511
|
row = self.get_verdict_post(repo, number, round_)
|
|
510
512
|
return bool(row is not None and row["abandoned_at"])
|
|
511
513
|
|
|
514
|
+
def abandoned_rounds(self, repo: str, number: int) -> int:
|
|
515
|
+
"""How many of this PR's rounds will never have a native verdict.
|
|
516
|
+
|
|
517
|
+
Each one leaves a permanent hole between the envelope count and the
|
|
518
|
+
review count -- the envelope is on the task forever, the review record
|
|
519
|
+
never exists -- so every later comparison of the two has to subtract
|
|
520
|
+
it. Without that the daemon reads the hole as "the newest round has no
|
|
521
|
+
native verdict" and posts a redundant one on the round AFTER each
|
|
522
|
+
abandonment.
|
|
523
|
+
"""
|
|
524
|
+
row = self._db.execute(
|
|
525
|
+
"SELECT COUNT(*) AS n FROM verdict_posts "
|
|
526
|
+
"WHERE repo=? AND number=? AND abandoned_at IS NOT NULL",
|
|
527
|
+
(repo, number),
|
|
528
|
+
).fetchone()
|
|
529
|
+
return int(row["n"]) if row else 0
|
|
530
|
+
|
|
512
531
|
def record_verdict_post(
|
|
513
532
|
self, repo: str, number: int, round_: int, review_url: str
|
|
514
533
|
) -> None:
|
|
@@ -551,6 +570,7 @@ class State:
|
|
|
551
570
|
reaped: int = 0,
|
|
552
571
|
posted: int = 0,
|
|
553
572
|
awaiting_post: int = 0,
|
|
573
|
+
abandoned: int = 0,
|
|
554
574
|
stages: list[dict],
|
|
555
575
|
) -> None:
|
|
556
576
|
"""Append one poll-pass observation, then prune to the newest
|
|
@@ -566,8 +586,8 @@ class State:
|
|
|
566
586
|
"INSERT INTO poll_snapshots "
|
|
567
587
|
"(ts, duration_ms, candidates, spawned, stale_reenqueued, "
|
|
568
588
|
"in_flight, deferred, converged, capped, escalated, skipped, "
|
|
569
|
-
"reaped, posted, awaiting_post, stages_json) "
|
|
570
|
-
"VALUES (
|
|
589
|
+
"reaped, posted, awaiting_post, abandoned, stages_json) "
|
|
590
|
+
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
571
591
|
(
|
|
572
592
|
int(time.time()),
|
|
573
593
|
duration_ms,
|
|
@@ -583,6 +603,7 @@ class State:
|
|
|
583
603
|
reaped,
|
|
584
604
|
posted,
|
|
585
605
|
awaiting_post,
|
|
606
|
+
abandoned,
|
|
586
607
|
json.dumps(stages, separators=(",", ":")),
|
|
587
608
|
),
|
|
588
609
|
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.16.4
|
|
@@ -208,6 +208,8 @@ section.panel {
|
|
|
208
208
|
border-color: var(--status-pending); background: var(--status-pending-bg); }
|
|
209
209
|
.pill.converged, .pill.posted { color: var(--status-in-progress);
|
|
210
210
|
border-color: var(--status-in-progress); background: var(--status-in-progress-bg); }
|
|
211
|
+
.pill.abandoned { color: var(--status-cancelled);
|
|
212
|
+
border-color: var(--status-cancelled); background: var(--status-cancelled-bg); }
|
|
211
213
|
.pill.awaiting-post { color: var(--status-blocked);
|
|
212
214
|
border-color: var(--status-blocked); background: var(--status-blocked-bg); }
|
|
213
215
|
.pill.capped { color: var(--status-cancelled);
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.16.3
|
|
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
|