@svayam-opensource/prj 0.5.1
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/LICENSE +21 -0
- package/README.md +123 -0
- package/agent/harness-manifest.yaml +225 -0
- package/agent/session-protocol.md +116 -0
- package/bin/prj +21 -0
- package/package.json +41 -0
- package/prj +2381 -0
- package/scripts/add-repo.sh +126 -0
- package/scripts/cancel.sh +157 -0
- package/scripts/close-knowledge.sh +250 -0
- package/scripts/close-project.sh +233 -0
- package/scripts/create-task.sh +226 -0
- package/scripts/install-deps.sh +292 -0
- package/scripts/join.sh +89 -0
- package/scripts/lib.sh +841 -0
- package/scripts/merge-task.sh +163 -0
- package/scripts/onboard-repo.sh +275 -0
- package/scripts/pause.sh +80 -0
- package/scripts/project-access.sh +34 -0
- package/scripts/propose-knowledge.sh +168 -0
- package/scripts/release-to-public.sh +185 -0
- package/scripts/render-harness.sh +151 -0
- package/scripts/resume.sh +103 -0
- package/scripts/seed.sh +774 -0
- package/scripts/sync-from-publish.sh +193 -0
- package/scripts/sync.sh +90 -0
- package/scripts/test-merge.sh +100 -0
- package/scripts/validate/check_knowledge.py +158 -0
- package/scripts/validate/check_privacy.py +211 -0
- package/scripts/validate/check_protocol.py +117 -0
- package/scripts/validate/check_secrets.py +175 -0
- package/scripts/validate/run.py +391 -0
- package/setup.sh +529 -0
package/scripts/lib.sh
ADDED
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Shared library for all Agentic Development Framework scripts.
|
|
3
|
+
# Source this at the top of each script:
|
|
4
|
+
# source "$(dirname "$0")/lib.sh"
|
|
5
|
+
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
|
|
8
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
9
|
+
# ADR-0001 Phase 4: honor $ADF_WORKSPACE (CLI installed separately from data);
|
|
10
|
+
# otherwise default to the vendored layout (scripts/ inside the workspace repo)
|
|
11
|
+
# — unchanged behavior.
|
|
12
|
+
if [[ -n "${ADF_WORKSPACE:-}" && -f "$ADF_WORKSPACE/org-config.yaml" ]]; then
|
|
13
|
+
REPO_ROOT="$ADF_WORKSPACE"
|
|
14
|
+
else
|
|
15
|
+
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
16
|
+
fi
|
|
17
|
+
CONFIG="$REPO_ROOT/org-config.yaml"
|
|
18
|
+
REGISTRY="$REPO_ROOT/registry.yaml"
|
|
19
|
+
|
|
20
|
+
# ── Dependency check ─────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
check_deps() {
|
|
23
|
+
local missing=()
|
|
24
|
+
# perl is a hidden seed.sh dependency (#65/H6 audit) — required, no fallback.
|
|
25
|
+
for dep in git gh yq python3 perl; do
|
|
26
|
+
command -v "$dep" &>/dev/null || missing+=("$dep")
|
|
27
|
+
done
|
|
28
|
+
# yq optional if python3 present; python3 optional if yq present
|
|
29
|
+
local missing_str=" ${missing[*]:-} "
|
|
30
|
+
if [[ "$missing_str" == *" yq "* && "$missing_str" != *" python3 "* ]]; then
|
|
31
|
+
missing=("${missing[@]/yq}") # python3 covers for yq
|
|
32
|
+
fi
|
|
33
|
+
missing_str=" ${missing[*]:-} "
|
|
34
|
+
if [[ "$missing_str" == *" python3 "* && "$missing_str" != *" yq "* ]]; then
|
|
35
|
+
missing=("${missing[@]/python3}") # yq covers for python3
|
|
36
|
+
fi
|
|
37
|
+
# Remove empty entries
|
|
38
|
+
local truly_missing=()
|
|
39
|
+
for m in "${missing[@]:-}"; do [[ -n "$m" ]] && truly_missing+=("$m"); done
|
|
40
|
+
|
|
41
|
+
if [[ ${#truly_missing[@]} -gt 0 ]]; then
|
|
42
|
+
echo "" >&2
|
|
43
|
+
echo "Missing dependencies: ${truly_missing[*]}" >&2
|
|
44
|
+
echo "Run: bash scripts/install-deps.sh" >&2
|
|
45
|
+
exit 1
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
# #65/H7: presence is not enough — an unauthenticated gh fails cryptically
|
|
49
|
+
# deep inside lifecycle ops (e.g. a misleading "Project not found"). Fail fast.
|
|
50
|
+
gh auth status >/dev/null 2>&1 \
|
|
51
|
+
|| hard_stop "gh is not authenticated — run: gh auth login"
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# ── Config ────────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
load_config() {
|
|
57
|
+
check_deps
|
|
58
|
+
if command -v yq &>/dev/null; then
|
|
59
|
+
ORG_NAME=$(yq '.org_name' "$CONFIG")
|
|
60
|
+
ORG_SHORT_NAME=$(yq '.org_short_name' "$CONFIG")
|
|
61
|
+
ORG_SLUG=$(yq '.org_slug' "$CONFIG")
|
|
62
|
+
ORG_SLUG_LOWER=$(yq '.org_slug_lower' "$CONFIG")
|
|
63
|
+
ORG_REPO_URL=$(yq '.org_repo_url' "$CONFIG" 2>/dev/null || echo "")
|
|
64
|
+
GITHUB_ORG=$(yq '.github_org' "$CONFIG")
|
|
65
|
+
WORKSPACE_REPO=$(yq '.workspace_repo' "$CONFIG")
|
|
66
|
+
DEFAULT_BRANCH=$(yq '.default_branch' "$CONFIG")
|
|
67
|
+
DEFAULT_CODE_BRANCH=$(yq '.default_code_branch' "$CONFIG")
|
|
68
|
+
AGENT_WORK_ROOT_CFG=$(yq '.agent_work_root' "$CONFIG" 2>/dev/null || echo "")
|
|
69
|
+
POLICY_OWNER_EMAIL=$(yq '.policy_owner_email' "$CONFIG")
|
|
70
|
+
else
|
|
71
|
+
_py() { python3 -c "import yaml; v = yaml.safe_load(open('$CONFIG')).get('$1', ''); print(v if v is not None else '')"; }
|
|
72
|
+
ORG_NAME=$(_py org_name)
|
|
73
|
+
ORG_SHORT_NAME=$(_py org_short_name)
|
|
74
|
+
ORG_SLUG=$(_py org_slug)
|
|
75
|
+
ORG_SLUG_LOWER=$(_py org_slug_lower)
|
|
76
|
+
ORG_REPO_URL=$(_py org_repo_url)
|
|
77
|
+
GITHUB_ORG=$(_py github_org)
|
|
78
|
+
WORKSPACE_REPO=$(_py workspace_repo)
|
|
79
|
+
DEFAULT_BRANCH=$(_py default_branch)
|
|
80
|
+
DEFAULT_CODE_BRANCH=$(_py default_code_branch)
|
|
81
|
+
AGENT_WORK_ROOT_CFG=$(_py agent_work_root)
|
|
82
|
+
POLICY_OWNER_EMAIL=$(_py policy_owner_email)
|
|
83
|
+
fi
|
|
84
|
+
# yq emits the literal "null" for missing keys; treat that as empty.
|
|
85
|
+
[[ "$AGENT_WORK_ROOT_CFG" == "null" ]] && AGENT_WORK_ROOT_CFG=""
|
|
86
|
+
[[ "$ORG_REPO_URL" == "null" ]] && ORG_REPO_URL=""
|
|
87
|
+
[[ "$ORG_SHORT_NAME" == "null" ]] && ORG_SHORT_NAME=""
|
|
88
|
+
export ORG_NAME ORG_SHORT_NAME ORG_SLUG ORG_SLUG_LOWER ORG_REPO_URL \
|
|
89
|
+
GITHUB_ORG WORKSPACE_REPO \
|
|
90
|
+
DEFAULT_BRANCH DEFAULT_CODE_BRANCH POLICY_OWNER_EMAIL
|
|
91
|
+
|
|
92
|
+
# Resolve AGENT_WORK_ROOT in priority order:
|
|
93
|
+
# 1. Env var (escape hatch for tests / overrides)
|
|
94
|
+
# 2. org-config.yaml agent_work_root
|
|
95
|
+
# 3. Fallback: ~/.<org_slug_lower>/projects (the documented default)
|
|
96
|
+
if [[ -z "${AGENT_WORK_ROOT:-}" ]]; then
|
|
97
|
+
if [[ -n "$AGENT_WORK_ROOT_CFG" ]]; then
|
|
98
|
+
AGENT_WORK_ROOT="$AGENT_WORK_ROOT_CFG"
|
|
99
|
+
else
|
|
100
|
+
AGENT_WORK_ROOT="$HOME/.${ORG_SLUG_LOWER:-org}/projects"
|
|
101
|
+
fi
|
|
102
|
+
fi
|
|
103
|
+
# Expand a leading ~ against the current user's $HOME. The config value is
|
|
104
|
+
# committed and shared, so it must stay portable (~/.svm/projects); without
|
|
105
|
+
# this, a literal "~" would be treated as a directory name and a path like
|
|
106
|
+
# "/Users/<other-user>" would fail on Windows/other machines.
|
|
107
|
+
case "$AGENT_WORK_ROOT" in
|
|
108
|
+
"~") AGENT_WORK_ROOT="$HOME" ;;
|
|
109
|
+
"~/"*) AGENT_WORK_ROOT="$HOME/${AGENT_WORK_ROOT#\~/}" ;;
|
|
110
|
+
esac
|
|
111
|
+
export AGENT_WORK_ROOT
|
|
112
|
+
|
|
113
|
+
# Lazy-create the current user's prefs file if setup.sh didn't already.
|
|
114
|
+
# No-op if gh login is unavailable; the file gets created on a later run
|
|
115
|
+
# once gh is configured. Failures here are non-fatal — preferences are C03.
|
|
116
|
+
ensure_user_prefs_file 2>/dev/null || true
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
# Resolve the current developer's preferences file path.
|
|
120
|
+
# Returns the path on stdout, or empty string if no gh login is available.
|
|
121
|
+
# Callers that need the file should also call ensure_user_prefs_file to
|
|
122
|
+
# lazily create it from the template when missing.
|
|
123
|
+
current_user_prefs_path() {
|
|
124
|
+
local login
|
|
125
|
+
login=$(gh api user --jq .login 2>/dev/null || echo "")
|
|
126
|
+
[[ -z "$login" ]] && return 0
|
|
127
|
+
echo "$AGENT_WORK_ROOT/preferences/$login.md"
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
# Lazily create the current user's prefs file from the template if absent.
|
|
131
|
+
# No-op if:
|
|
132
|
+
# - gh login is unavailable, OR
|
|
133
|
+
# - the prefs file already exists, OR
|
|
134
|
+
# - the template still contains {{PLACEHOLDER}} markers (workspace is
|
|
135
|
+
# in template state, not yet configured by setup.sh — copying now
|
|
136
|
+
# would persist unresolved placeholders into the user's prefs).
|
|
137
|
+
ensure_user_prefs_file() {
|
|
138
|
+
local path template
|
|
139
|
+
path=$(current_user_prefs_path)
|
|
140
|
+
[[ -z "$path" ]] && return 0
|
|
141
|
+
[[ -f "$path" ]] && return 0
|
|
142
|
+
template="$REPO_ROOT/framework/knowledge/guidance/preferences-template.md"
|
|
143
|
+
[[ -f "$template" ]] || return 0
|
|
144
|
+
# Refuse to seed from an un-substituted template. setup.sh is the
|
|
145
|
+
# right tool to substitute placeholders; only then can we copy.
|
|
146
|
+
if grep -q '{{[A-Z_a-z0-9][A-Z_a-z0-9]*}}' "$template" 2>/dev/null; then
|
|
147
|
+
return 0
|
|
148
|
+
fi
|
|
149
|
+
mkdir -p "$(dirname "$path")"
|
|
150
|
+
cp "$template" "$path"
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
# ── Terminal helpers ──────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
hard_stop() {
|
|
156
|
+
echo "" >&2
|
|
157
|
+
echo "HARD STOP [C01]: $*" >&2
|
|
158
|
+
exit 1
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
warn() { echo "WARNING [C02]: $*"; }
|
|
162
|
+
|
|
163
|
+
info() { echo " $*"; }
|
|
164
|
+
|
|
165
|
+
confirm() {
|
|
166
|
+
local _ans
|
|
167
|
+
printf "%s [y/N] " "$*"
|
|
168
|
+
if ! IFS= read -r _ans; then
|
|
169
|
+
echo ""
|
|
170
|
+
echo "Aborted (no input)."
|
|
171
|
+
exit 1
|
|
172
|
+
fi
|
|
173
|
+
if [[ "$_ans" != [yY] && "$_ans" != [yY][eE][sS] ]]; then
|
|
174
|
+
echo "Aborted."
|
|
175
|
+
exit 1
|
|
176
|
+
fi
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
# ── String helpers ────────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
slugify() {
|
|
182
|
+
echo "$1" | tr '[:upper:]' '[:lower:]' \
|
|
183
|
+
| sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//'
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
today() { date +%Y-%m-%d; }
|
|
187
|
+
|
|
188
|
+
# ── Concurrency / atomic writes ───────────────────────────────────────────────
|
|
189
|
+
# Shared state (project.yaml, registry.yaml) is mutated by parallel agents
|
|
190
|
+
# (audit C7/C8). Two guarantees protect it:
|
|
191
|
+
# * _with_lock — serialize a mutation behind an advisory file lock.
|
|
192
|
+
# * never truncate-write in place — write a temp file beside the target and
|
|
193
|
+
# atomically rename() over it, so a crash mid-write leaves the old file
|
|
194
|
+
# intact (POL-002 "recoverable").
|
|
195
|
+
|
|
196
|
+
# Run <cmd...> while holding an exclusive advisory lock on <lockfile>.
|
|
197
|
+
# Uses flock(1) when available. macOS/BSD ships no flock by default; when it is
|
|
198
|
+
# absent we proceed WITHOUT the lock (still safe-ish thanks to the atomic
|
|
199
|
+
# temp+rename writes below) rather than failing the operation.
|
|
200
|
+
_with_lock() {
|
|
201
|
+
local lockfile="$1"; shift
|
|
202
|
+
if command -v flock &>/dev/null; then
|
|
203
|
+
exec 9>"$lockfile"
|
|
204
|
+
flock 9
|
|
205
|
+
"$@"
|
|
206
|
+
local rc=$?
|
|
207
|
+
flock -u 9
|
|
208
|
+
exec 9>&-
|
|
209
|
+
return $rc
|
|
210
|
+
fi
|
|
211
|
+
# flock absent (e.g. stock macOS): degrade gracefully — no lock, atomic write
|
|
212
|
+
# via temp+rename still prevents torn/partial files.
|
|
213
|
+
"$@"
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
# Atomically replace <file> with the contents written to stdin: write to a
|
|
217
|
+
# temp file in the same directory (so rename() is atomic on the same FS) then
|
|
218
|
+
# mv over the target. Preserves the original on any failure mid-write.
|
|
219
|
+
atomic_write() {
|
|
220
|
+
local file="$1"
|
|
221
|
+
local tmp; tmp="$(mktemp "${file}.XXXXXX")" || return 1
|
|
222
|
+
if cat >"$tmp"; then
|
|
223
|
+
mv -f "$tmp" "$file"
|
|
224
|
+
else
|
|
225
|
+
rm -f "$tmp"
|
|
226
|
+
return 1
|
|
227
|
+
fi
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
# ── YAML read/write ───────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
yaml_get() {
|
|
233
|
+
local file="$1" key="$2"
|
|
234
|
+
if command -v yq &>/dev/null; then
|
|
235
|
+
local v
|
|
236
|
+
v=$(yq ".$key" "$file" 2>/dev/null)
|
|
237
|
+
[[ "$v" == "null" ]] && echo "" || echo "$v"
|
|
238
|
+
else
|
|
239
|
+
python3 - "$file" "$key" <<'PY'
|
|
240
|
+
import sys, yaml
|
|
241
|
+
c = yaml.safe_load(open(sys.argv[1]))
|
|
242
|
+
v = c
|
|
243
|
+
for k in sys.argv[2].split('.'):
|
|
244
|
+
v = (v or {}).get(k) if isinstance(v, dict) else None
|
|
245
|
+
print('' if v is None else v)
|
|
246
|
+
PY
|
|
247
|
+
fi
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
yaml_set() {
|
|
251
|
+
local file="$1" key="$2" value="$3"
|
|
252
|
+
_with_lock "$file.lock" _yaml_set_impl "$file" "$key" "$value"
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
# Internal: the actual mutation, run under _with_lock. KEY is an internal
|
|
256
|
+
# constant and stays interpolated; VALUE is untrusted and is NEVER interpolated
|
|
257
|
+
# into the yq expression (audit C9 — PoC value `x" | .assigned_to = "evil@x.com`
|
|
258
|
+
# would otherwise rewrite an unrelated field). The value is passed via the
|
|
259
|
+
# environment and read with yq's strenv(). The python3 fallback already passes
|
|
260
|
+
# the value through argv (safe). Both backends write atomically (temp+rename,
|
|
261
|
+
# audit C8) instead of truncating the file in place.
|
|
262
|
+
_yaml_set_impl() {
|
|
263
|
+
local file="$1" key="$2" value="$3"
|
|
264
|
+
if command -v yq &>/dev/null; then
|
|
265
|
+
if [[ "$value" == "~" || "$value" == "null" ]]; then
|
|
266
|
+
yq ".$key = null" "$file" | atomic_write "$file"
|
|
267
|
+
else
|
|
268
|
+
VAL="$value" yq ".$key = strenv(VAL)" "$file" | atomic_write "$file"
|
|
269
|
+
fi
|
|
270
|
+
else
|
|
271
|
+
python3 - "$file" "$key" "$value" <<'PY' | atomic_write "$file"
|
|
272
|
+
import sys, yaml
|
|
273
|
+
file, key, value = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
274
|
+
c = yaml.safe_load(open(file))
|
|
275
|
+
c[key] = None if value in ('~', 'null') else value
|
|
276
|
+
yaml.dump(c, sys.stdout, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
277
|
+
PY
|
|
278
|
+
fi
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
# ── Project YAML helpers ──────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
get_project_yaml() { echo "$REPO_ROOT/projects/$1/project.yaml"; }
|
|
284
|
+
get_project_dir() { echo "$REPO_ROOT/projects/$1"; }
|
|
285
|
+
|
|
286
|
+
check_project_exists() {
|
|
287
|
+
local pf; pf=$(get_project_yaml "$1")
|
|
288
|
+
[[ -f "$pf" ]] || hard_stop "project.yaml not found: $pf"
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
require_project_status() {
|
|
292
|
+
local pf="$1" expected="$2"
|
|
293
|
+
local s; s=$(yaml_get "$pf" "status")
|
|
294
|
+
[[ "$s" == "$expected" ]] || hard_stop "Project status is '$s', expected '$expected'"
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
require_any_project_status() {
|
|
298
|
+
local pf="$1"; shift
|
|
299
|
+
local s; s=$(yaml_get "$pf" "status")
|
|
300
|
+
for e in "$@"; do [[ "$s" == "$e" ]] && return 0; done
|
|
301
|
+
hard_stop "Project status is '$s', expected one of: $*"
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
# Print one repo URL per line from project.yaml repos[]
|
|
305
|
+
get_project_repos() {
|
|
306
|
+
python3 - "$1" <<'PY'
|
|
307
|
+
import sys, yaml
|
|
308
|
+
sys.stdout.reconfigure(newline='\n')
|
|
309
|
+
c = yaml.safe_load(open(sys.argv[1]))
|
|
310
|
+
for r in (c.get('repos') or []):
|
|
311
|
+
if r and r.get('url'):
|
|
312
|
+
print(r['url'])
|
|
313
|
+
PY
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
# Print base_branch for a specific repo URL
|
|
317
|
+
get_repo_base_branch() {
|
|
318
|
+
python3 - "$1" "$2" <<'PY'
|
|
319
|
+
import sys, yaml
|
|
320
|
+
sys.stdout.reconfigure(newline='\n')
|
|
321
|
+
c = yaml.safe_load(open(sys.argv[1]))
|
|
322
|
+
for r in (c.get('repos') or []):
|
|
323
|
+
if r and r.get('url') == sys.argv[2]:
|
|
324
|
+
print(r.get('base_branch') or 'dev')
|
|
325
|
+
sys.exit(0)
|
|
326
|
+
print('dev')
|
|
327
|
+
PY
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
get_repo_name() { basename "$1" .git; }
|
|
331
|
+
|
|
332
|
+
# Resolve the project branch name for a given PROJECT_ID. Prefers the
|
|
333
|
+
# canonical value from registry.yaml's projects[<id>].branch; falls back to
|
|
334
|
+
# deriving from the ID for backwards compatibility:
|
|
335
|
+
# - PRJ-NNN-slug → brnch-NNN-slug (v0.2.0+ convention)
|
|
336
|
+
# - <ANY>-NNN-slug → lowercase form (pre-v0.2.0 convention; ORG_SLUG-NNN → org_slug-NNN)
|
|
337
|
+
project_branch_for_id() {
|
|
338
|
+
local pid="$1" branch
|
|
339
|
+
branch=$(python3 - "$REGISTRY" "$pid" 2>/dev/null <<'PY'
|
|
340
|
+
import sys, yaml
|
|
341
|
+
c = yaml.safe_load(open(sys.argv[1])) or {}
|
|
342
|
+
for p in (c.get('projects') or []):
|
|
343
|
+
if p and p.get('id') == sys.argv[2]:
|
|
344
|
+
b = p.get('branch')
|
|
345
|
+
if b:
|
|
346
|
+
print(b); sys.exit(0)
|
|
347
|
+
sys.exit(1)
|
|
348
|
+
PY
|
|
349
|
+
)
|
|
350
|
+
if [[ -n "$branch" && "$branch" != "null" ]]; then
|
|
351
|
+
echo "$branch"
|
|
352
|
+
return 0
|
|
353
|
+
fi
|
|
354
|
+
# Fallback: derive from ID. PRJ- prefix gets the new brnch- mapping;
|
|
355
|
+
# legacy uppercase prefixes (e.g. ACME-001-foo) get the historical
|
|
356
|
+
# lowercase mapping (acme-001-foo).
|
|
357
|
+
if [[ "$pid" == PRJ-* ]]; then
|
|
358
|
+
echo "brnch-${pid#PRJ-}"
|
|
359
|
+
else
|
|
360
|
+
echo "$pid" | tr '[:upper:]' '[:lower:]'
|
|
361
|
+
fi
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
# Per-project workspace paths (Direction A layout)
|
|
365
|
+
project_work_root() { echo "$AGENT_WORK_ROOT/$1"; }
|
|
366
|
+
org_gov_clone() { echo "$AGENT_WORK_ROOT/$1/$WORKSPACE_REPO"; }
|
|
367
|
+
repo_clone_dir() { echo "$AGENT_WORK_ROOT/$1/$2"; }
|
|
368
|
+
# Back-compat aliases used by join.sh
|
|
369
|
+
project_clone_root() { project_work_root "$1"; }
|
|
370
|
+
|
|
371
|
+
# Base clone shared by all per-project worktrees of a repo (ADR-0001 Phase 2).
|
|
372
|
+
base_clone_dir() { echo "$AGENT_WORK_ROOT/.bases/$(get_repo_name "$1")"; }
|
|
373
|
+
|
|
374
|
+
# Materialize <branch> of <repo_url> at <target_dir> as a git WORKTREE of a
|
|
375
|
+
# single shared base clone (one base per repo under $AGENT_WORK_ROOT/.bases/),
|
|
376
|
+
# instead of a full per-project clone. This is the ADR-0001 Phase 2 storage
|
|
377
|
+
# model: one fetch/identity per repo, shared object store, far less disk.
|
|
378
|
+
#
|
|
379
|
+
# Backward compatible: if <target_dir> already exists (a legacy full clone or
|
|
380
|
+
# an existing worktree), it is just fetched + checked out, never re-created.
|
|
381
|
+
# Returns non-zero on failure so callers can warn/skip.
|
|
382
|
+
ensure_repo_worktree() {
|
|
383
|
+
local url="$1" target="$2" branch="$3"
|
|
384
|
+
local base; base="$(base_clone_dir "$url")"
|
|
385
|
+
|
|
386
|
+
# Already materialized (legacy clone OR existing worktree) — update in place.
|
|
387
|
+
if [[ -e "$target/.git" ]]; then
|
|
388
|
+
git -C "$target" fetch origin "$branch" 2>/dev/null || true
|
|
389
|
+
git -C "$target" checkout "$branch" 2>/dev/null || return 1
|
|
390
|
+
return 0
|
|
391
|
+
fi
|
|
392
|
+
|
|
393
|
+
# Ensure the single shared base clone exists and knows the branch.
|
|
394
|
+
if [[ ! -e "$base/.git" ]]; then
|
|
395
|
+
mkdir -p "$(dirname "$base")"
|
|
396
|
+
git_clone_retry "$url" "$base" || return 1
|
|
397
|
+
fi
|
|
398
|
+
git -C "$base" fetch origin "$branch" 2>/dev/null \
|
|
399
|
+
|| git -C "$base" fetch origin 2>/dev/null || true
|
|
400
|
+
|
|
401
|
+
mkdir -p "$(dirname "$target")"
|
|
402
|
+
# Add the worktree on the branch, tracking origin/<branch> when needed.
|
|
403
|
+
if git -C "$base" show-ref --verify --quiet "refs/heads/$branch"; then
|
|
404
|
+
git -C "$base" worktree add "$target" "$branch"
|
|
405
|
+
elif git -C "$base" show-ref --verify --quiet "refs/remotes/origin/$branch"; then
|
|
406
|
+
git -C "$base" worktree add --track -b "$branch" "$target" "origin/$branch"
|
|
407
|
+
else
|
|
408
|
+
git -C "$base" worktree add -b "$branch" "$target"
|
|
409
|
+
fi
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
# Clone with retry + backoff.
|
|
413
|
+
# "early EOF / unexpected disconnect while reading sideband packet"; a couple of
|
|
414
|
+
# retries usually rides through a transient drop. Honors GIT_CLONE_ATTEMPTS
|
|
415
|
+
# (default 3). Any extra git-clone args (a branch, --depth, …) pass through after
|
|
416
|
+
# <dest>. Removes a partial <dest> before each attempt. Returns non-zero if all
|
|
417
|
+
# attempts fail (callers decide whether that's fatal).
|
|
418
|
+
git_clone_retry() {
|
|
419
|
+
local url="$1" dest="$2"; shift 2
|
|
420
|
+
local attempts="${GIT_CLONE_ATTEMPTS:-3}" n=1 delay=5
|
|
421
|
+
while true; do
|
|
422
|
+
rm -rf "$dest"
|
|
423
|
+
if git -c http.postBuffer=524288000 clone "$@" "$url" "$dest"; then
|
|
424
|
+
return 0
|
|
425
|
+
fi
|
|
426
|
+
if [[ "$n" -ge "$attempts" ]]; then
|
|
427
|
+
return 1
|
|
428
|
+
fi
|
|
429
|
+
warn "Clone of $url failed (attempt $n/$attempts) — retrying in ${delay}s..."
|
|
430
|
+
sleep "$delay"
|
|
431
|
+
n=$((n + 1)); delay=$((delay * 3))
|
|
432
|
+
done
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
# Is the current user authorized to work this project? (per-task/team model)
|
|
436
|
+
# assigned_to is either an individual email (contains '@') or a GitHub team slug.
|
|
437
|
+
# Authorized when: assigned_to is empty/~ (unrestricted), OR equals the current
|
|
438
|
+
# git email (individual), OR the current gh login is a member of the team
|
|
439
|
+
# (needs read:org). seeded_by is an audit record and is NOT an authorization gate.
|
|
440
|
+
is_authorized() {
|
|
441
|
+
local assigned="${1:-}"
|
|
442
|
+
[[ -z "$assigned" || "$assigned" == "~" ]] && return 0
|
|
443
|
+
local email; email=$(git config user.email 2>/dev/null || echo "")
|
|
444
|
+
[[ -n "$email" && "$assigned" == "$email" ]] && return 0
|
|
445
|
+
if [[ "$assigned" == @* || "$assigned" != *"@"* ]]; then # team: leading '@' or bare slug
|
|
446
|
+
local login team
|
|
447
|
+
login=$(gh api user --jq .login 2>/dev/null || echo "")
|
|
448
|
+
[[ -z "$login" ]] && return 1
|
|
449
|
+
team="${assigned#@}"; team="${team##*/}" # strip leading @ and any org/ prefix
|
|
450
|
+
gh api "orgs/$GITHUB_ORG/teams/$team/members" --jq '.[].login' 2>/dev/null \
|
|
451
|
+
| grep -qx "$login" && return 0
|
|
452
|
+
fi
|
|
453
|
+
return 1
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
# ── GitHub Project access — ADR-0001 Phase 3 authorization source of truth ───
|
|
457
|
+
# Authorization moves from YAML assigned_to to "does the current GitHub user
|
|
458
|
+
# have write access to the linked GitHub Project v2" (viewerCanUpdate). YAML
|
|
459
|
+
# assigned_to becomes a display/audit cache. When GitHub is unreachable these
|
|
460
|
+
# signal (rc 2) so callers fall back to the legacy YAML check.
|
|
461
|
+
|
|
462
|
+
# Parse "<scope> <owner> <number>" from a Project v2 URL (scope = orgs|users).
|
|
463
|
+
gh_project_owner_number() {
|
|
464
|
+
if [[ "$1" =~ /(orgs|users)/([^/]+)/projects/([0-9]+) ]]; then
|
|
465
|
+
printf '%s %s %s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}"
|
|
466
|
+
return 0
|
|
467
|
+
fi
|
|
468
|
+
return 1
|
|
469
|
+
}
|
|
470
|
+
_gh_owner_field() { [[ "$1" == "orgs" ]] && echo organization || echo user; }
|
|
471
|
+
|
|
472
|
+
# Close the GitHub Project board for a project URL so a completed/cancelled
|
|
473
|
+
# project stops showing as active in board-driven views (#56 Facet A). The
|
|
474
|
+
# board's open/closed state is what `prj manage list` keys on (open-only), so
|
|
475
|
+
# closing it both fixes the status display AND drops the project out of the
|
|
476
|
+
# active-management view (registry-backed `prj list` still shows it). Idempotent
|
|
477
|
+
# and non-fatal: a missing URL or already-closed board only warns.
|
|
478
|
+
close_project_board() {
|
|
479
|
+
local url="$1" scope owner num
|
|
480
|
+
if ! read -r scope owner num < <(gh_project_owner_number "$url"); then
|
|
481
|
+
warn "Could not derive project number from '$url' — close the board manually."
|
|
482
|
+
return 0
|
|
483
|
+
fi
|
|
484
|
+
if gh project close "$num" --owner "$owner" >/dev/null 2>&1; then
|
|
485
|
+
info "Closed GitHub Project board #$num (owner $owner)"
|
|
486
|
+
else
|
|
487
|
+
warn "Could not close GitHub Project board #$num — close manually: gh project close $num --owner $owner"
|
|
488
|
+
fi
|
|
489
|
+
return 0
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
# Echo the ProjectV2 node id for a project URL (empty + non-zero on failure).
|
|
493
|
+
gh_project_node_id() {
|
|
494
|
+
local scope owner num field id
|
|
495
|
+
read -r scope owner num < <(gh_project_owner_number "$1") || return 2
|
|
496
|
+
field=$(_gh_owner_field "$scope")
|
|
497
|
+
id=$(gh api graphql -f query="query{ $field(login:\"$owner\"){ projectV2(number:$num){ id } } }" \
|
|
498
|
+
--jq ".data.$field.projectV2.id" 2>/dev/null) || return 1
|
|
499
|
+
[[ -n "$id" && "$id" != "null" ]] && { echo "$id"; return 0; }
|
|
500
|
+
return 1
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
# rc 0 = current gh user can write the Project (authorized); rc 1 = cannot;
|
|
504
|
+
# rc 2 = GitHub unreachable (caller should fall back to the YAML check).
|
|
505
|
+
gh_viewer_can_update_project() {
|
|
506
|
+
local scope owner num field v
|
|
507
|
+
read -r scope owner num < <(gh_project_owner_number "$1") || return 2
|
|
508
|
+
field=$(_gh_owner_field "$scope")
|
|
509
|
+
v=$(gh api graphql -f query="query{ $field(login:\"$owner\"){ projectV2(number:$num){ viewerCanUpdate } } }" \
|
|
510
|
+
--jq ".data.$field.projectV2.viewerCanUpdate" 2>/dev/null) || return 2
|
|
511
|
+
[[ -z "$v" || "$v" == "null" ]] && return 2
|
|
512
|
+
[[ "$v" == "true" ]]
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
# Authorization gate (Phase 3). Args: <project_url> [yaml_assigned_to_for_fallback].
|
|
516
|
+
is_authorized_for_project() {
|
|
517
|
+
local url="$1" yaml_assigned="${2:-}"
|
|
518
|
+
gh_viewer_can_update_project "$url"
|
|
519
|
+
case "$?" in
|
|
520
|
+
0) return 0 ;;
|
|
521
|
+
1) return 1 ;;
|
|
522
|
+
2) warn "Could not reach GitHub to check Project access — using cached assignment."
|
|
523
|
+
is_authorized "$yaml_assigned"; return $? ;;
|
|
524
|
+
esac
|
|
525
|
+
return 1
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
# Resolve an assignee token to "user <nodeId>" or "team <nodeId>".
|
|
529
|
+
# Convention: '@slug' = a GitHub team; a bare 'login' = a GitHub user.
|
|
530
|
+
# An email-style value (contains '@' not at the start) is legacy and unsupported.
|
|
531
|
+
gh_resolve_actor() {
|
|
532
|
+
local who="$1" id
|
|
533
|
+
if [[ "$who" == @* ]]; then
|
|
534
|
+
local slug="${who#@}"; slug="${slug##*/}"
|
|
535
|
+
id=$(gh api graphql -f query="query{ organization(login:\"$GITHUB_ORG\"){ team(slug:\"$slug\"){ id } } }" --jq '.data.organization.team.id' 2>/dev/null)
|
|
536
|
+
[[ -n "$id" && "$id" != "null" ]] && { echo "team $id"; return 0; }
|
|
537
|
+
return 1
|
|
538
|
+
elif [[ "$who" != *"@"* ]]; then
|
|
539
|
+
id=$(gh api graphql -f query="query{ user(login:\"$who\"){ id } }" --jq '.data.user.id' 2>/dev/null)
|
|
540
|
+
[[ -n "$id" && "$id" != "null" ]] && { echo "user $id"; return 0; }
|
|
541
|
+
return 1
|
|
542
|
+
fi
|
|
543
|
+
return 1
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
# Grant/revoke Project access. role: WRITER (assign) | NONE (unassign).
|
|
547
|
+
# kind: user|team. MUTATES real GitHub Project access. Returns gh's exit status.
|
|
548
|
+
gh_project_set_access() {
|
|
549
|
+
local project_id="$1" kind="$2" actor_id="$3" role="$4" idfield
|
|
550
|
+
[[ "$kind" == "team" ]] && idfield="teamId" || idfield="userId"
|
|
551
|
+
gh api graphql -f query="mutation(\$p:ID!,\$a:ID!){ updateProjectV2Collaborators(input:{projectId:\$p, collaborators:[{$idfield:\$a, role:$role}]}){ clientMutationId } }" \
|
|
552
|
+
-f p="$project_id" -f a="$actor_id" >/dev/null 2>&1
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
# Copy the developer's git identity from the workspace a lifecycle command runs
|
|
556
|
+
# in ($REPO_ROOT, where setup.sh configured user.name/user.email) into a freshly
|
|
557
|
+
# created per-project clone. Without this, the per-project workspace inherits
|
|
558
|
+
# only the ambient global identity, so commits and C01 authorization there would
|
|
559
|
+
# not reflect the developer who seeded/joined the project.
|
|
560
|
+
set_clone_identity() {
|
|
561
|
+
local dir="$1" name email
|
|
562
|
+
name=$(git -C "$REPO_ROOT" config user.name 2>/dev/null || echo "")
|
|
563
|
+
email=$(git -C "$REPO_ROOT" config user.email 2>/dev/null || echo "")
|
|
564
|
+
[[ -n "$name" ]] && git -C "$dir" config user.name "$name"
|
|
565
|
+
[[ -n "$email" ]] && git -C "$dir" config user.email "$email"
|
|
566
|
+
return 0
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
# ── Registry-on-default-branch (Option 2 global index) ──────────────────────
|
|
570
|
+
# registry.yaml is the authoritative index and lives on $DEFAULT_BRANCH so
|
|
571
|
+
# management/read commands see all projects without checking out a project
|
|
572
|
+
# branch. This sets a project's status there and pushes. Safe to call from a
|
|
573
|
+
# standalone clone on any branch when the working tree is clean (callers commit
|
|
574
|
+
# their own changes first); it switches to the default branch and back.
|
|
575
|
+
# Internal: rewrite a project's status in registry.yaml atomically. Run under
|
|
576
|
+
# _with_lock by registry_set_status_on_main. Writes to stdout and lets
|
|
577
|
+
# atomic_write handle the temp+rename, never truncating the file in place.
|
|
578
|
+
_registry_set_status_impl() {
|
|
579
|
+
python3 - "$1" "$2" "$3" <<'PY' | atomic_write "$1"
|
|
580
|
+
import sys, yaml
|
|
581
|
+
reg, pid, status = sys.argv[1:]
|
|
582
|
+
c = yaml.safe_load(open(reg)) or {}
|
|
583
|
+
for p in (c.get('projects') or []):
|
|
584
|
+
if p and p.get('id') == pid:
|
|
585
|
+
p['status'] = status
|
|
586
|
+
break
|
|
587
|
+
yaml.dump(c, sys.stdout, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
588
|
+
PY
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
registry_set_status_on_main() {
|
|
592
|
+
local pid="$1" status="$2"
|
|
593
|
+
local cur; cur=$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
|
|
594
|
+
if [[ -n "$(git -C "$REPO_ROOT" status --porcelain 2>/dev/null)" ]]; then
|
|
595
|
+
warn "Working tree not clean — skipping registry status update on $DEFAULT_BRANCH for $pid."
|
|
596
|
+
return 0
|
|
597
|
+
fi
|
|
598
|
+
git -C "$REPO_ROOT" fetch origin "$DEFAULT_BRANCH" 2>/dev/null || true
|
|
599
|
+
git -C "$REPO_ROOT" checkout "$DEFAULT_BRANCH" 2>/dev/null \
|
|
600
|
+
|| { warn "Could not switch to $DEFAULT_BRANCH to update registry for $pid."; return 0; }
|
|
601
|
+
git -C "$REPO_ROOT" pull --ff-only origin "$DEFAULT_BRANCH" 2>/dev/null || true
|
|
602
|
+
# Audit C7: serialize concurrent seed/status writers and write atomically
|
|
603
|
+
# (temp+rename) so a crash mid-write can't truncate the shared registry.
|
|
604
|
+
_with_lock "$REGISTRY.lock" _registry_set_status_impl "$REGISTRY" "$pid" "$status"
|
|
605
|
+
if [[ -n "$(git -C "$REPO_ROOT" status --porcelain registry.yaml 2>/dev/null)" ]]; then
|
|
606
|
+
git -C "$REPO_ROOT" add registry.yaml
|
|
607
|
+
git -C "$REPO_ROOT" commit -m "registry: $pid status=$status" >/dev/null 2>&1 || true
|
|
608
|
+
git -C "$REPO_ROOT" push origin "$DEFAULT_BRANCH" 2>/dev/null \
|
|
609
|
+
|| warn "Could not push registry status=$status for $pid to $DEFAULT_BRANCH."
|
|
610
|
+
fi
|
|
611
|
+
[[ -n "$cur" ]] && git -C "$REPO_ROOT" checkout "$cur" 2>/dev/null || true
|
|
612
|
+
return 0
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
# Best-effort: mirror a read-only governance summary into the GitHub Project
|
|
616
|
+
# README. Needs the 'project' (write) scope; on any failure it warns and
|
|
617
|
+
# returns 0 so it can never break a lifecycle op. git stays authoritative.
|
|
618
|
+
project_readme_mirror() {
|
|
619
|
+
local pid="$1" gh_url="$2" status="$3" assigned="$4" seeded="$5" branch="$6"
|
|
620
|
+
[[ -z "$gh_url" ]] && return 0
|
|
621
|
+
command -v gh >/dev/null 2>&1 || return 0
|
|
622
|
+
local num owner field
|
|
623
|
+
num=$(echo "$gh_url" | grep -oE '/projects/[0-9]+' | grep -oE '[0-9]+' || echo "")
|
|
624
|
+
[[ -z "$num" ]] && return 0
|
|
625
|
+
if echo "$gh_url" | grep -q '/orgs/'; then
|
|
626
|
+
owner=$(echo "$gh_url" | sed 's|.*/orgs/\([^/]*\)/.*|\1|'); field="organization"
|
|
627
|
+
else
|
|
628
|
+
owner=$(echo "$gh_url" | sed 's|.*/users/\([^/]*\)/.*|\1|'); field="user"
|
|
629
|
+
fi
|
|
630
|
+
local node_id
|
|
631
|
+
node_id=$(gh api graphql -f query="query{ ${field}(login: \"$owner\"){ projectV2(number: $num){ id } } }" \
|
|
632
|
+
--jq ".data.${field}.projectV2.id" 2>/dev/null || echo "")
|
|
633
|
+
if [[ -z "$node_id" || "$node_id" == "null" ]]; then
|
|
634
|
+
warn "README mirror skipped for $pid (could not resolve project — needs 'project' scope)."
|
|
635
|
+
return 0
|
|
636
|
+
fi
|
|
637
|
+
local readme
|
|
638
|
+
readme=$(cat <<MD
|
|
639
|
+
<!-- Managed by the agentic-dev framework — do not edit. Mirrored from registry.yaml. -->
|
|
640
|
+
## Governance — $pid
|
|
641
|
+
|
|
642
|
+
| Field | Value |
|
|
643
|
+
|---|---|
|
|
644
|
+
| Project ID | \`$pid\` |
|
|
645
|
+
| Status | $status |
|
|
646
|
+
| Assigned to | $assigned |
|
|
647
|
+
| Seeded by | $seeded |
|
|
648
|
+
| Branch | \`$branch\` |
|
|
649
|
+
|
|
650
|
+
Authoritative record: \`registry.yaml\` + \`projects/$pid/\` in the governance repo.
|
|
651
|
+
MD
|
|
652
|
+
)
|
|
653
|
+
gh api graphql \
|
|
654
|
+
-f query='mutation($id:ID!,$r:String!){ updateProjectV2(input:{projectId:$id, readme:$r}){ projectV2 { id } } }' \
|
|
655
|
+
-f id="$node_id" -f r="$readme" >/dev/null 2>&1 \
|
|
656
|
+
|| warn "README mirror skipped for $pid (write failed — needs 'project' scope)."
|
|
657
|
+
return 0
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
# Print active task IDs from project.yaml tasks[]
|
|
661
|
+
# Active tasks = OPEN/non-Done issues on the project board (tasks-on-board model;
|
|
662
|
+
# the board is the source of truth for task state, not project.yaml). Echoes one
|
|
663
|
+
# issue URL per line. Reads the board via gh (needs 'project' scope). Arg: project.yaml path.
|
|
664
|
+
get_project_tasks() {
|
|
665
|
+
local pf="$1"
|
|
666
|
+
local url; url=$(yaml_get "$pf" "github_project")
|
|
667
|
+
[[ -z "$url" || "$url" == "~" ]] && return 0
|
|
668
|
+
command -v gh >/dev/null 2>&1 || return 0
|
|
669
|
+
local num owner
|
|
670
|
+
num=$(echo "$url" | grep -oE '/projects/[0-9]+' | grep -oE '[0-9]+' || echo "")
|
|
671
|
+
[[ -z "$num" ]] && return 0
|
|
672
|
+
if echo "$url" | grep -q '/orgs/'; then
|
|
673
|
+
owner=$(echo "$url" | sed 's|.*/orgs/\([^/]*\)/.*|\1|')
|
|
674
|
+
else
|
|
675
|
+
owner=$(echo "$url" | sed 's|.*/users/\([^/]*\)/.*|\1|')
|
|
676
|
+
fi
|
|
677
|
+
gh project item-list "$num" --owner "$owner" --format json --limit 200 2>/dev/null | python3 -c "
|
|
678
|
+
import sys, json
|
|
679
|
+
try: d = json.load(sys.stdin)
|
|
680
|
+
except Exception: sys.exit(0)
|
|
681
|
+
for i in d.get('items', []):
|
|
682
|
+
c = i.get('content') or {}
|
|
683
|
+
if c.get('type') == 'Issue' and str(i.get('status','')).strip().lower() != 'done':
|
|
684
|
+
u = c.get('url')
|
|
685
|
+
if u: print(u)
|
|
686
|
+
" 2>/dev/null
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
# Best-effort: set a GitHub Project 'Status' single-select for an issue's item.
|
|
690
|
+
# Needs the 'project' (write) scope; warns + returns 0 on any failure so it can
|
|
691
|
+
# never break a task op. Args: project_url, issue_url, status_option_name.
|
|
692
|
+
board_set_status() {
|
|
693
|
+
local url="$1" issue="$2" want="$3"
|
|
694
|
+
[[ -z "$url" || -z "$issue" ]] && return 0
|
|
695
|
+
command -v gh >/dev/null 2>&1 || return 0
|
|
696
|
+
local num owner
|
|
697
|
+
num=$(echo "$url" | grep -oE '/projects/[0-9]+' | grep -oE '[0-9]+' || echo "")
|
|
698
|
+
[[ -z "$num" ]] && return 0
|
|
699
|
+
if echo "$url" | grep -q '/orgs/'; then
|
|
700
|
+
owner=$(echo "$url" | sed 's|.*/orgs/\([^/]*\)/.*|\1|')
|
|
701
|
+
else
|
|
702
|
+
owner=$(echo "$url" | sed 's|.*/users/\([^/]*\)/.*|\1|')
|
|
703
|
+
fi
|
|
704
|
+
local pid fid oid iid
|
|
705
|
+
pid=$(gh project view "$num" --owner "$owner" --format json 2>/dev/null \
|
|
706
|
+
| python3 -c "import sys,json; print((json.load(sys.stdin) or {}).get('id',''))" 2>/dev/null)
|
|
707
|
+
read -r fid oid <<EOF2
|
|
708
|
+
$(gh project field-list "$num" --owner "$owner" --format json 2>/dev/null | WANT="$want" python3 -c "
|
|
709
|
+
import sys, json, os
|
|
710
|
+
want = os.environ.get('WANT','').strip().lower()
|
|
711
|
+
d = json.load(sys.stdin)
|
|
712
|
+
for f in d.get('fields', []):
|
|
713
|
+
if f.get('name') == 'Status':
|
|
714
|
+
oid = ''
|
|
715
|
+
for o in (f.get('options') or []):
|
|
716
|
+
if o.get('name','').strip().lower() == want: oid = o.get('id','')
|
|
717
|
+
print(f.get('id',''), oid); break
|
|
718
|
+
" 2>/dev/null)
|
|
719
|
+
EOF2
|
|
720
|
+
iid=$(gh project item-list "$num" --owner "$owner" --format json --limit 200 2>/dev/null \
|
|
721
|
+
| ISSUE="$issue" python3 -c "
|
|
722
|
+
import sys, json, os
|
|
723
|
+
iss = os.environ.get('ISSUE','')
|
|
724
|
+
d = json.load(sys.stdin)
|
|
725
|
+
for i in d.get('items', []):
|
|
726
|
+
if (i.get('content') or {}).get('url') == iss: print(i.get('id','')); break
|
|
727
|
+
" 2>/dev/null)
|
|
728
|
+
if [[ -z "$pid" || -z "$fid" || -z "$oid" || -z "$iid" ]]; then
|
|
729
|
+
warn "Board Status not set for $issue (need 'project' scope + a '$want' option)."
|
|
730
|
+
return 0
|
|
731
|
+
fi
|
|
732
|
+
gh project item-edit --id "$iid" --project-id "$pid" --field-id "$fid" \
|
|
733
|
+
--single-select-option-id "$oid" >/dev/null 2>&1 \
|
|
734
|
+
|| warn "Board Status update to '$want' failed for $issue."
|
|
735
|
+
return 0
|
|
736
|
+
}
|
|
737
|
+
# ── Git helpers ───────────────────────────────────────────────────────────────
|
|
738
|
+
|
|
739
|
+
check_clean() {
|
|
740
|
+
local path="${1:-$REPO_ROOT}"
|
|
741
|
+
if [[ -n "$(git -C "$path" status --porcelain 2>/dev/null)" ]]; then
|
|
742
|
+
hard_stop "Uncommitted changes in $path — commit or stash first."
|
|
743
|
+
fi
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
# Create branch from a base branch and push it
|
|
747
|
+
create_and_push_branch() {
|
|
748
|
+
local path="$1" branch="$2" from="$3"
|
|
749
|
+
info "Creating branch '$branch' from '$from' in $(basename "$path")..."
|
|
750
|
+
git -C "$path" fetch origin "$from" 2>/dev/null || true
|
|
751
|
+
git -C "$path" checkout "$from"
|
|
752
|
+
git -C "$path" pull origin "$from" 2>/dev/null || true
|
|
753
|
+
if git -C "$path" rev-parse --verify "$branch" &>/dev/null; then
|
|
754
|
+
hard_stop "Branch '$branch' already exists in $path — investigate before proceeding."
|
|
755
|
+
fi
|
|
756
|
+
git -C "$path" checkout -b "$branch"
|
|
757
|
+
git -C "$path" push -u origin "$branch"
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
# Archive (tag) and delete a branch in a repo
|
|
761
|
+
archive_branch() {
|
|
762
|
+
local path="$1" branch="$2"
|
|
763
|
+
local tag="archive/$branch"
|
|
764
|
+
info "Archiving '$branch' → '$tag' in $(basename "$path")..."
|
|
765
|
+
git -C "$path" tag "$tag" \
|
|
766
|
+
|| hard_stop "Failed to create archive tag '$tag' in $path — branch NOT deleted."
|
|
767
|
+
git -C "$path" push origin "$tag" \
|
|
768
|
+
|| hard_stop "Failed to push archive tag '$tag' — branch NOT deleted."
|
|
769
|
+
git -C "$path" push origin --delete "$branch" 2>/dev/null \
|
|
770
|
+
&& info "Deleted remote branch '$branch'" \
|
|
771
|
+
|| warn "Remote branch '$branch' not found (may already be deleted)"
|
|
772
|
+
git -C "$path" branch -D "$branch" 2>/dev/null || true
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
# Merge one branch into another; exit 2 on conflict so caller can handle
|
|
776
|
+
merge_branch() {
|
|
777
|
+
local path="$1" from="$2" into="$3"
|
|
778
|
+
info "Merging '$from' → '$into' in $(basename "$path")..."
|
|
779
|
+
git -C "$path" checkout "$into"
|
|
780
|
+
if ! git -C "$path" merge --no-edit "$from" 2>/dev/null; then
|
|
781
|
+
echo ""
|
|
782
|
+
echo "MERGE CONFLICT: $from → $into in $path"
|
|
783
|
+
echo "Resolve conflicts manually, then re-run this script to continue."
|
|
784
|
+
exit 2
|
|
785
|
+
fi
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
# ── Validation ────────────────────────────────────────────────────────────────
|
|
789
|
+
|
|
790
|
+
# Run validators against the current working tree of the workspace repo.
|
|
791
|
+
# Used by scripts that commit DIRECTLY to $DEFAULT_BRANCH (cancel, close-knowledge
|
|
792
|
+
# project.yaml status update). Call AFTER making the commit, BEFORE pushing.
|
|
793
|
+
# On validation failure: rolls back the most recent commit and hard_stops.
|
|
794
|
+
# On success: returns silently.
|
|
795
|
+
#
|
|
796
|
+
# Usage: validate_or_revert
|
|
797
|
+
# (Operates on $REPO_ROOT; reverts HEAD~1 on failure)
|
|
798
|
+
validate_or_revert() {
|
|
799
|
+
local validator="$REPO_ROOT/scripts/validate/run.py"
|
|
800
|
+
if [[ ! -x "$validator" ]]; then
|
|
801
|
+
warn "Validator not found at $validator — skipping pre-push validation."
|
|
802
|
+
return 0
|
|
803
|
+
fi
|
|
804
|
+
echo ""
|
|
805
|
+
info "Running validators on local tree before push..."
|
|
806
|
+
echo ""
|
|
807
|
+
if ! python3 "$validator" "$REPO_ROOT"; then
|
|
808
|
+
echo ""
|
|
809
|
+
warn "Validation FAILED — rolling back last commit."
|
|
810
|
+
git -C "$REPO_ROOT" reset --hard HEAD~1
|
|
811
|
+
hard_stop "Local commit rolled back. Remote $DEFAULT_BRANCH is unchanged."
|
|
812
|
+
fi
|
|
813
|
+
info "✓ Validation passed."
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
# ── Framework version guard ───────────────────────────────────────────────────
|
|
817
|
+
# No lower framework version may silently overwrite a higher one. Compare two
|
|
818
|
+
# semver-ish versions (strip leading v + any -suffix). Echoes -1 / 0 / 1.
|
|
819
|
+
version_cmp() {
|
|
820
|
+
local a="${1#v}" b="${2#v}"; a="${a%%-*}"; b="${b%%-*}"
|
|
821
|
+
[[ "$a" == "$b" ]] && { echo 0; return; }
|
|
822
|
+
local lo; lo="$(printf '%s\n%s\n' "$a" "$b" | sort -V | head -n1)"
|
|
823
|
+
[[ "$lo" == "$a" ]] && echo -1 || echo 1
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
# Hard-stop if INCOMING framework version < CURRENT (a downgrade that would
|
|
827
|
+
# overwrite newer framework code with older). Override only with the deliberate
|
|
828
|
+
# ALLOW_DOWNGRADE=true escape hatch ("stop and take a careful look").
|
|
829
|
+
assert_no_framework_downgrade() {
|
|
830
|
+
local incoming="$1" current="$2" context="${3:-framework sync}"
|
|
831
|
+
[[ -z "$incoming" || -z "$current" ]] && return 0
|
|
832
|
+
if [[ "$(version_cmp "$incoming" "$current")" == "-1" ]]; then
|
|
833
|
+
if [[ "${ALLOW_DOWNGRADE:-false}" == "true" ]]; then
|
|
834
|
+
warn "DOWNGRADE OVERRIDE ($context): applying v$incoming over v$current — proceeding (--allow-downgrade)."
|
|
835
|
+
else
|
|
836
|
+
hard_stop "Refusing $context: incoming framework v$incoming is LOWER than current v$current.
|
|
837
|
+
This would overwrite newer framework code with an older version — stopped for a careful look.
|
|
838
|
+
If this is genuinely intended, re-run with --allow-downgrade (ALLOW_DOWNGRADE=true)."
|
|
839
|
+
fi
|
|
840
|
+
fi
|
|
841
|
+
}
|