hubkit-cli 0.2.1__tar.gz → 0.2.2__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.
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/CHANGELOG.md +5 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/PKG-INFO +1 -1
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/pyproject.toml +1 -1
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/routed.py +314 -83
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/run_reverts.py +1 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/test_review_chain.py +13 -1
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/.gitignore +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/CLAUDE.md +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/CONTRIBUTING.md +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/LICENSE +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/README.md +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/SECURITY.md +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/SKILL.md +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/config.example.toml +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/docs/architecture.md +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/docs/commands.md +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/docs/configuration.md +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/docs/troubleshooting.md +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/intents.example.toml +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/references/design-sources.md +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/references/research.md +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/__init__.py +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/cli.py +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/compat.py +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/config.py +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/gate.py +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/hooks.py +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/inbound.py +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/install.py +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/learnings.py +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/ledger.py +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/lessons.py +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/orders.py +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/redact.py +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/slow.py +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/status.py +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/store.py +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/trust.py +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/watcher.py +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubkit +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/learn.py +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/skills/hub-mep/SKILL.md +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/skills/hubkit-setup/SKILL.md +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/conftest.py +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/test_blockers.py +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/test_compat.py +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/test_guards2.py +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/test_hubkit.py +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/test_inbound.py +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/test_install.py +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/test_learning_rules.py +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/test_ledger.py +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/test_lessons.py +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/test_own_work.py +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/test_peer_reports.py +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/test_slow.py +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/test_state_rules.py +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/test_tool_guards.py +0 -0
- {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/test_watcher.py +0 -0
|
@@ -4,6 +4,11 @@ All notable changes to this project are recorded here. The format follows [Keep
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.2.2] - 2026-11-06
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- A fix job whose change verb and PR number are in different sentences ("Fix the gaps. ... PR #7") is still recognised as a fix job, so its owner pushing to the PR is not counted as a stalled or moved head.
|
|
11
|
+
|
|
7
12
|
## [0.2.1] - 2026-11-06
|
|
8
13
|
|
|
9
14
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hubkit-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Session supervisor for Claude Code: hooks-based multi-agent orchestration with order clocks, routed-work tracking, a watchdog and a stop gate, standard library only.
|
|
5
5
|
Project-URL: Repository, https://github.com/ankitkpandey1/hubkit
|
|
6
6
|
Project-URL: Documentation, https://github.com/ankitkpandey1/hubkit/tree/main/docs
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hubkit-cli"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
8
8
|
description = "Session supervisor for Claude Code: hooks-based multi-agent orchestration with order clocks, routed-work tracking, a watchdog and a stop gate, standard library only."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.14"
|
|
@@ -49,16 +49,23 @@ def opts(cfg: dict) -> dict:
|
|
|
49
49
|
PUSH = re.compile(
|
|
50
50
|
r"(?i)\b(fix(?:es|ed)?|address|rebase|implement|resolve|amend|patch|push|rework|refactor)\b"
|
|
51
51
|
)
|
|
52
|
-
EXAMPLE = re.compile(
|
|
52
|
+
EXAMPLE = re.compile(
|
|
53
|
+
r"(?i)\b(examples?|e\.g\.|for instance|such as|sample|placeholder|hypothetical)\b"
|
|
54
|
+
)
|
|
53
55
|
QUOTED = re.compile(r"```.*?```|`[^`\n]*`|\"[^\"\n]*\"|\u201c[^\u201d\n]*\u201d", re.S)
|
|
54
|
-
NOUN_BEFORE = re.compile(
|
|
55
|
-
|
|
56
|
+
NOUN_BEFORE = re.compile(
|
|
57
|
+
r"(?i)\b(the|his|her|their|its|a|an|my|our|your|that|this|in|from|with)\s+$"
|
|
58
|
+
)
|
|
59
|
+
NOUN_AFTER = re.compile(
|
|
60
|
+
r"(?i)^s?\s+(comments?|findings?|feedback|notes?|threads?|rounds?)\b"
|
|
61
|
+
)
|
|
56
62
|
|
|
57
63
|
|
|
58
64
|
def strip_examples(text: str) -> str:
|
|
59
65
|
"""The text without what only illustrates: code fences, backtick and quoted spans, and the part of a sentence from
|
|
60
66
|
"example", "e.g." or "such as" on (the line after a header that ends in a colon goes too). What comes before the marker
|
|
61
|
-
stays: "Review #7, e.g. the loader part" still asks for #7. A PR number inside an example is not an ask.
|
|
67
|
+
stays: "Review #7, e.g. the loader part" still asks for #7. A PR number inside an example is not an ask.
|
|
68
|
+
"""
|
|
62
69
|
text = QUOTED.sub(" ", text)
|
|
63
70
|
kept: list[str] = []
|
|
64
71
|
skip_next = False
|
|
@@ -86,11 +93,20 @@ def _kinds(cfg: dict, sentence: str) -> set[str]:
|
|
|
86
93
|
PR, not to review it. "push" marks a change verb anywhere."""
|
|
87
94
|
low = sentence.lower()
|
|
88
95
|
out = {"push"} if PUSH.search(low) else set()
|
|
89
|
-
hits = [
|
|
90
|
-
|
|
96
|
+
hits = [
|
|
97
|
+
m
|
|
98
|
+
for w in (x.lower() for x in opts(cfg)["review_words"])
|
|
99
|
+
for m in re.finditer(re.escape(w), low)
|
|
100
|
+
]
|
|
101
|
+
existing = all(
|
|
102
|
+
NOUN_BEFORE.search(low[: m.start()]) or NOUN_AFTER.match(low[m.end() :])
|
|
103
|
+
for m in hits
|
|
104
|
+
)
|
|
91
105
|
words = [x.lower() for x in opts(cfg)["review_words"]]
|
|
92
106
|
later = [c.strip() for c in re.split(r",|\bthen\b|\band\b", low)[1:]]
|
|
93
|
-
asks_later = any(
|
|
107
|
+
asks_later = any(
|
|
108
|
+
any(w in c for w in words) and not LEADS_CHANGE.match(c) for c in later
|
|
109
|
+
)
|
|
94
110
|
if hits and not (LEADS_CHANGE.match(low) and existing and not asks_later):
|
|
95
111
|
out.add("review")
|
|
96
112
|
return out
|
|
@@ -98,7 +114,9 @@ def _kinds(cfg: dict, sentence: str) -> set[str]:
|
|
|
98
114
|
|
|
99
115
|
def _asks(cfg: dict, text: str, summary: str = "") -> set[str]:
|
|
100
116
|
"""The kinds of ask (see _kinds) in the sentences that name a PR, plus the summary when the text names one."""
|
|
101
|
-
sentences = [
|
|
117
|
+
sentences = [
|
|
118
|
+
x for x in re.split(r"(?<=[.!?])\s+|\n", text) if x.strip() and PR_REF.search(x)
|
|
119
|
+
]
|
|
102
120
|
if summary.strip() and PR_REF.search(text + " " + summary):
|
|
103
121
|
sentences.append(summary)
|
|
104
122
|
return set().union(*(_kinds(cfg, x) for x in sentences)) if sentences else set()
|
|
@@ -113,24 +131,38 @@ def is_review(cfg: dict, text: str, summary: str = "") -> bool:
|
|
|
113
131
|
|
|
114
132
|
def pushes_to_pr(cfg: dict, text: str, summary: str = "") -> bool:
|
|
115
133
|
"""The owner is asked to change the PR (fix, rebase, address findings): its own pushes move the head."""
|
|
116
|
-
|
|
134
|
+
body = strip_examples(text)
|
|
135
|
+
if "push" in _asks(cfg, body, summary):
|
|
136
|
+
return True
|
|
137
|
+
# a fix job whose change verb sits in another sentence than the PR number ("Fix the gaps. ... PR #7"): still a fix job
|
|
138
|
+
return (
|
|
139
|
+
bool(PR_REF.search(text + " " + summary))
|
|
140
|
+
and bool(PUSH.search(body + " " + summary))
|
|
141
|
+
and not is_review(cfg, text, summary)
|
|
142
|
+
)
|
|
117
143
|
|
|
118
144
|
|
|
119
145
|
def owner_is_author(cfg: dict, owner: str, author: str) -> bool:
|
|
120
146
|
"""Is the owner the PR's author? A peer or agent has no GitHub login, so this uses the data on hand: an
|
|
121
147
|
explicit `routed.owner_logins` map (owner name -> login), else the owner's name equals the login or contains
|
|
122
|
-
it (a login of 4+ characters). An agent id never matches, so it is treated as a non-author.
|
|
148
|
+
it (a login of 4+ characters). An agent id never matches, so it is treated as a non-author.
|
|
149
|
+
"""
|
|
123
150
|
login = author.lower()
|
|
124
151
|
if not login:
|
|
125
152
|
return False
|
|
126
153
|
mapped = opts(cfg)["owner_logins"].get(owner)
|
|
127
|
-
return (
|
|
154
|
+
return (
|
|
155
|
+
(mapped or "").lower() == login
|
|
156
|
+
or owner.lower() == login
|
|
157
|
+
or (len(login) >= 4 and login in owner.lower())
|
|
158
|
+
)
|
|
128
159
|
|
|
129
160
|
|
|
130
161
|
def is_chase(cfg: dict, text: str) -> bool:
|
|
131
162
|
"""A short status question about a PR: an event on the existing item, not a new ask. A message with a brief marker
|
|
132
163
|
(Done-when, Output) or a sentence that opens with an action on a PR (Review #N ...) is an ask whatever else it
|
|
133
|
-
says; a status question that merely mentions a review ("state of the review on #N") is not.
|
|
164
|
+
says; a status question that merely mentions a review ("state of the review on #N") is not.
|
|
165
|
+
"""
|
|
134
166
|
sentences = [x.strip() for x in re.split(r"(?<=[.!?])\s+|\n", text)]
|
|
135
167
|
if DELIVERABLE.search(text) or any(DIRECTIVE.match(x) for x in sentences):
|
|
136
168
|
return False
|
|
@@ -152,14 +184,17 @@ DELIVERABLE = re.compile(r"(?i)done[- ]when|\boutput\b|\bdeliverable")
|
|
|
152
184
|
|
|
153
185
|
def is_deliverable_brief(cfg: dict, description: str, prompt: str) -> bool:
|
|
154
186
|
"""A background agent is routed work only when its brief asks for a result (a PR, an Output, a Done-when) and is
|
|
155
|
-
not a standing watcher (`routed.skip_briefs`); a long-running agent with nothing to deliver cannot stall.
|
|
187
|
+
not a standing watcher (`routed.skip_briefs`); a long-running agent with nothing to deliver cannot stall.
|
|
188
|
+
"""
|
|
156
189
|
low = f"{description} {prompt}".lower()
|
|
157
190
|
if any(w.lower() in low for w in opts(cfg)["skip_briefs"]):
|
|
158
191
|
return False
|
|
159
192
|
return bool(DELIVERABLE.search(prompt) or PR_REF.search(prompt))
|
|
160
193
|
|
|
161
194
|
|
|
162
|
-
def _is_reply(
|
|
195
|
+
def _is_reply(
|
|
196
|
+
cfg: dict, owner: str, text: str, asked: list[str], now: float | None
|
|
197
|
+
) -> bool:
|
|
163
198
|
"""The hub answering a peer who wrote to it recently, with no PR to work on and no brief, is not an ask."""
|
|
164
199
|
if asked or DELIVERABLE.search(text):
|
|
165
200
|
return False
|
|
@@ -199,7 +234,9 @@ def is_work(cfg: dict, owner: str, text: str, asked: list[str] | None = None) ->
|
|
|
199
234
|
o = opts(cfg)
|
|
200
235
|
if any(s.lower() in owner.lower() for s in o["skip_owners"]):
|
|
201
236
|
return False
|
|
202
|
-
if ACK.match(text) and not (
|
|
237
|
+
if ACK.match(text) and not (
|
|
238
|
+
DELIVERABLE.search(text) or DIRECTIVE.search(text) or REQUEST.search(text)
|
|
239
|
+
):
|
|
203
240
|
return False # "Ack #399 hold": a bare acknowledgement that names a PR is not an ask; any request cue makes it one
|
|
204
241
|
if asked if asked is not None else PR_REF.search(text):
|
|
205
242
|
return True
|
|
@@ -215,8 +252,14 @@ SHORT_ASK = re.compile(
|
|
|
215
252
|
|
|
216
253
|
def is_short_ask(text: str) -> bool:
|
|
217
254
|
"""A short message that asks the peer to do or answer something: a request verb opening a sentence, or a question.
|
|
218
|
-
Not an acknowledgement, not a status question (that is a chase), and long enough to carry a request.
|
|
219
|
-
|
|
255
|
+
Not an acknowledgement, not a status question (that is a chase), and long enough to carry a request.
|
|
256
|
+
"""
|
|
257
|
+
return (
|
|
258
|
+
len(text.strip()) >= 15
|
|
259
|
+
and not ACK.match(text)
|
|
260
|
+
and not CHASE.search(text)
|
|
261
|
+
and bool(SHORT_ASK.search(text))
|
|
262
|
+
)
|
|
220
263
|
|
|
221
264
|
|
|
222
265
|
REQUEST = re.compile(
|
|
@@ -228,7 +271,9 @@ DIRECTIVE = re.compile(
|
|
|
228
271
|
)
|
|
229
272
|
|
|
230
273
|
|
|
231
|
-
def asked_refs(
|
|
274
|
+
def asked_refs(
|
|
275
|
+
cfg: dict, text: str, summary: str = "", cap: int | None = MAX_QUEUE
|
|
276
|
+
) -> list[str]:
|
|
232
277
|
"""The PRs the owner is asked to work on. A short message asks about every PR it names. In a long one only
|
|
233
278
|
the summary and sentences that direct an action at a PR count: a PR named in passing (a bug
|
|
234
279
|
list, a status) is not an ask."""
|
|
@@ -247,7 +292,11 @@ def pr_num(ref: str) -> str:
|
|
|
247
292
|
def pr_key(ref: str) -> str:
|
|
248
293
|
"""Repo and number of a PR ref: `owner/repo#N` for a URL, `#N` for a bare number (repo not known)."""
|
|
249
294
|
parts = ref.rstrip("/").split("/")
|
|
250
|
-
return
|
|
295
|
+
return (
|
|
296
|
+
f"{parts[3]}/{parts[4]}#{parts[6]}".lower()
|
|
297
|
+
if ref.startswith("http") and len(parts) >= 7
|
|
298
|
+
else "#" + pr_num(ref)
|
|
299
|
+
)
|
|
251
300
|
|
|
252
301
|
|
|
253
302
|
def same_pr(a: str, b: str) -> bool:
|
|
@@ -287,7 +336,11 @@ def _repo_refs(cfg: dict, text: str) -> tuple[list[str], str]:
|
|
|
287
336
|
|
|
288
337
|
text = REPO_REF.sub(repl(), text)
|
|
289
338
|
for alias, repo in dict(opts(cfg).get("repo_aliases", {})).items():
|
|
290
|
-
text = re.sub(
|
|
339
|
+
text = re.sub(
|
|
340
|
+
rf"(?i)(?<![\w/.-]){re.escape(alias)}(?:\s*#|\s+pr\s*#?\s*|\s+)(\d{{2,6}})\b",
|
|
341
|
+
repl(repo),
|
|
342
|
+
text,
|
|
343
|
+
)
|
|
291
344
|
return list(dict.fromkeys(out)), text
|
|
292
345
|
|
|
293
346
|
|
|
@@ -335,9 +388,17 @@ def pr_info(cfg: dict, url: str, timeout: float) -> dict | None:
|
|
|
335
388
|
return None
|
|
336
389
|
try:
|
|
337
390
|
r = subprocess.run(
|
|
338
|
-
[
|
|
391
|
+
[
|
|
392
|
+
exe,
|
|
393
|
+
"pr",
|
|
394
|
+
"view",
|
|
395
|
+
url,
|
|
396
|
+
"--json",
|
|
397
|
+
"headRefOid,reviews,author,commits,state,statusCheckRollup",
|
|
398
|
+
],
|
|
339
399
|
capture_output=True,
|
|
340
|
-
encoding="utf-8",
|
|
400
|
+
encoding="utf-8",
|
|
401
|
+
errors="replace",
|
|
341
402
|
timeout=timeout,
|
|
342
403
|
check=False,
|
|
343
404
|
)
|
|
@@ -366,7 +427,14 @@ def pr_info(cfg: dict, url: str, timeout: float) -> dict | None:
|
|
|
366
427
|
return None
|
|
367
428
|
|
|
368
429
|
|
|
369
|
-
FAILED_CHECK = {
|
|
430
|
+
FAILED_CHECK = {
|
|
431
|
+
"FAILURE",
|
|
432
|
+
"TIMED_OUT",
|
|
433
|
+
"CANCELLED",
|
|
434
|
+
"STARTUP_FAILURE",
|
|
435
|
+
"ACTION_REQUIRED",
|
|
436
|
+
"ERROR",
|
|
437
|
+
}
|
|
370
438
|
OK_CHECK = {"SUCCESS", "NEUTRAL", "SKIPPED"}
|
|
371
439
|
|
|
372
440
|
|
|
@@ -374,7 +442,11 @@ def ci_state(rollup: object) -> str:
|
|
|
374
442
|
"""CI at the PR's head from gh's statusCheckRollup: 'failing', 'pending', 'green', or 'none' (no checks reported)."""
|
|
375
443
|
if not isinstance(rollup, list) or not rollup:
|
|
376
444
|
return "none"
|
|
377
|
-
seen = {
|
|
445
|
+
seen = {
|
|
446
|
+
str(c.get("conclusion") or c.get("state") or "").upper() or "PENDING"
|
|
447
|
+
for c in rollup
|
|
448
|
+
if isinstance(c, dict)
|
|
449
|
+
}
|
|
378
450
|
if seen & FAILED_CHECK:
|
|
379
451
|
return "failing"
|
|
380
452
|
return "green" if seen <= OK_CHECK else "pending"
|
|
@@ -445,12 +517,15 @@ def _same_job(i: dict, owner: str, prs: list[str], summary: str) -> bool:
|
|
|
445
517
|
return False
|
|
446
518
|
if prs:
|
|
447
519
|
return overlaps([*i["prs"], *i.get("queue", [])], prs)
|
|
448
|
-
return not i[
|
|
520
|
+
return not i[
|
|
521
|
+
"prs"
|
|
522
|
+
] # no PR named: a follow-up to the same owner is the same outcome
|
|
449
523
|
|
|
450
524
|
|
|
451
525
|
def _chase(cfg: dict, item: dict, text: str, heads: dict[str, str], now: float) -> None:
|
|
452
526
|
"""A follow-up to the owner about work already on the item: counted, never a new clock. It answers a moved head
|
|
453
|
-
or an old-head verdict (the hub acted), so those flags clear and the new head becomes the baseline.
|
|
527
|
+
or an old-head verdict (the hub acted), so those flags clear and the new head becomes the baseline.
|
|
528
|
+
"""
|
|
454
529
|
_event(item, "chase", text, now)
|
|
455
530
|
item["chases"] = int(item.get("chases", 0)) + 1
|
|
456
531
|
item["addressed"] = [*item.get("addressed", []), now]
|
|
@@ -462,7 +537,9 @@ def _chase(cfg: dict, item: dict, text: str, heads: dict[str, str], now: float)
|
|
|
462
537
|
item["heads"] = {**item["heads"], **heads}
|
|
463
538
|
|
|
464
539
|
|
|
465
|
-
def chase_owner(
|
|
540
|
+
def chase_owner(
|
|
541
|
+
cfg: dict, owner: str, text: str, now: float | None = None
|
|
542
|
+
) -> dict | None:
|
|
466
543
|
"""A message to an owner who has open items, naming no PR and carrying no new brief, is a follow-up on all of
|
|
467
544
|
them. Returns the first item, or None when the owner has none."""
|
|
468
545
|
now = now or time.time()
|
|
@@ -508,7 +585,9 @@ def record(
|
|
|
508
585
|
if hit is not None and (chase or follow_up):
|
|
509
586
|
_chase(cfg, hit, summary, heads, now)
|
|
510
587
|
if not hit.get("queue"):
|
|
511
|
-
hit["prs"] = _merge_prs(
|
|
588
|
+
hit["prs"] = _merge_prs(
|
|
589
|
+
hit["prs"], prs
|
|
590
|
+
) # a full URL replaces a bare #N
|
|
512
591
|
work_save(sd, all_)
|
|
513
592
|
return hit
|
|
514
593
|
is_new = hit is None
|
|
@@ -528,14 +607,37 @@ def record(
|
|
|
528
607
|
all_.append(hit)
|
|
529
608
|
moved: list[dict] = []
|
|
530
609
|
if is_new:
|
|
531
|
-
hit["behind"] = [
|
|
532
|
-
|
|
533
|
-
|
|
610
|
+
hit["behind"] = [
|
|
611
|
+
i["id"]
|
|
612
|
+
for i in all_
|
|
613
|
+
if i is not hit and i.get("kind") == "routed" and i["owner"] == owner
|
|
614
|
+
]
|
|
615
|
+
for (
|
|
616
|
+
other
|
|
617
|
+
) in (
|
|
618
|
+
all_
|
|
619
|
+
): # a brief on the same PRs to another owner is a re-brief: it addresses the old item
|
|
620
|
+
if (
|
|
621
|
+
other is not hit
|
|
622
|
+
and other.get("kind") == "routed"
|
|
623
|
+
and other["owner"] != owner
|
|
624
|
+
and overlaps(named, [*other["prs"], *other.get("queue", [])])
|
|
625
|
+
):
|
|
534
626
|
other["addressed"] = [*other.get("addressed", []), now]
|
|
535
|
-
if
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
other["
|
|
627
|
+
if (
|
|
628
|
+
brief
|
|
629
|
+
): # a real re-brief moves the PRs: the old owner no longer holds them
|
|
630
|
+
other["prs"] = [
|
|
631
|
+
u for u in other["prs"] if not overlaps([u], named)
|
|
632
|
+
]
|
|
633
|
+
other["queue"] = [
|
|
634
|
+
u
|
|
635
|
+
for u in other.get("queue", [])
|
|
636
|
+
if not overlaps([u], named)
|
|
637
|
+
]
|
|
638
|
+
other["heads"] = {
|
|
639
|
+
u: h for u, h in other["heads"].items() if u in other["prs"]
|
|
640
|
+
}
|
|
539
641
|
if not other["prs"]:
|
|
540
642
|
_archive(cfg, other, f"re-briefed to {owner}", now)
|
|
541
643
|
moved.append(other)
|
|
@@ -557,7 +659,9 @@ def record(
|
|
|
557
659
|
chased_level=None,
|
|
558
660
|
)
|
|
559
661
|
_set_estimate(cfg, hit, estimate, now)
|
|
560
|
-
hit.setdefault(
|
|
662
|
+
hit.setdefault(
|
|
663
|
+
"heads0", dict(heads)
|
|
664
|
+
) # the head when first asked: a later head is the owner's artifact
|
|
561
665
|
hit["heads"] = {
|
|
562
666
|
**hit["heads"],
|
|
563
667
|
**heads,
|
|
@@ -578,8 +682,21 @@ def _set_estimate(cfg: dict, item: dict, given: int | None, now: float) -> None:
|
|
|
578
682
|
from . import slow
|
|
579
683
|
|
|
580
684
|
mins, basis = (float(given), "brief") if given else slow.item_default(cfg, item)
|
|
581
|
-
item["estimates"] = [
|
|
582
|
-
|
|
685
|
+
item["estimates"] = [
|
|
686
|
+
*item.get("estimates", []),
|
|
687
|
+
{
|
|
688
|
+
"t": now,
|
|
689
|
+
"min": mins,
|
|
690
|
+
"source": "brief" if given else "default",
|
|
691
|
+
"basis": basis,
|
|
692
|
+
},
|
|
693
|
+
]
|
|
694
|
+
item.update(
|
|
695
|
+
estimate_min=mins,
|
|
696
|
+
estimate_defaulted=not given,
|
|
697
|
+
estimate_basis=basis,
|
|
698
|
+
addressed=[],
|
|
699
|
+
)
|
|
583
700
|
|
|
584
701
|
|
|
585
702
|
def from_tool(
|
|
@@ -599,7 +716,9 @@ def from_tool(
|
|
|
599
716
|
if _is_follow_up(cfg, owner, text):
|
|
600
717
|
return chase_owner(cfg, owner, str(ti.get("summary") or text), now)
|
|
601
718
|
asked = asked_refs(cfg, text, str(ti.get("summary") or ""))
|
|
602
|
-
if _is_reply(cfg, owner, text, asked, now) or not is_work(
|
|
719
|
+
if _is_reply(cfg, owner, text, asked, now) or not is_work(
|
|
720
|
+
cfg, owner, text, asked
|
|
721
|
+
):
|
|
603
722
|
return None
|
|
604
723
|
summary = str(ti.get("summary") or text.splitlines()[0])[:120]
|
|
605
724
|
chase = is_chase(cfg, text)
|
|
@@ -620,7 +739,9 @@ def from_tool(
|
|
|
620
739
|
else:
|
|
621
740
|
return None
|
|
622
741
|
every = asked_refs(cfg, text, summary)
|
|
623
|
-
queue =
|
|
742
|
+
queue = (
|
|
743
|
+
every if len(every) > 1 else []
|
|
744
|
+
) # one PR per clock: several PRs are a queue, the first is current
|
|
624
745
|
prs = every[:1] if queue else every
|
|
625
746
|
heads: dict[str, str] = {}
|
|
626
747
|
authored = False
|
|
@@ -732,8 +853,10 @@ def next_action(cfg: dict, item: dict, now: float) -> str:
|
|
|
732
853
|
lvl = eff_level(cfg, item, now)
|
|
733
854
|
if item.get("reported") and not item.get("review"):
|
|
734
855
|
prs = " ".join(_pr_no(u) for u in item["prs"])
|
|
735
|
-
return (
|
|
736
|
-
|
|
856
|
+
return (
|
|
857
|
+
f"{o} reported it done {int((now - item['reported']) / 60)} min ago; that is a claim. Check the artifact"
|
|
858
|
+
f"{' (' + prs + ')' if prs else ''}, then: hubkit work done {item['id']} --note \"<what you checked>\""
|
|
859
|
+
)
|
|
737
860
|
if lvl is not None:
|
|
738
861
|
if item.get("chased_level") is not None and item["chased_level"] >= lvl:
|
|
739
862
|
nxt = base(cfg, item) + limit(cfg, item) * 60 * (lvl + 2)
|
|
@@ -813,7 +936,9 @@ def alerts(cfg: dict, now: float) -> list[dict]:
|
|
|
813
936
|
}
|
|
814
937
|
)
|
|
815
938
|
lvl = eff_level(cfg, i, now)
|
|
816
|
-
if lvl is not None and needs_action(
|
|
939
|
+
if lvl is not None and needs_action(
|
|
940
|
+
cfg, i, now
|
|
941
|
+
): # a chased item waits: no alert, no wake-up
|
|
817
942
|
out.append(
|
|
818
943
|
{
|
|
819
944
|
"key": f"{i['id']}:routed_stalled:L{lvl}",
|
|
@@ -836,8 +961,10 @@ def block_lines(cfg: dict, work: list[dict], now: float) -> list[str]:
|
|
|
836
961
|
pr = ", ".join(
|
|
837
962
|
f"{_pr_no(u)}@{i['heads'].get(u, '?')[:7]}" for u in i.get("prs", [])
|
|
838
963
|
)
|
|
839
|
-
flags = (
|
|
840
|
-
"
|
|
964
|
+
flags = (
|
|
965
|
+
(f" +{len(i['queue']) - 1} queued" if i.get("queue") else "")
|
|
966
|
+
+ (" REVIEW" if i.get("review") else "")
|
|
967
|
+
+ (" PR unknown" if i.get("pr_unknown") else "")
|
|
841
968
|
)
|
|
842
969
|
out.append(
|
|
843
970
|
f" {i['id']} -> {i['owner']}: {i['title'][:50]}{' [' + pr + ']' if pr else ''}{flags}; "
|
|
@@ -868,15 +995,28 @@ def heard(cfg: dict, events: list[tuple[float, str, str]]) -> int:
|
|
|
868
995
|
i["owner"] in text
|
|
869
996
|
or bool(i.get("tool_use_id") and i["tool_use_id"] in text)
|
|
870
997
|
)
|
|
871
|
-
if
|
|
998
|
+
if (
|
|
999
|
+
by_text
|
|
1000
|
+
and COMPLETED.search(text)
|
|
1001
|
+
and ts > (i.get("routed_at") or i["added"])
|
|
1002
|
+
):
|
|
872
1003
|
i["reported"] = ts # the agent finished after this ask and reported
|
|
873
|
-
if not i.get(
|
|
1004
|
+
if not i.get(
|
|
1005
|
+
"review"
|
|
1006
|
+
): # a review item stays: the review check on the PR decides
|
|
874
1007
|
i["answered"] = True
|
|
875
1008
|
i["done_note"] = "agent completed (task notification)"
|
|
876
1009
|
from_owner = bool(name) and name.lower() == i["owner"].lower()
|
|
877
|
-
if
|
|
878
|
-
|
|
879
|
-
|
|
1010
|
+
if (
|
|
1011
|
+
from_owner
|
|
1012
|
+
and not i.get("review")
|
|
1013
|
+
and ts > (i.get("routed_at") or i["added"])
|
|
1014
|
+
and is_report(text)
|
|
1015
|
+
and _report_is_about(all_, i, text)
|
|
1016
|
+
):
|
|
1017
|
+
i["reported"] = (
|
|
1018
|
+
ts # a claim, not evidence: the item closes on the artifact (refresh_prs) or on `work done`
|
|
1019
|
+
)
|
|
880
1020
|
i["claimed"] = text[:160]
|
|
881
1021
|
n += 1 # a change to save even when no last_heard moved
|
|
882
1022
|
if (name.lower() == i["owner"].lower() or by_text) and ts > (
|
|
@@ -885,10 +1025,14 @@ def heard(cfg: dict, events: list[tuple[float, str, str]]) -> int:
|
|
|
885
1025
|
i["last_heard"] = ts
|
|
886
1026
|
n += 1
|
|
887
1027
|
if i.get("state_ask") and ts > (i.get("routed_at") or 0):
|
|
888
|
-
i["answered"] =
|
|
1028
|
+
i["answered"] = (
|
|
1029
|
+
True # a state question is answered by one reply
|
|
1030
|
+
)
|
|
889
1031
|
closing = [x for x in all_ if x.get("answered")]
|
|
890
1032
|
for i in closing:
|
|
891
|
-
_archive(
|
|
1033
|
+
_archive(
|
|
1034
|
+
cfg, i, i.get("done_note") or "state question answered", time.time()
|
|
1035
|
+
)
|
|
892
1036
|
all_.remove(i)
|
|
893
1037
|
if n or closing:
|
|
894
1038
|
work_save(sd, all_)
|
|
@@ -896,22 +1040,39 @@ def heard(cfg: dict, events: list[tuple[float, str, str]]) -> int:
|
|
|
896
1040
|
|
|
897
1041
|
|
|
898
1042
|
COMPLETED = re.compile(r"<status>\s*completed\s*</status>")
|
|
899
|
-
PEER_BODY = re.compile(
|
|
900
|
-
|
|
901
|
-
|
|
1043
|
+
PEER_BODY = re.compile(
|
|
1044
|
+
r"<cross-session-message[^>]*>(.*?)(?:</cross-session-message>|\Z)", re.S
|
|
1045
|
+
)
|
|
1046
|
+
PEER_RESULT = re.compile(
|
|
1047
|
+
r"(?i)\b(done|finished|complete[d]?|merged|pushed|passed|passing|green|fixed|shipped|result|ready for)\b"
|
|
1048
|
+
)
|
|
1049
|
+
PEER_NOT_RESULT = re.compile(
|
|
1050
|
+
r"(?i)\b(blocked|failed|failing|cannot|can't|couldn't|unable|error|stuck|waiting)\b"
|
|
1051
|
+
)
|
|
902
1052
|
|
|
903
1053
|
|
|
904
1054
|
def _report_is_about(all_: list[dict], item: dict, text: str) -> bool:
|
|
905
1055
|
"""A report closes the owner's only item; when the owner holds several, it must name this item's PR or id."""
|
|
906
|
-
mine = [
|
|
907
|
-
|
|
1056
|
+
mine = [
|
|
1057
|
+
x for x in all_ if x.get("kind") == "routed" and x["owner"] == item["owner"]
|
|
1058
|
+
]
|
|
1059
|
+
return (
|
|
1060
|
+
len(mine) == 1
|
|
1061
|
+
or any(pr_num(u) in re.findall(r"\d+", text) for u in item["prs"])
|
|
1062
|
+
or item["id"] in text
|
|
1063
|
+
)
|
|
908
1064
|
|
|
909
1065
|
|
|
910
1066
|
def is_report(text: str) -> bool:
|
|
911
1067
|
"""A peer's message reports a result: a result word, no blocker word, and it does not end on a question."""
|
|
912
1068
|
m = PEER_BODY.search(text)
|
|
913
1069
|
body = (m[1] if m else text).strip()
|
|
914
|
-
return
|
|
1070
|
+
return (
|
|
1071
|
+
bool(body)
|
|
1072
|
+
and not body.endswith("?")
|
|
1073
|
+
and bool(PEER_RESULT.search(body))
|
|
1074
|
+
and not PEER_NOT_RESULT.search(body)
|
|
1075
|
+
)
|
|
915
1076
|
|
|
916
1077
|
|
|
917
1078
|
def peer_name(text: str) -> str:
|
|
@@ -1008,12 +1169,16 @@ def _landed(
|
|
|
1008
1169
|
out: dict[str, tuple[str, dict | None]] = {}
|
|
1009
1170
|
for u in item["prs"]:
|
|
1010
1171
|
info = got.get(u)
|
|
1011
|
-
revs =
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1172
|
+
revs = (
|
|
1173
|
+
[
|
|
1174
|
+
r
|
|
1175
|
+
for r in (info["reviews"] if info else [])
|
|
1176
|
+
if r["at"] >= (item.get("routed_at") or item["added"])
|
|
1177
|
+
and counts_as_review(cfg, r, info)
|
|
1178
|
+
]
|
|
1179
|
+
if info
|
|
1180
|
+
else []
|
|
1181
|
+
)
|
|
1017
1182
|
if not info or not revs:
|
|
1018
1183
|
out[u] = ("", None)
|
|
1019
1184
|
continue
|
|
@@ -1068,11 +1233,24 @@ def _advance(item: dict, det: dict, now: float) -> dict:
|
|
|
1068
1233
|
item["queue"] = rest if len(rest) > 1 else []
|
|
1069
1234
|
item["prs"] = rest[:1]
|
|
1070
1235
|
item["heads"] = {}
|
|
1071
|
-
item.update(
|
|
1072
|
-
|
|
1236
|
+
item.update(
|
|
1237
|
+
routed_at=now,
|
|
1238
|
+
head_moved=None,
|
|
1239
|
+
verdict_old_head=None,
|
|
1240
|
+
verdict_acked=None,
|
|
1241
|
+
chased_level=None,
|
|
1242
|
+
)
|
|
1243
|
+
_event(
|
|
1244
|
+
item,
|
|
1245
|
+
"advance",
|
|
1246
|
+
f"{_pr_no(done)} reviewed; now {_pr_no(rest[0]) if rest else 'none'}",
|
|
1247
|
+
now,
|
|
1248
|
+
)
|
|
1073
1249
|
return {
|
|
1074
1250
|
"key": f"{item['id']}:queue_advanced:{pr_num(done)}",
|
|
1075
|
-
"msg":
|
|
1251
|
+
"msg": (
|
|
1252
|
+
f"{item['id']} to {item['owner']}: {_pr_no(done)} {det['state']} at {det['commit'][:7]}; the clock restarts on the next PR. NEXT: read the review on GitHub and act on it"
|
|
1253
|
+
),
|
|
1076
1254
|
}
|
|
1077
1255
|
|
|
1078
1256
|
|
|
@@ -1081,7 +1259,11 @@ def artifact_exists(item: dict, url: str, info: dict) -> bool:
|
|
|
1081
1259
|
if info.get("state") in ("MERGED", "CLOSED"):
|
|
1082
1260
|
return True
|
|
1083
1261
|
first = str((item.get("heads0") or {}).get(url) or "")
|
|
1084
|
-
return
|
|
1262
|
+
return (
|
|
1263
|
+
bool(first)
|
|
1264
|
+
and not same_head(first, info["head"])
|
|
1265
|
+
and info.get("ci", "none") != "failing"
|
|
1266
|
+
)
|
|
1085
1267
|
|
|
1086
1268
|
|
|
1087
1269
|
def next_step(cfg: dict, d: dict, owner: str) -> str:
|
|
@@ -1103,7 +1285,13 @@ def next_step(cfg: dict, d: dict, owner: str) -> str:
|
|
|
1103
1285
|
|
|
1104
1286
|
def _reply_owed(cfg: dict, item: dict, done: dict[str, dict]) -> str:
|
|
1105
1287
|
"""Names the people who asked the hub for this review (inbound reply items on the same PRs)."""
|
|
1106
|
-
who = sorted(
|
|
1288
|
+
who = sorted(
|
|
1289
|
+
{
|
|
1290
|
+
x["owner"]
|
|
1291
|
+
for x in work_load(store.state(cfg))
|
|
1292
|
+
if x.get("kind") == "reply" and overlaps(list(done), x.get("prs", []))
|
|
1293
|
+
}
|
|
1294
|
+
)
|
|
1107
1295
|
return f" Reply owed to: {', '.join(who)}." if who else ""
|
|
1108
1296
|
|
|
1109
1297
|
|
|
@@ -1117,13 +1305,19 @@ def _close_landed(cfg: dict, item: dict, done: dict[str, dict], now: float) -> d
|
|
|
1117
1305
|
f"{_pr_no(u)} {d['state']} by {d['login']} at {d['commit'][:7]} (the head), CI {d.get('ci', 'none')}"
|
|
1118
1306
|
for u, d in done.items()
|
|
1119
1307
|
)
|
|
1120
|
-
gap =
|
|
1308
|
+
gap = (
|
|
1309
|
+
f" Still open: {' '.join(_pr_no(u) for u in item['prs'])} (no review at its head yet)."
|
|
1310
|
+
if item["prs"]
|
|
1311
|
+
else ""
|
|
1312
|
+
)
|
|
1121
1313
|
if not item["prs"]:
|
|
1122
1314
|
_archive(cfg, item, "review " + note, now)
|
|
1123
1315
|
first = next(iter(done.values()))
|
|
1124
1316
|
return {
|
|
1125
1317
|
"key": f"{item['id']}:review_landed:{pr_num(next(iter(done)))}",
|
|
1126
|
-
"msg":
|
|
1318
|
+
"msg": (
|
|
1319
|
+
f"{item['id']} to {item['owner']}: {note}.{gap}{_reply_owed(cfg, item, done)} NEXT: {next_step(cfg, first, item['owner'])}"
|
|
1320
|
+
),
|
|
1127
1321
|
}
|
|
1128
1322
|
|
|
1129
1323
|
|
|
@@ -1134,12 +1328,19 @@ def resolve_bare_refs(cfg: dict) -> None:
|
|
|
1134
1328
|
return
|
|
1135
1329
|
|
|
1136
1330
|
def fix(u: str) -> str:
|
|
1137
|
-
return
|
|
1331
|
+
return (
|
|
1332
|
+
u if u.startswith("http") else f"https://github.com/{repo}/pull/{pr_num(u)}"
|
|
1333
|
+
)
|
|
1138
1334
|
|
|
1139
1335
|
sd = store.state(cfg)
|
|
1140
1336
|
with store.locked(sd / "work.lock"):
|
|
1141
1337
|
all_ = work_load(sd)
|
|
1142
|
-
if not any(
|
|
1338
|
+
if not any(
|
|
1339
|
+
not u.startswith("http")
|
|
1340
|
+
for i in all_
|
|
1341
|
+
if i.get("kind") == "routed"
|
|
1342
|
+
for u in [*i["prs"], *i.get("queue", [])]
|
|
1343
|
+
):
|
|
1143
1344
|
return
|
|
1144
1345
|
for i in all_:
|
|
1145
1346
|
if i.get("kind") == "routed":
|
|
@@ -1175,15 +1376,23 @@ def refresh_prs(cfg: dict, now: float) -> list[dict]:
|
|
|
1175
1376
|
if not info:
|
|
1176
1377
|
continue
|
|
1177
1378
|
old = i["heads"].get(u)
|
|
1178
|
-
if not (
|
|
1379
|
+
if not (
|
|
1380
|
+
old and HEX_HEAD.fullmatch(old)
|
|
1381
|
+
): # unset, or mangled by an older redaction: rebaseline
|
|
1179
1382
|
i["heads"][u] = info["head"]
|
|
1180
1383
|
elif owner_is_author(cfg, i["owner"], info["author"]):
|
|
1181
|
-
i["review"] =
|
|
1384
|
+
i["review"] = (
|
|
1385
|
+
False # the person asked wrote the PR: their pushes are the work, not a stale review
|
|
1386
|
+
)
|
|
1182
1387
|
i["heads"][u] = info["head"]
|
|
1183
1388
|
elif i.get("pushes") and not i.get("review"):
|
|
1184
|
-
i["heads"][u] = info[
|
|
1389
|
+
i["heads"][u] = info[
|
|
1390
|
+
"head"
|
|
1391
|
+
] # the owner was asked to push to this PR: its pushes are the work
|
|
1185
1392
|
elif owner_is_author(cfg, i["owner"], info.get("head_author", "")):
|
|
1186
|
-
i["heads"][u] = info[
|
|
1393
|
+
i["heads"][u] = info[
|
|
1394
|
+
"head"
|
|
1395
|
+
] # the head moved by the owner's own commit: progress, not a stray push
|
|
1187
1396
|
i["last_heard"] = max(i.get("last_heard") or 0, now)
|
|
1188
1397
|
elif not same_head(old, info["head"]):
|
|
1189
1398
|
i["head_moved"] = {
|
|
@@ -1194,8 +1403,14 @@ def refresh_prs(cfg: dict, now: float) -> list[dict]:
|
|
|
1194
1403
|
}
|
|
1195
1404
|
_event(i, "head_moved", f"{old[:7]} -> {info['head'][:7]}", now)
|
|
1196
1405
|
i["heads"][u] = info["head"]
|
|
1197
|
-
if
|
|
1198
|
-
i
|
|
1406
|
+
if (
|
|
1407
|
+
i.get("waiting")
|
|
1408
|
+
and info["review_at"]
|
|
1409
|
+
and info["review_at"] > (i.get("routed_at") or 0)
|
|
1410
|
+
):
|
|
1411
|
+
i["waiting"] = (
|
|
1412
|
+
False # a review shows on the PR: the owner has started it
|
|
1413
|
+
)
|
|
1199
1414
|
if info["review_at"] and info["review_at"] > base(cfg, i):
|
|
1200
1415
|
i["last_heard"] = info["review_at"]
|
|
1201
1416
|
res = _landed(cfg, i, got) if i.get("review") else {}
|
|
@@ -1205,16 +1420,32 @@ def refresh_prs(cfg: dict, now: float) -> list[dict]:
|
|
|
1205
1420
|
old if old and acked != [old["commit"], old["head"]] else None
|
|
1206
1421
|
)
|
|
1207
1422
|
done = {u: d for u, (v, d) in res.items() if v == "closed" and d}
|
|
1208
|
-
if i.get("reported") and not i.get(
|
|
1423
|
+
if i.get("reported") and not i.get(
|
|
1424
|
+
"review"
|
|
1425
|
+
): # the owner said done: close only on its artifact
|
|
1209
1426
|
for u in i["prs"]:
|
|
1210
1427
|
info = got.get(u)
|
|
1211
1428
|
if info and artifact_exists(i, u, info):
|
|
1212
|
-
done[u] = {
|
|
1429
|
+
done[u] = {
|
|
1430
|
+
"state": "REPORTED",
|
|
1431
|
+
"login": i["owner"],
|
|
1432
|
+
"commit": info["head"],
|
|
1433
|
+
"ci": info.get("ci", "none"),
|
|
1434
|
+
}
|
|
1213
1435
|
if i.get("review"): # a merged or closed PR needs no review any more
|
|
1214
1436
|
for u in i["prs"]:
|
|
1215
1437
|
info = got.get(u)
|
|
1216
|
-
if
|
|
1217
|
-
|
|
1438
|
+
if (
|
|
1439
|
+
u not in done
|
|
1440
|
+
and info
|
|
1441
|
+
and info.get("state") in ("MERGED", "CLOSED")
|
|
1442
|
+
):
|
|
1443
|
+
done[u] = {
|
|
1444
|
+
"state": info["state"],
|
|
1445
|
+
"login": "GitHub",
|
|
1446
|
+
"commit": info["head"],
|
|
1447
|
+
"ci": info.get("ci", "none"),
|
|
1448
|
+
}
|
|
1218
1449
|
if done and i.get("queue"):
|
|
1219
1450
|
cur = i["prs"][0]
|
|
1220
1451
|
if cur in done:
|
|
@@ -350,6 +350,7 @@ REVERTS = [
|
|
|
350
350
|
('peer prompts redacted', 'hubcore/ledger.py', '"text": redact(" ".join(text.split()))[0][:300]', '"text": " ".join(text.split())[:300]', 'tests/test_ledger.py::test_peer_prompts_are_redacted_like_state'),
|
|
351
351
|
('eod lists PRs and peers', 'hubcore/status.py', 'glance(cfg, day) + [""] + ledger.day_lines(cfg, day)', 'glance(cfg, day)', 'tests/test_ledger.py::test_digest_and_eod_list_todays_prs_and_what_each_peer_was_asked'),
|
|
352
352
|
('digest lists PRs and peers', 'hubcore/status.py', 'return out + ledger.day_lines(cfg, store.day_of(cfg, since))', 'return out', 'tests/test_ledger.py::test_digest_and_eod_list_todays_prs_and_what_each_peer_was_asked'),
|
|
353
|
+
('fix job push is not a stall', 'hubcore/routed.py', ' bool(PR_REF.search(text + " " + summary))\n and bool(PUSH', ' False\n and bool(PUSH', 'tests/test_review_chain.py::test_an_owners_pushes_to_a_fix_job_are_not_a_stalled_head_but_a_reviews_moved_head_still_is'),
|
|
353
354
|
]
|
|
354
355
|
|
|
355
356
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
|
|
5
|
-
from test_hubkit import PR1, fake_gh, review, routed_items, send
|
|
5
|
+
from test_hubkit import PR1, PR2, fake_gh, review, routed_items, send
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
def gh_json(env, head, reviews=None, **extra):
|
|
@@ -42,3 +42,15 @@ def test_an_approval_on_red_ci_says_not_to_merge(env):
|
|
|
42
42
|
gh_json(env, "aaa1111", [review("aaa1111")], statusCheckRollup=[{"conclusion": "FAILURE"}])
|
|
43
43
|
out = env.run("tick", "--no-notify").stdout
|
|
44
44
|
assert "CI is failing" in out and "do not merge on red" in out
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_an_owners_pushes_to_a_fix_job_are_not_a_stalled_head_but_a_reviews_moved_head_still_is(env):
|
|
48
|
+
fake_gh(env, head="aaa1111")
|
|
49
|
+
env.bind()
|
|
50
|
+
send(env, "peer-fix", f"Fix the gaps from the audit and update the tests. Done-when: tests pass. Output: the PR {PR1}. Estimate: 30m", "audit work")
|
|
51
|
+
send(env, "peer-rev", f"Full gated review of {PR2}", "review 102")
|
|
52
|
+
gh_json(env, "bbb2222")
|
|
53
|
+
out = env.run("tick", "--no-notify").stdout
|
|
54
|
+
items = {i["owner"]: i for i in routed_items(env)}
|
|
55
|
+
assert items["peer-fix"]["head_moved"] is None and items["peer-fix"]["heads"][PR1] == "bbb2222"
|
|
56
|
+
assert items["peer-rev"]["head_moved"] and "ALERT" in out
|
|
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
|
|
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
|
|
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
|