Files
paperclip-harness/bin/office

242 lines
9.0 KiB
Python
Executable File

#!/usr/bin/env python3
"""Open a folder as a project: adopt it, remember it, build its pages.
bin/office <folder> [--project <id|urlKey|name>] [--session NAME] [--dry]
bin/office . # the folder you are in
Adopting writes one file, <folder>/.harness/harness.toml, and nothing else. An
existing folder is never overwritten: what is already there stays, and only the
missing file is written. Paperclip's project record is read, never created —
bin/office adopts a project that exists, it does not invent one.
The machine is remembered, so the flag is stated once. Running the same command
on a machine that is not the recorded one is refused rather than silently
producing a second copy of the same project.
"""
import json
import os
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "lib"))
import harness as h # noqa: E402
import project as pj # noqa: E402
import tuios # noqa: E402
def parse(argv):
opts = {"folder": ".", "project": "", "session": "", "dry": "--dry" in argv,
"host": "", "no_switch": "--no-switch" in argv, "new": ""}
for i, a in enumerate(argv):
if a == "--new" and i + 1 < len(argv):
opts["new"] = argv[i + 1]
args = [a for a in argv if not a.startswith("--")]
if args:
opts["folder"] = args[0]
for i, a in enumerate(argv):
if a == "--project" and i + 1 < len(argv):
opts["project"] = argv[i + 1]
if a == "--session" and i + 1 < len(argv):
opts["session"] = argv[i + 1]
if a == "--host" and i + 1 < len(argv):
opts["host"] = argv[i + 1]
return opts
def create_project(name, folder, dry):
"""Create a Paperclip project for a folder, with the worktree policy set.
Adopting a folder that Paperclip has never heard of needed two steps before:
make the project in the browser, then run this command. One command is enough.
"""
body = {
"name": name,
"description": "Adopted from %s by bin/office." % folder,
"status": "backlog",
"workspace": {"name": name, "sourceType": "git_repo", "cwd": folder, "isPrimary": True},
"executionWorkspacePolicy": {
"enabled": True,
"defaultMode": "isolated_workspace",
"allowIssueOverride": True,
"workspaceStrategy": {"type": "git_worktree", "baseRef": "main",
"branchTemplate": "{{issue.identifier}}-{{slug}}"},
},
}
if dry:
h.say("would create Paperclip project %r for %s" % (name, folder))
return "(dry run)"
try:
created = h.api("POST", "/api/companies/%s/projects" % h.COMPANY, body)
except h.Refused as exc:
# A project with that name may already exist; adopting it is the right
# thing to do then, not a failure.
try:
for p in h.api("GET", "/api/companies/%s/projects" % h.COMPANY):
if p.get("name") == name:
h.say("project: %s (already in Paperclip)" % name)
return p["id"]
except h.Refused:
pass
raise exc
h.say("project: %s created (%s)" % (name, created.get("id")))
return created.get("id")
def ensure_session(session, folder):
"""Make the session exist. It cannot be shown from here, and that is fine.
Moving a person's TUIOS client to another session is refused when the call
comes from a pane — the daemon will not let a program change what the person
is looking at. Creating the session and building its panes works, so the
only step left for the person is switching to it, which TUIOS has a key for.
"""
if session in tuios.sessions():
h.say("session: %s (already there)" % session)
return
params = {"name": session, "cwd": folder, "window": False}
out = subprocess.run(["tuios", "call", "new-session", json.dumps(params)],
capture_output=True, text=True, timeout=60)
if out.returncode != 0 and "session_exists" not in (out.stderr or out.stdout):
raise h.Refused(3, "could not create session %r: %s" % (session, (out.stderr or out.stdout).strip()[:300]))
h.say("session: %s (created; switch to it with TUIOS's session switcher)" % session)
def scaffold(folder, name):
"""Write the files this project keeps, and only the ones that are missing.
Anything already there is left exactly as it is: adopting a folder must never
overwrite a person's own notes or scripts.
"""
written = []
harness = os.path.join(folder, ".harness")
os.makedirs(harness, exist_ok=True)
check = os.path.join(harness, "check.sh")
if not os.path.exists(check):
with open(check, "w") as f:
f.write("""#!/bin/sh
# This project's own check, run by bin/merge before and after it puts a task's
# work on the base branch. Exit 0 means "the project still passes".
#
# It does not decide that a task is finished: an agent sets a task's status, and
# the review decides whether the change is good. This is the regression check,
# the same thing a CI pipeline would run.
#
# Leave it as it is while the project has no tests. bin/merge will say there is
# no check and let the review decide. Replace the body when tests exist:
#
# exec go test ./...
# exec make test
echo "this project has no check yet"
exit 0
""")
os.chmod(check, 0o755)
written.append(check)
notes = os.path.join(harness, "NOTES.md")
if not os.path.exists(notes):
with open(notes, "w") as f:
f.write("# %s\n\nWhat this project is, what finished looks like, and anything a person\n"
"picking it up next week needs to know. Notes only: tasks live in Paperclip.\n" % name)
written.append(notes)
return written
def main():
opts = parse(sys.argv[1:])
folder = os.path.abspath(os.path.expanduser(opts["folder"]))
if not os.path.isdir(folder):
raise h.Refused(2, "no folder at %s" % folder)
host = opts["host"] or pj.this_machine()
# An adopted project already knows its session. Only a new one needs a
# default, and a default that names a session nobody made is worse than an
# error, so the session is checked before anything is written.
existing = {}
if pj.exists(folder):
existing = pj.load(folder)
session = (
opts["session"]
or existing.get("session")
or os.path.basename(folder)
)
# The session is created if it does not exist and the client is moved to it,
# so one command takes a person from a folder to a working layout. TUIOS has
# the verb for exactly this; asking the person to run `tuios new` first was
# the step everyone forgot.
if opts["dry"]:
h.say("would create a Paperclip project %r for %s" % (opts["new"] or opts["project"], folder))
h.say("would write %s" % os.path.join(folder, ".harness", "harness.toml"))
h.say("would create or reuse session %r and build eight panes there" % session)
h.say("dry run: nothing was written")
return 0
if not opts["no_switch"]:
ensure_session(session, folder)
if opts["host"] and opts["host"] not in (pj.this_machine(), pj.this_machine().split(".")[0]):
# Only a note, because the panes are built by the machine that owns them.
h.say("host: %s (build the pages there)" % host)
if opts["new"]:
ref = create_project(opts["new"], folder, opts["dry"])
if not ref:
return 1
opts["project"] = ref
ref = opts["project"]
if not ref and pj.exists(folder):
ref = pj.load(folder)["paperclip"]["project"]
if not ref:
raise h.Refused(
2,
"which Paperclip project is %s? pass --project <id|urlKey|name>" % folder,
)
proj = pj.resolve_project(ref)
h.say("project: %s (%s)" % (proj["name"], proj["id"]))
h.say("folder: %s" % folder)
h.say("session: %s" % session)
h.say("machine: %s" % host)
tasks = pj.active_tasks(proj["id"])
watching = next((t["identifier"] for t in tasks if t["status"] not in ("done", "cancelled")),
tasks[0]["identifier"] if tasks else "")
cfg = {
"session": session,
"paperclip": {"company": h.COMPANY, "project": proj["id"]},
"machine": {"host": host, "folder": folder},
"watching": watching,
}
if opts["dry"]:
h.say("would write %s" % pj.path(folder))
h.say("would build pages: shell, tree, reply, log, new")
return 0
pj.save(folder, cfg)
h.say("wrote %s" % pj.path(folder))
pj.register(cfg, proj["name"])
h.say("recorded in %s" % pj.REGISTRY)
written = scaffold(folder, proj["name"])
for path in written:
h.say("wrote %s" % path)
# The pages are built by this machine, from this folder, in this session.
code = os.spawnv(
os.P_WAIT,
sys.executable,
[sys.executable, os.path.join(HERE, "board"), "--folder", folder],
)
return code
if __name__ == "__main__":
h.main_guard(main)