Projects as configuration entries (M1.4, D8)
CI / checks (push) Skipped
CI / checks (pull_request) Failing after 5m7s
CI / checks (push) Skipped
CI / checks (pull_request) Failing after 5m7s
- Config: `projects: {name: entry}` with repo, base_branch, workspace,
build_host, push credential, labels (defaults added to every issue),
secrets by ${VAR} reference with visibility (commands | harness), and
egress (recorded, not enforced). Every problem in every entry is
reported at once; an inline secret, a bad visibility, a repeated repo or
a non-slug name is an error.
- The old loop runs exactly one project (only_project) and refuses more.
- The new loop is per project: each its own clone, workspace (with its
commands-visible secrets, redacted on return), push credential and
milestones; intake polls every project's repository; items are scoped
to their project for every run, publish and PR watch. Issues are named
project#14 when there is more than one project, and a bare #14 that
matches two is asked about. Switching projects parks the one worked on
last (all others after a boot).
- A restatement that ends in a question is sent back to the driver, not
posted: the e2e driver kept asking "Is this the right interpretation?",
which a plain yes can't answer.
Tests: config entries and errors, two-project assembly, two-project loop
tests (intake, own workspaces and repos, #14 in both, do-next, label
defaults, parking across projects), the property walk over two projects
with a new own_repository_only property (an item using the first
project's repo fails 15 of 16 seeds), and the restatement check.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
This commit is contained in:
1 parent
4ecff19933
commit
064d9bf554
17 files changed
+1139
-315
No files matched your search
@@ -19,8 +19,8 @@ import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import replace
|
||||
from collections.abc import Callable, Iterable, Sequence
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
from nisse_dev.cancel import Cancel
|
||||
from nisse_dev.canonical import Fact, PlanSubmitted, UserMessage
|
||||
@@ -61,9 +61,11 @@ from nisse_dev.worker import Worker
|
||||
|
||||
INTAKE_INTERVAL_S = 30.0
|
||||
_GO = frozenset({"go", "approve"})
|
||||
_NEXT = re.compile(r"(?:do|work on) #?(\d+) (?:next|first|now)")
|
||||
# An issue, named: "#14", "14", or with its project, "sandbox#14" (D8).
|
||||
_ISSUE = r"(?:([a-z0-9][a-z0-9-]*)#|#)?(\d+)"
|
||||
_NEXT = re.compile(rf"(?:do|work on) {_ISSUE} (?:next|first|now)")
|
||||
# A command that names its item applies to it, wherever it is said (L5).
|
||||
_NAMED = re.compile(r"(go|approve|abandon|stop) #(\d+)")
|
||||
_NAMED = re.compile(r"(go|approve|abandon|stop) (?:([a-z0-9][a-z0-9-]*))?#(\d+)")
|
||||
# At top level these need an item; without one they are ambiguous (L5).
|
||||
_NEEDS_ITEM = frozenset({"go", "approve", "abandon"})
|
||||
BUG_GRACE_S = 600.0
|
||||
@@ -125,6 +127,33 @@ LABELS = {
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Project:
|
||||
"""What the loop needs of one project (D8): its repository, label
|
||||
defaults, and its own workspace and milestones on its build VM."""
|
||||
|
||||
name: str
|
||||
repo: str
|
||||
workspace: Workspace | None = None
|
||||
milestones: Milestones | None = None
|
||||
labels: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def _restatement_problem(args: dict[str, object]) -> str | None:
|
||||
"""L5, D1: a restatement is agreed to as it stands, so it asks nothing.
|
||||
One that ends in a question is sent back: questions go by ask_human."""
|
||||
text = str(args.get("text", "")).strip()
|
||||
if not text:
|
||||
return "a restatement needs text: what you take the request to mean"
|
||||
if is_question(text):
|
||||
return (
|
||||
"not posted: a restatement asks no questions, so the operator can "
|
||||
"agree to it as it stands. If you need an answer first, use "
|
||||
"ask_human; otherwise restate it as a statement."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class LoopAgent:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -132,16 +161,18 @@ class LoopAgent:
|
||||
transport: Transport,
|
||||
gitea: GiteaClient,
|
||||
*,
|
||||
repo: str,
|
||||
project: str,
|
||||
account: str,
|
||||
operators: Iterable[str],
|
||||
gitea_operators: Iterable[str],
|
||||
driver: Driver,
|
||||
workspace: Workspace | None,
|
||||
worker: Worker,
|
||||
cancel: Cancel,
|
||||
oplog: OpLog,
|
||||
projects: Sequence[Project] = (),
|
||||
# One project, given directly: the same as projects=[Project(...)].
|
||||
repo: str = "",
|
||||
project: str = "",
|
||||
workspace: Workspace | None = None,
|
||||
roles: dict[str, str] | None = None,
|
||||
milestones: Milestones | None = None,
|
||||
reader: GoAheadReader | None = None,
|
||||
@@ -152,32 +183,56 @@ class LoopAgent:
|
||||
self._store = store
|
||||
self._transport = transport
|
||||
self._gitea = gitea
|
||||
self._repo = repo
|
||||
self._project = project
|
||||
if not projects:
|
||||
projects = [Project(project, repo, workspace, milestones)]
|
||||
self._projects = {p.name: p for p in projects}
|
||||
self._account = account
|
||||
self._operators = frozenset(operators)
|
||||
self._gitea_operators = frozenset(gitea_operators)
|
||||
self._driver = driver
|
||||
self._workspace = workspace
|
||||
self._worker = worker
|
||||
self._cancel = cancel
|
||||
self._oplog = oplog
|
||||
self._roles = roles or {}
|
||||
self._milestones = milestones
|
||||
self._reader = reader
|
||||
self._grace = bug_grace_s
|
||||
self._clock = clock
|
||||
self._interval = intake_interval_s
|
||||
# thread root -> revision on screen there, read on the transport's
|
||||
# thread to stamp replies on arrival (L3); issue -> thread root, for
|
||||
# a command that names its item.
|
||||
# thread to stamp replies on arrival (L3); (project, issue) -> thread
|
||||
# root, for a command that names its item.
|
||||
self._shown: dict[str, int] = {}
|
||||
self._threads: dict[int, str] = {}
|
||||
self._threads: dict[tuple[str, int], str] = {}
|
||||
self._active_thread = ""
|
||||
# The project whose workspace was last worked on: switching to
|
||||
# another parks it (L1). None after a boot: unknown, so all are.
|
||||
self._last_project: str | None = None
|
||||
self._running = False
|
||||
self._stop = threading.Event()
|
||||
self._intake_thread: threading.Thread | None = None
|
||||
|
||||
# -- projects (D8) ----------------------------------------------------------
|
||||
|
||||
def _at(self, item: WorkItem) -> Project:
|
||||
return self._projects[item.scope or ""]
|
||||
|
||||
def _label(self, item: WorkItem) -> str:
|
||||
"""How an item's issue is named to the operator: #14 with one
|
||||
project, sandbox#14 with more, where #14 alone could be either."""
|
||||
issue = loop_of(item).issue
|
||||
return f"#{issue}" if len(self._projects) == 1 else f"{item.scope}#{issue}"
|
||||
|
||||
def _named(self, project: str | None, issue: int) -> list[WorkItem]:
|
||||
"""Open items for ``issue``, in ``project`` if it is given."""
|
||||
return [
|
||||
i
|
||||
for i in self._store.work_items("open")
|
||||
if loop_of(i).issue == issue and (project is None or i.scope == project)
|
||||
]
|
||||
|
||||
def _repos(self) -> str:
|
||||
return ", ".join(p.repo for p in self._projects.values())
|
||||
|
||||
# -- lifecycle ----------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
@@ -220,7 +275,7 @@ class LoopAgent:
|
||||
self._reconcile_publishing()
|
||||
for item in self._store.work_items("open"):
|
||||
loop = loop_of(item)
|
||||
self._threads[loop.issue] = loop.thread
|
||||
self._threads[(item.scope or "", loop.issue)] = loop.thread
|
||||
if item.position == RUNNING:
|
||||
note = "I restarted while working on this. Reply to continue."
|
||||
self._wait(item, replace(loop, wait=ANSWER, note=note), note)
|
||||
@@ -247,8 +302,11 @@ class LoopAgent:
|
||||
continue
|
||||
item = self._store.work_item(intent.work_item)
|
||||
branch = str(intent.payload.get("branch", ""))
|
||||
if item is None or item.scope not in self._projects:
|
||||
self._store.abort_intent(intent.id, cause="item or project unknown")
|
||||
continue
|
||||
try:
|
||||
pr = self._gitea.find_pull_request(self._repo, branch)
|
||||
pr = self._gitea.find_pull_request(self._at(item).repo, branch)
|
||||
except GiteaError:
|
||||
continue # unverifiable: left pending, asked about below
|
||||
if item is None or item.status != "open":
|
||||
@@ -272,33 +330,39 @@ class LoopAgent:
|
||||
# -- intake (D1) ---------------------------------------------------------
|
||||
|
||||
def intake(self) -> None:
|
||||
"""Issues assigned to the agent become work items, once each."""
|
||||
"""Issues assigned to the agent, in every project's repository,
|
||||
become work items, once each."""
|
||||
for project in self._projects.values():
|
||||
self._intake(project)
|
||||
self.watch()
|
||||
self.check_blocked()
|
||||
self.check_grace()
|
||||
self._schedule()
|
||||
|
||||
def _intake(self, project: Project) -> None:
|
||||
try:
|
||||
issues = self._gitea.assigned_issues(self._repo, self._account)
|
||||
issues = self._gitea.assigned_issues(project.repo, self._account)
|
||||
except GiteaError as exc:
|
||||
# L5: an outage delays intake, never fails anything.
|
||||
self._oplog.emit("loop.intake_unavailable", cause=str(exc))
|
||||
return
|
||||
for issue in issues:
|
||||
if item_for_issue(self._store, issue.number) is not None:
|
||||
if item_for_issue(self._store, issue.number, project.name) is not None:
|
||||
continue
|
||||
if not self._from_an_operator(issue):
|
||||
if not self._from_an_operator(project, issue):
|
||||
self._oplog.emit(
|
||||
"loop.intake_ignored",
|
||||
cause="not filed or assigned by an operator",
|
||||
issue=issue.number,
|
||||
repo=project.repo,
|
||||
)
|
||||
continue
|
||||
try:
|
||||
blocked_by = self._gitea.open_dependencies(self._repo, issue.number)
|
||||
blocked_by = self._gitea.open_dependencies(project.repo, issue.number)
|
||||
except GiteaError as exc:
|
||||
self._oplog.emit("loop.intake_unavailable", cause=str(exc))
|
||||
continue # taken on the next round
|
||||
self._open(issue, tuple(blocked_by))
|
||||
self.watch()
|
||||
self.check_blocked()
|
||||
self.check_grace()
|
||||
self._schedule()
|
||||
self._open(project, issue, tuple(blocked_by))
|
||||
|
||||
# -- PR review and merge (D4, D5) -------------------------------------------
|
||||
|
||||
@@ -309,10 +373,11 @@ class LoopAgent:
|
||||
loop = loop_of(item)
|
||||
if not loop.pr:
|
||||
continue
|
||||
repo = self._at(item).repo
|
||||
try:
|
||||
pr = self._gitea.pull_request(self._repo, loop.pr)
|
||||
comments = self._gitea.list_comments(self._repo, loop.pr)
|
||||
notes = self._gitea.review_notes(self._repo, loop.pr)
|
||||
pr = self._gitea.pull_request(repo, loop.pr)
|
||||
comments = self._gitea.list_comments(repo, loop.pr)
|
||||
notes = self._gitea.review_notes(repo, loop.pr)
|
||||
except GiteaError as exc:
|
||||
self._oplog.emit("loop.watch_unavailable", cause=str(exc))
|
||||
continue
|
||||
@@ -379,19 +444,20 @@ class LoopAgent:
|
||||
if item.position not in (READY, BLOCKED):
|
||||
continue
|
||||
loop = loop_of(item)
|
||||
issue = loop.issue
|
||||
repo = self._at(item).repo
|
||||
try:
|
||||
blocked_by = tuple(self._gitea.open_dependencies(self._repo, issue))
|
||||
blocked_by = tuple(self._gitea.open_dependencies(repo, loop.issue))
|
||||
except GiteaError as exc:
|
||||
self._oplog.emit("loop.watch_unavailable", cause=str(exc))
|
||||
continue # as it was, until Gitea answers
|
||||
position = BLOCKED if blocked_by else READY
|
||||
if position == item.position and blocked_by == loop.blocked_by:
|
||||
continue
|
||||
name = self._label(item)
|
||||
if position == READY:
|
||||
said = f"#{issue} is unblocked: its dependencies have closed."
|
||||
said = f"{name} is unblocked: its dependencies have closed."
|
||||
else:
|
||||
said = f"#{issue} is blocked by {', '.join(blocked_by)}."
|
||||
said = f"{name} is blocked by {', '.join(blocked_by)}."
|
||||
self._fact(item, said)
|
||||
if position != item.position:
|
||||
self._transport.post(said, thread=loop.thread)
|
||||
@@ -421,38 +487,42 @@ class LoopAgent:
|
||||
)
|
||||
self._go(item, loop, "the bug grace period")
|
||||
|
||||
def _from_an_operator(self, issue: Issue) -> bool:
|
||||
def _from_an_operator(self, project: Project, issue: Issue) -> bool:
|
||||
"""L11: only an operator's issue, or one an operator assigned, is
|
||||
an instruction."""
|
||||
if issue.author in self._gitea_operators:
|
||||
return True
|
||||
try:
|
||||
who = self._gitea.assigned_by(self._repo, issue.number, self._account)
|
||||
who = self._gitea.assigned_by(project.repo, issue.number, self._account)
|
||||
except GiteaError:
|
||||
return False
|
||||
return who in self._gitea_operators
|
||||
|
||||
def _open(self, issue: Issue, blocked_by: tuple[str, ...]) -> None:
|
||||
item_id = f"{self._project}-{issue.number}-{uuid.uuid4().hex[:6]}"
|
||||
cause = f"assigned {self._repo}#{issue.number}"
|
||||
def _open(
|
||||
self, project: Project, issue: Issue, blocked_by: tuple[str, ...]
|
||||
) -> None:
|
||||
item_id = f"{project.name}-{issue.number}-{uuid.uuid4().hex[:6]}"
|
||||
cause = f"assigned {project.repo}#{issue.number}"
|
||||
ahead = len(self._store.work_items("open"))
|
||||
where = f", {ahead} ahead" if ahead else ""
|
||||
if blocked_by:
|
||||
where += f"; blocked by {', '.join(blocked_by)}"
|
||||
root = self._transport.post(
|
||||
f"Queued {self._repo}#{issue.number}: {issue.title}{where}"
|
||||
f"Queued {project.repo}#{issue.number}: {issue.title}{where}"
|
||||
)
|
||||
# D8: the project's label defaults steer as if the issue had them.
|
||||
labels = tuple(dict.fromkeys((*issue.labels, *project.labels)))
|
||||
loop = Loop(
|
||||
thread=root,
|
||||
issue=issue.number,
|
||||
blocked_by=blocked_by,
|
||||
labels=tuple(issue.labels),
|
||||
labels=labels,
|
||||
)
|
||||
self._threads[issue.number] = root
|
||||
self._threads[(project.name, issue.number)] = root
|
||||
self._store.open_work_item(
|
||||
item_id,
|
||||
title=issue.title,
|
||||
scope=self._project,
|
||||
scope=project.name,
|
||||
position=BLOCKED if blocked_by else READY,
|
||||
data={"loop": loop.to_data(), "roles": self._roles},
|
||||
cause=cause,
|
||||
@@ -460,9 +530,14 @@ class LoopAgent:
|
||||
log = self._store.transcript(item_id, "orchestrator")
|
||||
log.append(
|
||||
Fact(
|
||||
text=f"work item opened from {self._repo}#{issue.number} "
|
||||
text=f"work item opened from {project.repo}#{issue.number} "
|
||||
f"({issue.title}), filed by {issue.author}"
|
||||
+ (f", labels: {', '.join(issue.labels)}" if issue.labels else "")
|
||||
+ (f", labels: {', '.join(labels)}" if labels else "")
|
||||
+ (
|
||||
f" ({', '.join(project.labels)} by the project's default)"
|
||||
if project.labels
|
||||
else ""
|
||||
)
|
||||
)
|
||||
)
|
||||
text = f"{issue.title}\n\n{issue.body}".strip()
|
||||
@@ -486,7 +561,14 @@ class LoopAgent:
|
||||
named = _NAMED.fullmatch(command)
|
||||
if named is not None:
|
||||
command = named.group(1)
|
||||
thread = self._threads.get(int(named.group(2)), thread)
|
||||
project, issue = named.group(2), int(named.group(3))
|
||||
threads = [
|
||||
t
|
||||
for (p, n), t in list(self._threads.items())
|
||||
if n == issue and project in (None, p)
|
||||
]
|
||||
# Ambiguous across projects: no stamp; handle asks which.
|
||||
thread = threads[0] if len(threads) == 1 else thread
|
||||
if (
|
||||
command == "stop"
|
||||
and self._running
|
||||
@@ -510,30 +592,27 @@ class LoopAgent:
|
||||
UserMessage(text=text, source=f"transport:{msg.sender}")
|
||||
)
|
||||
self._transport.post(
|
||||
self._put_first(int(order.group(1))), thread=msg.thread
|
||||
self._put_first(order.group(1), int(order.group(2))),
|
||||
thread=msg.thread,
|
||||
)
|
||||
self._schedule()
|
||||
return
|
||||
named = _NAMED.fullmatch(command)
|
||||
if named is not None:
|
||||
# L5: a command that names its item applies to it.
|
||||
command, issue = named.group(1), int(named.group(2))
|
||||
item = next(
|
||||
(
|
||||
i
|
||||
for i in self._store.work_items("open")
|
||||
if loop_of(i).issue == issue
|
||||
),
|
||||
None,
|
||||
)
|
||||
if item is None:
|
||||
command = named.group(1)
|
||||
project, issue = named.group(2), int(named.group(3))
|
||||
found = self._named(project, issue)
|
||||
if len(found) != 1:
|
||||
self._store.transcript(None, "operator").append(
|
||||
UserMessage(text=text, source=f"transport:{msg.sender}")
|
||||
)
|
||||
self._transport.post(
|
||||
f"#{issue} isn't one of my open work items.", thread=msg.thread
|
||||
self._not_one(found, project, issue, f"{command} {{}}"),
|
||||
thread=msg.thread,
|
||||
)
|
||||
return
|
||||
[item] = found
|
||||
if item is None:
|
||||
self._store.transcript(None, "operator").append(
|
||||
UserMessage(text=text, source=f"transport:{msg.sender}")
|
||||
@@ -547,7 +626,7 @@ class LoopAgent:
|
||||
self._transport.post("Nothing is running.", thread=msg.thread)
|
||||
else:
|
||||
self._transport.post(
|
||||
f"I take work from issues assigned to me in {self._repo}: "
|
||||
f"I take work from issues assigned to me in {self._repos()}: "
|
||||
"assign one and I'll pick it up. Reply in its thread to "
|
||||
"talk about it.",
|
||||
thread=msg.thread,
|
||||
@@ -574,12 +653,12 @@ class LoopAgent:
|
||||
# Stopping a run took effect on arrival; a stop is never an answer.
|
||||
if not loop.note.startswith("stopped ("):
|
||||
self._transport.post(
|
||||
f"#{loop.issue} isn't running; nothing to stop.",
|
||||
f"{self._label(item)} isn't running; nothing to stop.",
|
||||
thread=loop.thread,
|
||||
)
|
||||
elif item.position == BLOCKED:
|
||||
self._transport.post(
|
||||
f"#{loop.issue} is blocked by {', '.join(loop.blocked_by)}; "
|
||||
f"{self._label(item)} is blocked by {', '.join(loop.blocked_by)}; "
|
||||
"I'll start on it when they close, or when you remove them.",
|
||||
thread=loop.thread,
|
||||
)
|
||||
@@ -642,31 +721,44 @@ class LoopAgent:
|
||||
items = self._store.work_items("open")
|
||||
if not items:
|
||||
return f"{command} what? I have no open work items."
|
||||
listed = ", ".join(f"#{loop_of(i).issue} ({i.title})" for i in items)
|
||||
listed = ", ".join(f"{self._label(i)} ({i.title})" for i in items)
|
||||
return (
|
||||
f"Which item? Say it in the item's thread, or name it "
|
||||
f'("{command} #{loop_of(items[0]).issue}"). Open: {listed}.'
|
||||
f'("{command} {self._label(items[0])}"). Open: {listed}.'
|
||||
)
|
||||
|
||||
def _put_first(self, issue: int) -> str:
|
||||
def _not_one(
|
||||
self, found: list[WorkItem], project: str | None, issue: int, say: str
|
||||
) -> str:
|
||||
"""A named item that is none, or more than one (the same number in
|
||||
two projects). ``say`` is the command, with {} for the item."""
|
||||
name = f"{project}#{issue}" if project else f"#{issue}"
|
||||
if not found:
|
||||
return f"{name} isn't one of my open work items."
|
||||
options = " or ".join(say.format(self._label(i)) for i in found)
|
||||
return f"{name} is open in more than one project: say {options}."
|
||||
|
||||
def _put_first(self, project: str | None, issue: int) -> str:
|
||||
"""The operator's "do #N next" (L1): it runs before every other
|
||||
ready item, the latest such order first. It doesn't unblock."""
|
||||
found = self._named(project, issue)
|
||||
if len(found) != 1:
|
||||
return self._not_one(found, project, issue, "do {} next")
|
||||
[item] = found
|
||||
name = self._label(item)
|
||||
items = self._store.work_items("open")
|
||||
item = next((i for i in items if loop_of(i).issue == issue), None)
|
||||
if item is None:
|
||||
return f"#{issue} isn't one of my open work items."
|
||||
first = max(loop_of(i).first for i in items) + 1
|
||||
loop = replace(loop_of(item), first=first)
|
||||
save(self._store, item.id, item.position, loop, cause=f"#{issue} put first")
|
||||
save(self._store, item.id, item.position, loop, cause=f"{name} put first")
|
||||
self._fact(item, "the operator put this item first in the queue")
|
||||
if item.position == BLOCKED:
|
||||
return (
|
||||
f"#{issue} goes first once it's unblocked "
|
||||
f"{name} goes first once it's unblocked "
|
||||
f"(blocked by {', '.join(loop.blocked_by)})."
|
||||
)
|
||||
if item.position == WAITING:
|
||||
return f"#{issue} goes first, but it's waiting for {loop.wait}: {loop.note}"
|
||||
return f"#{issue} is next."
|
||||
return f"{name} goes first, but it's waiting for {loop.wait}: {loop.note}"
|
||||
return f"{name} is next."
|
||||
|
||||
def _queue(self) -> list[WorkItem]:
|
||||
"""Ready items in the order they run (L1): the operator's order
|
||||
@@ -678,7 +770,10 @@ class LoopAgent:
|
||||
def status(self) -> str:
|
||||
items = self._store.work_items("open")
|
||||
if not items:
|
||||
return f"idle: no open work items. I take issues assigned to me in {self._repo}."
|
||||
return (
|
||||
"idle: no open work items. I take issues assigned to me in "
|
||||
f"{self._repos()}."
|
||||
)
|
||||
queue = [i.id for i in self._queue()]
|
||||
lines = []
|
||||
for item in items:
|
||||
@@ -693,7 +788,7 @@ class LoopAgent:
|
||||
what = f"blocked by {', '.join(loop.blocked_by)}"
|
||||
elif item.position == READY:
|
||||
what = f"ready, {queue.index(item.id) + 1} of {len(queue)} in line"
|
||||
lines.append(f"#{loop.issue} {item.title}: {what}")
|
||||
lines.append(f"{self._label(item)} {item.title}: {what}")
|
||||
return "\n".join(lines)
|
||||
|
||||
# -- running (L1, L2) ------------------------------------------------------
|
||||
@@ -709,20 +804,46 @@ class LoopAgent:
|
||||
return
|
||||
self._run(ready[0])
|
||||
|
||||
def _keep(self, project: str) -> tuple[str, ...]:
|
||||
"""Branch prefixes of a project's open items: parked or not, they
|
||||
are never set aside."""
|
||||
return tuple(
|
||||
f"{i.id}-" for i in self._store.work_items("open") if i.scope == project
|
||||
)
|
||||
|
||||
def _park_elsewhere(self, here: str) -> None:
|
||||
"""L1: switching to another project's item parks the one on the
|
||||
project worked on last, so nothing lives only on its build VM. After
|
||||
a boot nobody knows which that was, so every other is parked."""
|
||||
last = self._last_project
|
||||
others = [p for p in self._projects.values() if p.name != here]
|
||||
for other in others if last is None else [self._projects[last]]:
|
||||
if other.name == here or other.milestones is None:
|
||||
continue
|
||||
try:
|
||||
other.milestones.park(keep=self._keep(other.name))
|
||||
except Exception as exc: # noqa: BLE001 -- reported; the run goes on
|
||||
self._oplog.emit(
|
||||
"loop.park_failed", cause=repr(exc), project=other.name
|
||||
)
|
||||
self._last_project = here
|
||||
|
||||
def _run(self, item: WorkItem) -> None:
|
||||
loop = loop_of(item)
|
||||
project = self._at(item)
|
||||
milestones = project.milestones
|
||||
self._store.set_active(item.id, cause="ready")
|
||||
if self._milestones is not None:
|
||||
# Branches of open items, parked or not, are never set aside.
|
||||
keep = tuple(f"{i.id}-" for i in self._store.work_items("open"))
|
||||
self._park_elsewhere(project.name)
|
||||
if milestones is not None:
|
||||
keep = self._keep(project.name)
|
||||
try:
|
||||
if not loop.prepared:
|
||||
# D3: a fresh workspace when a work item starts.
|
||||
notes = self._milestones.prepare(f"{item.id}-work", keep=keep)
|
||||
notes = milestones.prepare(f"{item.id}-work", keep=keep)
|
||||
else:
|
||||
# D4: back to its branch, parking whoever was there.
|
||||
notes = []
|
||||
self._milestones.resume(
|
||||
milestones.resume(
|
||||
loop.branch or f"{item.id}-work", keep=keep, mine=f"{item.id}-"
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 -- the item waits, not the loop
|
||||
@@ -755,12 +876,13 @@ class LoopAgent:
|
||||
]
|
||||
),
|
||||
]
|
||||
workspace = project.workspace
|
||||
system = SYSTEM.format(
|
||||
repo=self._repo,
|
||||
repo=project.repo,
|
||||
issue=loop.issue,
|
||||
title=item.title,
|
||||
labels="".join(f"{LABELS[x]}\n\n" for x in loop.labels if x in LABELS),
|
||||
workspace=self._workspace.describe() if self._workspace else "",
|
||||
workspace=workspace.describe() if workspace else "",
|
||||
)
|
||||
self._active_thread = loop.thread
|
||||
self._running = True
|
||||
@@ -769,8 +891,9 @@ class LoopAgent:
|
||||
self._store.transcript(item.id, "driver"),
|
||||
system=system,
|
||||
tools=tools,
|
||||
workspace=self._workspace,
|
||||
workspace=workspace,
|
||||
handlers={"start_milestone": lambda args: self._start(item.id, args)},
|
||||
checks={"restate": _restatement_problem},
|
||||
)
|
||||
finally:
|
||||
self._running = False
|
||||
@@ -805,12 +928,12 @@ class LoopAgent:
|
||||
)
|
||||
elif outcome.kind == "wait" and outcome.tool == "ask_human":
|
||||
question = str(args.get("question", "")).strip()
|
||||
self._answer_where_asked(loop, question)
|
||||
self._answer_where_asked(item, loop, question)
|
||||
self._wait(item, replace(loop, wait=ANSWER, note=question), None)
|
||||
elif outcome.kind == "wait" and outcome.tool == "wait_for_review":
|
||||
note = str(args.get("note", "")).strip() or f"waiting on PR #{loop.pr}"
|
||||
if loop.pr:
|
||||
self._answer_where_asked(loop, note)
|
||||
self._answer_where_asked(item, loop, note)
|
||||
self._wait(item, replace(loop, wait=MERGE, note=f"PR #{loop.pr}"), None)
|
||||
else:
|
||||
say = f"{note} (there's no open PR, so I need you to tell me what next)"
|
||||
@@ -855,11 +978,12 @@ class LoopAgent:
|
||||
item = self._store.work_item(item_id)
|
||||
assert item is not None
|
||||
loop = loop_of(item)
|
||||
project = self._at(item)
|
||||
if loop.pr:
|
||||
# D4: the next milestone waits for the merge, read from Gitea now,
|
||||
# not from the last watch round.
|
||||
try:
|
||||
pr = self._gitea.pull_request(self._repo, loop.pr)
|
||||
pr = self._gitea.pull_request(project.repo, loop.pr)
|
||||
except GiteaError as exc:
|
||||
return f"couldn't check PR #{loop.pr}: {exc}; try again", False
|
||||
if pr.state == "open":
|
||||
@@ -875,12 +999,12 @@ class LoopAgent:
|
||||
if loop.branch:
|
||||
busy = f"milestone {loop.milestone} is still open; finish it first"
|
||||
return busy, False
|
||||
if self._milestones is None:
|
||||
if project.milestones is None:
|
||||
return "milestones aren't available here", False
|
||||
number = loop.milestone + 1
|
||||
branch = f"{item.id}-milestone-{number}-{uuid.uuid4().hex[:4]}"
|
||||
try:
|
||||
self._milestones.start(branch)
|
||||
project.milestones.start(branch)
|
||||
except GitError as exc:
|
||||
return f"couldn't start the milestone: {exc}", False
|
||||
name = str(args.get("name", "")).strip()
|
||||
@@ -894,7 +1018,7 @@ class LoopAgent:
|
||||
)
|
||||
return f"started milestone {number} on branch {branch}", True
|
||||
|
||||
def _answer_where_asked(self, loop: Loop, text: str) -> None:
|
||||
def _answer_where_asked(self, item: WorkItem, loop: Loop, text: str) -> None:
|
||||
"""D5: answer where the operator asked. A PR question is answered on
|
||||
the PR, quoting the line it was about (an inline thread can't be
|
||||
replied to through the API), with a pointer in the Matrix thread."""
|
||||
@@ -905,7 +1029,7 @@ class LoopAgent:
|
||||
quoted += [f"> {line}" for line in loop.asked_text.splitlines()]
|
||||
body = "\n".join([*quoted, "", text])
|
||||
try:
|
||||
self._gitea.post_comment(self._repo, loop.asked_on_pr, body)
|
||||
self._gitea.post_comment(self._at(item).repo, loop.asked_on_pr, body)
|
||||
except GiteaError:
|
||||
self._transport.post(text, thread=loop.thread) # the PR is unreachable
|
||||
return
|
||||
@@ -924,12 +1048,14 @@ class LoopAgent:
|
||||
final: bool = True,
|
||||
) -> None:
|
||||
"""finish_milestone: publish the milestone, or push a review fix."""
|
||||
if self._milestones is None:
|
||||
project = self._at(item)
|
||||
milestones = project.milestones
|
||||
if milestones is None:
|
||||
note = "milestones aren't available here"
|
||||
self._wait(item, replace(loop, wait=ANSWER, note=note), note)
|
||||
return
|
||||
if loop.pr:
|
||||
fixed = self._milestones.push_fix(
|
||||
fixed = milestones.push_fix(
|
||||
item_id=item.id,
|
||||
branch=loop.branch,
|
||||
pr=loop.pr,
|
||||
@@ -938,7 +1064,7 @@ class LoopAgent:
|
||||
)
|
||||
self._fact(item, f"review fix: {fixed.note}")
|
||||
if fixed.kind in ("pushed", "nothing"):
|
||||
self._gitea_comment(loop.pr, fixed.note)
|
||||
self._gitea_comment(project.repo, loop.pr, fixed.note)
|
||||
self._wait(
|
||||
item,
|
||||
replace(loop, wait=MERGE, note=f"PR #{loop.pr}"),
|
||||
@@ -971,13 +1097,13 @@ class LoopAgent:
|
||||
number = loop.milestone + 1
|
||||
branch = f"{item.id}-milestone-{number}-{uuid.uuid4().hex[:4]}"
|
||||
try:
|
||||
self._milestones.start(branch)
|
||||
milestones.start(branch)
|
||||
except GitError as exc:
|
||||
note = f"I couldn't start milestone {number}: {exc}"
|
||||
self._wait(item, replace(loop, wait=ANSWER, note=note), note)
|
||||
return
|
||||
loop = replace(loop, milestone=number, branch=branch)
|
||||
published = self._milestones.publish(
|
||||
published = milestones.publish(
|
||||
item_id=item.id,
|
||||
branch=loop.branch,
|
||||
title=title or f"{item.title} (milestone {loop.milestone})",
|
||||
@@ -1018,9 +1144,9 @@ class LoopAgent:
|
||||
f"Milestone {loop.milestone}: {published.note}. What next?",
|
||||
)
|
||||
|
||||
def _gitea_comment(self, pr: int, text: str) -> None:
|
||||
def _gitea_comment(self, repo: str, pr: int, text: str) -> None:
|
||||
try:
|
||||
self._gitea.post_comment(self._repo, pr, text)
|
||||
self._gitea.post_comment(repo, pr, text)
|
||||
except GiteaError:
|
||||
pass # courtesy only; the pushed commit is the answer
|
||||
|
||||
@@ -1031,26 +1157,27 @@ class LoopAgent:
|
||||
|
||||
def _close(self, item: WorkItem, loop: Loop, outcome: str, note: str) -> None:
|
||||
self._fact(item, f"work item {outcome}: {note}")
|
||||
project = self._at(item)
|
||||
if loop.pr and outcome == "abandoned":
|
||||
# L2: abandoning closes the item's open PR with a note.
|
||||
try:
|
||||
self._gitea.post_comment(self._repo, loop.pr, f"abandoned: {note}")
|
||||
self._gitea.close_pull_request(self._repo, loop.pr)
|
||||
self._gitea.post_comment(project.repo, loop.pr, f"abandoned: {note}")
|
||||
self._gitea.close_pull_request(project.repo, loop.pr)
|
||||
except GiteaError as exc:
|
||||
self._transport.post(
|
||||
f"could not close PR #{loop.pr}: {exc}", thread=loop.thread
|
||||
)
|
||||
save(self._store, item.id, CLOSED, loop, cause=outcome)
|
||||
self._store.close_work_item(item.id, outcome=outcome, cause=note)
|
||||
if self._milestones is not None:
|
||||
if project.milestones is not None:
|
||||
try:
|
||||
self._milestones.drop_parked(f"{item.id}-")
|
||||
project.milestones.drop_parked(f"{item.id}-")
|
||||
except GitError:
|
||||
pass # a leftover side branch; nothing depends on it
|
||||
if loop.issue:
|
||||
try:
|
||||
self._gitea.post_comment(self._repo, loop.issue, f"{outcome}: {note}")
|
||||
self._gitea.close_issue(self._repo, loop.issue)
|
||||
self._gitea.post_comment(project.repo, loop.issue, f"{outcome}: {note}")
|
||||
self._gitea.close_issue(project.repo, loop.issue)
|
||||
except GiteaError as exc:
|
||||
self._transport.post(
|
||||
f"closed here, but could not close #{loop.issue}: {exc}",
|
||||
|
||||
Reference in new issue
Block a user