@delorenj/pjangler 1.4.6 → 1.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -596
- package/dist/assets/project-notebook-skill/SHA256SUMS +5 -5
- package/dist/assets/project-notebook-skill/export-manifest.json +5 -5
- package/dist/assets/project-notebook-skill/hooks/session-end.sh +20 -1
- package/dist/assets/project-notebook-skill/hooks/session-start.sh +20 -1
- package/dist/assets/project-notebook-skill/references/configuration.md +2 -0
- package/dist/assets/project-notebook-skill/scripts/project-hooks.py +33 -3
- package/dist/assets/project-notebook-skill/tests/test_project_hooks.py +37 -0
- package/dist/index.js +9385 -18379
- package/dist/mcp-server.js +9432 -17852
- package/dist/notebook/journal-service-main.js +5682 -0
- package/dist/project-registry-service.js +748 -0
- package/dist/prompt.js +2 -1
- package/package.json +15 -10
- package/scripts/install-project-registry.mjs +34 -0
- package/scripts/migrate-project-registry.mjs +117 -0
- package/src/project/registryClient.ts +62 -0
- package/templates/commonproject/copier.yml +16 -19
- package/templates/commonproject/template/.agents/hooks/README.md +10 -4
- package/templates/commonproject/template/.agents/hooks/hermes/hindsight-hook.sh +12 -2
- package/templates/commonproject/template/.agents/hooks/hooks.master.json +10 -10
- package/templates/commonproject/template/.agents/hooks/lib/hook-guard.sh +13 -0
- package/templates/commonproject/template/.agents/hooks/sync.py +66 -17
- package/templates/commonproject/template/.agents/skills.json +0 -1
- package/templates/commonproject/template/.github/workflows/code-review.yml.jinja +7 -1
- package/templates/commonproject/template/.project.json.jinja +1 -1
- package/templates/commonproject/template/mise.toml.jinja +11 -21
- package/contracts/fleet-contract.yaml +0 -584
- package/templates/commonproject/template/.mise/scripts/provision-packs.py +0 -587
- package/templates/commonproject/template/.mise/scripts/sync-skills.py +0 -2183
- package/templates/hermes-agent/copier.yml +0 -186
- package/templates/hermes-agent/template/.gitignore.jinja +0 -11
- package/templates/hermes-agent/template/.runtime-scaffold/.gitattributes +0 -10
- package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +0 -44
- package/templates/hermes-agent/template/.runtime-scaffold/README.md +0 -56
- package/templates/hermes-agent/template/.runtime-scaffold/decisions/.gitkeep +0 -2
- package/templates/hermes-agent/template/.runtime-scaffold/logs/.gitkeep +0 -1
- package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +0 -50
- package/templates/hermes-agent/template/.runtime-scaffold/memories/USER.md +0 -51
- package/templates/hermes-agent/template/.scripts/00-banner.sh +0 -21
- package/templates/hermes-agent/template/.scripts/01-config.sh +0 -60
- package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +0 -42
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +0 -222
- package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +0 -199
- package/templates/hermes-agent/template/.scripts/30-telegram.sh +0 -363
- package/templates/hermes-agent/template/.scripts/31-slack.sh +0 -457
- package/templates/hermes-agent/template/.scripts/40-plane.sh +0 -60
- package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +0 -373
- package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +0 -18
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +0 -354
- package/templates/hermes-agent/template/.scripts/80-registry.sh +0 -185
- package/templates/hermes-agent/template/.scripts/99-summary.sh +0 -87
- package/templates/hermes-agent/template/.scripts/_lib.sh +0 -1138
- package/templates/hermes-agent/template/.scripts/channel-transaction.py +0 -2340
- package/templates/hermes-agent/template/.scripts/checkpoint.sh +0 -82
- package/templates/hermes-agent/template/.scripts/config.example.toml +0 -70
- package/templates/hermes-agent/template/.scripts/credential-launch.sh +0 -85
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +0 -283
- package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +0 -202
- package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +0 -761
- package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +0 -182
- package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +0 -108
- package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +0 -199
- package/templates/hermes-agent/template/.scripts/lib/voice-config.py +0 -546
- package/templates/hermes-agent/template/.scripts/lifecycle.sh +0 -126
- package/templates/hermes-agent/template/.scripts/momo-wip-lock.py +0 -137
- package/templates/hermes-agent/template/.scripts/providers/linear.sh +0 -329
- package/templates/hermes-agent/template/.scripts/providers/plane.sh +0 -671
- package/templates/hermes-agent/template/.scripts/providers/trello.sh +0 -241
- package/templates/hermes-agent/template/.scripts/reporter-watchdog.py +0 -242
- package/templates/hermes-agent/template/.scripts/secret-scan.py +0 -134
- package/templates/hermes-agent/template/.scripts/sentinel/bin/emit-event.py +0 -106
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +0 -339
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +0 -179
- package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +0 -162
- package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +0 -42
- package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +0 -77
- package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +0 -116
- package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +0 -260
- package/templates/hermes-agent/template/SOUL.md.jinja +0 -197
- package/templates/hermes-agent/template/hermes.jinja +0 -109
- package/templates/hermes-agent/template/momo.jinja +0 -177
- package/templates/hermes-agent/template/role.yaml.jinja +0 -112
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""momo-wip-lock — shared WIP=1 driver lease so interactive Momo and the Hermes
|
|
3
|
-
sentinel never double-drive one board (momo E2/S2.3, the coexistence gate).
|
|
4
|
-
|
|
5
|
-
Both drivers acquire the SAME advisory lease (a JSON file, conventionally
|
|
6
|
-
<runtime>/wip-driver.lock) before a board-driving pass. The lease PERSISTS across
|
|
7
|
-
processes — it is deliberately NOT a held flock, because a Momo pass spans many
|
|
8
|
-
separate tool-call/bash invocations over minutes. flock is used only to serialize
|
|
9
|
-
the check-and-set so two acquirers can't race. A lease is respected while its
|
|
10
|
-
heartbeat is fresh (now - heartbeat_at < ttl); a stale lease (holder died without
|
|
11
|
-
releasing) can be stolen after it expires.
|
|
12
|
-
|
|
13
|
-
Owners are free strings by convention: "momo" (interactive) or
|
|
14
|
-
"hermes:<agent_id>" (the sentinel). WIP=1 is per BOARD == per runtime, so there
|
|
15
|
-
is one lease file per runtime.
|
|
16
|
-
|
|
17
|
-
Protocol:
|
|
18
|
-
* Driver start: acquire <lock> <me> -> exit 0 → drive; exit 1 → HELD, back off.
|
|
19
|
-
* While driving: refresh <lock> <me> periodically (< ttl) so the lease stays fresh.
|
|
20
|
-
* Driver end: release <lock> <me>.
|
|
21
|
-
* A holder that crashed leaves a lease that expires after `ttl`; the next driver
|
|
22
|
-
acquires it normally (or `--steal` to take an explicitly stale one immediately).
|
|
23
|
-
|
|
24
|
-
Commands (exit 0 = you hold it; 1 = someone else holds it fresh; 2 = usage/error):
|
|
25
|
-
acquire <lockfile> <owner> [--ttl S] [--steal]
|
|
26
|
-
refresh <lockfile> <owner>
|
|
27
|
-
release <lockfile> <owner>
|
|
28
|
-
status <lockfile> # always exit 0
|
|
29
|
-
"""
|
|
30
|
-
from __future__ import annotations
|
|
31
|
-
import argparse, fcntl, json, os, socket, sys, tempfile, time
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def _read(path: str):
|
|
35
|
-
try:
|
|
36
|
-
with open(path) as f:
|
|
37
|
-
return json.load(f)
|
|
38
|
-
except Exception:
|
|
39
|
-
return None
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def _fresh(lease: dict | None, now: float) -> bool:
|
|
43
|
-
return bool(lease) and (now - lease.get("heartbeat_at", 0)) < lease.get("ttl", 300)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def _write_atomic(path: str, data: dict) -> None:
|
|
47
|
-
d = os.path.dirname(os.path.abspath(path)) or "."
|
|
48
|
-
fd, tmp = tempfile.mkstemp(dir=d)
|
|
49
|
-
with os.fdopen(fd, "w") as f:
|
|
50
|
-
json.dump(data, f)
|
|
51
|
-
os.replace(tmp, path)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def _guard(lockfile: str):
|
|
55
|
-
"""Sibling .flock file held only for the read-modify-write critical section."""
|
|
56
|
-
g = open(lockfile + ".flock", "a")
|
|
57
|
-
fcntl.flock(g, fcntl.LOCK_EX)
|
|
58
|
-
return g
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def acquire(lockfile: str, owner: str, ttl: int, steal: bool) -> int:
|
|
62
|
-
now = time.time(); g = _guard(lockfile)
|
|
63
|
-
try:
|
|
64
|
-
cur = _read(lockfile)
|
|
65
|
-
if cur and cur.get("owner") != owner and _fresh(cur, now) and not steal:
|
|
66
|
-
print(f"HELD by {cur['owner']} (fresh, {int(now - cur['heartbeat_at'])}s ago) — back off")
|
|
67
|
-
return 1
|
|
68
|
-
started = cur["started_at"] if (cur and cur.get("owner") == owner and "started_at" in cur) else now
|
|
69
|
-
_write_atomic(lockfile, {
|
|
70
|
-
"owner": owner, "pid": os.getpid(), "host": socket.gethostname(),
|
|
71
|
-
"started_at": started, "heartbeat_at": now, "ttl": ttl,
|
|
72
|
-
})
|
|
73
|
-
print(f"ACQUIRED by {owner}" + (" (stole stale lease)" if (cur and cur.get('owner') != owner) else ""))
|
|
74
|
-
return 0
|
|
75
|
-
finally:
|
|
76
|
-
fcntl.flock(g, fcntl.LOCK_UN); g.close()
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def refresh(lockfile: str, owner: str) -> int:
|
|
80
|
-
now = time.time(); g = _guard(lockfile)
|
|
81
|
-
try:
|
|
82
|
-
cur = _read(lockfile)
|
|
83
|
-
if not cur or cur.get("owner") != owner:
|
|
84
|
-
print(f"NOT OWNER (held by {cur.get('owner') if cur else 'nobody'}) — cannot refresh")
|
|
85
|
-
return 1
|
|
86
|
-
cur["heartbeat_at"] = now
|
|
87
|
-
_write_atomic(lockfile, cur); print("REFRESHED"); return 0
|
|
88
|
-
finally:
|
|
89
|
-
fcntl.flock(g, fcntl.LOCK_UN); g.close()
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def release(lockfile: str, owner: str) -> int:
|
|
93
|
-
g = _guard(lockfile)
|
|
94
|
-
try:
|
|
95
|
-
cur = _read(lockfile)
|
|
96
|
-
if cur and cur.get("owner") != owner:
|
|
97
|
-
print(f"NOT OWNER (held by {cur['owner']}) — not releasing"); return 1
|
|
98
|
-
try:
|
|
99
|
-
os.remove(lockfile)
|
|
100
|
-
except FileNotFoundError:
|
|
101
|
-
pass
|
|
102
|
-
print("RELEASED"); return 0
|
|
103
|
-
finally:
|
|
104
|
-
fcntl.flock(g, fcntl.LOCK_UN); g.close()
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def status(lockfile: str) -> int:
|
|
108
|
-
now = time.time(); cur = _read(lockfile)
|
|
109
|
-
if not cur:
|
|
110
|
-
print("FREE (no lease)"); return 0
|
|
111
|
-
state = "fresh" if _fresh(cur, now) else "STALE"
|
|
112
|
-
print(f"{cur['owner']} — {state} (heartbeat {int(now - cur.get('heartbeat_at', 0))}s ago, "
|
|
113
|
-
f"ttl {cur.get('ttl')}s, pid {cur.get('pid')}@{cur.get('host')})")
|
|
114
|
-
return 0
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def main() -> int:
|
|
118
|
-
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
119
|
-
ap.add_argument("cmd", choices=["acquire", "refresh", "release", "status"])
|
|
120
|
-
ap.add_argument("lockfile")
|
|
121
|
-
ap.add_argument("owner", nargs="?")
|
|
122
|
-
ap.add_argument("--ttl", type=int, default=300, help="freshness window in seconds (default 300)")
|
|
123
|
-
ap.add_argument("--steal", action="store_true", help="take an explicitly stale lease immediately")
|
|
124
|
-
a = ap.parse_args()
|
|
125
|
-
if a.cmd in ("acquire", "refresh", "release") and not a.owner:
|
|
126
|
-
print("owner required", file=sys.stderr); return 2
|
|
127
|
-
if a.cmd == "acquire":
|
|
128
|
-
return acquire(a.lockfile, a.owner, a.ttl, a.steal)
|
|
129
|
-
if a.cmd == "refresh":
|
|
130
|
-
return refresh(a.lockfile, a.owner)
|
|
131
|
-
if a.cmd == "release":
|
|
132
|
-
return release(a.lockfile, a.owner)
|
|
133
|
-
return status(a.lockfile)
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
if __name__ == "__main__":
|
|
137
|
-
raise SystemExit(main())
|
|
@@ -1,329 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env sh
|
|
2
|
-
# Linear ticket-provider adapter (reference implementation).
|
|
3
|
-
#
|
|
4
|
-
# Credentials: LINEAR_API_KEY
|
|
5
|
-
# Board binding (role.yaml `ticket_provider:`):
|
|
6
|
-
# name: linear
|
|
7
|
-
# team: <TEAM_KEY> e.g. DEL
|
|
8
|
-
# project: "<Project name>" optional; scopes milestone/issue queries
|
|
9
|
-
# state_map: { in_review: "In Review", completed: "Done",
|
|
10
|
-
# cancelled: "Canceled" } optional overrides
|
|
11
|
-
#
|
|
12
|
-
# Implements the contract in lib/ticket-provider.sh. All Linear access goes
|
|
13
|
-
# through GraphQL so the same envelope works in unattended runs.
|
|
14
|
-
# LINEAR_MAX_PAGES bounds issue pagination (default 1000).
|
|
15
|
-
# GraphQL variable references are intentionally single-quoted shell literals.
|
|
16
|
-
# shellcheck disable=SC2016
|
|
17
|
-
set -eu
|
|
18
|
-
|
|
19
|
-
OP="${1:-}"; shift 2>/dev/null || true
|
|
20
|
-
ROLE_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
|
21
|
-
ROLE_YAML="$ROLE_DIR/role.yaml"
|
|
22
|
-
GRAPHQL_URL="${LINEAR_GRAPHQL_URL:-https://api.linear.app/graphql}"
|
|
23
|
-
|
|
24
|
-
die() { echo "linear: $*" >&2; exit 1; }
|
|
25
|
-
need_key() { [ -n "${LINEAR_API_KEY:-}" ] || die "LINEAR_API_KEY is not set"; }
|
|
26
|
-
|
|
27
|
-
validated_uint() {
|
|
28
|
-
setting="$1"; value="$2"; minimum="$3"; maximum="$4"
|
|
29
|
-
case "$value" in ''|*[!0-9]*) die "$setting must be an integer from $minimum through $maximum" ;; esac
|
|
30
|
-
[ "$value" -ge "$minimum" ] && [ "$value" -le "$maximum" ] \
|
|
31
|
-
|| die "$setting must be an integer from $minimum through $maximum"
|
|
32
|
-
printf '%s' "$value"
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
# tp_cfg KEY — read ticket_provider.<KEY> from role.yaml (best-effort, flat).
|
|
36
|
-
tp_cfg() {
|
|
37
|
-
[ -f "$ROLE_YAML" ] || return 0
|
|
38
|
-
python3 - "$ROLE_YAML" "$1" <<'PY'
|
|
39
|
-
import sys, re, pathlib
|
|
40
|
-
text = pathlib.Path(sys.argv[1]).read_text()
|
|
41
|
-
m = re.search(r'(?ms)^ticket_provider:\s*$(.*?)(?=^\S)', text + "\n\x00")
|
|
42
|
-
block = m.group(1) if m else ""
|
|
43
|
-
key = sys.argv[2]
|
|
44
|
-
mm = re.search(rf'(?m)^\s*{re.escape(key)}:\s*"?([^"\n]*)"?\s*$', block)
|
|
45
|
-
print(mm.group(1).strip() if mm else "")
|
|
46
|
-
PY
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
# pj_cfg KEY — read ticket_provider.<KEY> from the repo-root .project.json (the
|
|
50
|
-
# SOT), walking up from the role dir. Preferred over role.yaml.
|
|
51
|
-
pj_cfg() {
|
|
52
|
-
python3 - "$ROLE_DIR" "$1" <<'PY'
|
|
53
|
-
import sys, json, pathlib
|
|
54
|
-
start = pathlib.Path(sys.argv[1]).resolve(); key = sys.argv[2]
|
|
55
|
-
for parent in [start, *start.parents]:
|
|
56
|
-
f = parent / ".project.json"
|
|
57
|
-
if f.is_file():
|
|
58
|
-
try: tp = (json.loads(f.read_text()).get("ticket_provider") or {})
|
|
59
|
-
except Exception: tp = {}
|
|
60
|
-
print(tp.get(key, "") if isinstance(tp, dict) else ""); break
|
|
61
|
-
else:
|
|
62
|
-
print("")
|
|
63
|
-
PY
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
# gql QUERY [VARS_JSON] — POST a GraphQL request, print data JSON, fail on errors.
|
|
67
|
-
gql() {
|
|
68
|
-
need_key
|
|
69
|
-
_vars="${2:-}"; [ -n "$_vars" ] || _vars='{}'
|
|
70
|
-
python3 - "$1" "$_vars" "$GRAPHQL_URL" <<'PY'
|
|
71
|
-
import json, os, sys, urllib.request, urllib.error
|
|
72
|
-
q, variables = sys.argv[1], json.loads(sys.argv[2])
|
|
73
|
-
req = urllib.request.Request(
|
|
74
|
-
sys.argv[3],
|
|
75
|
-
data=json.dumps({"query": q, "variables": variables}).encode(),
|
|
76
|
-
headers={"Authorization": os.environ["LINEAR_API_KEY"],
|
|
77
|
-
"Content-Type": "application/json"},
|
|
78
|
-
method="POST")
|
|
79
|
-
try:
|
|
80
|
-
body = json.loads(urllib.request.urlopen(req, timeout=30).read())
|
|
81
|
-
except urllib.error.HTTPError as e:
|
|
82
|
-
body = json.loads(e.read() or "{}")
|
|
83
|
-
except urllib.error.URLError as e:
|
|
84
|
-
print(f"linear request failed: {e}", file=sys.stderr); sys.exit(1)
|
|
85
|
-
if body.get("errors"):
|
|
86
|
-
print(json.dumps(body["errors"]), file=sys.stderr); sys.exit(1)
|
|
87
|
-
print(json.dumps(body.get("data") or {}))
|
|
88
|
-
PY
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
TEAM="$(pj_cfg team)"; [ -n "$TEAM" ] || TEAM="$(tp_cfg team)"
|
|
92
|
-
PROJECT="$(pj_cfg project)"; [ -n "$PROJECT" ] || PROJECT="$(tp_cfg project)"
|
|
93
|
-
SM_IN_REVIEW="$(tp_cfg in_review)"; SM_IN_REVIEW="${SM_IN_REVIEW:-In Review}"
|
|
94
|
-
SM_DONE="$(tp_cfg completed)"; SM_DONE="${SM_DONE:-Done}"
|
|
95
|
-
SM_CANCELLED="$(tp_cfg cancelled)"; SM_CANCELLED="${SM_CANCELLED:-Canceled}"
|
|
96
|
-
SM_STARTED="$(tp_cfg started)"
|
|
97
|
-
SM_UNSTARTED="$(tp_cfg unstarted)"
|
|
98
|
-
SM_BACKLOG="$(tp_cfg backlog)"
|
|
99
|
-
MAX_PAGES="$(validated_uint LINEAR_MAX_PAGES "${LINEAR_MAX_PAGES:-1000}" 1 1000)"
|
|
100
|
-
|
|
101
|
-
# All Linear ops require the API key; fail fast and clean before any pipe.
|
|
102
|
-
need_key
|
|
103
|
-
|
|
104
|
-
case "$OP" in
|
|
105
|
-
resolve)
|
|
106
|
-
[ -n "$TEAM" ] || die "ticket_provider.team (Linear team key) not set in role.yaml"
|
|
107
|
-
gql 'query($k:String!){ teams(filter:{key:{eq:$k}}){nodes{id key name}} }' \
|
|
108
|
-
"$(printf '{"k":"%s"}' "$TEAM")" \
|
|
109
|
-
| python3 -c 'import sys,json; d=json.load(sys.stdin); t=(d.get("teams",{}).get("nodes") or [{}])[0]; print(json.dumps({"provider":"linear","board_id":t.get("id",""),"board_url":"https://linear.app/team/"+t.get("key",""),"identifier":t.get("key","")}))'
|
|
110
|
-
;;
|
|
111
|
-
|
|
112
|
-
active_milestone)
|
|
113
|
-
# Linear project milestones; pick the first non-completed milestone in the project.
|
|
114
|
-
gql 'query($p:String){ projects(filter:{name:{eq:$p}}){nodes{ id name projectMilestones{nodes{id name targetDate}} state }} }' \
|
|
115
|
-
"$(printf '{"p":"%s"}' "$PROJECT")" \
|
|
116
|
-
| python3 -c 'import sys,json
|
|
117
|
-
d=json.load(sys.stdin); ps=d.get("projects",{}).get("nodes") or []
|
|
118
|
-
p=ps[0] if ps else {}
|
|
119
|
-
ms=(p.get("projectMilestones",{}) or {}).get("nodes") or []
|
|
120
|
-
m=ms[0] if ms else {"id":p.get("id",""),"name":p.get("name","")}
|
|
121
|
-
print(json.dumps({"id":m.get("id",""),"name":m.get("name",""),"state":p.get("state","")}))'
|
|
122
|
-
;;
|
|
123
|
-
|
|
124
|
-
list_issues)
|
|
125
|
-
[ -n "$TEAM" ] || die "ticket_provider.team not set"
|
|
126
|
-
ISSUE_PAGE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/linear-issues.XXXXXX")" || die "could not create pagination scratch directory"
|
|
127
|
-
ISSUE_PAGE_ROWS="$ISSUE_PAGE_DIR/rows"
|
|
128
|
-
ISSUE_PAGE_CURSORS="$ISSUE_PAGE_DIR/cursors"
|
|
129
|
-
: > "$ISSUE_PAGE_ROWS"
|
|
130
|
-
: > "$ISSUE_PAGE_CURSORS"
|
|
131
|
-
cleanup_issue_pages() {
|
|
132
|
-
rm -f "$ISSUE_PAGE_ROWS" "$ISSUE_PAGE_CURSORS"
|
|
133
|
-
rmdir "$ISSUE_PAGE_DIR" 2>/dev/null || true
|
|
134
|
-
}
|
|
135
|
-
trap cleanup_issue_pages 0
|
|
136
|
-
trap 'cleanup_issue_pages; exit 129' HUP
|
|
137
|
-
trap 'cleanup_issue_pages; exit 130' INT
|
|
138
|
-
trap 'cleanup_issue_pages; exit 143' TERM
|
|
139
|
-
AFTER=""
|
|
140
|
-
PAGE_COUNT=0
|
|
141
|
-
while :; do
|
|
142
|
-
PAGE_COUNT=$((PAGE_COUNT + 1))
|
|
143
|
-
[ "$PAGE_COUNT" -le "$MAX_PAGES" ] \
|
|
144
|
-
|| die "pagination exceeded LINEAR_MAX_PAGES=$MAX_PAGES"
|
|
145
|
-
VARS="$(python3 -c 'import json,sys; print(json.dumps({"k":sys.argv[1],"after":sys.argv[2] or None}))' "$TEAM" "$AFTER")"
|
|
146
|
-
PAGE="$(gql 'query($k:String!,$after:String){ issues(first:100, after:$after, filter:{team:{key:{eq:$k}}}){nodes{ id identifier title updatedAt url state{name type} assignee{name} } pageInfo{hasNextPage endCursor}} }' "$VARS")"
|
|
147
|
-
printf '%s' "$PAGE" | python3 -c 'import sys,json
|
|
148
|
-
d=json.load(sys.stdin)
|
|
149
|
-
for n in (d.get("issues") or {}).get("nodes") or []:
|
|
150
|
-
st=n.get("state") or {}
|
|
151
|
-
print(json.dumps({"id":n["id"],"key":n.get("identifier",""),"title":n.get("title",""),
|
|
152
|
-
"state":st.get("name",""),"state_type":st.get("type",""),
|
|
153
|
-
"updated_at":n.get("updatedAt",""),
|
|
154
|
-
"assignee":(n.get("assignee") or {}).get("name",""),"url":n.get("url","")}))' \
|
|
155
|
-
>> "$ISSUE_PAGE_ROWS"
|
|
156
|
-
HAS_NEXT="$(printf '%s' "$PAGE" | python3 -c 'import sys,json; print("true" if ((json.load(sys.stdin).get("issues") or {}).get("pageInfo") or {}).get("hasNextPage") else "false")')"
|
|
157
|
-
[ "$HAS_NEXT" = "true" ] || break
|
|
158
|
-
NEXT="$(printf '%s' "$PAGE" | python3 -c 'import sys,json; print(str(((json.load(sys.stdin).get("issues") or {}).get("pageInfo") or {}).get("endCursor") or ""))')"
|
|
159
|
-
[ -n "$NEXT" ] || die "Linear pagination reported another page without an end cursor"
|
|
160
|
-
CURSOR_FINGERPRINT="$(python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.argv[1].encode()).hexdigest())' "$NEXT")"
|
|
161
|
-
if grep -Fqx "$CURSOR_FINGERPRINT" "$ISSUE_PAGE_CURSORS"; then
|
|
162
|
-
die "Linear pagination cursor repeated"
|
|
163
|
-
fi
|
|
164
|
-
printf '%s\n' "$CURSOR_FINGERPRINT" >> "$ISSUE_PAGE_CURSORS"
|
|
165
|
-
AFTER="$NEXT"
|
|
166
|
-
done
|
|
167
|
-
python3 - "$ISSUE_PAGE_ROWS" <<'PY'
|
|
168
|
-
import json
|
|
169
|
-
import pathlib
|
|
170
|
-
import sys
|
|
171
|
-
|
|
172
|
-
rows = [json.loads(line) for line in pathlib.Path(sys.argv[1]).read_text().splitlines() if line]
|
|
173
|
-
print(json.dumps(rows))
|
|
174
|
-
PY
|
|
175
|
-
cleanup_issue_pages
|
|
176
|
-
trap - 0 HUP INT TERM
|
|
177
|
-
;;
|
|
178
|
-
|
|
179
|
-
get_issue)
|
|
180
|
-
ID="${1:?usage: get_issue <id>}"
|
|
181
|
-
gql 'query($id:String!){ issue(id:$id){ id identifier title description state{name type} comments{nodes{id body user{name}}} } }' \
|
|
182
|
-
"$(printf '{"id":"%s"}' "$ID")" \
|
|
183
|
-
| python3 -c 'import sys,json
|
|
184
|
-
d=json.load(sys.stdin); i=d.get("issue") or {}
|
|
185
|
-
st=i.get("state") or {}
|
|
186
|
-
cs=[{"id":c["id"],"body":c.get("body",""),"author":(c.get("user") or {}).get("name","")} for c in (i.get("comments",{}) or {}).get("nodes") or []]
|
|
187
|
-
print(json.dumps({"id":i.get("id",""),"key":i.get("identifier",""),"title":i.get("title",""),
|
|
188
|
-
"description":i.get("description",""),"acceptance":i.get("description",""),
|
|
189
|
-
"state":st.get("name",""),"state_type":st.get("type",""),"comments":cs}))'
|
|
190
|
-
;;
|
|
191
|
-
|
|
192
|
-
comment)
|
|
193
|
-
ID="${1:?usage: comment <id> <body>}"; BODY="${2:?usage: comment <id> <body>}"
|
|
194
|
-
gql 'mutation($id:String!,$b:String!){ commentCreate(input:{issueId:$id,body:$b}){ comment{id issue{id}} success } }' \
|
|
195
|
-
"$(python3 -c 'import json,sys; print(json.dumps({"id":sys.argv[1],"b":sys.argv[2]}))' "$ID" "$BODY")" \
|
|
196
|
-
| EXPECTED_ID="$ID" python3 -c 'import sys,json,os
|
|
197
|
-
document=json.load(sys.stdin)
|
|
198
|
-
created=document.get("commentCreate") if isinstance(document,dict) else None
|
|
199
|
-
if not isinstance(created,dict) or created.get("success") is not True:
|
|
200
|
-
raise SystemExit("linear: commentCreate did not report success")
|
|
201
|
-
comment=created.get("comment")
|
|
202
|
-
if not isinstance(comment,dict):
|
|
203
|
-
raise SystemExit("linear: commentCreate response omitted its comment object")
|
|
204
|
-
comment_id=comment.get("id")
|
|
205
|
-
if not isinstance(comment_id,str) or not comment_id.strip():
|
|
206
|
-
raise SystemExit("linear: commentCreate response omitted its comment id")
|
|
207
|
-
issue=comment.get("issue")
|
|
208
|
-
if not isinstance(issue,dict) or str(issue.get("id") or "").strip()!=os.environ["EXPECTED_ID"]:
|
|
209
|
-
raise SystemExit("linear: commentCreate response did not identify the requested issue")
|
|
210
|
-
print(comment_id.strip())'
|
|
211
|
-
;;
|
|
212
|
-
|
|
213
|
-
transition)
|
|
214
|
-
ID="${1:?usage: transition <id> <normalized-state>}"; TARGET="${2:?}"
|
|
215
|
-
# Map normalized -> a concrete Linear state name, then resolve its id on the team.
|
|
216
|
-
case "$TARGET" in
|
|
217
|
-
completed) WANT_TYPE=completed; WANT_NAME="$SM_DONE" ;;
|
|
218
|
-
cancelled) WANT_TYPE=canceled; WANT_NAME="$SM_CANCELLED" ;;
|
|
219
|
-
in_review) WANT_TYPE=started; WANT_NAME="$SM_IN_REVIEW" ;;
|
|
220
|
-
started) WANT_TYPE=started; WANT_NAME="$SM_STARTED" ;;
|
|
221
|
-
unstarted) WANT_TYPE=unstarted; WANT_NAME="$SM_UNSTARTED" ;;
|
|
222
|
-
backlog) WANT_TYPE=backlog; WANT_NAME="$SM_BACKLOG" ;;
|
|
223
|
-
*) die "invalid normalized state: $TARGET" ;;
|
|
224
|
-
esac
|
|
225
|
-
STATE_ID="$(gql 'query($id:String!){ issue(id:$id){ team{ states{nodes{id name type}} } } }' \
|
|
226
|
-
"$(printf '{"id":"%s"}' "$ID")" \
|
|
227
|
-
| WANT_TYPE="$WANT_TYPE" WANT_NAME="$WANT_NAME" python3 -c 'import sys,json,os
|
|
228
|
-
d=json.load(sys.stdin)
|
|
229
|
-
states=((d.get("issue") or {}).get("team") or {}).get("states",{}).get("nodes") or []
|
|
230
|
-
want_t=os.environ["WANT_TYPE"]; want_n=os.environ.get("WANT_NAME","")
|
|
231
|
-
if want_n:
|
|
232
|
-
candidates=[s for s in states if str(s.get("name") or "").strip().casefold()==want_n.strip().casefold()]
|
|
233
|
-
basis=f"configured name {want_n!r}"
|
|
234
|
-
else:
|
|
235
|
-
candidates=[s for s in states if s.get("type")==want_t]
|
|
236
|
-
basis=f"workflow type {want_t!r}"
|
|
237
|
-
if len(candidates) != 1:
|
|
238
|
-
raise SystemExit(f"linear: {basis} resolved {len(candidates)} states; exactly one is required")
|
|
239
|
-
pick=candidates[0]
|
|
240
|
-
if pick.get("type") != want_t:
|
|
241
|
-
raise SystemExit(f"linear: configured state {want_n!r} has type {pick.get('type')!r}, expected {want_t!r}")
|
|
242
|
-
state_id=str(pick.get("id") or "")
|
|
243
|
-
if not state_id:
|
|
244
|
-
raise SystemExit("linear: resolved state omitted its id")
|
|
245
|
-
print(state_id)')"
|
|
246
|
-
[ -n "$STATE_ID" ] || die "no Linear state for normalized '$TARGET'"
|
|
247
|
-
gql 'mutation($id:String!,$s:String!){ issueUpdate(id:$id,input:{stateId:$s}){ success issue{id identifier state{id name type}} } }' \
|
|
248
|
-
"$(python3 -c 'import json,sys; print(json.dumps({"id":sys.argv[1],"s":sys.argv[2]}))' "$ID" "$STATE_ID")" \
|
|
249
|
-
| EXPECTED_ID="$ID" EXPECTED_STATE_ID="$STATE_ID" python3 -c 'import sys,json,os
|
|
250
|
-
u=json.load(sys.stdin).get("issueUpdate") or {}
|
|
251
|
-
if u.get("success") is not True:
|
|
252
|
-
raise SystemExit("linear: issueUpdate did not report success")
|
|
253
|
-
issue=u.get("issue") or {}
|
|
254
|
-
if str(issue.get("id") or "") != os.environ["EXPECTED_ID"]:
|
|
255
|
-
raise SystemExit("linear: issueUpdate read-back did not identify the requested issue")
|
|
256
|
-
state=issue.get("state") or {}
|
|
257
|
-
if str(state.get("id") or "") != os.environ["EXPECTED_STATE_ID"]:
|
|
258
|
-
raise SystemExit("linear: issueUpdate read-back did not confirm the exact target state")
|
|
259
|
-
print("ok " + str(issue.get("identifier") or issue.get("id") or ""))'
|
|
260
|
-
;;
|
|
261
|
-
|
|
262
|
-
describe_board)
|
|
263
|
-
# Read-only team lookup by id, against an EXPLICIT workspace argument so no
|
|
264
|
-
# ambient binding can answer for the wrong org. Linear's team KEY is a real
|
|
265
|
-
# authoritative identifier — it prefixes every issue — so it is read back
|
|
266
|
-
# from Linear itself and never proposed locally.
|
|
267
|
-
DWS="${1:?usage: describe_board <workspace> <board_id>}"
|
|
268
|
-
DBID="${2:?usage: describe_board <workspace> <board_id>}"
|
|
269
|
-
gql 'query($id:String!){ team(id:$id){ id key name } organization{ urlKey } }' \
|
|
270
|
-
"$(printf '{"id":"%s"}' "$DBID")" \
|
|
271
|
-
| WS="$DWS" BID="$DBID" python3 -c 'import sys, json, os
|
|
272
|
-
d = json.load(sys.stdin)
|
|
273
|
-
t = d.get("team") or {}
|
|
274
|
-
ident = str(t.get("key") or "")
|
|
275
|
-
if not ident:
|
|
276
|
-
raise SystemExit("linear: team %s reported no authoritative key" % os.environ["BID"])
|
|
277
|
-
print(json.dumps({
|
|
278
|
-
"board_id": str(t.get("id") or os.environ["BID"]),
|
|
279
|
-
"identifier": ident,
|
|
280
|
-
"workspace": str((d.get("organization") or {}).get("urlKey") or os.environ["WS"]),
|
|
281
|
-
"name": str(t.get("name") or ""),
|
|
282
|
-
}))'
|
|
283
|
-
;;
|
|
284
|
-
|
|
285
|
-
create_board)
|
|
286
|
-
# Linear teams/projects are created by humans; the adapter resolves, not creates.
|
|
287
|
-
echo "linear: create_board is a no-op (Linear team/project created via Linear UI); using resolve" >&2
|
|
288
|
-
exec sh "$0" resolve
|
|
289
|
-
;;
|
|
290
|
-
|
|
291
|
-
create_issue)
|
|
292
|
-
# File a new issue on the bound team. Team comes from resolved config, never
|
|
293
|
-
# from an argument. Deliberately NOT idempotent by default: two issues may
|
|
294
|
-
# legitimately share a title. Pass --if-absent to reuse an issue whose title
|
|
295
|
-
# already matches instead.
|
|
296
|
-
IF_ABSENT=0
|
|
297
|
-
case "${1:-}" in --if-absent) IF_ABSENT=1; shift ;; esac
|
|
298
|
-
TITLE="${1:?usage: create_issue [--if-absent] <title> [description]}"; DESC="${2:-}"
|
|
299
|
-
[ -n "$TEAM" ] || die "ticket_provider.team (Linear team key) not set"
|
|
300
|
-
ISSUE=""; CREATED=true
|
|
301
|
-
if [ "$IF_ABSENT" = 1 ]; then
|
|
302
|
-
ISSUE="$(gql 'query($k:String!,$t:String!){ issues(first:1, filter:{team:{key:{eq:$k}}, title:{eq:$t}}){nodes{id identifier url}} }' \
|
|
303
|
-
"$(python3 -c 'import json,sys; print(json.dumps({"k":sys.argv[1],"t":sys.argv[2]}))' "$TEAM" "$TITLE")" \
|
|
304
|
-
| python3 -c 'import sys,json
|
|
305
|
-
ns=((json.load(sys.stdin).get("issues") or {}).get("nodes")) or []
|
|
306
|
-
print(json.dumps(ns[0]) if ns else "")')"
|
|
307
|
-
[ -z "$ISSUE" ] || CREATED=false
|
|
308
|
-
fi
|
|
309
|
-
if [ -z "$ISSUE" ]; then
|
|
310
|
-
TEAM_ID="$(gql 'query($k:String!){ teams(filter:{key:{eq:$k}}){nodes{id}} }' "$(printf '{"k":"%s"}' "$TEAM")" \
|
|
311
|
-
| python3 -c 'import sys,json
|
|
312
|
-
ns=((json.load(sys.stdin).get("teams") or {}).get("nodes")) or []
|
|
313
|
-
print(ns[0].get("id","") if ns else "")')"
|
|
314
|
-
[ -n "$TEAM_ID" ] || die "no Linear team with key '$TEAM'"
|
|
315
|
-
ISSUE="$(gql 'mutation($team:String!,$t:String!,$d:String){ issueCreate(input:{teamId:$team,title:$t,description:$d}){ success issue{id identifier url} } }' \
|
|
316
|
-
"$(python3 -c 'import json,sys; print(json.dumps({"team":sys.argv[1],"t":sys.argv[2],"d":sys.argv[3] or None}))' "$TEAM_ID" "$TITLE" "$DESC")" \
|
|
317
|
-
| python3 -c 'import sys,json
|
|
318
|
-
r=json.load(sys.stdin).get("issueCreate") or {}
|
|
319
|
-
print(json.dumps(r["issue"]) if r.get("success") and r.get("issue") else "")')"
|
|
320
|
-
fi
|
|
321
|
-
[ -n "$ISSUE" ] || die "create_issue failed"
|
|
322
|
-
printf '%s' "$ISSUE" | CREATED="$CREATED" python3 -c 'import sys,json,os
|
|
323
|
-
i=json.load(sys.stdin)
|
|
324
|
-
print(json.dumps({"issue_id":i.get("id",""),"key":i.get("identifier",""),
|
|
325
|
-
"issue_url":i.get("url",""),"created":os.environ["CREATED"]=="true"}))'
|
|
326
|
-
;;
|
|
327
|
-
|
|
328
|
-
*) die "unknown op: $OP" ;;
|
|
329
|
-
esac
|