submit and author resubmits: the commit must be a hash git knows (checked in the author's workdir or the Logbook checkout) and is stored in full; HEAD, placeholders and unknown hashes are refused

This commit is contained in:
KeelrLeek committed 2026-10-06 21:20:45 -05:00
1 parent a92efdad45
commit eb948ddda1
3 files changed
+198 -166

No files matched your search

+3
View File
@@ -761,6 +761,7 @@ class Harbor(WorkMixin, ExtrasMixin, FlowMixin, LaunchMixin):
if not reviewers: if not reviewers:
raise HarborError("no reviewer: name one, or set reviewers in the project file") raise HarborError("no reviewer: name one, or set reviewers in the project file")
canonical = parse_scope(scope)[0] if scope else None canonical = parse_scope(scope)[0] if scope else None
commit = self.resolve_commit(author, commit)
def do(c): def do(c):
now = self.now() now = self.now()
@@ -822,6 +823,8 @@ class Harbor(WorkMixin, ExtrasMixin, FlowMixin, LaunchMixin):
self.require_running(agent, "move review items") self.require_running(agent, "move review items")
if to_state == "landed": if to_state == "landed":
self.check_landing_policy(item_id) self.check_landing_policy(item_id)
if is_author and commit:
commit = self.resolve_commit(agent, commit)
def do(c): def do(c):
now = self.now() now = self.now()
+23
View File
@@ -39,6 +39,29 @@ def _git(workdir, *args):
class FlowMixin: class FlowMixin:
def resolve_commit(self, agent, commit):
"""The full hash of a commit an item names, checked in agent's workdir (or the Logbook's decomp checkout).
A placeholder ('HEAD', 'PLACEHOLDER') or a hash git does not know is refused; without any git checkout to
ask, the value is kept as given."""
if not commit:
return commit
commit = str(commit).strip()
spec = next((a for a in self.project.agents if a["name"] == agent), {})
places = [spec.get("workdir"), (self.project.logbook or {}).get("decomp")]
repos = [p for p in places if p and (Path(p) / ".git").exists()]
if not repos:
return commit
if not re.fullmatch(r"[0-9a-fA-F]{7,40}", commit):
raise HarborError(f"commit {commit!r} is not a hash: give the commit's hash (git rev-parse HEAD), "
"not a branch name, HEAD or a placeholder")
for repo in repos:
try:
return _git(repo, "rev-parse", "--verify", "--quiet", f"{commit}^{{commit}}").strip()
except (RuntimeError, subprocess.TimeoutExpired):
continue
raise HarborError(f"commit {commit} is not in the repository: commit your work first and give the hash "
"git rev-parse HEAD prints")
def worktree_change(self, agent): def worktree_change(self, agent):
"""(mtime, path) of the most recently modified uncommitted file in agent's worktree, or None.""" """(mtime, path) of the most recently modified uncommitted file in agent's worktree, or None."""
spec = next((a for a in self.project.agents if a["name"] == agent), {}) spec = next((a for a in self.project.agents if a["name"] == agent), {})
+172 -166
View File
@@ -1,166 +1,172 @@
"""Git radar, bulk tasks and templates, the watchdog, policies and Windows notifications.""" """Git radar, bulk tasks and templates, the watchdog, policies and Windows notifications."""
import os import os
import re import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
import unittest import unittest
os.environ["HARBOR_NO_TOASTS"] = "1" os.environ["HARBOR_NO_TOASTS"] = "1"
from helpers import ROOT, env_for, http, make_home, temp_dir, wait_for_port from helpers import ROOT, env_for, http, make_home, temp_dir, wait_for_port
from harbor import notify, projects from harbor import notify, projects
from harbor.core import Harbor, HarborError from harbor.core import Harbor, HarborError
def git(cwd, *args): def git(cwd, *args):
subprocess.run(["git", "-C", str(cwd), *args], check=True, capture_output=True, subprocess.run(["git", "-C", str(cwd), *args], check=True, capture_output=True,
env={**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@x", "GIT_COMMITTER_NAME": "t", env={**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@x", "GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@x"}) "GIT_COMMITTER_EMAIL": "t@x"})
class Clock: class Clock:
def __init__(self, t=1_790_000_000.0): def __init__(self, t=1_790_000_000.0):
self.t = t self.t = t
def __call__(self): def __call__(self):
return self.t return self.t
class Flow(unittest.TestCase): class Flow(unittest.TestCase):
def setUp(self): def setUp(self):
self.dir = temp_dir() self.dir = temp_dir()
repo = self.dir / "repo" repo = self.dir / "repo"
repo.mkdir() repo.mkdir()
git(repo, "init", "-q", "-b", "main") git(repo, "init", "-q", "-b", "main")
(repo / "a.c").write_text("int a;\n", encoding="utf-8") (repo / "a.c").write_text("int a;\n", encoding="utf-8")
git(repo, "add", "a.c") git(repo, "add", "a.c")
git(repo, "commit", "-q", "-m", "base") git(repo, "commit", "-q", "-m", "base")
git(repo, "worktree", "add", "-q", "-b", "ann/work", str(self.dir / "wt-ann")) git(repo, "worktree", "add", "-q", "-b", "ann/work", str(self.dir / "wt-ann"))
for i in range(3): for i in range(3):
(self.dir / "wt-ann" / f"f{i}.c").write_text(f"int f{i};\n", encoding="utf-8") (self.dir / "wt-ann" / f"f{i}.c").write_text(f"int f{i};\n", encoding="utf-8")
git(self.dir / "wt-ann", "add", f"f{i}.c") git(self.dir / "wt-ann", "add", f"f{i}.c")
git(self.dir / "wt-ann", "commit", "-q", "-m", f"draft {i}") git(self.dir / "wt-ann", "commit", "-q", "-m", f"draft {i}")
(self.dir / "wt-ann" / "scratch.txt").write_text("x", encoding="utf-8") (self.dir / "wt-ann" / "scratch.txt").write_text("x", encoding="utf-8")
data = {"name": "flow", "coordinators": ["Lead"], "reviewers": ["Lead"], "database": str(self.dir / "f.db"), data = {"name": "flow", "coordinators": ["Lead"], "reviewers": ["Lead"], "database": str(self.dir / "f.db"),
"policy": {"land_requires": ["Rev"], "all_per_hour": 2}, "policy": {"land_requires": ["Rev"], "all_per_hour": 2},
"templates": {"decomp": {"steps": [ "templates": {"decomp": {"steps": [
{"name": "draft", "title": "Make {scope} readable", "assignee": "{owner}"}, {"name": "draft", "title": "Make {scope} readable", "assignee": "{owner}"},
{"name": "names", "title": "Name what {scope} calls", "assignee": "{owner}", "after": ["draft"]}]}}, {"name": "names", "title": "Name what {scope} calls", "assignee": "{owner}", "after": ["draft"]}]}},
"agents": [{"name": "Lead", "workdir": str(repo)}, {"name": "Ann", "workdir": str(self.dir / "wt-ann")}, "agents": [{"name": "Lead", "workdir": str(repo)}, {"name": "Ann", "workdir": str(self.dir / "wt-ann")},
{"name": "Rev"}]} {"name": "Rev"}]}
self.clock = Clock() self.clock = Clock()
self.h = Harbor(projects.project_from_dict(data), clock=self.clock) self.h = Harbor(projects.project_from_dict(data), clock=self.clock)
def tearDown(self): def tearDown(self):
self.h.close() self.h.close()
shutil.rmtree(self.dir, ignore_errors=True) shutil.rmtree(self.dir, ignore_errors=True)
def test_git_radar(self): def test_git_radar(self):
g = self.h.git_status("Ann", max_age=0) g = self.h.git_status("Ann", max_age=0)
self.assertEqual((g["branch"], g["dirty"], g["behind_main"], g["ahead_of_main"]), ("ann/work", 1, 0, 3)) self.assertEqual((g["branch"], g["dirty"], g["behind_main"], g["ahead_of_main"]), ("ann/work", 1, 0, 3))
self.assertEqual(len(g["unsubmitted"]), 3) self.assertEqual(len(g["unsubmitted"]), 3)
sha = g["last_commit"]["sha"] sha = g["last_commit"]["sha"]
self.h.submit("Ann", "drafts", commit=sha) self.h.submit("Ann", "drafts", commit=sha)
self.assertEqual([c["sha"] for c in self.h.git_status("Ann", max_age=0)["unsubmitted"]][:1] != [sha], True) self.assertEqual([c["sha"] for c in self.h.git_status("Ann", max_age=0)["unsubmitted"]][:1] != [sha], True)
self.assertEqual(self.h.git_status("Lead", max_age=0)["unsubmitted"], []) # main is the landing branch self.assertEqual(self.h.git_status("Lead", max_age=0)["unsubmitted"], []) # main is the landing branch
self.assertIsNone(self.h.git_status("Rev")) self.assertIsNone(self.h.git_status("Rev"))
def test_templates_bulk_and_band_routing(self): def test_templates_bulk_and_band_routing(self):
self.h.claim("Lead", "range:0x440000-0x47FFFF", kind="band", on_behalf_of="Ann") self.h.claim("Lead", "range:0x440000-0x47FFFF", kind="band", on_behalf_of="Ann")
steps = self.h.run_template("Lead", "decomp", scope="fn:0x445310") steps = self.h.run_template("Lead", "decomp", scope="fn:0x445310")
self.assertEqual([(t["title"], t["assignee"]) for t in steps], self.assertEqual([(t["title"], t["assignee"]) for t in steps],
[("Make fn:0x445310 readable", "Ann"), ("Name what fn:0x445310 calls", "Ann")]) [("Make fn:0x445310 readable", "Ann"), ("Name what fn:0x445310 calls", "Ann")])
self.assertEqual(steps[1]["waiting_on"], [steps[0]["id"]]) self.assertEqual(steps[1]["waiting_on"], [steps[0]["id"]])
r = self.h.bulk_tasks("Lead", "# held functions\n0x445310 again\nfn:0x450000 | RouteCellCost\n0x500000\n" r = self.h.bulk_tasks("Lead", "# held functions\n0x445310 again\nfn:0x450000 | RouteCellCost\n0x500000\n"
"Write the README\n") "Write the README\n")
self.assertEqual([t["title"] for t in r["created"]], self.assertEqual([t["title"] for t in r["created"]],
["Make 0x450000 readable (RouteCellCost)", "Make 0x500000 readable", "Write the README"]) ["Make 0x450000 readable (RouteCellCost)", "Make 0x500000 readable", "Write the README"])
self.assertEqual([t["assignee"] for t in r["created"]], ["Ann", None, None]) self.assertEqual([t["assignee"] for t in r["created"]], ["Ann", None, None])
self.assertEqual(r["skipped"], ["fn:0x445310 already has an open task"]) self.assertEqual(r["skipped"], ["fn:0x445310 already has an open task"])
with self.assertRaises(HarborError): with self.assertRaises(HarborError):
self.h.run_template("Lead", "nope") self.h.run_template("Lead", "nope")
def test_policies(self): def test_policies(self):
item = self.h.submit("Ann", "x", commit="abc") head, older = subprocess.run(["git", "-C", str(self.dir / "wt-ann"), "rev-parse", "HEAD", "HEAD~1"],
self.h.transition("Lead", item["id"], "approved") capture_output=True, text=True, check=True).stdout.split()
with self.assertRaises(HarborError): for bad in ("HEAD", "PLACEHOLDER", "abc", "0123456789abcdef"): # not a hash, or not in git
self.h.transition("Lead", item["id"], "landed", commit="b4") # Rev has not approved with self.assertRaises(HarborError):
self.h.transition("Rev", item["id"], "approved") if False else None self.h.submit("Ann", "x", commit=bad)
self.h.transition("Lead", item["id"], "changes_requested") item = self.h.submit("Ann", "x", commit=head[:8])
self.h.transition("Ann", item["id"], "submitted", commit="def") self.assertEqual(self.h.item(item["id"])[0]["commit_hash"], head) # stored in full
self.h.transition("Rev", item["id"], "approved") self.h.transition("Lead", item["id"], "approved")
self.h.transition("Lead", item["id"], "landed", commit="b4") with self.assertRaises(HarborError):
self.h.send("Ann", "All", "one") self.h.transition("Lead", item["id"], "landed", commit="b4") # Rev has not approved
self.h.send("Ann", "All", "two") self.h.transition("Rev", item["id"], "approved") if False else None
with self.assertRaises(HarborError): self.h.transition("Lead", item["id"], "changes_requested")
self.h.send("Ann", "All", "three") # capped at 2 per hour self.h.transition("Ann", item["id"], "submitted", commit=older)
self.h.send("Ann", ["Lead"], "direct is fine") self.h.transition("Rev", item["id"], "approved")
self.h.send("Lead", "All", "coordinators are not capped") self.h.transition("Lead", item["id"], "landed", commit="b4")
self.h.send("Ann", "All", "one")
def test_watchdog(self): self.h.send("Ann", "All", "two")
for _ in range(160): with self.assertRaises(HarborError):
self.h.activity("Ann", "shell") self.h.send("Ann", "All", "three") # capped at 2 per hour
self.assertEqual([b["agent"] for b in self.h.busy_without_progress()], ["Ann"]) self.h.send("Ann", ["Lead"], "direct is fine")
self.h.doing("Ann", "still on the proof") self.h.send("Lead", "All", "coordinators are not capped")
self.assertEqual(self.h.busy_without_progress(), [])
def test_watchdog(self):
def test_project_validation(self): for _ in range(160):
bad = {"name": "x", "agents": [{"name": "A"}], "templates": {"t": {"steps": [{"title": "a", "after": ["zz"]}]}}} self.h.activity("Ann", "shell")
with self.assertRaises(projects.ProjectError): self.assertEqual([b["agent"] for b in self.h.busy_without_progress()], ["Ann"])
projects.project_from_dict(bad, home=self.dir) self.h.doing("Ann", "still on the proof")
self.assertEqual(self.h.busy_without_progress(), [])
class Notifications(unittest.TestCase): def test_project_validation(self):
def test_script_and_selection(self): bad = {"name": "x", "agents": [{"name": "A"}], "templates": {"t": {"steps": [{"title": "a", "after": ["zz"]}]}}}
script = notify.toast_script("Codex asks you", "Rename <x> & 'y'?") with self.assertRaises(projects.ProjectError):
self.assertIn("&lt;x&gt; &amp; ''y''", script) projects.project_from_dict(bad, home=self.dir)
d = temp_dir()
h = Harbor(make_home(d)["test"])
try: class Notifications(unittest.TestCase):
h.send("Codex", "All", "before") def test_script_and_selection(self):
notify.set_enabled(h, True) script = notify.toast_script("Codex asks you", "Rename <x> & 'y'?")
self.assertTrue(notify.enabled(h)) self.assertIn("&lt;x&gt; &amp; ''y''", script)
h.send("Codex", "All", "a broadcast") d = temp_dir()
h.send("Codex", ["User"], "a direct message") h = Harbor(make_home(d)["test"])
h.ask("Kimi", "Ship?", ["Yes", "No"]) try:
pending, top = notify.pending_for_human(h) h.send("Codex", "All", "before")
self.assertEqual([m["body"].split("\n")[0] for m in pending], ["a direct message", "Ship?"]) notify.set_enabled(h, True)
notify.mark(h, top) self.assertTrue(notify.enabled(h))
self.assertEqual(notify.pending_for_human(h)[0], []) h.send("Codex", "All", "a broadcast")
self.assertFalse(notify.toast("t", "b")) # disabled in tests h.send("Codex", ["User"], "a direct message")
finally: h.ask("Kimi", "Ship?", ["Yes", "No"])
h.close() pending, top = notify.pending_for_human(h)
shutil.rmtree(d, ignore_errors=True) self.assertEqual([m["body"].split("\n")[0] for m in pending], ["a direct message", "Ship?"])
notify.mark(h, top)
self.assertEqual(notify.pending_for_human(h)[0], [])
class ServerFlow(unittest.TestCase): self.assertFalse(notify.toast("t", "b")) # disabled in tests
PORT = 8793 finally:
h.close()
@classmethod shutil.rmtree(d, ignore_errors=True)
def setUpClass(cls):
cls.dir = temp_dir()
cls.project = make_home(cls.dir)["test"] class ServerFlow(unittest.TestCase):
cls.proc = subprocess.Popen([sys.executable, "-B", "-m", "harbor", "serve", "--port", str(cls.PORT)], PORT = 8793
cwd=cls.dir, env=env_for(cls.dir, PYTHONPATH=str(ROOT)),
stdout=subprocess.PIPE, stderr=subprocess.STDOUT) @classmethod
assert wait_for_port(cls.PORT) def setUpClass(cls):
cls.token = re.search(r'name="harbor-token" content="([^"]+)"', http(cls.PORT, "/")[1]).group(1) cls.dir = temp_dir()
cls.project = make_home(cls.dir)["test"]
@classmethod cls.proc = subprocess.Popen([sys.executable, "-B", "-m", "harbor", "serve", "--port", str(cls.PORT)],
def tearDownClass(cls): cwd=cls.dir, env=env_for(cls.dir, PYTHONPATH=str(ROOT)),
cls.proc.terminate() stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
cls.proc.wait(timeout=10) assert wait_for_port(cls.PORT)
cls.proc.stdout.close() cls.token = re.search(r'name="harbor-token" content="([^"]+)"', http(cls.PORT, "/")[1]).group(1)
shutil.rmtree(cls.dir, ignore_errors=True)
@classmethod
def test_bulk_and_toasts(self): def tearDownClass(cls):
status, r = http(self.PORT, "/api/bulk", {"text": "0x445310 One\n0x445320 Two"}, token=self.token) cls.proc.terminate()
self.assertEqual((status, r["created"]), (200, 2)) cls.proc.wait(timeout=10)
self.assertEqual(len(http(self.PORT, "/api/overview")[1]["tasks"]), 2) cls.proc.stdout.close()
self.assertEqual(http(self.PORT, "/api/toasts", {"on": True}, token=self.token)[1]["on"], True) shutil.rmtree(cls.dir, ignore_errors=True)
self.assertTrue(http(self.PORT, "/api/overview")[1]["toasts"])
self.assertEqual(http(self.PORT, "/api/toasts", {"on": False}, token=self.token)[1]["on"], False) def test_bulk_and_toasts(self):
status, r = http(self.PORT, "/api/bulk", {"text": "0x445310 One\n0x445320 Two"}, token=self.token)
self.assertEqual((status, r["created"]), (200, 2))
if __name__ == "__main__": self.assertEqual(len(http(self.PORT, "/api/overview")[1]["tasks"]), 2)
unittest.main() self.assertEqual(http(self.PORT, "/api/toasts", {"on": True}, token=self.token)[1]["on"], True)
self.assertTrue(http(self.PORT, "/api/overview")[1]["toasts"])
self.assertEqual(http(self.PORT, "/api/toasts", {"on": False}, token=self.token)[1]["on"], False)
if __name__ == "__main__":
unittest.main()