hubkit-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hubcore/__init__.py +1 -0
- hubcore/cli.py +273 -0
- hubcore/compat.py +187 -0
- hubcore/config.py +107 -0
- hubcore/data/hub/LICENSE +202 -0
- hubcore/data/hub/SKILL.md +125 -0
- hubcore/data/hub/config.example.toml +84 -0
- hubcore/data/hub/docs/architecture.md +133 -0
- hubcore/data/hub/docs/commands.md +66 -0
- hubcore/data/hub/docs/configuration.md +151 -0
- hubcore/data/hub/docs/troubleshooting.md +21 -0
- hubcore/data/hub/intents.example.toml +74 -0
- hubcore/data/hub/references/design-sources.md +14 -0
- hubcore/data/hub/references/research.md +49 -0
- hubcore/data/hub/scripts/hubcore/__init__.py +1 -0
- hubcore/data/hub/scripts/hubcore/cli.py +273 -0
- hubcore/data/hub/scripts/hubcore/compat.py +187 -0
- hubcore/data/hub/scripts/hubcore/config.py +107 -0
- hubcore/data/hub/scripts/hubcore/gate.py +145 -0
- hubcore/data/hub/scripts/hubcore/hooks.py +326 -0
- hubcore/data/hub/scripts/hubcore/install.py +66 -0
- hubcore/data/hub/scripts/hubcore/learnings.py +232 -0
- hubcore/data/hub/scripts/hubcore/lessons.py +168 -0
- hubcore/data/hub/scripts/hubcore/orders.py +508 -0
- hubcore/data/hub/scripts/hubcore/redact.py +89 -0
- hubcore/data/hub/scripts/hubcore/routed.py +1169 -0
- hubcore/data/hub/scripts/hubcore/slow.py +531 -0
- hubcore/data/hub/scripts/hubcore/status.py +535 -0
- hubcore/data/hub/scripts/hubcore/store.py +172 -0
- hubcore/data/hub/scripts/hubcore/watcher.py +167 -0
- hubcore/data/hub/scripts/hubkit +37 -0
- hubcore/data/hub/scripts/learn.py +84 -0
- hubcore/data/hub-mep/SKILL.md +35 -0
- hubcore/data/hubkit-setup/SKILL.md +50 -0
- hubcore/gate.py +145 -0
- hubcore/hooks.py +326 -0
- hubcore/install.py +66 -0
- hubcore/learnings.py +232 -0
- hubcore/lessons.py +168 -0
- hubcore/orders.py +508 -0
- hubcore/redact.py +89 -0
- hubcore/routed.py +1169 -0
- hubcore/slow.py +531 -0
- hubcore/status.py +535 -0
- hubcore/store.py +172 -0
- hubcore/watcher.py +167 -0
- hubkit_cli-0.1.0.dist-info/METADATA +195 -0
- hubkit_cli-0.1.0.dist-info/RECORD +51 -0
- hubkit_cli-0.1.0.dist-info/WHEEL +4 -0
- hubkit_cli-0.1.0.dist-info/entry_points.txt +2 -0
- hubkit_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,1169 @@
|
|
|
1
|
+
"""Routed work: an outcome ledger for everything the hub hands to a peer session or background agent.
|
|
2
|
+
|
|
3
|
+
An item is an OUTCOME (owner + PR number), not a message. The PostToolUse hook on SendMessage and
|
|
4
|
+
Agent opens it; a second ask about the same PR to the same owner refreshes it; a chase ("status on
|
|
5
|
+
#7") is only an event on it and never restarts its clock. Time is read from files: nothing from the
|
|
6
|
+
owner for the limit is a stall, the stall climbs a ladder, and every item has one NEXT action.
|
|
7
|
+
`tick` reads each PR with `gh` once per tick; a review that lands at the PR's current head closes a
|
|
8
|
+
review item by itself, a head that moved on is an alert.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import glob
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import time
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from . import store
|
|
24
|
+
from .orders import PR_REF, PR_URL, load_intents
|
|
25
|
+
from .status import next_work_id, work_load, work_save
|
|
26
|
+
|
|
27
|
+
ACK = re.compile(
|
|
28
|
+
r"(?i)^\W*(ack(nowledged)?|ok(ay)?|thanks?|thank you|got it|noted|will do|roger|done)\b"
|
|
29
|
+
)
|
|
30
|
+
CHASE = re.compile(
|
|
31
|
+
r"(?i)\b(status|eta|updates?|any progress|where are|how long|still (not|waiting|running)|done\?|report back|one[- ]line (state|status)|current state|what state|state of)\b"
|
|
32
|
+
)
|
|
33
|
+
BARE = re.compile(r"(?<![\w/])(?:#|PR\s*#?\s*)(\d{2,6})\b", re.IGNORECASE)
|
|
34
|
+
FROM_NAME = re.compile(r'<cross-session-message[^>]*\bfrom-name="([^"]+)"')
|
|
35
|
+
MAX_PRS = 3
|
|
36
|
+
MAX_QUEUE = 50
|
|
37
|
+
MAX_EVENTS = 20
|
|
38
|
+
SCAN_TAIL = 2_000_000 # first scan of a transcript reads only its last 2 MB
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def opts(cfg: dict) -> dict:
|
|
42
|
+
"""cfg [routed] overridden by intents.toml [routed]."""
|
|
43
|
+
return {**cfg["routed"], **load_intents(cfg).get("routed", {})}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---- what counts as routed work ----
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
PUSH = re.compile(
|
|
50
|
+
r"(?i)\b(fix(?:es|ed)?|address|rebase|implement|resolve|amend|patch|push|rework|refactor)\b"
|
|
51
|
+
)
|
|
52
|
+
EXAMPLE = re.compile(r"(?i)\b(examples?|e\.g\.|for instance|such as|sample|placeholder|hypothetical)\b")
|
|
53
|
+
QUOTED = re.compile(r"```.*?```|`[^`\n]*`|\"[^\"\n]*\"|\u201c[^\u201d\n]*\u201d", re.S)
|
|
54
|
+
NOUN_BEFORE = re.compile(r"(?i)\b(the|his|her|their|its|a|an|my|our|your|that|this|in|from|with)\s+$")
|
|
55
|
+
NOUN_AFTER = re.compile(r"(?i)^s?\s+(comments?|findings?|feedback|notes?|threads?|rounds?)\b")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def strip_examples(text: str) -> str:
|
|
59
|
+
"""The text without what only illustrates: code fences, backtick and quoted spans, and the part of a sentence from
|
|
60
|
+
"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."""
|
|
62
|
+
text = QUOTED.sub(" ", text)
|
|
63
|
+
kept: list[str] = []
|
|
64
|
+
skip_next = False
|
|
65
|
+
for s in re.split(r"(?<=[.!?])\s+|\n", text.replace("e.g.", "example")):
|
|
66
|
+
m = EXAMPLE.search(s)
|
|
67
|
+
if skip_next or m:
|
|
68
|
+
skip_next = bool(m) and s.rstrip().endswith(":")
|
|
69
|
+
if m and not skip_next and not m.start() == 0:
|
|
70
|
+
kept.append(s[: m.start()])
|
|
71
|
+
continue
|
|
72
|
+
kept.append(s)
|
|
73
|
+
return "\n".join(kept)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
LEADS_CHANGE = re.compile(
|
|
77
|
+
r"(?i)^\W*(?:(?:please|then|and|also)\s+)*(?:fix(?:es|ed)?|address|resolve|apply|rebase|implement|amend|patch|push|rework|refactor)\b"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _kinds(cfg: dict, sentence: str) -> set[str]:
|
|
82
|
+
"""What the owner may be asked to do in one sentence. A review word beside a PR is a review ask by default
|
|
83
|
+
("review #7", "needs a review", "can I get a review on #7"). The one exception: the sentence opens with a change
|
|
84
|
+
verb (fix, address, resolve, apply, ...), every review word in it names an existing review (his review, the
|
|
85
|
+
review comments) and no later clause ("then give #7 a review") asks for one, so the owner is asked to change the
|
|
86
|
+
PR, not to review it. "push" marks a change verb anywhere."""
|
|
87
|
+
low = sentence.lower()
|
|
88
|
+
out = {"push"} if PUSH.search(low) else set()
|
|
89
|
+
hits = [m for w in (x.lower() for x in opts(cfg)["review_words"]) for m in re.finditer(re.escape(w), low)]
|
|
90
|
+
existing = all(NOUN_BEFORE.search(low[: m.start()]) or NOUN_AFTER.match(low[m.end() :]) for m in hits)
|
|
91
|
+
words = [x.lower() for x in opts(cfg)["review_words"]]
|
|
92
|
+
later = [c.strip() for c in re.split(r",|\bthen\b|\band\b", low)[1:]]
|
|
93
|
+
asks_later = any(any(w in c for w in words) and not LEADS_CHANGE.match(c) for c in later)
|
|
94
|
+
if hits and not (LEADS_CHANGE.match(low) and existing and not asks_later):
|
|
95
|
+
out.add("review")
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _asks(cfg: dict, text: str, summary: str = "") -> set[str]:
|
|
100
|
+
"""The kinds of ask (see _kinds) in the sentences that name a PR, plus the summary when the text names one."""
|
|
101
|
+
sentences = [x for x in re.split(r"(?<=[.!?])\s+|\n", text) if x.strip() and PR_REF.search(x)]
|
|
102
|
+
if summary.strip() and PR_REF.search(text + " " + summary):
|
|
103
|
+
sentences.append(summary)
|
|
104
|
+
return set().union(*(_kinds(cfg, x) for x in sentences)) if sentences else set()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def is_review(cfg: dict, text: str, summary: str = "") -> bool:
|
|
108
|
+
"""A review ask: the owner is asked to review a PR, i.e. a sentence naming a PR leads with a review word (or the
|
|
109
|
+
summary does). A review word elsewhere (a handover, a status) or as a noun after another action ("fix the
|
|
110
|
+
findings in his review of #7") does not make it one."""
|
|
111
|
+
return "review" in _asks(cfg, strip_examples(text), summary)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def pushes_to_pr(cfg: dict, text: str, summary: str = "") -> bool:
|
|
115
|
+
"""The owner is asked to change the PR (fix, rebase, address findings): its own pushes move the head."""
|
|
116
|
+
return "push" in _asks(cfg, strip_examples(text), summary)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def owner_is_author(cfg: dict, owner: str, author: str) -> bool:
|
|
120
|
+
"""Is the owner the PR's author? A peer or agent has no GitHub login, so this uses the data on hand: an
|
|
121
|
+
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."""
|
|
123
|
+
login = author.lower()
|
|
124
|
+
if not login:
|
|
125
|
+
return False
|
|
126
|
+
mapped = opts(cfg)["owner_logins"].get(owner)
|
|
127
|
+
return (mapped or "").lower() == login or owner.lower() == login or (len(login) >= 4 and login in owner.lower())
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def is_chase(cfg: dict, text: str) -> bool:
|
|
131
|
+
"""A short status question about a PR: an event on the existing item, not a new ask. A message with a brief marker
|
|
132
|
+
(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."""
|
|
134
|
+
sentences = [x.strip() for x in re.split(r"(?<=[.!?])\s+|\n", text)]
|
|
135
|
+
if DELIVERABLE.search(text) or any(DIRECTIVE.match(x) for x in sentences):
|
|
136
|
+
return False
|
|
137
|
+
return len(text) < int(opts(cfg)["min_chars"]) and bool(CHASE.search(text))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
QUEUED = re.compile(
|
|
141
|
+
r"(?i)\b(?:after|behind|once)\b[^.\n]{0,40}?(?:'s\b|\bqueue\b|\bbatch\b|\bfinish(?:es|ed)?\b|\bdone\b|\bcomplete[sd]?\b|\bahead\b)"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def queued_behind(text: str, summary: str = "") -> bool:
|
|
146
|
+
"""The ask says it waits behind other work ("after X's queue", "once Y finishes"): the owner has not started it."""
|
|
147
|
+
return bool(QUEUED.search(f"{summary}\n{text}"))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
DELIVERABLE = re.compile(r"(?i)done[- ]when|\boutput\b|\bdeliverable")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def is_deliverable_brief(cfg: dict, description: str, prompt: str) -> bool:
|
|
154
|
+
"""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."""
|
|
156
|
+
low = f"{description} {prompt}".lower()
|
|
157
|
+
if any(w.lower() in low for w in opts(cfg)["skip_briefs"]):
|
|
158
|
+
return False
|
|
159
|
+
return bool(DELIVERABLE.search(prompt) or PR_REF.search(prompt))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _is_reply(cfg: dict, owner: str, text: str, asked: list[str], now: float | None) -> bool:
|
|
163
|
+
"""The hub answering a peer who wrote to it recently, with no PR to work on and no brief, is not an ask."""
|
|
164
|
+
if asked or DELIVERABLE.search(text):
|
|
165
|
+
return False
|
|
166
|
+
wrote = peers(cfg).get(owner.lower(), 0.0)
|
|
167
|
+
return (now or time.time()) - wrote < float(opts(cfg)["reply_window_min"]) * 60
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def peers(cfg: dict) -> dict[str, float]:
|
|
171
|
+
"""Last time each peer wrote to the hub (from the transcript scans)."""
|
|
172
|
+
try:
|
|
173
|
+
return json.loads((store.state(cfg) / "peers.json").read_text(encoding="utf-8"))
|
|
174
|
+
except ValueError, OSError:
|
|
175
|
+
return {}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _note_peers(cfg: dict, events: list[tuple[float, str, str]]) -> None:
|
|
179
|
+
seen = peers(cfg)
|
|
180
|
+
for ts, name, _ in events:
|
|
181
|
+
if name and ts > seen.get(name.lower(), 0.0):
|
|
182
|
+
seen[name.lower()] = ts
|
|
183
|
+
store.write_atomic(store.state(cfg) / "peers.json", json.dumps(seen))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _is_follow_up(cfg: dict, owner: str, text: str) -> bool:
|
|
187
|
+
"""To an owner with open items: not an acknowledgement, names no PR, carries no new brief."""
|
|
188
|
+
return (
|
|
189
|
+
not ACK.match(text)
|
|
190
|
+
and not PR_REF.search(text)
|
|
191
|
+
and not DELIVERABLE.search(text)
|
|
192
|
+
and not any(s.lower() in owner.lower() for s in opts(cfg)["skip_owners"])
|
|
193
|
+
and any(i["owner"] == owner for i in items(cfg))
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def is_work(cfg: dict, owner: str, text: str, asked: list[str] | None = None) -> bool:
|
|
198
|
+
"""A message is routed work when it names a PR or is long; acknowledgements and the inbox watcher are not."""
|
|
199
|
+
o = opts(cfg)
|
|
200
|
+
if any(s.lower() in owner.lower() for s in o["skip_owners"]):
|
|
201
|
+
return False
|
|
202
|
+
if ACK.match(text) and not (DELIVERABLE.search(text) or DIRECTIVE.search(text) or REQUEST.search(text)):
|
|
203
|
+
return False # "Ack #399 hold": a bare acknowledgement that names a PR is not an ask; any request cue makes it one
|
|
204
|
+
if asked if asked is not None else PR_REF.search(text):
|
|
205
|
+
return True
|
|
206
|
+
if len(text) >= int(o["min_chars"]):
|
|
207
|
+
return not ACK.match(text)
|
|
208
|
+
return is_short_ask(text)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
SHORT_ASK = re.compile(
|
|
212
|
+
r"(?im)(?:^\W*|[.!?]\s+)(?:please|pls|can you|could you|would you|will you|check|look|fix|send|run|merge|deploy|rebase|take|handle|tell|report|reply|confirm|verify|investigate|find|start|stop|update|write|add|open|close|make)\b|\?\s*$"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def is_short_ask(text: str) -> bool:
|
|
217
|
+
"""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
|
+
return len(text.strip()) >= 15 and not ACK.match(text) and not CHASE.search(text) and bool(SHORT_ASK.search(text))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
REQUEST = re.compile(
|
|
223
|
+
r"(?i)\b(please|pls|needs?|needed|next|your|you|eyes|look|review|check|fix|merge|deploy|rebase|send|take|handle|findings?|can|could|would|should|must|do)\b|\?"
|
|
224
|
+
)
|
|
225
|
+
DIRECTIVE = re.compile(
|
|
226
|
+
r"(?i)\b(re-?review|review|re-?check|check|merge|approve|test|rebase|deploy|fix|land|verify|finish|start|continue)\b"
|
|
227
|
+
r"(?:[^.\n]|\.(?=\S)){0,60}?(?:https://github\.com/[\w.-]+/[\w.-]+/pull/\d+|#\d{2,}|\bpr\s*#?\d+)(?:[^.\n]|\.(?=\S)){0,300}"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def asked_refs(cfg: dict, text: str, summary: str = "", cap: int | None = MAX_QUEUE) -> list[str]:
|
|
232
|
+
"""The PRs the owner is asked to work on. A short message asks about every PR it names. In a long one only
|
|
233
|
+
the summary and sentences that direct an action at a PR count: a PR named in passing (a bug
|
|
234
|
+
list, a status) is not an ask."""
|
|
235
|
+
text = strip_examples(text)
|
|
236
|
+
if len(text) < int(opts(cfg)["min_chars"]):
|
|
237
|
+
return refs(cfg, f"{summary}\n{text}", cap)
|
|
238
|
+
spans = [summary, *(m.group(0) for m in DIRECTIVE.finditer(text))]
|
|
239
|
+
return refs(cfg, "\n".join(spans), cap)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def pr_num(ref: str) -> str:
|
|
243
|
+
"""The PR number at the end of a URL or `#N` reference."""
|
|
244
|
+
return ref.rstrip("/").rsplit("/", 1)[-1].lstrip("#")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def pr_key(ref: str) -> str:
|
|
248
|
+
"""Repo and number of a PR ref: `owner/repo#N` for a URL, `#N` for a bare number (repo not known)."""
|
|
249
|
+
parts = ref.rstrip("/").split("/")
|
|
250
|
+
return f"{parts[3]}/{parts[4]}#{parts[6]}".lower() if ref.startswith("http") and len(parts) >= 7 else "#" + pr_num(ref)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def same_pr(a: str, b: str) -> bool:
|
|
254
|
+
"""The same PR: equal repo and number, or one side is a bare number (repo unknown) with the same number."""
|
|
255
|
+
ka, kb = pr_key(a), pr_key(b)
|
|
256
|
+
return ka == kb or (("/" not in ka or "/" not in kb) and pr_num(a) == pr_num(b))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def overlaps(xs: list[str], ys: list[str]) -> bool:
|
|
260
|
+
"""Any PR in common."""
|
|
261
|
+
return any(same_pr(x, y) for x in xs for y in ys)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def covers(xs: list[str], ys: list[str]) -> bool:
|
|
265
|
+
"""Every PR of `xs` is in `ys`."""
|
|
266
|
+
return all(any(same_pr(x, y) for y in ys) for x in xs)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
REPO_REF = re.compile(r"(?<![\w/.-])([\w.-]+/[\w.-]+)#(\d{2,6})\b")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _repo_refs(cfg: dict, text: str) -> tuple[list[str], str]:
|
|
273
|
+
"""PRs named with their repo: `owner/repo#N`, or `<alias> N` / `<alias>#N` / `<alias> PR N` for a configured
|
|
274
|
+
`routed.repo_aliases` entry (alias -> owner/repo). Returns the URLs and the text with those spans removed, so
|
|
275
|
+
the number is not read again as a bare one in the default repo."""
|
|
276
|
+
out: list[str] = []
|
|
277
|
+
|
|
278
|
+
def take(repo: str, n: str) -> str:
|
|
279
|
+
out.append(f"https://github.com/{repo}/pull/{n}")
|
|
280
|
+
return " "
|
|
281
|
+
|
|
282
|
+
def repl(repo: str | None = None): # noqa: ANN202 - a re.sub callback
|
|
283
|
+
def inner(m: re.Match[str]) -> str:
|
|
284
|
+
return take(repo or m[1], m[2] if repo is None else m[1])
|
|
285
|
+
|
|
286
|
+
return inner
|
|
287
|
+
|
|
288
|
+
text = REPO_REF.sub(repl(), text)
|
|
289
|
+
for alias, repo in dict(opts(cfg).get("repo_aliases", {})).items():
|
|
290
|
+
text = re.sub(rf"(?i)(?<![\w/.-]){re.escape(alias)}(?:\s*#|\s+pr\s*#?\s*|\s+)(\d{{2,6}})\b", repl(repo), text)
|
|
291
|
+
return list(dict.fromkeys(out)), text
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def refs(cfg: dict, text: str, cap: int | None = MAX_PRS) -> list[str]:
|
|
295
|
+
"""PR refs in a text: full URLs, repo-qualified refs, and bare #N / PR N resolved against `routed.repo`
|
|
296
|
+
(else left as #N)."""
|
|
297
|
+
qualified, text = _repo_refs(cfg, text)
|
|
298
|
+
urls = list(dict.fromkeys([*PR_URL.findall(text), *qualified]))
|
|
299
|
+
seen = {pr_key(u) for u in urls}
|
|
300
|
+
repo = str(opts(cfg).get("repo", ""))
|
|
301
|
+
out = list(urls)
|
|
302
|
+
for n in dict.fromkeys(BARE.findall(text)):
|
|
303
|
+
ref = f"https://github.com/{repo}/pull/{n}" if repo else f"#{n}"
|
|
304
|
+
if pr_key(ref) not in seen:
|
|
305
|
+
seen.add(pr_key(ref))
|
|
306
|
+
out.append(ref)
|
|
307
|
+
return out[:cap]
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# ---- gh ----
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _gh(cfg: dict) -> str | None:
|
|
314
|
+
want = os.path.expanduser(cfg["tools"]["gh"])
|
|
315
|
+
if os.sep in want:
|
|
316
|
+
return want if os.access(want, os.X_OK) else None
|
|
317
|
+
found = shutil.which(want)
|
|
318
|
+
if found:
|
|
319
|
+
return found
|
|
320
|
+
for d in ("~/.local/bin", "/usr/local/bin", "/usr/bin"):
|
|
321
|
+
cand = os.path.join(os.path.expanduser(d), want)
|
|
322
|
+
if os.access(cand, os.X_OK):
|
|
323
|
+
return cand
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _epoch(iso: str) -> float:
|
|
328
|
+
return datetime.fromisoformat(iso).timestamp()
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def pr_info(cfg: dict, url: str, timeout: float) -> dict | None:
|
|
332
|
+
"""Head sha, PR author and every review, from gh; None (unknown) on any failure."""
|
|
333
|
+
exe = _gh(cfg)
|
|
334
|
+
if exe is None or timeout <= 0 or not url.startswith("http"):
|
|
335
|
+
return None
|
|
336
|
+
try:
|
|
337
|
+
r = subprocess.run(
|
|
338
|
+
[exe, "pr", "view", url, "--json", "headRefOid,reviews,author,commits"],
|
|
339
|
+
capture_output=True,
|
|
340
|
+
encoding="utf-8", errors="replace",
|
|
341
|
+
timeout=timeout,
|
|
342
|
+
check=False,
|
|
343
|
+
)
|
|
344
|
+
data = json.loads(r.stdout)
|
|
345
|
+
revs: list[dict] = [
|
|
346
|
+
{
|
|
347
|
+
"at": _epoch(x["submittedAt"]),
|
|
348
|
+
"state": str(x.get("state", "")),
|
|
349
|
+
"commit": str((x.get("commit") or {}).get("oid", "")),
|
|
350
|
+
"login": str((x.get("author") or {}).get("login", "")),
|
|
351
|
+
"body": str(x.get("body") or ""),
|
|
352
|
+
}
|
|
353
|
+
for x in data["reviews"]
|
|
354
|
+
if x.get("submittedAt")
|
|
355
|
+
]
|
|
356
|
+
return {
|
|
357
|
+
"head": str(data["headRefOid"]),
|
|
358
|
+
"reviews": revs,
|
|
359
|
+
"author": str((data.get("author") or {}).get("login", "")),
|
|
360
|
+
"review_at": max((x["at"] for x in revs), default=None),
|
|
361
|
+
"head_author": _last_author(data.get("commits")),
|
|
362
|
+
}
|
|
363
|
+
except OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError:
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _last_author(commits: object) -> str:
|
|
368
|
+
"""Login of the author of the PR's newest commit, else an empty string (unknown)."""
|
|
369
|
+
try:
|
|
370
|
+
return str(commits[-1]["authors"][0]["login"]) if commits else "" # type: ignore[index]
|
|
371
|
+
except KeyError, IndexError, TypeError:
|
|
372
|
+
return ""
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def counts_as_review(cfg: dict, rev: dict, info: dict) -> bool:
|
|
376
|
+
"""Does this review count as the review that was asked for? A non-author review does. On a PR written by the
|
|
377
|
+
hub's user (`user.github_login`) the reviewer peer posts from that same account, and GitHub allows only a
|
|
378
|
+
COMMENT there, so a COMMENT review with a body counts on such a PR. A COMMENT with an empty body is what a reply in
|
|
379
|
+
an inline thread leaves behind (gh gives each review's `body`), so it does not. `reviewer_logins`, when set,
|
|
380
|
+
limits who counts."""
|
|
381
|
+
need = opts(cfg)["reviewer_logins"]
|
|
382
|
+
if need and rev["login"] not in need:
|
|
383
|
+
return False
|
|
384
|
+
if rev["login"] != info["author"]:
|
|
385
|
+
return True
|
|
386
|
+
me = str(cfg["user"].get("github_login", "")).lower()
|
|
387
|
+
return (
|
|
388
|
+
bool(me)
|
|
389
|
+
and rev["login"].lower() == me
|
|
390
|
+
and rev["state"].upper().startswith("COMMENT")
|
|
391
|
+
and bool(rev["body"].strip())
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def same_head(a: str, b: str) -> bool:
|
|
396
|
+
"""True when two shas agree on their common prefix."""
|
|
397
|
+
return bool(a) and bool(b) and (a.startswith(b) or b.startswith(a))
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# ---- the items ----
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def items(cfg: dict) -> list[dict]:
|
|
404
|
+
"""All routed work items."""
|
|
405
|
+
return [i for i in work_load(store.state(cfg)) if i.get("kind") == "routed"]
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _event(item: dict, kind: str, text: str, now: float) -> None:
|
|
409
|
+
item["events"] = [
|
|
410
|
+
*item.get("events", []),
|
|
411
|
+
{"t": now, "kind": kind, "text": text[:160]},
|
|
412
|
+
][-MAX_EVENTS:]
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _merge_prs(old: list[str], new: list[str]) -> list[str]:
|
|
416
|
+
"""One ref per PR number; a full URL replaces a bare #N."""
|
|
417
|
+
merged: list[str] = []
|
|
418
|
+
for ref in [*old, *new]:
|
|
419
|
+
hit = next((k for k, m in enumerate(merged) if same_pr(m, ref)), None)
|
|
420
|
+
if hit is None:
|
|
421
|
+
merged.append(ref)
|
|
422
|
+
elif ref.startswith("http"):
|
|
423
|
+
merged[hit] = ref
|
|
424
|
+
return merged[:MAX_PRS]
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _same_job(i: dict, owner: str, prs: list[str], summary: str) -> bool:
|
|
428
|
+
if i.get("kind") != "routed" or i["owner"] != owner:
|
|
429
|
+
return False
|
|
430
|
+
if prs:
|
|
431
|
+
return overlaps([*i["prs"], *i.get("queue", [])], prs)
|
|
432
|
+
return not i["prs"] # no PR named: a follow-up to the same owner is the same outcome
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _chase(cfg: dict, item: dict, text: str, heads: dict[str, str], now: float) -> None:
|
|
436
|
+
"""A follow-up to the owner about work already on the item: counted, never a new clock. It answers a moved head
|
|
437
|
+
or an old-head verdict (the hub acted), so those flags clear and the new head becomes the baseline."""
|
|
438
|
+
_event(item, "chase", text, now)
|
|
439
|
+
item["chases"] = int(item.get("chases", 0)) + 1
|
|
440
|
+
item["addressed"] = [*item.get("addressed", []), now]
|
|
441
|
+
item["chased_level"] = eff_level(cfg, item, now)
|
|
442
|
+
v = item.get("verdict_old_head")
|
|
443
|
+
if v:
|
|
444
|
+
item["verdict_acked"] = [v["commit"], v["head"]]
|
|
445
|
+
item["head_moved"] = item["verdict_old_head"] = None
|
|
446
|
+
item["heads"] = {**item["heads"], **heads}
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def chase_owner(cfg: dict, owner: str, text: str, now: float | None = None) -> dict | None:
|
|
450
|
+
"""A message to an owner who has open items, naming no PR and carrying no new brief, is a follow-up on all of
|
|
451
|
+
them. Returns the first item, or None when the owner has none."""
|
|
452
|
+
now = now or time.time()
|
|
453
|
+
sd = store.state(cfg)
|
|
454
|
+
with store.locked(sd / "work.lock"):
|
|
455
|
+
all_ = work_load(sd)
|
|
456
|
+
mine = [i for i in all_ if i.get("kind") == "routed" and i["owner"] == owner]
|
|
457
|
+
for i in mine:
|
|
458
|
+
_chase(cfg, i, text[:120], {}, now)
|
|
459
|
+
if mine:
|
|
460
|
+
work_save(sd, all_)
|
|
461
|
+
return mine[0] if mine else None
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def record(
|
|
465
|
+
cfg: dict,
|
|
466
|
+
owner: str,
|
|
467
|
+
summary: str,
|
|
468
|
+
prs: list[str],
|
|
469
|
+
heads: dict[str, str],
|
|
470
|
+
tool_use_id: str = "",
|
|
471
|
+
now: float | None = None,
|
|
472
|
+
review: bool = False,
|
|
473
|
+
chase: bool = False,
|
|
474
|
+
brief: bool = False,
|
|
475
|
+
queue: list[str] | None = None,
|
|
476
|
+
waiting: bool = False,
|
|
477
|
+
pushes: bool = False,
|
|
478
|
+
estimate: int | None = None,
|
|
479
|
+
) -> dict:
|
|
480
|
+
"""Open the outcome, or refresh it (same owner and PR number). A chase only adds an event."""
|
|
481
|
+
now = now or time.time()
|
|
482
|
+
sd = store.state(cfg)
|
|
483
|
+
with store.locked(sd / "work.lock"):
|
|
484
|
+
all_ = work_load(sd)
|
|
485
|
+
named = [*prs, *(queue or [])]
|
|
486
|
+
hit = next((i for i in all_ if _same_job(i, owner, named, summary)), None)
|
|
487
|
+
follow_up = (
|
|
488
|
+
hit is not None
|
|
489
|
+
and not brief
|
|
490
|
+
and covers(named, [*hit["prs"], *hit.get("queue", [])])
|
|
491
|
+
)
|
|
492
|
+
if hit is not None and (chase or follow_up):
|
|
493
|
+
_chase(cfg, hit, summary, heads, now)
|
|
494
|
+
if not hit.get("queue"):
|
|
495
|
+
hit["prs"] = _merge_prs(hit["prs"], prs) # a full URL replaces a bare #N
|
|
496
|
+
work_save(sd, all_)
|
|
497
|
+
return hit
|
|
498
|
+
is_new = hit is None
|
|
499
|
+
if hit is None:
|
|
500
|
+
hit = {
|
|
501
|
+
"id": next_work_id(sd, all_),
|
|
502
|
+
"kind": "routed",
|
|
503
|
+
"owner": owner,
|
|
504
|
+
"note": "",
|
|
505
|
+
"added": now,
|
|
506
|
+
"last_heard": None,
|
|
507
|
+
"heads": {},
|
|
508
|
+
"prs": [],
|
|
509
|
+
"events": [],
|
|
510
|
+
"chases": 0,
|
|
511
|
+
}
|
|
512
|
+
all_.append(hit)
|
|
513
|
+
if is_new:
|
|
514
|
+
hit["behind"] = [i["id"] for i in all_ if i is not hit and i.get("kind") == "routed" and i["owner"] == owner]
|
|
515
|
+
for other in all_: # a brief on the same PRs to another owner is a re-brief: it addresses the old item
|
|
516
|
+
if other is not hit and other.get("kind") == "routed" and other["owner"] != owner and overlaps(named, [*other["prs"], *other.get("queue", [])]):
|
|
517
|
+
other["addressed"] = [*other.get("addressed", []), now]
|
|
518
|
+
_event(hit, "ask", summary, now)
|
|
519
|
+
hit.update(
|
|
520
|
+
title=summary,
|
|
521
|
+
routed_at=now,
|
|
522
|
+
prs=prs if queue else _merge_prs(hit["prs"], prs),
|
|
523
|
+
queue=queue or [],
|
|
524
|
+
waiting=waiting,
|
|
525
|
+
verdict_acked=None,
|
|
526
|
+
review=review,
|
|
527
|
+
pushes=pushes,
|
|
528
|
+
state_ask=chase and not brief,
|
|
529
|
+
tool_use_id=tool_use_id or hit.get("tool_use_id", ""),
|
|
530
|
+
head_moved=None,
|
|
531
|
+
verdict_old_head=None,
|
|
532
|
+
chased_level=None,
|
|
533
|
+
)
|
|
534
|
+
_set_estimate(cfg, hit, estimate, now)
|
|
535
|
+
hit["heads"] = {
|
|
536
|
+
**hit["heads"],
|
|
537
|
+
**heads,
|
|
538
|
+
} # re-asking resets the baseline: the hub has acted on the new head
|
|
539
|
+
work_save(sd, all_)
|
|
540
|
+
return hit
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def from_estimate(text: str) -> int | None:
|
|
544
|
+
"""Minutes of the brief's `Estimate: <n>m` line, else None."""
|
|
545
|
+
from . import slow
|
|
546
|
+
|
|
547
|
+
return slow.parse_estimate(text)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _set_estimate(cfg: dict, item: dict, given: int | None, now: float) -> None:
|
|
551
|
+
"""A new ask carries an estimate: the brief's `Estimate:` line, else the usual time for its step (defaulted)."""
|
|
552
|
+
from . import slow
|
|
553
|
+
|
|
554
|
+
mins, basis = (float(given), "brief") if given else slow.item_default(cfg, item)
|
|
555
|
+
item["estimates"] = [*item.get("estimates", []), {"t": now, "min": mins, "source": "brief" if given else "default", "basis": basis}]
|
|
556
|
+
item.update(estimate_min=mins, estimate_defaulted=not given, estimate_basis=basis, addressed=[])
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def from_tool(
|
|
560
|
+
cfg: dict,
|
|
561
|
+
tool: str,
|
|
562
|
+
ti: dict,
|
|
563
|
+
resp: object,
|
|
564
|
+
tool_use_id: str,
|
|
565
|
+
now: float | None = None,
|
|
566
|
+
) -> dict | None:
|
|
567
|
+
"""The item for one SendMessage / Agent call, or None when the call is not routed work."""
|
|
568
|
+
chase = False
|
|
569
|
+
if tool == "SendMessage":
|
|
570
|
+
owner, text = str(ti.get("to") or ti.get("recipient") or ""), ti.get("message")
|
|
571
|
+
if not owner or not isinstance(text, str):
|
|
572
|
+
return None
|
|
573
|
+
if _is_follow_up(cfg, owner, text):
|
|
574
|
+
return chase_owner(cfg, owner, str(ti.get("summary") or text), now)
|
|
575
|
+
asked = asked_refs(cfg, text, str(ti.get("summary") or ""))
|
|
576
|
+
if _is_reply(cfg, owner, text, asked, now) or not is_work(cfg, owner, text, asked):
|
|
577
|
+
return None
|
|
578
|
+
summary = str(ti.get("summary") or text.splitlines()[0])[:120]
|
|
579
|
+
chase = is_chase(cfg, text)
|
|
580
|
+
elif tool == "Agent" and ti.get(
|
|
581
|
+
"run_in_background"
|
|
582
|
+
): # a foreground agent hands its result back in-turn
|
|
583
|
+
text = str(ti.get("prompt") or "")
|
|
584
|
+
if not is_deliverable_brief(cfg, str(ti.get("description") or ""), text):
|
|
585
|
+
return None
|
|
586
|
+
found = re.search(r"agentId: (\w+)", resp) if isinstance(resp, str) else None
|
|
587
|
+
ident = (
|
|
588
|
+
resp.get("agentId")
|
|
589
|
+
if isinstance(resp, dict)
|
|
590
|
+
else (found.group(1) if found else None)
|
|
591
|
+
)
|
|
592
|
+
owner = str(ident or "agent:" + str(ti.get("description", "")))
|
|
593
|
+
summary = str(ti.get("description") or text.splitlines()[0])[:120]
|
|
594
|
+
else:
|
|
595
|
+
return None
|
|
596
|
+
every = asked_refs(cfg, text, summary)
|
|
597
|
+
queue = every if len(every) > 1 else [] # one PR per clock: several PRs are a queue, the first is current
|
|
598
|
+
prs = every[:1] if queue else every
|
|
599
|
+
heads: dict[str, str] = {}
|
|
600
|
+
authored = False
|
|
601
|
+
deadline = time.time() + 3.0 # the hook is synchronous: 3 s in total for baselines
|
|
602
|
+
for url in prs:
|
|
603
|
+
info = pr_info(cfg, url, deadline - time.time())
|
|
604
|
+
if info:
|
|
605
|
+
heads[url] = info["head"]
|
|
606
|
+
authored = authored or owner_is_author(cfg, owner, info["author"])
|
|
607
|
+
return record(
|
|
608
|
+
cfg,
|
|
609
|
+
owner,
|
|
610
|
+
summary,
|
|
611
|
+
prs,
|
|
612
|
+
heads,
|
|
613
|
+
tool_use_id,
|
|
614
|
+
now,
|
|
615
|
+
bool(prs) and not authored and not chase and is_review(cfg, text, summary),
|
|
616
|
+
chase,
|
|
617
|
+
bool(DELIVERABLE.search(text)),
|
|
618
|
+
queue,
|
|
619
|
+
queued_behind(text, summary),
|
|
620
|
+
pushes_to_pr(cfg, text, summary) and not chase,
|
|
621
|
+
from_estimate(text),
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
# ---- clocks and the ladder ----
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def started_at(cfg: dict, item: dict) -> float:
|
|
629
|
+
"""When the owner is expected to start: the ask, or for work queued behind other work, `queued_wait_min` later
|
|
630
|
+
(until a review shows on the PR)."""
|
|
631
|
+
t = item.get("routed_at") or item["added"]
|
|
632
|
+
return t + float(opts(cfg)["queued_wait_min"]) * 60 if item.get("waiting") else t
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def base(cfg: dict, item: dict) -> float:
|
|
636
|
+
"""Last word from the owner: the later of the start and last heard."""
|
|
637
|
+
return max(started_at(cfg, item), item.get("last_heard") or 0.0)
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def limit(cfg: dict, item: dict) -> float:
|
|
641
|
+
"""Stall limit in minutes: reviews are held to the shorter review clock."""
|
|
642
|
+
o = opts(cfg)
|
|
643
|
+
return float(o["review_stall_min"] if item.get("review") else o["stall_min"])
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def overdue(cfg: dict, item: dict, now: float) -> int | None:
|
|
647
|
+
"""Minutes since the ask when a review passes its hard limit (hearing from the owner does not stop it)."""
|
|
648
|
+
age = int((now - started_at(cfg, item)) / 60)
|
|
649
|
+
return (
|
|
650
|
+
age
|
|
651
|
+
if item.get("review") and age > float(opts(cfg)["review_overdue_min"])
|
|
652
|
+
else None
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def level(cfg: dict, item: dict, now: float) -> int | None:
|
|
657
|
+
"""Ladder step by silence: 0 past 1x the limit, 1 past 2x, 2 past 3x; None while within the limit."""
|
|
658
|
+
lim = limit(cfg, item)
|
|
659
|
+
idle = (now - base(cfg, item)) / 60
|
|
660
|
+
return None if idle <= lim else min(int(idle // lim) - 1, 2)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def eff_level(cfg: dict, item: dict, now: float) -> int | None:
|
|
664
|
+
"""level(), but a review past its hard limit counts as at least step 1."""
|
|
665
|
+
lvl = level(cfg, item, now)
|
|
666
|
+
return (
|
|
667
|
+
max(lvl if lvl is not None else 0, 1)
|
|
668
|
+
if overdue(cfg, item, now) is not None
|
|
669
|
+
else lvl
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def needs_action(cfg: dict, item: dict, now: float) -> bool:
|
|
674
|
+
"""Something the hub must do now: a moved head, a verdict on an old head, or a silence not yet chased at this step."""
|
|
675
|
+
if item.get("head_moved") or item.get("verdict_old_head"):
|
|
676
|
+
return True
|
|
677
|
+
from . import watcher
|
|
678
|
+
|
|
679
|
+
lvl = eff_level(cfg, item, now)
|
|
680
|
+
return (
|
|
681
|
+
lvl is not None
|
|
682
|
+
and (item.get("chased_level") is None or item["chased_level"] < lvl)
|
|
683
|
+
and watcher.excused(cfg, item, now) is None
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _pr_no(ref: str) -> str:
|
|
688
|
+
return "#" + pr_num(ref)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _label(item: dict) -> str:
|
|
692
|
+
prs = " ".join(_pr_no(u) for u in item["prs"])
|
|
693
|
+
if item.get("queue"):
|
|
694
|
+
prs += f" (+{len(item['queue']) - 1} queued)"
|
|
695
|
+
return f"{item['id']} ({item['title'][:50]}{' ' + prs if prs else ''})"
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def next_action(cfg: dict, item: dict, now: float) -> str:
|
|
699
|
+
"""The single next action for one item."""
|
|
700
|
+
o = item["owner"]
|
|
701
|
+
m, v = item.get("head_moved"), item.get("verdict_old_head")
|
|
702
|
+
if m:
|
|
703
|
+
return f"SendMessage {o}: {_pr_no(m['url'])} head moved {m['old'][:7]} -> {m['new'][:7]}; ask for a re-check at the new head"
|
|
704
|
+
if v:
|
|
705
|
+
return f"SendMessage {o}: the {v['state']} review landed at {v['commit'][:7]} but the head is {v['head'][:7]}; ask for a re-review at the head"
|
|
706
|
+
lvl = eff_level(cfg, item, now)
|
|
707
|
+
if lvl is not None:
|
|
708
|
+
if item.get("chased_level") is not None and item["chased_level"] >= lvl:
|
|
709
|
+
nxt = base(cfg, item) + limit(cfg, item) * 60 * (lvl + 2)
|
|
710
|
+
return f"chased {item.get('chases', 0)}x, waiting; escalate to step {lvl + 1} at {store.clock(cfg, nxt)}"
|
|
711
|
+
heard = item.get("last_heard")
|
|
712
|
+
if lvl >= 1 and heard and now - heard < limit(cfg, item) * 60:
|
|
713
|
+
return f"SendMessage {o} for an ETA on {' '.join(_pr_no(u) for u in item['prs']) or 'the work'}; it reported {int((now - heard) / 60)} min ago, so do not re-brief it"
|
|
714
|
+
steps = (
|
|
715
|
+
f"SendMessage {o} for a one-line state",
|
|
716
|
+
"re-brief this work to another idle peer or a fresh agent (ListAgents first); the new brief opens its own item",
|
|
717
|
+
"tell the user in the next report in one line (no card), then decide the executor",
|
|
718
|
+
)
|
|
719
|
+
return steps[lvl]
|
|
720
|
+
if item.get("pr_unknown"):
|
|
721
|
+
if any(not u.startswith("http") for u in item["pr_unknown"]):
|
|
722
|
+
return "a PR is a bare #N and routed.repo is not set: set [routed] repo in config.toml (or work add --pr with the full URL)"
|
|
723
|
+
return (
|
|
724
|
+
"gh could not read the PR: have an agent read it; nothing is known about it"
|
|
725
|
+
)
|
|
726
|
+
if item.get("waiting") and now < started_at(cfg, item):
|
|
727
|
+
return f"queued behind other work: no clock until {store.clock(cfg, started_at(cfg, item))} or until a review shows on the PR; then chase {o}"
|
|
728
|
+
return f"wait for {o}; next check {store.clock(cfg, base(cfg, item) + limit(cfg, item) * 60)} (limit {int(limit(cfg, item))} min)"
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _why(cfg: dict, item: dict, now: float) -> str:
|
|
732
|
+
if item.get("head_moved"):
|
|
733
|
+
m = item["head_moved"]
|
|
734
|
+
return f"{_pr_no(m['url'])} head moved {m['old'][:7]} -> {m['new'][:7]} after the ask"
|
|
735
|
+
if item.get("verdict_old_head"):
|
|
736
|
+
return "the review landed on an old head"
|
|
737
|
+
late = overdue(cfg, item, now)
|
|
738
|
+
idle = int((now - base(cfg, item)) / 60)
|
|
739
|
+
if late is not None:
|
|
740
|
+
return f"review asked {late} min ago, past the hard limit of {opts(cfg)['review_overdue_min']} min"
|
|
741
|
+
return f"nothing for {idle} min (limit {int(limit(cfg, item))})"
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def prompt_lines(cfg: dict, now: float) -> list[str]:
|
|
745
|
+
"""One line per owner that needs the hub, most urgent first. The action is always one NEXT."""
|
|
746
|
+
by_owner: dict[str, list[dict]] = {}
|
|
747
|
+
for i in items(cfg):
|
|
748
|
+
if needs_action(cfg, i, now):
|
|
749
|
+
by_owner.setdefault(i["owner"], []).append(i)
|
|
750
|
+
out = []
|
|
751
|
+
for owner, its in by_owner.items():
|
|
752
|
+
tag = (
|
|
753
|
+
"OVERDUE"
|
|
754
|
+
if any(overdue(cfg, i, now) is not None for i in its)
|
|
755
|
+
else "STALLED"
|
|
756
|
+
)
|
|
757
|
+
facts = "; ".join(f"{_label(i)}: {_why(cfg, i, now)}" for i in its)
|
|
758
|
+
out.append(
|
|
759
|
+
f"hubkit {tag}: {owner}: {facts}. NEXT: {next_action(cfg, its[0], now)}"
|
|
760
|
+
)
|
|
761
|
+
return out
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def slow_why(cfg: dict, item: dict, now: float) -> str:
|
|
765
|
+
"""What the holder is doing, from real signals (see slow.why)."""
|
|
766
|
+
from . import slow
|
|
767
|
+
|
|
768
|
+
return slow.why(cfg, item["owner"], item, now, False)[0]
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def alerts(cfg: dict, now: float) -> list[dict]:
|
|
772
|
+
"""Tick alerts, one per item and ladder step; back-off is applied by the tick."""
|
|
773
|
+
out = []
|
|
774
|
+
for i in items(cfg):
|
|
775
|
+
m = i.get("head_moved")
|
|
776
|
+
if m:
|
|
777
|
+
out.append(
|
|
778
|
+
{
|
|
779
|
+
"key": f"{i['id']}:head_moved:{m['new'][:8]}",
|
|
780
|
+
"msg": (
|
|
781
|
+
f"{_label(i)} to {i['owner']}: {_why(cfg, i, now)}. NEXT: {next_action(cfg, i, now)}"
|
|
782
|
+
),
|
|
783
|
+
}
|
|
784
|
+
)
|
|
785
|
+
lvl = eff_level(cfg, i, now)
|
|
786
|
+
if lvl is not None and needs_action(cfg, i, now): # a chased item waits: no alert, no wake-up
|
|
787
|
+
out.append(
|
|
788
|
+
{
|
|
789
|
+
"key": f"{i['id']}:routed_stalled:L{lvl}",
|
|
790
|
+
"msg": (
|
|
791
|
+
f"{_label(i)} to {i['owner']}: {_why(cfg, i, now)}; holder: {slow_why(cfg, i, now)}. NEXT: {next_action(cfg, i, now)}"
|
|
792
|
+
),
|
|
793
|
+
}
|
|
794
|
+
)
|
|
795
|
+
return out
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def block_lines(cfg: dict, work: list[dict], now: float) -> list[str]:
|
|
799
|
+
"""Status-block lines for routed work: owner, age, last heard and the NEXT action."""
|
|
800
|
+
mine = [i for i in work if i.get("kind") == "routed"]
|
|
801
|
+
if not mine:
|
|
802
|
+
return []
|
|
803
|
+
out = ["routed work (owner, age, last heard, NEXT):"]
|
|
804
|
+
for i in mine:
|
|
805
|
+
heard = int((now - i["last_heard"]) / 60) if i.get("last_heard") else None
|
|
806
|
+
pr = ", ".join(
|
|
807
|
+
f"{_pr_no(u)}@{i['heads'].get(u, '?')[:7]}" for u in i.get("prs", [])
|
|
808
|
+
)
|
|
809
|
+
flags = (f" +{len(i['queue']) - 1} queued" if i.get("queue") else "") + (" REVIEW" if i.get("review") else "") + (
|
|
810
|
+
" PR unknown" if i.get("pr_unknown") else ""
|
|
811
|
+
)
|
|
812
|
+
out.append(
|
|
813
|
+
f" {i['id']} -> {i['owner']}: {i['title'][:50]}{' [' + pr + ']' if pr else ''}{flags}; "
|
|
814
|
+
f"age {int((now - i.get('routed_at', i['added'])) / 60)}m, last heard "
|
|
815
|
+
f"{'never' if heard is None else str(heard) + 'm ago'}, chased {i.get('chases', 0)}x"
|
|
816
|
+
)
|
|
817
|
+
out.append(f" NEXT: {next_action(cfg, i, now)}")
|
|
818
|
+
return out
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
# ---- heard-from ----
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def heard(cfg: dict, events: list[tuple[float, str, str]]) -> int:
|
|
825
|
+
"""events: (epoch, from-name or "", free text). Marks items whose owner or tool use id is named."""
|
|
826
|
+
if not events:
|
|
827
|
+
return 0
|
|
828
|
+
_note_peers(cfg, events)
|
|
829
|
+
sd = store.state(cfg)
|
|
830
|
+
n = 0
|
|
831
|
+
with store.locked(sd / "work.lock"):
|
|
832
|
+
all_ = work_load(sd)
|
|
833
|
+
for i in all_:
|
|
834
|
+
if i.get("kind") != "routed":
|
|
835
|
+
continue
|
|
836
|
+
for ts, name, text in events:
|
|
837
|
+
by_text = not name and (
|
|
838
|
+
i["owner"] in text
|
|
839
|
+
or bool(i.get("tool_use_id") and i["tool_use_id"] in text)
|
|
840
|
+
)
|
|
841
|
+
if by_text and COMPLETED.search(text) and ts > (i.get("routed_at") or i["added"]):
|
|
842
|
+
i["reported"] = ts # the agent finished after this ask and reported
|
|
843
|
+
if not i.get("review"): # a review item stays: the review check on the PR decides
|
|
844
|
+
i["answered"] = True
|
|
845
|
+
i["done_note"] = "agent completed (task notification)"
|
|
846
|
+
from_owner = bool(name) and name.lower() == i["owner"].lower()
|
|
847
|
+
if from_owner and not i.get("review") and ts > (i.get("routed_at") or i["added"]) and is_report(text) \
|
|
848
|
+
and _report_is_about(all_, i, text):
|
|
849
|
+
i["answered"] = True # the owner reported its result to the hub; a review item waits for the PR's own review
|
|
850
|
+
i["done_note"] = "owner reported its result (message to the hub)"
|
|
851
|
+
if (name.lower() == i["owner"].lower() or by_text) and ts > (
|
|
852
|
+
i.get("last_heard") or 0
|
|
853
|
+
):
|
|
854
|
+
i["last_heard"] = ts
|
|
855
|
+
n += 1
|
|
856
|
+
if i.get("state_ask") and ts > (i.get("routed_at") or 0):
|
|
857
|
+
i["answered"] = True # a state question is answered by one reply
|
|
858
|
+
closing = [x for x in all_ if x.get("answered")]
|
|
859
|
+
for i in closing:
|
|
860
|
+
_archive(cfg, i, i.get("done_note") or "state question answered", time.time())
|
|
861
|
+
all_.remove(i)
|
|
862
|
+
if n or closing:
|
|
863
|
+
work_save(sd, all_)
|
|
864
|
+
return n
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
COMPLETED = re.compile(r"<status>\s*completed\s*</status>")
|
|
868
|
+
PEER_BODY = re.compile(r"<cross-session-message[^>]*>(.*?)(?:</cross-session-message>|\Z)", re.S)
|
|
869
|
+
PEER_RESULT = re.compile(r"(?i)\b(done|finished|complete[d]?|merged|pushed|passed|passing|green|fixed|shipped|result|ready for)\b")
|
|
870
|
+
PEER_NOT_RESULT = re.compile(r"(?i)\b(blocked|failed|failing|cannot|can't|couldn't|unable|error|stuck|waiting)\b")
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _report_is_about(all_: list[dict], item: dict, text: str) -> bool:
|
|
874
|
+
"""A report closes the owner's only item; when the owner holds several, it must name this item's PR or id."""
|
|
875
|
+
mine = [x for x in all_ if x.get("kind") == "routed" and x["owner"] == item["owner"]]
|
|
876
|
+
return len(mine) == 1 or any(pr_num(u) in re.findall(r"\d+", text) for u in item["prs"]) or item["id"] in text
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def is_report(text: str) -> bool:
|
|
880
|
+
"""A peer's message reports a result: a result word, no blocker word, and it does not end on a question."""
|
|
881
|
+
m = PEER_BODY.search(text)
|
|
882
|
+
body = (m[1] if m else text).strip()
|
|
883
|
+
return bool(body) and not body.endswith("?") and bool(PEER_RESULT.search(body)) and not PEER_NOT_RESULT.search(body)
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def peer_name(text: str) -> str:
|
|
887
|
+
"""Name of the peer a message came from, or an empty string."""
|
|
888
|
+
m = FROM_NAME.search(text)
|
|
889
|
+
return m.group(1) if m else ""
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def _entry_event(o: dict) -> tuple[str, str] | None:
|
|
893
|
+
"""(from-name, text) of a peer message or task notification entry in a transcript, else None.
|
|
894
|
+
|
|
895
|
+
Two shapes carry them. A queue-operation entry holds the raw text the moment the message is queued
|
|
896
|
+
(while the hub is busy, this is the only entry for a long time). A user entry with origin.kind
|
|
897
|
+
peer / task-notification is written when the hub takes the message up.
|
|
898
|
+
"""
|
|
899
|
+
if o.get("type") == "queue-operation":
|
|
900
|
+
text = o.get("content") if o.get("operation") == "enqueue" else None
|
|
901
|
+
text = text.lstrip() if isinstance(text, str) else ""
|
|
902
|
+
if text.startswith("<cross-session-message"):
|
|
903
|
+
return peer_name(text), text
|
|
904
|
+
return ("", text) if text.startswith("<task-notification") else None
|
|
905
|
+
kind = (o.get("origin") or {}).get("kind")
|
|
906
|
+
if o.get("type") != "user" or kind not in ("peer", "task-notification"):
|
|
907
|
+
return None
|
|
908
|
+
c = o.get("message", {}).get("content")
|
|
909
|
+
text = c if isinstance(c, str) else json.dumps(c)
|
|
910
|
+
return (peer_name(text), text) if kind == "peer" else ("", text)
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def scan_transcript(cfg: dict, path: str | Path | None) -> int:
|
|
914
|
+
"""Incremental scan of the hub transcript: peer messages and task notifications are 'heard from'."""
|
|
915
|
+
if not path:
|
|
916
|
+
return 0
|
|
917
|
+
p = Path(path)
|
|
918
|
+
sp = store.state(cfg) / "routed-scan.json"
|
|
919
|
+
try:
|
|
920
|
+
size = p.stat().st_size
|
|
921
|
+
except OSError:
|
|
922
|
+
return 0
|
|
923
|
+
try:
|
|
924
|
+
seen = json.loads(sp.read_text(encoding="utf-8"))
|
|
925
|
+
except ValueError, OSError:
|
|
926
|
+
seen = {}
|
|
927
|
+
prev = seen.get(str(p))
|
|
928
|
+
first = not isinstance(prev, int) or prev > size
|
|
929
|
+
off: int = max(0, size - SCAN_TAIL) if first or not isinstance(prev, int) else prev
|
|
930
|
+
with open(p, "rb") as fh:
|
|
931
|
+
fh.seek(off)
|
|
932
|
+
data = fh.read()
|
|
933
|
+
cut = data.rfind(b"\n") + 1 # only whole lines
|
|
934
|
+
lines = data[:cut].decode("utf-8", "replace").splitlines()
|
|
935
|
+
if first and off > 0 and lines:
|
|
936
|
+
lines = lines[1:] # the first line may start mid-record
|
|
937
|
+
events = []
|
|
938
|
+
for line in lines:
|
|
939
|
+
if "cross-session-message" not in line and "task-notification" not in line:
|
|
940
|
+
continue
|
|
941
|
+
try:
|
|
942
|
+
o = json.loads(line)
|
|
943
|
+
hit = _entry_event(o)
|
|
944
|
+
if hit:
|
|
945
|
+
events.append((_epoch(o["timestamp"]), *hit))
|
|
946
|
+
except ValueError, KeyError:
|
|
947
|
+
continue
|
|
948
|
+
seen[str(p)] = off + cut
|
|
949
|
+
store.write_atomic(sp, json.dumps(seen))
|
|
950
|
+
return heard(cfg, events)
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def transcript_of(cfg: dict, session_id: str) -> str | None:
|
|
954
|
+
"""Path of the transcript file for a session id, or None."""
|
|
955
|
+
for f in glob.glob(os.path.expanduser(cfg["paths"]["transcripts_glob"])):
|
|
956
|
+
if os.path.basename(f) == f"{session_id}.jsonl":
|
|
957
|
+
return f
|
|
958
|
+
return None
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
# ---- PR check, once per PR per tick ----
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def _archive(cfg: dict, item: dict, note: str, now: float) -> None:
|
|
965
|
+
day = store.day_of(cfg, now)
|
|
966
|
+
store.append_row(
|
|
967
|
+
store.state(cfg) / "archive" / f"archive_{day}.jsonl",
|
|
968
|
+
{"type": "work", **item, "outcome": "done", "done_note": note, "ts": now},
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def _landed(
|
|
973
|
+
cfg: dict, item: dict, got: dict[str, dict | None]
|
|
974
|
+
) -> dict[str, tuple[str, dict | None]]:
|
|
975
|
+
"""Per PR of a review item: ('closed'|'old'|'', detail). The newest review since the ask, from someone but the
|
|
976
|
+
author, judged against that PR's current head."""
|
|
977
|
+
out: dict[str, tuple[str, dict | None]] = {}
|
|
978
|
+
for u in item["prs"]:
|
|
979
|
+
info = got.get(u)
|
|
980
|
+
revs = [
|
|
981
|
+
r
|
|
982
|
+
for r in (info["reviews"] if info else [])
|
|
983
|
+
if r["at"] >= (item.get("routed_at") or item["added"])
|
|
984
|
+
and counts_as_review(cfg, r, info)
|
|
985
|
+
] if info else []
|
|
986
|
+
if not info or not revs:
|
|
987
|
+
out[u] = ("", None)
|
|
988
|
+
continue
|
|
989
|
+
last = max(revs, key=lambda r: r["at"])
|
|
990
|
+
if same_head(last["commit"], info["head"]):
|
|
991
|
+
out[u] = ("closed", {**last, "url": u})
|
|
992
|
+
else:
|
|
993
|
+
out[u] = ("old", {**last, "head": info["head"], "url": u})
|
|
994
|
+
return out
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
HEX_HEAD = re.compile(r"[0-9a-f]{7,40}")
|
|
998
|
+
AGENT_ID = re.compile(r"^[0-9a-f]{12,}$")
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def agent_activity(cfg: dict, now: float) -> int:
|
|
1002
|
+
"""A background agent whose transcript or output file is still growing is not silent: count the file's
|
|
1003
|
+
modification time as heard-from. Returns the number of items refreshed."""
|
|
1004
|
+
sd = store.state(cfg)
|
|
1005
|
+
pats = opts(cfg)["activity_globs"]
|
|
1006
|
+
n = 0
|
|
1007
|
+
with store.locked(sd / "work.lock"):
|
|
1008
|
+
all_ = work_load(sd)
|
|
1009
|
+
for i in all_:
|
|
1010
|
+
if i.get("kind") != "routed" or not AGENT_ID.match(i["owner"]):
|
|
1011
|
+
continue
|
|
1012
|
+
stamps = [
|
|
1013
|
+
m
|
|
1014
|
+
for pat in pats
|
|
1015
|
+
for f in glob.glob(os.path.expanduser(pat.format(id=i["owner"])))
|
|
1016
|
+
if (m := _mtime(f)) is not None
|
|
1017
|
+
]
|
|
1018
|
+
seen = min(max(stamps, default=0.0), now)
|
|
1019
|
+
if seen > base(cfg, i):
|
|
1020
|
+
i["last_heard"] = seen
|
|
1021
|
+
n += 1
|
|
1022
|
+
if n:
|
|
1023
|
+
work_save(sd, all_)
|
|
1024
|
+
return n
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def _mtime(path: str) -> float | None:
|
|
1028
|
+
try:
|
|
1029
|
+
return os.path.getmtime(path)
|
|
1030
|
+
except OSError:
|
|
1031
|
+
return None
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def _advance(item: dict, det: dict, now: float) -> dict:
|
|
1035
|
+
"""A queue item's current PR has its review: move to the next PR with a fresh clock."""
|
|
1036
|
+
done, rest = item["queue"][0], item["queue"][1:]
|
|
1037
|
+
item["queue"] = rest if len(rest) > 1 else []
|
|
1038
|
+
item["prs"] = rest[:1]
|
|
1039
|
+
item["heads"] = {}
|
|
1040
|
+
item.update(routed_at=now, head_moved=None, verdict_old_head=None, verdict_acked=None, chased_level=None)
|
|
1041
|
+
_event(item, "advance", f"{_pr_no(done)} reviewed; now {_pr_no(rest[0]) if rest else 'none'}", now)
|
|
1042
|
+
return {
|
|
1043
|
+
"key": f"{item['id']}:queue_advanced:{pr_num(done)}",
|
|
1044
|
+
"msg": 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",
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def _close_landed(cfg: dict, item: dict, done: dict[str, dict], now: float) -> dict:
|
|
1049
|
+
"""Reviews landed at the head for some PRs of a (non-queue) item: drop them from the item and report the gaps.
|
|
1050
|
+
The item is archived when no PR is left."""
|
|
1051
|
+
item["prs"] = [u for u in item["prs"] if u not in done]
|
|
1052
|
+
for u in done:
|
|
1053
|
+
item["heads"].pop(u, None)
|
|
1054
|
+
note = "; ".join(
|
|
1055
|
+
f"{_pr_no(u)} {d['state']} by {d['login']} at {d['commit'][:7]} (the head)"
|
|
1056
|
+
for u, d in done.items()
|
|
1057
|
+
)
|
|
1058
|
+
gap = f" Still open: {' '.join(_pr_no(u) for u in item['prs'])} (no review at its head yet)." if item["prs"] else ""
|
|
1059
|
+
if not item["prs"]:
|
|
1060
|
+
_archive(cfg, item, "review " + note, now)
|
|
1061
|
+
first = next(iter(done.values()))
|
|
1062
|
+
return {
|
|
1063
|
+
"key": f"{item['id']}:review_landed:{pr_num(next(iter(done)))}",
|
|
1064
|
+
"msg": f"{item['id']} to {item['owner']}: {note}.{gap} NEXT: read the review on GitHub and act on the {first['state']} verdict",
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
def resolve_bare_refs(cfg: dict) -> None:
|
|
1069
|
+
"""Items recorded before `routed.repo` was set (or by hand) hold bare #N refs gh cannot read: resolve them."""
|
|
1070
|
+
repo = str(opts(cfg).get("repo", ""))
|
|
1071
|
+
if not repo:
|
|
1072
|
+
return
|
|
1073
|
+
|
|
1074
|
+
def fix(u: str) -> str:
|
|
1075
|
+
return u if u.startswith("http") else f"https://github.com/{repo}/pull/{pr_num(u)}"
|
|
1076
|
+
|
|
1077
|
+
sd = store.state(cfg)
|
|
1078
|
+
with store.locked(sd / "work.lock"):
|
|
1079
|
+
all_ = work_load(sd)
|
|
1080
|
+
if not any(not u.startswith("http") for i in all_ if i.get("kind") == "routed" for u in [*i["prs"], *i.get("queue", [])]):
|
|
1081
|
+
return
|
|
1082
|
+
for i in all_:
|
|
1083
|
+
if i.get("kind") == "routed":
|
|
1084
|
+
i["prs"] = [fix(u) for u in i["prs"]]
|
|
1085
|
+
i["queue"] = [fix(u) for u in i.get("queue", [])]
|
|
1086
|
+
i["heads"] = {fix(u): h for u, h in i["heads"].items()}
|
|
1087
|
+
work_save(sd, all_)
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def refresh_prs(cfg: dict, now: float) -> list[dict]:
|
|
1091
|
+
"""Read head and reviews with gh once per PR. A failure marks the PRs unknown. A moved head is recorded for
|
|
1092
|
+
the alert (it does not reset the stall clock: the pusher is not known). A review after the ask counts as
|
|
1093
|
+
movement. A review item closes when a review lands at the PR's current head; a review on an older head never
|
|
1094
|
+
closes it. Returns the alerts for items closed here."""
|
|
1095
|
+
sd = store.state(cfg)
|
|
1096
|
+
resolve_bare_refs(cfg)
|
|
1097
|
+
todo = {u for i in items(cfg) for u in i["prs"]}
|
|
1098
|
+
if not todo:
|
|
1099
|
+
return []
|
|
1100
|
+
timeout = float(opts(cfg)["gh_timeout_s"])
|
|
1101
|
+
got = {u: pr_info(cfg, u, timeout) for u in sorted(todo)}
|
|
1102
|
+
msgs: list[dict] = []
|
|
1103
|
+
with store.locked(sd / "work.lock"):
|
|
1104
|
+
all_ = work_load(sd)
|
|
1105
|
+
keep = []
|
|
1106
|
+
for i in all_:
|
|
1107
|
+
if i.get("kind") != "routed":
|
|
1108
|
+
keep.append(i)
|
|
1109
|
+
continue
|
|
1110
|
+
i["pr_unknown"] = [u for u in i["prs"] if got.get(u) is None]
|
|
1111
|
+
for u in i["prs"]:
|
|
1112
|
+
info = got.get(u)
|
|
1113
|
+
if not info:
|
|
1114
|
+
continue
|
|
1115
|
+
old = i["heads"].get(u)
|
|
1116
|
+
if not (old and HEX_HEAD.fullmatch(old)): # unset, or mangled by an older redaction: rebaseline
|
|
1117
|
+
i["heads"][u] = info["head"]
|
|
1118
|
+
elif owner_is_author(cfg, i["owner"], info["author"]):
|
|
1119
|
+
i["review"] = False # the person asked wrote the PR: their pushes are the work, not a stale review
|
|
1120
|
+
i["heads"][u] = info["head"]
|
|
1121
|
+
elif i.get("pushes") and not i.get("review"):
|
|
1122
|
+
i["heads"][u] = info["head"] # the owner was asked to push to this PR: its pushes are the work
|
|
1123
|
+
elif owner_is_author(cfg, i["owner"], info.get("head_author", "")):
|
|
1124
|
+
i["heads"][u] = info["head"] # the head moved by the owner's own commit: progress, not a stray push
|
|
1125
|
+
i["last_heard"] = max(i.get("last_heard") or 0, now)
|
|
1126
|
+
elif not same_head(old, info["head"]):
|
|
1127
|
+
i["head_moved"] = {
|
|
1128
|
+
"url": u,
|
|
1129
|
+
"old": old,
|
|
1130
|
+
"new": info["head"],
|
|
1131
|
+
"at": now,
|
|
1132
|
+
}
|
|
1133
|
+
_event(i, "head_moved", f"{old[:7]} -> {info['head'][:7]}", now)
|
|
1134
|
+
i["heads"][u] = info["head"]
|
|
1135
|
+
if i.get("waiting") and info["review_at"] and info["review_at"] > (i.get("routed_at") or 0):
|
|
1136
|
+
i["waiting"] = False # a review shows on the PR: the owner has started it
|
|
1137
|
+
if info["review_at"] and info["review_at"] > base(cfg, i):
|
|
1138
|
+
i["last_heard"] = info["review_at"]
|
|
1139
|
+
res = _landed(cfg, i, got) if i.get("review") else {}
|
|
1140
|
+
old = next((d for v, d in res.values() if v == "old" and d), None)
|
|
1141
|
+
acked = i.get("verdict_acked")
|
|
1142
|
+
i["verdict_old_head"] = (
|
|
1143
|
+
old if old and acked != [old["commit"], old["head"]] else None
|
|
1144
|
+
)
|
|
1145
|
+
done = {u: d for u, (v, d) in res.items() if v == "closed" and d}
|
|
1146
|
+
if done and i.get("queue"):
|
|
1147
|
+
cur = i["prs"][0]
|
|
1148
|
+
if cur in done:
|
|
1149
|
+
msgs.append(_advance(i, done[cur], now))
|
|
1150
|
+
elif done:
|
|
1151
|
+
msgs.append(_close_landed(cfg, i, done, now))
|
|
1152
|
+
if not i["prs"]:
|
|
1153
|
+
continue
|
|
1154
|
+
keep.append(i)
|
|
1155
|
+
work_save(sd, keep)
|
|
1156
|
+
return msgs
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
def close_asks(cfg: dict, now: float) -> int:
|
|
1160
|
+
"""The user answered: every open ask-the-user item is done."""
|
|
1161
|
+
sd = store.state(cfg)
|
|
1162
|
+
with store.locked(sd / "work.lock"):
|
|
1163
|
+
all_ = work_load(sd)
|
|
1164
|
+
done = [i for i in all_ if i.get("kind") == "ask"]
|
|
1165
|
+
for i in done:
|
|
1166
|
+
_archive(cfg, i, "answered", now)
|
|
1167
|
+
if done:
|
|
1168
|
+
work_save(sd, [i for i in all_ if i.get("kind") != "ask"])
|
|
1169
|
+
return len(done)
|