forked from Keelrleek/Harbor
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:
1 parent
a92efdad45
commit
eb948ddda1
3 files changed
+198
-166
No files matched your search
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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("<x> & ''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("<x> & ''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()
|
||||||
Reference in new issue
Block a user