New projects from conversation (M2.5, PR B)
- propose_project(name, purpose, decisions) in a conversation goes up for a go-ahead (checked: a slug, not an existing project, no trailing question). - On go, the provisioning checklist (D1): a repository with a README, protection on its default branch, the agent as a collaborator, a build VM, a deploy key, and the project entry as YAML with <...> to fill in. - On "done", the agent reads its configuration again and adds the new project live (build_loop_agent(load=...); only additions), then checks what was provided (D4, verified, not assumed): permissions and protection as it sees them, then on a throwaway branch with an empty commit, a branch push must succeed and a push to the default branch and merging its own PR must be refused. The check's PR is closed and its branch deleted. Failures are said per item; nothing starts. - Checked: the tracking issue (the distilled decisions, carrying the new item's ID), and the project's item, agreed and labelled `design`, whose first milestone is ARCHITECTURE.md and the build plan. A conversation waiting on provisioning doesn't close as idle. - The walk replays exactly from its seed (item IDs too), and event IDs stay unique across simulated restarts, as a server's are. - The test config holds output to 8192 tokens, under the test key's per-request cost cap. Tests: new-project unit tests (checklist and its YAML parse as config, done before the entry, what isn't set up, a checked project's record and first PR, an existing name), protection enforced by a hook on the fake forge, the walk proposing projects and saying done, and an e2e new project in a repository the test operator provisions from the agent's checklist. 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
9a86ccb2a4
commit
ad2d090143
13 files changed
+723
-54
No files matched your search
@@ -7,7 +7,9 @@ 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
|
||||
|
||||
@@ -159,6 +161,79 @@ class Milestones:
|
||||
if git.remote_sha(self._remote, parked, credential=self._credential):
|
||||
git.delete_remote_branch(self._remote, parked, credential=self._credential)
|
||||
|
||||
# -- 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:
|
||||
|
||||
Reference in new issue
Block a user