CI / checks (push) Skipped
CI / checks (pull_request) Successful in 2m17s
The first live run of the new loop's e2e test found that a fact recorded while start_milestone was in flight landed between the tool call and its result; both API formats reject that, so every later request in the run failed. Views now keep each result right after its call. Scripted test models check the APIs' ordering rules, so this can't pass unit tests again. The e2e tests no longer depend on the sandbox's history: each run asks for a uniquely named function and works against a base branch of its own, deleted afterwards. The review step asks for a change that can't already be there. The scripted operator gives up after five fruitless answers, and each run keeps its state store for inspection. The driver's prompt says review fixes stay in the current milestone. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
669 lines
28 KiB
Python
669 lines
28 KiB
Python
"""The loop's scheduler (L1, L2, L5, D1): intake, messages and driver runs.
|
|
|
|
Everything durable happens on one worker thread, in order. Inputs (an
|
|
operator's message, an issue assigned to the agent) are handed to it as they
|
|
arrive; a message is stamped with the revision on screen in its thread first,
|
|
because by the time the worker reaches it a newer one may be up (L3).
|
|
|
|
One work item at a time is active. An item runs until its driver calls a
|
|
waiting tool; it then waits on the operator, the active pointer is cleared
|
|
(a safe point, L1), and the next ready item runs. Conversation is not work:
|
|
a message is recorded and changes its item's state, but never interrupts a
|
|
run (L1).
|
|
"""
|
|
|
|
import threading
|
|
import uuid
|
|
from collections.abc import Callable, Iterable
|
|
from dataclasses import replace
|
|
|
|
from nisse_dev.cancel import Cancel
|
|
from nisse_dev.canonical import Fact, PlanSubmitted, UserMessage
|
|
from nisse_dev.gates import command_form
|
|
from nisse_dev.gitea import GiteaClient, GiteaError, Issue
|
|
from nisse_dev.loop.driver import Driver, RunOutcome
|
|
from nisse_dev.loop.items import (
|
|
ANSWER,
|
|
CLOSED,
|
|
GO_AHEAD,
|
|
MERGE,
|
|
READY,
|
|
RUNNING,
|
|
WAITING,
|
|
Loop,
|
|
item_for_issue,
|
|
item_for_thread,
|
|
loop_of,
|
|
save,
|
|
)
|
|
from nisse_dev.loop.milestones import OPEN_PR, Milestones
|
|
from nisse_dev.loop.tools import (
|
|
ASK_HUMAN,
|
|
CLOSE_ITEM,
|
|
FINISH_MILESTONE,
|
|
RESTATE,
|
|
START_MILESTONE,
|
|
)
|
|
from nisse_dev.oplog import OpLog
|
|
from nisse_dev.state import StateStore, WorkItem
|
|
from nisse_dev.tools import EXECUTOR_TOOLS, Workspace
|
|
from nisse_dev.transport import InboundMessage, Transport
|
|
from nisse_dev.worker import Worker
|
|
|
|
INTAKE_INTERVAL_S = 30.0
|
|
_GO = frozenset({"go", "approve"})
|
|
|
|
# Before the go-ahead the driver may look around (read, list, run a command
|
|
# to reproduce a bug) but not change anything (D1); after it, everything.
|
|
_LOOK = [t for t in EXECUTOR_TOOLS if t["function"]["name"] != "write_file"]
|
|
|
|
SYSTEM = """You are nisse-dev, a software agent working with the operator on
|
|
the repository {repo}. You are on one work item: issue #{issue}, "{title}".
|
|
The issue and the conversation about it are below.
|
|
|
|
Before any work, find out what you need, then use restate to put your
|
|
understanding to the operator: what you take the request to mean, how it fits
|
|
the project, and your first step. Wait for their go-ahead. If the request is
|
|
unclear, ask_human instead of guessing.
|
|
|
|
After the go-ahead, work in a milestone: call start_milestone before
|
|
changing anything (never create or switch branches yourself), make the
|
|
change, run the project's tests, then call finish_milestone with a summary.
|
|
The harness commits, pushes and opens a PR, and you wait for review. Review
|
|
comments arrive as messages: make the changes within the same milestone (don't
|
|
start another), run the tests, and call finish_milestone again to push the fix. When the PR has merged, call
|
|
close_item with what was done, what was verified, and what is left open. If
|
|
the PR is closed without merging, ask the operator what next.
|
|
|
|
{workspace}"""
|
|
|
|
|
|
class LoopAgent:
|
|
def __init__(
|
|
self,
|
|
store: StateStore,
|
|
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,
|
|
roles: dict[str, str] | None = None,
|
|
milestones: Milestones | None = None,
|
|
intake_interval_s: float = INTAKE_INTERVAL_S,
|
|
) -> None:
|
|
self._store = store
|
|
self._transport = transport
|
|
self._gitea = gitea
|
|
self._repo = repo
|
|
self._project = project
|
|
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._interval = intake_interval_s
|
|
# thread root -> revision on screen there, read on the transport's
|
|
# thread to stamp replies on arrival (L3).
|
|
self._shown: dict[str, int] = {}
|
|
self._running = False
|
|
self._stop = threading.Event()
|
|
self._intake_thread: threading.Thread | None = None
|
|
|
|
# -- lifecycle ----------------------------------------------------------
|
|
|
|
def start(self) -> None:
|
|
self._worker.call(self.boot)
|
|
self._transport.on_message(self.on_message)
|
|
start = getattr(self._transport, "start", None)
|
|
if start is not None:
|
|
start()
|
|
self._intake_thread = threading.Thread(target=self._intake_loop, daemon=True)
|
|
self._intake_thread.start()
|
|
|
|
def stop(self) -> None:
|
|
self._stop.set()
|
|
if self._intake_thread is not None:
|
|
self._intake_thread.join(timeout=5)
|
|
|
|
def _intake_loop(self) -> None:
|
|
while not self._stop.is_set():
|
|
self._worker.submit(self._settled("intake", self.intake))
|
|
self._stop.wait(self._interval)
|
|
|
|
def _settled(self, what: str, job: Callable[[], None]) -> Callable[[], None]:
|
|
"""A worker job that reports a failure instead of losing it."""
|
|
|
|
def run() -> None:
|
|
try:
|
|
job()
|
|
except Exception as exc: # noqa: BLE001 -- reported, the loop goes on
|
|
self._oplog.emit("loop.job_failed", cause=what, error=repr(exc))
|
|
self._transport.post(f"something went wrong with {what}: {exc}")
|
|
|
|
return run
|
|
|
|
# -- boot (L8) --------------------------------------------------------------
|
|
|
|
def boot(self) -> None:
|
|
"""Resume waiting points as they were, and never resume interrupted
|
|
work blindly: an item cut off mid-run waits on the operator."""
|
|
self._store.set_active(None, cause="boot")
|
|
self._reconcile_publishing()
|
|
for item in self._store.work_items("open"):
|
|
loop = loop_of(item)
|
|
if item.position == RUNNING:
|
|
note = "I restarted while working on this. Reply to continue."
|
|
self._wait(item, replace(loop, wait=ANSWER, note=note), note)
|
|
elif item.position == WAITING and loop.thread:
|
|
if loop.wait == GO_AHEAD:
|
|
self._shown[loop.thread] = loop.rev
|
|
self._transport.post(
|
|
f"(after a restart) rev-{loop.rev} is still up; say go "
|
|
"to start, or reply with changes.",
|
|
thread=loop.thread,
|
|
)
|
|
else:
|
|
self._transport.post(
|
|
f"(after a restart) still waiting: {loop.note}",
|
|
thread=loop.thread,
|
|
)
|
|
|
|
def _reconcile_publishing(self) -> None:
|
|
"""L8 step 2: a publish cut off by a crash is settled against Gitea.
|
|
An open PR for the branch is adopted; otherwise the intent is aborted
|
|
and the operator is told, never a blind retry."""
|
|
for intent in self._store.pending_intents():
|
|
if intent.kind != OPEN_PR or not intent.work_item:
|
|
continue
|
|
item = self._store.work_item(intent.work_item)
|
|
branch = str(intent.payload.get("branch", ""))
|
|
try:
|
|
pr = self._gitea.find_pull_request(self._repo, branch)
|
|
except GiteaError:
|
|
continue # unverifiable: left pending, asked about below
|
|
if item is None or item.status != "open":
|
|
self._store.abort_intent(intent.id, cause="item no longer open")
|
|
elif pr is not None and pr.state == "open":
|
|
self._store.commit_intent(intent.id, cause=f"adopted PR #{pr.number}")
|
|
loop = replace(loop_of(item), pr=pr.number, pr_url=pr.url)
|
|
self._fact(item, f"after a restart, adopted PR #{pr.number} ({pr.url})")
|
|
save(
|
|
self._store,
|
|
item.id,
|
|
WAITING,
|
|
replace(loop, wait=MERGE, note=f"PR #{pr.number}"),
|
|
cause="adopted PR after restart",
|
|
)
|
|
else:
|
|
self._store.abort_intent(intent.id, cause="no PR was opened")
|
|
note = "publishing was cut off by a restart; reply to try again."
|
|
self._wait(item, replace(loop_of(item), wait=ANSWER, note=note), note)
|
|
|
|
# -- intake (D1) ---------------------------------------------------------
|
|
|
|
def intake(self) -> None:
|
|
"""Issues assigned to the agent become work items, once each."""
|
|
try:
|
|
issues = self._gitea.assigned_issues(self._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:
|
|
continue
|
|
if not self._from_an_operator(issue):
|
|
self._oplog.emit(
|
|
"loop.intake_ignored",
|
|
cause="not filed or assigned by an operator",
|
|
issue=issue.number,
|
|
)
|
|
continue
|
|
self._open(issue)
|
|
self.watch()
|
|
self._schedule()
|
|
|
|
# -- PR review and merge (D4, D5) -------------------------------------------
|
|
|
|
def watch(self) -> None:
|
|
"""New operator comments on an item's PR, and its merge or close,
|
|
reach the item; a waiting item becomes ready to act on them."""
|
|
for item in self._store.work_items("open"):
|
|
loop = loop_of(item)
|
|
if not loop.pr:
|
|
continue
|
|
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)
|
|
except GiteaError as exc:
|
|
self._oplog.emit("loop.watch_unavailable", cause=str(exc))
|
|
continue
|
|
log = self._store.transcript(item.id, "operator")
|
|
new = False
|
|
for comment in comments:
|
|
if comment.id <= loop.comments:
|
|
continue
|
|
loop = replace(loop, comments=comment.id)
|
|
if comment.author in self._gitea_operators:
|
|
log.append(
|
|
UserMessage(
|
|
text=comment.body,
|
|
source=f"gitea:pr:{loop.pr}:comment:{comment.id}",
|
|
)
|
|
)
|
|
new = True
|
|
for note in notes:
|
|
if note.key in loop.notes:
|
|
continue
|
|
loop = replace(loop, notes=(*loop.notes, note.key))
|
|
if note.author in self._gitea_operators:
|
|
where = f"on {note.where}: " if note.where else ""
|
|
log.append(
|
|
UserMessage(
|
|
text=f"{where}{note.body}",
|
|
source=f"gitea:pr:{loop.pr}:review:{note.key}",
|
|
)
|
|
)
|
|
new = True
|
|
position = item.position
|
|
if pr.merged:
|
|
self._fact(item, f"PR #{loop.pr} merged")
|
|
loop, position = replace(loop, pr=0, pr_url="", branch=""), READY
|
|
elif pr.state != "open":
|
|
self._fact(item, f"PR #{loop.pr} was closed without merging")
|
|
loop, position = replace(loop, pr=0, pr_url="", branch=""), READY
|
|
elif new and item.position == WAITING:
|
|
position = READY
|
|
if position != item.position or loop != loop_of(item):
|
|
if position == READY:
|
|
loop = replace(loop, wait="", note="")
|
|
save(self._store, item.id, position, loop, cause="PR update")
|
|
|
|
def _from_an_operator(self, 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)
|
|
except GiteaError:
|
|
return False
|
|
return who in self._gitea_operators
|
|
|
|
def _open(self, issue: Issue) -> None:
|
|
item_id = f"{self._project}-{issue.number}-{uuid.uuid4().hex[:6]}"
|
|
cause = f"assigned {self._repo}#{issue.number}"
|
|
root = self._transport.post(
|
|
f"Queued {self._repo}#{issue.number}: {issue.title}"
|
|
)
|
|
loop = Loop(thread=root, issue=issue.number)
|
|
self._store.open_work_item(
|
|
item_id,
|
|
title=issue.title,
|
|
scope=self._project,
|
|
position=READY,
|
|
data={"loop": loop.to_data(), "roles": self._roles},
|
|
cause=cause,
|
|
)
|
|
log = self._store.transcript(item_id, "orchestrator")
|
|
log.append(
|
|
Fact(
|
|
text=f"work item opened from {self._repo}#{issue.number} "
|
|
f"({issue.title}), filed by {issue.author}"
|
|
+ (f", labels: {', '.join(issue.labels)}" if issue.labels else "")
|
|
)
|
|
)
|
|
text = f"{issue.title}\n\n{issue.body}".strip()
|
|
if issue.author in self._gitea_operators:
|
|
log.as_actor("operator").append(
|
|
UserMessage(text=text, source=f"gitea:issue-body:{issue.number}")
|
|
)
|
|
else:
|
|
# L11: someone else's text is data; the operator's assignment is
|
|
# the instruction.
|
|
log.append(Fact(text=f"the issue as written by {issue.author}:\n{text}"))
|
|
|
|
# -- messages (L5) -------------------------------------------------------
|
|
|
|
def on_message(self, msg: InboundMessage) -> None:
|
|
"""On the transport's thread: stamp, then hand to the worker."""
|
|
if msg.sender not in self._operators:
|
|
return
|
|
if command_form(msg.text) == "stop" and self._running:
|
|
# Straight to the flag: the run is what it has to reach (L2).
|
|
self._cancel.set()
|
|
about = self._shown.get(msg.thread) if msg.thread else None
|
|
self._worker.submit(
|
|
self._settled("your message", lambda: self.handle(msg, about))
|
|
)
|
|
|
|
def handle(self, msg: InboundMessage, about: int | None) -> None:
|
|
item = item_for_thread(self._store, msg.thread) if msg.thread else None
|
|
text = (msg.original or msg.text).strip()
|
|
command = command_form(msg.text)
|
|
if item is None:
|
|
self._store.transcript(None, "operator").append(
|
|
UserMessage(text=text, source=f"transport:{msg.sender}")
|
|
)
|
|
if command == "status":
|
|
self._transport.post(self.status(), thread=msg.thread)
|
|
elif command != "stop":
|
|
self._transport.post(
|
|
f"I take work from issues assigned to me in {self._repo}: "
|
|
"assign one and I'll pick it up. Reply in its thread to "
|
|
"talk about it.",
|
|
thread=msg.thread,
|
|
)
|
|
return
|
|
self._store.transcript(item.id, "operator").append(
|
|
UserMessage(text=text, source=f"transport:{msg.sender}")
|
|
)
|
|
loop = loop_of(item)
|
|
if command == "abandon":
|
|
self._close(item, loop, "abandoned", f"abandoned by {msg.sender}")
|
|
elif item.position == WAITING and loop.wait == GO_AHEAD and command in _GO:
|
|
if about != loop.rev:
|
|
# L3: a go-ahead is about what was on screen when it arrived.
|
|
self._transport.post(
|
|
f"that go was for rev-{about}, but rev-{loop.rev} is up now; "
|
|
"say go again if it still stands.",
|
|
thread=loop.thread,
|
|
)
|
|
return
|
|
self._fact(item, f"go-ahead on rev-{loop.rev} from {msg.sender}")
|
|
save(
|
|
self._store,
|
|
item.id,
|
|
READY,
|
|
replace(loop, started=True, wait=""),
|
|
cause=f"go-ahead on rev-{loop.rev}",
|
|
)
|
|
elif item.position == WAITING:
|
|
# An answer, or changes to a restatement: the driver reads it.
|
|
save(
|
|
self._store,
|
|
item.id,
|
|
READY,
|
|
replace(loop, wait="", note=""),
|
|
cause="the operator answered",
|
|
)
|
|
self._schedule()
|
|
|
|
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}."
|
|
lines = []
|
|
for item in items:
|
|
loop = loop_of(item)
|
|
what = item.position
|
|
if item.position == WAITING:
|
|
what = f"waiting for {loop.wait} ({loop.note or f'rev-{loop.rev}'})"
|
|
lines.append(f"#{loop.issue} {item.title}: {what}")
|
|
return "\n".join(lines)
|
|
|
|
# -- running (L1, L2) ------------------------------------------------------
|
|
|
|
def _schedule(self) -> None:
|
|
"""Run ready items until none is left. Only on the worker."""
|
|
if self._running:
|
|
return
|
|
while True:
|
|
ready = [i for i in self._store.work_items("open") if i.position == READY]
|
|
if not ready:
|
|
self._store.set_active(None, cause="nothing ready")
|
|
return
|
|
self._run(ready[0]) # oldest first (L1)
|
|
|
|
def _run(self, item: WorkItem) -> None:
|
|
loop = loop_of(item)
|
|
self._store.set_active(item.id, cause="ready")
|
|
if not loop.prepared and self._milestones is not None:
|
|
# D3: a fresh workspace when a work item starts.
|
|
try:
|
|
notes = self._milestones.prepare(f"{item.id}-work")
|
|
except Exception as exc: # noqa: BLE001 -- the item waits, not the loop
|
|
note = f"I couldn't prepare the workspace: {exc}. Reply to retry."
|
|
self._wait(item, replace(loop, wait=ANSWER, note=note), note)
|
|
self._store.set_active(None, cause="workspace failed")
|
|
return
|
|
self._fact(item, "workspace prepared from the latest default branch")
|
|
if notes:
|
|
said = "Set aside from the workspace: " + "; ".join(notes)
|
|
self._fact(item, said)
|
|
self._transport.post(said, thread=loop.thread)
|
|
loop = replace(loop, prepared=True)
|
|
save(self._store, item.id, RUNNING, loop, cause="driver run")
|
|
tools = [
|
|
RESTATE,
|
|
ASK_HUMAN,
|
|
*(
|
|
_LOOK
|
|
if not loop.started
|
|
else [START_MILESTONE, FINISH_MILESTONE, CLOSE_ITEM, *EXECUTOR_TOOLS]
|
|
),
|
|
]
|
|
system = SYSTEM.format(
|
|
repo=self._repo,
|
|
issue=loop.issue,
|
|
title=item.title,
|
|
workspace=self._workspace.describe() if self._workspace else "",
|
|
)
|
|
self._running = True
|
|
try:
|
|
outcome = self._driver.run(
|
|
self._store.transcript(item.id, "driver"),
|
|
system=system,
|
|
tools=tools,
|
|
workspace=self._workspace,
|
|
handlers={"start_milestone": lambda args: self._start(item.id, args)},
|
|
)
|
|
finally:
|
|
self._running = False
|
|
self._cancel.clear()
|
|
self._settle(item.id, outcome)
|
|
|
|
def _settle(self, item_id: str, outcome: RunOutcome) -> None:
|
|
item = self._store.work_item(item_id)
|
|
assert item is not None
|
|
loop = loop_of(item)
|
|
args = outcome.arguments or {}
|
|
if outcome.kind == "wait" and outcome.tool == "restate":
|
|
text = str(args.get("text", "")).strip()
|
|
rev = loop.rev + 1
|
|
self._store.transcript(item.id, "driver").append(
|
|
PlanSubmitted(plan=text, model=self._roles.get("planner", ""))
|
|
)
|
|
self._fact(item, f"restatement rev-{rev} put up for a go-ahead")
|
|
self._shown[loop.thread] = rev
|
|
self._transport.post(
|
|
f"rev-{rev}: {text}\n\nSay go to start, or reply with changes.",
|
|
thread=loop.thread,
|
|
)
|
|
self._wait(item, replace(loop, rev=rev, wait=GO_AHEAD, note=""), None)
|
|
elif outcome.kind == "wait" and outcome.tool == "ask_human":
|
|
question = str(args.get("question", "")).strip()
|
|
self._wait(item, replace(loop, wait=ANSWER, note=question), question)
|
|
elif outcome.kind == "wait" and outcome.tool == "finish_milestone":
|
|
self._finish(item, loop, str(args.get("summary", "")).strip())
|
|
elif outcome.kind == "wait" and outcome.tool == "close_item":
|
|
summary = str(args.get("summary", "")).strip()
|
|
self._transport.post(f"Done: {summary}", thread=loop.thread)
|
|
self._close(item, loop, "completed", summary)
|
|
elif outcome.kind == "stopped":
|
|
note = f"stopped ({outcome.note}). Why did you stop it? Reply to continue."
|
|
self._wait(item, replace(loop, wait=ANSWER, note=note), note)
|
|
else:
|
|
note = f"I stopped short: {outcome.note}. Reply to continue."
|
|
self._wait(item, replace(loop, wait=ANSWER, note=note), note)
|
|
self._store.set_active(None, cause="item waiting")
|
|
|
|
# -- milestones (D4) -----------------------------------------------------
|
|
|
|
def _start(self, item_id: str, args: dict[str, object]) -> tuple[str, bool]:
|
|
"""start_milestone, mid-run: cut the milestone's branch."""
|
|
item = self._store.work_item(item_id)
|
|
assert item is not None
|
|
loop = loop_of(item)
|
|
if loop.branch:
|
|
busy = f"milestone {loop.milestone} is still open; finish it first"
|
|
return busy, False
|
|
if self._milestones is None:
|
|
return "milestones aren't available here", False
|
|
number = loop.milestone + 1
|
|
branch = f"{item.id}-milestone-{number}-{uuid.uuid4().hex[:4]}"
|
|
self._milestones.start(branch)
|
|
name = str(args.get("name", "")).strip()
|
|
self._fact(item, f"milestone {number} started ({name}) on branch {branch}")
|
|
save(
|
|
self._store,
|
|
item.id,
|
|
RUNNING,
|
|
replace(loop, milestone=number, branch=branch),
|
|
cause=f"milestone {number} started",
|
|
)
|
|
return f"started milestone {number} on branch {branch}", True
|
|
|
|
def _finish(self, item: WorkItem, loop: Loop, summary: str) -> None:
|
|
"""finish_milestone: publish the milestone, or push a review fix."""
|
|
if self._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(
|
|
item_id=item.id,
|
|
branch=loop.branch,
|
|
pr=loop.pr,
|
|
message=summary or "Address review comments",
|
|
attempt=loop.fixes + 1,
|
|
)
|
|
self._fact(item, f"review fix: {fixed.note}")
|
|
if fixed.kind in ("pushed", "nothing"):
|
|
self._gitea_comment(loop.pr, fixed.note)
|
|
self._wait(
|
|
item,
|
|
replace(loop, wait=MERGE, note=f"PR #{loop.pr}"),
|
|
f"PR #{loop.pr}: {fixed.note}",
|
|
)
|
|
elif fixed.kind == "new-pr" and fixed.pr is not None:
|
|
# Later fixes go to the new PR's branch, never the merged one.
|
|
loop = replace(
|
|
loop,
|
|
pr=fixed.pr.number,
|
|
pr_url=fixed.pr.url,
|
|
branch=fixed.branch,
|
|
fixes=loop.fixes + 1,
|
|
)
|
|
self._wait(
|
|
item,
|
|
replace(loop, wait=MERGE, note=f"PR #{fixed.pr.number}"),
|
|
f"The PR merged before the fix landed, so it's a new PR: "
|
|
f"#{fixed.pr.number} {fixed.pr.url}",
|
|
)
|
|
else:
|
|
self._wait(
|
|
item,
|
|
replace(loop, wait=ANSWER, note=fixed.note),
|
|
f"{fixed.note}. What next?",
|
|
)
|
|
return
|
|
if not loop.branch:
|
|
# D4: finishing without a start cuts the branch now.
|
|
number = loop.milestone + 1
|
|
branch = f"{item.id}-milestone-{number}-{uuid.uuid4().hex[:4]}"
|
|
self._milestones.start(branch)
|
|
loop = replace(loop, milestone=number, branch=branch)
|
|
published = self._milestones.publish(
|
|
item_id=item.id,
|
|
branch=loop.branch,
|
|
title=f"{item.title} (milestone {loop.milestone})",
|
|
summary=summary,
|
|
issue=loop.issue,
|
|
)
|
|
left = (
|
|
f" (left out {len(published.left_out)} build artifact(s))"
|
|
if published.left_out
|
|
else ""
|
|
)
|
|
if published.pr is not None and published.kind in ("opened", "adopted"):
|
|
pr = published.pr
|
|
self._fact(
|
|
item,
|
|
f"milestone {loop.milestone} published: PR #{pr.number} "
|
|
f"({pr.url}){left}",
|
|
)
|
|
self._wait(
|
|
item,
|
|
replace(
|
|
loop,
|
|
pr=pr.number,
|
|
pr_url=pr.url,
|
|
wait=MERGE,
|
|
note=f"PR #{pr.number}",
|
|
),
|
|
f"Milestone {loop.milestone} is up for review: {pr.url}{left}",
|
|
)
|
|
else:
|
|
self._fact(
|
|
item, f"milestone {loop.milestone} not published: {published.note}"
|
|
)
|
|
self._wait(
|
|
item,
|
|
replace(loop, wait=ANSWER, note=published.note),
|
|
f"Milestone {loop.milestone}: {published.note}. What next?",
|
|
)
|
|
|
|
def _gitea_comment(self, pr: int, text: str) -> None:
|
|
try:
|
|
self._gitea.post_comment(self._repo, pr, text)
|
|
except GiteaError:
|
|
pass # courtesy only; the pushed commit is the answer
|
|
|
|
def _wait(self, item: WorkItem, loop: Loop, say: str | None) -> None:
|
|
if say:
|
|
self._transport.post(say, thread=loop.thread)
|
|
save(self._store, item.id, WAITING, loop, cause=f"waiting for {loop.wait}")
|
|
|
|
def _close(self, item: WorkItem, loop: Loop, outcome: str, note: str) -> None:
|
|
self._fact(item, f"work item {outcome}: {note}")
|
|
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)
|
|
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 loop.issue:
|
|
try:
|
|
self._gitea.post_comment(self._repo, loop.issue, f"{outcome}: {note}")
|
|
self._gitea.close_issue(self._repo, loop.issue)
|
|
except GiteaError as exc:
|
|
self._transport.post(
|
|
f"closed here, but could not close #{loop.issue}: {exc}",
|
|
thread=loop.thread,
|
|
)
|
|
if outcome == "abandoned":
|
|
self._transport.post(f"abandoned: {note}", thread=loop.thread)
|
|
|
|
def _fact(self, item: WorkItem, text: str) -> None:
|
|
self._store.transcript(item.id, "orchestrator").append(Fact(text=text))
|