@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/prj
ADDED
|
@@ -0,0 +1,2381 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# prj — Agentic Development Framework CLI
|
|
3
|
+
#
|
|
4
|
+
# Usage:
|
|
5
|
+
# ./prj interactive menu
|
|
6
|
+
# ./prj <command> run a specific command
|
|
7
|
+
#
|
|
8
|
+
# Commands:
|
|
9
|
+
# init Initialize a new project
|
|
10
|
+
# task Create a task (sub-branch) for parallel work
|
|
11
|
+
# merge Merge a completed task back to the project branch
|
|
12
|
+
# pause Pause an active project
|
|
13
|
+
# resume Resume a paused project
|
|
14
|
+
# sync Sync project branches with latest base branches
|
|
15
|
+
# add-repo Add a repository to an active project
|
|
16
|
+
# cancel Cancel a project
|
|
17
|
+
# close Close a completed project
|
|
18
|
+
# knowledge Propose org knowledge changes
|
|
19
|
+
# onboard Onboard a repository into the framework
|
|
20
|
+
# list List all projects
|
|
21
|
+
# status Show status of a project
|
|
22
|
+
# deps Install / verify dependencies
|
|
23
|
+
|
|
24
|
+
set -euo pipefail
|
|
25
|
+
|
|
26
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
27
|
+
SCRIPTS="$SCRIPT_DIR/scripts"
|
|
28
|
+
# ADR-0001 Phase 4: the CLI may be installed separately from the workspace
|
|
29
|
+
# data. $ADF_WORKSPACE (exported by the installed `prj` wrapper, or set by
|
|
30
|
+
# hand) points at the governance repo; default to the vendored layout (the
|
|
31
|
+
# CLI lives inside the workspace) when it is unset — i.e. unchanged behavior.
|
|
32
|
+
if [[ -n "${ADF_WORKSPACE:-}" && -f "$ADF_WORKSPACE/org-config.yaml" ]]; then
|
|
33
|
+
WORKSPACE_ROOT="$ADF_WORKSPACE"
|
|
34
|
+
else
|
|
35
|
+
WORKSPACE_ROOT="$SCRIPT_DIR"
|
|
36
|
+
fi
|
|
37
|
+
CONFIG="$WORKSPACE_ROOT/org-config.yaml"
|
|
38
|
+
REGISTRY="$WORKSPACE_ROOT/registry.yaml"
|
|
39
|
+
|
|
40
|
+
# ── Colours ───────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
BOLD='\033[1m'; DIM='\033[2m'; CYAN='\033[0;36m'; GREEN='\033[0;32m'
|
|
43
|
+
YELLOW='\033[1;33m'; RED='\033[0;31m'; MAGENTA='\033[0;35m'; NC='\033[0m'
|
|
44
|
+
|
|
45
|
+
# Sentinel returned by interactive helpers when the user chooses "go back".
|
|
46
|
+
# Every menu offers '0) ← back'; callers compare the result against $BACK and
|
|
47
|
+
# step backward (or return to the previous menu) instead of proceeding.
|
|
48
|
+
BACK='__back__'
|
|
49
|
+
# is_back <value> — true if the helper result is the back sentinel.
|
|
50
|
+
is_back() { [[ "$1" == "$BACK" ]]; }
|
|
51
|
+
|
|
52
|
+
header() { echo -e "\n${BOLD}${CYAN}$*${NC}"; }
|
|
53
|
+
label() { echo -e "${DIM}$*${NC}"; }
|
|
54
|
+
ok() { echo -e "${GREEN}✓${NC} $*"; }
|
|
55
|
+
warn() { echo -e "${YELLOW}!${NC} $*"; }
|
|
56
|
+
err() { echo -e "${RED}✗${NC} $*" >&2; }
|
|
57
|
+
info() { echo -e "${DIM}›${NC} $*"; }
|
|
58
|
+
divider(){ echo -e "${DIM}────────────────────────────────────────${NC}"; }
|
|
59
|
+
|
|
60
|
+
# ── Read org config ───────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
if command -v yq &>/dev/null; then
|
|
63
|
+
ORG_NAME=$(yq '.org_name' "$CONFIG" 2>/dev/null || echo "")
|
|
64
|
+
ORG_SLUG=$(yq '.org_slug' "$CONFIG" 2>/dev/null || echo "")
|
|
65
|
+
ORG_SLUG_LOWER=$(yq '.org_slug_lower' "$CONFIG" 2>/dev/null || echo "")
|
|
66
|
+
GITHUB_ORG=$(yq '.github_org' "$CONFIG" 2>/dev/null || echo "")
|
|
67
|
+
WORKSPACE_REPO=$(yq '.workspace_repo' "$CONFIG" 2>/dev/null || echo "")
|
|
68
|
+
AGENT_WORK_ROOT=$(yq '.agent_work_root' "$CONFIG" 2>/dev/null || echo "")
|
|
69
|
+
DEFAULT_BRANCH=$(yq '.default_branch' "$CONFIG" 2>/dev/null || echo "main")
|
|
70
|
+
POLICY_OWNER_EMAIL=$(yq '.policy_owner_email' "$CONFIG" 2>/dev/null || echo "")
|
|
71
|
+
else
|
|
72
|
+
ORG_NAME=$(python3 -c "import yaml; print(yaml.safe_load(open('$CONFIG'))['org_name'])" 2>/dev/null || echo "")
|
|
73
|
+
ORG_SLUG=$(python3 -c "import yaml; print(yaml.safe_load(open('$CONFIG'))['org_slug'])" 2>/dev/null || echo "")
|
|
74
|
+
ORG_SLUG_LOWER=$(python3 -c "import yaml; print(yaml.safe_load(open('$CONFIG'))['org_slug_lower'])" 2>/dev/null || echo "")
|
|
75
|
+
GITHUB_ORG=$(python3 -c "import yaml; print(yaml.safe_load(open('$CONFIG'))['github_org'])" 2>/dev/null || echo "")
|
|
76
|
+
WORKSPACE_REPO=$(python3 -c "import yaml; print(yaml.safe_load(open('$CONFIG'))['workspace_repo'])" 2>/dev/null || echo "")
|
|
77
|
+
AGENT_WORK_ROOT=$(python3 -c "import yaml; print(yaml.safe_load(open('$CONFIG')).get('agent_work_root',''))" 2>/dev/null || echo "")
|
|
78
|
+
DEFAULT_BRANCH=$(python3 -c "import yaml; print(yaml.safe_load(open('$CONFIG'))['default_branch'])" 2>/dev/null || echo "main")
|
|
79
|
+
POLICY_OWNER_EMAIL=$(python3 -c "import yaml; print(yaml.safe_load(open('$CONFIG'))['policy_owner_email'])" 2>/dev/null || echo "")
|
|
80
|
+
fi
|
|
81
|
+
|
|
82
|
+
AGENT_WORK_ROOT="${AGENT_WORK_ROOT/#\~/$HOME}" # expand leading ~ for path use
|
|
83
|
+
WORKSPACE_REPO="${WORKSPACE_REPO:-svm-prj-work}"
|
|
84
|
+
|
|
85
|
+
CURRENT_USER=$(git config user.email 2>/dev/null || echo "")
|
|
86
|
+
|
|
87
|
+
# ── Navigation context + agent launch (#57) — prj-local (prj does not source lib.sh) ──
|
|
88
|
+
org_gov_clone() { echo "$AGENT_WORK_ROOT/$1/$WORKSPACE_REPO"; }
|
|
89
|
+
|
|
90
|
+
resolve_my_identities() {
|
|
91
|
+
git config user.email 2>/dev/null || true
|
|
92
|
+
gh api user --jq '.login' 2>/dev/null || true
|
|
93
|
+
gh api user/teams --jq '.[].slug' 2>/dev/null || true
|
|
94
|
+
gh api user/teams --jq '.[] | (.organization.login + "/" + .slug)' 2>/dev/null || true
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
# project_context_list <assignment:me|unassigned|any> <lifecycle:not-initiated|initiated|paused|ongoing|done|any>
|
|
98
|
+
# TSV: <id-or-(not seeded)>\t<github_url>\t<status>\t<assigned_to>. Registry-backed
|
|
99
|
+
# (board open/closed approximated from lifecycle, so no per-project GitHub calls).
|
|
100
|
+
project_context_list() {
|
|
101
|
+
local assignment="${1:-any}" lifecycle="${2:-any}" me owned=""
|
|
102
|
+
me="$(resolve_my_identities | paste -sd '|' -)"
|
|
103
|
+
# #57 Increment 5: ownership ("me") is GitHub-definitive — the boards whose
|
|
104
|
+
# anchor issue is assigned to me. Resolve once here and pass to the filter.
|
|
105
|
+
# If empty (no anchors labelled yet, or API hiccup), the python falls back to
|
|
106
|
+
# the registry assigned_to cache so nav never breaks during the transition.
|
|
107
|
+
if [[ "$assignment" == "me" ]]; then
|
|
108
|
+
owned="$(my_github_project_numbers | paste -sd ' ' -)"
|
|
109
|
+
fi
|
|
110
|
+
ME="$me" OWNED_NUMS="$owned" python3 - "$REGISTRY" "$assignment" "$lifecycle" <<'PY'
|
|
111
|
+
import sys, os, re, yaml
|
|
112
|
+
registry, assignment, lifecycle = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
113
|
+
me = set(x for x in os.environ.get('ME', '').split('|') if x)
|
|
114
|
+
owned = set(x for x in re.split(r'[ ,]+', os.environ.get('OWNED_NUMS', '')) if x)
|
|
115
|
+
use_gh = bool(owned) # GitHub anchor ownership known → authoritative; else cache
|
|
116
|
+
doc = yaml.safe_load(open(registry)) or {}
|
|
117
|
+
projects = [p for p in (doc.get('projects') or []) if p]
|
|
118
|
+
preasn = [a for a in (doc.get('pre_assignments') or []) if a]
|
|
119
|
+
seeded = {p.get('github_project') for p in projects}
|
|
120
|
+
ONGOING, DONE = {'active', 'paused'}, {'completed', 'cancelled'}
|
|
121
|
+
def board_num(url):
|
|
122
|
+
m = re.search(r'/projects/(\d+)', url or ''); return m.group(1) if m else ''
|
|
123
|
+
def assign_ok(who, url):
|
|
124
|
+
if assignment == 'any': return True
|
|
125
|
+
if assignment == 'unassigned': return not who
|
|
126
|
+
if assignment == 'me':
|
|
127
|
+
if use_gh: return board_num(url) in owned
|
|
128
|
+
return bool(who) and who in me
|
|
129
|
+
return True
|
|
130
|
+
def nnn(pid):
|
|
131
|
+
m = re.search(r'-(\d+)-', pid or ''); return int(m.group(1)) if m else -1
|
|
132
|
+
rows = []
|
|
133
|
+
if lifecycle in ('initiated', 'paused', 'ongoing', 'done', 'any'):
|
|
134
|
+
for p in projects:
|
|
135
|
+
s = p.get('status', '')
|
|
136
|
+
url = p.get('github_project', '') or ''
|
|
137
|
+
ok = ((lifecycle == 'initiated' and s == 'active') or
|
|
138
|
+
(lifecycle == 'paused' and s == 'paused') or
|
|
139
|
+
(lifecycle == 'ongoing' and s in ONGOING) or
|
|
140
|
+
(lifecycle == 'done' and s in DONE) or
|
|
141
|
+
(lifecycle == 'any'))
|
|
142
|
+
if ok and assign_ok(p.get('assigned_to'), url):
|
|
143
|
+
rows.append((nnn(p.get('id', '')), p['id'], url, s, p.get('assigned_to') or ''))
|
|
144
|
+
if lifecycle in ('not-initiated', 'any'):
|
|
145
|
+
for a in preasn:
|
|
146
|
+
url = a.get('github_project', '') or ''
|
|
147
|
+
if url in seeded: continue
|
|
148
|
+
if assign_ok(a.get('assigned_to'), url):
|
|
149
|
+
rows.append((-1, '(not seeded)', url, 'not-initiated', a.get('assigned_to') or ''))
|
|
150
|
+
for _, pid, url, s, who in sorted(rows, key=lambda r: r[0]):
|
|
151
|
+
print('\t'.join((pid, url, s, who)))
|
|
152
|
+
PY
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
agent_session_start_prompt() {
|
|
156
|
+
printf 'Run the session-start protocol for %s now, before I send anything else: read org-config.yaml, projects/%s/agent.md, knowledge/policies/agentic-development-policy.md, and surface any "## Open" items from projects/%s/knowledge/todo.md; then post the context manifest and wait for my direction.' "$1" "$1" "$1"
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
SANCTIONED_AGENTS="claude cursor"
|
|
160
|
+
launch_agent() {
|
|
161
|
+
local agent="$1" dir="$2" pid="$3" inject
|
|
162
|
+
[[ -d "$dir" ]] || { warn "Project worktree not found: $dir"; return 0; }
|
|
163
|
+
case " $SANCTIONED_AGENTS " in
|
|
164
|
+
*" $agent "*) : ;;
|
|
165
|
+
*) warn "Agent '$agent' is not sanctioned (sanctioned: $SANCTIONED_AGENTS; see knowledge/policies/sanctioned_agents.md)."; return 0 ;;
|
|
166
|
+
esac
|
|
167
|
+
inject="$(agent_session_start_prompt "$pid")"
|
|
168
|
+
case "$agent" in
|
|
169
|
+
claude)
|
|
170
|
+
command -v claude >/dev/null 2>&1 || { warn "claude not on PATH. Start manually: cd \"$dir\" && claude \"<session-start>\""; return 0; }
|
|
171
|
+
ok "Launching Claude in $dir (session-start runs first)..."; cd "$dir" && exec claude "$inject" ;;
|
|
172
|
+
cursor)
|
|
173
|
+
command -v cursor-agent >/dev/null 2>&1 || { warn "cursor-agent not on PATH (install: curl https://cursor.com/install -fsS | bash). Start manually: cd \"$dir\" && cursor-agent \"<session-start>\""; return 0; }
|
|
174
|
+
ok "Launching cursor-agent in $dir (session-start runs first)..."; cd "$dir" && exec cursor-agent "$inject" ;;
|
|
175
|
+
esac
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
prompt_and_launch_agent() {
|
|
179
|
+
local dir="$1" pid="$2" choice
|
|
180
|
+
if [[ ! -t 0 ]]; then
|
|
181
|
+
label "Project ready at $dir. Start your agent: cd \"$dir\" && claude \"<session-start>\" (or cursor-agent)"
|
|
182
|
+
return 0
|
|
183
|
+
fi
|
|
184
|
+
echo ""
|
|
185
|
+
label "Start an agent in the project workspace (it runs the session-start protocol automatically):"
|
|
186
|
+
echo " 1) claude 2) cursor 0) none — I'll start it myself"
|
|
187
|
+
printf " Choose [1]: "
|
|
188
|
+
IFS= read -r choice || choice=""
|
|
189
|
+
case "${choice:-1}" in
|
|
190
|
+
1|claude) launch_agent claude "$dir" "$pid" ;;
|
|
191
|
+
2|cursor) launch_agent cursor "$dir" "$pid" ;;
|
|
192
|
+
0|none|"") label "Skipped. Start later: cd \"$dir\" && claude \"<session-start>\"" ;;
|
|
193
|
+
*) warn "Unknown choice — skipped. Start later: cd \"$dir\" && claude \"<session-start>\"" ;;
|
|
194
|
+
esac
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
# ── GitHub-definitive ownership + repo helpers (manage/work redesign) ─────────
|
|
198
|
+
# No cache: ownership = the project's anchor-issue assignees; repos = the board's
|
|
199
|
+
# issues' repos. All read live from GitHub. See prj-manage-work-redesign-spec.md.
|
|
200
|
+
ANCHOR_LABEL="anchor"
|
|
201
|
+
|
|
202
|
+
# anchor_issue_ref <project_number> [owner] → "owner/repo#number" of the board's
|
|
203
|
+
# anchor issue (labelled 'anchor'), or empty. owner defaults to $GITHUB_ORG.
|
|
204
|
+
anchor_issue_ref() {
|
|
205
|
+
local num="$1" owner="${2:-$GITHUB_ORG}"
|
|
206
|
+
gh api graphql -f query='query($o:String!,$n:Int!){ organization(login:$o){ projectV2(number:$n){ items(first:100){ nodes{ content{ __typename ... on Issue { number repository{nameWithOwner} labels(first:30){nodes{name}} } } } } } } }' \
|
|
207
|
+
-F o="$owner" -F n="$num" --jq '
|
|
208
|
+
[ .data.organization.projectV2.items.nodes[].content
|
|
209
|
+
| select(.__typename=="Issue")
|
|
210
|
+
| select(([.labels.nodes[].name] | index("anchor")) != null)
|
|
211
|
+
| "\(.repository.nameWithOwner)#\(.number)" ] | (.[0] // "")' 2>/dev/null
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
# project_owners <project_number> [owner] → anchor-issue assignee logins (the
|
|
215
|
+
# definitive, readable owners — no cache).
|
|
216
|
+
project_owners() {
|
|
217
|
+
local ref; ref="$(anchor_issue_ref "$@")"
|
|
218
|
+
[[ -z "$ref" ]] && return 0
|
|
219
|
+
gh issue view "${ref##*#}" --repo "${ref%%#*}" --json assignees --jq '.assignees[].login' 2>/dev/null
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
# my_projects → anchor issues assigned to me (TSV: owner/repo#num \t title).
|
|
223
|
+
# "my projects" as one definitive query; map to a board via issue.projectItems.
|
|
224
|
+
my_projects() {
|
|
225
|
+
local login; login="$(gh api user --jq .login 2>/dev/null || echo "")"
|
|
226
|
+
[[ -z "$login" ]] && return 0
|
|
227
|
+
# Flag form, NOT the inline "label:.. assignee:.. org:.." query string — the
|
|
228
|
+
# inline multi-qualifier form silently returns empty for this combination
|
|
229
|
+
# (verified against issue #78); the flags are reliable.
|
|
230
|
+
gh search issues --owner "$GITHUB_ORG" --label "$ANCHOR_LABEL" --assignee "$login" \
|
|
231
|
+
--json number,title,repository \
|
|
232
|
+
--jq '.[] | "\(.repository.nameWithOwner)#\(.number)\t\(.title)"' 2>/dev/null
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
# my_github_project_numbers → the GitHub Project NUMBERS of boards whose anchor
|
|
236
|
+
# issue is assigned to me (ownership, GitHub-definitive). Resolves each anchor
|
|
237
|
+
# issue I'm assigned to → its board(s) via issue.projectItems. Empty if none
|
|
238
|
+
# (or on API error) — callers fall back to the registry assigned_to cache so
|
|
239
|
+
# nav never breaks before anchors are labelled. One number per line, deduped.
|
|
240
|
+
my_github_project_numbers() {
|
|
241
|
+
local login repo num
|
|
242
|
+
login="$(gh api user --jq .login 2>/dev/null || echo "")"
|
|
243
|
+
[[ -z "$login" ]] && return 0
|
|
244
|
+
while IFS=$'\t' read -r repo num; do
|
|
245
|
+
[[ -z "$repo" || -z "$num" ]] && continue
|
|
246
|
+
gh api graphql -f query='query($o:String!,$r:String!,$n:Int!){ repository(owner:$o,name:$r){ issue(number:$n){ projectItems(first:20){ nodes{ ... on ProjectV2Item { project{ number } } } } } } }' \
|
|
247
|
+
-F o="${repo%%/*}" -F r="${repo##*/}" -F n="$num" \
|
|
248
|
+
--jq '.data.repository.issue.projectItems.nodes[].project.number' 2>/dev/null
|
|
249
|
+
done < <(gh search issues --owner "$GITHUB_ORG" --label "$ANCHOR_LABEL" --assignee "$login" \
|
|
250
|
+
--json number,repository \
|
|
251
|
+
--jq '.[] | "\(.repository.nameWithOwner)\t\(.number)"' 2>/dev/null) | sort -un
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
# project_repos <project_number> [owner] → distinct repos of the board's items
|
|
255
|
+
# (the project's repo set, derived — no repos[]; workspace repo is implicit).
|
|
256
|
+
project_repos() {
|
|
257
|
+
local num="$1" owner="${2:-$GITHUB_ORG}"
|
|
258
|
+
gh api graphql -f query='query($o:String!,$n:Int!){ organization(login:$o){ projectV2(number:$n){ items(first:100){ nodes{ content{ ... on Issue { repository{nameWithOwner} } ... on PullRequest { repository{nameWithOwner} } } } } } } }' \
|
|
259
|
+
-F o="$owner" -F n="$num" --jq '
|
|
260
|
+
[ .data.organization.projectV2.items.nodes[].content | .repository.nameWithOwner | select(.) ] | unique | .[]' 2>/dev/null
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
# set_owner <add|remove> <project_number> <login> [owner]
|
|
264
|
+
# Atomic owner change: (1) anchor-issue assignee (readable owner) + (2) project
|
|
265
|
+
# write access (authorization gate, via project-access.sh). Fail-closed.
|
|
266
|
+
set_owner() {
|
|
267
|
+
local action="$1" num="$2" login="$3" owner="${4:-$GITHUB_ORG}"
|
|
268
|
+
local ref repo inum url aflag pa
|
|
269
|
+
ref="$(anchor_issue_ref "$num" "$owner")"
|
|
270
|
+
[[ -z "$ref" ]] && { warn "No '$ANCHOR_LABEL' issue on project #$num — designate one first (prj manage)."; return 1; }
|
|
271
|
+
repo="${ref%%#*}"; inum="${ref##*#}"
|
|
272
|
+
url="https://github.com/orgs/$owner/projects/$num"
|
|
273
|
+
case "$action" in
|
|
274
|
+
add) aflag="--add-assignee"; pa="grant" ;;
|
|
275
|
+
remove) aflag="--remove-assignee"; pa="revoke" ;;
|
|
276
|
+
*) warn "set_owner: action must be add|remove"; return 1 ;;
|
|
277
|
+
esac
|
|
278
|
+
gh issue edit "$inum" --repo "$repo" "$aflag" "$login" >/dev/null 2>&1 \
|
|
279
|
+
|| { warn "Failed to $action anchor-issue assignee $login on $repo#$inum"; return 1; }
|
|
280
|
+
bash "$SCRIPTS/project-access.sh" "$pa" "$url" "$login" >/dev/null 2>&1 \
|
|
281
|
+
|| warn "Assignee updated, but board write $pa failed — reconcile: bash scripts/project-access.sh $pa $url $login"
|
|
282
|
+
ok "${action}: owner $login on project #$num (anchor $repo#$inum + board $pa)"
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
# pid_for_project_number <num> → the registry project id whose github_project URL
|
|
286
|
+
# ends in /projects/<num>, or empty if the board isn't seeded locally yet.
|
|
287
|
+
pid_for_project_number() {
|
|
288
|
+
python3 - "$REGISTRY" "$1" <<'PY'
|
|
289
|
+
import sys, re, yaml
|
|
290
|
+
reg, num = sys.argv[1], sys.argv[2]
|
|
291
|
+
d = yaml.safe_load(open(reg)) or {}
|
|
292
|
+
for p in (d.get('projects') or []):
|
|
293
|
+
if not p: continue
|
|
294
|
+
m = re.search(r'/projects/(\d+)', p.get('github_project') or '')
|
|
295
|
+
if m and m.group(1) == num:
|
|
296
|
+
print(p.get('id', '')); break
|
|
297
|
+
PY
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
# i_can_work_project <project_number> [owner] → 0 if the current GitHub user is a
|
|
301
|
+
# project owner (anchor-issue assignee) OR is assigned to any of the board's issues
|
|
302
|
+
# (GitHub-definitive authorization for `work`, no cache).
|
|
303
|
+
i_can_work_project() {
|
|
304
|
+
local num="$1" owner="${2:-$GITHUB_ORG}" me o
|
|
305
|
+
me="$(gh api user --jq .login 2>/dev/null || echo "")"
|
|
306
|
+
[[ -z "$me" ]] && return 1
|
|
307
|
+
while IFS= read -r o; do [[ "$o" == "$me" ]] && return 0; done < <(project_owners "$num" "$owner")
|
|
308
|
+
gh api graphql -f query='query($o:String!,$n:Int!){ organization(login:$o){ projectV2(number:$n){ items(first:100){ nodes{ content{ ... on Issue { assignees(first:20){nodes{login}} } } } } } } }' \
|
|
309
|
+
-F o="$owner" -F n="$num" \
|
|
310
|
+
--jq '.data.organization.projectV2.items.nodes[].content.assignees.nodes[].login' 2>/dev/null \
|
|
311
|
+
| grep -qx "$me" && return 0
|
|
312
|
+
return 1
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
# i_can_write_board <project_number> [owner] → 0 if I have write access to the
|
|
316
|
+
# board (projectV2.viewerCanUpdate). Returns 0 (allow) when the check is
|
|
317
|
+
# indeterminate — e.g. a user-owned board where the org query yields null — so
|
|
318
|
+
# we never block on an unreadable signal; the downstream GitHub mutations
|
|
319
|
+
# (seed/create-task/merge/close, project-access grant) remain the hard gate.
|
|
320
|
+
i_can_write_board() {
|
|
321
|
+
local num="$1" owner="${2:-$GITHUB_ORG}" vcu
|
|
322
|
+
vcu=$(gh api graphql -f query='query($o:String!,$n:Int!){ organization(login:$o){ projectV2(number:$n){ viewerCanUpdate } } }' \
|
|
323
|
+
-F o="$owner" -F n="$num" --jq '.data.organization.projectV2.viewerCanUpdate' 2>/dev/null || echo "")
|
|
324
|
+
[[ "$vcu" == "false" ]] && return 1
|
|
325
|
+
return 0
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
# ensure_issue_repo <owner/repo> <project_id> <project_branch> [base]
|
|
329
|
+
# Repo-on-demand: if the issue's repo isn't in the project workspace, bring it in
|
|
330
|
+
# (clone/worktree on the project branch). No repos[]. base = DEFAULT_CODE_BRANCH.
|
|
331
|
+
ensure_issue_repo() {
|
|
332
|
+
local repo="$1" pid="$2" branch="$3" base="${4:-${DEFAULT_CODE_BRANCH:-dev}}"
|
|
333
|
+
local name dir
|
|
334
|
+
name="${repo##*/}"
|
|
335
|
+
dir="$AGENT_WORK_ROOT/$pid/$name"
|
|
336
|
+
if [[ -e "$dir/.git" ]]; then return 0; fi
|
|
337
|
+
info "Bringing in $repo on-demand (base $base) → $dir"
|
|
338
|
+
bash "$SCRIPTS/add-repo.sh" "$pid" "https://github.com/$repo" "dependency" \
|
|
339
|
+
"auto: brought in on-demand for an issue" "$base" 2>/dev/null \
|
|
340
|
+
|| warn "Could not auto-add $repo (check access). It will be needed to work its issues."
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
# ── Registry-elimination derivation helpers (#57 / registry-elimination-spec) ──
|
|
344
|
+
# GitHub is the sole SoT; the registry survives only as a FROZEN legacy shim
|
|
345
|
+
# (grandfather: PRJ-001…013, whose ids/branches predate scheme B). Every helper
|
|
346
|
+
# below checks the shim FIRST (by board number → its stored field) and otherwise
|
|
347
|
+
# derives the value from GitHub. New projects are never in the shim → fully derived.
|
|
348
|
+
# These are pure/read-only and not yet wired into the live flows (Increment 1).
|
|
349
|
+
|
|
350
|
+
# slugify_str <text> — same rule as lib.sh slugify (prj does not source lib.sh).
|
|
351
|
+
slugify_str() {
|
|
352
|
+
echo "$1" | tr '[:upper:]' '[:lower:]' \
|
|
353
|
+
| sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//'
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
# legacy_shim_field <board_number> <field> → the stored registry value for the
|
|
357
|
+
# legacy project on that board, or empty if the board isn't a legacy project.
|
|
358
|
+
# field ∈ id | branch | status | assigned_to (anything in the registry entry).
|
|
359
|
+
legacy_shim_field() {
|
|
360
|
+
python3 - "$REGISTRY" "$1" "$2" <<'PY'
|
|
361
|
+
import sys, re, yaml
|
|
362
|
+
reg, num, field = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
363
|
+
d = yaml.safe_load(open(reg)) or {}
|
|
364
|
+
for p in (d.get('projects') or []):
|
|
365
|
+
if not p: continue
|
|
366
|
+
m = re.search(r'/projects/(\d+)', p.get('github_project') or '')
|
|
367
|
+
if m and m.group(1) == num:
|
|
368
|
+
v = p.get(field)
|
|
369
|
+
if v is not None: print(v)
|
|
370
|
+
break
|
|
371
|
+
PY
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
# gh_board_title <board_number> [owner] → the GitHub Project's title (for slug).
|
|
375
|
+
gh_board_title() {
|
|
376
|
+
local num="$1" owner="${2:-$GITHUB_ORG}"
|
|
377
|
+
gh api graphql -f query='query($o:String!,$n:Int!){ organization(login:$o){ projectV2(number:$n){ title } } }' \
|
|
378
|
+
-F o="$owner" -F n="$num" --jq '.data.organization.projectV2.title // ""' 2>/dev/null
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
# derive_project_id <board_number> [owner] → PRJ-<id>. Legacy → stored id;
|
|
382
|
+
# else PRJ-<number>-<slug(title)> (scheme B; id == branch for new projects).
|
|
383
|
+
derive_project_id() {
|
|
384
|
+
local num="$1" owner="${2:-$GITHUB_ORG}" id slug
|
|
385
|
+
id="$(legacy_shim_field "$num" id)"
|
|
386
|
+
[[ -n "$id" ]] && { echo "$id"; return; }
|
|
387
|
+
slug="$(slugify_str "$(gh_board_title "$num" "$owner")")"
|
|
388
|
+
[[ -z "$slug" ]] && return 1
|
|
389
|
+
echo "PRJ-${num}-${slug}"
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
# derive_project_branch <board_number> [owner] → branch. Legacy → stored branch;
|
|
393
|
+
# else the scheme-B branch, which for new projects equals the derived id.
|
|
394
|
+
derive_project_branch() {
|
|
395
|
+
local num="$1" owner="${2:-$GITHUB_ORG}" br
|
|
396
|
+
br="$(legacy_shim_field "$num" branch)"
|
|
397
|
+
[[ -n "$br" ]] && { echo "$br"; return; }
|
|
398
|
+
derive_project_id "$num" "$owner"
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
# anchor_has_label <board_number> <label> [owner] → 0 if the board's anchor issue
|
|
402
|
+
# carries <label>. Used by status derivation (paused / cancelled live as labels).
|
|
403
|
+
anchor_has_label() {
|
|
404
|
+
local num="$1" want="$2" owner="${3:-$GITHUB_ORG}" ref
|
|
405
|
+
ref="$(anchor_issue_ref "$num" "$owner")"
|
|
406
|
+
[[ -z "$ref" ]] && return 1
|
|
407
|
+
gh issue view "${ref##*#}" --repo "${ref%%#*}" --json labels \
|
|
408
|
+
--jq '.labels[].name' 2>/dev/null | grep -qx "$want"
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
# derive_project_status <board_number> [owner] → active|paused|completed|cancelled.
|
|
412
|
+
# Legacy → stored status (don't rewrite history). Else from GitHub:
|
|
413
|
+
# open board: anchor 'paused' → paused else active
|
|
414
|
+
# closed board: anchor 'cancelled'→ cancelled else completed
|
|
415
|
+
derive_project_status() {
|
|
416
|
+
local num="$1" owner="${2:-$GITHUB_ORG}" s closed
|
|
417
|
+
s="$(legacy_shim_field "$num" status)"
|
|
418
|
+
[[ -n "$s" ]] && { echo "$s"; return; }
|
|
419
|
+
closed="$(gh api graphql -f query='query($o:String!,$n:Int!){ organization(login:$o){ projectV2(number:$n){ closed } } }' \
|
|
420
|
+
-F o="$owner" -F n="$num" --jq '.data.organization.projectV2.closed' 2>/dev/null)"
|
|
421
|
+
if [[ "$closed" == "true" ]]; then
|
|
422
|
+
anchor_has_label "$num" cancelled "$owner" && echo "cancelled" || echo "completed"
|
|
423
|
+
else
|
|
424
|
+
anchor_has_label "$num" paused "$owner" && echo "paused" || echo "active"
|
|
425
|
+
fi
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
# derive_workspace_dir <board_number> [owner] → the project's local workspace dir.
|
|
429
|
+
# Legacy → $AGENT_WORK_ROOT/<stored-id> (dirs predate scheme B). Else the existing
|
|
430
|
+
# PRJ-<number>-* dir if one is present (rename-proof glob), else the derived path.
|
|
431
|
+
derive_workspace_dir() {
|
|
432
|
+
local num="$1" owner="${2:-$GITHUB_ORG}" id hit
|
|
433
|
+
id="$(legacy_shim_field "$num" id)"
|
|
434
|
+
[[ -n "$id" ]] && { echo "$AGENT_WORK_ROOT/$id"; return; }
|
|
435
|
+
for hit in "$AGENT_WORK_ROOT/PRJ-${num}-"*; do
|
|
436
|
+
[[ -d "$hit" ]] && { echo "$hit"; return; }
|
|
437
|
+
done
|
|
438
|
+
echo "$AGENT_WORK_ROOT/$(derive_project_id "$num" "$owner")"
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
# ensure_anchor_label <owner/repo> — create the 'anchor' label if it's missing
|
|
442
|
+
# (gh issue edit --add-label fails when the label doesn't exist in the repo).
|
|
443
|
+
ensure_anchor_label() {
|
|
444
|
+
gh label create "$ANCHOR_LABEL" --repo "$1" --color 5319e7 \
|
|
445
|
+
--description "Project scope/anchor issue — its assignees are the project owners" \
|
|
446
|
+
>/dev/null 2>&1 || true
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
# create_anchor_issue <board_number> <board_title> [owner] — create the project's
|
|
450
|
+
# scope/anchor issue in the workspace repo, label it 'anchor', assign the current
|
|
451
|
+
# user, and add it to the board. Echoes "owner/repo#number" (empty on failure).
|
|
452
|
+
create_anchor_issue() {
|
|
453
|
+
local num="$1" title="$2" owner="${3:-$GITHUB_ORG}"
|
|
454
|
+
local repo="$owner/$WORKSPACE_REPO" me url body
|
|
455
|
+
me="$(gh api user --jq .login 2>/dev/null || echo "")"
|
|
456
|
+
ensure_anchor_label "$repo"
|
|
457
|
+
body="Anchor issue for the project on GitHub Project #$num — *$title*.
|
|
458
|
+
|
|
459
|
+
Owners = this issue's assignees (managed via \`prj manage\` / \`prj work\`). Long-lived
|
|
460
|
+
scope marker for the project; closed at project close."
|
|
461
|
+
url="$(gh issue create --repo "$repo" --title "$title: project scope & anchor" \
|
|
462
|
+
--label "$ANCHOR_LABEL" ${me:+--assignee "$me"} --body "$body" 2>/dev/null | tail -1)"
|
|
463
|
+
[[ -z "$url" ]] && return 1
|
|
464
|
+
gh project item-add "$num" --owner "$owner" --url "$url" >/dev/null 2>&1 || true
|
|
465
|
+
echo "${repo}#${url##*/}"
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
# Verify git is configured with an author identity before any write op.
|
|
469
|
+
# Without both user.name and user.email, git commit fails deep in the
|
|
470
|
+
# flow with 'empty ident name' — confusing for users running ./prj
|
|
471
|
+
# interactively. Call this at the top of each write command.
|
|
472
|
+
require_git_identity() {
|
|
473
|
+
local missing=()
|
|
474
|
+
[[ -z "$(git config user.name 2>/dev/null)" ]] && missing+=("user.name")
|
|
475
|
+
[[ -z "$(git config user.email 2>/dev/null)" ]] && missing+=("user.email")
|
|
476
|
+
if [[ ${#missing[@]} -gt 0 ]]; then
|
|
477
|
+
local joined
|
|
478
|
+
joined=$(IFS='+'; echo "${missing[*]}")
|
|
479
|
+
joined=${joined//+/ + }
|
|
480
|
+
err "git $joined not set — required for commits this command will make."
|
|
481
|
+
echo ""
|
|
482
|
+
echo " Set them globally (one-time):"
|
|
483
|
+
for f in "${missing[@]}"; do
|
|
484
|
+
case "$f" in
|
|
485
|
+
user.name) echo " git config --global user.name 'Your Name'" ;;
|
|
486
|
+
user.email) echo " git config --global user.email 'you@example.com'" ;;
|
|
487
|
+
esac
|
|
488
|
+
done
|
|
489
|
+
echo ""
|
|
490
|
+
echo " Or for this repo only:"
|
|
491
|
+
for f in "${missing[@]}"; do
|
|
492
|
+
case "$f" in
|
|
493
|
+
user.name) echo " git -C $(pwd) config user.name 'Your Name'" ;;
|
|
494
|
+
user.email) echo " git -C $(pwd) config user.email 'you@example.com'" ;;
|
|
495
|
+
esac
|
|
496
|
+
done
|
|
497
|
+
echo ""
|
|
498
|
+
exit 1
|
|
499
|
+
fi
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
# ── Prompt helpers ────────────────────────────────────────────────────────────
|
|
503
|
+
|
|
504
|
+
# NOTE: helpers below use unique internal variable names (__rabort_val,
|
|
505
|
+
# __ask_val, __choice_val) to avoid shadowing the caller's __val via
|
|
506
|
+
# bash's dynamic scoping. printf -v writes to the closest local in scope —
|
|
507
|
+
# if both inner and outer scopes declare `local __val`, the inner wins
|
|
508
|
+
# and the read value never propagates back.
|
|
509
|
+
|
|
510
|
+
# Read a line from stdin or exit with a clear message on EOF (Ctrl-D, closed pipe).
|
|
511
|
+
# Sets the named variable. Returns 0 on success; calls exit on EOF.
|
|
512
|
+
_read_or_abort() {
|
|
513
|
+
local __varname="$1" __rabort_val
|
|
514
|
+
if ! IFS= read -r __rabort_val; then
|
|
515
|
+
echo ""
|
|
516
|
+
err "Aborted (no input)."
|
|
517
|
+
exit 1
|
|
518
|
+
fi
|
|
519
|
+
printf -v "$__varname" '%s' "$__rabort_val"
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
# ask <var_name> <prompt> [default]
|
|
523
|
+
ask() {
|
|
524
|
+
local __var="$1" __prompt="$2" __default="${3:-}" __ask_val
|
|
525
|
+
if [[ -n "$__default" ]]; then
|
|
526
|
+
printf "${BOLD}%s${NC} ${DIM}[%s]${NC}: " "$__prompt" "$__default"
|
|
527
|
+
else
|
|
528
|
+
printf "${BOLD}%s${NC}: " "$__prompt"
|
|
529
|
+
fi
|
|
530
|
+
_read_or_abort __ask_val
|
|
531
|
+
__ask_val="${__ask_val:-$__default}"
|
|
532
|
+
while [[ -z "$__ask_val" ]]; do
|
|
533
|
+
printf "${RED}Required.${NC} ${BOLD}%s${NC}: " "$__prompt"
|
|
534
|
+
_read_or_abort __ask_val
|
|
535
|
+
done
|
|
536
|
+
printf -v "$__var" '%s' "$__ask_val"
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
# ask_optional <var_name> <prompt> [default]
|
|
540
|
+
ask_optional() {
|
|
541
|
+
local __var="$1" __prompt="$2" __default="${3:-}" __ask_val
|
|
542
|
+
if [[ -n "$__default" ]]; then
|
|
543
|
+
printf "${BOLD}%s${NC} ${DIM}[%s]${NC}: " "$__prompt" "$__default"
|
|
544
|
+
else
|
|
545
|
+
printf "${BOLD}%s${NC} ${DIM}(optional)${NC}: " "$__prompt"
|
|
546
|
+
fi
|
|
547
|
+
_read_or_abort __ask_val
|
|
548
|
+
printf -v "$__var" '%s' "${__ask_val:-$__default}"
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
# ask_choice <var_name> <prompt> <option1> <option2> ...
|
|
552
|
+
# Always offers '0) ← back'; on 0/b/back the var is set to $BACK and the function
|
|
553
|
+
# returns 0 (callers must check is_back to step backward / return to the menu).
|
|
554
|
+
ask_choice() {
|
|
555
|
+
local __var="$1" __prompt="$2"; shift 2
|
|
556
|
+
local __opts=("$@") __i __choice_val
|
|
557
|
+
echo -e "${BOLD}$__prompt${NC}"
|
|
558
|
+
for __i in "${!__opts[@]}"; do
|
|
559
|
+
printf " ${CYAN}%d)${NC} %s\n" $((__i+1)) "${__opts[$__i]}"
|
|
560
|
+
done
|
|
561
|
+
printf " ${DIM}0) ← back${NC}\n"
|
|
562
|
+
while true; do
|
|
563
|
+
printf "Choose [1-%d, 0=back]: " "${#__opts[@]}"
|
|
564
|
+
_read_or_abort __choice_val
|
|
565
|
+
if [[ "$__choice_val" == "0" || "$__choice_val" == "b" || "$__choice_val" == "back" ]]; then
|
|
566
|
+
printf -v "$__var" '%s' "$BACK"; return
|
|
567
|
+
fi
|
|
568
|
+
if [[ "$__choice_val" =~ ^[0-9]+$ ]] && (( __choice_val >= 1 && __choice_val <= ${#__opts[@]} )); then
|
|
569
|
+
printf -v "$__var" '%s' "${__opts[$((__choice_val-1))]}"
|
|
570
|
+
return
|
|
571
|
+
fi
|
|
572
|
+
err "Invalid choice."
|
|
573
|
+
done
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
# confirm <prompt> — exits non-zero if user says anything other than yes,
|
|
577
|
+
# including EOF/empty input.
|
|
578
|
+
confirm() {
|
|
579
|
+
local _ans
|
|
580
|
+
printf "${YELLOW}%s${NC} ${DIM}[y/N]${NC}: " "$*"
|
|
581
|
+
if ! IFS= read -r _ans; then
|
|
582
|
+
echo ""
|
|
583
|
+
echo "Aborted (no input)."
|
|
584
|
+
exit 1
|
|
585
|
+
fi
|
|
586
|
+
if [[ "$_ans" != [yY] && "$_ans" != [yY][eE][sS] ]]; then
|
|
587
|
+
echo "Aborted."
|
|
588
|
+
exit 1
|
|
589
|
+
fi
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
# ── Project list helpers ──────────────────────────────────────────────────────
|
|
593
|
+
|
|
594
|
+
# select_project <var_name> [status_filter...] (no filter = all)
|
|
595
|
+
select_project() {
|
|
596
|
+
local __var="$1"; shift
|
|
597
|
+
local __filters=("$@")
|
|
598
|
+
|
|
599
|
+
local __projects
|
|
600
|
+
__projects=$(python3 - "$REGISTRY" "${__filters[@]:-}" <<'PY'
|
|
601
|
+
import sys, yaml
|
|
602
|
+
registry = sys.argv[1]
|
|
603
|
+
filters = sys.argv[2:]
|
|
604
|
+
c = yaml.safe_load(open(registry))
|
|
605
|
+
projects = c.get('projects') or []
|
|
606
|
+
for p in projects:
|
|
607
|
+
if not p:
|
|
608
|
+
continue
|
|
609
|
+
if not filters or p.get('status') in filters:
|
|
610
|
+
print(f"{p['id']}|{p.get('status','?')}")
|
|
611
|
+
PY
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
if [[ -z "$__projects" ]]; then
|
|
615
|
+
err "No matching projects found."
|
|
616
|
+
exit 1
|
|
617
|
+
fi
|
|
618
|
+
|
|
619
|
+
local __ids=()
|
|
620
|
+
while IFS='|' read -r id status; do
|
|
621
|
+
__ids+=("$id")
|
|
622
|
+
done <<< "$__projects"
|
|
623
|
+
|
|
624
|
+
echo -e "${BOLD}Select project:${NC}"
|
|
625
|
+
for i in "${!__ids[@]}"; do
|
|
626
|
+
printf " ${CYAN}%d)${NC} %s ${DIM}(%s)${NC}\n" $((i+1)) \
|
|
627
|
+
"${__ids[$i]}" \
|
|
628
|
+
"$(echo "$__projects" | sed -n "$((i+1))p" | cut -d'|' -f2)"
|
|
629
|
+
done
|
|
630
|
+
printf " ${DIM}0) ← back${NC}\n"
|
|
631
|
+
|
|
632
|
+
local __choice
|
|
633
|
+
while true; do
|
|
634
|
+
printf "Choose [1-%d, 0=back]: " "${#__ids[@]}"
|
|
635
|
+
read -r __choice
|
|
636
|
+
if [[ "$__choice" == "0" || "$__choice" == "b" || "$__choice" == "back" ]]; then
|
|
637
|
+
printf -v "$__var" '%s' "$BACK"; return
|
|
638
|
+
fi
|
|
639
|
+
if [[ "$__choice" =~ ^[0-9]+$ ]] && (( __choice >= 1 && __choice <= ${#__ids[@]} )); then
|
|
640
|
+
printf -v "$__var" '%s' "${__ids[$((__choice-1))]}"
|
|
641
|
+
return
|
|
642
|
+
fi
|
|
643
|
+
err "Invalid choice."
|
|
644
|
+
done
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
# select_project_ctx <var> <assignment> <lifecycle> — navigation-context picker (#57).
|
|
648
|
+
# Filters via project_context_list (lib.sh): assignment ∈ me|unassigned|any,
|
|
649
|
+
# lifecycle ∈ not-initiated|initiated|paused|ongoing|done|any. Sets <var> to the
|
|
650
|
+
# chosen project id (or, for not-initiated rows, the GitHub project URL).
|
|
651
|
+
select_project_ctx() {
|
|
652
|
+
local __var="$1" __assignment="$2" __lifecycle="$3"
|
|
653
|
+
local __rows __vals=() __labels=()
|
|
654
|
+
__rows=$(project_context_list "$__assignment" "$__lifecycle")
|
|
655
|
+
if [[ -z "$__rows" ]]; then
|
|
656
|
+
err "No projects match (assignment=$__assignment, lifecycle=$__lifecycle)."
|
|
657
|
+
exit 1
|
|
658
|
+
fi
|
|
659
|
+
local id url status who
|
|
660
|
+
while IFS=$'\t' read -r id url status who; do
|
|
661
|
+
[[ -z "$id$url" ]] && continue
|
|
662
|
+
if [[ "$id" == "(not seeded)" ]]; then
|
|
663
|
+
__vals+=("$url"); __labels+=("$url ${DIM}($status)${NC}")
|
|
664
|
+
else
|
|
665
|
+
__vals+=("$id"); __labels+=("$id ${DIM}($status${who:+, $who})${NC}")
|
|
666
|
+
fi
|
|
667
|
+
done <<< "$__rows"
|
|
668
|
+
echo -e "${BOLD}Select project:${NC}"
|
|
669
|
+
local i
|
|
670
|
+
for i in "${!__labels[@]}"; do
|
|
671
|
+
printf " ${CYAN}%d)${NC} %b\n" $((i+1)) "${__labels[$i]}"
|
|
672
|
+
done
|
|
673
|
+
printf " ${DIM}0) ← back${NC}\n"
|
|
674
|
+
local __choice
|
|
675
|
+
while true; do
|
|
676
|
+
printf "Choose [1-%d, 0=back]: " "${#__vals[@]}"
|
|
677
|
+
read -r __choice || { err "No input."; exit 1; }
|
|
678
|
+
if [[ "$__choice" == "0" || "$__choice" == "b" || "$__choice" == "back" ]]; then
|
|
679
|
+
printf -v "$__var" '%s' "$BACK"; return
|
|
680
|
+
fi
|
|
681
|
+
if [[ "$__choice" =~ ^[0-9]+$ ]] && (( __choice >= 1 && __choice <= ${#__vals[@]} )); then
|
|
682
|
+
printf -v "$__var" '%s' "${__vals[$((__choice-1))]}"
|
|
683
|
+
return
|
|
684
|
+
fi
|
|
685
|
+
err "Invalid choice."
|
|
686
|
+
done
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
# select_task <var_name> <project_id>
|
|
690
|
+
# Tasks-on-board: active tasks are OPEN (non-Done) issues on the project board.
|
|
691
|
+
# Sets <var_name> to the chosen issue URL (merge-task derives the sub-branch).
|
|
692
|
+
select_task() {
|
|
693
|
+
local __var="$1" __pid="$2"
|
|
694
|
+
local __pf="$SCRIPT_DIR/projects/$__pid/project.yaml"
|
|
695
|
+
local __url
|
|
696
|
+
__url=$(python3 -c "import yaml; print(yaml.safe_load(open('$__pf')).get('github_project') or '')" 2>/dev/null)
|
|
697
|
+
if [[ -z "$__url" || "$__url" == "~" ]]; then
|
|
698
|
+
err "No github_project on $__pid (run merge from the project's PRJ_GOV clone)."
|
|
699
|
+
exit 1
|
|
700
|
+
fi
|
|
701
|
+
local __num __owner
|
|
702
|
+
__num=$(echo "$__url" | grep -oE '/projects/[0-9]+' | grep -oE '[0-9]+')
|
|
703
|
+
if echo "$__url" | grep -q '/orgs/'; then
|
|
704
|
+
__owner=$(echo "$__url" | sed 's|.*/orgs/\([^/]*\)/.*|\1|')
|
|
705
|
+
else
|
|
706
|
+
__owner=$(echo "$__url" | sed 's|.*/users/\([^/]*\)/.*|\1|')
|
|
707
|
+
fi
|
|
708
|
+
local __rows
|
|
709
|
+
__rows=$(gh project item-list "$__num" --owner "$__owner" --format json --limit 200 2>/dev/null | python3 -c "
|
|
710
|
+
import sys, json
|
|
711
|
+
try: d = json.load(sys.stdin)
|
|
712
|
+
except Exception: sys.exit(0)
|
|
713
|
+
for i in d.get('items', []):
|
|
714
|
+
c = i.get('content') or {}
|
|
715
|
+
if c.get('type') == 'Issue' and str(i.get('status','')).strip().lower() != 'done':
|
|
716
|
+
u = c.get('url'); t = c.get('title','')
|
|
717
|
+
if u: print(u + '\t' + t)
|
|
718
|
+
")
|
|
719
|
+
if [[ -z "$__rows" ]]; then
|
|
720
|
+
err "No active (open) task issues on the board for $__pid."
|
|
721
|
+
exit 1
|
|
722
|
+
fi
|
|
723
|
+
|
|
724
|
+
local __urls=() __titles=()
|
|
725
|
+
while IFS=$'\t' read -r u t; do
|
|
726
|
+
[[ -n "$u" ]] && __urls+=("$u") && __titles+=("$t")
|
|
727
|
+
done <<< "$__rows"
|
|
728
|
+
|
|
729
|
+
echo -e "${BOLD}Select task (open issue):${NC}"
|
|
730
|
+
for i in "${!__urls[@]}"; do
|
|
731
|
+
printf " ${CYAN}%d)${NC} %s ${DIM}%s${NC}\n" $((i+1)) "${__titles[$i]}" "${__urls[$i]}"
|
|
732
|
+
done
|
|
733
|
+
printf " ${DIM}0) ← back${NC}\n"
|
|
734
|
+
|
|
735
|
+
local __choice
|
|
736
|
+
while true; do
|
|
737
|
+
printf "Choose [1-%d, 0=back]: " "${#__urls[@]}"
|
|
738
|
+
read -r __choice
|
|
739
|
+
if [[ "$__choice" == "0" || "$__choice" == "b" || "$__choice" == "back" ]]; then
|
|
740
|
+
printf -v "$__var" '%s' "$BACK"; return
|
|
741
|
+
fi
|
|
742
|
+
if [[ "$__choice" =~ ^[0-9]+$ ]] && (( __choice >= 1 && __choice <= ${#__urls[@]} )); then
|
|
743
|
+
printf -v "$__var" '%s' "${__urls[$((__choice-1))]}"
|
|
744
|
+
return
|
|
745
|
+
fi
|
|
746
|
+
err "Invalid choice."
|
|
747
|
+
done
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
# ── Commands ──────────────────────────────────────────────────────────────────
|
|
751
|
+
|
|
752
|
+
cmd_init() {
|
|
753
|
+
header "Initialize New Project"
|
|
754
|
+
divider
|
|
755
|
+
echo ""
|
|
756
|
+
|
|
757
|
+
# Ask for GitHub owner, defaulting to config value
|
|
758
|
+
ask GH_OWNER "GitHub org or username" "$GITHUB_ORG"
|
|
759
|
+
echo ""
|
|
760
|
+
label "Fetching GitHub Projects for ${GH_OWNER}..."
|
|
761
|
+
|
|
762
|
+
# Fetch projects — gh project list works for both orgs and users
|
|
763
|
+
PROJECTS_JSON=$(gh project list --owner "$GH_OWNER" --format json 2>/dev/null) \
|
|
764
|
+
|| PROJECTS_JSON=""
|
|
765
|
+
|
|
766
|
+
PROJECT_COUNT=$(echo "$PROJECTS_JSON" | python3 -c \
|
|
767
|
+
"import sys,json; print(len(json.load(sys.stdin).get('projects',[])))" 2>/dev/null || echo "0")
|
|
768
|
+
|
|
769
|
+
if [[ -z "$PROJECTS_JSON" ]] || [[ "$PROJECT_COUNT" == "0" ]]; then
|
|
770
|
+
err "No open GitHub Projects found for '$GH_OWNER'."
|
|
771
|
+
echo ""
|
|
772
|
+
label "Possible reasons:"
|
|
773
|
+
label " • Projects exist but are closed — check: gh project list --owner $GH_OWNER --closed"
|
|
774
|
+
label " • Wrong owner — try the org name instead of a username (or vice versa)"
|
|
775
|
+
label " • No projects created yet — create one at: https://github.com/orgs/${GH_OWNER}/projects/new"
|
|
776
|
+
echo ""
|
|
777
|
+
confirm "Try a different owner?"
|
|
778
|
+
cmd_init
|
|
779
|
+
return
|
|
780
|
+
fi
|
|
781
|
+
|
|
782
|
+
# Parse and display projects (bash 3.2 compatible — no mapfile)
|
|
783
|
+
local TITLES=() URLS=() NUMS=()
|
|
784
|
+
while IFS= read -r line; do TITLES+=("$line"); done < <(echo "$PROJECTS_JSON" | python3 -c "
|
|
785
|
+
import sys, json
|
|
786
|
+
ps = json.load(sys.stdin).get('projects', [])
|
|
787
|
+
for p in ps: print(p.get('title', '(untitled)'))
|
|
788
|
+
")
|
|
789
|
+
while IFS= read -r line; do URLS+=("$line"); done < <(echo "$PROJECTS_JSON" | python3 -c "
|
|
790
|
+
import sys, json
|
|
791
|
+
ps = json.load(sys.stdin).get('projects', [])
|
|
792
|
+
for p in ps: print(p.get('url', ''))
|
|
793
|
+
")
|
|
794
|
+
while IFS= read -r line; do NUMS+=("$line"); done < <(echo "$PROJECTS_JSON" | python3 -c "
|
|
795
|
+
import sys, json
|
|
796
|
+
ps = json.load(sys.stdin).get('projects', [])
|
|
797
|
+
for p in ps: print(p.get('number', ''))
|
|
798
|
+
")
|
|
799
|
+
|
|
800
|
+
echo -e "${BOLD}Select a GitHub Project to initialize:${NC}"
|
|
801
|
+
for i in "${!TITLES[@]}"; do
|
|
802
|
+
printf " ${CYAN}%d)${NC} %s\n" $((i+1)) "${TITLES[$i]}"
|
|
803
|
+
done
|
|
804
|
+
echo ""
|
|
805
|
+
|
|
806
|
+
local CHOICE
|
|
807
|
+
while true; do
|
|
808
|
+
printf "Choose [1-%d]: " "${#TITLES[@]}"
|
|
809
|
+
read -r CHOICE
|
|
810
|
+
if [[ "$CHOICE" =~ ^[0-9]+$ ]] && (( CHOICE >= 1 && CHOICE <= ${#TITLES[@]} )); then
|
|
811
|
+
break
|
|
812
|
+
fi
|
|
813
|
+
err "Invalid choice."
|
|
814
|
+
done
|
|
815
|
+
|
|
816
|
+
local SELECTED_TITLE="${TITLES[$((CHOICE-1))]}"
|
|
817
|
+
local SELECTED_URL="${URLS[$((CHOICE-1))]}"
|
|
818
|
+
|
|
819
|
+
# ── Phase 3 (ADR-0001): authorization = GitHub Project access ──────────────
|
|
820
|
+
# The seeder must have write access to the project's GitHub Project board (an
|
|
821
|
+
# owner grants it via './prj manage assign'). seed.sh enforces this
|
|
822
|
+
# authoritatively; here we check early for a friendly message. We no longer
|
|
823
|
+
# consult YAML pre_assignments — GitHub access is the source of truth.
|
|
824
|
+
local PNUM VCU
|
|
825
|
+
PNUM=$(printf '%s' "$SELECTED_URL" | sed -nE 's#.*/projects/([0-9]+).*#\1#p')
|
|
826
|
+
VCU=$(gh api graphql -f query="query{ organization(login:\"$GH_OWNER\"){ projectV2(number:$PNUM){ viewerCanUpdate } } }" \
|
|
827
|
+
--jq '.data.organization.projectV2.viewerCanUpdate' 2>/dev/null || echo "")
|
|
828
|
+
if [[ "$VCU" == "false" ]]; then
|
|
829
|
+
err "You don't have write access to this project's GitHub Project board."
|
|
830
|
+
err "Ask an owner to grant access (./prj manage assign), then retry."
|
|
831
|
+
exit 1
|
|
832
|
+
fi # VCU empty = couldn't check (e.g. user project); seed.sh is the gate.
|
|
833
|
+
|
|
834
|
+
echo ""
|
|
835
|
+
echo -e " ${BOLD}Project:${NC} $SELECTED_TITLE"
|
|
836
|
+
echo -e " ${BOLD}URL:${NC} $SELECTED_URL"
|
|
837
|
+
echo ""
|
|
838
|
+
|
|
839
|
+
# The recorded assignee is a display/audit cache (authorization is GitHub
|
|
840
|
+
# access). Default to the seeder's GitHub login; accept a login or @team.
|
|
841
|
+
local CURRENT_LOGIN
|
|
842
|
+
CURRENT_LOGIN=$(gh api user --jq .login 2>/dev/null || echo "")
|
|
843
|
+
ask ASSIGNEE "Assignee (GitHub login or @team)" "${CURRENT_LOGIN:-$CURRENT_USER}"
|
|
844
|
+
echo ""
|
|
845
|
+
confirm "Initialize '$SELECTED_TITLE' assigned to '$ASSIGNEE'?"
|
|
846
|
+
echo ""
|
|
847
|
+
bash "$SCRIPTS/seed.sh" "$SELECTED_URL" "$ASSIGNEE" || return 1
|
|
848
|
+
|
|
849
|
+
# #57: resolve the just-seeded project, then offer to launch the chosen agent
|
|
850
|
+
# in its worktree with the session-start protocol pre-run.
|
|
851
|
+
local NEW_PID NEW_DIR
|
|
852
|
+
NEW_PID=$(python3 -c "import yaml; d=yaml.safe_load(open('$REGISTRY')) or {}; print(next((p['id'] for p in (d.get('projects') or []) if p and p.get('github_project')=='$SELECTED_URL'), ''))" 2>/dev/null)
|
|
853
|
+
if [[ -n "$NEW_PID" ]]; then
|
|
854
|
+
NEW_DIR="$(org_gov_clone "$NEW_PID")"
|
|
855
|
+
prompt_and_launch_agent "$NEW_DIR" "$NEW_PID"
|
|
856
|
+
fi
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
cmd_join() {
|
|
860
|
+
header "Join Project"
|
|
861
|
+
divider
|
|
862
|
+
label "Clone your own PRJ_GOV + code repos for an existing project (team member)."
|
|
863
|
+
echo ""
|
|
864
|
+
select_project_ctx PROJECT_ID me ongoing # join = my active/paused projects (#57)
|
|
865
|
+
is_back "$PROJECT_ID" && return
|
|
866
|
+
echo ""
|
|
867
|
+
confirm "Join '$PROJECT_ID' — clone gov + code repos on its branch?"
|
|
868
|
+
echo ""
|
|
869
|
+
bash "$SCRIPTS/join.sh" "$PROJECT_ID"
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
# ── Phase-1 developer verb facade (ADR-0001) ─────────────────────────────────
|
|
873
|
+
# A smaller, work-focused vocabulary layered over the existing lifecycle
|
|
874
|
+
# commands. The old verbs keep working unchanged; these just give developers
|
|
875
|
+
# one front door so they don't have to know join vs init vs task vs merge vs
|
|
876
|
+
# close. No topology change yet — worktrees / GitHub-as-source-of-truth land
|
|
877
|
+
# in later ADR-0001 phases.
|
|
878
|
+
|
|
879
|
+
cmd_start() {
|
|
880
|
+
header "Start Work"
|
|
881
|
+
divider
|
|
882
|
+
label "One front door to get to work — routes to the right lifecycle command."
|
|
883
|
+
echo ""
|
|
884
|
+
local __what
|
|
885
|
+
ask_choice __what "What do you want to start?" \
|
|
886
|
+
"Join a project I'm assigned to" \
|
|
887
|
+
"Start a task (parallel work on a GitHub issue)" \
|
|
888
|
+
"Initialize a brand-new project"
|
|
889
|
+
is_back "$__what" && return
|
|
890
|
+
echo ""
|
|
891
|
+
case "$__what" in
|
|
892
|
+
"Join a project I'm assigned to") cmd_join ;;
|
|
893
|
+
"Start a task (parallel work on a GitHub issue)") cmd_task ;;
|
|
894
|
+
"Initialize a brand-new project") cmd_init ;;
|
|
895
|
+
esac
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
# cmd_work — GitHub-definitive Work/Finish front door (#57 redesign §4).
|
|
899
|
+
# Work: select board → owner-or-assignee gate → ensure seeded+worktree →
|
|
900
|
+
# Existing branch (switch+sync) or New branch (pick issue[s], create
|
|
901
|
+
# task branch, repo-on-demand) → launch agent with session-start.
|
|
902
|
+
# Finish: delegated to cmd_finish (task submit / project close).
|
|
903
|
+
# cmd_work — project-first front door (redesigned per the user flow; work now
|
|
904
|
+
# absorbs init/join/task/finish):
|
|
905
|
+
# STEP 1 pick an open GitHub Project to work on.
|
|
906
|
+
# STEP 1.1 ensure it has an anchor issue WITH an owner (create/designate +
|
|
907
|
+
# claim ownership if not).
|
|
908
|
+
# STEP 1.2 initiated (seeded + cloned) ?
|
|
909
|
+
# 1.2.1 NO → seed/clone all the way → launch agent + session-start. END.
|
|
910
|
+
# 1.2.2 YES → pick a branch (project branch + task branches).
|
|
911
|
+
# 1.2.3 → Continue (open in agent) / Task (new task branch) /
|
|
912
|
+
# Finish (project branch → close, else → merge this task).
|
|
913
|
+
# Back-navigable: every step offers '0) ← back'; state persists in outer locals.
|
|
914
|
+
cmd_work() {
|
|
915
|
+
header "Work"
|
|
916
|
+
divider
|
|
917
|
+
echo ""
|
|
918
|
+
local PNUM PTITLE PID DIR BRANCH SEL ACT me yn
|
|
919
|
+
local step=pick
|
|
920
|
+
while true; do
|
|
921
|
+
case "$step" in
|
|
922
|
+
|
|
923
|
+
pick) # STEP 1 — choose a project (all open GitHub Projects)
|
|
924
|
+
_pick_open_project PNUM PTITLE
|
|
925
|
+
is_back "$PNUM" && return
|
|
926
|
+
# Early authorization: fail fast with a friendly message if you can't write
|
|
927
|
+
# the board, rather than deep in seed/anchor/merge. Indeterminate = allow.
|
|
928
|
+
if ! i_can_write_board "$PNUM"; then
|
|
929
|
+
err "You don't have write access to '$PTITLE' (its GitHub Project board)."
|
|
930
|
+
err "Ask an owner to grant access (./prj manage), then retry."
|
|
931
|
+
echo ""; step=pick; continue
|
|
932
|
+
fi
|
|
933
|
+
echo ""
|
|
934
|
+
step=anchor ;;
|
|
935
|
+
|
|
936
|
+
anchor) # STEP 1.1 — ensure an anchor issue WITH an owner
|
|
937
|
+
local ref owners how
|
|
938
|
+
ref="$(anchor_issue_ref "$PNUM")"
|
|
939
|
+
owners="$(project_owners "$PNUM")"
|
|
940
|
+
if [[ -n "$ref" && -n "$owners" ]]; then
|
|
941
|
+
info "Anchor: $ref · owner(s): $(echo "$owners" | paste -sd ',' - | sed 's/,/, /g')"
|
|
942
|
+
else
|
|
943
|
+
warn "'$PTITLE' has no anchor issue with an owner yet — let's set it up."
|
|
944
|
+
if [[ -z "$ref" ]]; then
|
|
945
|
+
ask_choice how "Set up its scope/anchor issue:" \
|
|
946
|
+
"Create a new scope/anchor issue" "Designate an existing board issue"
|
|
947
|
+
is_back "$how" && { echo ""; step=pick; continue; }
|
|
948
|
+
if [[ "$how" == Create* ]]; then
|
|
949
|
+
ref="$(create_anchor_issue "$PNUM" "$PTITLE")"
|
|
950
|
+
[[ -z "$ref" ]] && { err "Could not create the anchor issue (check access)."; return 1; }
|
|
951
|
+
ok "Anchor created: $ref"
|
|
952
|
+
else
|
|
953
|
+
_pick_board_issue ref "$PNUM"
|
|
954
|
+
is_back "$ref" && { echo ""; step=pick; continue; }
|
|
955
|
+
ensure_anchor_label "${ref%%#*}"
|
|
956
|
+
gh issue edit "${ref##*#}" --repo "${ref%%#*}" --add-label "$ANCHOR_LABEL" >/dev/null 2>&1 \
|
|
957
|
+
&& ok "Designated $ref as the anchor." || { err "Could not label $ref."; return 1; }
|
|
958
|
+
fi
|
|
959
|
+
fi
|
|
960
|
+
owners="$(project_owners "$PNUM")"
|
|
961
|
+
if [[ -z "$owners" ]]; then
|
|
962
|
+
me="$(gh api user --jq .login 2>/dev/null || echo "")"
|
|
963
|
+
[[ -z "$me" ]] && { err "Could not resolve your GitHub login."; return 1; }
|
|
964
|
+
ask_choice yn "Make you ('$me') the owner of '$PTITLE'?" "Yes — claim ownership"
|
|
965
|
+
is_back "$yn" && { echo ""; step=pick; continue; }
|
|
966
|
+
set_owner add "$PNUM" "$me"
|
|
967
|
+
fi
|
|
968
|
+
fi
|
|
969
|
+
echo ""
|
|
970
|
+
step=route ;;
|
|
971
|
+
|
|
972
|
+
route) # STEP 1.2 — initiated (seeded + cloned locally) or not?
|
|
973
|
+
PID="$(pid_for_project_number "$PNUM")"
|
|
974
|
+
if [[ -z "$PID" ]]; then
|
|
975
|
+
# 1.2.1 NOT initiated → seed all the way to the agent + session-start.
|
|
976
|
+
require_git_identity
|
|
977
|
+
me="$(gh api user --jq .login 2>/dev/null || echo "$CURRENT_USER")"
|
|
978
|
+
info "Initializing '$PTITLE' (seed → branch → clone)…"
|
|
979
|
+
bash "$SCRIPTS/seed.sh" "https://github.com/orgs/$GITHUB_ORG/projects/$PNUM" "$me" || return 1
|
|
980
|
+
PID="$(pid_for_project_number "$PNUM")"
|
|
981
|
+
[[ -z "$PID" ]] && { err "Seed did not register the project."; return 1; }
|
|
982
|
+
DIR="$(org_gov_clone "$PID")"
|
|
983
|
+
prompt_and_launch_agent "$DIR" "$PID"
|
|
984
|
+
return
|
|
985
|
+
fi
|
|
986
|
+
DIR="$(org_gov_clone "$PID")"
|
|
987
|
+
BRANCH="$(project_branch "$PID")"
|
|
988
|
+
if [[ ! -e "$DIR/.git" ]]; then
|
|
989
|
+
# Seeded by someone, but no local clone yet → clone all the way + launch.
|
|
990
|
+
require_git_identity
|
|
991
|
+
info "Cloning your workspace for '$PID'…"
|
|
992
|
+
bash "$SCRIPTS/join.sh" "$PID" || return 1
|
|
993
|
+
prompt_and_launch_agent "$DIR" "$PID"
|
|
994
|
+
return
|
|
995
|
+
fi
|
|
996
|
+
echo ""
|
|
997
|
+
step=branch ;;
|
|
998
|
+
|
|
999
|
+
branch) # STEP 1.2.2 — pick a branch (project branch + task branches)
|
|
1000
|
+
local brs=() b i c
|
|
1001
|
+
while IFS= read -r b; do [[ -n "$b" ]] && brs+=("$b"); done < <(
|
|
1002
|
+
git -C "$DIR" for-each-ref --format='%(refname:short)' \
|
|
1003
|
+
"refs/heads/$BRANCH" "refs/heads/$BRANCH.*" 2>/dev/null)
|
|
1004
|
+
if [[ ${#brs[@]} -eq 0 ]]; then err "No local branches for '$PID'."; echo ""; step=pick; continue; fi
|
|
1005
|
+
echo -e "${BOLD}Select branch:${NC}"
|
|
1006
|
+
for i in "${!brs[@]}"; do
|
|
1007
|
+
if [[ "${brs[$i]}" == "$BRANCH" ]]; then
|
|
1008
|
+
printf " ${CYAN}%d)${NC} %s ${DIM}(project branch)${NC}\n" $((i+1)) "${brs[$i]}"
|
|
1009
|
+
else
|
|
1010
|
+
printf " ${CYAN}%d)${NC} %s\n" $((i+1)) "${brs[$i]}"
|
|
1011
|
+
fi
|
|
1012
|
+
done
|
|
1013
|
+
printf " ${DIM}0) ← back${NC}\n"
|
|
1014
|
+
SEL=""
|
|
1015
|
+
while true; do
|
|
1016
|
+
printf "Choose [1-%d, 0=back]: " "${#brs[@]}"; _read_or_abort c
|
|
1017
|
+
if [[ "$c" == "0" || "$c" == "b" || "$c" == "back" ]]; then SEL="$BACK"; break; fi
|
|
1018
|
+
if [[ "$c" =~ ^[0-9]+$ ]] && (( c>=1 && c<=${#brs[@]} )); then SEL="${brs[$((c-1))]}"; break; fi
|
|
1019
|
+
err "Invalid choice."
|
|
1020
|
+
done
|
|
1021
|
+
is_back "$SEL" && { echo ""; step=pick; continue; }
|
|
1022
|
+
echo ""
|
|
1023
|
+
step=action ;;
|
|
1024
|
+
|
|
1025
|
+
action) # STEP 1.2.3 — Continue / Task / Finish on the selected branch
|
|
1026
|
+
local fin_label
|
|
1027
|
+
if [[ "$SEL" == "$BRANCH" ]]; then fin_label="Finish — close the project"; else fin_label="Finish — merge this task into the project branch"; fi
|
|
1028
|
+
ask_choice ACT "On '$SEL' — what next?" \
|
|
1029
|
+
"Continue — open this branch in an agent" \
|
|
1030
|
+
"Task — start a new task branch (pick issue[s])" \
|
|
1031
|
+
"$fin_label"
|
|
1032
|
+
is_back "$ACT" && { echo ""; step=branch; continue; }
|
|
1033
|
+
echo ""
|
|
1034
|
+
case "$ACT" in
|
|
1035
|
+
Continue*)
|
|
1036
|
+
git -C "$DIR" checkout "$SEL" >/dev/null 2>&1 || { err "Could not switch to '$SEL'."; return 1; }
|
|
1037
|
+
ok "On branch '$SEL'."
|
|
1038
|
+
prompt_and_launch_agent "$DIR" "$PID"
|
|
1039
|
+
return ;;
|
|
1040
|
+
Task*)
|
|
1041
|
+
# New task branch — pick open board issue(s) without a local task branch.
|
|
1042
|
+
local refs=() titles=() ref title n sel picks=() tok urls=() bad=0 r joined
|
|
1043
|
+
while IFS=$'\t' read -r ref title; do
|
|
1044
|
+
[[ -z "$ref" ]] && continue
|
|
1045
|
+
n="${ref##*#}"
|
|
1046
|
+
git -C "$DIR" rev-parse --verify "refs/heads/$BRANCH.ISSUE-$n" &>/dev/null && continue
|
|
1047
|
+
refs+=("$ref"); titles+=("$title")
|
|
1048
|
+
done < <(
|
|
1049
|
+
gh api graphql -f query='query($o:String!,$n:Int!){ organization(login:$o){ projectV2(number:$n){ items(first:100){ nodes{ content{ ... on Issue { number title state repository{nameWithOwner} } } } } } } }' \
|
|
1050
|
+
-F o="$GITHUB_ORG" -F n="$PNUM" \
|
|
1051
|
+
--jq '.data.organization.projectV2.items.nodes[].content | select(.number) | select(.state=="OPEN") | "\(.repository.nameWithOwner)#\(.number)\t\(.title)"' 2>/dev/null)
|
|
1052
|
+
if [[ ${#refs[@]} -eq 0 ]]; then err "No open board issues without a local branch."; echo ""; step=action; continue; fi
|
|
1053
|
+
echo -e "${BOLD}Select issue(s)${NC} ${DIM}— one, or several (e.g. 1,3) for a combined branch:${NC}"
|
|
1054
|
+
for i in "${!refs[@]}"; do printf " ${CYAN}%d)${NC} %s ${DIM}%s${NC}\n" $((i+1)) "${titles[$i]}" "${refs[$i]}"; done
|
|
1055
|
+
printf " ${DIM}0) ← back${NC}\n"
|
|
1056
|
+
ask_optional sel "Choose [1-${#refs[@]}, 0=back]" ""
|
|
1057
|
+
if [[ -z "$sel" || "$sel" == "0" || "$sel" == "b" || "$sel" == "back" ]]; then echo ""; step=action; continue; fi
|
|
1058
|
+
IFS=', ' read -ra picks <<< "$sel"
|
|
1059
|
+
for tok in "${picks[@]}"; do
|
|
1060
|
+
[[ -z "$tok" ]] && continue
|
|
1061
|
+
if ! [[ "$tok" =~ ^[0-9]+$ ]] || (( tok<1 || tok>${#refs[@]} )); then err "Invalid selection: '$tok'."; bad=1; break; fi
|
|
1062
|
+
r="${refs[$((tok-1))]}"; urls+=("https://github.com/${r%%#*}/issues/${r##*#}")
|
|
1063
|
+
done
|
|
1064
|
+
(( bad )) && { echo ""; step=action; continue; }
|
|
1065
|
+
[[ ${#urls[@]} -eq 0 ]] && { err "Nothing selected."; echo ""; step=action; continue; }
|
|
1066
|
+
require_git_identity
|
|
1067
|
+
me="$(gh api user --jq .login 2>/dev/null || echo "$CURRENT_USER")"
|
|
1068
|
+
joined="$(IFS=,; echo "${urls[*]}")"
|
|
1069
|
+
ask_choice yn "Create a task branch for ${#urls[@]} issue(s) and assign to '$me'?" "Yes — create the branch"
|
|
1070
|
+
is_back "$yn" && { echo ""; step=action; continue; }
|
|
1071
|
+
echo ""
|
|
1072
|
+
bash "$SCRIPTS/create-task.sh" "$PID" "$joined" "$me" || return 1
|
|
1073
|
+
prompt_and_launch_agent "$DIR" "$PID"
|
|
1074
|
+
return ;;
|
|
1075
|
+
Finish*)
|
|
1076
|
+
if [[ "$SEL" == "$BRANCH" ]]; then
|
|
1077
|
+
ask_choice yn "Close project '$PID'? Merges all branches to base + triggers close-knowledge." "Yes — close the project"
|
|
1078
|
+
is_back "$yn" && { echo ""; step=action; continue; }
|
|
1079
|
+
echo ""
|
|
1080
|
+
bash "$SCRIPTS/close-project.sh" "$PID"
|
|
1081
|
+
else
|
|
1082
|
+
ask_choice yn "Merge task branch '$SEL' into '$BRANCH' and close its issue(s)?" "Yes — merge the task"
|
|
1083
|
+
is_back "$yn" && { echo ""; step=action; continue; }
|
|
1084
|
+
echo ""
|
|
1085
|
+
bash "$SCRIPTS/merge-task.sh" "$PID" "$SEL"
|
|
1086
|
+
fi
|
|
1087
|
+
return ;;
|
|
1088
|
+
esac ;;
|
|
1089
|
+
|
|
1090
|
+
esac
|
|
1091
|
+
done
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
cmd_finish() {
|
|
1095
|
+
# 'finish' is context-aware: finishing a *task* hands it back to the project
|
|
1096
|
+
# branch (merge); finishing the *project* runs the governance close gate
|
|
1097
|
+
# (unchanged). The old 'submit'/'merge'/'close' verbs still work underneath.
|
|
1098
|
+
header "Finish"
|
|
1099
|
+
divider
|
|
1100
|
+
label "Finish a task (hand it back) or finish the whole project (close)."
|
|
1101
|
+
echo ""
|
|
1102
|
+
local __what
|
|
1103
|
+
ask_choice __what "What are you finishing?" \
|
|
1104
|
+
"A task — submit it back to the project branch" \
|
|
1105
|
+
"The whole project — close it (governance gate)"
|
|
1106
|
+
is_back "$__what" && return
|
|
1107
|
+
echo ""
|
|
1108
|
+
case "$__what" in
|
|
1109
|
+
"A task — submit it back to the project branch") cmd_merge ;;
|
|
1110
|
+
"The whole project — close it (governance gate)") cmd_close ;;
|
|
1111
|
+
esac
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
cmd_task() {
|
|
1115
|
+
header "Create Task"
|
|
1116
|
+
divider
|
|
1117
|
+
label "Creates a sub-branch for parallel work on a specific GitHub Issue."
|
|
1118
|
+
echo ""
|
|
1119
|
+
select_project_ctx PROJECT_ID me initiated
|
|
1120
|
+
is_back "$PROJECT_ID" && return
|
|
1121
|
+
echo ""
|
|
1122
|
+
ask ISSUE_URL "GitHub Issue URL"
|
|
1123
|
+
ask ASSIGNEE "Assignee email" "$CURRENT_USER"
|
|
1124
|
+
echo ""
|
|
1125
|
+
confirm "Create task for issue '$ISSUE_URL' assigned to '$ASSIGNEE'?"
|
|
1126
|
+
echo ""
|
|
1127
|
+
bash "$SCRIPTS/create-task.sh" "$PROJECT_ID" "$ISSUE_URL" "$ASSIGNEE"
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
cmd_merge() {
|
|
1131
|
+
header "Merge Task"
|
|
1132
|
+
divider
|
|
1133
|
+
label "Merges a completed task sub-branch back to the project branch and closes the issue."
|
|
1134
|
+
echo ""
|
|
1135
|
+
select_project_ctx PROJECT_ID me initiated
|
|
1136
|
+
is_back "$PROJECT_ID" && return
|
|
1137
|
+
echo ""
|
|
1138
|
+
select_task ISSUE_URL "$PROJECT_ID"
|
|
1139
|
+
is_back "$ISSUE_URL" && return
|
|
1140
|
+
echo ""
|
|
1141
|
+
confirm "Merge the task for issue '$ISSUE_URL' into the project branch?"
|
|
1142
|
+
echo ""
|
|
1143
|
+
bash "$SCRIPTS/merge-task.sh" "$PROJECT_ID" "$ISSUE_URL"
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
cmd_pause() {
|
|
1147
|
+
header "Pause Project"
|
|
1148
|
+
divider
|
|
1149
|
+
label "Commits and pushes all changes, then marks the project as paused."
|
|
1150
|
+
echo ""
|
|
1151
|
+
select_project_ctx PROJECT_ID me initiated
|
|
1152
|
+
is_back "$PROJECT_ID" && return
|
|
1153
|
+
echo ""
|
|
1154
|
+
confirm "Pause '$PROJECT_ID'?"
|
|
1155
|
+
echo ""
|
|
1156
|
+
bash "$SCRIPTS/pause.sh" "$PROJECT_ID"
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
cmd_resume() {
|
|
1160
|
+
header "Resume Project"
|
|
1161
|
+
divider
|
|
1162
|
+
label "Syncs all branches with their base, then marks the project as active."
|
|
1163
|
+
echo ""
|
|
1164
|
+
select_project_ctx PROJECT_ID me paused # resume = my paused projects (#57)
|
|
1165
|
+
is_back "$PROJECT_ID" && return
|
|
1166
|
+
echo ""
|
|
1167
|
+
confirm "Resume '$PROJECT_ID'? (base branches will be merged in)"
|
|
1168
|
+
echo ""
|
|
1169
|
+
bash "$SCRIPTS/resume.sh" "$PROJECT_ID"
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
cmd_sync() {
|
|
1173
|
+
header "Sync Project"
|
|
1174
|
+
divider
|
|
1175
|
+
label "Merges latest base branches into all project branches mid-project."
|
|
1176
|
+
echo ""
|
|
1177
|
+
select_project_ctx PROJECT_ID me initiated
|
|
1178
|
+
is_back "$PROJECT_ID" && return
|
|
1179
|
+
echo ""
|
|
1180
|
+
confirm "Sync '$PROJECT_ID' with latest base branches?"
|
|
1181
|
+
echo ""
|
|
1182
|
+
bash "$SCRIPTS/sync.sh" "$PROJECT_ID"
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
cmd_add_repo() {
|
|
1186
|
+
header "Add Repository to Project"
|
|
1187
|
+
divider
|
|
1188
|
+
label "Adds a new repo to an active project when scope expands."
|
|
1189
|
+
echo ""
|
|
1190
|
+
select_project_ctx PROJECT_ID me initiated
|
|
1191
|
+
is_back "$PROJECT_ID" && return
|
|
1192
|
+
echo ""
|
|
1193
|
+
ask REPO_URL "Repository URL"
|
|
1194
|
+
ask_choice ROLE "Role for this repo" "primary" "dependency" "read-only"
|
|
1195
|
+
is_back "$ROLE" && return
|
|
1196
|
+
ask ADDED_REASON "Why is this repo being added?"
|
|
1197
|
+
ask_optional BASE_BRANCH "Base branch" "dev"
|
|
1198
|
+
echo ""
|
|
1199
|
+
confirm "Add '$REPO_URL' ($ROLE) to '$PROJECT_ID'?"
|
|
1200
|
+
echo ""
|
|
1201
|
+
bash "$SCRIPTS/add-repo.sh" "$PROJECT_ID" "$REPO_URL" "$ROLE" "$ADDED_REASON" "${BASE_BRANCH:-dev}"
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
cmd_cancel() {
|
|
1205
|
+
header "Cancel Project"
|
|
1206
|
+
divider
|
|
1207
|
+
warn "Cancellation archives all branches. No code is merged. This cannot be undone."
|
|
1208
|
+
echo ""
|
|
1209
|
+
select_project_ctx PROJECT_ID me ongoing # cancel = my active/paused projects (#57)
|
|
1210
|
+
is_back "$PROJECT_ID" && return
|
|
1211
|
+
echo ""
|
|
1212
|
+
ask REASON "Cancellation reason (required — C01)"
|
|
1213
|
+
echo ""
|
|
1214
|
+
confirm "Cancel '$PROJECT_ID'? All branches will be archived, not merged."
|
|
1215
|
+
echo ""
|
|
1216
|
+
bash "$SCRIPTS/cancel.sh" "$PROJECT_ID" "$REASON"
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
cmd_close() {
|
|
1220
|
+
header "Close Project"
|
|
1221
|
+
divider
|
|
1222
|
+
label "Merges all branches to base, archives them, and triggers a knowledge close PR."
|
|
1223
|
+
echo ""
|
|
1224
|
+
select_project_ctx PROJECT_ID me initiated
|
|
1225
|
+
is_back "$PROJECT_ID" && return
|
|
1226
|
+
echo ""
|
|
1227
|
+
confirm "Close '$PROJECT_ID'? This will merge all branches and trigger close-knowledge."
|
|
1228
|
+
echo ""
|
|
1229
|
+
bash "$SCRIPTS/close-project.sh" "$PROJECT_ID"
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
cmd_knowledge() {
|
|
1233
|
+
header "Propose Org Knowledge"
|
|
1234
|
+
divider
|
|
1235
|
+
label "Creates a knowledge branch for ad-hoc org knowledge proposals."
|
|
1236
|
+
echo ""
|
|
1237
|
+
|
|
1238
|
+
local SUB_CMD
|
|
1239
|
+
ask_choice SUB_CMD "What would you like to do?" \
|
|
1240
|
+
"Create a new knowledge proposal branch" \
|
|
1241
|
+
"Submit PR for an existing knowledge branch" \
|
|
1242
|
+
"Archive a merged knowledge branch"
|
|
1243
|
+
is_back "$SUB_CMD" && return
|
|
1244
|
+
|
|
1245
|
+
case "$SUB_CMD" in
|
|
1246
|
+
"Create a new knowledge proposal branch")
|
|
1247
|
+
ask SLUG "Branch slug (e.g. api-design-patterns)"
|
|
1248
|
+
ask DESCRIPTION "One-line description of what's being proposed"
|
|
1249
|
+
echo ""
|
|
1250
|
+
confirm "Create branch 'knowledge-$SLUG'?"
|
|
1251
|
+
echo ""
|
|
1252
|
+
bash "$SCRIPTS/propose-knowledge.sh" "$SLUG" "$DESCRIPTION"
|
|
1253
|
+
;;
|
|
1254
|
+
"Submit PR for an existing knowledge branch")
|
|
1255
|
+
ask SLUG "Branch slug (the part after 'knowledge-')"
|
|
1256
|
+
ask DESCRIPTION "PR description"
|
|
1257
|
+
echo ""
|
|
1258
|
+
confirm "Create PR for 'knowledge-$SLUG'?"
|
|
1259
|
+
echo ""
|
|
1260
|
+
bash "$SCRIPTS/propose-knowledge.sh" "$SLUG" "$DESCRIPTION" --submit
|
|
1261
|
+
;;
|
|
1262
|
+
"Archive a merged knowledge branch")
|
|
1263
|
+
ask SLUG "Branch slug (the part after 'knowledge-')"
|
|
1264
|
+
echo ""
|
|
1265
|
+
confirm "Archive 'knowledge-$SLUG'?"
|
|
1266
|
+
echo ""
|
|
1267
|
+
bash "$SCRIPTS/propose-knowledge.sh" "$SLUG" "" --archive
|
|
1268
|
+
;;
|
|
1269
|
+
esac
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
cmd_onboard() {
|
|
1273
|
+
header "Onboard Repository"
|
|
1274
|
+
divider
|
|
1275
|
+
label "Initializes a knowledge/ folder in an existing repo and raises a PR."
|
|
1276
|
+
echo ""
|
|
1277
|
+
ask REPO_URL "Repository URL"
|
|
1278
|
+
ask DESCRIPTION "One-line description of what this repo does"
|
|
1279
|
+
ask OWNER "Repo owner (team or individual)"
|
|
1280
|
+
echo ""
|
|
1281
|
+
confirm "Onboard '$REPO_URL'?"
|
|
1282
|
+
echo ""
|
|
1283
|
+
bash "$SCRIPTS/onboard-repo.sh" "$REPO_URL" "$DESCRIPTION" "$OWNER"
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
cmd_list() {
|
|
1287
|
+
# Two registry-backed views (the registry is the SoT):
|
|
1288
|
+
# prj list → ongoing only (active/paused) — stays readable as the
|
|
1289
|
+
# registry grows; completed/cancelled are hidden.
|
|
1290
|
+
# prj list-all → every project, newest first (reverse chronological by NNN).
|
|
1291
|
+
local mode="open"
|
|
1292
|
+
case "${1:-}" in all|--all|-a) mode="all" ;; esac
|
|
1293
|
+
if [[ "$mode" == "all" ]]; then header "All Projects (newest first)"; else header "Ongoing Projects (active / paused)"; fi
|
|
1294
|
+
divider
|
|
1295
|
+
python3 - "$REGISTRY" "$mode" <<'PY'
|
|
1296
|
+
import sys, re, yaml
|
|
1297
|
+
registry, mode = sys.argv[1], sys.argv[2]
|
|
1298
|
+
doc = yaml.safe_load(open(registry)) or {}
|
|
1299
|
+
projects = [p for p in (doc.get('projects') or []) if p]
|
|
1300
|
+
status_colour = {
|
|
1301
|
+
'active': '\033[0;32m',
|
|
1302
|
+
'paused': '\033[1;33m',
|
|
1303
|
+
'completed': '\033[0;36m',
|
|
1304
|
+
'cancelled': '\033[0;31m',
|
|
1305
|
+
}
|
|
1306
|
+
NC = '\033[0m'; BOLD = '\033[1m'; DIM = '\033[2m'
|
|
1307
|
+
ONGOING = {'active', 'paused'}
|
|
1308
|
+
def nnn(p):
|
|
1309
|
+
m = re.search(r'PRJ-(\d+)', p.get('id', '') or '')
|
|
1310
|
+
return int(m.group(1)) if m else -1
|
|
1311
|
+
if mode == 'all':
|
|
1312
|
+
rows = sorted(projects, key=nnn, reverse=True) # newest first
|
|
1313
|
+
else:
|
|
1314
|
+
rows = sorted([p for p in projects if (p.get('status') or '') in ONGOING], key=nnn)
|
|
1315
|
+
if not rows:
|
|
1316
|
+
print(" No ongoing projects." if mode != 'all' else " No projects found.")
|
|
1317
|
+
else:
|
|
1318
|
+
for p in rows:
|
|
1319
|
+
s = p.get('status', '?')
|
|
1320
|
+
col = status_colour.get(s, '')
|
|
1321
|
+
a = p.get('assigned_to', '') or ''
|
|
1322
|
+
print(f" {BOLD}{p['id']}{NC} {col}{s}{NC} {DIM}{a}{NC}")
|
|
1323
|
+
if mode != 'all':
|
|
1324
|
+
hidden = sum(1 for p in projects if (p.get('status') or '') not in ONGOING)
|
|
1325
|
+
if hidden:
|
|
1326
|
+
print(f"\n {DIM}{hidden} completed/cancelled hidden — 'prj list-all' to show all.{NC}")
|
|
1327
|
+
PY
|
|
1328
|
+
echo ""
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
cmd_status() {
|
|
1332
|
+
header "Project Status"
|
|
1333
|
+
divider
|
|
1334
|
+
echo ""
|
|
1335
|
+
select_project PROJECT_ID
|
|
1336
|
+
is_back "$PROJECT_ID" && return
|
|
1337
|
+
local PF="$SCRIPT_DIR/projects/$PROJECT_ID/project.yaml"
|
|
1338
|
+
echo ""
|
|
1339
|
+
divider
|
|
1340
|
+
if [[ ! -f "$PF" ]]; then
|
|
1341
|
+
# Active project: the manifest lives on the project branch, not on the
|
|
1342
|
+
# default branch where Gov.local rests. Show the registry index summary
|
|
1343
|
+
# (authoritative for status / assignee).
|
|
1344
|
+
python3 - "$REGISTRY" "$PROJECT_ID" <<'PY'
|
|
1345
|
+
import sys, yaml
|
|
1346
|
+
reg, pid = sys.argv[1], sys.argv[2]
|
|
1347
|
+
c = yaml.safe_load(open(reg)) or {}
|
|
1348
|
+
e = next((p for p in (c.get('projects') or []) if p and p.get('id') == pid), None)
|
|
1349
|
+
BOLD='\033[1m'; NC='\033[0m'; DIM='\033[2m'
|
|
1350
|
+
if not e:
|
|
1351
|
+
print(f" {pid}: not found in registry."); sys.exit(0)
|
|
1352
|
+
print(f" {BOLD}ID:{NC} {e.get('id','')}")
|
|
1353
|
+
print(f" {BOLD}Status:{NC} {e.get('status','')}")
|
|
1354
|
+
print(f" {BOLD}Assigned to:{NC} {e.get('assigned_to','')}")
|
|
1355
|
+
print(f" {BOLD}Seeded by:{NC} {e.get('seeded_by','')}")
|
|
1356
|
+
print(f" {BOLD}Branch:{NC} {e.get('branch','')}")
|
|
1357
|
+
print(f" {BOLD}GitHub:{NC} {e.get('github_project','')}")
|
|
1358
|
+
print(f" {DIM}Full manifest (repos / tasks) lives on the project branch — run from the project's PRJ_GOV clone for detail.{NC}")
|
|
1359
|
+
PY
|
|
1360
|
+
echo ""
|
|
1361
|
+
return
|
|
1362
|
+
fi
|
|
1363
|
+
python3 - "$PF" <<'PY'
|
|
1364
|
+
import sys, yaml
|
|
1365
|
+
BOLD='\033[1m'; NC='\033[0m'; DIM='\033[2m'; GREEN='\033[0;32m'
|
|
1366
|
+
YELLOW='\033[1;33m'; RED='\033[0;31m'; CYAN='\033[0;36m'
|
|
1367
|
+
c = yaml.safe_load(open(sys.argv[1]))
|
|
1368
|
+
status_colour = {'active': GREEN, 'paused': YELLOW, 'completed': CYAN, 'cancelled': RED}
|
|
1369
|
+
s = c.get('status','?')
|
|
1370
|
+
sc = status_colour.get(s,'')
|
|
1371
|
+
print(f" {BOLD}ID:{NC} {c.get('id','')}")
|
|
1372
|
+
print(f" {BOLD}Status:{NC} {sc}{s}{NC}")
|
|
1373
|
+
print(f" {BOLD}Assigned to:{NC} {c.get('assigned_to','')}")
|
|
1374
|
+
print(f" {BOLD}Started:{NC} {c.get('started_at') or '—'}")
|
|
1375
|
+
print(f" {BOLD}Paused:{NC} {c.get('paused_at') or '—'}")
|
|
1376
|
+
print(f" {BOLD}GitHub:{NC} {c.get('github_project','')}")
|
|
1377
|
+
print()
|
|
1378
|
+
repos = [r for r in (c.get('repos') or []) if r and r.get('url')]
|
|
1379
|
+
if repos:
|
|
1380
|
+
print(f" {BOLD}Repos ({len(repos)}):{NC}")
|
|
1381
|
+
for r in repos:
|
|
1382
|
+
print(f" {DIM}•{NC} {r['url']} {DIM}[{r.get('role','?')} / base: {r.get('base_branch','?')}]{NC}")
|
|
1383
|
+
tasks = [t for t in (c.get('tasks') or []) if t and t.get('id')]
|
|
1384
|
+
if tasks:
|
|
1385
|
+
print()
|
|
1386
|
+
print(f" {BOLD}Tasks ({len(tasks)}):{NC}")
|
|
1387
|
+
for t in tasks:
|
|
1388
|
+
ts = t.get('status','?')
|
|
1389
|
+
tc = GREEN if ts == 'completed' else (YELLOW if ts == 'active' else '')
|
|
1390
|
+
print(f" {DIM}•{NC} {t['id']} {tc}{ts}{NC}")
|
|
1391
|
+
PY
|
|
1392
|
+
echo ""
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
cmd_deps() {
|
|
1396
|
+
header "Dependencies"
|
|
1397
|
+
divider
|
|
1398
|
+
echo ""
|
|
1399
|
+
bash "$SCRIPTS/install-deps.sh"
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
cmd_upgrade() {
|
|
1403
|
+
header "Upgrade Framework"
|
|
1404
|
+
divider
|
|
1405
|
+
local target="${1:-}"
|
|
1406
|
+
echo ""
|
|
1407
|
+
label "Fetches a new framework version from the 'template' remote,"
|
|
1408
|
+
label "applies it via framework/bin/setup.sh, and leaves changes staged"
|
|
1409
|
+
label "for review + commit."
|
|
1410
|
+
echo ""
|
|
1411
|
+
|
|
1412
|
+
# Pre-conditions
|
|
1413
|
+
if [[ -n "$(git status --porcelain)" ]]; then
|
|
1414
|
+
err "Working tree has uncommitted changes. Commit or stash first."
|
|
1415
|
+
exit 1
|
|
1416
|
+
fi
|
|
1417
|
+
if ! git remote get-url template &>/dev/null; then
|
|
1418
|
+
err "'template' remote not configured."
|
|
1419
|
+
echo " Add it: git remote add template git@github.com:svayam-opensource/governed-agentic-dev-framework.git"
|
|
1420
|
+
exit 1
|
|
1421
|
+
fi
|
|
1422
|
+
local current_branch
|
|
1423
|
+
current_branch=$(git rev-parse --abbrev-ref HEAD)
|
|
1424
|
+
if [[ "$current_branch" != "$DEFAULT_BRANCH" ]]; then
|
|
1425
|
+
warn "You're on '$current_branch', not '$DEFAULT_BRANCH'. Framework upgrades should happen on the default branch."
|
|
1426
|
+
confirm "Continue anyway?"
|
|
1427
|
+
fi
|
|
1428
|
+
|
|
1429
|
+
# Resolve target version
|
|
1430
|
+
if [[ -z "$target" ]]; then
|
|
1431
|
+
target="template/$DEFAULT_BRANCH"
|
|
1432
|
+
label "No version specified — using $target (latest)"
|
|
1433
|
+
fi
|
|
1434
|
+
|
|
1435
|
+
echo ""
|
|
1436
|
+
label "Fetching from template remote..."
|
|
1437
|
+
git fetch template --tags 2>&1 | tail -3
|
|
1438
|
+
|
|
1439
|
+
echo ""
|
|
1440
|
+
label "Checking out framework/ at $target..."
|
|
1441
|
+
git checkout "$target" -- framework/ \
|
|
1442
|
+
|| { err "git checkout $target failed. Is the tag/branch published?"; exit 1; }
|
|
1443
|
+
|
|
1444
|
+
if [[ ! -d "$SCRIPT_DIR/framework" ]]; then
|
|
1445
|
+
err "framework/ directory was not created by the checkout. Aborting."
|
|
1446
|
+
exit 1
|
|
1447
|
+
fi
|
|
1448
|
+
|
|
1449
|
+
echo ""
|
|
1450
|
+
label "Running framework/bin/setup.sh..."
|
|
1451
|
+
echo ""
|
|
1452
|
+
bash "$SCRIPT_DIR/framework/bin/setup.sh"
|
|
1453
|
+
echo ""
|
|
1454
|
+
|
|
1455
|
+
divider
|
|
1456
|
+
ok "Framework upgrade complete."
|
|
1457
|
+
echo ""
|
|
1458
|
+
echo " Review: git diff HEAD && git status"
|
|
1459
|
+
echo " Commit: git add -A && git commit -m 'framework upgrade'"
|
|
1460
|
+
echo " Push: git push origin $DEFAULT_BRANCH"
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
# ── Manage: project assignment commands ──────────────────────────────────────
|
|
1464
|
+
#
|
|
1465
|
+
# Open to anyone with write access to the workspace repo. GitHub enforces
|
|
1466
|
+
# write access at git push time, so there is no need to gate on identity
|
|
1467
|
+
# here — and pretending we do creates a Policy Owner bottleneck where
|
|
1468
|
+
# none is required. The git audit trail records who assigned whom.
|
|
1469
|
+
|
|
1470
|
+
# Fetch GitHub org members (with email) and teams.
|
|
1471
|
+
# Outputs "value|display" per line.
|
|
1472
|
+
# value = email for members (if public) or @team-slug for teams; empty if email is private
|
|
1473
|
+
# display = "email (login)" | "login (no public email)" | "@team-slug"
|
|
1474
|
+
fetch_gh_assignees() {
|
|
1475
|
+
local owner="${1:-$GITHUB_ORG}"
|
|
1476
|
+
python3 - "$owner" 2>/dev/null <<'PY'
|
|
1477
|
+
import sys, subprocess, json
|
|
1478
|
+
|
|
1479
|
+
owner = sys.argv[1]
|
|
1480
|
+
|
|
1481
|
+
# One GraphQL call to get login + email for all org members
|
|
1482
|
+
gql = ('{ organization(login: "' + owner + '") { membersWithRole(first: 100) '
|
|
1483
|
+
'{ nodes { login email } } } }')
|
|
1484
|
+
gr = subprocess.run(['gh', 'api', 'graphql', '-f', f'query={gql}'],
|
|
1485
|
+
capture_output=True, text=True)
|
|
1486
|
+
members_done = False
|
|
1487
|
+
if gr.returncode == 0 and gr.stdout.strip():
|
|
1488
|
+
try:
|
|
1489
|
+
nodes = (json.loads(gr.stdout)
|
|
1490
|
+
.get('data', {}).get('organization', {})
|
|
1491
|
+
.get('membersWithRole', {}).get('nodes', []))
|
|
1492
|
+
for n in nodes:
|
|
1493
|
+
login = n.get('login', '')
|
|
1494
|
+
email = n.get('email') or ''
|
|
1495
|
+
if email:
|
|
1496
|
+
print(f"{email}|{email} ({login})")
|
|
1497
|
+
else:
|
|
1498
|
+
print(f"|{login} (no public email)")
|
|
1499
|
+
members_done = True
|
|
1500
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
1501
|
+
pass
|
|
1502
|
+
|
|
1503
|
+
if not members_done:
|
|
1504
|
+
# REST fallback — logins only
|
|
1505
|
+
r = subprocess.run(['gh', 'api', f'/orgs/{owner}/members', '--jq', '.[].login'],
|
|
1506
|
+
capture_output=True, text=True)
|
|
1507
|
+
if r.returncode == 0 and r.stdout.strip():
|
|
1508
|
+
for login in r.stdout.strip().splitlines():
|
|
1509
|
+
if login.strip():
|
|
1510
|
+
print(f"|{login.strip()} (no public email)")
|
|
1511
|
+
|
|
1512
|
+
# Org teams
|
|
1513
|
+
tr = subprocess.run(['gh', 'api', f'/orgs/{owner}/teams', '--jq', '.[].slug'],
|
|
1514
|
+
capture_output=True, text=True)
|
|
1515
|
+
if tr.returncode == 0 and tr.stdout.strip():
|
|
1516
|
+
for slug in tr.stdout.strip().splitlines():
|
|
1517
|
+
if slug.strip():
|
|
1518
|
+
print(f"@{slug.strip()}|@{slug.strip()}")
|
|
1519
|
+
PY
|
|
1520
|
+
return ${PIPESTATUS[0]}
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
# Resolve the project branch name from a project_id. Prefers the canonical
|
|
1524
|
+
# value stored in registry.yaml's projects[<id>].branch (which seed.sh writes
|
|
1525
|
+
# at create time). Falls back to deriving from the ID, supporting both the
|
|
1526
|
+
# v0.2.0+ PRJ- prefix and legacy <ORG_SLUG>-NNN-slug projects.
|
|
1527
|
+
project_branch() {
|
|
1528
|
+
local pid="$1" branch
|
|
1529
|
+
branch=$(python3 - "$REGISTRY" "$pid" 2>/dev/null <<'PY'
|
|
1530
|
+
import sys, yaml
|
|
1531
|
+
c = yaml.safe_load(open(sys.argv[1])) or {}
|
|
1532
|
+
for p in (c.get('projects') or []):
|
|
1533
|
+
if p and p.get('id') == sys.argv[2]:
|
|
1534
|
+
b = p.get('branch')
|
|
1535
|
+
if b:
|
|
1536
|
+
print(b); sys.exit(0)
|
|
1537
|
+
sys.exit(1)
|
|
1538
|
+
PY
|
|
1539
|
+
)
|
|
1540
|
+
if [[ -n "$branch" && "$branch" != "null" ]]; then
|
|
1541
|
+
echo "$branch"
|
|
1542
|
+
elif [[ "$pid" == PRJ-* ]]; then
|
|
1543
|
+
# Defensive fallback only: every seeded project stores its branch above, so
|
|
1544
|
+
# this is never hit for a registered project. Scheme B (POL-069,
|
|
1545
|
+
# PRJ-<github_project_number>-<slug>) can't be derived from the registry id
|
|
1546
|
+
# alone (needs the GitHub project number), so the legacy brnch- shape stays
|
|
1547
|
+
# as a last resort for an unregistered/legacy id.
|
|
1548
|
+
echo "brnch-${pid#PRJ-}"
|
|
1549
|
+
else
|
|
1550
|
+
echo "$pid" | tr '[:upper:]' '[:lower:]'
|
|
1551
|
+
fi
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
# Commit registry.yaml changes to the default branch and push.
|
|
1555
|
+
# Caller must already have modified registry.yaml in place. The home workspace
|
|
1556
|
+
# must be on $DEFAULT_BRANCH — project branches live only in per-project
|
|
1557
|
+
# workspaces under $AGENT_WORK_ROOT.
|
|
1558
|
+
commit_registry() {
|
|
1559
|
+
local msg="$1"
|
|
1560
|
+
cd "$SCRIPT_DIR"
|
|
1561
|
+
local current
|
|
1562
|
+
current=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
|
|
1563
|
+
if [[ "$current" != "$DEFAULT_BRANCH" ]]; then
|
|
1564
|
+
err "Home workspace is on '$current' but assignment changes must be made on '$DEFAULT_BRANCH'."
|
|
1565
|
+
err "Switch back: git checkout $DEFAULT_BRANCH"
|
|
1566
|
+
exit 1
|
|
1567
|
+
fi
|
|
1568
|
+
git pull --ff-only origin "$DEFAULT_BRANCH" 2>/dev/null || true
|
|
1569
|
+
git add registry.yaml
|
|
1570
|
+
git commit -m "$msg"
|
|
1571
|
+
git push origin "$DEFAULT_BRANCH"
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
# select_from_owner_projects <id_var> <filter>
|
|
1575
|
+
# filter: "unassigned" | "assigned"
|
|
1576
|
+
# Shows ALL GitHub Projects for owner, filtered by assignment state.
|
|
1577
|
+
# Un-seeded projects use pre_assignments in registry.yaml; seeded projects use project.yaml.
|
|
1578
|
+
# Side-effects: sets GH_OWNER, MANAGE_SELECTED_URL, MANAGE_SELECTED_PID.
|
|
1579
|
+
MANAGE_SELECTED_URL=""
|
|
1580
|
+
MANAGE_SELECTED_PID=""
|
|
1581
|
+
select_from_owner_projects() {
|
|
1582
|
+
local __var="$1" __filter="${2:-unassigned}"
|
|
1583
|
+
MANAGE_SELECTED_URL=""
|
|
1584
|
+
MANAGE_SELECTED_PID=""
|
|
1585
|
+
|
|
1586
|
+
ask GH_OWNER "GitHub org or username" "$GITHUB_ORG"
|
|
1587
|
+
echo ""
|
|
1588
|
+
label "Fetching GitHub Projects for ${GH_OWNER}..."
|
|
1589
|
+
echo ""
|
|
1590
|
+
|
|
1591
|
+
local __raw
|
|
1592
|
+
__raw=$(python3 - "$REGISTRY" "$SCRIPT_DIR" "$GH_OWNER" "$__filter" <<'PY'
|
|
1593
|
+
import sys, os, json, subprocess, yaml
|
|
1594
|
+
|
|
1595
|
+
registry_path, proj_dir, owner, filt = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
|
|
1596
|
+
|
|
1597
|
+
result = subprocess.run(
|
|
1598
|
+
['gh', 'project', 'list', '--owner', owner, '--format', 'json', '--limit', '100'],
|
|
1599
|
+
capture_output=True, text=True
|
|
1600
|
+
)
|
|
1601
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
1602
|
+
sys.exit(0)
|
|
1603
|
+
|
|
1604
|
+
gh_projects = json.loads(result.stdout).get('projects', [])
|
|
1605
|
+
c = yaml.safe_load(open(registry_path)) or {}
|
|
1606
|
+
|
|
1607
|
+
reg_by_url = {}
|
|
1608
|
+
for p in (c.get('projects') or []):
|
|
1609
|
+
if p and p.get('github_project'):
|
|
1610
|
+
reg_by_url[p['github_project']] = p
|
|
1611
|
+
|
|
1612
|
+
pre_by_url = {}
|
|
1613
|
+
for a in (c.get('pre_assignments') or []):
|
|
1614
|
+
if a and a.get('github_project'):
|
|
1615
|
+
pre_by_url[a['github_project']] = a
|
|
1616
|
+
|
|
1617
|
+
for gh in gh_projects:
|
|
1618
|
+
title = (gh.get('title') or '').replace('\t', ' ')
|
|
1619
|
+
url = gh.get('url', '')
|
|
1620
|
+
pid = ''
|
|
1621
|
+
s = ''
|
|
1622
|
+
assignee = ''
|
|
1623
|
+
|
|
1624
|
+
if url in reg_by_url:
|
|
1625
|
+
reg = reg_by_url[url]
|
|
1626
|
+
pid = reg.get('id', '')
|
|
1627
|
+
s = reg.get('status', '')
|
|
1628
|
+
pf = os.path.join(proj_dir, 'projects', pid, 'project.yaml')
|
|
1629
|
+
if os.path.exists(pf):
|
|
1630
|
+
assignee = yaml.safe_load(open(pf)).get('assigned_to') or ''
|
|
1631
|
+
elif url in pre_by_url:
|
|
1632
|
+
assignee = pre_by_url[url].get('assigned_to') or ''
|
|
1633
|
+
|
|
1634
|
+
if filt == 'unassigned' and assignee:
|
|
1635
|
+
continue
|
|
1636
|
+
if filt == 'assigned' and not assignee:
|
|
1637
|
+
continue
|
|
1638
|
+
print(f"{title}\x1f{url}\x1f{pid}\x1f{s}\x1f{assignee}")
|
|
1639
|
+
PY
|
|
1640
|
+
)
|
|
1641
|
+
|
|
1642
|
+
if [[ -z "$__raw" ]]; then
|
|
1643
|
+
case "$__filter" in
|
|
1644
|
+
unassigned) err "No unassigned GitHub Projects found for '${GH_OWNER}'." ;;
|
|
1645
|
+
assigned) err "No assigned projects found for '${GH_OWNER}'." ;;
|
|
1646
|
+
*) err "No matching projects found." ;;
|
|
1647
|
+
esac
|
|
1648
|
+
exit 1
|
|
1649
|
+
fi
|
|
1650
|
+
|
|
1651
|
+
local __titles=() __urls=() __pids=() __statuses=() __assignees=()
|
|
1652
|
+
while IFS=$'\x1f' read -r title url pid status assignee; do
|
|
1653
|
+
[[ -z "$url" ]] && continue
|
|
1654
|
+
__titles+=("$title"); __urls+=("$url"); __pids+=("$pid")
|
|
1655
|
+
__statuses+=("$status"); __assignees+=("$assignee")
|
|
1656
|
+
done <<< "$__raw"
|
|
1657
|
+
|
|
1658
|
+
if [[ ${#__urls[@]} -eq 0 ]]; then
|
|
1659
|
+
case "$__filter" in
|
|
1660
|
+
unassigned) err "No unassigned GitHub Projects found for '${GH_OWNER}'." ;;
|
|
1661
|
+
assigned) err "No assigned projects found for '${GH_OWNER}'." ;;
|
|
1662
|
+
*) err "No matching projects found." ;;
|
|
1663
|
+
esac
|
|
1664
|
+
exit 1
|
|
1665
|
+
fi
|
|
1666
|
+
|
|
1667
|
+
echo -e "${BOLD}Select project:${NC}"
|
|
1668
|
+
local i
|
|
1669
|
+
for i in "${!__urls[@]}"; do
|
|
1670
|
+
local _title="${__titles[$i]}"
|
|
1671
|
+
local _pid="${__pids[$i]}"
|
|
1672
|
+
local _asgn="${__assignees[$i]}"
|
|
1673
|
+
local _pd="${_pid:-(not seeded)}"
|
|
1674
|
+
local _ad="${_asgn:-(unassigned)}"
|
|
1675
|
+
if [[ -n "$_pid" && -n "$_asgn" ]]; then
|
|
1676
|
+
printf " ${CYAN}%d)${NC} %-38s %-24s %s\n" $((i+1)) "$_title" "$_pd" "$_ad"
|
|
1677
|
+
elif [[ -n "$_pid" ]]; then
|
|
1678
|
+
printf " ${CYAN}%d)${NC} %-38s %-24s ${DIM}%s${NC}\n" $((i+1)) "$_title" "$_pd" "$_ad"
|
|
1679
|
+
else
|
|
1680
|
+
printf " ${CYAN}%d)${NC} %-38s ${DIM}%-24s %s${NC}\n" $((i+1)) "$_title" "$_pd" "$_ad"
|
|
1681
|
+
fi
|
|
1682
|
+
done
|
|
1683
|
+
|
|
1684
|
+
local __choice
|
|
1685
|
+
while true; do
|
|
1686
|
+
printf "Choose [1-%d]: " "${#__urls[@]}"
|
|
1687
|
+
read -r __choice
|
|
1688
|
+
if [[ "$__choice" =~ ^[0-9]+$ ]] && (( __choice >= 1 && __choice <= ${#__urls[@]} )); then
|
|
1689
|
+
local __idx=$((__choice-1))
|
|
1690
|
+
printf -v "$__var" '%s' "${__pids[$__idx]}"
|
|
1691
|
+
MANAGE_SELECTED_URL="${__urls[$__idx]}"
|
|
1692
|
+
MANAGE_SELECTED_PID="${__pids[$__idx]}"
|
|
1693
|
+
return
|
|
1694
|
+
fi
|
|
1695
|
+
err "Invalid choice."
|
|
1696
|
+
done
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
# Pick an assignee from org members/teams or enter manually.
|
|
1700
|
+
# Stores email (or @team-slug) in the named variable.
|
|
1701
|
+
select_assignee() {
|
|
1702
|
+
local __var="$1" __owner="${2:-$GITHUB_ORG}"
|
|
1703
|
+
|
|
1704
|
+
label "Fetching org members and teams from ${__owner}..."
|
|
1705
|
+
|
|
1706
|
+
local values=() displays=()
|
|
1707
|
+
while IFS='|' read -r val disp; do
|
|
1708
|
+
values+=("$val")
|
|
1709
|
+
displays+=("$disp")
|
|
1710
|
+
done < <(fetch_gh_assignees "$__owner")
|
|
1711
|
+
|
|
1712
|
+
if [[ ${#displays[@]} -eq 0 ]]; then
|
|
1713
|
+
warn "Could not fetch org members (may not be an org, or insufficient permissions)."
|
|
1714
|
+
ask "$__var" "Assignee email"
|
|
1715
|
+
return
|
|
1716
|
+
fi
|
|
1717
|
+
|
|
1718
|
+
values+=("")
|
|
1719
|
+
displays+=("— enter manually —")
|
|
1720
|
+
|
|
1721
|
+
echo ""
|
|
1722
|
+
echo -e "${BOLD}Select assignee:${NC}"
|
|
1723
|
+
local i
|
|
1724
|
+
for i in "${!displays[@]}"; do
|
|
1725
|
+
printf " ${CYAN}%d)${NC} %s\n" $((i+1)) "${displays[$i]}"
|
|
1726
|
+
done
|
|
1727
|
+
|
|
1728
|
+
local __choice
|
|
1729
|
+
while true; do
|
|
1730
|
+
printf "Choose [1-%d]: " "${#displays[@]}"
|
|
1731
|
+
read -r __choice
|
|
1732
|
+
if [[ "$__choice" =~ ^[0-9]+$ ]] && (( __choice >= 1 && __choice <= ${#displays[@]} )); then
|
|
1733
|
+
local _val="${values[$((__choice-1))]}"
|
|
1734
|
+
local _disp="${displays[$((__choice-1))]}"
|
|
1735
|
+
if [[ "$_disp" == "— enter manually —" || -z "$_val" ]]; then
|
|
1736
|
+
ask "$__var" "Assignee email"
|
|
1737
|
+
else
|
|
1738
|
+
printf -v "$__var" '%s' "$_val"
|
|
1739
|
+
fi
|
|
1740
|
+
return
|
|
1741
|
+
fi
|
|
1742
|
+
err "Invalid choice."
|
|
1743
|
+
done
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
# _pick_open_project <numvar> <titlevar> [owner] — pick from open GitHub projects.
|
|
1747
|
+
_pick_open_project() {
|
|
1748
|
+
local __nv="$1" __tv="$2" owner="${3:-$GITHUB_ORG}" __json __titles=() __nums=()
|
|
1749
|
+
__json=$(gh project list --owner "$owner" --format json --limit 100 2>/dev/null) \
|
|
1750
|
+
|| { err "Could not list GitHub Projects for '$owner'."; exit 1; }
|
|
1751
|
+
while IFS=$'\t' read -r t n; do __titles+=("$t"); __nums+=("$n"); done < <(echo "$__json" | python3 -c "
|
|
1752
|
+
import sys, json
|
|
1753
|
+
for p in json.load(sys.stdin).get('projects', []):
|
|
1754
|
+
print('%s\t%s' % (p.get('title','(untitled)'), p.get('number','')))
|
|
1755
|
+
")
|
|
1756
|
+
[[ ${#__nums[@]} -eq 0 ]] && { err "No open GitHub Projects for '$owner'."; exit 1; }
|
|
1757
|
+
echo -e "${BOLD}Select project:${NC}"
|
|
1758
|
+
local i
|
|
1759
|
+
for i in "${!__titles[@]}"; do printf " ${CYAN}%d)${NC} %s ${DIM}(#%s)${NC}\n" $((i+1)) "${__titles[$i]}" "${__nums[$i]}"; done
|
|
1760
|
+
printf " ${DIM}0) ← back${NC}\n"
|
|
1761
|
+
local c
|
|
1762
|
+
while true; do
|
|
1763
|
+
printf "Choose [1-%d, 0=back]: " "${#__nums[@]}"; _read_or_abort c
|
|
1764
|
+
if [[ "$c" == "0" || "$c" == "b" || "$c" == "back" ]]; then
|
|
1765
|
+
printf -v "$__nv" '%s' "$BACK"; printf -v "$__tv" '%s' "$BACK"; return
|
|
1766
|
+
fi
|
|
1767
|
+
if [[ "$c" =~ ^[0-9]+$ ]] && (( c>=1 && c<=${#__nums[@]} )); then
|
|
1768
|
+
printf -v "$__nv" '%s' "${__nums[$((c-1))]}"; printf -v "$__tv" '%s' "${__titles[$((c-1))]}"; return
|
|
1769
|
+
fi
|
|
1770
|
+
err "Invalid choice."
|
|
1771
|
+
done
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
# _pick_board_issue <refvar> <project_number> [owner] — sets refvar to "owner/repo#number".
|
|
1775
|
+
_pick_board_issue() {
|
|
1776
|
+
local __rv="$1" num="$2" owner="${3:-$GITHUB_ORG}" __refs=() __rows=()
|
|
1777
|
+
while IFS=$'\t' read -r ref title; do [[ -z "$ref" ]] && continue; __refs+=("$ref"); __rows+=("$ref $title"); done < <(
|
|
1778
|
+
gh api graphql -f query='query($o:String!,$n:Int!){ organization(login:$o){ projectV2(number:$n){ items(first:100){ nodes{ content{ ... on Issue { number title repository{nameWithOwner} } } } } } } }' \
|
|
1779
|
+
-F o="$owner" -F n="$num" --jq '.data.organization.projectV2.items.nodes[].content | select(.number) | "\(.repository.nameWithOwner)#\(.number)\t\(.title)"' 2>/dev/null)
|
|
1780
|
+
[[ ${#__refs[@]} -eq 0 ]] && { err "No issues on this board."; exit 1; }
|
|
1781
|
+
echo -e "${BOLD}Select issue:${NC}"
|
|
1782
|
+
local i
|
|
1783
|
+
for i in "${!__rows[@]}"; do printf " ${CYAN}%d)${NC} %s\n" $((i+1)) "${__rows[$i]}"; done
|
|
1784
|
+
printf " ${DIM}0) ← back${NC}\n"
|
|
1785
|
+
local c
|
|
1786
|
+
while true; do
|
|
1787
|
+
printf "Choose [1-%d, 0=back]: " "${#__refs[@]}"; _read_or_abort c
|
|
1788
|
+
if [[ "$c" == "0" || "$c" == "b" || "$c" == "back" ]]; then printf -v "$__rv" '%s' "$BACK"; return; fi
|
|
1789
|
+
if [[ "$c" =~ ^[0-9]+$ ]] && (( c>=1 && c<=${#__refs[@]} )); then printf -v "$__rv" '%s' "${__refs[$((c-1))]}"; return; fi
|
|
1790
|
+
err "Invalid choice."
|
|
1791
|
+
done
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
# cmd_manage_owners — GitHub-definitive assign/reassign/unassign, combined (#57 §3).
|
|
1795
|
+
# Project → owners = anchor-issue assignees (+ board write, via set_owner).
|
|
1796
|
+
# Issue → assignees (native). No cache.
|
|
1797
|
+
cmd_manage_owners() {
|
|
1798
|
+
header "Manage Assignment"
|
|
1799
|
+
divider
|
|
1800
|
+
echo ""
|
|
1801
|
+
local PNUM PTITLE
|
|
1802
|
+
_pick_open_project PNUM PTITLE
|
|
1803
|
+
is_back "$PNUM" && return
|
|
1804
|
+
echo ""
|
|
1805
|
+
local SCOPE
|
|
1806
|
+
ask_choice SCOPE "Manage the Project (owners) or an Issue (assignees)?" "Project (owners)" "Issue (assignees)"
|
|
1807
|
+
is_back "$SCOPE" && return
|
|
1808
|
+
echo ""
|
|
1809
|
+
if [[ "$SCOPE" == "Project (owners)" ]]; then
|
|
1810
|
+
local ref; ref="$(anchor_issue_ref "$PNUM")"
|
|
1811
|
+
if [[ -z "$ref" ]]; then
|
|
1812
|
+
warn "No '$ANCHOR_LABEL' issue on '$PTITLE' — owners are read from the anchor issue."
|
|
1813
|
+
confirm "Designate an anchor issue now?"
|
|
1814
|
+
local AREF; _pick_board_issue AREF "$PNUM"
|
|
1815
|
+
is_back "$AREF" && return
|
|
1816
|
+
gh issue edit "${AREF##*#}" --repo "${AREF%%#*}" --add-label "$ANCHOR_LABEL" >/dev/null 2>&1 \
|
|
1817
|
+
&& ok "Labelled $AREF as the anchor." || { err "Could not add '$ANCHOR_LABEL' label (does the label exist in ${AREF%%#*}?)."; exit 1; }
|
|
1818
|
+
ref="$AREF"
|
|
1819
|
+
fi
|
|
1820
|
+
local owners; owners="$(project_owners "$PNUM" | paste -sd ',' - | sed 's/,/, /g')"
|
|
1821
|
+
echo -e " ${BOLD}Anchor:${NC} $ref"
|
|
1822
|
+
echo -e " ${BOLD}Current owners:${NC} ${owners:-(none)}"
|
|
1823
|
+
echo ""
|
|
1824
|
+
local ACT; ask_choice ACT "Add or remove an owner?" "Add owner" "Remove owner"
|
|
1825
|
+
is_back "$ACT" && return
|
|
1826
|
+
echo ""
|
|
1827
|
+
if [[ "$ACT" == "Add owner" ]]; then
|
|
1828
|
+
local WHO; ask WHO "GitHub login to add as owner (@me for yourself)" ""
|
|
1829
|
+
[[ "$WHO" == "@me" ]] && WHO="$(gh api user --jq .login 2>/dev/null)"
|
|
1830
|
+
[[ -z "$WHO" ]] && { err "No login given."; exit 1; }
|
|
1831
|
+
confirm "Add '$WHO' as owner of '$PTITLE' (anchor assignee + board write)?"
|
|
1832
|
+
set_owner add "$PNUM" "$WHO"
|
|
1833
|
+
else
|
|
1834
|
+
local cur; cur="$(project_owners "$PNUM")"
|
|
1835
|
+
[[ -z "$cur" ]] && { err "No current owners to remove."; exit 1; }
|
|
1836
|
+
local WHO; ask_choice WHO "Remove which owner?" $cur
|
|
1837
|
+
is_back "$WHO" && return
|
|
1838
|
+
confirm "Remove '$WHO' as owner of '$PTITLE' (unassign + revoke board write)?"
|
|
1839
|
+
set_owner remove "$PNUM" "$WHO"
|
|
1840
|
+
fi
|
|
1841
|
+
else
|
|
1842
|
+
local IREF; _pick_board_issue IREF "$PNUM"
|
|
1843
|
+
is_back "$IREF" && return
|
|
1844
|
+
local irepo="${IREF%%#*}" inum="${IREF##*#}"
|
|
1845
|
+
local asn; asn="$(gh issue view "$inum" --repo "$irepo" --json assignees --jq '.assignees[].login' 2>/dev/null)"
|
|
1846
|
+
echo -e " ${BOLD}Issue:${NC} $IREF"
|
|
1847
|
+
echo -e " ${BOLD}Assignees:${NC} $(echo "$asn" | paste -sd ',' - | sed 's/,/, /g')"
|
|
1848
|
+
echo ""
|
|
1849
|
+
local ACT; ask_choice ACT "Add or remove an assignee?" "Add assignee" "Remove assignee"
|
|
1850
|
+
is_back "$ACT" && return
|
|
1851
|
+
echo ""
|
|
1852
|
+
if [[ "$ACT" == "Add assignee" ]]; then
|
|
1853
|
+
local WHO; ask WHO "GitHub login to assign (@me)" "@me"
|
|
1854
|
+
gh issue edit "$inum" --repo "$irepo" --add-assignee "$WHO" >/dev/null 2>&1 && ok "Assigned $WHO to $IREF." || err "Failed to assign $WHO."
|
|
1855
|
+
else
|
|
1856
|
+
[[ -z "$asn" ]] && { err "No assignees to remove."; exit 1; }
|
|
1857
|
+
local WHO; ask_choice WHO "Remove which assignee?" $asn
|
|
1858
|
+
is_back "$WHO" && return
|
|
1859
|
+
gh issue edit "$inum" --repo "$irepo" --remove-assignee "$WHO" >/dev/null 2>&1 && ok "Unassigned $WHO from $IREF." || err "Failed to unassign $WHO."
|
|
1860
|
+
fi
|
|
1861
|
+
fi
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
cmd_manage_list() {
|
|
1865
|
+
# GitHub-definitive (registry-elimination Increment 2): the ONGOING project set
|
|
1866
|
+
# is the org's OPEN GitHub Project boards (open = active/paused; closed boards =
|
|
1867
|
+
# completed/cancelled, shown by 'manage list-all'). For each board, OWNERS are the
|
|
1868
|
+
# anchor-issue assignees and STATUS is derived (board open/closed + anchor labels)
|
|
1869
|
+
# via the derivation helpers — legacy projects (PRJ-001…013) resolve through the
|
|
1870
|
+
# frozen registry shim, everything else 100% from GitHub. No assigned_to cache.
|
|
1871
|
+
header "Ongoing Project Owners (live from GitHub)"
|
|
1872
|
+
divider
|
|
1873
|
+
label "Owners = anchor-issue assignees · status from the board + anchor labels · no cache."
|
|
1874
|
+
echo ""
|
|
1875
|
+
local owner="$GITHUB_ORG" json
|
|
1876
|
+
json=$(gh project list --owner "$owner" --format json --limit 100 2>/dev/null) \
|
|
1877
|
+
|| { err "Could not list GitHub Projects for '$owner'."; return 1; }
|
|
1878
|
+
local nums=() titles=()
|
|
1879
|
+
while IFS=$'\t' read -r n t; do [[ -n "$n" ]] && nums+=("$n") && titles+=("$t"); done < <(
|
|
1880
|
+
echo "$json" | python3 -c "
|
|
1881
|
+
import sys, json
|
|
1882
|
+
for p in json.load(sys.stdin).get('projects', []):
|
|
1883
|
+
print('%s\t%s' % (p.get('number',''), (p.get('title') or '(untitled)')))
|
|
1884
|
+
")
|
|
1885
|
+
if [[ ${#nums[@]} -eq 0 ]]; then echo " No open GitHub Projects for '$owner'."; echo ""; return; fi
|
|
1886
|
+
printf " ${BOLD}%-34s %-10s %s${NC}\n" "PROJECT" "STATUS" "OWNERS"
|
|
1887
|
+
printf " ${DIM}%-34s %-10s %s${NC}\n" "──────────────────────────────────" "──────────" "─────────────────────"
|
|
1888
|
+
local i id status owners scol
|
|
1889
|
+
for i in "${!nums[@]}"; do
|
|
1890
|
+
id="$(derive_project_id "${nums[$i]}" "$owner")"; [[ -z "$id" ]] && id="${titles[$i]}"
|
|
1891
|
+
status="$(derive_project_status "${nums[$i]}" "$owner")"
|
|
1892
|
+
owners="$(project_owners "${nums[$i]}" "$owner" | paste -sd ',' - | sed 's/,/, /g')"
|
|
1893
|
+
case "$status" in active) scol="$GREEN" ;; paused) scol="$YELLOW" ;; *) scol="$DIM" ;; esac
|
|
1894
|
+
if [[ -n "$owners" ]]; then
|
|
1895
|
+
printf " ${BOLD}%-34s${NC} ${scol}%-10s${NC} %s\n" "$id" "$status" "$owners"
|
|
1896
|
+
else
|
|
1897
|
+
printf " ${BOLD}%-34s${NC} ${scol}%-10s${NC} ${YELLOW}⚠ no anchor set up yet${NC}\n" "$id" "$status"
|
|
1898
|
+
fi
|
|
1899
|
+
done
|
|
1900
|
+
echo ""
|
|
1901
|
+
label "Closed boards (completed/cancelled) are hidden — 'prj manage list-all' for the full board universe."
|
|
1902
|
+
echo ""
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
cmd_manage_list_all() {
|
|
1906
|
+
# Full board universe — sourced from GitHub (gh project list). Shows every
|
|
1907
|
+
# GitHub Project incl. pre-assigned / not-yet-seeded ones, enriched with
|
|
1908
|
+
# registry status/assignment. (#57: 'manage list-all' = from GitHub.)
|
|
1909
|
+
header "All GitHub Projects (board universe)"
|
|
1910
|
+
divider
|
|
1911
|
+
echo ""
|
|
1912
|
+
|
|
1913
|
+
ask GH_OWNER "GitHub org or username" "$GITHUB_ORG"
|
|
1914
|
+
echo ""
|
|
1915
|
+
label "Fetching GitHub Projects for ${GH_OWNER}..."
|
|
1916
|
+
echo ""
|
|
1917
|
+
|
|
1918
|
+
python3 - "$REGISTRY" "$SCRIPT_DIR" "$GH_OWNER" <<'PY'
|
|
1919
|
+
import sys, os, json, subprocess, yaml
|
|
1920
|
+
|
|
1921
|
+
registry_path, proj_dir, owner = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
1922
|
+
BOLD='\033[1m'; NC='\033[0m'; DIM='\033[2m'
|
|
1923
|
+
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; RED='\033[0;31m'
|
|
1924
|
+
status_colour = {'active': GREEN, 'paused': YELLOW, 'completed': CYAN, 'cancelled': RED}
|
|
1925
|
+
|
|
1926
|
+
result = subprocess.run(
|
|
1927
|
+
['gh', 'project', 'list', '--owner', owner, '--format', 'json', '--limit', '100'],
|
|
1928
|
+
capture_output=True, text=True
|
|
1929
|
+
)
|
|
1930
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
1931
|
+
print(f" Could not fetch GitHub Projects for '{owner}'.")
|
|
1932
|
+
print(f" {DIM}Tip: gh project list --owner {owner}{NC}")
|
|
1933
|
+
sys.exit(0)
|
|
1934
|
+
|
|
1935
|
+
gh_projects = json.loads(result.stdout).get('projects', [])
|
|
1936
|
+
if not gh_projects:
|
|
1937
|
+
print(f" No open GitHub Projects found for '{owner}'.")
|
|
1938
|
+
sys.exit(0)
|
|
1939
|
+
|
|
1940
|
+
c = yaml.safe_load(open(registry_path)) or {}
|
|
1941
|
+
reg_by_url = {}
|
|
1942
|
+
for p in (c.get('projects') or []):
|
|
1943
|
+
if p and p.get('github_project'):
|
|
1944
|
+
reg_by_url[p['github_project']] = p
|
|
1945
|
+
|
|
1946
|
+
pre_by_url = {}
|
|
1947
|
+
for a in (c.get('pre_assignments') or []):
|
|
1948
|
+
if a and a.get('github_project'):
|
|
1949
|
+
pre_by_url[a['github_project']] = a
|
|
1950
|
+
|
|
1951
|
+
print(f" {BOLD}{'GITHUB PROJECT':<38} {'SEEDED AS':<22} {'STATUS':<12} ASSIGNED TO{NC}")
|
|
1952
|
+
print(f" {DIM}{'─'*38} {'─'*22} {'─'*12} {'─'*25}{NC}")
|
|
1953
|
+
|
|
1954
|
+
for gh in gh_projects:
|
|
1955
|
+
title = (gh.get('title') or '(untitled)')[:36]
|
|
1956
|
+
url = gh.get('url', '')
|
|
1957
|
+
|
|
1958
|
+
if url in reg_by_url:
|
|
1959
|
+
reg = reg_by_url[url]
|
|
1960
|
+
pid = reg.get('id', '—')
|
|
1961
|
+
s = reg.get('status', '?')
|
|
1962
|
+
sc = status_colour.get(s, '')
|
|
1963
|
+
assignee = reg.get('assigned_to') or ''
|
|
1964
|
+
if not assignee:
|
|
1965
|
+
pf = os.path.join(proj_dir, 'projects', pid, 'project.yaml')
|
|
1966
|
+
if os.path.exists(pf):
|
|
1967
|
+
assignee = (yaml.safe_load(open(pf)) or {}).get('assigned_to') or ''
|
|
1968
|
+
adisp = assignee if assignee else f'{DIM}(unassigned){NC}'
|
|
1969
|
+
print(f" {title:<38} {BOLD}{pid:<22}{NC} {sc}{s:<12}{NC} {adisp}")
|
|
1970
|
+
elif url in pre_by_url:
|
|
1971
|
+
assignee = pre_by_url[url].get('assigned_to') or ''
|
|
1972
|
+
adisp = assignee if assignee else f'{DIM}(unassigned){NC}'
|
|
1973
|
+
print(f" {title:<38} {DIM}{'(not seeded)':<22}{'pre-assigned':<12}{NC} {adisp}")
|
|
1974
|
+
else:
|
|
1975
|
+
print(f" {title:<38} {DIM}{'(not seeded)':<22}{'—':<12}(unassigned){NC}")
|
|
1976
|
+
|
|
1977
|
+
print()
|
|
1978
|
+
PY
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
cmd_manage_assign() {
|
|
1982
|
+
header "Assign Project"
|
|
1983
|
+
divider
|
|
1984
|
+
label "Assign an unassigned project to a GitHub user or team."
|
|
1985
|
+
echo ""
|
|
1986
|
+
select_from_owner_projects PROJECT_ID "unassigned"
|
|
1987
|
+
echo ""
|
|
1988
|
+
|
|
1989
|
+
select_assignee ASSIGNEE "$GH_OWNER"
|
|
1990
|
+
echo ""
|
|
1991
|
+
|
|
1992
|
+
local label_proj="${MANAGE_SELECTED_PID:-$MANAGE_SELECTED_URL}"
|
|
1993
|
+
echo -e " ${BOLD}Project:${NC} $label_proj"
|
|
1994
|
+
echo -e " ${BOLD}Assignee:${NC} $ASSIGNEE"
|
|
1995
|
+
echo ""
|
|
1996
|
+
confirm "Assign this project to '$ASSIGNEE'?"
|
|
1997
|
+
echo ""
|
|
1998
|
+
|
|
1999
|
+
# Phase 3 (ADR-0001): the real assignment is WRITE access on the GitHub
|
|
2000
|
+
# Project. Grant it; the YAML written below is a display/audit cache.
|
|
2001
|
+
bash "$SCRIPTS/project-access.sh" grant "$MANAGE_SELECTED_URL" "$ASSIGNEE" \
|
|
2002
|
+
|| warn "GitHub access not granted — recorded in cache only; grant it manually."
|
|
2003
|
+
|
|
2004
|
+
local today
|
|
2005
|
+
today=$(date +%Y-%m-%d)
|
|
2006
|
+
|
|
2007
|
+
if [[ -n "$MANAGE_SELECTED_PID" ]]; then
|
|
2008
|
+
# Seeded project — assignment lives on registry.yaml (default branch).
|
|
2009
|
+
# Project.yaml's locked_by is the per-session operational lock and is
|
|
2010
|
+
# managed inside the per-project workspace, not from here.
|
|
2011
|
+
python3 - "$REGISTRY" "$MANAGE_SELECTED_PID" "$ASSIGNEE" <<'PY'
|
|
2012
|
+
import sys, yaml
|
|
2013
|
+
registry_path, pid, assignee = sys.argv[1:]
|
|
2014
|
+
with open(registry_path) as f: c = yaml.safe_load(f) or {}
|
|
2015
|
+
for entry in (c.get('projects') or []):
|
|
2016
|
+
if entry and entry.get('id') == pid:
|
|
2017
|
+
entry['assigned_to'] = assignee
|
|
2018
|
+
break
|
|
2019
|
+
with open(registry_path, 'w') as f:
|
|
2020
|
+
yaml.dump(c, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
2021
|
+
PY
|
|
2022
|
+
commit_registry "manage: assign $MANAGE_SELECTED_PID → $ASSIGNEE"
|
|
2023
|
+
echo ""
|
|
2024
|
+
ok "'$MANAGE_SELECTED_PID' assigned to '$ASSIGNEE' (registry on $DEFAULT_BRANCH)."
|
|
2025
|
+
else
|
|
2026
|
+
# Unseeded project — pre_assignment in registry on default branch.
|
|
2027
|
+
python3 - "$REGISTRY" "$MANAGE_SELECTED_URL" "$ASSIGNEE" "$CURRENT_USER" "$today" <<'PY'
|
|
2028
|
+
import sys, yaml
|
|
2029
|
+
registry_path, url, assignee, assigned_by, today = sys.argv[1:]
|
|
2030
|
+
with open(registry_path) as f: c = yaml.safe_load(f) or {}
|
|
2031
|
+
pre = [a for a in (c.get('pre_assignments') or []) if a and a.get('github_project') != url]
|
|
2032
|
+
pre.append({'github_project': url, 'assigned_to': assignee, 'assigned_by': assigned_by, 'assigned_at': today})
|
|
2033
|
+
c['pre_assignments'] = pre
|
|
2034
|
+
with open(registry_path, 'w') as f:
|
|
2035
|
+
yaml.dump(c, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
2036
|
+
PY
|
|
2037
|
+
commit_registry "manage: pre-assign $MANAGE_SELECTED_URL → $ASSIGNEE"
|
|
2038
|
+
echo ""
|
|
2039
|
+
ok "Project assigned to '$ASSIGNEE'. They may now run './prj init' to seed it."
|
|
2040
|
+
fi
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
cmd_manage_reassign() {
|
|
2044
|
+
header "Reassign Project"
|
|
2045
|
+
divider
|
|
2046
|
+
label "Reassign a project to a different user or team. Requires a reason (C02)."
|
|
2047
|
+
echo ""
|
|
2048
|
+
select_from_owner_projects PROJECT_ID "assigned"
|
|
2049
|
+
echo ""
|
|
2050
|
+
|
|
2051
|
+
local current_assignee=""
|
|
2052
|
+
if [[ -n "$MANAGE_SELECTED_PID" ]]; then
|
|
2053
|
+
current_assignee=$(python3 - "$REGISTRY" "$MANAGE_SELECTED_PID" <<'PY'
|
|
2054
|
+
import sys, yaml
|
|
2055
|
+
c = yaml.safe_load(open(sys.argv[1])) or {}
|
|
2056
|
+
for entry in (c.get('projects') or []):
|
|
2057
|
+
if entry and entry.get('id') == sys.argv[2]:
|
|
2058
|
+
print(entry.get('assigned_to') or ''); sys.exit(0)
|
|
2059
|
+
print('')
|
|
2060
|
+
PY
|
|
2061
|
+
)
|
|
2062
|
+
else
|
|
2063
|
+
current_assignee=$(python3 - "$REGISTRY" "$MANAGE_SELECTED_URL" <<'PY'
|
|
2064
|
+
import sys, yaml
|
|
2065
|
+
c = yaml.safe_load(open(sys.argv[1])) or {}
|
|
2066
|
+
for a in (c.get('pre_assignments') or []):
|
|
2067
|
+
if a and a.get('github_project') == sys.argv[2]:
|
|
2068
|
+
print(a.get('assigned_to') or ''); sys.exit(0)
|
|
2069
|
+
print('')
|
|
2070
|
+
PY
|
|
2071
|
+
)
|
|
2072
|
+
fi
|
|
2073
|
+
|
|
2074
|
+
echo -e " ${BOLD}Current assignee:${NC} ${current_assignee:-${DIM}(unassigned)${NC}}"
|
|
2075
|
+
echo ""
|
|
2076
|
+
|
|
2077
|
+
select_assignee NEW_ASSIGNEE "$GH_OWNER"
|
|
2078
|
+
echo ""
|
|
2079
|
+
|
|
2080
|
+
ask REASON "Reassignment reason (C02 — required)"
|
|
2081
|
+
echo ""
|
|
2082
|
+
|
|
2083
|
+
local today
|
|
2084
|
+
today=$(date +%Y-%m-%d)
|
|
2085
|
+
local label_proj="${MANAGE_SELECTED_PID:-$MANAGE_SELECTED_URL}"
|
|
2086
|
+
|
|
2087
|
+
echo -e " ${BOLD}Project:${NC} $label_proj"
|
|
2088
|
+
echo -e " ${BOLD}From:${NC} ${current_assignee:-unassigned}"
|
|
2089
|
+
echo -e " ${BOLD}To:${NC} $NEW_ASSIGNEE"
|
|
2090
|
+
echo -e " ${BOLD}Reason:${NC} $REASON"
|
|
2091
|
+
echo -e " ${BOLD}Approved by:${NC} $CURRENT_USER"
|
|
2092
|
+
echo ""
|
|
2093
|
+
confirm "Reassign this project?"
|
|
2094
|
+
echo ""
|
|
2095
|
+
|
|
2096
|
+
# Phase 3: move WRITE access on the GitHub Project from old to new assignee.
|
|
2097
|
+
[[ -n "$current_assignee" ]] && { bash "$SCRIPTS/project-access.sh" revoke "$MANAGE_SELECTED_URL" "$current_assignee" \
|
|
2098
|
+
|| warn "Could not revoke access for '$current_assignee' — revoke manually."; }
|
|
2099
|
+
bash "$SCRIPTS/project-access.sh" grant "$MANAGE_SELECTED_URL" "$NEW_ASSIGNEE" \
|
|
2100
|
+
|| warn "Could not grant access to '$NEW_ASSIGNEE' — grant manually."
|
|
2101
|
+
|
|
2102
|
+
if [[ -n "$MANAGE_SELECTED_PID" ]]; then
|
|
2103
|
+
# Seeded — append reassignment record to registry entry's history.
|
|
2104
|
+
python3 - "$REGISTRY" "$MANAGE_SELECTED_PID" "$current_assignee" "$NEW_ASSIGNEE" "$REASON" "$CURRENT_USER" "$today" <<'PY'
|
|
2105
|
+
import sys, yaml
|
|
2106
|
+
registry_path, pid, prev, new_assignee, reason, approved_by, today = sys.argv[1:]
|
|
2107
|
+
with open(registry_path) as f: c = yaml.safe_load(f) or {}
|
|
2108
|
+
for entry in (c.get('projects') or []):
|
|
2109
|
+
if entry and entry.get('id') == pid:
|
|
2110
|
+
entry['assigned_to'] = new_assignee
|
|
2111
|
+
history = entry.get('reassignment_history') or []
|
|
2112
|
+
history.append({'from': prev, 'to': new_assignee, 'reason': reason,
|
|
2113
|
+
'approved_by': approved_by, 'at': today})
|
|
2114
|
+
entry['reassignment_history'] = history
|
|
2115
|
+
break
|
|
2116
|
+
with open(registry_path, 'w') as f:
|
|
2117
|
+
yaml.dump(c, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
2118
|
+
PY
|
|
2119
|
+
commit_registry "manage: reassign $MANAGE_SELECTED_PID → $NEW_ASSIGNEE (C02)"
|
|
2120
|
+
echo ""
|
|
2121
|
+
ok "'$MANAGE_SELECTED_PID' reassigned to '$NEW_ASSIGNEE'."
|
|
2122
|
+
else
|
|
2123
|
+
# Unseeded — update pre_assignment.
|
|
2124
|
+
python3 - "$REGISTRY" "$MANAGE_SELECTED_URL" "$NEW_ASSIGNEE" "$REASON" "$CURRENT_USER" "$today" <<'PY'
|
|
2125
|
+
import sys, yaml
|
|
2126
|
+
registry_path, url, new_assignee, reason, approved_by, today = sys.argv[1:]
|
|
2127
|
+
with open(registry_path) as f: c = yaml.safe_load(f) or {}
|
|
2128
|
+
for a in (c.get('pre_assignments') or []):
|
|
2129
|
+
if a and a.get('github_project') == url:
|
|
2130
|
+
a['assigned_to'] = new_assignee
|
|
2131
|
+
a['reassignment_reason'] = reason
|
|
2132
|
+
a['reassigned_at'] = today
|
|
2133
|
+
a['reassigned_approved_by'] = approved_by
|
|
2134
|
+
break
|
|
2135
|
+
with open(registry_path, 'w') as f:
|
|
2136
|
+
yaml.dump(c, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
2137
|
+
PY
|
|
2138
|
+
commit_registry "manage: reassign pre-assignment → $NEW_ASSIGNEE (C02)"
|
|
2139
|
+
echo ""
|
|
2140
|
+
ok "Project reassigned to '$NEW_ASSIGNEE'."
|
|
2141
|
+
fi
|
|
2142
|
+
ok "C02 fields recorded: reason, approver, timestamp."
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
cmd_manage_unassign() {
|
|
2146
|
+
header "Unassign Project"
|
|
2147
|
+
divider
|
|
2148
|
+
label "Remove the current assignment, leaving the project unassigned."
|
|
2149
|
+
echo ""
|
|
2150
|
+
select_from_owner_projects PROJECT_ID "assigned"
|
|
2151
|
+
echo ""
|
|
2152
|
+
|
|
2153
|
+
local current_assignee=""
|
|
2154
|
+
if [[ -n "$MANAGE_SELECTED_PID" ]]; then
|
|
2155
|
+
current_assignee=$(python3 - "$REGISTRY" "$MANAGE_SELECTED_PID" <<'PY'
|
|
2156
|
+
import sys, yaml
|
|
2157
|
+
c = yaml.safe_load(open(sys.argv[1])) or {}
|
|
2158
|
+
for entry in (c.get('projects') or []):
|
|
2159
|
+
if entry and entry.get('id') == sys.argv[2]:
|
|
2160
|
+
print(entry.get('assigned_to') or ''); sys.exit(0)
|
|
2161
|
+
print('')
|
|
2162
|
+
PY
|
|
2163
|
+
)
|
|
2164
|
+
else
|
|
2165
|
+
current_assignee=$(python3 - "$REGISTRY" "$MANAGE_SELECTED_URL" <<'PY'
|
|
2166
|
+
import sys, yaml
|
|
2167
|
+
c = yaml.safe_load(open(sys.argv[1])) or {}
|
|
2168
|
+
for a in (c.get('pre_assignments') or []):
|
|
2169
|
+
if a and a.get('github_project') == sys.argv[2]:
|
|
2170
|
+
print(a.get('assigned_to') or ''); sys.exit(0)
|
|
2171
|
+
print('')
|
|
2172
|
+
PY
|
|
2173
|
+
)
|
|
2174
|
+
fi
|
|
2175
|
+
|
|
2176
|
+
echo -e " ${BOLD}Current assignee:${NC} $current_assignee"
|
|
2177
|
+
echo ""
|
|
2178
|
+
confirm "Remove assignment from this project?"
|
|
2179
|
+
echo ""
|
|
2180
|
+
|
|
2181
|
+
# Phase 3: revoke WRITE access on the GitHub Project (the real unassignment).
|
|
2182
|
+
[[ -n "$current_assignee" ]] && { bash "$SCRIPTS/project-access.sh" revoke "$MANAGE_SELECTED_URL" "$current_assignee" \
|
|
2183
|
+
|| warn "Could not revoke access for '$current_assignee' — revoke manually."; }
|
|
2184
|
+
|
|
2185
|
+
if [[ -n "$MANAGE_SELECTED_PID" ]]; then
|
|
2186
|
+
# Seeded — clear registry entry's assigned_to.
|
|
2187
|
+
python3 - "$REGISTRY" "$MANAGE_SELECTED_PID" <<'PY'
|
|
2188
|
+
import sys, yaml
|
|
2189
|
+
registry_path, pid = sys.argv[1:]
|
|
2190
|
+
with open(registry_path) as f: c = yaml.safe_load(f) or {}
|
|
2191
|
+
for entry in (c.get('projects') or []):
|
|
2192
|
+
if entry and entry.get('id') == pid:
|
|
2193
|
+
entry['assigned_to'] = None
|
|
2194
|
+
break
|
|
2195
|
+
with open(registry_path, 'w') as f:
|
|
2196
|
+
yaml.dump(c, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
2197
|
+
PY
|
|
2198
|
+
commit_registry "manage: unassign $MANAGE_SELECTED_PID"
|
|
2199
|
+
echo ""
|
|
2200
|
+
ok "'$MANAGE_SELECTED_PID' is now unassigned."
|
|
2201
|
+
else
|
|
2202
|
+
# Unseeded — drop pre_assignment.
|
|
2203
|
+
python3 - "$REGISTRY" "$MANAGE_SELECTED_URL" <<'PY'
|
|
2204
|
+
import sys, yaml
|
|
2205
|
+
registry_path, url = sys.argv[1:]
|
|
2206
|
+
with open(registry_path) as f: c = yaml.safe_load(f) or {}
|
|
2207
|
+
c['pre_assignments'] = [a for a in (c.get('pre_assignments') or []) if a and a.get('github_project') != url]
|
|
2208
|
+
with open(registry_path, 'w') as f:
|
|
2209
|
+
yaml.dump(c, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
2210
|
+
PY
|
|
2211
|
+
commit_registry "manage: drop pre-assignment for $MANAGE_SELECTED_URL"
|
|
2212
|
+
echo ""
|
|
2213
|
+
ok "Assignment removed. Project is now unassigned."
|
|
2214
|
+
fi
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
cmd_manage() {
|
|
2218
|
+
# Optional non-interactive subcommand: `prj manage list|list-all|assign|...`.
|
|
2219
|
+
local sub="${1:-}"
|
|
2220
|
+
if [[ -z "$sub" ]]; then
|
|
2221
|
+
header "Manage Assignments"
|
|
2222
|
+
divider
|
|
2223
|
+
echo ""
|
|
2224
|
+
echo -e " ${CYAN}1)${NC} list Ongoing assignments (registry cache, excl. completed)"
|
|
2225
|
+
echo -e " ${CYAN}2)${NC} list-all Full board universe (from GitHub)"
|
|
2226
|
+
echo -e " ${CYAN}3)${NC} manage Add/remove owners (project) or assignees (issue) — GitHub-definitive"
|
|
2227
|
+
echo -e " ${DIM}0) Back${NC}"
|
|
2228
|
+
echo ""
|
|
2229
|
+
printf " Choose: "
|
|
2230
|
+
read -r sub
|
|
2231
|
+
echo ""
|
|
2232
|
+
fi
|
|
2233
|
+
case "$sub" in
|
|
2234
|
+
1|list) cmd_manage_list ;;
|
|
2235
|
+
2|list-all) cmd_manage_list_all ;;
|
|
2236
|
+
3|manage|assign|reassign|unassign) cmd_manage_owners ;; # collapsed (#57 §3); old verbs aliased
|
|
2237
|
+
0|back|"") return ;;
|
|
2238
|
+
*) err "Unknown option '$sub'." ;;
|
|
2239
|
+
esac
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
# ── Main menu ─────────────────────────────────────────────────────────────────
|
|
2243
|
+
|
|
2244
|
+
show_menu() {
|
|
2245
|
+
clear
|
|
2246
|
+
# Show the ACTUAL checked-out branch of the current repo (not the org default
|
|
2247
|
+
# branch) — this is what the developer is on. Fall back to the org default if
|
|
2248
|
+
# cwd isn't a git repo.
|
|
2249
|
+
local cur_branch
|
|
2250
|
+
cur_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "$DEFAULT_BRANCH")
|
|
2251
|
+
echo ""
|
|
2252
|
+
echo -e "${BOLD}${MAGENTA} ▸ ${ORG_NAME} — Agentic Development Framework${NC}"
|
|
2253
|
+
echo -e "${DIM} Org: ${ORG_SLUG} | Branch: ${cur_branch} | User: ${CURRENT_USER}${NC}"
|
|
2254
|
+
echo ""
|
|
2255
|
+
divider
|
|
2256
|
+
echo ""
|
|
2257
|
+
echo -e " ${GREEN}${BOLD}Work${NC} ${DIM}— developers start here${NC}"
|
|
2258
|
+
echo -e " ${GREEN}▸${NC} work Pick a project → init / continue / new task / finish (one front door)"
|
|
2259
|
+
echo ""
|
|
2260
|
+
echo -e " ${CYAN}${BOLD}Status${NC} ${CYAN}list${NC} · ${CYAN}status${NC}"
|
|
2261
|
+
echo ""
|
|
2262
|
+
echo -e " ${YELLOW}${BOLD}Admin${NC} ${DIM}(managers & maintenance)${NC}"
|
|
2263
|
+
echo -e " ${YELLOW}manage${NC} ${DIM}assign / reassign / unassign — grants GitHub Project access${NC}"
|
|
2264
|
+
echo -e " ${YELLOW}knowledge${NC} · ${YELLOW}onboard${NC} · ${YELLOW}upgrade${NC} · ${YELLOW}deps${NC}"
|
|
2265
|
+
echo ""
|
|
2266
|
+
echo -e " ${DIM}Direct lifecycle (normally reached via work):${NC}"
|
|
2267
|
+
echo -e " ${DIM} init · join · task · merge · pause · resume · sync · add-repo · cancel · close${NC}"
|
|
2268
|
+
echo ""
|
|
2269
|
+
echo -e " ${DIM}Type a verb name or number; 0 to exit.${NC}"
|
|
2270
|
+
echo ""
|
|
2271
|
+
divider
|
|
2272
|
+
printf " Choose: "
|
|
2273
|
+
read -r choice
|
|
2274
|
+
echo ""
|
|
2275
|
+
# Write ops need a configured git identity; check before dispatching.
|
|
2276
|
+
case "$choice" in
|
|
2277
|
+
1|init|2|task|3|merge|4|pause|5|resume|6|sync|7|add-repo|8|cancel|9|close|10|knowledge|11|onboard|15|manage|16|upgrade|start|work|finish)
|
|
2278
|
+
require_git_identity
|
|
2279
|
+
;;
|
|
2280
|
+
esac
|
|
2281
|
+
|
|
2282
|
+
case "$choice" in
|
|
2283
|
+
1|init) cmd_init ;;
|
|
2284
|
+
16|join) cmd_join ;;
|
|
2285
|
+
start) cmd_start ;;
|
|
2286
|
+
work) cmd_work ;;
|
|
2287
|
+
finish) cmd_finish ;;
|
|
2288
|
+
2|task) cmd_task ;;
|
|
2289
|
+
3|merge) cmd_merge ;;
|
|
2290
|
+
4|pause) cmd_pause ;;
|
|
2291
|
+
5|resume) cmd_resume ;;
|
|
2292
|
+
6|sync) cmd_sync ;;
|
|
2293
|
+
7|add-repo) cmd_add_repo ;;
|
|
2294
|
+
8|cancel) cmd_cancel ;;
|
|
2295
|
+
9|close) cmd_close ;;
|
|
2296
|
+
10|knowledge) cmd_knowledge ;;
|
|
2297
|
+
11|onboard) cmd_onboard ;;
|
|
2298
|
+
12|list) cmd_list ;;
|
|
2299
|
+
13|status) cmd_status ;;
|
|
2300
|
+
14|deps) cmd_deps ;;
|
|
2301
|
+
15|manage) cmd_manage ;;
|
|
2302
|
+
16|upgrade) cmd_upgrade ;;
|
|
2303
|
+
0|q|quit|exit) echo "Bye."; exit 0 ;;
|
|
2304
|
+
*) err "Unknown option '$choice'." ;;
|
|
2305
|
+
esac
|
|
2306
|
+
echo ""
|
|
2307
|
+
printf "${DIM}Press Enter to return to menu...${NC}"
|
|
2308
|
+
read -r
|
|
2309
|
+
show_menu
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
# ── Entry point ───────────────────────────────────────────────────────────────
|
|
2313
|
+
|
|
2314
|
+
CMD="${1:-}"
|
|
2315
|
+
|
|
2316
|
+
# Write commands need a configured git identity (they call git commit
|
|
2317
|
+
# during the flow). Read-only commands don't.
|
|
2318
|
+
case "$CMD" in
|
|
2319
|
+
init|task|merge|pause|resume|sync|add-repo|cancel|close|knowledge|onboard|manage|upgrade|start|work|finish)
|
|
2320
|
+
require_git_identity
|
|
2321
|
+
;;
|
|
2322
|
+
esac
|
|
2323
|
+
|
|
2324
|
+
case "$CMD" in
|
|
2325
|
+
"") show_menu ;;
|
|
2326
|
+
init) cmd_init ;;
|
|
2327
|
+
join) cmd_join ;;
|
|
2328
|
+
start) cmd_start ;;
|
|
2329
|
+
work) cmd_work ;;
|
|
2330
|
+
finish) cmd_finish ;;
|
|
2331
|
+
task) cmd_task ;;
|
|
2332
|
+
merge) cmd_merge ;;
|
|
2333
|
+
pause) cmd_pause ;;
|
|
2334
|
+
resume) cmd_resume ;;
|
|
2335
|
+
sync) cmd_sync ;;
|
|
2336
|
+
add-repo) cmd_add_repo ;;
|
|
2337
|
+
cancel) cmd_cancel ;;
|
|
2338
|
+
close) cmd_close ;;
|
|
2339
|
+
knowledge) cmd_knowledge ;;
|
|
2340
|
+
onboard) cmd_onboard ;;
|
|
2341
|
+
list) cmd_list ;;
|
|
2342
|
+
list-all) cmd_list all ;;
|
|
2343
|
+
status) cmd_status ;;
|
|
2344
|
+
deps) cmd_deps ;;
|
|
2345
|
+
manage) cmd_manage "${2:-}" ;;
|
|
2346
|
+
upgrade) cmd_upgrade "${2:-}" ;;
|
|
2347
|
+
help|--help|-h)
|
|
2348
|
+
echo ""
|
|
2349
|
+
echo -e "${BOLD}Usage:${NC} ./prj [command]"
|
|
2350
|
+
echo ""
|
|
2351
|
+
echo -e "${BOLD}Commands:${NC}"
|
|
2352
|
+
echo " start Begin work — join / new task / new project"
|
|
2353
|
+
echo " work Continue work — sync your branch and keep going"
|
|
2354
|
+
echo " finish Finish a task (submit) or the project (close)"
|
|
2355
|
+
echo " init Initialize a new project"
|
|
2356
|
+
echo " join Join an existing project (team member)"
|
|
2357
|
+
echo " task Create a task for parallel work"
|
|
2358
|
+
echo " merge Merge a completed task"
|
|
2359
|
+
echo " pause Pause a project"
|
|
2360
|
+
echo " resume Resume a paused project"
|
|
2361
|
+
echo " sync Sync with latest base branches"
|
|
2362
|
+
echo " add-repo Add a repository to a project"
|
|
2363
|
+
echo " cancel Cancel a project"
|
|
2364
|
+
echo " close Close a completed project"
|
|
2365
|
+
echo " knowledge Propose org knowledge changes"
|
|
2366
|
+
echo " onboard Onboard a repository"
|
|
2367
|
+
echo " list List ongoing projects (active/paused)"
|
|
2368
|
+
echo " list-all List all projects, newest first"
|
|
2369
|
+
echo " status Show project status"
|
|
2370
|
+
echo " deps Install / verify dependencies"
|
|
2371
|
+
echo " manage List, assign, reassign, unassign projects"
|
|
2372
|
+
echo " upgrade Pull a new framework version from the template remote"
|
|
2373
|
+
echo ""
|
|
2374
|
+
echo " Run without arguments for the interactive menu."
|
|
2375
|
+
echo ""
|
|
2376
|
+
;;
|
|
2377
|
+
*)
|
|
2378
|
+
err "Unknown command '$CMD'. Run './prj help' for usage."
|
|
2379
|
+
exit 1
|
|
2380
|
+
;;
|
|
2381
|
+
esac
|