242 lines
9.0 KiB
Python
Executable File
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)
|