"""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 contextlib import time import uuid 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, *, keep: tuple[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. Branches starting with a ``keep`` prefix belong to open work items, which parking has pushed: they are left alone. Returns what was set aside.""" git = self._git self.park(keep=keep) 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 if keep and branch.startswith(keep): 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 # -- parking and resuming (L1, D4) ----------------------------------------- def park(self, *, keep: tuple[str, ...]) -> str | None: """Before another item uses the workspace: nothing of the item on it may live only on the build VM. Its uncommitted work is stashed and pushed to ``-parked``, never committed onto its branch, which may be a PR's that has merged meanwhile; resuming puts it back uncommitted, for wherever the work goes next. Commits the harness made but couldn't push are pushed. Only an open item's harness branch (one starting with a ``keep`` prefix, ``-``) is parked; returns its name.""" git = self._git branch = git.current_branch() prefix = next((k for k in keep if branch.startswith(k)), None) if prefix is None: return None sha = git.stash_all(f"parked from {branch}") if sha is not None: git.push_commit( self._remote, sha, f"{prefix}parked", credential=self._credential ) remote = git.remote_sha(self._remote, branch, credential=self._credential) if remote != git.head_sha() and ( remote is not None or git.commits_since(f"origin/{self._base}") > 0 ): git.push(self._remote, branch, credential=self._credential) return branch def resume(self, branch: str, *, keep: tuple[str, ...], mine: str) -> None: """Back to a parked item: park whatever is on the workspace now, check out the item's branch, and put its parked work back. The local branch wins when it exists (it can only be ahead: parking pushed it); otherwise it comes from origin. A workspace already on one of the item's own branches (``mine``, its ``-`` prefix) stays.""" git = self._git current = git.current_branch() if current != branch and not current.startswith(mine): self.park(keep=keep) if branch in git.local_branches(): git.checkout_existing_branch(branch) git.reset_to(branch, branch) else: git.fetch(credential=self._credential) pushed = git.remote_sha( self._remote, branch, credential=self._credential ) # A branch never pushed held nothing beyond the default branch. start = f"origin/{branch}" if pushed else f"origin/{self._base}" git.reset_to(branch, start) # Even when it stayed: a crash may have come between parking and the # next item's start. self.unpark(mine) def unpark(self, mine: str) -> None: parked = f"{mine}parked" git = self._git sha = git.remote_sha(self._remote, parked, credential=self._credential) if sha is None: return if not git.has_commit(sha): git.fetch(credential=self._credential) git.apply_stash(sha) git.delete_remote_branch(self._remote, parked, credential=self._credential) def drop_parked(self, mine: str) -> None: """A closed item's parked work, if any, goes: closing set it aside.""" parked = f"{mine}parked" git = self._git if git.remote_sha(self._remote, parked, credential=self._credential): git.delete_remote_branch(self._remote, parked, credential=self._credential) @property def git(self) -> GitRepo: """The clone, for a delegation's branch (L9).""" return self._git def changed(self) -> list[str]: """What the current milestone changes, from the default branch.""" return self._git.changed_paths(f"origin/{self._base}") # -- a new project's checks (D1, D4) ----------------------------------------- def verify_access(self, account: str) -> list[str]: """What the operator provided, checked rather than assumed: the agent may push a branch with its push credential, may not push to the default branch, and may not merge its own PR without approval. A throwaway branch with an empty commit carries the checks; its PR is closed and the branch deleted. Returns what failed.""" git, base, failures = self._git, self._base, [] try: repo = self._gitea.repository(self._repo) if not (repo.get("permissions") or {}).get("push"): failures.append(f"{account} isn't a collaborator with write access") branch = self._gitea.branch(self._repo, base) if not branch.get("protected") or branch.get("user_can_push"): failures.append(f"{base} isn't protected against my pushes") except GiteaError as exc: return [f"I can't see the repository {self._repo}: {exc}"] check = f"nisse-check-{uuid.uuid4().hex[:6]}" try: git.fetch(credential=self._credential) git.reset_to(check, f"origin/{base}") git._git("commit", "-q", "--allow-empty", "-m", "nisse-dev: access check") except GitError as exc: return [*failures, f"the workspace isn't usable: {exc}"] try: git.push(self._remote, check, credential=self._credential) except GitError as exc: failures.append(f"my push credential can't push a branch: {exc}") self._back_to_base(check) return failures try: git.push(self._remote, base, credential=self._credential) failures.append( f"I could push to {base}, which protection should refuse; an " f"empty commit, 'nisse-dev: access check', is on {base} now" ) except GitError: pass # refused, as it should be pr = None try: pr = self._gitea.open_pull_request( self._repo, head=check, base=base, title="nisse-dev access check (never merged)", body="Checks that merging needs an approval; closed straight away.", ) try: self._gitea.merge_pull_request(self._repo, pr.number) failures.append( f"I could merge my own PR #{pr.number} without an approval" ) except GiteaError: pass # refused, as it should be except GiteaError as exc: failures.append(f"I couldn't open a PR to check merging: {exc}") finally: if pr is not None: with contextlib.suppress(GiteaError): self._gitea.close_pull_request(self._repo, pr.number) with contextlib.suppress(GitError): git.delete_remote_branch( self._remote, check, credential=self._credential ) self._back_to_base(check) return failures def _back_to_base(self, branch: str) -> None: with contextlib.suppress(GitError): self._git.reset_to(self._base, f"origin/{self._base}") self._git._git("branch", "-q", "-D", branch) 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 from the latest default branch (D4): a milestone after a merge builds on it, and one redone after a closed PR doesn't carry the rejected work. Uncommitted work comes along. Returns where it starts.""" self._git.fetch(credential=self._credential) self._git.checkout_new_branch(branch, f"origin/{self._base}") return self._git.head_sha() def publish( self, *, item_id: str, branch: str, title: str, summary: str, issue: int, final: bool = True, ) -> 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, final), ) 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, ...], final: bool = True ) -> str: parts = [summary] if issue: # D1: the last PR closes the issue on merge; earlier ones are part of it. parts += ["", f"Closes #{issue}" if final else f"Part of #{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)