Files
nisse/nisse-dev/src/nisse_dev/loop/milestones.py
T
dongarandClaude Opus 5.5 266b5dbbd7
CI / checks (push) Skipped
Check loop.qnt's properties against the real loop (M2.1, PR D part 1)
test_loop_properties.py walks the new loop with a seeded mix of operator
and Gitea actions (go-aheads, stale go-aheads, answers, comments, merges,
the comment-then-merge race, closes, abandon, a crash and restart) and an
adaptive scripted model, checking every loop.qnt property after every
step. It found two bugs, both fixed with regression tests: abandoning an
item left its open PR open (the model's fix 1, now in code), and after a
review fix became a follow-up PR, the next fix went to the merged branch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-03 14:19:19 +00:00

197 lines
7.7 KiB
Python

"""The workspace and milestones of a work item (D3, D4, D5).
The harness owns branches: a work item starts from a fresh default branch on
a branch of its own, a milestone is cut by start_milestone, and finishing it
commits only the work, pushes with the push credential, and opens the PR.
Publishing is idempotent under an intent (L8): an open PR for the branch is
adopted, and a PR the operator closed is an answer, never reopened.
"""
import time
from dataclasses import dataclass
from typing import Literal
from nisse_dev.git import GitError, GitRepo, PushCredential
from nisse_dev.gitea import GiteaClient, GiteaError, PullRequest
from nisse_dev.state import StateStore
OPEN_PR = "open_pr"
SET_ASIDE = "set-aside"
@dataclass(frozen=True)
class Published:
kind: Literal["opened", "adopted", "nothing", "closed", "failed"]
note: str
pr: PullRequest | None = None
left_out: tuple[str, ...] = ()
@dataclass(frozen=True)
class Fixed:
kind: Literal["pushed", "nothing", "new-pr", "closed", "failed"]
note: str
pr: PullRequest | None = None
branch: str = "" # a new PR's branch, which later fixes go to
class Milestones:
def __init__(
self,
store: StateStore,
git: GitRepo,
gitea: GiteaClient,
*,
repo: str,
remote: str,
credential: PushCredential | None,
base: str = "main",
) -> None:
self._store = store
self._git = git
self._gitea = gitea
self._repo = repo
self._remote = remote
self._credential = credential
self._base = base
# -- a work item's workspace (D3) ------------------------------------------
def prepare(self, item_branch: str) -> list[str]:
"""A fresh workspace for a work item: fetch, set aside (never
discard) whatever was left, check out a clean default branch, and put
the item on a branch of its own. Returns what was set aside."""
git = self._git
git.fetch(credential=self._credential)
stamp = time.strftime("%Y%m%d-%H%M%S")
notes: list[str] = []
if git.has_changes():
aside = f"{SET_ASIDE}/{stamp}/worktree"
git.checkout_new_branch(aside)
git.commit_all("Set aside: uncommitted changes left in the workspace")
notes.append(f"uncommitted changes, as {aside}")
for branch in git.local_branches():
if branch == self._base or branch.startswith(f"{SET_ASIDE}/"):
continue
git.rename_branch(branch, f"{SET_ASIDE}/{stamp}/{branch}")
notes.append(f"branch {branch}, as {SET_ASIDE}/{stamp}/{branch}")
if self._base in git.local_branches() and self._ahead_of_origin():
aside = f"{SET_ASIDE}/{stamp}/{self._base}"
git.branch_at(aside, self._base)
notes.append(f"unpushed commits on {self._base}, as {aside}")
git.reset_to(self._base, f"origin/{self._base}")
git.checkout_new_branch(item_branch)
return notes
def _ahead_of_origin(self) -> bool:
current = self._git.current_branch()
try:
self._git.checkout_existing_branch(self._base)
return self._git.commits_since(f"origin/{self._base}") > 0
finally:
if current and current != self._base:
self._git.checkout_existing_branch(current)
# -- milestones (D4) -------------------------------------------------------
def start(self, branch: str) -> str:
"""Cut the milestone's branch where the item is; returns its start."""
self._git.checkout_new_branch(branch)
return self._git.head_sha()
def publish(
self,
*,
item_id: str,
branch: str,
title: str,
summary: str,
issue: int,
) -> Published:
git = self._git
try:
if git.current_branch() != branch:
git.checkout_new_branch(branch)
git.commit_all(title)
left_out = tuple(git.left_out())
if git.commits_since(f"origin/{self._base}") == 0:
return Published("nothing", "no changes to publish", left_out=left_out)
existing = self._gitea.find_pull_request(self._repo, branch)
if existing is not None and existing.state != "open":
# D4: the operator closed it; that is their answer.
return Published(
"closed",
f"PR #{existing.number} for this branch was closed",
existing,
left_out,
)
intent = self._store.begin_intent(
OPEN_PR,
{"branch": branch, "title": title, "issue": issue},
cause=f"publishing {branch}",
work_item=item_id,
)
git.push(self._remote, branch, credential=self._credential)
existing = self._gitea.find_pull_request(self._repo, branch)
if existing is not None and existing.state == "open":
self._store.commit_intent(
intent, cause=f"adopted PR #{existing.number}"
)
return Published("adopted", "pushed to the open PR", existing, left_out)
pr = self._gitea.open_pull_request(
self._repo,
head=branch,
base=self._base,
title=title,
body=_body(summary, issue, left_out),
)
self._store.commit_intent(intent, cause=f"opened PR #{pr.number}")
return Published("opened", f"opened PR #{pr.number}", pr, left_out)
except (GitError, GiteaError) as exc:
return Published("failed", f"publishing failed: {exc}")
def push_fix(
self, *, item_id: str, branch: str, pr: int, message: str, attempt: int
) -> Fixed:
"""Push a review fix to the PR's branch, then check the PR: one that
merged before the fix landed gets the fix as a new PR, and one that
was closed is reported (D5)."""
git = self._git
try:
if git.current_branch() != branch:
git.checkout_existing_branch(branch)
sha = git.commit_all(message) or git.head_sha()
if git.remote_sha(self._remote, branch, credential=self._credential) == sha:
return Fixed("nothing", "no change was needed")
git.push(self._remote, branch, credential=self._credential)
state = self._gitea.pull_request(self._repo, pr)
if state.merged:
git.fetch(credential=self._credential)
if git.is_ancestor(sha, f"origin/{self._base}"):
return Fixed("pushed", "pushed, and it was merged with the PR")
follow = f"{branch}-fix-{attempt}"
git.checkout_new_branch(follow)
new = self.publish(
item_id=item_id,
branch=follow,
title=f"Follow-up to #{pr}: {message}",
summary=f"PR #{pr} merged before this review fix landed.",
issue=0,
)
return Fixed("new-pr", new.note, new.pr, follow)
if state.state != "open":
return Fixed("closed", f"PR #{pr} was closed", state)
return Fixed("pushed", f"pushed {sha[:8]}", state)
except (GitError, GiteaError) as exc:
return Fixed("failed", f"pushing the fix failed: {exc}")
def _body(summary: str, issue: int, left_out: tuple[str, ...]) -> str:
parts = [summary]
if issue:
parts += ["", f"Closes #{issue}"]
if left_out:
parts += ["", "## Left out", "", "Build artifacts, never part of the work:"]
parts += [f"- `{path}`" for path in left_out]
return "\n".join(parts)