@raquezha/norpiv 0.0.2

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.
@@ -0,0 +1,328 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # validate_active_task.sh
4
+ # Lightweight, print-only validation of .workflow/active_task.json and related files.
5
+ # This script never mutates task state; it only reports inconsistencies and suggested fixes.
6
+
7
+ set -euo pipefail
8
+
9
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
+ TRIAGE_HELPER="$SCRIPT_DIR/triage_helper.sh"
11
+ REPO_ROOT=$(git rev-parse --show-toplevel)
12
+ ATF="$REPO_ROOT/.workflow/active_task.json"
13
+ CURRENT_BRANCH=$(git branch --show-current 2>/dev/null || true)
14
+ WORK_MD=""
15
+ TASK_DIR=""
16
+ META=""
17
+ WARNINGS=0
18
+ ERRORS=0
19
+
20
+ warn() {
21
+ WARNINGS=$((WARNINGS + 1))
22
+ echo "WARN: $*"
23
+ }
24
+
25
+ error() {
26
+ ERRORS=$((ERRORS + 1))
27
+ echo "ERROR: $*"
28
+ }
29
+
30
+ ok() {
31
+ echo "OK: $*"
32
+ }
33
+
34
+ json_read() {
35
+ local file="$1"
36
+ local expr="$2"
37
+ if [[ ! -f "$file" ]]; then
38
+ echo ""
39
+ return
40
+ fi
41
+ python3 - "$file" "$expr" <<'PY'
42
+ import json
43
+ import re
44
+ import sys
45
+ from pathlib import Path
46
+
47
+ path = Path(sys.argv[1])
48
+ expr = sys.argv[2]
49
+ try:
50
+ data = json.loads(path.read_text())
51
+ except Exception:
52
+ print("")
53
+ raise SystemExit
54
+
55
+ # Supports the tiny subset used by this validator:
56
+ # .field and .field // .fallback // empty
57
+ for part in [p.strip() for p in expr.split("//")]:
58
+ if part == "empty":
59
+ continue
60
+ match = re.fullmatch(r"\.([A-Za-z_][A-Za-z0-9_]*)", part)
61
+ if not match or not isinstance(data, dict):
62
+ continue
63
+ value = data.get(match.group(1))
64
+ if value not in (None, ""):
65
+ print(value)
66
+ raise SystemExit
67
+ print("")
68
+ PY
69
+ }
70
+
71
+ json_pretty() {
72
+ local file="$1"
73
+ python3 -m json.tool "$file"
74
+ }
75
+
76
+ count_section() {
77
+ local section="$1"
78
+ local file="$2"
79
+ grep -Ec "^(## )?\[$section\][[:space:]]*$" "$file" 2>/dev/null || true
80
+ }
81
+
82
+ section_body_nonempty() {
83
+ local section="$1"
84
+ local file="$2"
85
+ python3 - "$file" "$section" <<'PY'
86
+ import re
87
+ import sys
88
+ from pathlib import Path
89
+
90
+ path = Path(sys.argv[1])
91
+ section = sys.argv[2]
92
+ text = path.read_text() if path.exists() else ""
93
+ lines = text.splitlines()
94
+ header = re.compile(rf"^(## )?\[{re.escape(section)}\]\s*$")
95
+ any_header = re.compile(r"^(## )?\[[A-Z0-9_-]+\]\s*$")
96
+ start = next((i for i, line in enumerate(lines) if header.match(line)), None)
97
+ if start is None:
98
+ print("no")
99
+ raise SystemExit
100
+ end = len(lines)
101
+ for i in range(start + 1, len(lines)):
102
+ if any_header.match(lines[i]):
103
+ end = i
104
+ break
105
+ body = "\n".join(lines[start + 1:end]).strip()
106
+ meaningful = [line.strip() for line in body.splitlines() if line.strip() and line.strip() not in {"-", "- [ ]"}]
107
+ print("yes" if meaningful else "no")
108
+ PY
109
+ }
110
+
111
+ has_plan_checkbox() {
112
+ local file="$1"
113
+ python3 - "$file" <<'PY'
114
+ import re
115
+ import sys
116
+ from pathlib import Path
117
+
118
+ path = Path(sys.argv[1])
119
+ text = path.read_text() if path.exists() else ""
120
+ lines = text.splitlines()
121
+ header = re.compile(r"^(## )?\[PLAN\]\s*$")
122
+ any_header = re.compile(r"^(## )?\[[A-Z0-9_-]+\]\s*$")
123
+ start = next((i for i, line in enumerate(lines) if header.match(line)), None)
124
+ if start is None:
125
+ print("no")
126
+ raise SystemExit
127
+ end = len(lines)
128
+ for i in range(start + 1, len(lines)):
129
+ if any_header.match(lines[i]):
130
+ end = i
131
+ break
132
+ body = "\n".join(lines[start + 1:end])
133
+ print("yes" if re.search(r"- \[[ xX]\]", body) else "no")
134
+ PY
135
+ }
136
+
137
+ print_json_field_report() {
138
+ local label="$1"
139
+ local file="$2"
140
+ shift 2
141
+
142
+ echo
143
+ echo "$label field check:"
144
+ for field in "$@"; do
145
+ local value
146
+ value=$(json_read "$file" ".$field")
147
+ if [[ -n "$value" ]]; then
148
+ ok "$field=$value"
149
+ else
150
+ warn "$label missing .$field"
151
+ fi
152
+ done
153
+ }
154
+
155
+ if [[ ! -f "$ATF" ]]; then
156
+ error "No active_task.json found at $ATF"
157
+ echo "Suggested fixes (dry):"
158
+ echo " - Run: $TRIAGE_HELPER <source> <id>"
159
+ exit 1
160
+ fi
161
+
162
+ if ! python3 -m json.tool "$ATF" >/dev/null 2>&1; then
163
+ error "active_task.json is not valid JSON: $ATF"
164
+ exit 1
165
+ fi
166
+
167
+ echo "Active task file: $ATF"
168
+ json_pretty "$ATF" || true
169
+
170
+ TASK_PATH=$(json_read "$ATF" '.taskPath // .path')
171
+ TASK_SOURCE=$(json_read "$ATF" '.source')
172
+ TASK_ID=$(json_read "$ATF" '.sourceId // .id')
173
+ ACTIVE_TASK=$(json_read "$ATF" '.active_task')
174
+ POINTER_BRANCH=$(json_read "$ATF" '.branch')
175
+
176
+ print_json_field_report "active_task.json" "$ATF" active_task source id sourceId taskPath path branch
177
+
178
+ echo
179
+ if [[ -n "$TASK_PATH" ]]; then
180
+ if [[ "$TASK_PATH" = /* ]]; then
181
+ WORK_MD="$TASK_PATH/WORK.md"
182
+ else
183
+ WORK_MD="$REPO_ROOT/$TASK_PATH/WORK.md"
184
+ fi
185
+ echo "Using taskPath -> checking: $WORK_MD"
186
+ elif [[ -n "$TASK_ID" ]]; then
187
+ echo "No taskPath in active_task.json; attempting to derive from source+id"
188
+ if [[ -n "$TASK_SOURCE" ]]; then
189
+ TASK_FOLDER="$TASK_SOURCE-$TASK_ID"
190
+ else
191
+ TASK_FOLDER="$TASK_ID"
192
+ fi
193
+ WORK_MD="$REPO_ROOT/.workflow/tasks/$TASK_FOLDER/WORK.md"
194
+ echo "Derived path: $WORK_MD"
195
+ else
196
+ error "Neither taskPath/path nor sourceId/id present in active_task.json"
197
+ fi
198
+
199
+ if [[ -n "$WORK_MD" && -f "$WORK_MD" ]]; then
200
+ ok "WORK.md exists at $WORK_MD"
201
+ TASK_DIR=$(dirname "$WORK_MD")
202
+ META="$TASK_DIR/metadata.json"
203
+ else
204
+ error "WORK.md not found at ${WORK_MD:-<unknown>}"
205
+ fi
206
+
207
+ if [[ -n "$ACTIVE_TASK" && -n "$TASK_DIR" ]]; then
208
+ ACTUAL_TASK_FOLDER=$(basename "$TASK_DIR")
209
+ if [[ "$ACTIVE_TASK" == "$ACTUAL_TASK_FOLDER" ]]; then
210
+ ok "active_task matches task folder ($ACTIVE_TASK)"
211
+ else
212
+ warn "active_task ($ACTIVE_TASK) differs from task folder ($ACTUAL_TASK_FOLDER)"
213
+ fi
214
+ fi
215
+
216
+ if [[ -n "$CURRENT_BRANCH" && -n "$POINTER_BRANCH" ]]; then
217
+ if [[ "$CURRENT_BRANCH" == "$POINTER_BRANCH" ]]; then
218
+ ok "current branch matches active pointer ($CURRENT_BRANCH)"
219
+ else
220
+ warn "current branch ($CURRENT_BRANCH) differs from active pointer branch ($POINTER_BRANCH)"
221
+ fi
222
+ fi
223
+
224
+ META_ID=""
225
+ META_SOURCE=""
226
+ META_TASK_FOLDER=""
227
+ META_STATUS=""
228
+ META_PHASE=""
229
+ META_BRANCH=""
230
+
231
+ if [[ -n "$META" ]]; then
232
+ if [[ -f "$META" ]]; then
233
+ if python3 -m json.tool "$META" >/dev/null 2>&1; then
234
+ echo
235
+ echo "Metadata file: $META"
236
+ json_pretty "$META" || true
237
+ print_json_field_report "metadata.json" "$META" id source taskFolder status phase branch createdAt updatedAt
238
+
239
+ META_ID=$(json_read "$META" '.id')
240
+ META_SOURCE=$(json_read "$META" '.source')
241
+ META_TASK_FOLDER=$(json_read "$META" '.taskFolder')
242
+ META_STATUS=$(json_read "$META" '.status')
243
+ META_PHASE=$(json_read "$META" '.phase')
244
+ META_BRANCH=$(json_read "$META" '.branch')
245
+
246
+ if [[ -n "$META_ID" && -n "$TASK_ID" && "$META_ID" != "$TASK_ID" ]]; then
247
+ warn "metadata.id ($META_ID) differs from active_task id/sourceId ($TASK_ID)"
248
+ fi
249
+ if [[ -n "$META_SOURCE" && -n "$TASK_SOURCE" && "$META_SOURCE" != "$TASK_SOURCE" ]]; then
250
+ warn "metadata.source ($META_SOURCE) differs from active_task.source ($TASK_SOURCE)"
251
+ fi
252
+ if [[ -n "$META_TASK_FOLDER" && -n "$TASK_DIR" && "$META_TASK_FOLDER" != "$(basename "$TASK_DIR")" ]]; then
253
+ warn "metadata.taskFolder ($META_TASK_FOLDER) differs from task folder ($(basename "$TASK_DIR"))"
254
+ fi
255
+ if [[ -n "$CURRENT_BRANCH" && -n "$META_BRANCH" && "$CURRENT_BRANCH" != "$META_BRANCH" ]]; then
256
+ warn "current branch ($CURRENT_BRANCH) differs from metadata.branch ($META_BRANCH)"
257
+ fi
258
+ else
259
+ error "metadata.json is not valid JSON: $META"
260
+ fi
261
+ else
262
+ warn "metadata.json not found at $META"
263
+ fi
264
+ fi
265
+
266
+ if [[ -n "$WORK_MD" && -f "$WORK_MD" ]]; then
267
+ echo
268
+ echo "WORK.md section health:"
269
+ for section in BRIEF GRILL PLAN LOG META; do
270
+ count=$(count_section "$section" "$WORK_MD")
271
+ case "$count" in
272
+ 0) warn "missing [$section] section" ;;
273
+ 1) ok "[$section] section present once" ;;
274
+ *) warn "duplicate [$section] sections found ($count)" ;;
275
+ esac
276
+ done
277
+
278
+ if [[ "$META_PHASE" =~ ^(planned|implementing|verifying|synced|closed)$ ]]; then
279
+ if [[ "$(has_plan_checkbox "$WORK_MD")" == "yes" ]]; then
280
+ ok "[PLAN] contains checkboxes for phase=$META_PHASE"
281
+ else
282
+ warn "phase=$META_PHASE but [PLAN] has no checkboxes"
283
+ fi
284
+ fi
285
+
286
+ if [[ "$META_PHASE" == "triaged" && "$(section_body_nonempty "BRIEF" "$WORK_MD")" == "yes" ]]; then
287
+ warn "phase=triaged but [BRIEF] appears populated; consider updating phase metadata"
288
+ fi
289
+ fi
290
+
291
+ if [[ "$META_STATUS" =~ ^(done|archived)$ ]]; then
292
+ warn "active pointer references a task with status=$META_STATUS; consider cleanup, archive handling, or reopening intentionally"
293
+ fi
294
+
295
+ echo
296
+ echo "Suggested fixes (dry):"
297
+ if [[ -n "$WORK_MD" && ! -f "$WORK_MD" ]]; then
298
+ echo " - Re-run triage: $TRIAGE_HELPER <source> <id>"
299
+ echo " - Or update .workflow/active_task.json to include a correct taskPath or source+id"
300
+ fi
301
+ if [[ -n "$META" && ! -f "$META" ]]; then
302
+ echo " - Resume/backfill metadata: $TRIAGE_HELPER ${TASK_SOURCE:-local} ${TASK_ID:-task} resume"
303
+ elif [[ -n "$META" && -f "$META" ]]; then
304
+ if [[ -z "$META_STATUS" || -z "$META_PHASE" || -z "$(json_read "$META" '.createdAt')" || -z "$(json_read "$META" '.updatedAt')" ]]; then
305
+ echo " - Backfill missing metadata fields: $TRIAGE_HELPER ${TASK_SOURCE:-local} ${TASK_ID:-task} resume"
306
+ fi
307
+ fi
308
+ if [[ -n "$WORK_MD" && -f "$WORK_MD" ]]; then
309
+ for section in BRIEF GRILL PLAN LOG META; do
310
+ count=$(count_section "$section" "$WORK_MD")
311
+ if [[ "$count" -gt 1 ]]; then
312
+ echo " - Manually merge duplicate [$section] sections in $WORK_MD"
313
+ fi
314
+ done
315
+ fi
316
+ if [[ -n "$META_STATUS" && "$META_STATUS" =~ ^(done|archived)$ ]]; then
317
+ echo " - Choose intentionally: $TRIAGE_HELPER ${TASK_SOURCE:-local} ${TASK_ID:-task} reopen"
318
+ echo " - Or leave closed and run cleanup when appropriate."
319
+ fi
320
+ if [[ "$WARNINGS" -eq 0 && "$ERRORS" -eq 0 ]]; then
321
+ echo " - No fixes suggested."
322
+ fi
323
+
324
+ echo
325
+ echo "Validation complete (print-only). warnings=$WARNINGS errors=$ERRORS"
326
+ if [[ "$ERRORS" -gt 0 ]]; then
327
+ exit 1
328
+ fi
package/sync/SKILL.md ADDED
@@ -0,0 +1,131 @@
1
+ ---
2
+ name: sync
3
+ description: Synchronizes local RPIV task state (WORK.md) to external trackers (Jira, GitHub, GitLab). Use this to publish progress, update implementation status, and maintain a durable audit trail between local development and remote project management tools.
4
+ ---
5
+
6
+ # Skill: sync
7
+
8
+ Maintains consistency between local `.workflow` state and the remote source of truth using a single Pi-owned living status comment per task.
9
+
10
+ ## Guardrails
11
+ - **Pre-flight**: Always read `.workflow/active_task.json` and the active `WORK.md` before executing.
12
+ - **Privacy**: NEVER sync secrets, environment variables, or private notes not intended for stakeholders.
13
+ - **Integrity**: Do not modify `[BRIEF]` or `[GRILL]` sections.
14
+ - **Idempotency**: If the remote Pi status already reflects the current local state, do not post or update.
15
+ - **Human safety**: NEVER edit human-authored comments. Only update comments/notes containing the Pi sync marker.
16
+
17
+ ## Living status marker
18
+ Every sync body MUST include this marker at the end:
19
+
20
+ ```md
21
+ <!-- pi-sync-marker -->
22
+ ```
23
+
24
+ Also include the human-readable signature:
25
+
26
+ ```md
27
+ 🤖 *Synced by pi (AI assistant) on behalf of the developer.*
28
+ ```
29
+
30
+ The marker identifies the mutable Pi-owned status comment. Human comments after the Pi status must not force new Pi comments.
31
+
32
+ ## Decision Logic (Find / No-op / Update / Create)
33
+ To keep remote history clean, use this hierarchy for Jira, GitHub, and GitLab:
34
+
35
+ 1. Render the new sync body from local `WORK.md`.
36
+ 2. Fetch existing comments/notes for the remote issue, PR, or MR.
37
+ 3. Search for the newest comment/note containing `<!-- pi-sync-marker -->`.
38
+ 4. If a marker comment exists and normalized body is identical: **NO-OP**.
39
+ 5. If a marker comment exists and body differs: **UPDATE** that marker comment/note.
40
+ 6. If no marker comment exists: **CREATE** one new Pi status comment/note.
41
+
42
+ Do **not** use latest-comment ownership as the primary decision. Latest-comment-only logic causes infinite comment spam when humans reply after Pi.
43
+
44
+ ## Workflow
45
+
46
+ ### 1. Discovery & State Loading
47
+ - Identify the platform and ID from `.workflow/active_task.json`.
48
+ - Extract **Slices** from `[PLAN]`, **Status** from `[LOG]`, and **Artifacts** such as PR/MR links, commit hashes, and verification output.
49
+
50
+ ### 2. Payload Preparation
51
+ Format the message for two audiences:
52
+ - **Stakeholders**: summarize outcome, current state, and next step.
53
+ - **Developers**: list vertical slices, commit/PR/MR links, and verification evidence.
54
+ - **Signature and marker**: always append both the signature and `<!-- pi-sync-marker -->`.
55
+
56
+ ### 3. Execution
57
+
58
+ #### Jira
59
+ Use the helper so ADF parsing and marker search stay centralized:
60
+
61
+ ```bash
62
+ cat body.md | <skill_location>/jira_smart_sync.sh <ISSUE_ID>
63
+ ```
64
+
65
+ Behavior:
66
+ - fetch recent comments newest-first, default limit `50`
67
+ - override limit with `PI_SYNC_COMMENT_LIMIT=<n>` if needed
68
+ - find newest marker comment anywhere in the fetched window
69
+ - update marker comment by ID, no-op if identical, create only if no marker exists
70
+
71
+ #### GitHub Issues / PRs
72
+ Use the issue comments API. PR comments use issue comments for PR body discussion.
73
+
74
+ Check:
75
+ ```bash
76
+ gh api repos/:owner/:repo/issues/<id>/comments --paginate \
77
+ --jq 'map(select(.body | contains("<!-- pi-sync-marker -->"))) | last'
78
+ ```
79
+
80
+ Update:
81
+ ```bash
82
+ gh api -X PATCH repos/:owner/:repo/issues/comments/<comment_id> \
83
+ -f body=@body.md
84
+ ```
85
+
86
+ Create:
87
+ ```bash
88
+ gh issue comment <id> --body-file body.md
89
+ ```
90
+
91
+ Rules:
92
+ - update only a comment containing the marker
93
+ - no-op when normalized body is already current
94
+ - create only when no marker comment exists
95
+
96
+ #### GitLab Issues / MRs
97
+ Use notes API for issues or merge requests.
98
+
99
+ Check MR notes:
100
+ ```bash
101
+ glab api projects/:id/merge_requests/<iid>/notes --paginate \
102
+ --jq 'map(select(.body | contains("<!-- pi-sync-marker -->"))) | last'
103
+ ```
104
+
105
+ Update MR note:
106
+ ```bash
107
+ glab api -X PUT projects/:id/merge_requests/<iid>/notes/<note_id> \
108
+ -f body=@body.md
109
+ ```
110
+
111
+ Create MR note:
112
+ ```bash
113
+ glab mr note <iid> --message "$(cat body.md)"
114
+ ```
115
+
116
+ Rules:
117
+ - update only a note containing the marker
118
+ - no-op when normalized body is already current
119
+ - create only when no marker note exists
120
+
121
+ ### 4. Local Confirmation
122
+ - Append a timestamped sync record to `WORK.md` `[LOG]` with action: `no-op`, `updated`, or `created`.
123
+ - Do not edit `[BRIEF]` or `[GRILL]`.
124
+
125
+ ## Output Contract
126
+ Return a concise summary:
127
+ - **Target**: platform and issue/PR/MR ID
128
+ - **Action**: no-op / updated existing status / created new status
129
+ - **Reason**: marker found, body identical, marker missing, etc.
130
+ - **Link**: remote comment/note URL if available
131
+ - **Next step**: review, verify, cleanup, or continue implementation
@@ -0,0 +1,39 @@
1
+ # Design Brief: Hardened Tracker Sync (Zero-Trust)
2
+
3
+ ## Skill Type
4
+ Workflow skill with supporting scripts for high-reliability tracker interaction.
5
+
6
+ ## Audience/Context
7
+ Developers using `pi` for RPIV or task-based implementation, reporting progress to Jira, GitHub, or GitLab.
8
+
9
+ ## Trigger Conditions
10
+ - Running `/sync` on a task with source `jira`, `github`, or `gitlab`.
11
+ - Running `/verify` or manually requesting a tracker update.
12
+
13
+ ## Inputs
14
+ - `WORK.md`: factual state for slices, commits, PR/MR links, and test results.
15
+ - Tracker comments/notes API: existing remote status state.
16
+ - `<!-- pi-sync-marker -->`: stable marker used to identify Pi-owned living status comments.
17
+
18
+ ## Outputs
19
+ - Updated existing Pi marker comment/note when content changed.
20
+ - Created new Pi marker comment/note only when no marker exists.
21
+ - No action when the existing Pi marker comment/note already matches local state.
22
+
23
+ ## Key Decisions & Heuristics
24
+ 1. **Marker Over Latest Actor**: Search for the newest comment/note containing `<!-- pi-sync-marker -->`; do not rely on the absolute latest comment. Human replies after Pi must not create infinite Pi comments.
25
+ 2. **Human Comment Safety**: Only marker-owned Pi comments are mutable. Never edit human-authored comments.
26
+ 3. **Recursive ADF Parsing**: Jira Cloud uses Atlassian Document Format. The Jira helper must crawl JSON recursively to find marker/signature text.
27
+ 4. **Identity Comparison**: Normalize whitespace before comparing remote and local bodies to avoid unnecessary updates.
28
+ 5. **Shell Safety**: Use temporary files and `--body-file`/API body file equivalents where possible to avoid shell length/escaping issues.
29
+ 6. **Bounded Search**: Jira helper defaults to the newest 50 comments via `PI_SYNC_COMMENT_LIMIT`; increase only when marker comments are older than the default window.
30
+
31
+ ## File Structure
32
+ - `sync/SKILL.md`: operational instructions for Jira/GitHub/GitLab.
33
+ - `sync/jira_smart_sync.sh`: Jira helper with ADF-aware marker search.
34
+
35
+ ## Success Metrics
36
+ - Zero duplicate Pi comments when progress is static.
37
+ - Zero new Pi comments when a human replies after an existing Pi status comment.
38
+ - Zero overwritten human comments.
39
+ - Reliable detection of Pi's living status comment even after Jira ADF conversion.
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # jira_smart_sync.sh - Hardened, Zero-Trust Sync for ACLI
4
+ # Usage: cat body.txt | ./jira_smart_sync.sh <ISSUE_ID>
5
+ #
6
+ # Maintains one Pi-owned living status comment per Jira work item.
7
+ # It finds an existing marker comment anywhere in the fetched window and updates it.
8
+ # It creates a new comment only when no Pi marker comment exists.
9
+
10
+ set -euo pipefail
11
+
12
+ ISSUE_ID=${1:-}
13
+ PI_SYNC_MARKER="<!-- pi-sync-marker -->"
14
+ PI_SIGNATURE="🤖 *Synced by pi (AI assistant) on behalf of the developer.*"
15
+ COMMENT_LIMIT=${PI_SYNC_COMMENT_LIMIT:-50}
16
+ TMP_BODY=$(mktemp)
17
+ TMP_JSON=$(mktemp)
18
+
19
+ cleanup() {
20
+ rm -f "$TMP_BODY" "$TMP_JSON"
21
+ }
22
+ trap cleanup EXIT
23
+
24
+ cat > "$TMP_BODY"
25
+
26
+ if [[ -z "$ISSUE_ID" ]]; then
27
+ echo "Usage: cat body.txt | $0 <ISSUE_ID>"
28
+ exit 1
29
+ fi
30
+
31
+ # Fetch newest comments first. The marker search is not latest-comment-only;
32
+ # the limit only bounds API cost/noise. Increase PI_SYNC_COMMENT_LIMIT if needed.
33
+ acli jira workitem comment list --key "$ISSUE_ID" --json --limit "$COMMENT_LIMIT" --order "-created" > "$TMP_JSON"
34
+
35
+ export PI_SYNC_MARKER
36
+ export PI_SIGNATURE
37
+ export NEW_BODY_FILE="$TMP_BODY"
38
+ export COMMENTS_JSON_FILE="$TMP_JSON"
39
+
40
+ MATCH_DATA=$(python3 <<'PY'
41
+ import json
42
+ import os
43
+ import re
44
+ import sys
45
+ from pathlib import Path
46
+
47
+ marker = os.environ.get("PI_SYNC_MARKER", "<!-- pi-sync-marker -->")
48
+ signature = os.environ.get("PI_SIGNATURE", "")
49
+ new_body_file = Path(os.environ["NEW_BODY_FILE"])
50
+ comments_json_file = Path(os.environ["COMMENTS_JSON_FILE"])
51
+
52
+
53
+ def get_text(obj):
54
+ if obj is None:
55
+ return ""
56
+ if isinstance(obj, str):
57
+ return obj
58
+ if isinstance(obj, list):
59
+ return "\n".join(get_text(x) for x in obj)
60
+ if isinstance(obj, dict):
61
+ # Common flattened outputs first.
62
+ rendered = obj.get("renderedBody")
63
+ if isinstance(rendered, str):
64
+ return rendered
65
+ body = obj.get("body")
66
+ if isinstance(body, str):
67
+ return body
68
+ # Atlassian Document Format text nodes.
69
+ if obj.get("type") == "text":
70
+ return obj.get("text", "")
71
+ content = obj.get("content")
72
+ if content:
73
+ return get_text(content)
74
+ return "\n".join(get_text(v) for v in obj.values() if isinstance(v, (dict, list, str)))
75
+ return str(obj)
76
+
77
+
78
+ def normalize(text):
79
+ # Jira/ACLI rendering can alter blank lines and trailing whitespace. Keep the
80
+ # comparison conservative: avoid duplicates when content is materially equal.
81
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
82
+ lines = [line.rstrip() for line in text.strip().split("\n")]
83
+ return re.sub(r"\n{3,}", "\n\n", "\n".join(lines)).strip()
84
+
85
+
86
+ def comment_id(comment):
87
+ for key in ("id", "commentId", "comment_id"):
88
+ value = comment.get(key) if isinstance(comment, dict) else None
89
+ if value is not None:
90
+ return str(value)
91
+ return ""
92
+
93
+
94
+ def unwrap_comments(data):
95
+ if isinstance(data, list):
96
+ return data
97
+ if isinstance(data, dict):
98
+ for key in ("comments", "values", "results", "data"):
99
+ value = data.get(key)
100
+ if isinstance(value, list):
101
+ return value
102
+ if "id" in data:
103
+ return [data]
104
+ return []
105
+
106
+ try:
107
+ new_text = new_body_file.read_text().strip()
108
+ data = json.loads(comments_json_file.read_text())
109
+ comments = unwrap_comments(data)
110
+
111
+ if not comments:
112
+ print("CREATE|no existing comments")
113
+ raise SystemExit(0)
114
+
115
+ # Comments are fetched newest first. Pick the newest Pi-owned marker comment,
116
+ # not necessarily the absolute latest comment. Human comments remain untouched.
117
+ for comment in comments:
118
+ text = get_text(comment.get("body", comment)) if isinstance(comment, dict) else get_text(comment)
119
+ owns_comment = marker in text or (signature and signature in text)
120
+ if not owns_comment:
121
+ continue
122
+
123
+ cid = comment_id(comment)
124
+ if not cid:
125
+ print("CREATE|marker found without id")
126
+ raise SystemExit(0)
127
+
128
+ if normalize(new_text) == normalize(text):
129
+ print(f"NOOP|{cid}")
130
+ else:
131
+ print(f"UPDATE|{cid}")
132
+ raise SystemExit(0)
133
+
134
+ print("CREATE|no pi marker comment")
135
+ except Exception as exc:
136
+ # Fail-open to create: better to keep stakeholder visibility than silently drop sync.
137
+ print(f"CREATE|parse fallback: {exc}")
138
+ PY
139
+ )
140
+
141
+ ACTION_CODE=$(echo "$MATCH_DATA" | cut -d'|' -f1)
142
+ TARGET_ID=$(echo "$MATCH_DATA" | cut -d'|' -f2-)
143
+
144
+ case "$ACTION_CODE" in
145
+ NOOP)
146
+ echo "SYNC: Existing Pi status is already current (ID: $TARGET_ID)."
147
+ ;;
148
+ UPDATE)
149
+ echo "SYNC: Updating existing Pi status (ID: $TARGET_ID)."
150
+ acli jira workitem comment update --key "$ISSUE_ID" --id "$TARGET_ID" --body-file "$TMP_BODY"
151
+ ;;
152
+ *)
153
+ echo "SYNC: Creating Pi status comment ($TARGET_ID)."
154
+ acli jira workitem comment create --key "$ISSUE_ID" --body-file "$TMP_BODY"
155
+ ;;
156
+ esac