Projects as configuration entries (M1.4, D8)
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:
dongarandClaude Opus 5.5 committed 2026-10-06 01:12:57 +00:00
1 parent 4ecff19933
commit 064d9bf554
17 files changed
+1139 -315

No files matched your search

+227 -100
View File
@@ -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}",