From f6b3d6aaefc37b149a1dd90fc1750b62fb4010b2 Mon Sep 17 00:00:00 2001 From: Harshal Patil <12152047+harche@users.noreply.github.com> Date: Sat, 9 May 2026 12:37:02 -0400 Subject: [PATCH 1/2] Add node-team plugin OpenShift Node team assistant plugin for development, deployment, debugging, and workflow tasks across kubelet, MCO, CRI-O, crun, conmonrs, Kueue operator, Jira, and platform docs. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude-plugin/marketplace.json | 6 + plugins/node-team/.claude-plugin/plugin.json | 8 + .../config/team-roster-core.example.json | 7 + .../config/team-roster-dra.example.json | 7 + plugins/node-team/scripts/jira.sh | 122 +++ plugins/node-team/scripts/lib/api/comment.sh | 40 + plugins/node-team/scripts/lib/api/fields.sh | 85 ++ plugins/node-team/scripts/lib/api/health.sh | 97 +++ plugins/node-team/scripts/lib/api/issue.sh | 147 ++++ plugins/node-team/scripts/lib/api/sprint.sh | 41 + .../node-team/scripts/lib/api/transition.sh | 70 ++ .../scripts/lib/composite/bug-overview.sh | 145 ++++ .../scripts/lib/composite/carryover-report.sh | 91 ++ .../scripts/lib/composite/epic-progress.sh | 177 ++++ .../scripts/lib/composite/issue-deep-dive.sh | 110 +++ .../scripts/lib/composite/my-board-data.sh | 120 +++ .../scripts/lib/composite/my-bugs-data.sh | 72 ++ .../scripts/lib/composite/my-standup-data.sh | 152 ++++ .../scripts/lib/composite/pickup-data.sh | 75 ++ .../scripts/lib/composite/planning-data.sh | 105 +++ .../scripts/lib/composite/release-data.sh | 111 +++ .../scripts/lib/composite/roster-sync.sh | 89 ++ .../scripts/lib/composite/sprint-dashboard.sh | 187 ++++ .../scripts/lib/composite/standup-data.sh | 295 +++++++ .../scripts/lib/composite/team-activity.sh | 151 ++++ plugins/node-team/scripts/lib/core.sh | 88 ++ plugins/node-team/scripts/lib/team.sh | 295 +++++++ plugins/node-team/scripts/lib/util/adf.py | 158 ++++ plugins/node-team/scripts/lib/util/cache.sh | 66 ++ .../node-team/scripts/lib/util/parallel.sh | 128 +++ plugins/node-team/scripts/lib/util/retry.sh | 99 +++ plugins/node-team/scripts/ocp-install.sh | 810 ++++++++++++++++++ plugins/node-team/scripts/worktree.sh | 351 ++++++++ plugins/node-team/scripts/worktree_test.sh | 270 ++++++ plugins/node-team/skills/node/SKILL.md | 10 + .../node-team/skills/node/references/INDEX.md | 34 + .../node-team/skills/node/references/SETUP.md | 94 ++ .../references/deployment/debug-binary.md | 113 +++ .../deployment/debug-binary/crio.md | 195 +++++ .../deployment/debug-binary/cross-compile.md | 160 ++++ .../deployment/debug-binary/deploy.md | 198 +++++ .../deployment/debug-binary/rollback.md | 137 +++ .../deployment/debug-binary/ssh-bastion.md | 150 ++++ .../node/references/development/crio-dev.md | 26 + .../references/development/crun-conmon.md | 26 + .../references/development/kubelet-dev.md | 22 + .../development/kueue-operator-dev.md | 17 + .../node/references/development/mco-dev.md | 40 + .../node/references/development/worktrees.md | 37 + .../node-team/skills/node/references/jira.md | 201 +++++ .../skills/node/references/platform-docs.md | 23 + .../skills/node/references/prometheus.md | 35 + .../skills/node/references/support.md | 34 + 53 files changed, 6327 insertions(+) create mode 100644 plugins/node-team/.claude-plugin/plugin.json create mode 100644 plugins/node-team/config/team-roster-core.example.json create mode 100644 plugins/node-team/config/team-roster-dra.example.json create mode 100755 plugins/node-team/scripts/jira.sh create mode 100644 plugins/node-team/scripts/lib/api/comment.sh create mode 100644 plugins/node-team/scripts/lib/api/fields.sh create mode 100644 plugins/node-team/scripts/lib/api/health.sh create mode 100644 plugins/node-team/scripts/lib/api/issue.sh create mode 100644 plugins/node-team/scripts/lib/api/sprint.sh create mode 100644 plugins/node-team/scripts/lib/api/transition.sh create mode 100644 plugins/node-team/scripts/lib/composite/bug-overview.sh create mode 100644 plugins/node-team/scripts/lib/composite/carryover-report.sh create mode 100644 plugins/node-team/scripts/lib/composite/epic-progress.sh create mode 100644 plugins/node-team/scripts/lib/composite/issue-deep-dive.sh create mode 100644 plugins/node-team/scripts/lib/composite/my-board-data.sh create mode 100644 plugins/node-team/scripts/lib/composite/my-bugs-data.sh create mode 100644 plugins/node-team/scripts/lib/composite/my-standup-data.sh create mode 100644 plugins/node-team/scripts/lib/composite/pickup-data.sh create mode 100644 plugins/node-team/scripts/lib/composite/planning-data.sh create mode 100644 plugins/node-team/scripts/lib/composite/release-data.sh create mode 100644 plugins/node-team/scripts/lib/composite/roster-sync.sh create mode 100644 plugins/node-team/scripts/lib/composite/sprint-dashboard.sh create mode 100644 plugins/node-team/scripts/lib/composite/standup-data.sh create mode 100644 plugins/node-team/scripts/lib/composite/team-activity.sh create mode 100644 plugins/node-team/scripts/lib/core.sh create mode 100644 plugins/node-team/scripts/lib/team.sh create mode 100644 plugins/node-team/scripts/lib/util/adf.py create mode 100644 plugins/node-team/scripts/lib/util/cache.sh create mode 100644 plugins/node-team/scripts/lib/util/parallel.sh create mode 100644 plugins/node-team/scripts/lib/util/retry.sh create mode 100755 plugins/node-team/scripts/ocp-install.sh create mode 100755 plugins/node-team/scripts/worktree.sh create mode 100755 plugins/node-team/scripts/worktree_test.sh create mode 100644 plugins/node-team/skills/node/SKILL.md create mode 100644 plugins/node-team/skills/node/references/INDEX.md create mode 100644 plugins/node-team/skills/node/references/SETUP.md create mode 100644 plugins/node-team/skills/node/references/deployment/debug-binary.md create mode 100644 plugins/node-team/skills/node/references/deployment/debug-binary/crio.md create mode 100644 plugins/node-team/skills/node/references/deployment/debug-binary/cross-compile.md create mode 100644 plugins/node-team/skills/node/references/deployment/debug-binary/deploy.md create mode 100644 plugins/node-team/skills/node/references/deployment/debug-binary/rollback.md create mode 100644 plugins/node-team/skills/node/references/deployment/debug-binary/ssh-bastion.md create mode 100644 plugins/node-team/skills/node/references/development/crio-dev.md create mode 100644 plugins/node-team/skills/node/references/development/crun-conmon.md create mode 100644 plugins/node-team/skills/node/references/development/kubelet-dev.md create mode 100644 plugins/node-team/skills/node/references/development/kueue-operator-dev.md create mode 100644 plugins/node-team/skills/node/references/development/mco-dev.md create mode 100644 plugins/node-team/skills/node/references/development/worktrees.md create mode 100644 plugins/node-team/skills/node/references/jira.md create mode 100644 plugins/node-team/skills/node/references/platform-docs.md create mode 100644 plugins/node-team/skills/node/references/prometheus.md create mode 100644 plugins/node-team/skills/node/references/support.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index da8eed40b..65db0ebea 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -238,6 +238,12 @@ "source": "./plugins/marketplace-ops", "description": "Maintenance commands for Claude Code plugin marketplaces", "version": "0.1.2" + }, + { + "name": "node-team", + "source": "./plugins/node-team", + "description": "OpenShift Node team assistant for development, deployment, debugging, and workflow tasks", + "version": "0.11.0" } ] } diff --git a/plugins/node-team/.claude-plugin/plugin.json b/plugins/node-team/.claude-plugin/plugin.json new file mode 100644 index 000000000..68db738bb --- /dev/null +++ b/plugins/node-team/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "node-team", + "description": "OpenShift Node team assistant for development, deployment, debugging, and workflow tasks across kubelet, MCO, CRI-O, crun, conmonrs, Kueue operator, Jira, Red Hat KB/support cases, Prometheus, and platform docs.", + "version": "0.11.0", + "author": { + "name": "github.com/openshift-eng" + } +} diff --git a/plugins/node-team/config/team-roster-core.example.json b/plugins/node-team/config/team-roster-core.example.json new file mode 100644 index 000000000..a110f5990 --- /dev/null +++ b/plugins/node-team/config/team-roster-core.example.json @@ -0,0 +1,7 @@ +{ + "description": "Node Core team roster — maps Jira display names to GitHub handles", + "members": { + "Jira Display Name": "github-handle", + "Another Person": "their-github-handle" + } +} diff --git a/plugins/node-team/config/team-roster-dra.example.json b/plugins/node-team/config/team-roster-dra.example.json new file mode 100644 index 000000000..de63d34fe --- /dev/null +++ b/plugins/node-team/config/team-roster-dra.example.json @@ -0,0 +1,7 @@ +{ + "description": "Node Devices (DRA) team roster — maps Jira display names to GitHub handles", + "members": { + "Jira Display Name": "github-handle", + "Another Person": "their-github-handle" + } +} diff --git a/plugins/node-team/scripts/jira.sh b/plugins/node-team/scripts/jira.sh new file mode 100755 index 000000000..6e432472d --- /dev/null +++ b/plugins/node-team/scripts/jira.sh @@ -0,0 +1,122 @@ +#!/bin/bash +# Jira CLI — thin dispatcher +# Sources modular libraries from lib/ and dispatches subcommands +# All existing commands are backward-compatible; composite commands are additive. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# ── Load modules ─────────────────────────────────────────────────────────────── + +source "${SCRIPT_DIR}/lib/core.sh" +source "${SCRIPT_DIR}/lib/api/issue.sh" +source "${SCRIPT_DIR}/lib/api/sprint.sh" +source "${SCRIPT_DIR}/lib/api/comment.sh" +source "${SCRIPT_DIR}/lib/api/transition.sh" +source "${SCRIPT_DIR}/lib/api/fields.sh" +source "${SCRIPT_DIR}/lib/api/health.sh" + +# Load utilities if available +[[ -f "${SCRIPT_DIR}/lib/util/retry.sh" ]] && source "${SCRIPT_DIR}/lib/util/retry.sh" +[[ -f "${SCRIPT_DIR}/lib/util/parallel.sh" ]] && source "${SCRIPT_DIR}/lib/util/parallel.sh" +[[ -f "${SCRIPT_DIR}/lib/util/cache.sh" ]] && source "${SCRIPT_DIR}/lib/util/cache.sh" +[[ -f "${SCRIPT_DIR}/lib/team.sh" ]] && source "${SCRIPT_DIR}/lib/team.sh" + +# Load composite commands if available +for f in "${SCRIPT_DIR}"/lib/composite/*.sh; do + [[ -f "$f" ]] && source "$f" +done + +# ── Help ─────────────────────────────────────────────────────────────────────── + +cmd_help() { + cat <<'EOF' +Usage: jira.sh [args] + +Low-level API commands: + search [limit] Search issues (default limit: 50) + get Get full issue details + sprints [state] List sprints (active|future|closed) + sprint-issues [limit] Get issues in a sprint (default limit: 100) + comments List comments on an issue + comment Add a comment to one or more issues + move-to-sprint Move issue(s) to a sprint + set-points Set story points on an issue + set-field Set any field (value: string, number, or JSON) + create [extra-fields-json] Create a new issue + assign Assign issue (resolves name via roster + Jira API) + find-user Search for a user by name (roster + Jira API) + link [title] Add a remote link (e.g., GitHub PR/issue) + transitions Get available transitions + transition Perform a transition on one or more issues + close [comment] Comment (optional) + close one or more issues + start-sprint Start a sprint (set state to active) + close-sprint Close a sprint (set state to closed) + health-check Validate custom field IDs against Jira metadata + +High-level composite commands: + sprint-dashboard Sprint info + issues by status + workload + blockers + standup-data Dashboard + recent updates + new bugs + comments + bug-overview Bug triage data (untriaged, unassigned, blockers, new) + carryover-report Not-done items with carryover context + planning-data Full planning package (carryovers + backlog + bugs) + issue-deep-dive Full issue + comments (ADF converted) + linked issues + release-data [version] Release readiness (blockers, bugs, epics) + team-activity Per-member sprint items + comment counts + roster-sync [--force] Download team rosters from Jira attachments + +Options: + --sprint Target a specific sprint (default: active) + --stream Stream JSON Lines output (composite commands only) + +Environment: + JIRA_EMAIL Override Jira email (default: Keychain account or git config user.email) + JIRA_BOARD_ID Override board ID (default: 7845) + NODE_ASSISTANT_CONFIG_ISSUE Jira issue with roster attachments (default: OCPNODE-4230) +EOF +} + +# ── Dispatch ─────────────────────────────────────────────────────────────────── + +case "${1:-help}" in + # Low-level API commands (backward-compatible) + search) cmd_search "${2:?JQL required}" "${3:-50}" ;; + get) cmd_get "${2:?ISSUE-KEY required}" ;; + sprints) cmd_sprints "${2:-active}" ;; + sprint-issues) cmd_sprint_issues "${2:?Sprint ID required}" "${3:-100}" ;; + comments) cmd_comments "${2:?ISSUE-KEY required}" ;; + comment) cmd_comment "${2:?Comment body required}" "${@:3}" ;; + move-to-sprint) cmd_move_to_sprint "${2:?Sprint ID required}" "${@:3}" ;; + set-points) cmd_set_points "${2:?ISSUE-KEY required}" "${3:?Story points required}" ;; + set-field) cmd_set_field "${2:?ISSUE-KEY required}" "${3:?Field ID required}" "${4:?Value required}" ;; + create) cmd_create "${2:?Project key required}" "${3:?Issue type required}" "${4:?Summary required}" "${5:-}" ;; + assign) cmd_assign "${2:?ISSUE-KEY required}" "${3:?Assignee name or accountId required}" ;; + find-user) cmd_find_user "${2:?Search query required}" ;; + link) cmd_link "${2:?ISSUE-KEY required}" "${3:?URL required}" "${4:-}" ;; + transitions) cmd_transitions "${2:?ISSUE-KEY required}" ;; + transition) cmd_transition "${2:?Transition ID required}" "${@:3}" ;; + close) cmd_close "${@:2}" ;; + start-sprint) cmd_start_sprint "${2:?Sprint ID required}" ;; + close-sprint) cmd_close_sprint "${2:?Sprint ID required}" ;; + health-check) cmd_health_check ;; + + # High-level composite commands + sprint-dashboard) cmd_sprint_dashboard "${@:2}" ;; + standup-data) cmd_standup_data "${@:2}" ;; + bug-overview) cmd_bug_overview "${@:2}" ;; + carryover-report) cmd_carryover_report "${@:2}" ;; + planning-data) cmd_planning_data "${@:2}" ;; + issue-deep-dive) cmd_issue_deep_dive "${@:2}" ;; + release-data) cmd_release_data "${@:2}" ;; + team-activity) cmd_team_activity "${@:2}" ;; + roster-sync) cmd_roster_sync "${@:2}" ;; + my-board-data) cmd_my_board_data "${@:2}" ;; + my-bugs-data) cmd_my_bugs_data "${@:2}" ;; + my-standup-data) cmd_my_standup_data "${@:2}" ;; + epic-progress) cmd_epic_progress "${@:2}" ;; + pickup-data) cmd_pickup_data "${@:2}" ;; + + help|--help|-h) cmd_help ;; + *) echo "Unknown command: $1" >&2; cmd_help >&2; exit 1 ;; +esac diff --git a/plugins/node-team/scripts/lib/api/comment.sh b/plugins/node-team/scripts/lib/api/comment.sh new file mode 100644 index 000000000..077efe9c0 --- /dev/null +++ b/plugins/node-team/scripts/lib/api/comment.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# API commands: list and add comments +# Sourced by jira.sh — requires core.sh + +[[ -n "${_API_COMMENT_LOADED:-}" ]] && return 0 +_API_COMMENT_LOADED=1 + +cmd_comments() { + local key="$1" + _curl "${JIRA_BASE}/rest/api/3/issue/${key}/comment" +} + +cmd_comment() { + local body="$1" + shift + local keys=("$@") + local payload + payload=$(python3 -c " +import json, sys +body = sys.argv[1] +print(json.dumps({ + 'body': { + 'version': 1, + 'type': 'doc', + 'content': [{'type': 'paragraph', 'content': [{'type': 'text', 'text': body}]}] + } +})) +" "$body") + for key in "${keys[@]}"; do + local result + result=$(_curl -X POST "${JIRA_BASE}/rest/api/3/issue/${key}/comment" -d "$payload" -w "\nHTTP_%{http_code}" 2>&1) + local code + code=$(echo "$result" | grep "HTTP_" | sed 's/HTTP_//') + if [[ "$code" == "201" ]]; then + echo "{\"key\":\"${key}\",\"status\":\"ok\"}" + else + echo "{\"key\":\"${key}\",\"status\":\"error\",\"code\":\"${code}\"}" >&2 + fi + done +} diff --git a/plugins/node-team/scripts/lib/api/fields.sh b/plugins/node-team/scripts/lib/api/fields.sh new file mode 100644 index 000000000..a6a958cc7 --- /dev/null +++ b/plugins/node-team/scripts/lib/api/fields.sh @@ -0,0 +1,85 @@ +#!/bin/bash +# API commands: set fields, set story points, move to sprint +# Sourced by jira.sh — requires core.sh + +[[ -n "${_API_FIELDS_LOADED:-}" ]] && return 0 +_API_FIELDS_LOADED=1 + +cmd_set_points() { + local key="$1" + local points="$2" + _curl -X PUT "${JIRA_BASE}/rest/api/3/issue/${key}" \ + -d "{\"fields\": {\"${CF_STORY_POINTS}\": ${points}}}" +} + +cmd_set_field() { + local key="$1" + local field="$2" + local value="$3" + local payload + payload=$(python3 -c " +import json, sys +key, field, value = sys.argv[1], sys.argv[2], sys.argv[3] +# Try parsing as JSON first (for arrays, objects, numbers, booleans) +try: + parsed = json.loads(value) +except (json.JSONDecodeError, ValueError): + parsed = value # plain string +print(json.dumps({'fields': {field: parsed}})) +" "$key" "$field" "$value") + local result + result=$(_curl -X PUT "${JIRA_BASE}/rest/api/3/issue/${key}" -d "$payload" -w "\nHTTP_%{http_code}") + local code + code=$(echo "$result" | grep "HTTP_" | sed 's/HTTP_//') + if [[ "$code" == "204" ]]; then + echo "{\"key\":\"${key}\",\"field\":\"${field}\",\"status\":\"ok\"}" + else + local body + body=$(echo "$result" | grep -v "HTTP_") + echo "{\"key\":\"${key}\",\"field\":\"${field}\",\"status\":\"error\",\"code\":\"${code}\",\"response\":${body:-\"{}\"}}" >&2 + return 1 + fi +} + +cmd_link() { + local key="$1" + local url="$2" + local title="${3:-$url}" + local payload + payload=$(python3 -c " +import json, sys +url, title = sys.argv[1], sys.argv[2] +# Auto-detect icon for GitHub URLs +icon = {} +if 'github.com' in url: + icon = {'url16x16': 'https://github.com/favicon.ico', 'title': 'GitHub'} +print(json.dumps({ + 'object': { + 'url': url, + 'title': title, + 'icon': icon + } +})) +" "$url" "$title") + local result + result=$(_curl -X POST "${JIRA_BASE}/rest/api/3/issue/${key}/remotelink" -d "$payload" -w "\nHTTP_%{http_code}") + local code + code=$(echo "$result" | grep "HTTP_" | sed 's/HTTP_//') + if [[ "$code" == "200" || "$code" == "201" ]]; then + echo "{\"key\":\"${key}\",\"url\":\"${url}\",\"status\":\"ok\"}" + else + local body + body=$(echo "$result" | grep -v "HTTP_") + echo "{\"key\":\"${key}\",\"url\":\"${url}\",\"status\":\"error\",\"code\":\"${code}\",\"response\":${body:-\"{}\"}}" >&2 + return 1 + fi +} + +cmd_move_to_sprint() { + local sprint_id="$1" + shift + local issues=("$@") + local json_issues + json_issues=$(python3 -c "import json,sys; print(json.dumps(sys.argv[1:]))" "${issues[@]}") + _curl -X POST "${JIRA_BASE}/rest/agile/1.0/sprint/${sprint_id}/issue" -d "{\"issues\": ${json_issues}}" +} diff --git a/plugins/node-team/scripts/lib/api/health.sh b/plugins/node-team/scripts/lib/api/health.sh new file mode 100644 index 000000000..d35973b39 --- /dev/null +++ b/plugins/node-team/scripts/lib/api/health.sh @@ -0,0 +1,97 @@ +#!/bin/bash +# API commands: health check — validates field metadata against Jira +# Sourced by jira.sh — requires core.sh + +[[ -n "${_API_HEALTH_LOADED:-}" ]] && return 0 +_API_HEALTH_LOADED=1 + +cmd_health_check() { + _init_auth + + # Fetch all field definitions from Jira (1 API call) + local fields_json + fields_json=$(_curl "${JIRA_BASE}/rest/api/3/field") + + # Validate our custom field IDs against actual Jira metadata + python3 - "$fields_json" < 0 + +print(json.dumps({ + "status": "HEALTHY" if errors == 0 and warnings == 0 else "DEGRADED" if errors == 0 else "UNHEALTHY", + "summary": { + "fieldsChecked": len(expected), + "errors": errors, + "warnings": warnings, + "totalJiraFields": len(fields_data), + "apiConnectivity": api_ok, + }, + "fields": results, + "missingStandardFields": missing_standard, +}, indent=2)) +PYEOF +} diff --git a/plugins/node-team/scripts/lib/api/issue.sh b/plugins/node-team/scripts/lib/api/issue.sh new file mode 100644 index 000000000..1725afe8a --- /dev/null +++ b/plugins/node-team/scripts/lib/api/issue.sh @@ -0,0 +1,147 @@ +#!/bin/bash +# API commands: issue search and get +# Sourced by jira.sh — requires core.sh + +[[ -n "${_API_ISSUE_LOADED:-}" ]] && return 0 +_API_ISSUE_LOADED=1 + +cmd_search() { + local jql="$1" + local limit="${2:-50}" + local fields_json="${3:-$SEARCH_FIELDS_JSON}" + local payload + payload=$(python3 -c " +import json, sys +print(json.dumps({ + 'jql': sys.argv[1], + 'maxResults': int(sys.argv[2]), + 'fields': json.loads(sys.argv[3]) +})) +" "$jql" "$limit" "$fields_json") + _curl -X POST "${JIRA_BASE}/rest/api/3/search/jql" -d "$payload" +} + +cmd_get() { + local key="$1" + local fields="${2:-}" + if [[ -n "$fields" ]]; then + _curl "${JIRA_BASE}/rest/api/3/issue/${key}?fields=${fields}" + else + _curl "${JIRA_BASE}/rest/api/3/issue/${key}" + fi +} + +cmd_create() { + local project="$1" + local issuetype="$2" + local summary="$3" + local extra_fields="${4:-}" + local payload + payload=$(python3 -c " +import json, sys +project, issuetype, summary = sys.argv[1], sys.argv[2], sys.argv[3] +extra = sys.argv[4] if len(sys.argv) > 4 and sys.argv[4] else '{}' +try: + extra_parsed = json.loads(extra) +except (json.JSONDecodeError, ValueError): + extra_parsed = {} +fields = { + 'project': {'key': project}, + 'issuetype': {'name': issuetype}, + 'summary': summary, +} +fields.update(extra_parsed) +print(json.dumps({'fields': fields})) +" "$project" "$issuetype" "$summary" "$extra_fields") + _curl -X POST "${JIRA_BASE}/rest/api/3/issue" -d "$payload" +} + +cmd_find_user() { + local query="$1" + local root_dir + root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../" && pwd)" + python3 -c " +import json, sys, os, glob + +query = sys.argv[1].lower() +root_dir = sys.argv[2] +na_dir = os.path.expanduser('~/.node-assistant') + +# Search roster files first +roster_files = [] +for d in [na_dir, os.path.join(root_dir, 'config')]: + roster_files.extend(glob.glob(os.path.join(d, 'team-roster-*.json'))) + +for rf in roster_files: + try: + with open(rf) as f: + data = json.load(f) + for name, github in data.get('members', {}).items(): + if query in name.lower(): + print(json.dumps({'displayName': name, 'github': github, 'source': os.path.basename(rf)})) + except Exception: + continue +" "$query" "$root_dir" + + # Also search via Jira API + local encoded + encoded=$(python3 -c "import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=''))" "$query") + local results + results=$(_curl "${JIRA_BASE}/rest/api/3/user/search?query=${encoded}&maxResults=5" 2>/dev/null) || true + if [[ -n "$results" && "$results" != "[]" ]]; then + echo "$results" | python3 -c " +import json, sys +users = json.load(sys.stdin) +for u in users: + if u.get('accountType') == 'atlassian': + print(json.dumps({'accountId': u['accountId'], 'displayName': u.get('displayName', ''), 'email': u.get('emailAddress', ''), 'source': 'jira-api'})) +" 2>/dev/null || true + fi +} + +cmd_assign() { + local key="$1" + local assignee="$2" + + local account_id="" + + # If it looks like an accountId already (contains colon), use directly + if [[ "$assignee" == *":"* ]]; then + account_id="$assignee" + else + # Try to resolve via find-user + local matches + matches=$(cmd_find_user "$assignee" 2>/dev/null) + if [[ -n "$matches" ]]; then + # Prefer Jira API result (has accountId) + account_id=$(echo "$matches" | python3 -c " +import json, sys +lines = [l.strip() for l in sys.stdin if l.strip()] +for l in lines: + d = json.loads(l) + if 'accountId' in d: + print(d['accountId']) + sys.exit(0) +print('') +" 2>/dev/null) + fi + if [[ -z "$account_id" ]]; then + echo "{\"error\":\"Could not resolve user '${assignee}'. Try jira.sh find-user '${assignee}' to search.\"}" >&2 + return 1 + fi + fi + + local result + result=$(_curl -X PUT "${JIRA_BASE}/rest/api/3/issue/${key}/assignee" \ + -d "{\"accountId\": \"${account_id}\"}" -w "\nHTTP_%{http_code}") + local code + code=$(echo "$result" | grep "HTTP_" | sed 's/HTTP_//') + if [[ "$code" == "204" ]]; then + echo "{\"key\":\"${key}\",\"assignee\":\"${assignee}\",\"accountId\":\"${account_id}\",\"status\":\"ok\"}" + else + local body + body=$(echo "$result" | grep -v "HTTP_") + echo "{\"key\":\"${key}\",\"status\":\"error\",\"code\":\"${code}\",\"response\":${body:-\"{}\"}}" >&2 + return 1 + fi +} diff --git a/plugins/node-team/scripts/lib/api/sprint.sh b/plugins/node-team/scripts/lib/api/sprint.sh new file mode 100644 index 000000000..c5b19ab4f --- /dev/null +++ b/plugins/node-team/scripts/lib/api/sprint.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# API commands: sprint discovery and sprint issues +# Sourced by jira.sh — requires core.sh + +[[ -n "${_API_SPRINT_LOADED:-}" ]] && return 0 +_API_SPRINT_LOADED=1 + +cmd_sprints() { + local state="${1:-active}" + local result + result=$(_curl "${JIRA_BASE}/rest/agile/1.0/board/${BOARD_ID}/sprint?state=${state}&maxResults=50") + # Filter to Node-related sprints only + python3 - "$result" <<'PYEOF' +import sys, json +data = json.loads(sys.argv[1]) +sprints = [] +for s in data.get('values', []): + name = s.get('name', '') + if 'Node' in name or 'Kueue' in name: + sprints.append(s) +sprints.sort(key=lambda x: x.get('startDate', ''), reverse=True) +print(json.dumps({'values': sprints})) +PYEOF +} + +cmd_sprint_issues() { + local sprint_id="$1" + local limit="${2:-100}" + local fields="${3:-$ISSUE_FIELDS}" + _curl "${JIRA_BASE}/rest/agile/1.0/sprint/${sprint_id}/issue?maxResults=${limit}&fields=${fields}" +} + +cmd_start_sprint() { + local sprint_id="${1:?Sprint ID required}" + _curl -X POST -d '{"state":"active"}' "${JIRA_BASE}/rest/agile/1.0/sprint/${sprint_id}" +} + +cmd_close_sprint() { + local sprint_id="${1:?Sprint ID required}" + _curl -X POST -d '{"state":"closed"}' "${JIRA_BASE}/rest/agile/1.0/sprint/${sprint_id}" +} diff --git a/plugins/node-team/scripts/lib/api/transition.sh b/plugins/node-team/scripts/lib/api/transition.sh new file mode 100644 index 000000000..5507001a1 --- /dev/null +++ b/plugins/node-team/scripts/lib/api/transition.sh @@ -0,0 +1,70 @@ +#!/bin/bash +# API commands: transitions, transition, close +# Sourced by jira.sh — requires core.sh + +[[ -n "${_API_TRANSITION_LOADED:-}" ]] && return 0 +_API_TRANSITION_LOADED=1 + +cmd_transitions() { + local key="$1" + _curl "${JIRA_BASE}/rest/api/3/issue/${key}/transitions" +} + +cmd_transition() { + local transition_id="$1" + shift + local keys=("$@") + for key in "${keys[@]}"; do + local result + result=$(_curl -X POST "${JIRA_BASE}/rest/api/3/issue/${key}/transitions" \ + -d "{\"transition\":{\"id\":\"${transition_id}\"}}" -w "\nHTTP_%{http_code}" 2>&1) + local code + code=$(echo "$result" | grep "HTTP_" | sed 's/HTTP_//') + if [[ "$code" == "204" ]]; then + echo "{\"key\":\"${key}\",\"status\":\"ok\"}" + else + echo "{\"key\":\"${key}\",\"status\":\"error\",\"code\":\"${code}\"}" >&2 + fi + done +} + +cmd_close() { + local comment="" + local keys=() + # First arg is optional comment (if it doesn't look like an issue key) + if [[ $# -ge 1 && ! "$1" =~ ^[A-Z]+-[0-9]+$ ]]; then + comment="$1" + shift + fi + keys=("$@") + if [[ ${#keys[@]} -eq 0 ]]; then + echo '{"error":"No issue keys provided"}' >&2 + return 1 + fi + for key in "${keys[@]}"; do + if [[ -n "$comment" ]]; then + cmd_comment "$comment" "$key" > /dev/null + fi + # Resolve close transition dynamically per issue + local close_id="${JIRA_CLOSE_TRANSITION_ID:-}" + if [[ -z "$close_id" ]]; then + local transitions_json + transitions_json=$(cmd_transitions "$key") || { echo "{\"key\":\"${key}\",\"status\":\"error\",\"cause\":\"failed to fetch transitions\"}" >&2; continue; } + close_id=$(echo "$transitions_json" | python3 -c " +import json, sys +data = json.load(sys.stdin) +for name in ['Closed', 'Close', 'Done']: + for t in data.get('transitions', []): + if t.get('name', '').lower() == name.lower(): + print(t['id']) + sys.exit(0) +print('') +" 2>/dev/null) + if [[ -z "$close_id" ]]; then + echo "{\"key\":\"${key}\",\"status\":\"error\",\"cause\":\"no Close/Closed/Done transition found\"}" >&2 + continue + fi + fi + cmd_transition "$close_id" "$key" + done +} diff --git a/plugins/node-team/scripts/lib/composite/bug-overview.sh b/plugins/node-team/scripts/lib/composite/bug-overview.sh new file mode 100644 index 000000000..7d99311eb --- /dev/null +++ b/plugins/node-team/scripts/lib/composite/bug-overview.sh @@ -0,0 +1,145 @@ +#!/bin/bash +# Composite: bug-overview +# Fetches all open bugs in 3 queries (was 7), categorizes in Python +# Serves: /bug-triage, /my-bugs + +[[ -n "${_COMPOSITE_BUG_OVERVIEW_LOADED:-}" ]] && return 0 +_COMPOSITE_BUG_OVERVIEW_LOADED=1 + +cmd_bug_overview() { + local team="${1:?Team required (e.g., 'Node Devices' or 'Node Core')}" + + # ── Resolve team config ────────────────────────────────────────────────── + team_config "$team" + + local comp_filter="component in (${TEAM_BUG_COMPONENTS})" + + # ── Build assignee filter from roster ──────────────────────────────────── + local roster_json + roster_json=$(team_roster "$team") + local assignee_emails + assignee_emails=$(echo "$roster_json" | python3 -c " +import json, sys +members = json.load(sys.stdin) +names = [m['name'] for m in members] +clauses = ' OR '.join(f'assignee = \"{n}\"' for n in names) +print(clauses) +") + + # ── Extended fields ── + local bug_fields="[\"key\",\"summary\",\"status\",\"assignee\",\"priority\",\"issuetype\",\"fixVersions\",\"components\",\"${CF_STORY_POINTS}\",\"${CF_RELEASE_BLOCKER}\"]" + + # ── 4 queries: all_open + new_this_week + team_no_component + escalation-labeled + parallel_init + + parallel_run "all_open" cmd_search \ + "project = OCPBUGS AND ${comp_filter} AND status not in (Closed, Done, Verified) ORDER BY priority ASC, created DESC" 200 "$bug_fields" + + # New bugs this week (includes closed, so can't merge with all_open) + parallel_run "new_this_week" cmd_search \ + "project = OCPBUGS AND ${comp_filter} AND created >= -7d ORDER BY created DESC" 50 "$bug_fields" + + # Bugs assigned to team members outside ALL Node components + parallel_run "team_no_component" cmd_search \ + "project = OCPBUGS AND (${assignee_emails}) AND (component is EMPTY OR component not in (${ALL_NODE_COMPONENTS})) AND status not in (Closed, Done, Verified) ORDER BY priority ASC, created DESC" 50 "$bug_fields" + + + parallel_wait_all || true + + # ── Assemble results — categorize from all_open in Python ──────────────── + python3 - \ + "$(parallel_get all_open)" \ + "$(parallel_get new_this_week)" \ + "$(parallel_get team_no_component)" \ + "$roster_json" \ + <<'PYEOF' +import json, sys + +def extract_bugs(data_str): + data = json.loads(data_str) + bugs = [] + for i in data.get("issues", []): + f = i.get("fields", {}) + components = [c.get("name", "") for c in (f.get("components") or [])] + rb = f.get("customfield_10847") + bugs.append({ + "key": i.get("key", ""), + "summary": f.get("summary", ""), + "status": f.get("status", {}).get("name", ""), + "priority": f.get("priority", {}).get("name", ""), + "assignee": (f.get("assignee") or {}).get("displayName", "Unassigned"), + "points": f.get("customfield_10028") or 0, + "releaseBlocker": rb, + "fixVersions": [v.get("name", "") for v in (f.get("fixVersions") or [])], + "components": components, + }) + return bugs + +all_open = extract_bugs(sys.argv[1]) +new_this_week = extract_bugs(sys.argv[2]) +missing_component = extract_bugs(sys.argv[3]) + +# Build roster name set for CVE filtering +roster_names = {m["name"] for m in json.loads(sys.argv[4])} + +# Filter out CVE bugs that are ASSIGNED to non-roster members (handled by other teams) +def is_external_cve(b): + return ("CVE" in b["summary"].upper() + and b["status"] == "ASSIGNED" + and b["assignee"] not in roster_names + and b["assignee"] != "Unassigned") + +excluded_cves = [b for b in all_open if is_external_cve(b)] +all_open = [b for b in all_open if not is_external_cve(b)] +new_this_week = [b for b in new_this_week if not is_external_cve(b)] +missing_component = [b for b in missing_component if not is_external_cve(b)] + +# Shape assertions — warn if field formats have changed +for b in all_open[:5]: # spot-check first 5 + rb = b.get("releaseBlocker") + if rb is not None and not isinstance(rb, dict): + print(f"SHAPE WARNING: releaseBlocker is {type(rb).__name__}, expected dict or None " + f"(on {b['key']}). Blocker categorization may be broken.", file=sys.stderr) + break + +# Categorize from all_open (was 4 separate JQL queries) +# Bot account is the default assignee — treat as effectively unassigned +BOT_ACCOUNTS = {"Node Team Bot Account"} +untriaged = [b for b in all_open if b["priority"] in ("Undefined", "Unprioritized")] +unassigned = [b for b in all_open if b["assignee"] in ({"Unassigned"} | BOT_ACCOUNTS)] +blocker_proposals = [b for b in all_open + if isinstance(b.get("releaseBlocker"), dict) + and b["releaseBlocker"].get("value") == "Proposed"] +# Canary: if we have bugs but nothing categorized, field formats may have changed +if len(all_open) > 10 and (len(untriaged) + len(unassigned) + len(blocker_proposals)) == 0: + print(f"CANARY: {len(all_open)} open bugs but 0 categorized. " + f"Check releaseBlocker (customfield_10847), priority values.", file=sys.stderr) + +# Merge missing-component bugs into allOpen (deduplicated) +all_open_keys = {b["key"] for b in all_open} +for b in missing_component: + if b["key"] not in all_open_keys: + all_open.append(b) + all_open_keys.add(b["key"]) + +result = { + "summary": { + "totalOpen": len(all_open), + "untriaged": len(untriaged), + "unassigned": len(unassigned), + "blockerProposals": len(blocker_proposals), + "newThisWeek": len(new_this_week), + "missingComponent": len(missing_component), + "excludedExternalCVEs": len(excluded_cves), + }, + "untriaged": untriaged, + "unassigned": unassigned, + "blockerProposals": blocker_proposals, + "newThisWeek": new_this_week, + "missingComponent": missing_component, + "allOpen": all_open, +} + +print(json.dumps(result)) +PYEOF +} diff --git a/plugins/node-team/scripts/lib/composite/carryover-report.sh b/plugins/node-team/scripts/lib/composite/carryover-report.sh new file mode 100644 index 000000000..d1d791d1d --- /dev/null +++ b/plugins/node-team/scripts/lib/composite/carryover-report.sh @@ -0,0 +1,91 @@ +#!/bin/bash +# Composite: carryover-report +# Returns not-done items with carryover context +# Serves: /carryovers + +[[ -n "${_COMPOSITE_CARRYOVER_LOADED:-}" ]] && return 0 +_COMPOSITE_CARRYOVER_LOADED=1 + +cmd_carryover_report() { + local team="${1:?Team required}" + + team_config "$team" + + # Get active (or last closed) and future sprint info in parallel + parallel_init + parallel_run "active_sprint" team_sprint_fallback "$team" + parallel_run "future_sprint" bash -c "source '${SCRIPT_DIR}/lib/core.sh'; source '${SCRIPT_DIR}/lib/api/sprint.sh'; source '${SCRIPT_DIR}/lib/team.sh'; result=\$(team_sprint '$team' future 2>/dev/null) && echo \"\$result\" || echo '{\"error\":\"No future sprint\"}'" + parallel_wait_all || true + + local active_sprint future_sprint + active_sprint=$(parallel_get "active_sprint") + future_sprint=$(parallel_get "future_sprint") + + local sprint_id + sprint_id=$(echo "$active_sprint" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))") + + # Fetch sprint issues + local issues_json + issues_json=$(cmd_sprint_issues "$sprint_id") + + python3 - "$active_sprint" "$future_sprint" "$issues_json" <<'PYEOF' +import json, sys +from datetime import datetime, timezone + +sprint = json.loads(sys.argv[1]) +future = json.loads(sys.argv[2]) +data = json.loads(sys.argv[3]) +issues = data.get("issues", []) + +carryovers = [] +done_items = [] + +for issue in issues: + f = issue.get("fields", {}) + status_cat = f.get("status", {}).get("statusCategory", {}).get("key", "") + key = issue.get("key", "") + summary = f.get("summary", "") + status_name = f.get("status", {}).get("name", "") + assignee = (f.get("assignee") or {}).get("displayName", "Unassigned") + points = f.get("customfield_10028") or 0 + issue_type = f.get("issuetype", {}).get("name", "") + blocked = (f.get("customfield_10517") or {}).get("value", "False") == "True" + + item = { + "key": key, "summary": summary, "status": status_name, + "assignee": assignee, "points": points, "type": issue_type, + "blocked": blocked, + } + + if status_cat == "done": + done_items.append(item) + else: + # Count how many sprints this has been in + sprints_in = len([s for s in (f.get("customfield_10020") or []) if s.get("state") == "closed"]) + item["previousSprints"] = sprints_in + carryovers.append(item) + +# Stats +by_assignee = {} +for c in carryovers: + a = c["assignee"] + by_assignee.setdefault(a, []).append(c["key"]) + +result = { + "activeSprint": sprint, + "futureSprint": future if "error" not in future else None, + "carryovers": carryovers, + "doneItems": done_items, + "stats": { + "totalItems": len(issues), + "doneCount": len(done_items), + "carryoverCount": len(carryovers), + "carryoverPoints": sum(c["points"] for c in carryovers), + "donePoints": sum(d["points"] for d in done_items), + "byAssignee": {a: len(keys) for a, keys in by_assignee.items()}, + }, +} + +print(json.dumps(result)) +PYEOF +} diff --git a/plugins/node-team/scripts/lib/composite/epic-progress.sh b/plugins/node-team/scripts/lib/composite/epic-progress.sh new file mode 100644 index 000000000..25a5b0e66 --- /dev/null +++ b/plugins/node-team/scripts/lib/composite/epic-progress.sh @@ -0,0 +1,177 @@ +#!/bin/bash +# Composite: epic-progress +# Epics the current user is contributing to, with children progress +# 2 bulk queries for all epics (was 2 per epic — 2N total) +# Serves: /my-epics + +[[ -n "${_COMPOSITE_EPIC_PROGRESS_LOADED:-}" ]] && return 0 +_COMPOSITE_EPIC_PROGRESS_LOADED=1 + +cmd_epic_progress() { + local team="${1:?Team required}" + + team_config "$team" + _init_auth + + local user_email="$JIRA_USER" + + # Get sprint + issues + local sprint_json + sprint_json=$(team_sprint "$team" active 2>/dev/null) || { + sprint_json=$(team_sprint "$team" future 2>/dev/null) || { + echo '{"error":"No active or future sprint found for '"$team"'"}' >&2; return 1 + } + _log "WARN" "No active sprint — using future sprint" + } + + local sprint_id + sprint_id=$(echo "$sprint_json" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])") + + local issues_json + issues_json=$(cmd_sprint_issues "$sprint_id") + + # Extract unique epic keys for the user's items + local epic_keys + epic_keys=$(python3 -c " +import json, sys +data = json.loads(sys.argv[1]) +user = sys.argv[2] +epics = set() +for i in data.get('issues', []): + f = i.get('fields', {}) + a = f.get('assignee') or {} + if a.get('emailAddress', '') == user or a.get('displayName', '') == user: + ek = f.get('customfield_10014') + if ek: + epics.add(ek) +for e in sorted(epics): + print(e) +" "$issues_json" "$user_email") + + if [[ -z "$epic_keys" ]]; then + python3 -c " +import json, sys +sprint = json.loads(sys.argv[1]) +print(json.dumps({'sprint': {'id': sprint['id'], 'name': sprint['name']}, 'epics': [], 'summary': {'totalEpics': 0}})) +" "$sprint_json" + return 0 + fi + + # Build comma-separated key lists for bulk JQL + local keys_csv keys_quoted + keys_csv=$(echo "$epic_keys" | tr '\n' ',' | sed 's/,$//') + keys_quoted=$(echo "$epic_keys" | sed 's/.*/"&"/' | tr '\n' ',' | sed 's/,$//') + + # 2 bulk queries (was 2 per epic) + parallel_init + parallel_run "epics" cmd_search "key in (${keys_csv})" 50 + parallel_run "children" cmd_search "\"Epic Link\" in (${keys_quoted}) ORDER BY status ASC" 200 + parallel_wait_all 2>/dev/null || true + + python3 - "$sprint_json" "$issues_json" "$(parallel_get epics)" "$(parallel_get children)" "$user_email" <<'PYEOF' +import json, sys + +sprint = json.loads(sys.argv[1]) +all_issues = json.loads(sys.argv[2]) +epics_data = json.loads(sys.argv[3]) +children_data = json.loads(sys.argv[4]) +user_email = sys.argv[5] + +# Build epic map by key +epic_map = {} +for e in epics_data.get("issues", []): + epic_map[e["key"]] = e + +# Group children by epic link +children_by_epic = {} +for c in children_data.get("issues", []): + ek = c.get("fields", {}).get("customfield_10014") + if ek: + children_by_epic.setdefault(ek, []).append(c) + +# My sprint items by epic +my_sprint_items = {} +for i in all_issues.get("issues", []): + f = i.get("fields", {}) + a = f.get("assignee") or {} + ek = f.get("customfield_10014") + if ek: + item = { + "key": i.get("key", ""), "summary": f.get("summary", ""), + "status": f.get("status", {}).get("name", ""), + "statusCategory": f.get("status", {}).get("statusCategory", {}).get("key", ""), + "points": f.get("customfield_10028") or 0, + "type": f.get("issuetype", {}).get("name", ""), + "mine": a.get("emailAddress", "") == user_email or a.get("displayName", "") == user_email, + "assignee": a.get("displayName", "Unassigned"), + } + my_sprint_items.setdefault(ek, []).append(item) + +epics_out = [] +for ek in sorted(epic_map.keys()): + epic = epic_map[ek] + ef = epic.get("fields", {}) + epic_children = children_by_epic.get(ek, []) + + children = [] + done_count = 0 + in_progress_count = 0 + todo_count = 0 + total_children = 0 + + for child in epic_children: + cf = child.get("fields", {}) + sc = cf.get("status", {}).get("statusCategory", {}).get("key", "") + total_children += 1 + if sc == "done": + done_count += 1 + elif sc == "indeterminate": + in_progress_count += 1 + else: + todo_count += 1 + children.append({ + "key": child.get("key", ""), + "summary": cf.get("summary", ""), + "status": cf.get("status", {}).get("name", ""), + "statusCategory": sc, + "assignee": (cf.get("assignee") or {}).get("displayName", "Unassigned"), + "points": cf.get("customfield_10028") or 0, + }) + + pct = round(done_count / total_children * 100) if total_children else 0 + + # Split sprint items into mine vs others + sprint_items = my_sprint_items.get(ek, []) + my_items = [i for i in sprint_items if i["mine"]] + other_items = [i for i in sprint_items if not i["mine"]] + + epics_out.append({ + "key": ek, + "summary": ef.get("summary", ""), + "status": ef.get("status", {}).get("name", ""), + "assignee": (ef.get("assignee") or {}).get("displayName", "Unassigned"), + "progress": { + "total": total_children, "done": done_count, + "inProgress": in_progress_count, "toDo": todo_count, + "percent": pct, + }, + "myItems": my_items, + "otherItems": other_items, + "allChildren": children, + }) + +at_risk = [e for e in epics_out if e["progress"]["toDo"] > e["progress"]["done"]] +near_complete = [e for e in epics_out if e["progress"]["percent"] >= 80] + +result = { + "sprint": {"id": sprint["id"], "name": sprint["name"]}, + "epics": epics_out, + "summary": { + "totalEpics": len(epics_out), + "nearComplete": [{"key": e["key"], "summary": e["summary"], "percent": e["progress"]["percent"]} for e in near_complete], + "atRisk": [{"key": e["key"], "summary": e["summary"], "toDo": e["progress"]["toDo"]} for e in at_risk], + }, +} +print(json.dumps(result)) +PYEOF +} diff --git a/plugins/node-team/scripts/lib/composite/issue-deep-dive.sh b/plugins/node-team/scripts/lib/composite/issue-deep-dive.sh new file mode 100644 index 000000000..d9bf80ddd --- /dev/null +++ b/plugins/node-team/scripts/lib/composite/issue-deep-dive.sh @@ -0,0 +1,110 @@ +#!/bin/bash +# Composite: issue-deep-dive +# Full issue details + comments (ADF converted) + linked issues +# Serves: /investigate, /briefing, /handoff + +[[ -n "${_COMPOSITE_ISSUE_DEEP_DIVE_LOADED:-}" ]] && return 0 +_COMPOSITE_ISSUE_DEEP_DIVE_LOADED=1 + +cmd_issue_deep_dive() { + local key="${1:?Issue key required (e.g., OCPNODE-1234)}" + + # ── Fetch issue + comments in parallel ─────────────────────────────────── + parallel_init + parallel_run "issue" cmd_get "$key" + parallel_run "comments" cmd_comments "$key" + parallel_run "transitions" cmd_transitions "$key" + parallel_wait_all || true + + local issue_json comments_json transitions_json + issue_json=$(parallel_get "issue") + comments_json=$(parallel_get "comments") + transitions_json=$(parallel_get "transitions") + + local adf_py + adf_py="$(cd "$(dirname "${BASH_SOURCE[0]}")/../util" && pwd)/adf.py" + + python3 - "$comments_json" "$transitions_json" "$adf_py" "$issue_json" <<'PYEOF' +import json, sys, importlib.util + +comments_data = json.loads(sys.argv[1]) +transitions_data = json.loads(sys.argv[2]) +adf_py_path = sys.argv[3] +issue = json.loads(sys.argv[4]) + +# Load ADF converter +spec = importlib.util.spec_from_file_location("adf", adf_py_path) +adf_mod = importlib.util.module_from_spec(spec) +spec.loader.exec_module(adf_mod) + +f = issue.get("fields", {}) + +# Extract description +desc = f.get("description") +desc_text = adf_mod.adf_to_text(desc).strip() if isinstance(desc, dict) else (desc or "") + +# Extract comments +comments = adf_mod.extract_comments(comments_data) + +# Extract linked issues +linked = [] +for link in f.get("issuelinks", []): + link_type = link.get("type", {}) + if "outwardIssue" in link: + target = link["outwardIssue"] + linked.append({ + "key": target.get("key", ""), + "summary": target.get("fields", {}).get("summary", ""), + "status": target.get("fields", {}).get("status", {}).get("name", ""), + "relationship": link_type.get("outward", ""), + }) + if "inwardIssue" in link: + target = link["inwardIssue"] + linked.append({ + "key": target.get("key", ""), + "summary": target.get("fields", {}).get("summary", ""), + "status": target.get("fields", {}).get("status", {}).get("name", ""), + "relationship": link_type.get("inward", ""), + }) + +# Extract SFDC cases +sfdc_count = f.get("customfield_10978") +sfdc_links = f.get("customfield_10979") + +# Epic context +epic_key = f.get("customfield_10014") + +# Available transitions +transitions = [{"id": t["id"], "name": t["name"]} for t in transitions_data.get("transitions", [])] + +# Blocked info +blocked = (f.get("customfield_10517") or {}).get("value", "False") == "True" +blocked_reason = f.get("customfield_10483") +blocked_text = adf_mod.adf_to_text(blocked_reason).strip() if isinstance(blocked_reason, dict) else "" + +result = { + "key": issue.get("key", ""), + "summary": f.get("summary", ""), + "description": desc_text, + "status": f.get("status", {}).get("name", ""), + "statusCategory": f.get("status", {}).get("statusCategory", {}).get("key", ""), + "assignee": (f.get("assignee") or {}).get("displayName", "Unassigned"), + "assigneeEmail": (f.get("assignee") or {}).get("emailAddress", ""), + "priority": f.get("priority", {}).get("name", ""), + "type": f.get("issuetype", {}).get("name", ""), + "points": f.get("customfield_10028") or 0, + "fixVersions": [v.get("name", "") for v in (f.get("fixVersions") or [])], + "epicKey": epic_key, + "releaseBlocker": f.get("customfield_10847"), + "blocked": blocked, + "blockedReason": blocked_text, + "sfdcCaseCount": sfdc_count, + "sfdcLinks": sfdc_links, + "linkedIssues": linked, + "comments": comments, + "transitions": transitions, +} + +print(json.dumps(result)) +PYEOF +} diff --git a/plugins/node-team/scripts/lib/composite/my-board-data.sh b/plugins/node-team/scripts/lib/composite/my-board-data.sh new file mode 100644 index 000000000..f9e464e5c --- /dev/null +++ b/plugins/node-team/scripts/lib/composite/my-board-data.sh @@ -0,0 +1,120 @@ +#!/bin/bash +# Composite: my-board-data +# Sprint dashboard pre-filtered to the current user (JIRA_EMAIL) +# Serves: /my-board + +[[ -n "${_COMPOSITE_MY_BOARD_LOADED:-}" ]] && return 0 +_COMPOSITE_MY_BOARD_LOADED=1 + +cmd_my_board_data() { + local team="${1:?Team required}" + shift + local sprint_ref="" + while [[ $# -gt 0 ]]; do + case "$1" in + --sprint) sprint_ref="${2:?--sprint requires a value}"; shift 2 ;; + *) shift ;; + esac + done + + team_config "$team" + _init_auth + + local sprint_json + sprint_json=$(resolve_sprint "$team" "$sprint_ref") || return 1 + + local sprint_id + sprint_id=$(echo "$sprint_json" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])") + + local issues_json + issues_json=$(cmd_sprint_issues "$sprint_id") + + python3 - "$sprint_json" "$issues_json" "${JIRA_USER}" <<'PYEOF' +import json, sys +from datetime import datetime, timezone + +sprint = json.loads(sys.argv[1]) +data = json.loads(sys.argv[2]) +user_email = sys.argv[3] +issues = data.get("issues", []) + +start = datetime.fromisoformat(sprint["startDate"].replace("Z", "+00:00")) +end = datetime.fromisoformat(sprint["endDate"].replace("Z", "+00:00")) +now = datetime.now(timezone.utc) +total_days = max((end - start).days, 1) +elapsed = min(max((now - start).days, 0), total_days) +remaining = max(total_days - elapsed, 0) + +STATUS_ORDER = {"done": 0, "codeReview": 1, "inProgress": 2, "modified": 3, "toDo": 4, "other": 5} +by_status = {} +total_pts = 0 +done_pts = 0 +flags = [] + +for issue in issues: + f = issue.get("fields", {}) + assignee = f.get("assignee") or {} + if assignee.get("emailAddress", "") != user_email and assignee.get("displayName", "") != user_email: + continue + + key = issue.get("key", "") + summary = f.get("summary", "") + status_name = f.get("status", {}).get("name", "") + status_cat = f.get("status", {}).get("statusCategory", {}).get("key", "") + pts = f.get("customfield_10028") or 0 + itype = f.get("issuetype", {}).get("name", "") + blocked = (f.get("customfield_10517") or {}).get("value", "False") == "True" + blocked_reason = f.get("customfield_10483") + br_text = "" + if isinstance(blocked_reason, dict): + # simple ADF extract + def _adf(n): + if not isinstance(n, dict): return "" + t = n.get("text", "") if n.get("type") == "text" else "" + for c in n.get("content", []): t += _adf(c) + return t + br_text = _adf(blocked_reason).strip() + + total_pts += pts + if status_cat == "done": + group = "done"; done_pts += pts + elif status_name == "Code Review": + group = "codeReview" + elif status_name == "MODIFIED": + group = "modified" + elif status_cat == "indeterminate" or status_name == "In Progress": + group = "inProgress" + elif status_cat == "new" or status_name in ("To Do", "NEW"): + group = "toDo" + else: + group = "other" + + item = {"key": key, "summary": summary, "status": status_name, "statusGroup": group, + "points": pts, "type": itype, "blocked": blocked, "blockedReason": br_text} + by_status.setdefault(group, []).append(item) + + if blocked: + flags.append({"key": key, "summary": summary, "reason": br_text or "Blocked (no reason given)"}) + if not pts and group != "done": + flags.append({"key": key, "summary": summary, "reason": "No story points"}) + if group == "toDo" and remaining <= 3: + flags.append({"key": key, "summary": summary, "reason": f"Still To Do with {remaining} days left"}) + +ordered = {} +for g in sorted(by_status.keys(), key=lambda x: STATUS_ORDER.get(x, 99)): + ordered[g] = by_status[g] + +total_items = sum(len(v) for v in by_status.values()) +result = { + "sprint": {"id": sprint["id"], "name": sprint["name"], "startDate": sprint["startDate"], + "endDate": sprint["endDate"], "daysElapsed": elapsed, "daysTotal": total_days, "daysRemaining": remaining}, + "summary": {"total": total_items, "done": len(by_status.get("done", [])), + "inProgress": len(by_status.get("inProgress", [])) + len(by_status.get("codeReview", [])), + "toDo": len(by_status.get("toDo", [])), + "totalPoints": total_pts, "donePoints": done_pts}, + "byStatus": ordered, + "flags": flags, +} +print(json.dumps(result)) +PYEOF +} diff --git a/plugins/node-team/scripts/lib/composite/my-bugs-data.sh b/plugins/node-team/scripts/lib/composite/my-bugs-data.sh new file mode 100644 index 000000000..f46eb99ce --- /dev/null +++ b/plugins/node-team/scripts/lib/composite/my-bugs-data.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# Composite: my-bugs-data +# All bugs assigned to the current user, categorized +# Serves: /my-bugs + +[[ -n "${_COMPOSITE_MY_BUGS_LOADED:-}" ]] && return 0 +_COMPOSITE_MY_BUGS_LOADED=1 + +cmd_my_bugs_data() { + local team="${1:?Team required}" + + team_config "$team" + + # _init_auth is called by _curl inside cmd_search, but we need JIRA_USER now + _init_auth + local user_email="$JIRA_USER" + + local search_result + search_result=$(cmd_search "project = OCPBUGS AND component in (${TEAM_BUG_COMPONENTS}) AND assignee = \"${user_email}\" AND status not in (CLOSED, Verified, Done) ORDER BY priority ASC, created ASC" 100) + + python3 - "$TEAM_NAME" "$search_result" <<'PYEOF' +import json, sys +from datetime import datetime, timezone + +team_name = sys.argv[1] +data = json.loads(sys.argv[2]) +issues = data.get("issues", []) +now = datetime.now(timezone.utc) + +all_bugs = [] +escalations = [] +release_blockers = [] +by_priority = {} + +for issue in issues: + f = issue.get("fields", {}) + key = issue.get("key", "") + summary = f.get("summary", "") + status = f.get("status", {}).get("name", "") + priority = f.get("priority", {}).get("name", "") + pts = f.get("customfield_10028") or 0 + sfdc = f.get("customfield_10978") + rb = f.get("customfield_10847") + fv = [v.get("name", "") for v in (f.get("fixVersions") or [])] + blocked = (f.get("customfield_10517") or {}).get("value", "False") == "True" + + bug = {"key": key, "summary": summary, "status": status, "priority": priority, + "points": pts, "fixVersions": fv, "blocked": blocked, + "sfdcCaseCount": sfdc, "releaseBlocker": rb} + all_bugs.append(bug) + by_priority[priority] = by_priority.get(priority, 0) + 1 + + if sfdc: + escalations.append(bug) + if rb: + release_blockers.append(bug) + +result = { + "team": team_name, + "summary": { + "total": len(all_bugs), + "byPriority": by_priority, + "customerEscalations": len(escalations), + "releaseBlockers": len(release_blockers), + }, + "customerEscalations": escalations, + "releaseBlockers": release_blockers, + "allBugs": all_bugs, +} +print(json.dumps(result)) +PYEOF +} diff --git a/plugins/node-team/scripts/lib/composite/my-standup-data.sh b/plugins/node-team/scripts/lib/composite/my-standup-data.sh new file mode 100644 index 000000000..1c65a39fb --- /dev/null +++ b/plugins/node-team/scripts/lib/composite/my-standup-data.sh @@ -0,0 +1,152 @@ +#!/bin/bash +# Composite: my-standup-data +# Standup data pre-filtered to current user (Jira side only) +# 1 data query (was 2 — removed redundant "recent" search that was unused) +# Serves: /my-standup + +[[ -n "${_COMPOSITE_MY_STANDUP_LOADED:-}" ]] && return 0 +_COMPOSITE_MY_STANDUP_LOADED=1 + +cmd_my_standup_data() { + local team="${1:?Team required}" + shift + local sprint_ref="" + while [[ $# -gt 0 ]]; do + case "$1" in + --sprint) sprint_ref="${2:?--sprint requires a value}"; shift 2 ;; + *) shift ;; + esac + done + + team_config "$team" + _init_auth + + local user_email="$JIRA_USER" + + local sprint_json + sprint_json=$(resolve_sprint "$team" "$sprint_ref") || return 1 + + local sprint_id + sprint_id=$(echo "$sprint_json" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])") + + # Sprint issues only (removed redundant "recent" search — data was unused) + local issues_json + issues_json=$(cmd_sprint_issues "$sprint_id") + + # Get my issue keys and fetch comments + local my_keys + my_keys=$(python3 -c " +import json, sys +data = json.loads(sys.argv[1]) +user = sys.argv[2] +for i in data.get('issues', []): + a = (i.get('fields', {}).get('assignee') or {}) + if a.get('emailAddress', '') == user or a.get('displayName', '') == user: + print(i['key']) +" "$issues_json" "$user_email") + + parallel_init + for key in $my_keys; do + parallel_run "comments_${key}" cmd_comments "$key" + done + parallel_wait_all 2>/dev/null || true + + local comments_combined="{" + local first=true + for key in $my_keys; do + local c + c=$(parallel_get "comments_${key}" 2>/dev/null) + if [[ -n "$c" && "$c" != *"error"* ]]; then + [[ "$first" == "true" ]] && first=false || comments_combined+="," + comments_combined+="\"${key}\":${c}" + fi + done + comments_combined+="}" + + local adf_py + adf_py="$(cd "$(dirname "${BASH_SOURCE[0]}")/../util" && pwd)/adf.py" + + python3 - "$sprint_json" "$issues_json" "$comments_combined" "$user_email" "$adf_py" <<'PYEOF' +import json, sys, importlib.util +from datetime import datetime, timedelta, timezone + +sprint = json.loads(sys.argv[1]) +all_issues = json.loads(sys.argv[2]) +all_comments = json.loads(sys.argv[3]) +user_email = sys.argv[4] +adf_py_path = sys.argv[5] + +spec = importlib.util.spec_from_file_location("adf", adf_py_path) +adf_mod = importlib.util.module_from_spec(spec) +spec.loader.exec_module(adf_mod) + +start = datetime.fromisoformat(sprint["startDate"].replace("Z", "+00:00")) +end = datetime.fromisoformat(sprint["endDate"].replace("Z", "+00:00")) +now = datetime.now(timezone.utc) +total_days = max((end - start).days, 1) +elapsed = min(max((now - start).days, 0), total_days) + +# Filter to my items +done = [] +in_progress = [] +blocked = [] +todo = [] + +for i in all_issues.get("issues", []): + f = i.get("fields", {}) + a = f.get("assignee") or {} + if a.get("emailAddress", "") != user_email and a.get("displayName", "") != user_email: + continue + + key = i.get("key", "") + status_cat = f.get("status", {}).get("statusCategory", {}).get("key", "") + status_name = f.get("status", {}).get("name", "") + is_blocked = (f.get("customfield_10517") or {}).get("value", "False") == "True" + br = f.get("customfield_10483") + br_text = adf_mod.adf_to_text(br).strip() if isinstance(br, dict) else "" + + item = {"key": key, "summary": f.get("summary", ""), "status": status_name, + "points": f.get("customfield_10028") or 0, "type": f.get("issuetype", {}).get("name", ""), + "blocked": is_blocked, "blockedReason": br_text} + + if status_cat == "done": + done.append(item) + elif is_blocked: + blocked.append(item) + elif status_cat == "new" or status_name in ("To Do", "NEW"): + todo.append(item) + else: + in_progress.append(item) + +# Recent comments on my items +my_comments = [] +cutoff = now - timedelta(days=7) +for key, cdata in all_comments.items(): + for c in cdata.get("comments", []): + created = c.get("created", "") + try: + dt = datetime.fromisoformat(created.replace("Z", "+00:00")) + if dt >= cutoff: + body = c.get("body", {}) + my_comments.append({ + "key": key, "author": c.get("author", {}).get("displayName", ""), + "created": created, + "body": adf_mod.adf_to_text(body).strip() if isinstance(body, dict) else str(body), + }) + except (ValueError, TypeError): + pass + +result = { + "sprint": {"id": sprint["id"], "name": sprint["name"], + "daysElapsed": elapsed, "daysTotal": total_days}, + "done": done, + "inProgress": in_progress, + "blocked": blocked, + "upNext": todo, + "recentComments": my_comments, + "summary": {"done": len(done), "inProgress": len(in_progress), + "blocked": len(blocked), "toDo": len(todo)}, +} +print(json.dumps(result)) +PYEOF +} diff --git a/plugins/node-team/scripts/lib/composite/pickup-data.sh b/plugins/node-team/scripts/lib/composite/pickup-data.sh new file mode 100644 index 000000000..e69e643be --- /dev/null +++ b/plugins/node-team/scripts/lib/composite/pickup-data.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# Composite: pickup-data +# All available unassigned work: sprint items + bugs (with escalation tagging) +# 2 queries (was 3 — merged bugs + escalations into 1) +# Serves: /pickup + +[[ -n "${_COMPOSITE_PICKUP_LOADED:-}" ]] && return 0 +_COMPOSITE_PICKUP_LOADED=1 + +cmd_pickup_data() { + local team="${1:?Team required}" + shift + local sprint_ref="" + while [[ $# -gt 0 ]]; do + case "$1" in + --sprint) sprint_ref="${2:?--sprint requires a value}"; shift 2 ;; + *) shift ;; + esac + done + + team_config "$team" + + local sprint_json + sprint_json=$(resolve_sprint "$team" "$sprint_ref") || return 1 + + local sprint_id + sprint_id=$(echo "$sprint_json" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])") + + local bug_fields="[\"key\",\"summary\",\"status\",\"assignee\",\"priority\",\"issuetype\",\"${CF_STORY_POINTS}\"]" + + # 2 queries: sprint items + unassigned bugs + parallel_init + parallel_run "issues" cmd_sprint_issues "$sprint_id" + parallel_run "bugs" cmd_search \ + "project = OCPBUGS AND component in (${TEAM_BUG_COMPONENTS}) AND assignee is EMPTY AND status not in (CLOSED, Verified, Done) ORDER BY priority ASC, created ASC" 50 "$bug_fields" + parallel_wait_all || true + + python3 - "$sprint_json" "$(parallel_get issues)" "$(parallel_get bugs)" <<'PYEOF' +import json, sys + +sprint = json.loads(sys.argv[1]) +issues_data = json.loads(sys.argv[2]) +bugs_data = json.loads(sys.argv[3]) + +def extract(data): + items = [] + for i in data.get("issues", []): + f = i.get("fields", {}) + items.append({ + "key": i.get("key", ""), "summary": f.get("summary", ""), + "status": f.get("status", {}).get("name", ""), + "priority": f.get("priority", {}).get("name", ""), + "type": f.get("issuetype", {}).get("name", ""), + "points": f.get("customfield_10028") or 0, + "assignee": (f.get("assignee") or {}).get("displayName", "Unassigned"), + }) + return items + +# Unassigned sprint items (bot account is the default assignee — treat as unassigned) +BOT_ACCOUNTS = {"Node Team Bot Account"} +unassigned_sprint = [i for i in extract(issues_data) if i["assignee"] in ({"Unassigned"} | BOT_ACCOUNTS)] +unassigned_bugs = extract(bugs_data) + +result = { + "sprint": {"id": sprint["id"], "name": sprint["name"]}, + "unassignedSprintItems": unassigned_sprint, + "unassignedBugs": unassigned_bugs, + "summary": { + "sprintItems": len(unassigned_sprint), + "bugs": len(unassigned_bugs), + }, +} +print(json.dumps(result)) +PYEOF +} diff --git a/plugins/node-team/scripts/lib/composite/planning-data.sh b/plugins/node-team/scripts/lib/composite/planning-data.sh new file mode 100644 index 000000000..d13975a2c --- /dev/null +++ b/plugins/node-team/scripts/lib/composite/planning-data.sh @@ -0,0 +1,105 @@ +#!/bin/bash +# Composite: planning-data +# Full planning package: carryovers + scheduled next sprint + backlog + bugs +# Serves: /sprint-plan + +[[ -n "${_COMPOSITE_PLANNING_LOADED:-}" ]] && return 0 +_COMPOSITE_PLANNING_LOADED=1 + +cmd_planning_data() { + local team="${1:?Team required}" + + team_config "$team" + + # ── Sprint discovery (active preferred, fall back to last closed) ──────── + local active_sprint future_sprint + active_sprint=$(team_sprint_fallback "$team") || { echo "$active_sprint" >&2; return 1; } + + local active_id + active_id=$(echo "$active_sprint" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])") + + future_sprint=$(team_sprint "$team" future 2>/dev/null) || future_sprint='{"error":"No future sprint"}' + + local future_id="" + future_id=$(echo "$future_sprint" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('id',''))" 2>/dev/null) || true + + # ── Parallel: active issues + future issues + backlog + unscheduled bugs + roster + parallel_init + + parallel_run "active_issues" cmd_sprint_issues "$active_id" + + if [[ -n "$future_id" ]]; then + parallel_run "future_issues" cmd_sprint_issues "$future_id" + fi + + parallel_run "backlog" cmd_search \ + "project = OCPNODE AND sprint is EMPTY AND status not in (Closed, Done) AND type in (Story, Task, Spike) ORDER BY priority ASC, created DESC" 30 + + parallel_run "unscheduled_bugs" cmd_search \ + "project = OCPBUGS AND component in (${TEAM_BUG_COMPONENTS}) AND sprint is EMPTY AND status not in (Closed, Done, Verified) ORDER BY priority ASC, created DESC" 30 + + parallel_run "roster" team_roster "$team" + + parallel_wait_all || true + + local active_issues future_issues backlog bugs roster_json + active_issues=$(parallel_get "active_issues") + future_issues=$(parallel_get "future_issues" 2>/dev/null || echo '{"issues":[]}') + backlog=$(parallel_get "backlog") + bugs=$(parallel_get "unscheduled_bugs") + roster_json=$(parallel_get "roster") + + python3 - "$active_sprint" "$future_sprint" "$backlog" "$bugs" "$roster_json" "$future_issues" "$active_issues" <<'PYEOF' +import json, sys + +active_sprint = json.loads(sys.argv[1]) +future_sprint = json.loads(sys.argv[2]) +backlog_data = json.loads(sys.argv[3]) +bugs_data = json.loads(sys.argv[4]) +roster = json.loads(sys.argv[5]) +future_data = json.loads(sys.argv[6]) +active_data = json.loads(sys.argv[7]) + +def extract_items(data): + items = [] + for i in data.get("issues", []): + f = i.get("fields", {}) + items.append({ + "key": i.get("key", ""), + "summary": f.get("summary", ""), + "status": f.get("status", {}).get("name", ""), + "statusCategory": f.get("status", {}).get("statusCategory", {}).get("key", ""), + "assignee": (f.get("assignee") or {}).get("displayName", "Unassigned"), + "points": f.get("customfield_10028") or 0, + "type": f.get("issuetype", {}).get("name", ""), + "priority": f.get("priority", {}).get("name", ""), + }) + return items + +active_items = extract_items(active_data) +carryovers = [i for i in active_items if i["statusCategory"] != "done"] +done_items = [i for i in active_items if i["statusCategory"] == "done"] +scheduled = extract_items(future_data) +backlog_items = extract_items(backlog_data) +bug_items = extract_items(bugs_data) + +result = { + "activeSprint": active_sprint, + "futureSprint": future_sprint if "error" not in future_sprint else None, + "wrapUp": { + "done": done_items, + "carryovers": carryovers, + "doneCount": len(done_items), + "carryoverCount": len(carryovers), + "donePoints": sum(i["points"] for i in done_items), + "carryoverPoints": sum(i["points"] for i in carryovers), + }, + "scheduled": scheduled, + "backlogCandidates": backlog_items, + "unscheduledBugs": bug_items, + "roster": roster, +} + +print(json.dumps(result)) +PYEOF +} diff --git a/plugins/node-team/scripts/lib/composite/release-data.sh b/plugins/node-team/scripts/lib/composite/release-data.sh new file mode 100644 index 000000000..10cc3ff03 --- /dev/null +++ b/plugins/node-team/scripts/lib/composite/release-data.sh @@ -0,0 +1,111 @@ +#!/bin/bash +# Composite: release-data [version] +# Release readiness: blockers, open bugs, epics +# 2 queries for bugs+epics (was 4 — approved/proposed/all merged into 1) +# Serves: /release-check + +[[ -n "${_COMPOSITE_RELEASE_DATA_LOADED:-}" ]] && return 0 +_COMPOSITE_RELEASE_DATA_LOADED=1 + +cmd_release_data() { + local team="${1:?Team required}" + local version="${2:-}" + + team_config "$team" + + # ── Version discovery if not provided ──────────────────────────────────── + if [[ -z "$version" ]]; then + version=$(cmd_search "project = OCPNODE AND fixVersion is not EMPTY AND status not in (Closed, Done) ORDER BY fixVersion DESC" 10 | \ + python3 -c " +import sys, json +versions = set() +for i in json.load(sys.stdin).get('issues', []): + for v in i['fields'].get('fixVersions', []): + versions.add(v['name']) +if versions: + print(sorted(versions)[-1]) +else: + print('') +") + if [[ -z "$version" ]]; then + echo '{"error":"No active fixVersion found"}' >&2 + return 1 + fi + fi + + # ── 2 queries (was 4): all bugs + epics — categorize blockers in Python ── + parallel_init + + parallel_run "all_bugs" cmd_search \ + "project = OCPBUGS AND component in (${TEAM_BUG_COMPONENTS}) AND fixVersion = \"${version}\" AND status not in (Closed, Done, Verified) ORDER BY priority ASC" 100 + + parallel_run "epics" cmd_search \ + "project = OCPNODE AND issuetype = Epic AND component in (${TEAM_BUG_COMPONENTS}) AND fixVersion = \"${version}\" ORDER BY status ASC" 50 + + parallel_wait_all || true + + python3 - "$version" \ + "$(parallel_get all_bugs)" \ + "$(parallel_get epics)" \ + <<'PYEOF' +import json, sys + +version = sys.argv[1] + +def extract(data_str): + items = [] + for i in json.loads(data_str).get("issues", []): + f = i.get("fields", {}) + items.append({ + "key": i.get("key", ""), + "summary": f.get("summary", ""), + "status": f.get("status", {}).get("name", ""), + "assignee": (f.get("assignee") or {}).get("displayName", "Unassigned"), + "priority": f.get("priority", {}).get("name", ""), + "points": f.get("customfield_10028") or 0, + "releaseBlocker": f.get("customfield_10847"), + }) + return items + +all_bugs = extract(sys.argv[2]) +epics = extract(sys.argv[3]) + +# Shape assertion — warn if releaseBlocker format changed +for b in all_bugs[:5]: + rb = b.get("releaseBlocker") + if rb is not None and not isinstance(rb, dict): + print(f"SHAPE WARNING: releaseBlocker is {type(rb).__name__}, expected dict or None " + f"(on {b['key']}). Blocker categorization may be broken.", file=sys.stderr) + break + +# Categorize blockers from all bugs (was 2 separate JQL queries) +approved = [b for b in all_bugs + if isinstance(b.get("releaseBlocker"), dict) + and b["releaseBlocker"].get("value") == "Approved"] +proposed = [b for b in all_bugs + if isinstance(b.get("releaseBlocker"), dict) + and b["releaseBlocker"].get("value") == "Proposed"] + +# Canary: if all bugs have releaseBlocker set but none match known values, values may have changed +bugs_with_rb = [b for b in all_bugs if b.get("releaseBlocker") is not None] +if len(bugs_with_rb) > 5 and len(approved) == 0 and len(proposed) == 0: + print(f"CANARY: {len(bugs_with_rb)} bugs have releaseBlocker set but 0 match " + f"'Approved' or 'Proposed'. Field values may have changed.", file=sys.stderr) + +result = { + "version": version, + "summary": { + "approvedBlockers": len(approved), + "proposedBlockers": len(proposed), + "openBugs": len(all_bugs), + "epics": len(epics), + }, + "approvedBlockers": approved, + "proposedBlockers": proposed, + "openBugs": all_bugs, + "epics": epics, +} + +print(json.dumps(result)) +PYEOF +} diff --git a/plugins/node-team/scripts/lib/composite/roster-sync.sh b/plugins/node-team/scripts/lib/composite/roster-sync.sh new file mode 100644 index 000000000..295266c30 --- /dev/null +++ b/plugins/node-team/scripts/lib/composite/roster-sync.sh @@ -0,0 +1,89 @@ +#!/bin/bash +# Composite: roster-sync [--force] +# Downloads team-roster-*.json attachments from a Jira config issue to ~/.node-assistant/ + +[[ -n "${_COMPOSITE_ROSTER_SYNC_LOADED:-}" ]] && return 0 +_COMPOSITE_ROSTER_SYNC_LOADED=1 + +NODE_ASSISTANT_DIR="${HOME}/.node-assistant" +NODE_ASSISTANT_CONFIG_ISSUE="${NODE_ASSISTANT_CONFIG_ISSUE:-OCPNODE-4230}" + +cmd_roster_sync() { + local force=false + [[ "${1:-}" == "--force" ]] && force=true + + mkdir -p "$NODE_ASSISTANT_DIR" + + local issue_json + issue_json=$(_curl "${JIRA_BASE}/rest/api/3/issue/${NODE_ASSISTANT_CONFIG_ISSUE}?fields=attachment") + + local roster_attachments + roster_attachments=$(python3 -c " +import json, sys +data = json.loads(sys.argv[1]) +attachments = data.get('fields', {}).get('attachment', []) +matches = [] +for a in attachments: + name = a.get('filename', '') + if name.startswith('team-roster-') and name.endswith('.json'): + matches.append({'id': str(a['id']), 'filename': name}) +print(json.dumps(matches)) +" "$issue_json") + + local count + count=$(python3 -c "import json,sys; print(len(json.loads(sys.argv[1])))" "$roster_attachments") + + if [[ "$count" -eq 0 ]]; then + echo '{"synced":[],"skipped":[],"message":"No team-roster-*.json attachments found on '"${NODE_ASSISTANT_CONFIG_ISSUE}"'"}' + return 0 + fi + + local synced=() skipped=() + local max_age_seconds=$((7 * 86400)) + + while IFS= read -r line; do + local att_id att_filename + att_id=$(echo "$line" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])") + att_filename=$(echo "$line" | python3 -c "import json,sys; print(json.load(sys.stdin)['filename'])") + + local dest="${NODE_ASSISTANT_DIR}/${att_filename}" + + if [[ "$force" == "false" && -f "$dest" ]]; then + local file_age + file_age=$(( $(date +%s) - $(stat -f %m "$dest" 2>/dev/null || stat -c %Y "$dest" 2>/dev/null || echo 0) )) + if (( file_age < max_age_seconds )); then + skipped+=("$att_filename") + _log "INFO" "Skipping ${att_filename} (${file_age}s old, <7d)" + continue + fi + fi + + _log "INFO" "Downloading ${att_filename} (attachment ${att_id})" + local tmp + tmp="$(mktemp "${dest}.tmp.XXXXXX")" + if _curl -L -o "$tmp" "${JIRA_BASE}/rest/api/3/attachment/content/${att_id}"; then + mv "$tmp" "$dest" + synced+=("$att_filename") + else + rm -f "$tmp" + return 1 + fi + + done < <(python3 -c " +import json, sys +for item in json.loads(sys.argv[1]): + print(json.dumps(item)) +" "$roster_attachments") + + python3 -c " +import json, sys +synced = [x for x in sys.argv[1].split(',') if x] if sys.argv[1] else [] +skipped = [x for x in sys.argv[2].split(',') if x] if sys.argv[2] else [] +print(json.dumps({ + 'synced': synced, + 'skipped': skipped, + 'configIssue': sys.argv[3], + 'directory': sys.argv[4], +})) +" "$(IFS=,; echo "${synced[*]:-}")" "$(IFS=,; echo "${skipped[*]:-}")" "$NODE_ASSISTANT_CONFIG_ISSUE" "$NODE_ASSISTANT_DIR" +} diff --git a/plugins/node-team/scripts/lib/composite/sprint-dashboard.sh b/plugins/node-team/scripts/lib/composite/sprint-dashboard.sh new file mode 100644 index 000000000..abe4d8f4e --- /dev/null +++ b/plugins/node-team/scripts/lib/composite/sprint-dashboard.sh @@ -0,0 +1,187 @@ +#!/bin/bash +# Composite: sprint-dashboard [--stream] +# Returns sprint info + issues grouped by status + workload + blockers +# Serves: /sprint-status, /team-load, /sprint-review, /my-board + +[[ -n "${_COMPOSITE_SPRINT_DASHBOARD_LOADED:-}" ]] && return 0 +_COMPOSITE_SPRINT_DASHBOARD_LOADED=1 + +cmd_sprint_dashboard() { + local team="${1:?Team required (e.g., 'Node Devices' or 'Node Core')}" + shift + local stream=false sprint_ref="" + while [[ $# -gt 0 ]]; do + case "$1" in + --stream) stream=true; shift ;; + --sprint) sprint_ref="${2:?--sprint requires a value}"; shift 2 ;; + *) shift ;; + esac + done + + # ── Resolve team config ────────────────────────────────────────────────── + team_config "$team" + + # ── Get sprint info ───────────────────────────────────────────────────── + local sprint_json + sprint_json=$(resolve_sprint "$team" "$sprint_ref") || return 1 + + local sprint_id + sprint_id=$(echo "$sprint_json" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])") + + # ── Get sprint issues + roster in parallel ─────────────────────────────── + parallel_init + parallel_run "issues" cmd_sprint_issues "$sprint_id" + parallel_run "roster" team_roster "$team" + parallel_wait_all || true + + local issues_json roster_json + issues_json=$(parallel_get "issues") + roster_json=$(parallel_get "roster") + + # ── Process everything in Python for speed ─────────────────────────────── + python3 - "$sprint_json" "$roster_json" "$issues_json" <<'PYEOF' +import json, sys +from datetime import datetime, timezone + +sprint = json.loads(sys.argv[1]) +roster = json.loads(sys.argv[2]) +data = json.loads(sys.argv[3]) +issues = data.get("issues", []) + +# Sprint progress +start = datetime.fromisoformat(sprint["startDate"].replace("Z", "+00:00")) +end = datetime.fromisoformat(sprint["endDate"].replace("Z", "+00:00")) +now = datetime.now(timezone.utc) +total_days = max((end - start).days, 1) +elapsed_days = min(max((now - start).days, 0), total_days) +days_remaining = max(total_days - elapsed_days, 0) + +# Categorize issues +status_groups = {} +team_workload = {} +total_points = 0 +done_points = 0 +blocked_items = [] +at_risk = [] + +STATUS_ORDER = {"done": 0, "codeReview": 1, "inProgress": 2, "modified": 3, "toDo": 4, "other": 5} + +for issue in issues: + f = issue.get("fields", {}) + key = issue.get("key", "") + summary = f.get("summary", "") + status_name = f.get("status", {}).get("name", "Unknown") + status_cat = f.get("status", {}).get("statusCategory", {}).get("key", "") + assignee_name = (f.get("assignee") or {}).get("displayName", "Unassigned") + points = f.get("customfield_10028") or 0 + issue_type = f.get("issuetype", {}).get("name", "") + priority = f.get("priority", {}).get("name", "") + blocked_val = (f.get("customfield_10517") or {}).get("value", "False") + blocked_reason_adf = f.get("customfield_10483") + release_blocker = f.get("customfield_10847") + + total_points += points + + # Map status to group + if status_cat == "done": + group = "done" + done_points += points + elif status_name == "Code Review": + group = "codeReview" + elif status_name == "MODIFIED": + group = "modified" + elif status_cat == "indeterminate" or status_name == "In Progress": + group = "inProgress" + elif status_cat == "new" or status_name in ("To Do", "NEW"): + group = "toDo" + else: + group = "other" + + item = { + "key": key, + "summary": summary, + "status": status_name, + "statusGroup": group, + "assignee": assignee_name, + "points": points, + "type": issue_type, + "priority": priority, + "blocked": blocked_val == "True", + "releaseBlocker": release_blocker, + } + + status_groups.setdefault(group, []).append(item) + + # Track blocked items + if blocked_val == "True": + blocked_items.append(item) + + # At risk: not done, with sprint ending soon + if group not in ("done",) and days_remaining <= 3: + at_risk.append(item) + + # Workload tracking + wl = team_workload.setdefault(assignee_name, { + "member": assignee_name, + "toDo": 0, "inProgress": 0, "codeReview": 0, "modified": 0, + "done": 0, "other": 0, "total": 0, + "pointsDone": 0, "pointsTotal": 0, + }) + wl[group] = wl.get(group, 0) + 1 + wl["total"] += 1 + wl["pointsTotal"] += points + if group == "done": + wl["pointsDone"] += points + +# Sort groups by status order +by_status = {} +for group in sorted(status_groups.keys(), key=lambda g: STATUS_ORDER.get(g, 99)): + by_status[group] = status_groups[group] + +# Build roster with hasItems flag +roster_out = [] +active_members = set(team_workload.keys()) +for m in roster: + roster_out.append({ + "name": m["name"], + "github": m["github"], + "hasItems": m["name"] in active_members, + }) +# Add non-roster assignees +roster_names = {m["name"] for m in roster} +for name in active_members - roster_names: + if name != "Unassigned": + roster_out.append({"name": name, "github": "", "hasItems": True, "offRoster": True}) + +result = { + "sprint": { + "id": sprint["id"], + "name": sprint["name"], + "startDate": sprint["startDate"], + "endDate": sprint["endDate"], + "goal": sprint.get("goal", ""), + "daysElapsed": elapsed_days, + "daysTotal": total_days, + "daysRemaining": days_remaining, + }, + "summary": { + "total": len(issues), + "done": len(status_groups.get("done", [])), + "codeReview": len(status_groups.get("codeReview", [])), + "inProgress": len(status_groups.get("inProgress", [])), + "modified": len(status_groups.get("modified", [])), + "toDo": len(status_groups.get("toDo", [])), + "other": len(status_groups.get("other", [])), + "totalPoints": total_points, + "donePoints": done_points, + }, + "byStatus": by_status, + "blockers": blocked_items, + "atRisk": at_risk, + "teamWorkload": sorted(team_workload.values(), key=lambda w: w["total"], reverse=True), + "roster": roster_out, +} + +print(json.dumps(result)) +PYEOF +} diff --git a/plugins/node-team/scripts/lib/composite/standup-data.sh b/plugins/node-team/scripts/lib/composite/standup-data.sh new file mode 100644 index 000000000..9bc7f72e1 --- /dev/null +++ b/plugins/node-team/scripts/lib/composite/standup-data.sh @@ -0,0 +1,295 @@ +#!/bin/bash +# Composite: standup-data [--stream] +# Returns sprint dashboard + recent updates + per-member comments +# 1 data query + N comment fetches +# Serves: /standup, /my-standup, /team-member + +[[ -n "${_COMPOSITE_STANDUP_DATA_LOADED:-}" ]] && return 0 +_COMPOSITE_STANDUP_DATA_LOADED=1 + +cmd_standup_data() { + local team="${1:?Team required (e.g., 'Node Devices' or 'Node Core')}" + shift + local stream=false sprint_ref="" + while [[ $# -gt 0 ]]; do + case "$1" in + --stream) stream=true; shift ;; + --sprint) sprint_ref="${2:?--sprint requires a value}"; shift 2 ;; + *) shift ;; + esac + done + + # ── Resolve team config ────────────────────────────────────────────────── + team_config "$team" + + # ── Get sprint info ───────────────────────────────────────────────────── + local sprint_json + sprint_json=$(resolve_sprint "$team" "$sprint_ref") || return 1 + + local sprint_id + sprint_id=$(echo "$sprint_json" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])") + + # ── Parallel: sprint issues (with updated field) + new bugs + roster ──── + parallel_init + + parallel_run "issues" cmd_sprint_issues "$sprint_id" 100 "${ISSUE_FIELDS},updated" + parallel_run "roster" team_roster "$team" + + parallel_wait_all || true + + local issues_json roster_json + issues_json=$(parallel_get "issues") + roster_json=$(parallel_get "roster") + + # ── Get issue keys for comment fetching ────────────────────────────────── + local issue_keys + issue_keys=$(echo "$issues_json" | python3 -c " +import json, sys +data = json.load(sys.stdin) +keys = [i['key'] for i in data.get('issues', [])] +print(' '.join(keys)) +") + + # ── Fetch comments in parallel (batch of 5) ───────────────────────────── + # Re-init parallel for comment batch + parallel_cleanup 2>/dev/null || true + parallel_init + + local count=0 + for key in $issue_keys; do + parallel_run "comments_${key}" cmd_comments "$key" + count=$((count + 1)) + if (( count % 5 == 0 )); then + parallel_wait_all 2>/dev/null || true + fi + done + parallel_wait_all 2>/dev/null || true + + # Collect all comments into a JSON object keyed by issue key + local comments_combined="{" + local first=true + for key in $issue_keys; do + local c + c=$(parallel_get "comments_${key}" 2>/dev/null) + if [[ -n "$c" && "$c" != *"error"* ]]; then + if [[ "$first" == "true" ]]; then + first=false + else + comments_combined+="," + fi + comments_combined+="\"${key}\":${c}" + fi + done + comments_combined+="}" + + # ── Process everything in Python ───────────────────────────────────────── + local adf_py + adf_py="$(cd "$(dirname "${BASH_SOURCE[0]}")/../util" && pwd)/adf.py" + + python3 - "$sprint_json" "$roster_json" "$comments_combined" "$adf_py" "$issues_json" <<'PYEOF' +import json, sys, importlib.util +from datetime import datetime, timedelta, timezone +from collections import Counter + +sprint = json.loads(sys.argv[1]) +roster = json.loads(sys.argv[2]) +all_comments = json.loads(sys.argv[3]) +adf_py_path = sys.argv[4] +data = json.loads(sys.argv[5]) +issues = data.get("issues", []) + +# Load ADF converter +spec = importlib.util.spec_from_file_location("adf", adf_py_path) +adf_mod = importlib.util.module_from_spec(spec) +spec.loader.exec_module(adf_mod) + +# Sprint progress +start = datetime.fromisoformat(sprint["startDate"].replace("Z", "+00:00")) +end = datetime.fromisoformat(sprint["endDate"].replace("Z", "+00:00")) +now = datetime.now(timezone.utc) +total_days = max((end - start).days, 1) +elapsed_days = min(max((now - start).days, 0), total_days) +days_remaining = max(total_days - elapsed_days, 0) +cutoff = now - timedelta(days=7) + +# Categorize issues +STATUS_ORDER = {"done": 0, "codeReview": 1, "inProgress": 2, "modified": 3, "toDo": 4, "other": 5} +status_groups = {} +team_workload = {} +total_points = 0 +done_points = 0 +blocked_items = [] +at_risk = [] +_shape_warned = set() + +for issue in issues: + f = issue.get("fields", {}) + key = issue.get("key", "") + summary = f.get("summary", "") + status_name = f.get("status", {}).get("name", "Unknown") + status_cat = f.get("status", {}).get("statusCategory", {}).get("key", "") + assignee_name = (f.get("assignee") or {}).get("displayName", "Unassigned") + points = f.get("customfield_10028") or 0 + issue_type = f.get("issuetype", {}).get("name", "") + priority = f.get("priority", {}).get("name", "") + blocked_raw = f.get("customfield_10517") + if blocked_raw is not None and not isinstance(blocked_raw, dict) and "blocked" not in _shape_warned: + print(f"SHAPE WARNING: Blocked field (customfield_10517) is {type(blocked_raw).__name__}, " + f"expected dict or None (on {key}). Blocker detection may be broken.", file=sys.stderr) + _shape_warned.add("blocked") + blocked_val = (blocked_raw or {}).get("value", "False") if isinstance(blocked_raw, dict) else "False" + release_blocker = f.get("customfield_10847") + + total_points += points + + if status_cat == "done": + group = "done" + done_points += points + elif status_name == "Code Review": + group = "codeReview" + elif status_name == "MODIFIED": + group = "modified" + elif status_cat == "indeterminate" or status_name == "In Progress": + group = "inProgress" + elif status_cat == "new" or status_name in ("To Do", "NEW"): + group = "toDo" + else: + group = "other" + + # Latest comment for this issue + latest_comment = None + issue_comments = all_comments.get(key, {}).get("comments", []) + if issue_comments: + last_c = issue_comments[-1] + body_adf = last_c.get("body", {}) + body_text = adf_mod.adf_to_text(body_adf).strip() if isinstance(body_adf, dict) else str(body_adf) + latest_comment = { + "author": last_c.get("author", {}).get("displayName", "Unknown"), + "created": last_c.get("created", ""), + "body": body_text, + } + + item = { + "key": key, "summary": summary, "status": status_name, + "statusGroup": group, "assignee": assignee_name, + "points": points, "type": issue_type, "priority": priority, + "blocked": blocked_val == "True", "releaseBlocker": release_blocker, + "latestComment": latest_comment, + } + status_groups.setdefault(group, []).append(item) + + if blocked_val == "True": + blocked_items.append(item) + if group not in ("done",) and days_remaining <= 3: + at_risk.append(item) + + # Workload + wl = team_workload.setdefault(assignee_name, { + "member": assignee_name, + "toDo": 0, "inProgress": 0, "codeReview": 0, "modified": 0, + "done": 0, "other": 0, "total": 0, + "pointsDone": 0, "pointsTotal": 0, "commentCount7d": 0, + }) + wl[group] = wl.get(group, 0) + 1 + wl["total"] += 1 + wl["pointsTotal"] += points + if group == "done": + wl["pointsDone"] += points + +# Process comments — extract recent ones, count per member +for key, comment_data in all_comments.items(): + for c in comment_data.get("comments", []): + created = c.get("created", "") + author = c.get("author", {}).get("displayName", "Unknown") + try: + dt = datetime.fromisoformat(created.replace("Z", "+00:00")) + if dt >= cutoff: + if author in team_workload: + team_workload[author]["commentCount7d"] += 1 + else: + team_workload.setdefault(author, { + "member": author, "toDo": 0, "inProgress": 0, "codeReview": 0, + "modified": 0, "done": 0, "other": 0, "total": 0, + "pointsDone": 0, "pointsTotal": 0, "commentCount7d": 1, + }) + except (ValueError, TypeError): + pass + +# Recently updated keys (derived from sprint issues' updated field — was a separate query) +recent_keys = [] +for issue in issues: + updated = issue.get("fields", {}).get("updated", "") + if updated: + try: + dt = datetime.fromisoformat(updated.replace("Z", "+00:00")) + if dt >= cutoff: + recent_keys.append(issue.get("key", "")) + except (ValueError, TypeError): + pass + +# Build roster +roster_out = [] +active_members = set(team_workload.keys()) +for m in roster: + name = m["name"] + wl = team_workload.get(name, {}) + roster_out.append({ + "name": name, "github": m["github"], + "hasItems": name in active_members, + "sprintItems": wl.get("total", 0), + "commentCount7d": wl.get("commentCount7d", 0), + "statusSummary": {g: wl.get(g, 0) for g in STATUS_ORDER if wl.get(g, 0) > 0}, + }) +# Non-roster assignees +roster_names = {m["name"] for m in roster} +for name in active_members - roster_names - {"Unassigned"}: + wl = team_workload[name] + roster_out.append({ + "name": name, "github": "", "hasItems": True, "offRoster": True, + "sprintItems": wl.get("total", 0), + "commentCount7d": wl.get("commentCount7d", 0), + "statusSummary": {g: wl.get(g, 0) for g in STATUS_ORDER if wl.get(g, 0) > 0}, + }) + +by_status = {} +for group in sorted(status_groups.keys(), key=lambda g: STATUS_ORDER.get(g, 99)): + by_status[group] = sorted(status_groups[group], key=lambda i: i["assignee"]) + +# Group by assignee (alphabetical), sorted by status within each person +by_assignee = {} +for group_items in status_groups.values(): + for item in group_items: + by_assignee.setdefault(item["assignee"], []).append(item) +for name in by_assignee: + by_assignee[name].sort(key=lambda i: STATUS_ORDER.get(i["statusGroup"], 99)) + +result = { + "sprint": { + "id": sprint["id"], "name": sprint["name"], + "startDate": sprint["startDate"], "endDate": sprint["endDate"], + "goal": sprint.get("goal", ""), + "daysElapsed": elapsed_days, "daysTotal": total_days, "daysRemaining": days_remaining, + }, + "summary": { + "total": len(issues), + "done": len(status_groups.get("done", [])), + "codeReview": len(status_groups.get("codeReview", [])), + "inProgress": len(status_groups.get("inProgress", [])), + "modified": len(status_groups.get("modified", [])), + "toDo": len(status_groups.get("toDo", [])), + "other": len(status_groups.get("other", [])), + "totalPoints": total_points, + "donePoints": done_points, + }, + "byStatus": by_status, + "byAssignee": dict(sorted(by_assignee.items(), key=lambda x: (x[0] == "Unassigned", x[0].lower()))), + "blockers": blocked_items, + "atRisk": at_risk, + "recentlyUpdatedKeys": recent_keys, + "memberActivity": roster_out, + "teamWorkload": sorted(team_workload.values(), key=lambda w: w["total"], reverse=True), +} + +print(json.dumps(result)) +PYEOF +} diff --git a/plugins/node-team/scripts/lib/composite/team-activity.sh b/plugins/node-team/scripts/lib/composite/team-activity.sh new file mode 100644 index 000000000..ea0c07007 --- /dev/null +++ b/plugins/node-team/scripts/lib/composite/team-activity.sh @@ -0,0 +1,151 @@ +#!/bin/bash +# Composite: team-activity +# Per-member sprint items + comment counts for the last 7 days +# Serves: supplements /standup, /team-member + +[[ -n "${_COMPOSITE_TEAM_ACTIVITY_LOADED:-}" ]] && return 0 +_COMPOSITE_TEAM_ACTIVITY_LOADED=1 + +cmd_team_activity() { + local team="${1:?Team required}" + shift + local sprint_ref="" + while [[ $# -gt 0 ]]; do + case "$1" in + --sprint) sprint_ref="${2:?--sprint requires a value}"; shift 2 ;; + *) shift ;; + esac + done + + team_config "$team" + + local sprint_json + sprint_json=$(resolve_sprint "$team" "$sprint_ref") || return 1 + + local sprint_id + sprint_id=$(echo "$sprint_json" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])") + + # ── Get issues + roster ────────────────────────────────────────────────── + parallel_init + parallel_run "issues" cmd_sprint_issues "$sprint_id" + parallel_run "roster" team_roster "$team" + parallel_wait_all || true + + local issues_json roster_json + issues_json=$(parallel_get "issues") + roster_json=$(parallel_get "roster") + + # ── Get issue keys and fetch comments in parallel ──────────────────────── + local issue_keys + issue_keys=$(echo "$issues_json" | python3 -c " +import json, sys +data = json.load(sys.stdin) +keys = [i['key'] for i in data.get('issues', [])] +print(' '.join(keys)) +") + + parallel_cleanup 2>/dev/null || true + parallel_init + + for key in $issue_keys; do + parallel_run "comments_${key}" cmd_comments "$key" + done + parallel_wait_all 2>/dev/null || true + + # Collect comments + local comments_combined="{" + local first=true + for key in $issue_keys; do + local c + c=$(parallel_get "comments_${key}" 2>/dev/null) + if [[ -n "$c" && "$c" != *"error"* ]]; then + [[ "$first" == "true" ]] && first=false || comments_combined+="," + comments_combined+="\"${key}\":${c}" + fi + done + comments_combined+="}" + + local adf_py + adf_py="$(cd "$(dirname "${BASH_SOURCE[0]}")/../util" && pwd)/adf.py" + + python3 - "$sprint_json" "$roster_json" "$comments_combined" "$adf_py" "$issues_json" <<'PYEOF' +import json, sys, importlib.util +from datetime import datetime, timedelta, timezone +from collections import Counter + +sprint = json.loads(sys.argv[1]) +roster = json.loads(sys.argv[2]) +all_comments = json.loads(sys.argv[3]) +adf_py_path = sys.argv[4] +data = json.loads(sys.argv[5]) +issues = data.get("issues", []) + +spec = importlib.util.spec_from_file_location("adf", adf_py_path) +adf_mod = importlib.util.module_from_spec(spec) +spec.loader.exec_module(adf_mod) + +cutoff = datetime.now(timezone.utc) - timedelta(days=7) + +# Build member activity +member_items = {} +member_comments = Counter() + +for issue in issues: + f = issue.get("fields", {}) + assignee = (f.get("assignee") or {}).get("displayName", "Unassigned") + status = f.get("status", {}).get("name", "") + member_items.setdefault(assignee, []).append({ + "key": issue.get("key", ""), + "summary": f.get("summary", ""), + "status": status, + "points": f.get("customfield_10028") or 0, + }) + +# Count recent comments +for key, comment_data in all_comments.items(): + for c in comment_data.get("comments", []): + created = c.get("created", "") + author = c.get("author", {}).get("displayName", "Unknown") + try: + dt = datetime.fromisoformat(created.replace("Z", "+00:00")) + if dt >= cutoff: + member_comments[author] += 1 + except (ValueError, TypeError): + pass + +# Build output +members = [] +roster_names = {m["name"] for m in roster} +all_names = roster_names | set(member_items.keys()) - {"Unassigned"} + +for m in roster: + name = m["name"] + items = member_items.get(name, []) + members.append({ + "name": name, + "github": m["github"], + "sprintItems": items, + "sprintItemCount": len(items), + "commentCount7d": member_comments.get(name, 0), + "onRoster": True, + }) + +for name in set(member_items.keys()) - roster_names - {"Unassigned"}: + items = member_items[name] + members.append({ + "name": name, + "github": "", + "sprintItems": items, + "sprintItemCount": len(items), + "commentCount7d": member_comments.get(name, 0), + "onRoster": False, + }) + +result = { + "sprint": {"id": sprint["id"], "name": sprint["name"]}, + "members": sorted(members, key=lambda m: m["sprintItemCount"], reverse=True), +} + +print(json.dumps(result)) +PYEOF +} diff --git a/plugins/node-team/scripts/lib/core.sh b/plugins/node-team/scripts/lib/core.sh new file mode 100644 index 000000000..15fe989d7 --- /dev/null +++ b/plugins/node-team/scripts/lib/core.sh @@ -0,0 +1,88 @@ +#!/bin/bash +# Core library: auth, HTTP, constants, logging +# Sourced by all other modules — never executed directly + +[[ -n "${_CORE_LOADED:-}" ]] && return 0 +_CORE_LOADED=1 + +# ── Constants ────────────────────────────────────────────────────────────────── + +JIRA_BASE="https://redhat.atlassian.net" +BOARD_ID="${JIRA_BOARD_ID:-7845}" + +# Custom field IDs +CF_SPRINT="customfield_10020" +CF_STORY_POINTS="customfield_10028" +CF_EPIC_LINK="customfield_10014" +CF_TARGET_VERSION="customfield_10855" +CF_RELEASE_BLOCKER="customfield_10847" +CF_SFDC_COUNTER="customfield_10978" +CF_SFDC_LINKS="customfield_10979" +CF_SEVERITY="customfield_10840" +CF_BLOCKED="customfield_10517" +CF_BLOCKED_REASON="customfield_10483" + +# Standard fields requested by search/sprint-issues +ISSUE_FIELDS="key,summary,status,assignee,priority,issuetype,fixVersions,components,${CF_SPRINT},${CF_STORY_POINTS},${CF_EPIC_LINK},${CF_BLOCKED},${CF_BLOCKED_REASON},${CF_RELEASE_BLOCKER}" +SEARCH_FIELDS_JSON="[\"key\",\"summary\",\"status\",\"assignee\",\"priority\",\"issuetype\",\"fixVersions\",\"components\",\"${CF_SPRINT}\",\"${CF_STORY_POINTS}\",\"${CF_EPIC_LINK}\",\"${CF_BLOCKED}\",\"${CF_BLOCKED_REASON}\",\"${CF_RELEASE_BLOCKER}\"]" + +# ── Logging ──────────────────────────────────────────────────────────────────── + +_log() { + local level="$1"; shift + echo "[$(date -u +%H:%M:%S)] ${level}: $*" >&2 +} + +# ── Python check ─────────────────────────────────────────────────────────────── + +_check_python() { + command -v python3 >/dev/null 2>&1 || { + echo '{"error":"python3 is required but not found"}' >&2 + exit 1 + } +} + +# ── Auth ─────────────────────────────────────────────────────────────────────── + +_init_auth() { + [[ -n "${_AUTH_INITIALIZED:-}" ]] && return 0 + _AUTH_INITIALIZED=1 + + JIRA_API_TOKEN="${JIRA_API_TOKEN:-$(security find-generic-password -s "JIRA_API_TOKEN" -w 2>/dev/null || true)}" + if [[ -z "$JIRA_API_TOKEN" ]]; then + echo '{"error": "JIRA_API_TOKEN not set (env var or Keychain)"}' >&2; exit 1 + fi + + JIRA_USER=$(security find-generic-password -s "JIRA_API_TOKEN" -g 2>&1 | grep "acct" | sed 's/.*="//;s/"//' 2>/dev/null) || true + if [[ -n "$JIRA_USER" && ! "$JIRA_USER" =~ "@" ]]; then + JIRA_USER="${JIRA_USER}@redhat.com" + fi + if [[ -z "$JIRA_USER" ]]; then + JIRA_USER="${JIRA_EMAIL:-$(git config user.email 2>/dev/null || echo "")}" + fi + if [[ -z "$JIRA_USER" ]]; then + echo '{"error": "Cannot determine Jira email. Set JIRA_EMAIL env var."}' >&2; exit 1 + fi + + AUTH="-u ${JIRA_USER}:${JIRA_API_TOKEN}" +} + +# ── HTTP ─────────────────────────────────────────────────────────────────────── + +_curl() { + _init_auth + curl -s $AUTH -H "Content-Type: application/json" "$@" +} + +# ── Utilities ────────────────────────────────────────────────────────────────── + +_jql_encode() { + python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$1" +} + +# ADF-to-text conversion via Python helper +_adf_to_text() { + local script_dir + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + python3 "${script_dir}/util/adf.py" "$@" +} diff --git a/plugins/node-team/scripts/lib/team.sh b/plugins/node-team/scripts/lib/team.sh new file mode 100644 index 000000000..2faae052b --- /dev/null +++ b/plugins/node-team/scripts/lib/team.sh @@ -0,0 +1,295 @@ +#!/bin/bash +# Team configuration: resolves team name to sprint filter, roster, bug components +# Sourced by jira.sh — requires core.sh + +[[ -n "${_TEAM_LOADED:-}" ]] && return 0 +_TEAM_LOADED=1 + +# Print a clear error when a roster file is missing, then exit +_roster_missing() { + local file="$1" + local example="${file%.json}.example.json" + cat >&2 <&2 + return 1 + ;; + esac +} + +# Load team roster as JSON array: [{name, github}, ...] +team_roster() { + local team="${1:-}" + [[ -n "$team" ]] && team_config "$team" + + local root_dir + root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + + # "all" team: merge both rosters (deduplicated) + if [[ -z "$TEAM_ROSTER_FILE" ]]; then + local na_dir="${HOME}/.node-assistant" + local dra_file="${na_dir}/team-roster-dra.json" + local core_file="${na_dir}/team-roster-core.json" + [[ ! -f "$dra_file" ]] && dra_file="${root_dir}/config/team-roster-dra.json" + [[ ! -f "$core_file" ]] && core_file="${root_dir}/config/team-roster-core.json" + if [[ ! -f "$dra_file" || ! -f "$core_file" ]]; then + if type -t cmd_roster_sync >/dev/null 2>&1; then + _log "INFO" "Roster(s) not found, attempting auto-sync..." + cmd_roster_sync >/dev/null 2>&1 || true + [[ ! -f "$dra_file" && -f "${na_dir}/team-roster-dra.json" ]] && dra_file="${na_dir}/team-roster-dra.json" + [[ ! -f "$core_file" && -f "${na_dir}/team-roster-core.json" ]] && core_file="${na_dir}/team-roster-core.json" + fi + [[ ! -f "$dra_file" ]] && _roster_missing "$dra_file" + [[ ! -f "$core_file" ]] && _roster_missing "$core_file" + fi + python3 -c " +import json, sys +members = {} +for f in sys.argv[1:]: + with open(f) as fh: + for k, v in json.load(fh).get('members', {}).items(): + members[k] = v +print(json.dumps([{'name': k, 'github': v} for k, v in members.items()])) +" "$dra_file" "$core_file" + return 0 + fi + + if [[ ! -f "$TEAM_ROSTER_FILE" ]]; then + if type -t cmd_roster_sync >/dev/null 2>&1; then + _log "INFO" "Roster not found, attempting auto-sync..." + cmd_roster_sync >/dev/null 2>&1 || true + [[ -n "${1:-}" ]] && team_config "$1" + fi + if [[ ! -f "$TEAM_ROSTER_FILE" ]]; then + _roster_missing "$TEAM_ROSTER_FILE" + fi + fi + + python3 -c " +import json, sys +with open(sys.argv[1]) as f: + data = json.load(f) +members = [{'name': k, 'github': v} for k, v in data.get('members', {}).items()] +print(json.dumps(members)) +" "$TEAM_ROSTER_FILE" +} + +# Find the active (or specified state) sprint for a team +# Returns JSON: {id, name, startDate, endDate, goal} +team_sprint() { + local team="$1" + local state="${2:-active}" + + [[ -z "${TEAM_SPRINT_FILTER:-}" ]] && team_config "$team" + + local sprints + if type -t cached_sprints >/dev/null 2>&1; then + sprints=$(cached_sprints "$state") + else + sprints=$(cmd_sprints "$state") + fi + + python3 -c " +import json, sys +data = json.loads(sys.argv[1]) +team_filter = sys.argv[2] +for s in data.get('values', []): + if team_filter in s.get('name', ''): + print(json.dumps({ + 'id': s['id'], + 'name': s['name'], + 'startDate': s.get('startDate', ''), + 'endDate': s.get('endDate', ''), + 'goal': s.get('goal', '') + })) + sys.exit(0) +print(json.dumps({'error': f'No {team_filter} sprint found with state={sys.argv[3]}'}), file=sys.stderr) +sys.exit(1) +" "$sprints" "$TEAM_SPRINT_FILTER" "$state" +} + +# Resolve a sprint by name substring or numeric ID. +# If sprint_ref is empty, falls back to active → future. +# Usage: resolve_sprint "core" "" → active sprint for core +# resolve_sprint "core" "Sprint 288" → sprint matching "Sprint 288" +# resolve_sprint "core" "65617" → sprint with id 65617 +resolve_sprint() { + local team="$1" + local sprint_ref="${2:-}" + + [[ -z "${TEAM_SPRINT_FILTER:-}" ]] && team_config "$team" + + # No ref → default: active, then future + if [[ -z "$sprint_ref" ]]; then + local result + result=$(team_sprint "$team" active 2>/dev/null) && { echo "$result"; return 0; } + result=$(team_sprint "$team" future 2>/dev/null) && { echo "$result"; return 0; } + echo '{"error":"No active or future sprint found for '"$team"'"}' >&2 + return 1 + fi + + # Search active, future, and closed sprints for a match + local all_sprints + all_sprints=$( + for state in active future closed; do + cmd_sprints "$state" 2>/dev/null || true + done | python3 -c ' +import json, sys +out = [] +for line in sys.stdin.read().split("\n"): + line = line.strip() + if not line: + continue + try: + d = json.loads(line) + except json.JSONDecodeError: + continue + out.extend(d.get("values", [])) +print(json.dumps(out)) +' + ) + + python3 -c " +import json, sys + +ref = sys.argv[1] +sprints = json.loads(sys.argv[2] or '[]') + +# Try numeric ID first +if ref.isdigit(): + ref_id = int(ref) + for s in sprints: + if s.get('id') == ref_id: + print(json.dumps({'id': s['id'], 'name': s['name'], 'startDate': s.get('startDate',''), 'endDate': s.get('endDate',''), 'goal': s.get('goal','')})) + sys.exit(0) + +# Substring match on name (case-insensitive) +ref_lower = ref.lower() +for s in sprints: + if ref_lower in s.get('name', '').lower(): + print(json.dumps({'id': s['id'], 'name': s['name'], 'startDate': s.get('startDate',''), 'endDate': s.get('endDate',''), 'goal': s.get('goal','')})) + sys.exit(0) + +print(json.dumps({'error': f'No sprint matching \"{ref}\" found'}), file=sys.stderr) +sys.exit(1) +" "$sprint_ref" "$all_sprints" +} + +# Find the active sprint, falling back to the most recently closed sprint. +# Returns JSON: {id, name, startDate, endDate, goal, state} +# The "state" field indicates whether this is "active" or "closed" (fallback). +team_sprint_fallback() { + local team="$1" + + [[ -z "${TEAM_SPRINT_FILTER:-}" ]] && team_config "$team" + + # Try active first + local active_result + active_result=$(team_sprint "$team" active 2>/dev/null) && { + # Add state field so callers know this is a live active sprint + echo "$active_result" | python3 -c " +import json, sys +d = json.load(sys.stdin) +d['state'] = 'active' +print(json.dumps(d)) +" + return 0 + } + + # Fall back to most recently closed sprint + local closed_sprints + if type -t cached_sprints >/dev/null 2>&1; then + closed_sprints=$(cached_sprints "closed") + else + closed_sprints=$(cmd_sprints "closed") + fi + + python3 -c " +import json, sys +data = json.loads(sys.argv[1]) +team_filter = sys.argv[2] +# cmd_sprints already sorts by startDate descending, so first match is most recent +for s in data.get('values', []): + if team_filter in s.get('name', ''): + print(json.dumps({ + 'id': s['id'], + 'name': s['name'], + 'startDate': s.get('startDate', ''), + 'endDate': s.get('endDate', ''), + 'goal': s.get('goal', ''), + 'state': 'closed', + })) + sys.exit(0) +print(json.dumps({'error': f'No {team_filter} sprint found (active or closed)'}), file=sys.stderr) +sys.exit(1) +" "$closed_sprints" "$TEAM_SPRINT_FILTER" +} diff --git a/plugins/node-team/scripts/lib/util/adf.py b/plugins/node-team/scripts/lib/util/adf.py new file mode 100644 index 000000000..90fc68b03 --- /dev/null +++ b/plugins/node-team/scripts/lib/util/adf.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Convert Atlassian Document Format (ADF) JSON to plain text. + +Usage: + echo '' | python3 adf.py # Convert raw ADF node + echo '' | python3 adf.py --field description # Extract ADF field from issue + echo '' | python3 adf.py --comments # Extract all comments with metadata + echo '' | python3 adf.py --issues # Extract from search/sprint-issues result +""" + +import json +import sys +from datetime import datetime, timedelta, timezone + + +def adf_to_text(node): + """Recursively convert an ADF node tree to plain text.""" + if isinstance(node, str): + return node + if not isinstance(node, dict): + return "" + + node_type = node.get("type", "") + text = "" + + if node_type == "text": + t = node.get("text", "") + # Check for link marks + for mark in node.get("marks", []): + if mark.get("type") == "link": + href = mark.get("attrs", {}).get("href", "") + if href and href != t: + t = f"{t} ({href})" + text = t + elif node_type in ("blockCard", "inlineCard", "embedCard"): + url = node.get("attrs", {}).get("url", "") + if url: + text = url + "\n" + elif node_type == "mediaInline": + alt = node.get("attrs", {}).get("alt", "") + text = f"[{alt or 'attachment'}]" + elif node_type == "mention": + text = "@" + node.get("attrs", {}).get("text", node.get("attrs", {}).get("id", "")) + + for child in node.get("content", []): + text += adf_to_text(child) + + if node_type in ("paragraph", "heading", "listItem", "blockquote"): + text += "\n" + elif node_type == "hardBreak": + text += "\n" + elif node_type in ("codeBlock",): + text += "\n" + return text + + +def extract_field(issue, field_name): + """Extract an ADF field from an issue JSON and convert to text.""" + fields = issue.get("fields", issue) + adf = fields.get(field_name) + if not adf: + return "" + if isinstance(adf, str): + return adf + return adf_to_text(adf).strip() + + +def extract_comments(data, since_days=None): + """Extract comments from a comments API response. + + Returns list of {author, date, body} dicts. + If since_days is set, only returns comments from the last N days. + """ + cutoff = None + if since_days is not None: + cutoff = datetime.now(timezone.utc) - timedelta(days=since_days) + + comments = data.get("comments", []) + results = [] + for c in comments: + created = c.get("created", "") + if cutoff and created: + try: + dt = datetime.fromisoformat(created.replace("Z", "+00:00")) + if dt < cutoff: + continue + except (ValueError, TypeError): + pass + + author = c.get("author", {}).get("displayName", "Unknown") + body_adf = c.get("body", {}) + body_text = adf_to_text(body_adf).strip() if isinstance(body_adf, dict) else str(body_adf) + + results.append({ + "author": author, + "created": created, + "body": body_text, + }) + return results + + +def extract_issues(data): + """Extract description text from each issue in a search/sprint-issues response.""" + issues = data.get("issues", []) + results = [] + for issue in issues: + key = issue.get("key", "") + fields = issue.get("fields", {}) + desc_adf = fields.get("description") + desc_text = "" + if isinstance(desc_adf, dict): + desc_text = adf_to_text(desc_adf).strip() + elif isinstance(desc_adf, str): + desc_text = desc_adf.strip() + + blocked_reason = fields.get("customfield_10483") + blocked_text = "" + if isinstance(blocked_reason, dict): + blocked_text = adf_to_text(blocked_reason).strip() + elif isinstance(blocked_reason, str): + blocked_text = blocked_reason.strip() + + results.append({ + "key": key, + "description": desc_text, + "blockedReason": blocked_text, + }) + return results + + +def main(): + args = sys.argv[1:] + data = json.load(sys.stdin) + + if "--field" in args: + idx = args.index("--field") + field_name = args[idx + 1] if idx + 1 < len(args) else "description" + print(extract_field(data, field_name)) + + elif "--comments" in args: + since = None + if "--since-days" in args: + si = args.index("--since-days") + since = int(args[si + 1]) if si + 1 < len(args) else None + results = extract_comments(data, since_days=since) + print(json.dumps(results)) + + elif "--issues" in args: + results = extract_issues(data) + print(json.dumps(results)) + + else: + # Raw ADF node conversion + print(adf_to_text(data).strip()) + + +if __name__ == "__main__": + main() diff --git a/plugins/node-team/scripts/lib/util/cache.sh b/plugins/node-team/scripts/lib/util/cache.sh new file mode 100644 index 000000000..5b7e96309 --- /dev/null +++ b/plugins/node-team/scripts/lib/util/cache.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# File-based caching for sprint discovery and other slow queries +# Cache lives in $TMPDIR, scoped to process tree, auto-cleaned on exit + +[[ -n "${_CACHE_LOADED:-}" ]] && return 0 +_CACHE_LOADED=1 + +_CACHE_TTL="${JIRA_CACHE_TTL:-300}" # 5 minutes default +_CACHE_DIR="" + +_cache_init() { + if [[ -z "$_CACHE_DIR" ]]; then + _CACHE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/jira-cache-$$.XXXXXX") + local existing + existing=$(trap -p EXIT | sed -E "s/^trap -- '(.*)' EXIT$/\1/") + if [[ -n "$existing" ]]; then + trap "_cache_cleanup; ${existing}" EXIT + else + trap '_cache_cleanup' EXIT + fi + fi +} + +_cache_cleanup() { + if [[ -n "$_CACHE_DIR" && -d "$_CACHE_DIR" ]]; then + rm -rf "$_CACHE_DIR" + fi +} + +cache_get() { + _cache_init + local key="$1" + local file="${_CACHE_DIR}/${key}" + if [[ -f "$file" ]]; then + local age + age=$(( $(date +%s) - $(stat -f %m "$file" 2>/dev/null || stat -c %Y "$file" 2>/dev/null || echo 0) )) + if (( age < _CACHE_TTL )); then + cat "$file" + return 0 + fi + rm -f "$file" + fi + return 1 +} + +cache_set() { + _cache_init + local key="$1" + local value="$2" + echo "$value" > "${_CACHE_DIR}/${key}" +} + +# Cache-through wrapper for sprint discovery +cached_sprints() { + local state="${1:-active}" + local cache_key="sprints_${state}" + local cached + if cached=$(cache_get "$cache_key" 2>/dev/null); then + echo "$cached" + return 0 + fi + local result + result=$(cmd_sprints "$state") + cache_set "$cache_key" "$result" + echo "$result" +} diff --git a/plugins/node-team/scripts/lib/util/parallel.sh b/plugins/node-team/scripts/lib/util/parallel.sh new file mode 100644 index 000000000..33998e1b5 --- /dev/null +++ b/plugins/node-team/scripts/lib/util/parallel.sh @@ -0,0 +1,128 @@ +#!/bin/bash +# Parallel job management for composite commands +# Runs multiple API calls concurrently using background jobs + temp files + +[[ -n "${_PARALLEL_LOADED:-}" ]] && return 0 +_PARALLEL_LOADED=1 + +_PARALLEL_DIR="" +_PARALLEL_PID_LIST="" # space-separated "name:pid" pairs + +parallel_init() { + _PARALLEL_DIR=$(mktemp -d "${TMPDIR:-/tmp}/jira-parallel-$$.XXXXXX") + _PARALLEL_PID_LIST="" + trap 'parallel_cleanup' EXIT +} + +parallel_run() { + local name="$1" + shift + ( "$@" > "${_PARALLEL_DIR}/${name}.json" 2>"${_PARALLEL_DIR}/${name}.err" ) & + _PARALLEL_PID_LIST="${_PARALLEL_PID_LIST} ${name}:$!" +} + +parallel_wait_all() { + local failed=0 + local entry pid name + for entry in $_PARALLEL_PID_LIST; do + name="${entry%%:*}" + pid="${entry##*:}" + if ! wait "$pid" 2>/dev/null; then + failed=1 + _log "WARN" "Parallel job '${name}' failed (PID ${pid})" + fi + done + _PARALLEL_PID_LIST="" + return $failed +} + +parallel_get() { + local name="$1" + local outfile="${_PARALLEL_DIR}/${name}.json" + if [[ -f "$outfile" ]]; then + cat "$outfile" + else + echo "{\"error\":\"No result for job '${name}'\"}" + fi +} + +parallel_get_err() { + local name="$1" + local errfile="${_PARALLEL_DIR}/${name}.err" + if [[ -f "$errfile" && -s "$errfile" ]]; then + cat "$errfile" + fi +} + +parallel_cleanup() { + if [[ -n "${_PARALLEL_DIR:-}" && -d "${_PARALLEL_DIR:-}" ]]; then + rm -rf "$_PARALLEL_DIR" + fi + _PARALLEL_DIR="" + _PARALLEL_PID_LIST="" +} + +# Run a batch of commands with limited concurrency +parallel_batch() { + local concurrency="$1" + local func="$2" + shift 2 + local args=("$@") + local running=0 + + for arg in "${args[@]}"; do + parallel_run "$arg" "$func" "$arg" + running=$((running + 1)) + if (( running >= concurrency )); then + wait -n 2>/dev/null || true + running=$((running - 1)) + fi + done + parallel_wait_all +} + +# Stream results as JSON Lines — emit each completed job as a {_section, data} line +# Usage: parallel_stream_wait +# Polls every 200ms, emits results as jobs complete +parallel_stream_wait() { + local prefix="${1:-data}" + local emitted="" + local all_done=false + local start_ms=$(($(date +%s) * 1000)) + + while [[ "$all_done" != "true" ]]; do + all_done=true + for entry in $_PARALLEL_PID_LIST; do + local name="${entry%%:*}" + local pid="${entry##*:}" + + # Skip already emitted + [[ "$emitted" == *" ${name} "* ]] && continue + + # Check if done + if ! kill -0 "$pid" 2>/dev/null; then + # Job finished — emit result + local outfile="${_PARALLEL_DIR}/${name}.json" + if [[ -f "$outfile" && -s "$outfile" ]]; then + local elapsed_ms=$(( $(date +%s) * 1000 - start_ms )) + python3 -c " +import json, sys +with open(sys.argv[1]) as f: + data = json.load(f) +print(json.dumps({'_section': sys.argv[2], '_job': sys.argv[3], '_elapsed_ms': int(sys.argv[4]), 'data': data})) +" "$outfile" "$prefix" "$name" "$elapsed_ms" + fi + emitted="${emitted} ${name} " + else + all_done=false + fi + done + + [[ "$all_done" != "true" ]] && sleep 0.2 + done + + # Emit completion marker + local total_ms=$(( $(date +%s) * 1000 - start_ms )) + echo "{\"_section\":\"complete\",\"elapsed_ms\":${total_ms}}" + _PARALLEL_PID_LIST="" +} diff --git a/plugins/node-team/scripts/lib/util/retry.sh b/plugins/node-team/scripts/lib/util/retry.sh new file mode 100644 index 000000000..4e2d6b9d0 --- /dev/null +++ b/plugins/node-team/scripts/lib/util/retry.sh @@ -0,0 +1,99 @@ +#!/bin/bash +# Retry and error handling for HTTP requests +# Sourced by core.sh — provides _curl_with_retry wrapping _curl + +[[ -n "${_RETRY_LOADED:-}" ]] && return 0 +_RETRY_LOADED=1 + +_RETRY_MAX="${JIRA_RETRY_MAX:-3}" +_RETRY_TIMEOUT="${JIRA_TIMEOUT:-30}" + +_curl_with_retry() { + local attempt=0 + local delay=1 + local tmpfile hdrfile + tmpfile=$(mktemp) + hdrfile=$(mktemp) + trap "rm -f '$tmpfile' '$hdrfile'" RETURN + + while (( attempt < _RETRY_MAX )); do + attempt=$((attempt + 1)) + local http_code + + # Execute curl, capture body to tmpfile and HTTP code to variable + http_code=$(curl -s $AUTH -H "Content-Type: application/json" \ + --max-time "$_RETRY_TIMEOUT" \ + -w "%{http_code}" \ + -D "$hdrfile" \ + -o "$tmpfile" \ + "$@" 2>/dev/null) || { + # curl itself failed (timeout, DNS, connection error) + if (( attempt < _RETRY_MAX )); then + _log "WARN" "curl failed (attempt ${attempt}/${_RETRY_MAX}), retrying in ${delay}s..." + sleep "$delay" + delay=$((delay * 2)) + continue + fi + echo '{"error":"Request failed after retries","cause":"connection"}' >&2 + return 1 + } + + case "$http_code" in + 2[0-9][0-9]) + # Success — output the body + cat "$tmpfile" + return 0 + ;; + 429) + # Rate limited — respect Retry-After if present + local retry_after + retry_after=$(grep -i "^retry-after:" "$hdrfile" 2>/dev/null | awk '{print $2}' || echo "$delay") + retry_after=${retry_after:-$delay} + if (( attempt < _RETRY_MAX )); then + _log "WARN" "Rate limited (429), retrying in ${retry_after}s (attempt ${attempt}/${_RETRY_MAX})..." + sleep "$retry_after" + delay=$((delay * 2)) + continue + fi + _log "ERROR" "Rate limited after ${_RETRY_MAX} retries" + echo "{\"error\":\"Rate limited\",\"httpCode\":429}" >&2 + return 1 + ;; + 5[0-9][0-9]) + # Server error — retry with backoff + if (( attempt < _RETRY_MAX )); then + _log "WARN" "Server error (${http_code}), retrying in ${delay}s (attempt ${attempt}/${_RETRY_MAX})..." + sleep "$delay" + delay=$((delay * 2)) + continue + fi + _log "ERROR" "Server error ${http_code} after ${_RETRY_MAX} retries" + echo "{\"error\":\"Server error\",\"httpCode\":${http_code}}" >&2 + return 1 + ;; + 4[0-9][0-9]) + # Client error — do not retry (400, 401, 403, 404, etc.) + _log "ERROR" "Client error: HTTP ${http_code}" + cat "$tmpfile" >&2 + return 1 + ;; + *) + _log "ERROR" "Unexpected HTTP status: ${http_code}" + cat "$tmpfile" >&2 + return 1 + ;; + esac + done +} + +# Graceful fallback: returns partial result with error marker instead of failing +_graceful_fallback() { + local section="$1" + shift + local result + if result=$("$@" 2>/dev/null); then + echo "$result" + else + echo "{\"_section\":\"${section}\",\"error\":\"Failed to fetch ${section}\"}" + fi +} diff --git a/plugins/node-team/scripts/ocp-install.sh b/plugins/node-team/scripts/ocp-install.sh new file mode 100755 index 000000000..84f084997 --- /dev/null +++ b/plugins/node-team/scripts/ocp-install.sh @@ -0,0 +1,810 @@ +#!/usr/bin/env bash +# +# ocp-install.sh — OpenShift cluster lifecycle manager +# +# Usage: +# ./ocp-install.sh download +# ./ocp-install.sh create [cluster-name] +# ./ocp-install.sh destroy +# ./ocp-install.sh debug +# ./ocp-install.sh list [version] +# ./ocp-install.sh kubeconfig +# +# Types: regular, sno, gpu, sno-cpu +# Platform: GCP (openshift-gce-devel) +# +# Secrets: +# Pull secret read from OS secret store (OCP_PULL_SECRET). +# SSH key read from ~/.ssh/id_rsa.pub. +# +# One-time setup (macOS): +# security add-generic-password -a "$USER" -s "OCP_PULL_SECRET" \ +# -w "$(cat ~/clusters/pull-secret-gcp.txt | python3 -c \ +# "import sys,json; print(json.dumps(json.load(sys.stdin), separators=(',',':')))")" +# +# One-time setup (Linux): +# cat ~/clusters/pull-secret-gcp.txt | python3 -c \ +# "import sys,json; print(json.dumps(json.load(sys.stdin), separators=(',',':')))" | \ +# secret-tool store --label="OCP Pull Secret" service ocp-install username "$USER" key OCP_PULL_SECRET +# +set -euo pipefail + +CLUSTERS_DIR="${CLUSTERS_DIR:-$HOME/clusters}" +ARTIFACTS_BASE="https://openshift-release-artifacts.apps.ci.l2s4.p1.openshiftapps.com" +SSH_KEY_FILE="${SSH_KEY_FILE:-$HOME/.ssh/id_rsa.pub}" +GCP_PROJECT="openshift-gce-devel" +GCP_REGION="us-central1" +GCP_GPU_ZONE="us-central1-f" +BASE_DOMAIN="gcp.devcluster.openshift.com" +NAME_PREFIX="${OCP_NAME_PREFIX:-$USER}" + +# ─── helpers ──────────────────────────────────────────────────────────────── + +die() { echo "ERROR: $*" >&2; exit 1; } +info() { echo "==> $*"; } + +major_minor() { + # 4.21.3 → 4.21, 4.21.0-ec.1 → 4.21 + echo "$1" | sed -E 's/^([0-9]+\.[0-9]+).*/\1/' +} + +version_dir() { + echo "${CLUSTERS_DIR}/$(major_minor "$1")/${1}" +} + +installer_bin() { + echo "$(version_dir "$1")/openshift-install" +} + +next_cluster_dir() { + local vdir + vdir="$(version_dir "$1")" + local n=1 + while [[ -d "${vdir}/cluster${n}" ]]; do + ((n++)) + done + echo "cluster${n}" +} + +random_suffix() { + LC_ALL=C tr -dc 'a-z0-9' &1 \ + | grep '^password: "' | sed 's/^password: "//;s/"$//')" || true + ;; + Linux) + # Linux: read from GNOME Keyring / libsecret via secret-tool + secret="$(secret-tool lookup service ocp-install key OCP_PULL_SECRET 2>/dev/null)" || true + ;; + esac + fi + if [[ -z "$secret" ]]; then + # Fallback: try file + local fallback="${CLUSTERS_DIR}/pull-secret-gcp.txt" + if [[ -f "$fallback" ]]; then + secret="$(python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin), separators=(',',':')))" < "$fallback")" + else + if [[ "$(uname -s)" == "Darwin" ]]; then + die "Pull secret not found. Store it in Keychain:\n security add-generic-password -a \"\$USER\" -s \"OCP_PULL_SECRET\" -w '\$(cat pull-secret.json)'" + else + die "Pull secret not found. Store it with secret-tool:\n cat pull-secret.json | secret-tool store --label=\"OCP Pull Secret\" service ocp-install username \"\$USER\" key OCP_PULL_SECRET" + fi + fi + fi + echo "$secret" +} + +require_ssh_key() { + [[ -f "$SSH_KEY_FILE" ]] || die "SSH key not found at ${SSH_KEY_FILE}" +} + +require_installer() { + local bin + bin="$(installer_bin "$1")" + [[ -x "$bin" ]] || die "openshift-install not found for ${1}. Run: $0 download ${1}" +} + +# ─── download ─────────────────────────────────────────────────────────────── + +cmd_download() { + local version="${1:?Usage: $0 download }" + local vdir + vdir="$(version_dir "$version")" + local bin="${vdir}/openshift-install" + + if [[ -x "$bin" ]]; then + info "openshift-install already exists at ${bin}" + "${bin}" version + return 0 + fi + + mkdir -p "$vdir" + + # Detect platform + local os arch tarball + os="$(uname -s | tr '[:upper:]' '[:lower:]')" + arch="$(uname -m)" + + case "${os}-${arch}" in + darwin-arm64) tarball="openshift-install-mac-arm64-${version}.tar.gz" ;; + darwin-x86_64) tarball="openshift-install-mac-${version}.tar.gz" ;; + linux-x86_64) tarball="openshift-install-linux-${version}.tar.gz" ;; + linux-aarch64) tarball="openshift-install-linux-arm64-${version}.tar.gz" ;; + *) die "Unsupported platform: ${os}-${arch}" ;; + esac + + local url="${ARTIFACTS_BASE}/${version}/${tarball}" + + info "Downloading ${tarball} ..." + if ! curl -fSL -o "${vdir}/${tarball}" "$url"; then + # Fallback: try oc adm release extract + info "Direct download failed. Trying oc adm release extract ..." + if command -v oc &>/dev/null; then + local release_arch + case "$arch" in + arm64|aarch64) release_arch="aarch64" ;; + *) release_arch="x86_64" ;; + esac + oc adm release extract --tools \ + --to="$vdir" \ + "quay.io/openshift-release-dev/ocp-release:${version}-${release_arch}" || \ + die "Failed to download openshift-install for ${version}" + else + die "Download failed and 'oc' not found for fallback extraction" + fi + fi + + if [[ -f "${vdir}/${tarball}" ]]; then + info "Extracting ..." + tar xzf "${vdir}/${tarball}" -C "$vdir" openshift-install 2>/dev/null || \ + tar xzf "${vdir}/${tarball}" -C "$vdir" + fi + chmod +x "$bin" + + info "Done. openshift-install ${version}:" + "${bin}" version +} + +# ─── install-config generation ────────────────────────────────────────────── + +generate_config() { + local type="$1" cluster_name="$2" + local pull_secret ssh_key + + pull_secret="$(get_pull_secret)" + ssh_key="$(cat "$SSH_KEY_FILE")" + + case "$type" in + regular) + cat < "${install_dir}/install-config.yaml" + + # Back up (consumed during install) + cp "${install_dir}/install-config.yaml" "${install_dir}/install-config.yaml.backup" + + info "Generated install-config.yaml for type '${type}'" + info "" + + # Confirm before proceeding + echo "--- install-config.yaml (summary) ---" + grep -E '^\s*(name|replicas|type|region|cpuPartitioning):' "${install_dir}/install-config.yaml.backup" || true + echo "--------------------------------------" + echo "" + read -rp "Proceed with cluster creation? [y/N] " confirm + [[ "$confirm" =~ ^[Yy]$ ]] || { info "Aborted."; exit 0; } + + info "Creating cluster (this will take 30-45 minutes) ..." + local bin + bin="$(installer_bin "$version")" + "${bin}" create cluster --dir="$install_dir" --log-level=info + + info "" + info "Cluster created successfully!" + info "" + info "Kubeconfig: export KUBECONFIG=${install_dir}/auth/kubeconfig" + info "Console: $(grep -o 'https://console-openshift.*' "${install_dir}/.openshift_install.log" 2>/dev/null | tail -1 || echo 'check install log')" + info "" + info "To destroy: $0 destroy ${version} ${cluster_dir}" +} + +# ─── destroy ──────────────────────────────────────────────────────────────── + +cmd_destroy() { + local version="${1:?Usage: $0 destroy }" + local cluster_dir="${2:?Usage: $0 destroy }" + + require_installer "$version" + + local vdir install_dir bin + vdir="$(version_dir "$version")" + install_dir="${vdir}/${cluster_dir}" + bin="$(installer_bin "$version")" + + [[ -d "$install_dir" ]] || die "Cluster directory not found: ${install_dir}" + + info "Destroying cluster at ${install_dir} ..." + read -rp "Are you sure? This cannot be undone. [y/N] " confirm + [[ "$confirm" =~ ^[Yy]$ ]] || { info "Aborted."; exit 0; } + + "${bin}" destroy cluster --dir="$install_dir" --log-level=info + + info "Cluster destroyed." +} + +# ─── debug ────────────────────────────────────────────────────────────────── + +cmd_debug() { + local version="${1:?Usage: $0 debug }" + local cluster_dir="${2:?Usage: $0 debug }" + + local vdir install_dir + vdir="$(version_dir "$version")" + install_dir="${vdir}/${cluster_dir}" + + [[ -d "$install_dir" ]] || die "Cluster directory not found: ${install_dir}" + + # Resolve cluster name from backup config, metadata, or install log + local cluster_name="" infra_id="" + if [[ -f "${install_dir}/metadata.json" ]]; then + cluster_name="$(python3 -c "import json; print(json.load(open('${install_dir}/metadata.json'))['clusterName'])" 2>/dev/null || true)" + infra_id="$(python3 -c "import json; print(json.load(open('${install_dir}/metadata.json'))['infraID'])" 2>/dev/null || true)" + fi + if [[ -z "$cluster_name" ]] && [[ -f "${install_dir}/install-config.yaml.backup" ]]; then + cluster_name="$(grep '^\s*name:' "${install_dir}/install-config.yaml.backup" | head -1 | awk '{print $2}')" + fi + # Fallback: extract cluster name from install log (api..gcp.devcluster...) + if [[ -z "$cluster_name" ]] && [[ -f "${install_dir}/.openshift_install.log" ]]; then + cluster_name="$(grep -o 'api\.[^.]*\.gcp' "${install_dir}/.openshift_install.log" 2>/dev/null \ + | head -1 | sed 's/^api\.//;s/\.gcp$//' || true)" + fi + # Fallback: try to find infra_id from log (lines like "Deleted network -network") + if [[ -z "$infra_id" ]] && [[ -f "${install_dir}/.openshift_install.log" ]]; then + infra_id="$(grep -o 'Deleted network [^ ]*-network' "${install_dir}/.openshift_install.log" 2>/dev/null \ + | head -1 | sed 's/^Deleted network //;s/-network$//' || true)" + fi + + echo "" + echo "==========================================" + echo " Cluster Debug: ${cluster_dir}" + echo "==========================================" + echo " Install dir: ${install_dir}" + echo " Cluster name: ${cluster_name:-unknown}" + echo " Infra ID: ${infra_id:-unknown}" + echo " Version: ${version}" + echo "==========================================" + + # ── 1. Local log analysis ── + echo "" + info "LOCAL LOGS" + echo "" + + local install_log="${install_dir}/.openshift_install.log" + if [[ -f "$install_log" ]]; then + local log_size + log_size="$(wc -c < "$install_log" | tr -d ' ')" + echo " Install log: ${install_log} ($(( log_size / 1024 )) KB)" + echo "" + + # Check if install completed successfully + if grep -q 'Install complete' "$install_log" 2>/dev/null; then + echo " Status: Install completed successfully" + grep 'Install complete' "$install_log" + echo "" + elif grep -q 'Uninstallation complete' "$install_log" 2>/dev/null; then + echo " Status: Cluster was destroyed" + echo "" + fi + + # Show level=error lines (deduplicated) + local error_count + error_count="$(grep -c 'level=error' "$install_log" 2>/dev/null || echo 0)" + if [[ "$error_count" -gt 0 ]]; then + echo " --- Errors (${error_count} total, showing unique) ---" + grep 'level=error' "$install_log" | sed 's/time="[^"]*" //' | sort -u + echo "" + fi + + # Show level=fatal lines + if grep -q 'level=fatal' "$install_log" 2>/dev/null; then + echo " --- Fatal ---" + grep 'level=fatal' "$install_log" + echo "" + fi + + # Common failure patterns + echo " --- Failure Pattern Analysis ---" + if grep -q 'Bootstrap failed to complete' "$install_log" 2>/dev/null; then + echo " BOOTSTRAP FAILURE: Bootstrap host failed to create temporary control plane" + echo " Likely causes: SSH key mismatch, instance didn't boot, ignition failure" + echo " Next step: check serial console output with: $0 debug ${version} ${cluster_dir} --gcp" + fi + if grep -q 'context deadline exceeded' "$install_log" 2>/dev/null; then + echo " TIMEOUT: Cluster API connection timed out" + fi + if grep -q 'quota' "$install_log" 2>/dev/null; then + echo " QUOTA: Possible GCP quota exceeded" + grep -i 'quota' "$install_log" | head -3 + fi + if grep -q 'resourceInUseByAnotherResource' "$install_log" 2>/dev/null; then + echo " RESOURCE CONFLICT: GCP resources still in use (stale resources from prior install)" + fi + if grep -q 'unable to authenticate' "$install_log" 2>/dev/null; then + echo " SSH AUTH FAILURE: Could not SSH to bootstrap node (wrong key or agent not running)" + fi + echo "" + + # Last 10 non-debug lines for context + echo " --- Last 10 significant log lines ---" + grep -v 'level=debug' "$install_log" | tail -10 + echo "" + else + echo " No install log found at ${install_log}" + echo "" + fi + + # Log bundles + local -a bundles=("${install_dir}"/log-bundle-*.tar.gz) + if [[ -e "${bundles[0]}" ]]; then + echo " --- Log Bundles ---" + for b in "${bundles[@]}"; do + echo " $(basename "$b") ($(du -h "$b" | cut -f1))" + done + echo "" + echo " To extract and inspect a bundle:" + echo " mkdir /tmp/logbundle && tar xzf -C /tmp/logbundle" + echo " # Then check: bootstrap/journals/*, control-plane/*/journals/*" + echo "" + fi + + # ── 2. openshift-install gather bootstrap ── + local bin + bin="$(installer_bin "$version" 2>/dev/null || true)" + if [[ -x "$bin" ]] && [[ -f "${install_dir}/metadata.json" ]]; then + echo " --- Gather Bootstrap Logs ---" + echo " Run this to collect bootstrap logs from the running cluster:" + echo " ${bin} gather bootstrap --dir=${install_dir}" + echo "" + fi + + # ── 3. GCP diagnostics ── + if ! command -v gcloud &>/dev/null; then + echo " [gcloud not found — skipping GCP diagnostics]" + echo " Install: https://cloud.google.com/sdk/docs/install" + return 0 + fi + + # Determine the filter prefix (infra_id or cluster_name) + local filter_name="${infra_id:-$cluster_name}" + if [[ -z "$filter_name" ]]; then + echo " [Cannot determine cluster name/infraID — skipping GCP diagnostics]" + return 0 + fi + + echo "" + info "GCP DIAGNOSTICS (project: ${GCP_PROJECT})" + echo "" + + # 3a. List instances (single API call, filter locally for bootstrap/masters) + local all_instances + all_instances="$(gcloud compute instances list \ + --project="$GCP_PROJECT" \ + --filter="name~${filter_name}" \ + --format="value(name,zone,status,machineType.basename())" 2>/dev/null || true)" + + echo " --- Compute Instances ---" + if [[ -n "$all_instances" ]]; then + printf " %-40s %-25s %-10s %s\n" "NAME" "ZONE" "STATUS" "TYPE" + while IFS=$'\t' read -r iname izone istatus itype; do + printf " %-40s %-25s %-10s %s\n" "$iname" "$izone" "$istatus" "$itype" + done <<< "$all_instances" + else + echo " (no instances found or gcloud error)" + fi + echo "" + + # 3b. Serial port output for bootstrap (last 50 lines) + local bootstrap_instance + bootstrap_instance="$(echo "$all_instances" | grep 'bootstrap' | head -1)" + + if [[ -n "$bootstrap_instance" ]]; then + local bname bzone + bname="$(echo "$bootstrap_instance" | cut -f1)" + bzone="$(echo "$bootstrap_instance" | cut -f2)" + echo " --- Bootstrap Serial Console (last 50 lines) ---" + echo " Instance: ${bname} (${bzone})" + gcloud compute instances get-serial-port-output "$bname" \ + --project="$GCP_PROJECT" \ + --zone="$bzone" 2>/dev/null | tail -50 || echo " (could not retrieve serial output)" + echo "" + fi + + # Serial output for master nodes + local master_instances + master_instances="$(echo "$all_instances" | grep 'master')" + + if [[ -n "$master_instances" ]]; then + echo " --- Master Node Serial Console (last 20 lines each) ---" + while IFS=$'\t' read -r mname mzone; do + echo " Instance: ${mname} (${mzone})" + gcloud compute instances get-serial-port-output "$mname" \ + --project="$GCP_PROJECT" \ + --zone="$mzone" 2>/dev/null | tail -20 || echo " (could not retrieve serial output)" + echo "" + done <<< "$master_instances" + fi + + # 3c. GCP Cloud Logging (last 30 minutes of errors) + echo " --- GCP Cloud Logging (recent errors) ---" + gcloud logging read \ + "resource.type=gce_instance AND textPayload:\"${filter_name}\" AND severity>=ERROR" \ + --project="$GCP_PROJECT" \ + --limit=20 \ + --format="table(timestamp,textPayload)" \ + --freshness=30m 2>/dev/null || echo " (no log entries found or gcloud error)" + echo "" + + # 3d. Firewall rules + echo " --- Firewall Rules ---" + gcloud compute firewall-rules list \ + --project="$GCP_PROJECT" \ + --filter="name~${filter_name}" \ + --format="table(name,direction,allowed,targetTags)" 2>/dev/null || echo " (none found)" + echo "" + + # 3e. Disks (checking for orphaned disks) + echo " --- Persistent Disks ---" + gcloud compute disks list \ + --project="$GCP_PROJECT" \ + --filter="name~${filter_name}" \ + --format="table(name,zone.basename(),sizeGb,status,users.basename())" 2>/dev/null || echo " (none found)" + echo "" + + echo "==========================================" + echo " Debug complete" + echo "==========================================" +} + +# ─── list ─────────────────────────────────────────────────────────────────── + +cmd_list() { + local filter_version="${1:-}" + + echo "" + printf "%-12s %-16s %-10s %-30s\n" "VERSION" "CLUSTER" "STATUS" "PATH" + printf "%-12s %-16s %-10s %-30s\n" "-------" "-------" "------" "----" + + local minor_dirs + if [[ -n "$filter_version" ]]; then + minor_dirs="${CLUSTERS_DIR}/$(major_minor "$filter_version")" + else + minor_dirs="${CLUSTERS_DIR}/4.*" + fi + + for minor_dir in $minor_dirs; do + [[ -d "$minor_dir" ]] || continue + for ver_dir in "$minor_dir"/*/; do + [[ -d "$ver_dir" ]] || continue + ver_dir="${ver_dir%/}" + local version + version="$(basename "$ver_dir")" + # Skip if doesn't look like a version + [[ "$version" =~ ^[0-9]+\.[0-9]+ ]] || continue + + for cluster in "$ver_dir"/cluster*/; do + [[ -d "$cluster" ]] || continue + local cname status + cname="$(basename "$cluster")" + + if [[ -f "${cluster}/.openshift_install.log" ]] && grep -q 'Uninstallation complete' "${cluster}/.openshift_install.log" 2>/dev/null; then + status="DESTROYED" + elif [[ -f "${cluster}/auth/kubeconfig" ]]; then + status="ACTIVE" + elif [[ -f "${cluster}/metadata.json" ]]; then + status="ACTIVE" + elif [[ -f "${cluster}/install-config.yaml" ]]; then + status="CONFIG" + elif [[ -f "${cluster}/.openshift_install.log" ]] || compgen -G "${cluster}/log-bundle-*.tar.gz" &>/dev/null; then + status="DESTROYED" + elif [[ -f "${cluster}/install-config.yaml.backup" ]]; then + status="DESTROYED" + else + status="EMPTY" + fi + + printf "%-12s %-16s %-10s %-30s\n" "$version" "$cname" "$status" "$cluster" + done + done + done + echo "" +} + +# ─── kubeconfig ───────────────────────────────────────────────────────────── + +cmd_kubeconfig() { + local version="${1:?Usage: $0 kubeconfig }" + local cluster_dir="${2:?Usage: $0 kubeconfig }" + + local vdir install_dir kubeconfig + vdir="$(version_dir "$version")" + install_dir="${vdir}/${cluster_dir}" + kubeconfig="${install_dir}/auth/kubeconfig" + + [[ -f "$kubeconfig" ]] || die "Kubeconfig not found: ${kubeconfig}" + + echo "export KUBECONFIG=${kubeconfig}" +} + +# ─── main ─────────────────────────────────────────────────────────────────── + +usage() { + cat < [args] + +Commands: + download Download openshift-install for a version + create [cluster-name] Create a cluster + destroy Destroy a cluster + debug Diagnose a failed installation + list [version] List all clusters + kubeconfig Print KUBECONFIG export command + +Cluster types: + regular 3 control-plane + 3 workers (standard instances) + sno Single Node OpenShift with GPU (a2-highgpu-2g) + gpu 3 control-plane + 3 GPU workers (a2-highgpu-1g) + sno-cpu Single Node OpenShift, CPU only (cpuPartitioningMode) + +Environment variables: + CLUSTERS_DIR Base directory (default: ~/clusters) + SSH_KEY_FILE SSH public key (default: ~/.ssh/id_rsa.pub) + +Secrets: + Pull secret is read from the OS secret store (OCP_PULL_SECRET). + Falls back to \${CLUSTERS_DIR}/pull-secret-gcp.txt if not found. + + macOS (Keychain): + security add-generic-password -a "\$USER" -s "OCP_PULL_SECRET" \\ + -w "\$(cat pull-secret.json | python3 -c \\ + "import sys,json; print(json.dumps(json.load(sys.stdin), separators=(',',':')))")" + + Linux (secret-tool / libsecret): + cat pull-secret.json | python3 -c \\ + "import sys,json; print(json.dumps(json.load(sys.stdin), separators=(',',':')))" | \\ + secret-tool store --label="OCP Pull Secret" service ocp-install username "\$USER" key OCP_PULL_SECRET + +Examples: + $0 download 4.21.3 + $0 create 4.21.3 sno + $0 create 4.21.3 gpu mycluster01 + $0 list + $0 list 4.21 + $0 debug 4.21.3 cluster1 + $0 destroy 4.21.3 cluster1 + eval \$($0 kubeconfig 4.21.3 cluster1) + +Download source: ${ARTIFACTS_BASE} +EOF +} + +case "${1:-}" in + download) shift; cmd_download "$@" ;; + create) shift; cmd_create "$@" ;; + destroy) shift; cmd_destroy "$@" ;; + debug) shift; cmd_debug "$@" ;; + list) shift; cmd_list "$@" ;; + kubeconfig) shift; cmd_kubeconfig "$@" ;; + -h|--help|help|"") usage ;; + *) die "Unknown command: $1. Run '$0 --help' for usage." ;; +esac diff --git a/plugins/node-team/scripts/worktree.sh b/plugins/node-team/scripts/worktree.sh new file mode 100755 index 000000000..ea3d811b2 --- /dev/null +++ b/plugins/node-team/scripts/worktree.sh @@ -0,0 +1,351 @@ +#!/bin/bash +# worktree.sh — create/remove/list parallel workspaces with all submodules +# +# Usage: +# worktree.sh sync # fetch + checkout main in all submodules +# worktree.sh create [base-branch] # create a workspace (runs sync first) +# worktree.sh pull # sync main + merge into worktree branches +# worktree.sh merge # merge worktree branches into main + update root +# worktree.sh remove # tear it down +# worktree.sh list # show active workspaces +# +# After creating: +# claude --cwd .worktrees/ +# +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && git rev-parse --show-toplevel)" +WT_DIR="$ROOT/.worktrees" + +cmd_sync() { + echo "Syncing all submodules to latest remote main..." + + # Fetch + pull root repo + echo " root: fetching..." + git -C "$ROOT" fetch --quiet + + # Init submodules first (ensures .git dirs exist before we fetch) + git -C "$ROOT" submodule update --init --quiet + + # Each submodule: fetch, checkout its tracked branch, fast-forward if clean + git -C "$ROOT" submodule foreach --quiet ' + tracked=$(git config -f "$toplevel/.gitmodules" --get "submodule.$name.branch" 2>/dev/null || echo "main") + git fetch --quiet origin + git checkout "$tracked" --quiet 2>/dev/null || git checkout -b "$tracked" "origin/$tracked" --quiet + + # Only fast-forward — never rebase/merge to avoid conflict loops + if git merge-base --is-ancestor HEAD "origin/$tracked" 2>/dev/null; then + echo " $sm_path: fast-forwarding $tracked..." + git merge --ff-only --quiet "origin/$tracked" + elif git merge-base --is-ancestor "origin/$tracked" HEAD 2>/dev/null; then + echo " $sm_path: already ahead of origin/$tracked (local commits), skipping pull" + else + echo " $sm_path: WARNING — diverged from origin/$tracked, skipping pull (resolve manually)" + fi + ' + + echo "All submodules synced." + echo "" +} + +cmd_create() { + local name="${1:?usage: worktree.sh create [base-branch]}" + local base="${2:-HEAD}" + local ws="$WT_DIR/$name" + + if [ -d "$ws" ]; then + echo "error: workspace '$name' already exists at $ws" >&2 + exit 1 + fi + + # If root repo has no commits yet, create an initial one so worktrees have + # something to branch from (common when setting up the repo for the first time) + if ! git -C "$ROOT" rev-parse HEAD >/dev/null 2>&1; then + echo "No commits in root repo — creating initial commit..." + git -C "$ROOT" add -A + git -C "$ROOT" commit -m "Initial commit: register submodules" --quiet + fi + + # Sync all submodules to latest remote main before branching + cmd_sync + + echo "Creating workspace '$name' from $base..." + + # Root repo worktree + git -C "$ROOT" worktree add "$ws" -b "wt/$name" "$base" 2>/dev/null \ + || git -C "$ROOT" worktree add "$ws" "wt/$name" + + # Submodule worktrees — each sub-repo gets its own branch inside the workspace + _WT_NAME="$name" _WT_WS="$ws" git -C "$ROOT" submodule foreach --quiet ' + branch="wt/${_WT_NAME}" + git worktree add "${_WT_WS}/$sm_path" -b "$branch" HEAD 2>/dev/null \ + || git worktree add "${_WT_WS}/$sm_path" "$branch" + ' + + echo "" + echo "Workspace ready at: $ws" + echo "" + echo " claude --cwd $ws" + echo "" +} + +cmd_pull() { + local wsname="${1:?usage: worktree.sh pull }" + local ws="$WT_DIR/$wsname" + local branch="wt/$wsname" + + if [ ! -d "$ws" ]; then + echo "error: workspace '$wsname' not found at $ws" >&2 + exit 1 + fi + + # Sync main to latest remote first + cmd_sync + + echo "Merging main into workspace '$wsname'..." + echo "" + + # Merge main into each submodule's worktree branch + if ! _WT_NAME="$wsname" _WT_WS="$ws" git -C "$ROOT" submodule foreach --quiet ' + branch="wt/${_WT_NAME}" + tracked=$(git config -f "$toplevel/.gitmodules" --get "submodule.$name.branch" 2>/dev/null || echo "main") + + if [ ! -d "${_WT_WS}/$sm_path" ]; then + echo " $sm_path: not in workspace, skipping" + exit 0 + fi + + cur=$(git -C "${_WT_WS}/$sm_path" branch --show-current 2>/dev/null || echo "") + if [ "$cur" != "$branch" ]; then + echo " $sm_path: not on $branch (on $cur), skipping" + exit 0 + fi + + behind=$(git -C "${_WT_WS}/$sm_path" rev-list --count HEAD.."$tracked" 2>/dev/null || echo "0") + if [ "$behind" = "0" ]; then + echo " $sm_path: already up to date with $tracked" + exit 0 + fi + + echo " $sm_path: merging $tracked ($behind commit(s)) into $branch..." + if git -C "${_WT_WS}/$sm_path" merge "$tracked" --no-edit --quiet; then + echo " $sm_path: merged ✓" + else + echo " $sm_path: CONFLICT — resolve in ${_WT_WS}/$sm_path, then re-run pull" >&2 + exit 1 + fi + '; then + echo "" + echo "Pull stopped due to conflicts. Resolve them, then run:" + echo " worktree.sh pull $wsname" + exit 1 + fi + + # Update root worktree submodule pointers + git -C "$ws" add -A 2>/dev/null + if ! git -C "$ws" diff --cached --quiet 2>/dev/null; then + git -C "$ws" commit -m "Sync all submodules with main" + echo "" + echo "Root worktree pointers updated." + else + echo "" + echo "No submodule pointer changes." + fi + + echo "" + echo "Workspace '$wsname' is up to date with main." + echo "" +} + +cmd_merge() { + local name="${1:?usage: worktree.sh merge }" + local ws="$WT_DIR/$name" + local branch="wt/$name" + + if [ ! -d "$ws" ]; then + echo "error: workspace '$name' not found at $ws" >&2 + exit 1 + fi + + echo "Merging workspace '$name'..." + echo "" + + # --- Merge root repo's wt/ branch first --- + if git -C "$ROOT" rev-parse --verify "$branch" >/dev/null 2>&1; then + local root_main + root_main=$(git -C "$ROOT" rev-parse --abbrev-ref HEAD) + local root_ahead + root_ahead=$(git -C "$ROOT" rev-list --count "$root_main..$branch" 2>/dev/null || echo "0") + if [ "$root_ahead" != "0" ]; then + echo " root: merging $branch ($root_ahead commit(s)) into $root_main..." + if git -C "$ROOT" merge --ff-only "$branch" --quiet 2>/dev/null; then + echo " root: fast-forward merge ✓" + elif git -C "$ROOT" merge "$branch" --no-edit --quiet; then + echo " root: merge commit created ✓" + else + echo " root: CONFLICT — resolve manually, then re-run merge" >&2 + exit 1 + fi + else + echo " root: no changes on $branch, skipping" + fi + fi + + # --- Merge each submodule's wt/ branch into its tracked main branch --- + if ! _WT_NAME="$name" git -C "$ROOT" submodule foreach --quiet ' + branch="wt/${_WT_NAME}" + tracked=$(git config -f "$toplevel/.gitmodules" --get "submodule.$name.branch" 2>/dev/null || echo "main") + + # Check if the worktree branch exists locally + if ! git rev-parse --verify "$branch" >/dev/null 2>&1; then + echo " $sm_path: no branch $branch, skipping" + exit 0 + fi + + # Fetch remote and update local wt branch if remote has commits we lack + # (worktree agents may have pushed directly to origin/wt/) + git fetch --quiet origin 2>/dev/null || true + if git rev-parse --verify "origin/$branch" >/dev/null 2>&1; then + if ! git merge-base --is-ancestor "origin/$branch" "$branch" 2>/dev/null; then + echo " $sm_path: syncing $branch with remote..." + if git merge-base --is-ancestor "$branch" "origin/$branch" 2>/dev/null; then + git update-ref "refs/heads/$branch" "$(git rev-parse "origin/$branch")" + else + cur=$(git branch --show-current 2>/dev/null || echo "") + git checkout "$branch" --quiet + git merge --no-edit --quiet "origin/$branch" + [ -n "$cur" ] && git checkout "$cur" --quiet 2>/dev/null || git checkout "$tracked" --quiet + fi + fi + fi + + ahead=$(git rev-list --count "$tracked..$branch" 2>/dev/null || echo "0") + if [ "$ahead" = "0" ]; then + echo " $sm_path: no changes on $branch, skipping" + exit 0 + fi + + echo " $sm_path: merging $branch ($ahead commit(s)) into $tracked..." + git checkout "$tracked" --quiet + if git merge --ff-only "$branch" --quiet 2>/dev/null; then + echo " $sm_path: fast-forward merge ✓" + elif git merge "$branch" --no-edit --quiet; then + echo " $sm_path: merge commit created ✓" + else + echo " $sm_path: CONFLICT — resolve manually, then re-run merge" >&2 + exit 1 + fi + '; then + echo "" + echo "Merge stopped due to conflicts. Resolve them, then run:" + echo " worktree.sh merge $name" + exit 1 + fi + + # Reconcile: ensure each submodule's main includes the commit the root expects. + # This catches cases where the root merge fast-forwarded a submodule pointer + # but the submodule's wt/ branch was already deleted (from a prior remove), + # leaving the submodule's main behind. + echo "" + echo "Reconciling submodule branches with root pointers..." + git -C "$ROOT" submodule foreach --quiet ' + tracked=$(git config -f "$toplevel/.gitmodules" --get "submodule.$name.branch" 2>/dev/null || echo "main") + # What does the root repo expect this submodule to be at? + expected=$(git -C "$toplevel" ls-tree HEAD -- "$sm_path" | awk "{print \$3}") + current=$(git rev-parse HEAD 2>/dev/null || echo "") + if [ -n "$expected" ] && [ "$expected" != "$current" ]; then + if git merge-base --is-ancestor "$current" "$expected" 2>/dev/null; then + echo " $sm_path: fast-forwarding $tracked to match root pointer..." + git checkout "$tracked" --quiet 2>/dev/null || true + git merge --ff-only "$expected" --quiet 2>/dev/null || true + fi + fi + ' + + # Update root repo submodule pointers for any submodules that changed + echo "" + echo "Updating root repo submodule pointers..." + # Stage any submodule pointer changes + git -C "$ROOT" add -A 2>/dev/null + if ! git -C "$ROOT" diff --cached --quiet 2>/dev/null; then + git -C "$ROOT" commit -m "Merge workspace '$name' submodule updates" + echo "Root repo updated." + else + echo "No submodule pointer changes to commit." + fi + + echo "" + echo "Merge complete. You can now remove the workspace:" + echo " worktree.sh remove $name" + echo "" +} + +cmd_remove() { + local name="${1:?usage: worktree.sh remove }" + local ws="$WT_DIR/$name" + + if [ ! -d "$ws" ]; then + echo "error: workspace '$name' not found at $ws" >&2 + exit 1 + fi + + echo "Removing workspace '$name'..." + + # Remove submodule worktrees first + _WT_NAME="$name" _WT_WS="$ws" git -C "$ROOT" submodule foreach --quiet ' + if git worktree list --porcelain | grep -q "worktree ${_WT_WS}/$sm_path"; then + git worktree remove --force "${_WT_WS}/$sm_path" 2>/dev/null || true + fi + git branch -D "wt/${_WT_NAME}" 2>/dev/null || true + ' + + # Remove root worktree + git -C "$ROOT" worktree remove --force "$ws" 2>/dev/null || true + git -C "$ROOT" branch -D "wt/$name" 2>/dev/null || true + + # Clean up empty dir if anything remains + rm -rf "$ws" 2>/dev/null || true + + echo "Workspace '$name' removed." +} + +cmd_list() { + if [ ! -d "$WT_DIR" ]; then + echo "No workspaces. Create one with: worktree.sh create " + return + fi + + echo "Active workspaces:" + echo "" + for ws in "$WT_DIR"/*/; do + [ -d "$ws" ] || continue + local name="$(basename "$ws")" + echo " $name → $ws" + _WT_WS="$ws" git -C "$ROOT" submodule foreach --quiet ' + if [ -d "${_WT_WS}/$sm_path" ]; then + branch=$(git -C "${_WT_WS}/$sm_path" branch --show-current 2>/dev/null || echo "detached") + echo " $sm_path ($branch)" + fi + ' + echo "" + done +} + +case "${1:-help}" in + sync) cmd_sync ;; + create) shift; cmd_create "$@" ;; + pull) shift; cmd_pull "$@" ;; + merge) shift; cmd_merge "$@" ;; + remove) shift; cmd_remove "$@" ;; + list) cmd_list ;; + *) + echo "Usage: worktree.sh {sync|create|pull|merge|remove|list} [args...]" + echo "" + echo " sync — fetch + checkout main in all submodules" + echo " create [base] — sync + create parallel workspace" + echo " pull — sync main + merge into all worktree branches" + echo " merge — merge worktree branches into main + update root" + echo " remove — tear down workspace" + echo " list — show active workspaces" + ;; +esac diff --git a/plugins/node-team/scripts/worktree_test.sh b/plugins/node-team/scripts/worktree_test.sh new file mode 100755 index 000000000..08106e08a --- /dev/null +++ b/plugins/node-team/scripts/worktree_test.sh @@ -0,0 +1,270 @@ +#!/usr/bin/env bash +# +# worktree_test.sh -- Integration tests for worktree.sh +# +# Creates a temporary workspace with bare repos and submodules, +# then runs through the critical worktree scenarios. +# +# Usage: +# ./scripts/worktree_test.sh +# + +# Clear any inherited git env vars that would confuse operations +unset GIT_DIR GIT_WORK_TREE GIT_CEILING_DIRECTORIES 2>/dev/null || true + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WORKTREE_SH="${SCRIPT_DIR}/worktree.sh" +TEST_DIR="" +WORKSPACE="" +PASS=0 +FAIL=0 + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +setup() { + TEST_DIR="$(mktemp -d)" + echo "Test directory: ${TEST_DIR}" + echo "" + + export GIT_CONFIG_COUNT=1 + export GIT_CONFIG_KEY_0=protocol.file.allow + export GIT_CONFIG_VALUE_0=always + + for repo in repo-a repo-b; do + git init --bare "${TEST_DIR}/remotes/${repo}.git" --quiet + local seed="${TEST_DIR}/seed-${repo}" + mkdir -p "${seed}" + git -C "${seed}" init --quiet + echo "# ${repo}" > "${seed}/README.md" + git -C "${seed}" add README.md + git -C "${seed}" commit -m "initial commit" --quiet + git -C "${seed}" remote add origin "${TEST_DIR}/remotes/${repo}.git" + git -C "${seed}" push --quiet origin main 2>/dev/null + done + + local ws="${TEST_DIR}/workspace" + mkdir -p "${ws}" + git -C "${ws}" init --quiet + git -C "${ws}" submodule add "${TEST_DIR}/remotes/repo-a.git" repo-a 2>/dev/null + git -C "${ws}" submodule add "${TEST_DIR}/remotes/repo-b.git" repo-b 2>/dev/null + git -C "${ws}" commit -m "add submodules" --quiet + + cp "${WORKTREE_SH}" "${ws}/worktree.sh" + chmod +x "${ws}/worktree.sh" + WORKSPACE="${ws}" +} + +teardown() { + if [[ -n "${TEST_DIR:-}" && -d "${TEST_DIR:-}" ]]; then + rm -rf "${TEST_DIR}" + fi +} + +# Run git command against a repo path +repo_git() { + local path="$1" + shift + git -C "${path}" "$@" +} + +# Commit a file in a worktree submodule +wt_commit() { + local wt_path="$1" + local filename="$2" + local content="$3" + local message="$4" + echo "${content}" > "${wt_path}/${filename}" + repo_git "${wt_path}" add "${filename}" 2>&1 + repo_git "${wt_path}" commit -m "${message}" --quiet 2>&1 +} + +assert_file_exists() { + [[ -f "$1" ]] || { echo " ASSERT FAILED: file $1 missing"; return 1; } +} + +assert_commit_on_main() { + local repo_path="$1" + local pattern="$2" + local log + log="$(repo_git "${repo_path}" log --oneline main 2>/dev/null)" + echo "${log}" | grep -q "${pattern}" || { + echo " ASSERT FAILED: '${pattern}' not on main in ${repo_path}" + return 1 + } +} + +run_test() { + local test_name="$1" + local test_func="$2" + echo "--- ${test_name} ---" + # Each test gets a fresh workspace to avoid inter-test interference + teardown + setup + if ${test_func}; then + echo " PASS" + PASS=$((PASS + 1)) + else + echo " FAIL" + FAIL=$((FAIL + 1)) + fi + echo "" +} + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +test_basic_create_merge() { + local w="${WORKSPACE}" + + "${w}/worktree.sh" create basic >/dev/null 2>&1 + + [[ -d "${w}/.worktrees/basic/repo-a" ]] || { echo " repo-a worktree missing"; return 1; } + [[ -d "${w}/.worktrees/basic/repo-b" ]] || { echo " repo-b worktree missing"; return 1; } + + wt_commit "${w}/.worktrees/basic/repo-a" "feature.txt" "feature A" "add feature A" || return 1 + wt_commit "${w}/.worktrees/basic/repo-b" "feature.txt" "feature B" "add feature B" || return 1 + + "${w}/worktree.sh" merge basic >/dev/null 2>&1 + + assert_commit_on_main "${w}/repo-a" "add feature A" || return 1 + assert_commit_on_main "${w}/repo-b" "add feature B" || return 1 + assert_file_exists "${w}/repo-a/feature.txt" || return 1 + assert_file_exists "${w}/repo-b/feature.txt" || return 1 + + return 0 +} + +test_remote_sync() { + local w="${WORKSPACE}" + + "${w}/worktree.sh" create remote-test >/dev/null 2>&1 + + # Local commit + push + wt_commit "${w}/.worktrees/remote-test/repo-a" "local.txt" "local work" "local commit" || return 1 + repo_git "${w}/.worktrees/remote-test/repo-a" push origin wt/remote-test --quiet 2>/dev/null + + # Agent pushes extra commits via separate clone + local agent="${TEST_DIR}/agent-clone" + git clone "${TEST_DIR}/remotes/repo-a.git" "${agent}" --quiet 2>/dev/null + git -C "${agent}" checkout wt/remote-test --quiet 2>/dev/null + echo "agent 1" > "${agent}/agent1.txt" + git -C "${agent}" add agent1.txt + git -C "${agent}" commit -m "agent commit 1" --quiet + echo "agent 2" > "${agent}/agent2.txt" + git -C "${agent}" add agent2.txt + git -C "${agent}" commit -m "agent commit 2" --quiet + git -C "${agent}" push origin wt/remote-test --quiet 2>/dev/null + rm -rf "${agent}" + + # Merge -- must pick up agent commits + "${w}/worktree.sh" merge remote-test >/dev/null 2>&1 + + assert_commit_on_main "${w}/repo-a" "local commit" || return 1 + assert_commit_on_main "${w}/repo-a" "agent commit 1" || return 1 + assert_commit_on_main "${w}/repo-a" "agent commit 2" || return 1 + assert_file_exists "${w}/repo-a/local.txt" || return 1 + assert_file_exists "${w}/repo-a/agent1.txt" || return 1 + assert_file_exists "${w}/repo-a/agent2.txt" || return 1 + + return 0 +} + +test_root_repo_merge() { + local w="${WORKSPACE}" + + "${w}/worktree.sh" create root-test >/dev/null 2>&1 + + # Verify root worktree branch + local root_branch + root_branch=$(repo_git "${w}/.worktrees/root-test" branch --show-current 2>/dev/null) + [[ "${root_branch}" == "wt/root-test" ]] || { echo " expected wt/root-test, got ${root_branch}"; return 1; } + + # Commit in root repo worktree + wt_commit "${w}/.worktrees/root-test" "DOCS.md" "# Root docs" "add root docs" || return 1 + + # Commit in submodule worktree + wt_commit "${w}/.worktrees/root-test/repo-b" "sub.txt" "sub work" "submodule work" || return 1 + + # DOCS.md should NOT exist on main yet + [[ ! -f "${w}/DOCS.md" ]] || { echo " DOCS.md should not exist on main yet"; return 1; } + + "${w}/worktree.sh" merge root-test >/dev/null 2>&1 + + assert_file_exists "${w}/DOCS.md" || return 1 + assert_commit_on_main "${w}" "add root docs" || return 1 + assert_commit_on_main "${w}/repo-b" "submodule work" || return 1 + assert_file_exists "${w}/repo-b/sub.txt" || return 1 + + return 0 +} + +test_remove_cleanup() { + local w="${WORKSPACE}" + + "${w}/worktree.sh" create cleanup-test >/dev/null 2>&1 + [[ -d "${w}/.worktrees/cleanup-test" ]] || { echo " workspace should exist"; return 1; } + + "${w}/worktree.sh" remove cleanup-test >/dev/null 2>&1 + + [[ ! -d "${w}/.worktrees/cleanup-test" ]] || { echo " workspace dir should be gone"; return 1; } + + repo_git "${w}/repo-a" show-ref --verify --quiet "refs/heads/wt/cleanup-test" 2>/dev/null && \ + { echo " wt/cleanup-test should be gone from repo-a"; return 1; } + repo_git "${w}" show-ref --verify --quiet "refs/heads/wt/cleanup-test" 2>/dev/null && \ + { echo " wt/cleanup-test should be gone from root"; return 1; } + + return 0 +} + +test_list() { + local w="${WORKSPACE}" + + "${w}/worktree.sh" create list-a >/dev/null 2>&1 + "${w}/worktree.sh" create list-b >/dev/null 2>&1 + + local output + output=$("${w}/worktree.sh" list 2>&1) + echo "${output}" | grep -q "list-a" || { echo " should contain list-a"; return 1; } + echo "${output}" | grep -q "list-b" || { echo " should contain list-b"; return 1; } + + return 0 +} + +test_no_changes_merge() { + local w="${WORKSPACE}" + + "${w}/worktree.sh" create no-changes >/dev/null 2>&1 + + local output + output=$("${w}/worktree.sh" merge no-changes 2>&1) + echo "${output}" | grep -qi "no changes\|skipping" || { echo " should report skipping"; return 1; } + + return 0 +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +trap teardown EXIT + +echo "=========================================" +echo " worktree.sh integration tests" +echo "=========================================" +echo "" + +run_test "Basic create + commit + merge" test_basic_create_merge +run_test "Remote sync (agent push scenario)" test_remote_sync +run_test "Root repo branch merge" test_root_repo_merge +run_test "Remove + cleanup" test_remove_cleanup +run_test "List workspaces" test_list +run_test "Merge with no changes" test_no_changes_merge + +echo "=========================================" +echo " Results: ${PASS} passed, ${FAIL} failed" +echo "=========================================" + +[[ ${FAIL} -eq 0 ]] diff --git a/plugins/node-team/skills/node/SKILL.md b/plugins/node-team/skills/node/SKILL.md new file mode 100644 index 000000000..712e50b6f --- /dev/null +++ b/plugins/node-team/skills/node/SKILL.md @@ -0,0 +1,10 @@ +--- +name: node-team +description: "OpenShift Node team assistant. Covers kubelet, MCO, CRI-O, crun, conmonrs, Kueue operator, Jira (OCPNODE/OCPBUGS), Red Hat KB/support cases, Prometheus, and K8s/OCP docs. Triggers on any OpenShift node-layer development, deployment, debugging, or workflow task." +allowed-tools: Bash(${CLAUDE_PLUGIN_ROOT}/scripts/*),Bash(curl:*) +--- + +## How to use this skill + +1. Read [references/INDEX.md](references/INDEX.md) to route to the relevant reference +2. Read the reference, then act on it — run scripts, fetch data, present results diff --git a/plugins/node-team/skills/node/references/INDEX.md b/plugins/node-team/skills/node/references/INDEX.md new file mode 100644 index 000000000..bdff2d4d0 --- /dev/null +++ b/plugins/node-team/skills/node/references/INDEX.md @@ -0,0 +1,34 @@ +# Node Skill Reference Index + +Reference files contain only tribal knowledge and non-obvious nuances. For discoverable details (build commands, repo layout, test targets), browse the source code directly. + +Root: `./` + +## Setup + +|SETUP.md + +## Development + +|development:{kubelet-dev.md,mco-dev.md,crio-dev.md,crun-conmon.md,kueue-operator-dev.md,worktrees.md} + +## Deployment + +|deployment:{debug-binary.md} +|deployment/debug-binary:{crio.md,cross-compile.md,deploy.md,rollback.md,ssh-bastion.md} + +## Jira + +|jira.md + +## Red Hat Support + +|support.md + +## Platform Documentation + +|platform-docs.md + +## Prometheus + +|prometheus.md diff --git a/plugins/node-team/skills/node/references/SETUP.md b/plugins/node-team/skills/node/references/SETUP.md new file mode 100644 index 000000000..a07685fb9 --- /dev/null +++ b/plugins/node-team/skills/node/references/SETUP.md @@ -0,0 +1,94 @@ +# Standard Repo Setup + +All node team repos follow the same clone + worktree workflow for feature work. + +## Clone + +Clone into the current working directory (if not already present): + +```bash +git clone +cd +``` + +## Worktree for Feature Work + +Never work directly on the default branch. Create a worktree: + +```bash +git worktree add .worktrees/ -b wt/ +cd .worktrees/ +``` + +Deduce `` from the task description (e.g., "reflink feature" -> `reflink`, "fix cgroup leak" -> `fix-cgroup-leak`, "OCPNODE-1234" -> `ocpnode-1234`). + +## Worktree for PR Work + +To review or continue work on an existing PR: + +```bash +git fetch origin pull//head:pr- +git worktree add .worktrees/pr- pr- +cd .worktrees/pr- +``` + +If resuming work on a PR you've already fetched, check `git worktree list` first — the worktree may already exist. + +## Worktree for Jira Ticket Work + +To investigate or fix a Jira issue: + +1. Fetch the issue details to determine the component: + ```bash + ./scripts/jira.sh get OCPNODE-1234 + ``` +2. Map the component to a repo (see Repo URLs below), confirm with the user, and clone if needed. +3. Create a worktree named after the ticket: + ```bash + git worktree add .worktrees/ocpnode-1234 -b wt/ocpnode-1234 + cd .worktrees/ocpnode-1234 + ``` + +## Component to Repo Mapping + +| Jira Label / Component | Repo | +|-------------------------|------| +| `crio` | cri-o | +| `kubelet` | kubernetes | +| `mco` | machine-config-operator | +| `crun` | crun | +| `conmonrs` | conmon-rs | +| `kueue` | kueue-operator | + +## Enable Node Assistant in the Worktree + +After creating a worktree, install the skill locally so it's available when you launch Claude there: + +```bash +cd .worktrees/ +claude plugin install node-assistant@node-skills --scope local +``` + +## Repo URLs + +| Component | Upstream | Downstream (OpenShift) | +|-----------|----------|------------------------| +| CRI-O | `https://github.com/cri-o/cri-o.git` | `https://github.com/openshift/cri-o.git` | +| Kubelet | `https://github.com/kubernetes/kubernetes.git` | `https://github.com/openshift/kubernetes.git` | +| MCO | — | `https://github.com/openshift/machine-config-operator.git` | +| crun | `https://github.com/containers/crun.git` | — | +| conmon-rs | `https://github.com/containers/conmon-rs.git` | — | +| Kueue Operator | `https://github.com/kubernetes-sigs/kueue.git` | `https://github.com/openshift/kueue-operator.git` | + +For upstream features and bug fixes, clone upstream. For OpenShift-specific work, clone downstream. + +## Cleanup + +```bash +# List worktrees +git worktree list + +# Remove when done +git worktree remove .worktrees/ +git branch -d wt/ +``` diff --git a/plugins/node-team/skills/node/references/deployment/debug-binary.md b/plugins/node-team/skills/node/references/deployment/debug-binary.md new file mode 100644 index 000000000..5198b28c8 --- /dev/null +++ b/plugins/node-team/skills/node/references/deployment/debug-binary.md @@ -0,0 +1,113 @@ +# Deploying Debug Binaries to RHCOS Nodes + +Deploy a custom-built binary (CRI-O, crun, kubelet, etc.) to an OpenShift worker node running RHCOS for debugging or POC testing. + +## The Challenge + +RHCOS (Red Hat Enterprise Linux CoreOS) has an **immutable `/usr` filesystem**. You cannot overwrite `/usr/bin/crio` or any other system binary directly. There is no package manager (`dnf`/`yum`), no compiler toolchain, and no development headers on the node. + +## The Solution + +**Bind-mount** your custom binary over the original. The bind mount shadows the original file without modifying the rootfs. The original binary remains intact underneath and is instantly recoverable by unmounting. + +``` +mount --bind /home/core/crio /usr/bin/crio +``` + +For cluster-wide deployment that survives reboots, use [layered images](layered-image.md) instead. + +## Four Phases + +### Phase 1: Build (Cross-Compile) + +Cross-compile the binary for `linux/amd64` using a Docker container that matches the target OS libraries. The binary must be dynamically linked against compatible library versions (same sonames as RHCOS). + +See [debug-binary/cross-compile.md](debug-binary/cross-compile.md) + +### Phase 2: Access (SSH Bastion) + +Reach the worker node via an SSH bastion pod. RHCOS nodes are not directly accessible from outside the cluster. You need to discover the SSH key used at cluster install time and deploy a bastion DaemonSet. + +See [debug-binary/ssh-bastion.md](debug-binary/ssh-bastion.md) + +### Phase 3: Deploy (Bind Mount) + +Transfer the binary to the node, verify it works, cordon/drain the node, set SELinux context, bind-mount over the original, restart the service. This phase has the most gotchas around SELinux, systemd, and service dependencies. + +See [debug-binary/deploy.md](debug-binary/deploy.md) + +### Phase 4: Rollback (Unmount) + +Unmount the bind mount, remove any config drop-ins, restart the service. The original binary is untouched underneath. + +See [debug-binary/rollback.md](debug-binary/rollback.md) + +## Binary-Specific References + +Each binary has its own reference with build dependencies, systemd units, and deployment details: + +- **CRI-O**: [debug-binary/crio.md](debug-binary/crio.md) -- build tags, library deps, kubelet restart, config drop-ins + +## Safety Rules + +These are non-negotiable. Skipping any of these can take a node out of the cluster. + +1. **Verify SSH bastion connectivity first.** Before building or deploying anything, confirm you can reach the target worker node via the bastion. Run `uname -a` over SSH. If you cannot reach the node, nothing else matters. + +2. **Always preflight-test the binary** before deploying. SCP it to `/home/core/`, run `ldd` to verify libraries resolve, and run ` --version` to confirm it loads. If either fails, do not proceed. + +3. **Always cordon and drain first.** Never restart a container runtime on a node with running workloads. + +4. **Always test on ONE worker node.** Keep at least one healthy worker to maintain cluster capacity. + +5. **Always set the SELinux context** before bind-mounting: + ```bash + sudo chcon --reference=/usr/bin/ /home/core/ + ``` + Without the correct context (e.g., `container_runtime_exec_t` for CRI-O), systemd will refuse to execute the binary with `Permission denied`. + +6. **Know how to rollback before you deploy.** The rollback is: unmount, restart service. Read [debug-binary/rollback.md](debug-binary/rollback.md) before starting. + +## Quick Reference + +| Step | Command | +|------|---------| +| Check node OS | `oc get nodes -o wide` | +| Check current binary version | SSH in, ` --version` | +| Cordon node | `oc adm cordon ` | +| Drain node | `oc adm drain --ignore-daemonsets --delete-emptydir-data` | +| Uncordon node | `oc adm uncordon ` | +| Verify node health | `oc get node ` (wait for Ready) | +| Check bind mounts | `ssh core@ "mount \| grep /usr/bin"` | + +## Deciding: Bind Mount vs Layered Image + +| | Bind Mount | Layered Image | +|---|---|---| +| Scope | Single node | All nodes in a pool | +| Survives reboot | No (unless systemd drop-in) | Yes | +| Speed | Minutes | 30-60 min (MCO rollout) | +| Use case | Quick debug/test | Cluster-wide validation, customer simulation | +| Rollback | `umount` | Delete MachineConfig | + +Use bind mounts for quick single-node testing. Use [layered images](layered-image.md) when you need the binary on all nodes or need it to persist across reboots. + +## Workflow Diagram + +``` +Local Machine RHCOS Worker Node +───────────── ───────────────── +1. Cross-compile in Docker + (linux/amd64, matching libs) + │ +2. SCP via bastion ─────────────► /home/core/ + 3. ldd, --version (preflight) + 4. chcon (SELinux) + │ + oc adm cordon/drain + 5. mount --bind + 6. systemctl restart + │ + oc adm uncordon + 7. Verify: --version, node Ready +``` diff --git a/plugins/node-team/skills/node/references/deployment/debug-binary/crio.md b/plugins/node-team/skills/node/references/deployment/debug-binary/crio.md new file mode 100644 index 000000000..1e06075ad --- /dev/null +++ b/plugins/node-team/skills/node/references/deployment/debug-binary/crio.md @@ -0,0 +1,195 @@ +# CRI-O Binary Reference + +## Binary Details + +| Property | Value | +|----------|-------| +| Binary path | `/usr/bin/crio` | +| Systemd unit | `crio.service` | +| Dependent service | `kubelet.service` (must restart after CRI-O restart) | +| RPM package | `cri-o` | +| SELinux context | `system_u:object_r:container_runtime_exec_t:s0` | +| Config drop-in dir | `/etc/crio/crio.conf.d/` | +| Linkmode | dynamic | + +## Build Dependencies (Debian/Bookworm) + +```dockerfile +RUN apt-get update && apt-get install -y --no-install-recommends \ + libseccomp-dev \ + libgpgme-dev \ + libassuan-dev \ + libgpg-error-dev \ + libselinux1-dev \ + pkg-config \ + make \ + git \ + && rm -rf /var/lib/apt/lists/* +``` + +## Dynamic Libraries + +CRI-O links against these shared libraries. The cross-compiled binary must show the same sonames in `ldd` output: + +``` +libseccomp.so.2 +libgpgme.so.11 +libassuan.so.0 +libgpg-error.so.0 +libc.so.6 +``` + +## Build Command + +```bash +make bin/crio +``` + +The Makefile auto-detects build tags based on available libraries. Expected tags on RHCOS-compatible builds: + +``` +containers_image_ostree_stub +exclude_graphdriver_btrfs +btrfs_noversion +seccomp +selinux +``` + +## Go Version + +Check `go.mod` for the required Go version. Use the matching `golang:-bookworm` Docker image. + +## Example Dockerfile + +```dockerfile +FROM --platform=linux/amd64 golang:1.23-bookworm + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libseccomp-dev libgpgme-dev libassuan-dev \ + libgpg-error-dev libselinux1-dev \ + pkg-config make git \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /build/cri-o +COPY . . + +RUN make bin/crio && ldd bin/crio +``` + +## pinns Binary + +CRI-O uses `pinns` for pod namespace pinning. If your changes affect networking or namespace handling, you may need to deploy a custom `pinns` as well: + +| Property | Value | +|----------|-------| +| Binary path | `/usr/bin/pinns` | +| Build command | `make bin/pinns` | + +`pinns` is a small C binary. Build it alongside CRI-O in the same Dockerfile: + +```bash +make bin/pinns +``` + +Deploy it the same way as CRI-O (SCP, chcon, bind-mount). + +## CRI-O Preflight Checks + +After SCPing the binary, run these CRI-O-specific checks: + +```bash +# Verify libraries +ssh core@${WORKER} "ldd /home/core/crio" + +# Check version and build info +ssh core@${WORKER} "/home/core/crio --version" + +# Validate it can parse the existing config +ssh core@${WORKER} "/home/core/crio config 2>&1 | head -5" +``` + +If `crio config` fails, the binary may have been built without required build tags or is incompatible with the node's config format. + +## CRI-O Restart Behavior + +**Restarting CRI-O terminates all running containers on the node** and disconnects kubelet from the container runtime. Kubelet will go inactive and the node will become `NotReady`. + +After starting CRI-O, **always restart kubelet**: + +```bash +sudo systemctl restart crio +sudo systemctl restart kubelet +``` + +Wait ~15 seconds, then verify the node returns to `Ready`: + +```bash +oc get node +``` + +This is why you must cordon/drain before restarting CRI-O. Without draining, all running workloads will be killed. + +## Config Drop-ins + +CRI-O reads additional configuration from `/etc/crio/crio.conf.d/`. Files are processed in lexicographic order; later files override earlier ones. + +Example (setting a runtime option): + +```bash +ssh core@${WORKER} "sudo tee /etc/crio/crio.conf.d/01-custom.conf <<'EOF' +[crio.runtime] +default_runtime = \"crun\" +EOF" + +ssh core@${WORKER} "sudo systemctl restart crio && sudo systemctl restart kubelet" +``` + +## Verifying the Deployment + +```bash +# Check version and build info +ssh core@${WORKER} "sudo crio --version" + +# Check it is running +ssh core@${WORKER} "sudo systemctl is-active crio" + +# Check kubelet is connected +ssh core@${WORKER} "sudo systemctl is-active kubelet" + +# Check node status (from your workstation) +oc get node + +# Check CRI-O logs for errors +ssh core@${WORKER} "sudo journalctl -u crio --no-pager -n 20" +``` + +## Monitoring After Deployment + +Watch for issues after uncordoning: + +```bash +# Watch for CRI-O errors +ssh core@${WORKER} "sudo journalctl -u crio -f" & + +# Watch pod events on this node +oc get events --field-selector involvedObject.kind=Node,involvedObject.name= -w + +# Verify pods can be scheduled and start +oc run test-pod --image=registry.access.redhat.com/ubi9/ubi-minimal:latest \ + --overrides='{"spec":{"nodeName":""}}' \ + --command -- sleep 30 +oc get pod test-pod -w +oc delete pod test-pod +``` + +## CRI-O Rollback + +Follow the standard rollback procedure in [rollback.md](rollback.md) with these values: + +| Parameter | Value | +|-----------|-------| +| `` | `crio` | +| `` | `/usr/bin/crio` | +| `` | `kubelet` | +| `` | `/etc/crio/crio.conf.d/01-custom.conf` (if created) | +| `` | `cri-o` | diff --git a/plugins/node-team/skills/node/references/deployment/debug-binary/cross-compile.md b/plugins/node-team/skills/node/references/deployment/debug-binary/cross-compile.md new file mode 100644 index 000000000..7003a1ecc --- /dev/null +++ b/plugins/node-team/skills/node/references/deployment/debug-binary/cross-compile.md @@ -0,0 +1,160 @@ +# Cross-Compiling for RHCOS + +RHCOS worker nodes run `linux/amd64`. If you are building from an arm64 Mac (Apple Silicon), you need to cross-compile using Docker with QEMU emulation. + +## Why Not Build on the Node? + +RHCOS is an immutable OS. It has no package manager (`dnf`/`yum`), no development headers, and no Go toolchain. Building must happen off-cluster. + +## Why Not Build a Static Binary? + +RHCOS ships dynamically-linked binaries. The target binary must link against the same shared libraries (same sonames) as the RPM-installed version on RHCOS. A statically-linked binary might work but diverges from the production configuration and may miss features gated behind dynamic library detection (e.g., SELinux, seccomp, gpgme). + +## Build Procedure + +### 1. Determine the Go Version + +Check `go.mod` in the source directory: + +```bash +head -3 go.mod +``` + +Use the matching `golang:-bookworm` Docker image. + +### 2. Determine Library Dependencies + +SSH into the target node and check what the existing binary links against: + +```bash +ssh core@${WORKER} "ldd \$(which )" +``` + +The cross-compiled binary must link against the same sonames. + +### 3. Create a Dockerfile + +Use a base image with matching libraries. Debian Bookworm and Fedora both produce binaries with compatible sonames for RHCOS 9.x. + +The binary-specific reference (e.g., [crio.md](crio.md)) lists the exact packages and build tags needed. + +```dockerfile +FROM --platform=linux/amd64 golang:-bookworm + +RUN apt-get update && apt-get install -y --no-install-recommends \ + \ + pkg-config make git \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /build/ +COPY . . + +RUN make && ldd +``` + +### 4. Build + +```bash +docker build --platform linux/amd64 -f Dockerfile.cross -t -cross . +``` + +This uses QEMU emulation on arm64 Mac. Expect builds to take 2-5x longer than native. + +### 5. Extract the Binary + +```bash +docker create --platform linux/amd64 --name extract -cross +mkdir -p bin +docker cp extract:/build// ./bin/ +docker rm extract +``` + +### 6. Verify Architecture + +```bash +file ./bin/ +# Should show: ELF 64-bit LSB executable, x86-64 +``` + +## Go Cross-Compile Settings + +For Go binaries (CRI-O, conmon-rs components): + +```bash +GOOS=linux GOARCH=amd64 CGO_ENABLED=1 +``` + +`CGO_ENABLED=1` is required because these binaries link against C libraries (libseccomp, libgpgme, etc.). The Docker container provides the correct C toolchain for the target platform. + +## Rust Cross-Compile (conmon-rs) + +conmon-rs is written in Rust. Cross-compile for `x86_64-unknown-linux-gnu`: + +```bash +# In the Docker container +rustup target add x86_64-unknown-linux-gnu + +# Build +cargo build --release --target x86_64-unknown-linux-gnu +``` + +Use a Fedora or RHEL-based container with matching system libraries. The Dockerfile should install: + +```dockerfile +FROM --platform=linux/amd64 fedora:latest + +RUN dnf install -y \ + rust cargo \ + glib2-devel libseccomp-devel systemd-devel \ + capnproto capnp-devel \ + pkg-config make git \ + && dnf clean all + +WORKDIR /build/conmon-rs +COPY . . + +RUN cargo build --release && ldd target/release/conmonrs +``` + +## C Cross-Compile (crun) + +crun uses autotools. Build in a matching container: + +```dockerfile +FROM --platform=linux/amd64 fedora:latest + +RUN dnf install -y \ + gcc automake autoconf libtool \ + libcap-devel systemd-devel \ + yajl-devel libseccomp-devel \ + python3 git \ + && dnf clean all + +WORKDIR /build/crun +COPY . . + +RUN ./autogen.sh && ./configure && make +RUN ldd crun +``` + +## Verifying the Binary + +After extraction, verify in a matching container: + +```bash +# Run ldd inside a matching container to confirm library compatibility +docker run --platform linux/amd64 --rm -v $(pwd)/bin:/check debian:bookworm \ + ldd /check/ +``` + +All libraries must resolve. If any show `not found`, the binary was built against incompatible library versions. + +## Common Issues + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `ldd` shows `not found` | Wrong base image or missing -dev package | Check sonames on target node, use matching base image | +| `GLIBC_x.xx not found` | glibc version mismatch | Use older base image (bookworm is usually safe for RHCOS 9.x) | +| Binary runs but features missing | Wrong build tags | Check binary-specific reference for required tags | +| Exec format error on node | Wrong architecture | Verify `file` output shows `x86-64` | +| Build extremely slow | QEMU emulation on arm64 Mac | Expected, 2-5x slower than native | diff --git a/plugins/node-team/skills/node/references/deployment/debug-binary/deploy.md b/plugins/node-team/skills/node/references/deployment/debug-binary/deploy.md new file mode 100644 index 000000000..fa1bd71de --- /dev/null +++ b/plugins/node-team/skills/node/references/deployment/debug-binary/deploy.md @@ -0,0 +1,198 @@ +# Deploying the Binary + +This is the critical phase. Follow the steps in order. Do not skip the preflight check. + +## Step 1: SCP Binary to Node + +Transfer the cross-compiled binary to the node via the SSH bastion: + +```bash +scp -i $SSH_KEY \ + -o StrictHostKeyChecking=no \ + -o ProxyCommand="ssh -i $SSH_KEY -A -o StrictHostKeyChecking=no -o ServerAliveInterval=30 -W %h:%p core@${BASTION_HOST}" \ + ./bin/ core@${WORKER}:/home/core/ +``` + +Make it executable: + +```bash +ssh core@${WORKER} "chmod +x /home/core/" +``` + +## Step 2: Preflight Test + +Verify the binary works before touching anything: + +```bash +# Check libraries resolve +ssh core@${WORKER} "ldd /home/core/" + +# Check it runs +ssh core@${WORKER} "/home/core/ --version" +# or +ssh core@${WORKER} "/home/core/ -h" +``` + +If `ldd` shows `not found` for any library, the binary was built against incompatible versions. Go back to the cross-compile step. + +If `--version` fails, check the error -- it may be an architecture mismatch, missing library, or permissions issue. + +## Step 3: Set SELinux Context + +RHCOS runs SELinux in enforcing mode. Systemd checks the SELinux context of binaries before executing them. A binary in `/home/core/` has `user_home_t` context, which systemd will reject. + +Copy the context from the original binary: + +```bash +ssh core@${WORKER} "sudo chcon --reference= /home/core/" +``` + +Verify: + +```bash +ssh core@${WORKER} "ls -laZ /home/core/ " +``` + +Both should show the same context (e.g., `system_u:object_r:container_runtime_exec_t:s0` for CRI-O). + +Without this step, systemd will fail with: +``` +Failed to locate executable : Permission denied +``` + +## Step 4: Cordon Node + +Prevent new pods from being scheduled: + +```bash +oc adm cordon +``` + +## Step 5: Drain Node + +Evict existing workloads: + +```bash +oc adm drain --ignore-daemonsets --delete-emptydir-data --timeout=120s +``` + +If drain times out on a stuck pod: + +```bash +oc get pods --all-namespaces --field-selector spec.nodeName= | grep Terminating +oc delete pod -n --force --grace-period=0 +``` + +## Step 6: Create Bind Mount + +Stop the service, mount the new binary, start the service: + +```bash +# Stop the service +ssh core@${WORKER} "sudo systemctl stop " + +# Bind mount the new binary over the original +ssh core@${WORKER} "sudo mount --bind /home/core/ " + +# Start the service +ssh core@${WORKER} "sudo systemctl start " +``` + +The bind mount shadows the original binary without modifying it. The original remains intact underneath. + +## Step 7: Make Persistent (Optional) + +The bind mount does not survive a reboot. To make it persistent, create a systemd drop-in that re-creates the mount before the service starts: + +```bash +ssh core@${WORKER} "sudo mkdir -p /etc/systemd/system/.d" + +ssh core@${WORKER} "sudo tee /etc/systemd/system/.d/10-bind-mount.conf <<'EOF' +[Service] +ExecStartPre=/usr/bin/mount --bind /home/core/ +EOF" + +ssh core@${WORKER} "sudo systemctl daemon-reload" +``` + +## Step 8: Restart Service and Dependents + +Verify the service is running with the new binary: + +```bash +# Check service status +ssh core@${WORKER} "sudo systemctl is-active " + +# Verify the new version +ssh core@${WORKER} "sudo --version" + +# Restart dependent services (e.g., kubelet after CRI-O restart) +ssh core@${WORKER} "sudo systemctl restart " +ssh core@${WORKER} "sudo systemctl is-active " +``` + +Check the binary-specific reference (e.g., [crio.md](crio.md)) for which dependent services need restarting. + +## Step 9: Verify Node Health + +```bash +# Wait for the node to become Ready +oc get node +``` + +If the node stays `NotReady`, check dependent services. A common issue is forgetting to restart kubelet after CRI-O restart. + +## Step 10: Uncordon + +```bash +oc adm uncordon +``` + +## Optional: Config Drop-ins + +To add configuration (e.g., feature flags), write a drop-in file and restart: + +```bash +ssh core@${WORKER} "sudo tee <<'EOF' + +EOF" + +ssh core@${WORKER} "sudo systemctl restart " +``` + +## Updating an Already-Deployed Binary + +If you need to deploy a newer version and the bind mount is already in place: + +1. SCP the new binary to a **different filename** (the mounted path is busy) +2. Stop the service +3. Unmount the old bind mount +4. Rename the new file to the expected name +5. Set SELinux context +6. Mount and start + +```bash +scp core@${WORKER}:/home/core/-v2 + +ssh core@${WORKER} "sudo systemctl stop && \ + sudo umount && \ + mv /home/core/-v2 /home/core/ && \ + chmod +x /home/core/ && \ + sudo chcon --reference= /home/core/ && \ + sudo mount --bind /home/core/ && \ + sudo systemctl start " +``` + +## Full Single-Command Deploy + +For convenience, after preflight passes and SELinux is set: + +```bash +oc adm cordon && \ +oc adm drain --ignore-daemonsets --delete-emptydir-data --timeout=120s && \ +ssh core@${WORKER} "sudo systemctl stop && \ + sudo mount --bind /home/core/ && \ + sudo systemctl start && \ + sudo systemctl restart " && \ +oc adm uncordon +``` diff --git a/plugins/node-team/skills/node/references/deployment/debug-binary/rollback.md b/plugins/node-team/skills/node/references/deployment/debug-binary/rollback.md new file mode 100644 index 000000000..1b3f9fde1 --- /dev/null +++ b/plugins/node-team/skills/node/references/deployment/debug-binary/rollback.md @@ -0,0 +1,137 @@ +# Rollback + +Rollback is straightforward because the bind mount never touched the original binary. The original is intact underneath the mount. + +## Procedure + +### Step 1: Cordon and Drain + +```bash +oc adm cordon +oc adm drain --ignore-daemonsets --delete-emptydir-data --timeout=120s +``` + +### Step 2: Stop Service and Unmount + +```bash +# Stop the service +ssh core@${WORKER} "sudo systemctl stop " + +# Unmount the bind mount (restores original binary) +ssh core@${WORKER} "sudo umount " +``` + +### Step 3: Remove Systemd Drop-ins + +If you created a systemd drop-in to persist the mount across reboots, remove it: + +```bash +ssh core@${WORKER} "sudo rm -rf /etc/systemd/system/.d" +ssh core@${WORKER} "sudo systemctl daemon-reload" +``` + +### Step 4: Remove Config Drop-ins + +If you added any configuration drop-in files: + +```bash +ssh core@${WORKER} "sudo rm -f " +``` + +### Step 5: Start Service and Dependents + +```bash +# Start the service (now using the original binary) +ssh core@${WORKER} "sudo systemctl start " + +# Restart dependent services +ssh core@${WORKER} "sudo systemctl restart " + +# Verify original version +ssh core@${WORKER} "sudo --version" +``` + +### Step 6: Verify Node Health + +```bash +oc get node +``` + +Wait for `Ready` status. If the node does not recover, check service logs: + +```bash +ssh core@${WORKER} "sudo journalctl -u --no-pager -n 30" +``` + +### Step 7: Uncordon + +```bash +oc adm uncordon +``` + +## Quick Rollback (Single Command) + +For when you need to rollback fast: + +```bash +oc adm cordon && \ +oc adm drain --ignore-daemonsets --delete-emptydir-data --timeout=120s && \ +ssh core@${WORKER} "sudo systemctl stop && \ + sudo umount && \ + sudo rm -rf /etc/systemd/system/.d && \ + sudo rm -f && \ + sudo systemctl daemon-reload && \ + sudo systemctl start && \ + sudo systemctl restart " && \ +oc adm uncordon +``` + +## Cleanup + +The debug binary remains at `/home/core/` after unmounting. Remove it if no longer needed: + +```bash +ssh core@${WORKER} "rm /home/core/" +``` + +## Troubleshooting + +### Service will not start after rollback + +This should not happen since the original binary is untouched, but if it does: + +1. **Verify the unmount happened:** + ```bash + ssh core@${WORKER} "mount | grep " + ``` + If it still shows a bind mount, run `sudo umount ` again. + +2. **Verify the original binary is intact:** + ```bash + ssh core@${WORKER} "rpm -V " + ``` + +3. **Restore SELinux context on the original:** + ```bash + ssh core@${WORKER} "sudo restorecon " + ``` + +4. **Check logs:** + ```bash + ssh core@${WORKER} "sudo journalctl -u -n 50" + ``` + +### Unmount fails with "target is busy" + +The service is still using the binary. Stop it first: + +```bash +ssh core@${WORKER} "sudo systemctl stop " +ssh core@${WORKER} "sudo umount " +``` + +If it is still busy, check for other processes using it: + +```bash +ssh core@${WORKER} "sudo fuser -v " +``` diff --git a/plugins/node-team/skills/node/references/deployment/debug-binary/ssh-bastion.md b/plugins/node-team/skills/node/references/deployment/debug-binary/ssh-bastion.md new file mode 100644 index 000000000..69d27b86d --- /dev/null +++ b/plugins/node-team/skills/node/references/deployment/debug-binary/ssh-bastion.md @@ -0,0 +1,150 @@ +# SSH Bastion Access to RHCOS Nodes + +RHCOS worker nodes are not directly accessible via SSH. The [ssh-bastion](https://github.com/eparis/ssh-bastion) project deploys a bastion pod that proxies SSH connections to cluster nodes. + +## Setup + +### 1. Deploy the Bastion + +Use the deploy script from the upstream repo: + +```bash +curl -sL https://raw.githubusercontent.com/eparis/ssh-bastion/master/deploy/deploy.sh | bash +``` + +The script creates the `openshift-ssh-bastion` namespace, deploys the bastion pod, and prints the LoadBalancer IP. + +If the script is unavailable, apply the individual manifests: + +```bash +for f in serviceaccount role clusterrole deployment service; do + oc apply -f "https://raw.githubusercontent.com/eparis/ssh-bastion/master/deploy/${f}.yaml" +done +``` + +**LoadBalancer warm-up:** After deployment, the cloud LoadBalancer (especially on GCP) takes 30-60 seconds to become reachable. SSH connections will be refused during this period. Wait and retry -- do not assume the bastion is broken. + +```bash +sleep 30 +ssh -i $SSH_KEY -o ConnectTimeout=15 core@${BASTION_HOST} echo "connected" +``` + +### 2. Discover the SSH Key + +The cluster's `99-worker-ssh` MachineConfig contains the authorized public key. Match it against your local keys: + +```bash +# Get the public key baked into the nodes +oc get machineconfig 99-worker-ssh -o jsonpath='{.spec.config.passwd.users[0].sshAuthorizedKeys[0]}' + +# Compare against local keys +for f in ~/.ssh/*.pub; do echo "=== $f ===" && cat "$f"; done +``` + +The matching key is what you need. Common gotcha: GCP clusters often use `~/.ssh/google_compute_engine`, not `~/.ssh/id_rsa`. + +### 3. Get the Bastion Host + +```bash +BASTION_HOST=$(oc get service --all-namespaces -l run=ssh-bastion \ + -o go-template='{{ with (index (index .items 0).status.loadBalancer.ingress 0) }}{{ or .hostname .ip }}{{end}}') +echo "Bastion: $BASTION_HOST" +``` + +## Running Commands + +Use raw SSH with the proxy command. The upstream `ssh-bastion.sh` script appends `sudo -i` which makes it unsuitable for non-interactive command execution. + +```bash +SSH_KEY=~/.ssh/ +BASTION_HOST= +WORKER= + +ssh -i $SSH_KEY \ + -o StrictHostKeyChecking=no \ + -o ProxyCommand="ssh -i $SSH_KEY -A -o StrictHostKeyChecking=no -o ServerAliveInterval=30 -W %h:%p core@${BASTION_HOST}" \ + core@${WORKER} "" +``` + +## SCP (Transferring Files) + +Use SCP with the same proxy command: + +```bash +scp -i $SSH_KEY \ + -o StrictHostKeyChecking=no \ + -o ProxyCommand="ssh -i $SSH_KEY -A -o StrictHostKeyChecking=no -o ServerAliveInterval=30 -W %h:%p core@${BASTION_HOST}" \ + ./local-file core@${WORKER}:/home/core/remote-file +``` + +The upstream [scp.sh](https://github.com/eparis/ssh-bastion/blob/master/scp.sh) script is also available but requires `SSH_KEY_PATH` to be set. + +## Alternative: oc debug node + +For a quick shell on a node without setting up the bastion: + +```bash +oc debug node/ +chroot /host +``` + +This gives you a root shell on the node. Limitations: +- Cannot SCP files (no file transfer mechanism) +- Cannot run background processes reliably +- Session dies if the debug pod is evicted +- Runs as a pod, not a real SSH session + +Use `oc debug node` for inspection. Use the SSH bastion for deployment workflows that need SCP. + +## Writable Paths on RHCOS + +RHCOS has an immutable rootfs. You can only write to: +- `/home/core/` -- user home +- `/var/` -- variable data +- `/etc/` -- configuration (overlayed) +- `/tmp/` -- temporary + +Always SCP files to `/home/core/` first. + +## Gotcha: SCP Fails on Bind-Mounted Files + +If the target file is already bind-mounted (busy), SCP will fail with `Failure`. Copy to a new filename (e.g., `/home/core/binary-v2`), then swap after unmounting. + +## Troubleshooting + +### Bastion connectivity issues + +If SSH connections are intermittently refused (`Connection refused` on port 22) after the bastion pod is running: + +1. **Restart the bastion pod.** Deleting the pod lets the deployment recreate it: + +```bash +oc delete pod -n openshift-ssh-bastion -l run=ssh-bastion +sleep 30 +``` + +2. **Verify the pod is running and the LB has an IP:** + +```bash +oc get pods -n openshift-ssh-bastion -o wide +oc get svc -n openshift-ssh-bastion ssh-bastion +``` + +3. **Re-fetch the bastion IP** (it should not change, but confirm): + +```bash +BASTION_HOST=$(oc get service -n openshift-ssh-bastion ssh-bastion \ + -o go-template='{{ with (index (index .status.loadBalancer.ingress 0)) }}{{ or .hostname .ip }}{{end}}') +``` + +### Permission denied + +- Verify you are using the correct SSH key (see step 2 above) +- Verify you are connecting as user `core` (not `root`) +- Check that the SSH agent has the key loaded: `ssh-add -l` + +### Connection timeout + +- The node might not be reachable from the bastion network +- Verify the node internal IP: `oc get node -o jsonpath='{.status.addresses[?(@.type=="InternalIP")].address}'` +- Check that the bastion pod is in the same VPC/network as the nodes diff --git a/plugins/node-team/skills/node/references/development/crio-dev.md b/plugins/node-team/skills/node/references/development/crio-dev.md new file mode 100644 index 000000000..dd34b84be --- /dev/null +++ b/plugins/node-team/skills/node/references/development/crio-dev.md @@ -0,0 +1,26 @@ +# CRI-O: Non-Obvious Notes (Tribal Knowledge) + +- **Upstream**: `https://github.com/cri-o/cri-o.git` +- **Downstream (OpenShift)**: `https://github.com/openshift/cri-o.git` + +For build commands, repo layout, dependencies, and test targets — browse the repo directly (Makefile, README, go.mod). + +## Branch Mapping + +OCP 4.X uses CRI-O 1.(X-4+17).x. The formula: **CRI-O minor = OCP minor + 13**. + +| OCP | CRI-O | +|-----|-------| +| 4.18 | 1.31.x | +| 4.17 | 1.30.x | +| 4.16 | 1.29.x | + +Downstream branches are `release-4.X`, upstream are `release-1.X`. + +## OpenShift-Specific + +- CRI-O **does not build natively on macOS** — CGO is required for seccomp and system libraries. Use a containerized build for cross-compilation. +- On RHCOS, CRI-O config is **managed by the MCO**. Do not edit `/etc/crio/crio.conf` directly — it will be overwritten. Use `ContainerRuntimeConfig` CRs instead. +- Drop-in files in `/etc/crio/crio.conf.d/` follow naming conventions: `00-default` (RHCOS base), `01-ctrcfg-*` (from ContainerRuntimeConfig CR), `10-*` (MCO overrides). +- Registry mirrors on OpenShift: configure via `ImageContentSourcePolicy` or `ImageDigestMirrorSet` CRs, not by editing `/etc/containers/registries.conf`. +- CRI-O exposes Prometheus metrics at `localhost:9537/metrics`. diff --git a/plugins/node-team/skills/node/references/development/crun-conmon.md b/plugins/node-team/skills/node/references/development/crun-conmon.md new file mode 100644 index 000000000..84c1e2f8b --- /dev/null +++ b/plugins/node-team/skills/node/references/development/crun-conmon.md @@ -0,0 +1,26 @@ +# crun and conmon-rs: Non-Obvious Notes (Tribal Knowledge) + +- **crun**: `https://github.com/containers/crun.git` (upstream only, no downstream fork) +- **conmon-rs**: `https://github.com/containers/conmon-rs.git` (upstream only, no downstream fork) + +For build commands, repo layout, and test targets — browse each repo directly (Makefile/Cargo.toml, README). + +## Version History + +- **crun** replaced runc as the default OCI runtime starting in **OCP 4.12**. +- **conmon-rs** replaced the C-based conmon starting in **OCP 4.14**. conmon-rs uses gRPC (defined in `proto/conmon.proto`) instead of pipe-based IPC. + +## Binary Paths on RHCOS + +| Binary | Path | +|--------|------| +| crun | `/usr/bin/crun` | +| conmonrs | `/usr/libexec/crio/conmonrs` | +| conmon (legacy) | `/usr/bin/conmon` | + +After replacing any of these binaries on a node, **restart CRI-O** (`sudo systemctl restart crio`) for it to pick up the change. + +## Build Notes + +- **crun**: Fully static builds with glibc are not recommended. Use musl libc for true static builds, or use dynamic linking matching RHCOS library versions. A containerized build (Fedora/UBI) is the easiest path for cross-compilation. +- **conmon-rs**: Use `cross` (cargo plugin) for cross-compilation rather than native cross-compilation toolchains — avoids linker issues. diff --git a/plugins/node-team/skills/node/references/development/kubelet-dev.md b/plugins/node-team/skills/node/references/development/kubelet-dev.md new file mode 100644 index 000000000..cd77ff34e --- /dev/null +++ b/plugins/node-team/skills/node/references/development/kubelet-dev.md @@ -0,0 +1,22 @@ +# Kubelet (OpenShift): Non-Obvious Notes (Tribal Knowledge) + +- **Upstream**: `https://github.com/kubernetes/kubernetes.git` +- **Downstream (OpenShift)**: `https://github.com/openshift/kubernetes.git` + +For build commands, repo layout, and test targets — browse the repo directly (Makefile, README, go.mod, openshift-hack/). + +## Carry Patch Conventions + +OpenShift maintains patches on top of upstream kubernetes. Commits use these prefixes: + +- `UPSTREAM: :` — OpenShift-specific patch, not intended for upstream +- `UPSTREAM: :` — merge commit from upstream rebase +- `UPSTREAM: 12345:` — cherry-pick of upstream PR #12345 + +These are in the `UPSTREAM/` directory and as commit prefixes in git history. + +## OpenShift-Specific + +- The kubelet binary ships inside the **`ose-hyperkube`** image in OCP. +- Kubelet configuration on RHCOS is rendered at `/etc/kubernetes/kubelet.conf`, managed by MCO. Customize via `KubeletConfig` CR, not by editing the file. +- For active development, work against `master` unless backporting a fix to `release-4.X`. diff --git a/plugins/node-team/skills/node/references/development/kueue-operator-dev.md b/plugins/node-team/skills/node/references/development/kueue-operator-dev.md new file mode 100644 index 000000000..6acdc65f5 --- /dev/null +++ b/plugins/node-team/skills/node/references/development/kueue-operator-dev.md @@ -0,0 +1,17 @@ +# OpenShift Kueue Operator: Non-Obvious Notes (Tribal Knowledge) + +- **Upstream Kueue**: `https://github.com/kubernetes-sigs/kueue.git` +- **Downstream Operator**: `https://github.com/openshift/kueue-operator.git` + +For build commands, repo layout, CRD types, and test targets — browse the repo directly (Makefile, README, go.mod, api/). + +## Architecture + +The operator manages the lifecycle of upstream Kueue on OpenShift. It deploys into `openshift-kueue-operator` namespace and creates the upstream `kueue-controller-manager` in the `kueue-system` namespace. + +When changes are needed in upstream Kueue itself, submit a PR to `kubernetes-sigs/kueue` first, then update the operator to consume the new version. + +## OpenShift-Specific + +- Built with operator-sdk framework (controller-runtime, controller-gen, OLM bundles). +- CVO override warning applies here too — scale down CVO if patching the operator deployment manually during development. diff --git a/plugins/node-team/skills/node/references/development/mco-dev.md b/plugins/node-team/skills/node/references/development/mco-dev.md new file mode 100644 index 000000000..4a3c1d43a --- /dev/null +++ b/plugins/node-team/skills/node/references/development/mco-dev.md @@ -0,0 +1,40 @@ +# MCO (Machine Config Operator): Non-Obvious Notes (Tribal Knowledge) + +- **Repo**: `https://github.com/openshift/machine-config-operator.git` (no upstream — MCO is OpenShift-only) + +For build commands, repo layout, CRD types, and test targets — browse the repo directly (Makefile, README, go.mod, pkg/apis/). + +## Rendering Pipeline + +MachineConfigs are sorted **lexicographically by name** before merging. This is why naming conventions matter (e.g., `00-worker`, `01-worker-custom`). Later configs override earlier ones for files (by path) and systemd units (by name). Kernel arguments and extensions are accumulated (union). + +## MCD Reboot Rules + +The MCD **does** trigger a reboot when: +- Files in `/etc` or `/usr` change +- Systemd units are added/removed/modified +- Kernel arguments or OS extensions change +- The OS image changes + +The MCD **does not** reboot when: +- Only SSH keys are updated +- Only node annotations change + +## CVO Override Warning + +The Cluster Version Operator (CVO) will **revert manual image overrides** on MCO deployments. During development, scale down CVO: + +```bash +oc scale deployment cluster-version-operator -n openshift-cluster-version --replicas=0 +``` + +Remember to scale it back when done. + +## On-Cluster Layering (OCP 4.13+) + +On-cluster layering builds custom OS images using `MachineOSConfig` and `MachineOSBuild` resources. The MCD applies layered images via `rpm-ostree rebase` or `bootc switch`. + +## Other Notes + +- MCP `maxUnavailable` defaults to 1 — nodes update one at a time. +- Machine Config Server (MCS) serves Ignition configs on port **22623**. diff --git a/plugins/node-team/skills/node/references/development/worktrees.md b/plugins/node-team/skills/node/references/development/worktrees.md new file mode 100644 index 000000000..5487b05a6 --- /dev/null +++ b/plugins/node-team/skills/node/references/development/worktrees.md @@ -0,0 +1,37 @@ +# Worktrees: Parallel Multi-Repo Workspaces + +`scripts/worktree.sh` creates isolated workspaces with a `wt/` branch under `.worktrees//`. Works with any git repo — single repos or repos with submodules. When submodules are present, each one gets its own worktree and branch inside the workspace automatically. + +## Commands + +| Command | What it does | +|---|---| +| `sync` | Fetch + fast-forward all submodules to their tracked branch (from `.gitmodules`) | +| `create [base]` | Sync, then create a workspace branching from `base` (default: `HEAD`) | +| `pull ` | Sync main, then merge main into every worktree branch (keeps you up to date) | +| `merge ` | Merge all `wt/` branches back into their tracked main branches | +| `remove ` | Delete the workspace directory and all `wt/` branches | +| `list` | Show active workspaces and their submodule branch status | + +## Typical Workflow + +``` +./scripts/worktree.sh create my-feature +cd .worktrees/my-feature/ +# work across repos... +# optionally pull in upstream changes: +./scripts/worktree.sh pull my-feature +# done — merge back and clean up: +./scripts/worktree.sh merge my-feature +./scripts/worktree.sh remove my-feature +``` + +## Non-Obvious Details + +- **Branch prefix is `wt/`** — every workspace creates `wt/` branches in the root and all submodules. Don't manually create branches with this prefix. +- **`create` always syncs first** — it fetches and fast-forwards all submodules before branching, so your workspace starts from the latest remote state. +- **`merge` fetches remote `wt/` branches** — if an agent or CI pushed commits to `origin/wt/`, merge will pick them up before merging into main. This handles the case where worktree agents push directly. +- **`merge` reconciles submodule pointers** — after merging, it ensures each submodule's main branch matches the commit the root repo expects. This prevents pointer drift when branches were already cleaned up from a prior remove. +- **`sync` only fast-forwards** — it never rebases or creates merge commits. If a submodule has diverged from its remote, sync will warn and skip it so you can resolve manually. +- **`pull` skips submodules not on the worktree branch** — if you've manually switched a submodule to a different branch, pull won't touch it. +- **First-time repos** — if the root repo has no commits, `create` will make an initial commit automatically. diff --git a/plugins/node-team/skills/node/references/jira.md b/plugins/node-team/skills/node/references/jira.md new file mode 100644 index 000000000..29231ad5c --- /dev/null +++ b/plugins/node-team/skills/node/references/jira.md @@ -0,0 +1,201 @@ +# Node Team Jira Reference + +Red Hat Jira: `redhat.atlassian.net`. The `jira.sh` script at `${CLAUDE_PLUGIN_ROOT}/scripts/jira.sh` wraps the REST API. + +## Scripts + +### Read commands + +| Command | What it does | +|---|---| +| `jira.sh get ` | Full issue details | +| `jira.sh search '' [limit]` | Search with JQL | +| `jira.sh comments ` | List comments | +| `jira.sh issue-deep-dive ` | Issue + comments + linked issues | +| `jira.sh find-user ` | Search for a user by name (roster + Jira API) | +| `jira.sh transitions ` | Available status transitions | +| `jira.sh sprints [state]` | List sprints (active\|future\|closed) | +| `jira.sh sprint-issues [limit]` | Issues in a sprint | +| `jira.sh health-check` | Validate custom field IDs | + +### Write commands + +| Command | What it does | +|---|---| +| `jira.sh create [extra-json]` | Create issue. Extra fields as JSON, e.g. `'{"description":...}'` | +| `jira.sh assign ` | Assign issue — resolves names via roster + Jira user search | +| `jira.sh comment "" ` | Add a comment to one or more issues | +| `jira.sh set-field ` | Set any field (string, number, or JSON value) | +| `jira.sh set-points ` | Set story points | +| `jira.sh link [title]` | Add a remote link | +| `jira.sh move-to-sprint ` | Move issue(s) to a sprint | +| `jira.sh transition ` | Perform a transition | +| `jira.sh close [comment] ` | Comment (optional) + close | +| `jira.sh start-sprint ` | Start a sprint | +| `jira.sh close-sprint ` | Close a sprint | + +### Composite / dashboard commands + +When the user names a specific sprint (e.g. "Sprint 288"), pass `--sprint ""`. Without it, the active sprint is used. The name is a substring match, so `--sprint "288"` works. + +| Command | What it does | +|---|---| +| `jira.sh sprint-dashboard [--sprint ]` | Sprint issues by status, workload, blockers | +| `jira.sh standup-data [--sprint ]` | Team standup (dashboard + recent updates) | +| `jira.sh team-activity [--sprint ]` | Per-member sprint items | +| `jira.sh my-board-data [--sprint ]` | My sprint board items | +| `jira.sh my-standup-data [--sprint ]` | My standup prep (board + bugs + comments) | +| `jira.sh pickup-data [--sprint ]` | Unassigned items to pick up | +| `jira.sh bug-overview ` | Untriaged, unassigned, blockers, new bugs | +| `jira.sh my-bugs-data ` | My assigned bugs | +| `jira.sh epic-progress ` | Epic children + completion stats | +| `jira.sh release-data [ver]` | Release readiness (blockers, bugs, epics) | +| `jira.sh planning-data ` | Sprint planning (carryovers + backlog + bugs) | +| `jira.sh carryover-report ` | Not-done items from previous sprint | +| `jira.sh roster-sync [--force]` | Download team rosters from Jira | + +Team values: `core`, `green`, `blue`, `dra`, `kueue`, `all` + +## Projects + +| Project | Tracks | +|---|---| +| OCPNODE | Node team epics, stories, tasks, spikes | +| OCPBUGS | Cross-team bugs (filter by Node components) | +| RHOCPPRIO | Red Hat OpenShift Priority List (escalations) | +| OCPKUEUE | Kueue-specific work | +| OCPSTRAT | Strategy/feature tracking | + +## Components We Own + +Defined in saved filter "Node Components": + +Node, Node / CRI-O, Node / Kubelet, Node / CPU manager, Node / Memory manager, Node / Topology manager, Node / Numa aware Scheduling, Node / Device Manager, Node / Pod resource API, Node / Node Problem Detector, Node / Kueue, Node / Instaslice-operator + +## Boards & Sprints + +| ID | Board | Type | +|---|---|---| +| 7845 | Node board | scrum | +| 4383 | Node-Epics | kanban | +| 9874 | Node QE | scrum | + +Sprint naming: `OCP Node Core Sprint N`, `OCP Node Devices Sprint N`, `OCP Kueue Sprint N`, `CNF Compute Sprint N` + +Team queue: `aos-node@redhat.com` + +## Sub-teams + +| Team | Filter | +|---|---| +| Core | `filter = "Node Core Team"` (`membersOf(OpenShift-Node-Team)`) | +| Green | `filter = "Node Green Team"` | +| Blue | `filter = "Node Blue Team"` | + +## Saved Filters + +Use in JQL via `filter = "Name"`: + +| Name | ID | Scope | +|---|---|---| +| Node Components | 91645 | Component list | +| Node Bugs | 83963 | Node component bugs in OCPBUGS/RHOCPPRIO/OCPNODE | +| Node Green Team | 89708 | Green team assignees | +| Node Blue Team | 64253 | Blue team assignees | +| Node Core Team | 66331 | Core team members | +| Node Epics | 96318 | OCPNODE epics | +| Node CR bugs | 94401 | Component regression bugs | + +## Custom Field IDs + +Use field names in JQL, IDs for REST API calls: + +| ID | Name | +|---|---| +| `customfield_10014` | Epic Link | +| `customfield_10011` | Epic Name | +| `customfield_10020` | Sprint | +| `customfield_10028` | Story Points | +| `customfield_10001` | Team | +| `customfield_10022` | Target start | +| `customfield_10023` | Target end | +| `customfield_10855` | Target Version | +| `customfield_10840` | Severity | +| `customfield_10847` | Release Blocker | +| `customfield_10877` | Bugzilla Bug | +| `customfield_10875` | Git Pull Request | +| `customfield_10978` | SFDC Cases Counter | +| `customfield_10979` | SFDC Cases Links | +| `customfield_12313441` | SFDC Cases (legacy) | + +## Workflow Statuses + +Bug lifecycle: NEW → To Do → ASSIGNED → POST → Modified → ON_QA → Verified → CLOSED/Done + +Feature/epic: New → Planning → To Do → In Progress → Code Review → Review → Dev Complete → Done/Closed + +## Key Field Meanings + +| Field Value | Meaning | +|---|---| +| Priority: Undefined | Untriaged — needs prioritization | +| Release Blocker: Proposed | Someone thinks this blocks the release | +| Release Blocker: Approved | Confirmed release blocker | +| Customer Impact: Customer Escalated | Customer-reported or escalated | +| SFDC Cases Counter (not empty) | Has linked support cases | +| Special Handling: contract-priority | Contractual obligation | + +## Bug Triage Definitions + +Base all queries on `filter = "Node Bugs"` and append: + +| Category | JQL Clause | +|---|---| +| Untriaged | `priority = Undefined OR "Release Blocker" = Proposed OR assignee in ("aos-node@redhat.com")` | +| Blocker? | `"Release Blocker" = Proposed OR priority = Blocker AND "Release Blocker" is EMPTY` | +| Blocker+ | `"Release Blocker" = Approved OR priority = Blocker` | +| Customer Issues | `"Customer Impact" = "Customer Escalated" OR "SFDC Cases Counter" is not EMPTY` | +| Escalations | `project = "Red Hat OpenShift Priority List" OR "Customer Impact" = "Customer Escalated" OR labels in (shift_telco5g)` | +| CVE | `labels in (SecurityTracking) OR issuetype in (Vulnerability, Weakness)` | +| CR | `labels = component-regression` | + +## Common Workflows + +### Creating and assigning an issue + +```bash +# Create an Epic in OCPNODE +jira.sh create OCPNODE Epic "My epic summary" '{"customfield_10011":"Epic Name Here"}' + +# Create a Story linked to an epic +jira.sh create OCPNODE Story "My story summary" '{"customfield_10014":"OCPNODE-1234"}' + +# Assign by display name (resolves via roster + Jira API) +jira.sh assign OCPNODE-1234 "John Smith" + +# Assign by accountId (if you already have it) +jira.sh assign OCPNODE-1234 "712020:abc-def-123" +``` + +### Finding a user + +```bash +# Searches team rosters first, then Jira user directory +jira.sh find-user "Sabuj" +``` + +### Standup and bug review + +```bash +# Team standup — present interactively, one person at a time, alphabetically +standup-data core # then present as a table, one-by-one + +# Bug overview — single table sorted by latest update +bug-overview core # present with bug-id, description, priority, status, assignee +``` + +## Gotchas + +- Epic children: use `"Epic Link" = EPIC-KEY` in JQL (not `parentEpic`). +- `issueFunction` (e.g. `issueFunction in commented("by currentUser()")`) does **not exist** on Jira Cloud. Workaround: `watcher = currentUser() AND comment ~ "keyword"`. +- Always confirm with the user before any write operation (create, edit, comment, transition). diff --git a/plugins/node-team/skills/node/references/platform-docs.md b/plugins/node-team/skills/node/references/platform-docs.md new file mode 100644 index 000000000..c01e44a01 --- /dev/null +++ b/plugins/node-team/skills/node/references/platform-docs.md @@ -0,0 +1,23 @@ +# Platform Documentation Lookup + +Prefer retrieval over pre-training for Kubernetes and OpenShift specifics — docs change across versions. Use `gh api` to fetch raw markdown. + +## Kubernetes + +- Repo: `kubernetes/website`, path: `content/en/docs/` +- Versioning: git branches named `release-X.Y` — discover latest by listing branches, grep `^release-`, sort, take last +- No index file — navigate by listing directories +- Hugo shortcodes (`{{< ... >}}`) appear in content — ignore them +- Always include `?ref=$VERSION` in API calls + +## OpenShift + +- Repo: `harche/openshift-docs-md`, path: `docs/{version}/` +- Versions are directories (e.g. `4.22`) — discover latest by listing `docs/`, filter numeric names, take highest +- Each version has an **`AGENTS.md`** index mapping topics to doc files — always start here + +## Common + +- Always use `-H "Accept: application/vnd.github.raw+json"` for raw file content +- Discover versions dynamically — never hardcode +- Read-only diff --git a/plugins/node-team/skills/node/references/prometheus.md b/plugins/node-team/skills/node/references/prometheus.md new file mode 100644 index 000000000..f8bd940e4 --- /dev/null +++ b/plugins/node-team/skills/node/references/prometheus.md @@ -0,0 +1,35 @@ +# Prometheus on OpenShift/Kubernetes + +Query cluster metrics using `promtool`. Install: `brew install prometheus`. + +## Critical Rules + +These caused real failures — follow exactly. + +1. **Run setup + queries in a single bash call.** Shell variables (`$PROM_URL`, `$HTTP_CONFIG`, `$TOKEN`) don't persist across separate bash invocations. Combine with `&&`. + +2. **Never use `!=` in PromQL.** Zsh mangles `!=` into `\!=` via history expansion, even inside single quotes. Use `=~".+"` instead of `!=""`, and negated regex instead of `!=`. + +3. **JSON output is a raw array.** `promtool -o json` outputs `[{metric:{...}, value:[ts, val]}, ...]` — NOT `{data:{result:...}}`. Parse with `jq '.[]'`, not `jq '.data.result[]'`. + +4. **`oc whoami -t` may return empty AND exit non-zero.** Client-cert kubeconfigs have no session token. Always: `TOKEN=$(oc whoami -t 2>/dev/null || true)`, then check if empty and fall back to creating a service account token. + +5. **`promtool check healthy/ready` returns 503 on Thanos Querier.** Expected — Thanos doesn't expose `/-/healthy`. Test with `promtool query instant ... 'up'` instead. + +6. **Clean up temp files.** Always `rm -f "$HTTP_CONFIG"` and `kill $PF_PID 2>/dev/null` after queries. + +## OpenShift Setup Pattern + +All in one bash call: + +1. Get token: `oc whoami -t` or create SA `prometheus-reader` in `openshift-monitoring` with `cluster-monitoring-view` role, then `oc create token` +2. Get Thanos route: `oc -n openshift-monitoring get route thanos-querier -o jsonpath='{.status.ingress[].host}'` +3. Write HTTP config to temp file (Bearer token + `insecure_skip_verify: true`) +4. Run queries +5. Clean up temp file + +For vanilla Kubernetes: find the Prometheus service (`kubectl get svc -A | grep prometheus`), port-forward to 9090, no auth usually needed. + +## Cross-Platform Date + +macOS and Linux `date` differ. Use: `date -u -d '1 hour ago' +FMT 2>/dev/null || date -u -v-1H +FMT` diff --git a/plugins/node-team/skills/node/references/support.md b/plugins/node-team/skills/node/references/support.md new file mode 100644 index 000000000..95c9a0986 --- /dev/null +++ b/plugins/node-team/skills/node/references/support.md @@ -0,0 +1,34 @@ +# Red Hat Support: Knowledge Base & Cases + +## Authentication + +Both APIs use OAuth Bearer tokens. Get the offline token from keychain, exchange for access token: + +- Keychain key: `RH_API_OFFLINE_TOKEN` (macOS: `security find-generic-password -a "$USER" -s "RH_API_OFFLINE_TOKEN" -w`, Linux: `secret-tool lookup service redhat key RH_API_OFFLINE_TOKEN`) +- Token exchange: `POST https://sso.redhat.com/auth/realms/redhat-external/protocol/openid-connect/token` with `grant_type=refresh_token`, `client_id=rhsm-api`, `refresh_token=$OFFLINE_TOKEN` → extract `access_token` from response + +Always get a fresh token before each session. + +## Knowledge Base + +Endpoint: `GET https://access.redhat.com/hydra/rest/search/kcs` + +Key params: `q` (search terms, `+` joins), `rows`, `start` (pagination offset), `fq` (filter), `fl` (field list), `sort`. + +Useful `fq` filters: `documentKind:Solution`, `id:7087003` (fetch by ID), `boostProduct:openshift`. + +Solution-specific field names (Solr): `solution_resolution`, `solution_rootcause`, `solution_environment`, `solution_diagnosticsteps`, `issue`, `caseCount`. + +URL parsing: `https://access.redhat.com/solutions/7087003` → extract `7087003`, fetch with `fq=id:7087003`. + +## Support Cases + +Endpoint: `https://api.access.redhat.com/support/v1/cases/{caseNumber}` + +Comments: `GET .../comments`, Attachments: `GET .../attachments`, Search: `POST .../filter` with JSON body (`maxResults`, `offset`, `keyword`, `status`, `product`, `startDate`, `endDate`). + +Statuses: `Waiting on Red Hat`, `Waiting on Customer`, `Closed`. Severities: `1 (Urgent)` = production down, `2 (High)`, `3 (Normal)`, `4 (Low)`. + +URL parsing: `https://access.redhat.com/support/cases/#/case/04378910` → extract `04378910`. + +When Jira bugs have SFDC case links (`customfield_12313441` or `customfield_10979`), look up each referenced case number. From 202d9c498c55f55b06281276afd9bb31a3919698 Mon Sep 17 00:00:00 2001 From: Harshal Patil <12152047+harche@users.noreply.github.com> Date: Sat, 9 May 2026 15:52:13 -0400 Subject: [PATCH 2/2] Replace node-team scripts with reference docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove ~4,900 lines of bash/Python scripts and replace with self-contained reference documentation that teaches Claude how to interact with Jira REST API, manage worktrees, and understand team structure directly — no wrapper scripts needed. - Delete all scripts/ (jira.sh, worktree.sh, ocp-install.sh, all API/composite/utility modules) - Rewrite jira.md as complete API reference (auth, endpoints, ADF format, custom fields, JQL recipes, triage definitions) - Update worktrees.md with native git worktree commands - Update SKILL.md to remove scripts tool permission - Bump version to 0.12.0 Co-Authored-By: Claude Opus 4.6 (1M context) --- plugins/node-team/.claude-plugin/plugin.json | 2 +- plugins/node-team/scripts/jira.sh | 122 --- plugins/node-team/scripts/lib/api/comment.sh | 40 - plugins/node-team/scripts/lib/api/fields.sh | 85 -- plugins/node-team/scripts/lib/api/health.sh | 97 --- plugins/node-team/scripts/lib/api/issue.sh | 147 ---- plugins/node-team/scripts/lib/api/sprint.sh | 41 - .../node-team/scripts/lib/api/transition.sh | 70 -- .../scripts/lib/composite/bug-overview.sh | 145 ---- .../scripts/lib/composite/carryover-report.sh | 91 -- .../scripts/lib/composite/epic-progress.sh | 177 ---- .../scripts/lib/composite/issue-deep-dive.sh | 110 --- .../scripts/lib/composite/my-board-data.sh | 120 --- .../scripts/lib/composite/my-bugs-data.sh | 72 -- .../scripts/lib/composite/my-standup-data.sh | 152 ---- .../scripts/lib/composite/pickup-data.sh | 75 -- .../scripts/lib/composite/planning-data.sh | 105 --- .../scripts/lib/composite/release-data.sh | 111 --- .../scripts/lib/composite/roster-sync.sh | 89 -- .../scripts/lib/composite/sprint-dashboard.sh | 187 ---- .../scripts/lib/composite/standup-data.sh | 295 ------- .../scripts/lib/composite/team-activity.sh | 151 ---- plugins/node-team/scripts/lib/core.sh | 88 -- plugins/node-team/scripts/lib/team.sh | 295 ------- plugins/node-team/scripts/lib/util/adf.py | 158 ---- plugins/node-team/scripts/lib/util/cache.sh | 66 -- .../node-team/scripts/lib/util/parallel.sh | 128 --- plugins/node-team/scripts/lib/util/retry.sh | 99 --- plugins/node-team/scripts/ocp-install.sh | 810 ------------------ plugins/node-team/scripts/worktree.sh | 351 -------- plugins/node-team/scripts/worktree_test.sh | 270 ------ plugins/node-team/skills/node/SKILL.md | 2 +- .../node-team/skills/node/references/SETUP.md | 4 +- .../node/references/development/worktrees.md | 66 +- .../node-team/skills/node/references/jira.md | 243 +++--- 35 files changed, 160 insertions(+), 4904 deletions(-) delete mode 100755 plugins/node-team/scripts/jira.sh delete mode 100644 plugins/node-team/scripts/lib/api/comment.sh delete mode 100644 plugins/node-team/scripts/lib/api/fields.sh delete mode 100644 plugins/node-team/scripts/lib/api/health.sh delete mode 100644 plugins/node-team/scripts/lib/api/issue.sh delete mode 100644 plugins/node-team/scripts/lib/api/sprint.sh delete mode 100644 plugins/node-team/scripts/lib/api/transition.sh delete mode 100644 plugins/node-team/scripts/lib/composite/bug-overview.sh delete mode 100644 plugins/node-team/scripts/lib/composite/carryover-report.sh delete mode 100644 plugins/node-team/scripts/lib/composite/epic-progress.sh delete mode 100644 plugins/node-team/scripts/lib/composite/issue-deep-dive.sh delete mode 100644 plugins/node-team/scripts/lib/composite/my-board-data.sh delete mode 100644 plugins/node-team/scripts/lib/composite/my-bugs-data.sh delete mode 100644 plugins/node-team/scripts/lib/composite/my-standup-data.sh delete mode 100644 plugins/node-team/scripts/lib/composite/pickup-data.sh delete mode 100644 plugins/node-team/scripts/lib/composite/planning-data.sh delete mode 100644 plugins/node-team/scripts/lib/composite/release-data.sh delete mode 100644 plugins/node-team/scripts/lib/composite/roster-sync.sh delete mode 100644 plugins/node-team/scripts/lib/composite/sprint-dashboard.sh delete mode 100644 plugins/node-team/scripts/lib/composite/standup-data.sh delete mode 100644 plugins/node-team/scripts/lib/composite/team-activity.sh delete mode 100644 plugins/node-team/scripts/lib/core.sh delete mode 100644 plugins/node-team/scripts/lib/team.sh delete mode 100644 plugins/node-team/scripts/lib/util/adf.py delete mode 100644 plugins/node-team/scripts/lib/util/cache.sh delete mode 100644 plugins/node-team/scripts/lib/util/parallel.sh delete mode 100644 plugins/node-team/scripts/lib/util/retry.sh delete mode 100755 plugins/node-team/scripts/ocp-install.sh delete mode 100755 plugins/node-team/scripts/worktree.sh delete mode 100755 plugins/node-team/scripts/worktree_test.sh diff --git a/plugins/node-team/.claude-plugin/plugin.json b/plugins/node-team/.claude-plugin/plugin.json index 68db738bb..a42ce30ef 100644 --- a/plugins/node-team/.claude-plugin/plugin.json +++ b/plugins/node-team/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "node-team", "description": "OpenShift Node team assistant for development, deployment, debugging, and workflow tasks across kubelet, MCO, CRI-O, crun, conmonrs, Kueue operator, Jira, Red Hat KB/support cases, Prometheus, and platform docs.", - "version": "0.11.0", + "version": "0.12.0", "author": { "name": "github.com/openshift-eng" } diff --git a/plugins/node-team/scripts/jira.sh b/plugins/node-team/scripts/jira.sh deleted file mode 100755 index 6e432472d..000000000 --- a/plugins/node-team/scripts/jira.sh +++ /dev/null @@ -1,122 +0,0 @@ -#!/bin/bash -# Jira CLI — thin dispatcher -# Sources modular libraries from lib/ and dispatches subcommands -# All existing commands are backward-compatible; composite commands are additive. - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# ── Load modules ─────────────────────────────────────────────────────────────── - -source "${SCRIPT_DIR}/lib/core.sh" -source "${SCRIPT_DIR}/lib/api/issue.sh" -source "${SCRIPT_DIR}/lib/api/sprint.sh" -source "${SCRIPT_DIR}/lib/api/comment.sh" -source "${SCRIPT_DIR}/lib/api/transition.sh" -source "${SCRIPT_DIR}/lib/api/fields.sh" -source "${SCRIPT_DIR}/lib/api/health.sh" - -# Load utilities if available -[[ -f "${SCRIPT_DIR}/lib/util/retry.sh" ]] && source "${SCRIPT_DIR}/lib/util/retry.sh" -[[ -f "${SCRIPT_DIR}/lib/util/parallel.sh" ]] && source "${SCRIPT_DIR}/lib/util/parallel.sh" -[[ -f "${SCRIPT_DIR}/lib/util/cache.sh" ]] && source "${SCRIPT_DIR}/lib/util/cache.sh" -[[ -f "${SCRIPT_DIR}/lib/team.sh" ]] && source "${SCRIPT_DIR}/lib/team.sh" - -# Load composite commands if available -for f in "${SCRIPT_DIR}"/lib/composite/*.sh; do - [[ -f "$f" ]] && source "$f" -done - -# ── Help ─────────────────────────────────────────────────────────────────────── - -cmd_help() { - cat <<'EOF' -Usage: jira.sh [args] - -Low-level API commands: - search [limit] Search issues (default limit: 50) - get Get full issue details - sprints [state] List sprints (active|future|closed) - sprint-issues [limit] Get issues in a sprint (default limit: 100) - comments List comments on an issue - comment Add a comment to one or more issues - move-to-sprint Move issue(s) to a sprint - set-points Set story points on an issue - set-field Set any field (value: string, number, or JSON) - create [extra-fields-json] Create a new issue - assign Assign issue (resolves name via roster + Jira API) - find-user Search for a user by name (roster + Jira API) - link [title] Add a remote link (e.g., GitHub PR/issue) - transitions Get available transitions - transition Perform a transition on one or more issues - close [comment] Comment (optional) + close one or more issues - start-sprint Start a sprint (set state to active) - close-sprint Close a sprint (set state to closed) - health-check Validate custom field IDs against Jira metadata - -High-level composite commands: - sprint-dashboard Sprint info + issues by status + workload + blockers - standup-data Dashboard + recent updates + new bugs + comments - bug-overview Bug triage data (untriaged, unassigned, blockers, new) - carryover-report Not-done items with carryover context - planning-data Full planning package (carryovers + backlog + bugs) - issue-deep-dive Full issue + comments (ADF converted) + linked issues - release-data [version] Release readiness (blockers, bugs, epics) - team-activity Per-member sprint items + comment counts - roster-sync [--force] Download team rosters from Jira attachments - -Options: - --sprint Target a specific sprint (default: active) - --stream Stream JSON Lines output (composite commands only) - -Environment: - JIRA_EMAIL Override Jira email (default: Keychain account or git config user.email) - JIRA_BOARD_ID Override board ID (default: 7845) - NODE_ASSISTANT_CONFIG_ISSUE Jira issue with roster attachments (default: OCPNODE-4230) -EOF -} - -# ── Dispatch ─────────────────────────────────────────────────────────────────── - -case "${1:-help}" in - # Low-level API commands (backward-compatible) - search) cmd_search "${2:?JQL required}" "${3:-50}" ;; - get) cmd_get "${2:?ISSUE-KEY required}" ;; - sprints) cmd_sprints "${2:-active}" ;; - sprint-issues) cmd_sprint_issues "${2:?Sprint ID required}" "${3:-100}" ;; - comments) cmd_comments "${2:?ISSUE-KEY required}" ;; - comment) cmd_comment "${2:?Comment body required}" "${@:3}" ;; - move-to-sprint) cmd_move_to_sprint "${2:?Sprint ID required}" "${@:3}" ;; - set-points) cmd_set_points "${2:?ISSUE-KEY required}" "${3:?Story points required}" ;; - set-field) cmd_set_field "${2:?ISSUE-KEY required}" "${3:?Field ID required}" "${4:?Value required}" ;; - create) cmd_create "${2:?Project key required}" "${3:?Issue type required}" "${4:?Summary required}" "${5:-}" ;; - assign) cmd_assign "${2:?ISSUE-KEY required}" "${3:?Assignee name or accountId required}" ;; - find-user) cmd_find_user "${2:?Search query required}" ;; - link) cmd_link "${2:?ISSUE-KEY required}" "${3:?URL required}" "${4:-}" ;; - transitions) cmd_transitions "${2:?ISSUE-KEY required}" ;; - transition) cmd_transition "${2:?Transition ID required}" "${@:3}" ;; - close) cmd_close "${@:2}" ;; - start-sprint) cmd_start_sprint "${2:?Sprint ID required}" ;; - close-sprint) cmd_close_sprint "${2:?Sprint ID required}" ;; - health-check) cmd_health_check ;; - - # High-level composite commands - sprint-dashboard) cmd_sprint_dashboard "${@:2}" ;; - standup-data) cmd_standup_data "${@:2}" ;; - bug-overview) cmd_bug_overview "${@:2}" ;; - carryover-report) cmd_carryover_report "${@:2}" ;; - planning-data) cmd_planning_data "${@:2}" ;; - issue-deep-dive) cmd_issue_deep_dive "${@:2}" ;; - release-data) cmd_release_data "${@:2}" ;; - team-activity) cmd_team_activity "${@:2}" ;; - roster-sync) cmd_roster_sync "${@:2}" ;; - my-board-data) cmd_my_board_data "${@:2}" ;; - my-bugs-data) cmd_my_bugs_data "${@:2}" ;; - my-standup-data) cmd_my_standup_data "${@:2}" ;; - epic-progress) cmd_epic_progress "${@:2}" ;; - pickup-data) cmd_pickup_data "${@:2}" ;; - - help|--help|-h) cmd_help ;; - *) echo "Unknown command: $1" >&2; cmd_help >&2; exit 1 ;; -esac diff --git a/plugins/node-team/scripts/lib/api/comment.sh b/plugins/node-team/scripts/lib/api/comment.sh deleted file mode 100644 index 077efe9c0..000000000 --- a/plugins/node-team/scripts/lib/api/comment.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/bin/bash -# API commands: list and add comments -# Sourced by jira.sh — requires core.sh - -[[ -n "${_API_COMMENT_LOADED:-}" ]] && return 0 -_API_COMMENT_LOADED=1 - -cmd_comments() { - local key="$1" - _curl "${JIRA_BASE}/rest/api/3/issue/${key}/comment" -} - -cmd_comment() { - local body="$1" - shift - local keys=("$@") - local payload - payload=$(python3 -c " -import json, sys -body = sys.argv[1] -print(json.dumps({ - 'body': { - 'version': 1, - 'type': 'doc', - 'content': [{'type': 'paragraph', 'content': [{'type': 'text', 'text': body}]}] - } -})) -" "$body") - for key in "${keys[@]}"; do - local result - result=$(_curl -X POST "${JIRA_BASE}/rest/api/3/issue/${key}/comment" -d "$payload" -w "\nHTTP_%{http_code}" 2>&1) - local code - code=$(echo "$result" | grep "HTTP_" | sed 's/HTTP_//') - if [[ "$code" == "201" ]]; then - echo "{\"key\":\"${key}\",\"status\":\"ok\"}" - else - echo "{\"key\":\"${key}\",\"status\":\"error\",\"code\":\"${code}\"}" >&2 - fi - done -} diff --git a/plugins/node-team/scripts/lib/api/fields.sh b/plugins/node-team/scripts/lib/api/fields.sh deleted file mode 100644 index a6a958cc7..000000000 --- a/plugins/node-team/scripts/lib/api/fields.sh +++ /dev/null @@ -1,85 +0,0 @@ -#!/bin/bash -# API commands: set fields, set story points, move to sprint -# Sourced by jira.sh — requires core.sh - -[[ -n "${_API_FIELDS_LOADED:-}" ]] && return 0 -_API_FIELDS_LOADED=1 - -cmd_set_points() { - local key="$1" - local points="$2" - _curl -X PUT "${JIRA_BASE}/rest/api/3/issue/${key}" \ - -d "{\"fields\": {\"${CF_STORY_POINTS}\": ${points}}}" -} - -cmd_set_field() { - local key="$1" - local field="$2" - local value="$3" - local payload - payload=$(python3 -c " -import json, sys -key, field, value = sys.argv[1], sys.argv[2], sys.argv[3] -# Try parsing as JSON first (for arrays, objects, numbers, booleans) -try: - parsed = json.loads(value) -except (json.JSONDecodeError, ValueError): - parsed = value # plain string -print(json.dumps({'fields': {field: parsed}})) -" "$key" "$field" "$value") - local result - result=$(_curl -X PUT "${JIRA_BASE}/rest/api/3/issue/${key}" -d "$payload" -w "\nHTTP_%{http_code}") - local code - code=$(echo "$result" | grep "HTTP_" | sed 's/HTTP_//') - if [[ "$code" == "204" ]]; then - echo "{\"key\":\"${key}\",\"field\":\"${field}\",\"status\":\"ok\"}" - else - local body - body=$(echo "$result" | grep -v "HTTP_") - echo "{\"key\":\"${key}\",\"field\":\"${field}\",\"status\":\"error\",\"code\":\"${code}\",\"response\":${body:-\"{}\"}}" >&2 - return 1 - fi -} - -cmd_link() { - local key="$1" - local url="$2" - local title="${3:-$url}" - local payload - payload=$(python3 -c " -import json, sys -url, title = sys.argv[1], sys.argv[2] -# Auto-detect icon for GitHub URLs -icon = {} -if 'github.com' in url: - icon = {'url16x16': 'https://github.com/favicon.ico', 'title': 'GitHub'} -print(json.dumps({ - 'object': { - 'url': url, - 'title': title, - 'icon': icon - } -})) -" "$url" "$title") - local result - result=$(_curl -X POST "${JIRA_BASE}/rest/api/3/issue/${key}/remotelink" -d "$payload" -w "\nHTTP_%{http_code}") - local code - code=$(echo "$result" | grep "HTTP_" | sed 's/HTTP_//') - if [[ "$code" == "200" || "$code" == "201" ]]; then - echo "{\"key\":\"${key}\",\"url\":\"${url}\",\"status\":\"ok\"}" - else - local body - body=$(echo "$result" | grep -v "HTTP_") - echo "{\"key\":\"${key}\",\"url\":\"${url}\",\"status\":\"error\",\"code\":\"${code}\",\"response\":${body:-\"{}\"}}" >&2 - return 1 - fi -} - -cmd_move_to_sprint() { - local sprint_id="$1" - shift - local issues=("$@") - local json_issues - json_issues=$(python3 -c "import json,sys; print(json.dumps(sys.argv[1:]))" "${issues[@]}") - _curl -X POST "${JIRA_BASE}/rest/agile/1.0/sprint/${sprint_id}/issue" -d "{\"issues\": ${json_issues}}" -} diff --git a/plugins/node-team/scripts/lib/api/health.sh b/plugins/node-team/scripts/lib/api/health.sh deleted file mode 100644 index d35973b39..000000000 --- a/plugins/node-team/scripts/lib/api/health.sh +++ /dev/null @@ -1,97 +0,0 @@ -#!/bin/bash -# API commands: health check — validates field metadata against Jira -# Sourced by jira.sh — requires core.sh - -[[ -n "${_API_HEALTH_LOADED:-}" ]] && return 0 -_API_HEALTH_LOADED=1 - -cmd_health_check() { - _init_auth - - # Fetch all field definitions from Jira (1 API call) - local fields_json - fields_json=$(_curl "${JIRA_BASE}/rest/api/3/field") - - # Validate our custom field IDs against actual Jira metadata - python3 - "$fields_json" < 0 - -print(json.dumps({ - "status": "HEALTHY" if errors == 0 and warnings == 0 else "DEGRADED" if errors == 0 else "UNHEALTHY", - "summary": { - "fieldsChecked": len(expected), - "errors": errors, - "warnings": warnings, - "totalJiraFields": len(fields_data), - "apiConnectivity": api_ok, - }, - "fields": results, - "missingStandardFields": missing_standard, -}, indent=2)) -PYEOF -} diff --git a/plugins/node-team/scripts/lib/api/issue.sh b/plugins/node-team/scripts/lib/api/issue.sh deleted file mode 100644 index 1725afe8a..000000000 --- a/plugins/node-team/scripts/lib/api/issue.sh +++ /dev/null @@ -1,147 +0,0 @@ -#!/bin/bash -# API commands: issue search and get -# Sourced by jira.sh — requires core.sh - -[[ -n "${_API_ISSUE_LOADED:-}" ]] && return 0 -_API_ISSUE_LOADED=1 - -cmd_search() { - local jql="$1" - local limit="${2:-50}" - local fields_json="${3:-$SEARCH_FIELDS_JSON}" - local payload - payload=$(python3 -c " -import json, sys -print(json.dumps({ - 'jql': sys.argv[1], - 'maxResults': int(sys.argv[2]), - 'fields': json.loads(sys.argv[3]) -})) -" "$jql" "$limit" "$fields_json") - _curl -X POST "${JIRA_BASE}/rest/api/3/search/jql" -d "$payload" -} - -cmd_get() { - local key="$1" - local fields="${2:-}" - if [[ -n "$fields" ]]; then - _curl "${JIRA_BASE}/rest/api/3/issue/${key}?fields=${fields}" - else - _curl "${JIRA_BASE}/rest/api/3/issue/${key}" - fi -} - -cmd_create() { - local project="$1" - local issuetype="$2" - local summary="$3" - local extra_fields="${4:-}" - local payload - payload=$(python3 -c " -import json, sys -project, issuetype, summary = sys.argv[1], sys.argv[2], sys.argv[3] -extra = sys.argv[4] if len(sys.argv) > 4 and sys.argv[4] else '{}' -try: - extra_parsed = json.loads(extra) -except (json.JSONDecodeError, ValueError): - extra_parsed = {} -fields = { - 'project': {'key': project}, - 'issuetype': {'name': issuetype}, - 'summary': summary, -} -fields.update(extra_parsed) -print(json.dumps({'fields': fields})) -" "$project" "$issuetype" "$summary" "$extra_fields") - _curl -X POST "${JIRA_BASE}/rest/api/3/issue" -d "$payload" -} - -cmd_find_user() { - local query="$1" - local root_dir - root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../" && pwd)" - python3 -c " -import json, sys, os, glob - -query = sys.argv[1].lower() -root_dir = sys.argv[2] -na_dir = os.path.expanduser('~/.node-assistant') - -# Search roster files first -roster_files = [] -for d in [na_dir, os.path.join(root_dir, 'config')]: - roster_files.extend(glob.glob(os.path.join(d, 'team-roster-*.json'))) - -for rf in roster_files: - try: - with open(rf) as f: - data = json.load(f) - for name, github in data.get('members', {}).items(): - if query in name.lower(): - print(json.dumps({'displayName': name, 'github': github, 'source': os.path.basename(rf)})) - except Exception: - continue -" "$query" "$root_dir" - - # Also search via Jira API - local encoded - encoded=$(python3 -c "import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=''))" "$query") - local results - results=$(_curl "${JIRA_BASE}/rest/api/3/user/search?query=${encoded}&maxResults=5" 2>/dev/null) || true - if [[ -n "$results" && "$results" != "[]" ]]; then - echo "$results" | python3 -c " -import json, sys -users = json.load(sys.stdin) -for u in users: - if u.get('accountType') == 'atlassian': - print(json.dumps({'accountId': u['accountId'], 'displayName': u.get('displayName', ''), 'email': u.get('emailAddress', ''), 'source': 'jira-api'})) -" 2>/dev/null || true - fi -} - -cmd_assign() { - local key="$1" - local assignee="$2" - - local account_id="" - - # If it looks like an accountId already (contains colon), use directly - if [[ "$assignee" == *":"* ]]; then - account_id="$assignee" - else - # Try to resolve via find-user - local matches - matches=$(cmd_find_user "$assignee" 2>/dev/null) - if [[ -n "$matches" ]]; then - # Prefer Jira API result (has accountId) - account_id=$(echo "$matches" | python3 -c " -import json, sys -lines = [l.strip() for l in sys.stdin if l.strip()] -for l in lines: - d = json.loads(l) - if 'accountId' in d: - print(d['accountId']) - sys.exit(0) -print('') -" 2>/dev/null) - fi - if [[ -z "$account_id" ]]; then - echo "{\"error\":\"Could not resolve user '${assignee}'. Try jira.sh find-user '${assignee}' to search.\"}" >&2 - return 1 - fi - fi - - local result - result=$(_curl -X PUT "${JIRA_BASE}/rest/api/3/issue/${key}/assignee" \ - -d "{\"accountId\": \"${account_id}\"}" -w "\nHTTP_%{http_code}") - local code - code=$(echo "$result" | grep "HTTP_" | sed 's/HTTP_//') - if [[ "$code" == "204" ]]; then - echo "{\"key\":\"${key}\",\"assignee\":\"${assignee}\",\"accountId\":\"${account_id}\",\"status\":\"ok\"}" - else - local body - body=$(echo "$result" | grep -v "HTTP_") - echo "{\"key\":\"${key}\",\"status\":\"error\",\"code\":\"${code}\",\"response\":${body:-\"{}\"}}" >&2 - return 1 - fi -} diff --git a/plugins/node-team/scripts/lib/api/sprint.sh b/plugins/node-team/scripts/lib/api/sprint.sh deleted file mode 100644 index c5b19ab4f..000000000 --- a/plugins/node-team/scripts/lib/api/sprint.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/bin/bash -# API commands: sprint discovery and sprint issues -# Sourced by jira.sh — requires core.sh - -[[ -n "${_API_SPRINT_LOADED:-}" ]] && return 0 -_API_SPRINT_LOADED=1 - -cmd_sprints() { - local state="${1:-active}" - local result - result=$(_curl "${JIRA_BASE}/rest/agile/1.0/board/${BOARD_ID}/sprint?state=${state}&maxResults=50") - # Filter to Node-related sprints only - python3 - "$result" <<'PYEOF' -import sys, json -data = json.loads(sys.argv[1]) -sprints = [] -for s in data.get('values', []): - name = s.get('name', '') - if 'Node' in name or 'Kueue' in name: - sprints.append(s) -sprints.sort(key=lambda x: x.get('startDate', ''), reverse=True) -print(json.dumps({'values': sprints})) -PYEOF -} - -cmd_sprint_issues() { - local sprint_id="$1" - local limit="${2:-100}" - local fields="${3:-$ISSUE_FIELDS}" - _curl "${JIRA_BASE}/rest/agile/1.0/sprint/${sprint_id}/issue?maxResults=${limit}&fields=${fields}" -} - -cmd_start_sprint() { - local sprint_id="${1:?Sprint ID required}" - _curl -X POST -d '{"state":"active"}' "${JIRA_BASE}/rest/agile/1.0/sprint/${sprint_id}" -} - -cmd_close_sprint() { - local sprint_id="${1:?Sprint ID required}" - _curl -X POST -d '{"state":"closed"}' "${JIRA_BASE}/rest/agile/1.0/sprint/${sprint_id}" -} diff --git a/plugins/node-team/scripts/lib/api/transition.sh b/plugins/node-team/scripts/lib/api/transition.sh deleted file mode 100644 index 5507001a1..000000000 --- a/plugins/node-team/scripts/lib/api/transition.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/bin/bash -# API commands: transitions, transition, close -# Sourced by jira.sh — requires core.sh - -[[ -n "${_API_TRANSITION_LOADED:-}" ]] && return 0 -_API_TRANSITION_LOADED=1 - -cmd_transitions() { - local key="$1" - _curl "${JIRA_BASE}/rest/api/3/issue/${key}/transitions" -} - -cmd_transition() { - local transition_id="$1" - shift - local keys=("$@") - for key in "${keys[@]}"; do - local result - result=$(_curl -X POST "${JIRA_BASE}/rest/api/3/issue/${key}/transitions" \ - -d "{\"transition\":{\"id\":\"${transition_id}\"}}" -w "\nHTTP_%{http_code}" 2>&1) - local code - code=$(echo "$result" | grep "HTTP_" | sed 's/HTTP_//') - if [[ "$code" == "204" ]]; then - echo "{\"key\":\"${key}\",\"status\":\"ok\"}" - else - echo "{\"key\":\"${key}\",\"status\":\"error\",\"code\":\"${code}\"}" >&2 - fi - done -} - -cmd_close() { - local comment="" - local keys=() - # First arg is optional comment (if it doesn't look like an issue key) - if [[ $# -ge 1 && ! "$1" =~ ^[A-Z]+-[0-9]+$ ]]; then - comment="$1" - shift - fi - keys=("$@") - if [[ ${#keys[@]} -eq 0 ]]; then - echo '{"error":"No issue keys provided"}' >&2 - return 1 - fi - for key in "${keys[@]}"; do - if [[ -n "$comment" ]]; then - cmd_comment "$comment" "$key" > /dev/null - fi - # Resolve close transition dynamically per issue - local close_id="${JIRA_CLOSE_TRANSITION_ID:-}" - if [[ -z "$close_id" ]]; then - local transitions_json - transitions_json=$(cmd_transitions "$key") || { echo "{\"key\":\"${key}\",\"status\":\"error\",\"cause\":\"failed to fetch transitions\"}" >&2; continue; } - close_id=$(echo "$transitions_json" | python3 -c " -import json, sys -data = json.load(sys.stdin) -for name in ['Closed', 'Close', 'Done']: - for t in data.get('transitions', []): - if t.get('name', '').lower() == name.lower(): - print(t['id']) - sys.exit(0) -print('') -" 2>/dev/null) - if [[ -z "$close_id" ]]; then - echo "{\"key\":\"${key}\",\"status\":\"error\",\"cause\":\"no Close/Closed/Done transition found\"}" >&2 - continue - fi - fi - cmd_transition "$close_id" "$key" - done -} diff --git a/plugins/node-team/scripts/lib/composite/bug-overview.sh b/plugins/node-team/scripts/lib/composite/bug-overview.sh deleted file mode 100644 index 7d99311eb..000000000 --- a/plugins/node-team/scripts/lib/composite/bug-overview.sh +++ /dev/null @@ -1,145 +0,0 @@ -#!/bin/bash -# Composite: bug-overview -# Fetches all open bugs in 3 queries (was 7), categorizes in Python -# Serves: /bug-triage, /my-bugs - -[[ -n "${_COMPOSITE_BUG_OVERVIEW_LOADED:-}" ]] && return 0 -_COMPOSITE_BUG_OVERVIEW_LOADED=1 - -cmd_bug_overview() { - local team="${1:?Team required (e.g., 'Node Devices' or 'Node Core')}" - - # ── Resolve team config ────────────────────────────────────────────────── - team_config "$team" - - local comp_filter="component in (${TEAM_BUG_COMPONENTS})" - - # ── Build assignee filter from roster ──────────────────────────────────── - local roster_json - roster_json=$(team_roster "$team") - local assignee_emails - assignee_emails=$(echo "$roster_json" | python3 -c " -import json, sys -members = json.load(sys.stdin) -names = [m['name'] for m in members] -clauses = ' OR '.join(f'assignee = \"{n}\"' for n in names) -print(clauses) -") - - # ── Extended fields ── - local bug_fields="[\"key\",\"summary\",\"status\",\"assignee\",\"priority\",\"issuetype\",\"fixVersions\",\"components\",\"${CF_STORY_POINTS}\",\"${CF_RELEASE_BLOCKER}\"]" - - # ── 4 queries: all_open + new_this_week + team_no_component + escalation-labeled - parallel_init - - parallel_run "all_open" cmd_search \ - "project = OCPBUGS AND ${comp_filter} AND status not in (Closed, Done, Verified) ORDER BY priority ASC, created DESC" 200 "$bug_fields" - - # New bugs this week (includes closed, so can't merge with all_open) - parallel_run "new_this_week" cmd_search \ - "project = OCPBUGS AND ${comp_filter} AND created >= -7d ORDER BY created DESC" 50 "$bug_fields" - - # Bugs assigned to team members outside ALL Node components - parallel_run "team_no_component" cmd_search \ - "project = OCPBUGS AND (${assignee_emails}) AND (component is EMPTY OR component not in (${ALL_NODE_COMPONENTS})) AND status not in (Closed, Done, Verified) ORDER BY priority ASC, created DESC" 50 "$bug_fields" - - - parallel_wait_all || true - - # ── Assemble results — categorize from all_open in Python ──────────────── - python3 - \ - "$(parallel_get all_open)" \ - "$(parallel_get new_this_week)" \ - "$(parallel_get team_no_component)" \ - "$roster_json" \ - <<'PYEOF' -import json, sys - -def extract_bugs(data_str): - data = json.loads(data_str) - bugs = [] - for i in data.get("issues", []): - f = i.get("fields", {}) - components = [c.get("name", "") for c in (f.get("components") or [])] - rb = f.get("customfield_10847") - bugs.append({ - "key": i.get("key", ""), - "summary": f.get("summary", ""), - "status": f.get("status", {}).get("name", ""), - "priority": f.get("priority", {}).get("name", ""), - "assignee": (f.get("assignee") or {}).get("displayName", "Unassigned"), - "points": f.get("customfield_10028") or 0, - "releaseBlocker": rb, - "fixVersions": [v.get("name", "") for v in (f.get("fixVersions") or [])], - "components": components, - }) - return bugs - -all_open = extract_bugs(sys.argv[1]) -new_this_week = extract_bugs(sys.argv[2]) -missing_component = extract_bugs(sys.argv[3]) - -# Build roster name set for CVE filtering -roster_names = {m["name"] for m in json.loads(sys.argv[4])} - -# Filter out CVE bugs that are ASSIGNED to non-roster members (handled by other teams) -def is_external_cve(b): - return ("CVE" in b["summary"].upper() - and b["status"] == "ASSIGNED" - and b["assignee"] not in roster_names - and b["assignee"] != "Unassigned") - -excluded_cves = [b for b in all_open if is_external_cve(b)] -all_open = [b for b in all_open if not is_external_cve(b)] -new_this_week = [b for b in new_this_week if not is_external_cve(b)] -missing_component = [b for b in missing_component if not is_external_cve(b)] - -# Shape assertions — warn if field formats have changed -for b in all_open[:5]: # spot-check first 5 - rb = b.get("releaseBlocker") - if rb is not None and not isinstance(rb, dict): - print(f"SHAPE WARNING: releaseBlocker is {type(rb).__name__}, expected dict or None " - f"(on {b['key']}). Blocker categorization may be broken.", file=sys.stderr) - break - -# Categorize from all_open (was 4 separate JQL queries) -# Bot account is the default assignee — treat as effectively unassigned -BOT_ACCOUNTS = {"Node Team Bot Account"} -untriaged = [b for b in all_open if b["priority"] in ("Undefined", "Unprioritized")] -unassigned = [b for b in all_open if b["assignee"] in ({"Unassigned"} | BOT_ACCOUNTS)] -blocker_proposals = [b for b in all_open - if isinstance(b.get("releaseBlocker"), dict) - and b["releaseBlocker"].get("value") == "Proposed"] -# Canary: if we have bugs but nothing categorized, field formats may have changed -if len(all_open) > 10 and (len(untriaged) + len(unassigned) + len(blocker_proposals)) == 0: - print(f"CANARY: {len(all_open)} open bugs but 0 categorized. " - f"Check releaseBlocker (customfield_10847), priority values.", file=sys.stderr) - -# Merge missing-component bugs into allOpen (deduplicated) -all_open_keys = {b["key"] for b in all_open} -for b in missing_component: - if b["key"] not in all_open_keys: - all_open.append(b) - all_open_keys.add(b["key"]) - -result = { - "summary": { - "totalOpen": len(all_open), - "untriaged": len(untriaged), - "unassigned": len(unassigned), - "blockerProposals": len(blocker_proposals), - "newThisWeek": len(new_this_week), - "missingComponent": len(missing_component), - "excludedExternalCVEs": len(excluded_cves), - }, - "untriaged": untriaged, - "unassigned": unassigned, - "blockerProposals": blocker_proposals, - "newThisWeek": new_this_week, - "missingComponent": missing_component, - "allOpen": all_open, -} - -print(json.dumps(result)) -PYEOF -} diff --git a/plugins/node-team/scripts/lib/composite/carryover-report.sh b/plugins/node-team/scripts/lib/composite/carryover-report.sh deleted file mode 100644 index d1d791d1d..000000000 --- a/plugins/node-team/scripts/lib/composite/carryover-report.sh +++ /dev/null @@ -1,91 +0,0 @@ -#!/bin/bash -# Composite: carryover-report -# Returns not-done items with carryover context -# Serves: /carryovers - -[[ -n "${_COMPOSITE_CARRYOVER_LOADED:-}" ]] && return 0 -_COMPOSITE_CARRYOVER_LOADED=1 - -cmd_carryover_report() { - local team="${1:?Team required}" - - team_config "$team" - - # Get active (or last closed) and future sprint info in parallel - parallel_init - parallel_run "active_sprint" team_sprint_fallback "$team" - parallel_run "future_sprint" bash -c "source '${SCRIPT_DIR}/lib/core.sh'; source '${SCRIPT_DIR}/lib/api/sprint.sh'; source '${SCRIPT_DIR}/lib/team.sh'; result=\$(team_sprint '$team' future 2>/dev/null) && echo \"\$result\" || echo '{\"error\":\"No future sprint\"}'" - parallel_wait_all || true - - local active_sprint future_sprint - active_sprint=$(parallel_get "active_sprint") - future_sprint=$(parallel_get "future_sprint") - - local sprint_id - sprint_id=$(echo "$active_sprint" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))") - - # Fetch sprint issues - local issues_json - issues_json=$(cmd_sprint_issues "$sprint_id") - - python3 - "$active_sprint" "$future_sprint" "$issues_json" <<'PYEOF' -import json, sys -from datetime import datetime, timezone - -sprint = json.loads(sys.argv[1]) -future = json.loads(sys.argv[2]) -data = json.loads(sys.argv[3]) -issues = data.get("issues", []) - -carryovers = [] -done_items = [] - -for issue in issues: - f = issue.get("fields", {}) - status_cat = f.get("status", {}).get("statusCategory", {}).get("key", "") - key = issue.get("key", "") - summary = f.get("summary", "") - status_name = f.get("status", {}).get("name", "") - assignee = (f.get("assignee") or {}).get("displayName", "Unassigned") - points = f.get("customfield_10028") or 0 - issue_type = f.get("issuetype", {}).get("name", "") - blocked = (f.get("customfield_10517") or {}).get("value", "False") == "True" - - item = { - "key": key, "summary": summary, "status": status_name, - "assignee": assignee, "points": points, "type": issue_type, - "blocked": blocked, - } - - if status_cat == "done": - done_items.append(item) - else: - # Count how many sprints this has been in - sprints_in = len([s for s in (f.get("customfield_10020") or []) if s.get("state") == "closed"]) - item["previousSprints"] = sprints_in - carryovers.append(item) - -# Stats -by_assignee = {} -for c in carryovers: - a = c["assignee"] - by_assignee.setdefault(a, []).append(c["key"]) - -result = { - "activeSprint": sprint, - "futureSprint": future if "error" not in future else None, - "carryovers": carryovers, - "doneItems": done_items, - "stats": { - "totalItems": len(issues), - "doneCount": len(done_items), - "carryoverCount": len(carryovers), - "carryoverPoints": sum(c["points"] for c in carryovers), - "donePoints": sum(d["points"] for d in done_items), - "byAssignee": {a: len(keys) for a, keys in by_assignee.items()}, - }, -} - -print(json.dumps(result)) -PYEOF -} diff --git a/plugins/node-team/scripts/lib/composite/epic-progress.sh b/plugins/node-team/scripts/lib/composite/epic-progress.sh deleted file mode 100644 index 25a5b0e66..000000000 --- a/plugins/node-team/scripts/lib/composite/epic-progress.sh +++ /dev/null @@ -1,177 +0,0 @@ -#!/bin/bash -# Composite: epic-progress -# Epics the current user is contributing to, with children progress -# 2 bulk queries for all epics (was 2 per epic — 2N total) -# Serves: /my-epics - -[[ -n "${_COMPOSITE_EPIC_PROGRESS_LOADED:-}" ]] && return 0 -_COMPOSITE_EPIC_PROGRESS_LOADED=1 - -cmd_epic_progress() { - local team="${1:?Team required}" - - team_config "$team" - _init_auth - - local user_email="$JIRA_USER" - - # Get sprint + issues - local sprint_json - sprint_json=$(team_sprint "$team" active 2>/dev/null) || { - sprint_json=$(team_sprint "$team" future 2>/dev/null) || { - echo '{"error":"No active or future sprint found for '"$team"'"}' >&2; return 1 - } - _log "WARN" "No active sprint — using future sprint" - } - - local sprint_id - sprint_id=$(echo "$sprint_json" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])") - - local issues_json - issues_json=$(cmd_sprint_issues "$sprint_id") - - # Extract unique epic keys for the user's items - local epic_keys - epic_keys=$(python3 -c " -import json, sys -data = json.loads(sys.argv[1]) -user = sys.argv[2] -epics = set() -for i in data.get('issues', []): - f = i.get('fields', {}) - a = f.get('assignee') or {} - if a.get('emailAddress', '') == user or a.get('displayName', '') == user: - ek = f.get('customfield_10014') - if ek: - epics.add(ek) -for e in sorted(epics): - print(e) -" "$issues_json" "$user_email") - - if [[ -z "$epic_keys" ]]; then - python3 -c " -import json, sys -sprint = json.loads(sys.argv[1]) -print(json.dumps({'sprint': {'id': sprint['id'], 'name': sprint['name']}, 'epics': [], 'summary': {'totalEpics': 0}})) -" "$sprint_json" - return 0 - fi - - # Build comma-separated key lists for bulk JQL - local keys_csv keys_quoted - keys_csv=$(echo "$epic_keys" | tr '\n' ',' | sed 's/,$//') - keys_quoted=$(echo "$epic_keys" | sed 's/.*/"&"/' | tr '\n' ',' | sed 's/,$//') - - # 2 bulk queries (was 2 per epic) - parallel_init - parallel_run "epics" cmd_search "key in (${keys_csv})" 50 - parallel_run "children" cmd_search "\"Epic Link\" in (${keys_quoted}) ORDER BY status ASC" 200 - parallel_wait_all 2>/dev/null || true - - python3 - "$sprint_json" "$issues_json" "$(parallel_get epics)" "$(parallel_get children)" "$user_email" <<'PYEOF' -import json, sys - -sprint = json.loads(sys.argv[1]) -all_issues = json.loads(sys.argv[2]) -epics_data = json.loads(sys.argv[3]) -children_data = json.loads(sys.argv[4]) -user_email = sys.argv[5] - -# Build epic map by key -epic_map = {} -for e in epics_data.get("issues", []): - epic_map[e["key"]] = e - -# Group children by epic link -children_by_epic = {} -for c in children_data.get("issues", []): - ek = c.get("fields", {}).get("customfield_10014") - if ek: - children_by_epic.setdefault(ek, []).append(c) - -# My sprint items by epic -my_sprint_items = {} -for i in all_issues.get("issues", []): - f = i.get("fields", {}) - a = f.get("assignee") or {} - ek = f.get("customfield_10014") - if ek: - item = { - "key": i.get("key", ""), "summary": f.get("summary", ""), - "status": f.get("status", {}).get("name", ""), - "statusCategory": f.get("status", {}).get("statusCategory", {}).get("key", ""), - "points": f.get("customfield_10028") or 0, - "type": f.get("issuetype", {}).get("name", ""), - "mine": a.get("emailAddress", "") == user_email or a.get("displayName", "") == user_email, - "assignee": a.get("displayName", "Unassigned"), - } - my_sprint_items.setdefault(ek, []).append(item) - -epics_out = [] -for ek in sorted(epic_map.keys()): - epic = epic_map[ek] - ef = epic.get("fields", {}) - epic_children = children_by_epic.get(ek, []) - - children = [] - done_count = 0 - in_progress_count = 0 - todo_count = 0 - total_children = 0 - - for child in epic_children: - cf = child.get("fields", {}) - sc = cf.get("status", {}).get("statusCategory", {}).get("key", "") - total_children += 1 - if sc == "done": - done_count += 1 - elif sc == "indeterminate": - in_progress_count += 1 - else: - todo_count += 1 - children.append({ - "key": child.get("key", ""), - "summary": cf.get("summary", ""), - "status": cf.get("status", {}).get("name", ""), - "statusCategory": sc, - "assignee": (cf.get("assignee") or {}).get("displayName", "Unassigned"), - "points": cf.get("customfield_10028") or 0, - }) - - pct = round(done_count / total_children * 100) if total_children else 0 - - # Split sprint items into mine vs others - sprint_items = my_sprint_items.get(ek, []) - my_items = [i for i in sprint_items if i["mine"]] - other_items = [i for i in sprint_items if not i["mine"]] - - epics_out.append({ - "key": ek, - "summary": ef.get("summary", ""), - "status": ef.get("status", {}).get("name", ""), - "assignee": (ef.get("assignee") or {}).get("displayName", "Unassigned"), - "progress": { - "total": total_children, "done": done_count, - "inProgress": in_progress_count, "toDo": todo_count, - "percent": pct, - }, - "myItems": my_items, - "otherItems": other_items, - "allChildren": children, - }) - -at_risk = [e for e in epics_out if e["progress"]["toDo"] > e["progress"]["done"]] -near_complete = [e for e in epics_out if e["progress"]["percent"] >= 80] - -result = { - "sprint": {"id": sprint["id"], "name": sprint["name"]}, - "epics": epics_out, - "summary": { - "totalEpics": len(epics_out), - "nearComplete": [{"key": e["key"], "summary": e["summary"], "percent": e["progress"]["percent"]} for e in near_complete], - "atRisk": [{"key": e["key"], "summary": e["summary"], "toDo": e["progress"]["toDo"]} for e in at_risk], - }, -} -print(json.dumps(result)) -PYEOF -} diff --git a/plugins/node-team/scripts/lib/composite/issue-deep-dive.sh b/plugins/node-team/scripts/lib/composite/issue-deep-dive.sh deleted file mode 100644 index d9bf80ddd..000000000 --- a/plugins/node-team/scripts/lib/composite/issue-deep-dive.sh +++ /dev/null @@ -1,110 +0,0 @@ -#!/bin/bash -# Composite: issue-deep-dive -# Full issue details + comments (ADF converted) + linked issues -# Serves: /investigate, /briefing, /handoff - -[[ -n "${_COMPOSITE_ISSUE_DEEP_DIVE_LOADED:-}" ]] && return 0 -_COMPOSITE_ISSUE_DEEP_DIVE_LOADED=1 - -cmd_issue_deep_dive() { - local key="${1:?Issue key required (e.g., OCPNODE-1234)}" - - # ── Fetch issue + comments in parallel ─────────────────────────────────── - parallel_init - parallel_run "issue" cmd_get "$key" - parallel_run "comments" cmd_comments "$key" - parallel_run "transitions" cmd_transitions "$key" - parallel_wait_all || true - - local issue_json comments_json transitions_json - issue_json=$(parallel_get "issue") - comments_json=$(parallel_get "comments") - transitions_json=$(parallel_get "transitions") - - local adf_py - adf_py="$(cd "$(dirname "${BASH_SOURCE[0]}")/../util" && pwd)/adf.py" - - python3 - "$comments_json" "$transitions_json" "$adf_py" "$issue_json" <<'PYEOF' -import json, sys, importlib.util - -comments_data = json.loads(sys.argv[1]) -transitions_data = json.loads(sys.argv[2]) -adf_py_path = sys.argv[3] -issue = json.loads(sys.argv[4]) - -# Load ADF converter -spec = importlib.util.spec_from_file_location("adf", adf_py_path) -adf_mod = importlib.util.module_from_spec(spec) -spec.loader.exec_module(adf_mod) - -f = issue.get("fields", {}) - -# Extract description -desc = f.get("description") -desc_text = adf_mod.adf_to_text(desc).strip() if isinstance(desc, dict) else (desc or "") - -# Extract comments -comments = adf_mod.extract_comments(comments_data) - -# Extract linked issues -linked = [] -for link in f.get("issuelinks", []): - link_type = link.get("type", {}) - if "outwardIssue" in link: - target = link["outwardIssue"] - linked.append({ - "key": target.get("key", ""), - "summary": target.get("fields", {}).get("summary", ""), - "status": target.get("fields", {}).get("status", {}).get("name", ""), - "relationship": link_type.get("outward", ""), - }) - if "inwardIssue" in link: - target = link["inwardIssue"] - linked.append({ - "key": target.get("key", ""), - "summary": target.get("fields", {}).get("summary", ""), - "status": target.get("fields", {}).get("status", {}).get("name", ""), - "relationship": link_type.get("inward", ""), - }) - -# Extract SFDC cases -sfdc_count = f.get("customfield_10978") -sfdc_links = f.get("customfield_10979") - -# Epic context -epic_key = f.get("customfield_10014") - -# Available transitions -transitions = [{"id": t["id"], "name": t["name"]} for t in transitions_data.get("transitions", [])] - -# Blocked info -blocked = (f.get("customfield_10517") or {}).get("value", "False") == "True" -blocked_reason = f.get("customfield_10483") -blocked_text = adf_mod.adf_to_text(blocked_reason).strip() if isinstance(blocked_reason, dict) else "" - -result = { - "key": issue.get("key", ""), - "summary": f.get("summary", ""), - "description": desc_text, - "status": f.get("status", {}).get("name", ""), - "statusCategory": f.get("status", {}).get("statusCategory", {}).get("key", ""), - "assignee": (f.get("assignee") or {}).get("displayName", "Unassigned"), - "assigneeEmail": (f.get("assignee") or {}).get("emailAddress", ""), - "priority": f.get("priority", {}).get("name", ""), - "type": f.get("issuetype", {}).get("name", ""), - "points": f.get("customfield_10028") or 0, - "fixVersions": [v.get("name", "") for v in (f.get("fixVersions") or [])], - "epicKey": epic_key, - "releaseBlocker": f.get("customfield_10847"), - "blocked": blocked, - "blockedReason": blocked_text, - "sfdcCaseCount": sfdc_count, - "sfdcLinks": sfdc_links, - "linkedIssues": linked, - "comments": comments, - "transitions": transitions, -} - -print(json.dumps(result)) -PYEOF -} diff --git a/plugins/node-team/scripts/lib/composite/my-board-data.sh b/plugins/node-team/scripts/lib/composite/my-board-data.sh deleted file mode 100644 index f9e464e5c..000000000 --- a/plugins/node-team/scripts/lib/composite/my-board-data.sh +++ /dev/null @@ -1,120 +0,0 @@ -#!/bin/bash -# Composite: my-board-data -# Sprint dashboard pre-filtered to the current user (JIRA_EMAIL) -# Serves: /my-board - -[[ -n "${_COMPOSITE_MY_BOARD_LOADED:-}" ]] && return 0 -_COMPOSITE_MY_BOARD_LOADED=1 - -cmd_my_board_data() { - local team="${1:?Team required}" - shift - local sprint_ref="" - while [[ $# -gt 0 ]]; do - case "$1" in - --sprint) sprint_ref="${2:?--sprint requires a value}"; shift 2 ;; - *) shift ;; - esac - done - - team_config "$team" - _init_auth - - local sprint_json - sprint_json=$(resolve_sprint "$team" "$sprint_ref") || return 1 - - local sprint_id - sprint_id=$(echo "$sprint_json" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])") - - local issues_json - issues_json=$(cmd_sprint_issues "$sprint_id") - - python3 - "$sprint_json" "$issues_json" "${JIRA_USER}" <<'PYEOF' -import json, sys -from datetime import datetime, timezone - -sprint = json.loads(sys.argv[1]) -data = json.loads(sys.argv[2]) -user_email = sys.argv[3] -issues = data.get("issues", []) - -start = datetime.fromisoformat(sprint["startDate"].replace("Z", "+00:00")) -end = datetime.fromisoformat(sprint["endDate"].replace("Z", "+00:00")) -now = datetime.now(timezone.utc) -total_days = max((end - start).days, 1) -elapsed = min(max((now - start).days, 0), total_days) -remaining = max(total_days - elapsed, 0) - -STATUS_ORDER = {"done": 0, "codeReview": 1, "inProgress": 2, "modified": 3, "toDo": 4, "other": 5} -by_status = {} -total_pts = 0 -done_pts = 0 -flags = [] - -for issue in issues: - f = issue.get("fields", {}) - assignee = f.get("assignee") or {} - if assignee.get("emailAddress", "") != user_email and assignee.get("displayName", "") != user_email: - continue - - key = issue.get("key", "") - summary = f.get("summary", "") - status_name = f.get("status", {}).get("name", "") - status_cat = f.get("status", {}).get("statusCategory", {}).get("key", "") - pts = f.get("customfield_10028") or 0 - itype = f.get("issuetype", {}).get("name", "") - blocked = (f.get("customfield_10517") or {}).get("value", "False") == "True" - blocked_reason = f.get("customfield_10483") - br_text = "" - if isinstance(blocked_reason, dict): - # simple ADF extract - def _adf(n): - if not isinstance(n, dict): return "" - t = n.get("text", "") if n.get("type") == "text" else "" - for c in n.get("content", []): t += _adf(c) - return t - br_text = _adf(blocked_reason).strip() - - total_pts += pts - if status_cat == "done": - group = "done"; done_pts += pts - elif status_name == "Code Review": - group = "codeReview" - elif status_name == "MODIFIED": - group = "modified" - elif status_cat == "indeterminate" or status_name == "In Progress": - group = "inProgress" - elif status_cat == "new" or status_name in ("To Do", "NEW"): - group = "toDo" - else: - group = "other" - - item = {"key": key, "summary": summary, "status": status_name, "statusGroup": group, - "points": pts, "type": itype, "blocked": blocked, "blockedReason": br_text} - by_status.setdefault(group, []).append(item) - - if blocked: - flags.append({"key": key, "summary": summary, "reason": br_text or "Blocked (no reason given)"}) - if not pts and group != "done": - flags.append({"key": key, "summary": summary, "reason": "No story points"}) - if group == "toDo" and remaining <= 3: - flags.append({"key": key, "summary": summary, "reason": f"Still To Do with {remaining} days left"}) - -ordered = {} -for g in sorted(by_status.keys(), key=lambda x: STATUS_ORDER.get(x, 99)): - ordered[g] = by_status[g] - -total_items = sum(len(v) for v in by_status.values()) -result = { - "sprint": {"id": sprint["id"], "name": sprint["name"], "startDate": sprint["startDate"], - "endDate": sprint["endDate"], "daysElapsed": elapsed, "daysTotal": total_days, "daysRemaining": remaining}, - "summary": {"total": total_items, "done": len(by_status.get("done", [])), - "inProgress": len(by_status.get("inProgress", [])) + len(by_status.get("codeReview", [])), - "toDo": len(by_status.get("toDo", [])), - "totalPoints": total_pts, "donePoints": done_pts}, - "byStatus": ordered, - "flags": flags, -} -print(json.dumps(result)) -PYEOF -} diff --git a/plugins/node-team/scripts/lib/composite/my-bugs-data.sh b/plugins/node-team/scripts/lib/composite/my-bugs-data.sh deleted file mode 100644 index f46eb99ce..000000000 --- a/plugins/node-team/scripts/lib/composite/my-bugs-data.sh +++ /dev/null @@ -1,72 +0,0 @@ -#!/bin/bash -# Composite: my-bugs-data -# All bugs assigned to the current user, categorized -# Serves: /my-bugs - -[[ -n "${_COMPOSITE_MY_BUGS_LOADED:-}" ]] && return 0 -_COMPOSITE_MY_BUGS_LOADED=1 - -cmd_my_bugs_data() { - local team="${1:?Team required}" - - team_config "$team" - - # _init_auth is called by _curl inside cmd_search, but we need JIRA_USER now - _init_auth - local user_email="$JIRA_USER" - - local search_result - search_result=$(cmd_search "project = OCPBUGS AND component in (${TEAM_BUG_COMPONENTS}) AND assignee = \"${user_email}\" AND status not in (CLOSED, Verified, Done) ORDER BY priority ASC, created ASC" 100) - - python3 - "$TEAM_NAME" "$search_result" <<'PYEOF' -import json, sys -from datetime import datetime, timezone - -team_name = sys.argv[1] -data = json.loads(sys.argv[2]) -issues = data.get("issues", []) -now = datetime.now(timezone.utc) - -all_bugs = [] -escalations = [] -release_blockers = [] -by_priority = {} - -for issue in issues: - f = issue.get("fields", {}) - key = issue.get("key", "") - summary = f.get("summary", "") - status = f.get("status", {}).get("name", "") - priority = f.get("priority", {}).get("name", "") - pts = f.get("customfield_10028") or 0 - sfdc = f.get("customfield_10978") - rb = f.get("customfield_10847") - fv = [v.get("name", "") for v in (f.get("fixVersions") or [])] - blocked = (f.get("customfield_10517") or {}).get("value", "False") == "True" - - bug = {"key": key, "summary": summary, "status": status, "priority": priority, - "points": pts, "fixVersions": fv, "blocked": blocked, - "sfdcCaseCount": sfdc, "releaseBlocker": rb} - all_bugs.append(bug) - by_priority[priority] = by_priority.get(priority, 0) + 1 - - if sfdc: - escalations.append(bug) - if rb: - release_blockers.append(bug) - -result = { - "team": team_name, - "summary": { - "total": len(all_bugs), - "byPriority": by_priority, - "customerEscalations": len(escalations), - "releaseBlockers": len(release_blockers), - }, - "customerEscalations": escalations, - "releaseBlockers": release_blockers, - "allBugs": all_bugs, -} -print(json.dumps(result)) -PYEOF -} diff --git a/plugins/node-team/scripts/lib/composite/my-standup-data.sh b/plugins/node-team/scripts/lib/composite/my-standup-data.sh deleted file mode 100644 index 1c65a39fb..000000000 --- a/plugins/node-team/scripts/lib/composite/my-standup-data.sh +++ /dev/null @@ -1,152 +0,0 @@ -#!/bin/bash -# Composite: my-standup-data -# Standup data pre-filtered to current user (Jira side only) -# 1 data query (was 2 — removed redundant "recent" search that was unused) -# Serves: /my-standup - -[[ -n "${_COMPOSITE_MY_STANDUP_LOADED:-}" ]] && return 0 -_COMPOSITE_MY_STANDUP_LOADED=1 - -cmd_my_standup_data() { - local team="${1:?Team required}" - shift - local sprint_ref="" - while [[ $# -gt 0 ]]; do - case "$1" in - --sprint) sprint_ref="${2:?--sprint requires a value}"; shift 2 ;; - *) shift ;; - esac - done - - team_config "$team" - _init_auth - - local user_email="$JIRA_USER" - - local sprint_json - sprint_json=$(resolve_sprint "$team" "$sprint_ref") || return 1 - - local sprint_id - sprint_id=$(echo "$sprint_json" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])") - - # Sprint issues only (removed redundant "recent" search — data was unused) - local issues_json - issues_json=$(cmd_sprint_issues "$sprint_id") - - # Get my issue keys and fetch comments - local my_keys - my_keys=$(python3 -c " -import json, sys -data = json.loads(sys.argv[1]) -user = sys.argv[2] -for i in data.get('issues', []): - a = (i.get('fields', {}).get('assignee') or {}) - if a.get('emailAddress', '') == user or a.get('displayName', '') == user: - print(i['key']) -" "$issues_json" "$user_email") - - parallel_init - for key in $my_keys; do - parallel_run "comments_${key}" cmd_comments "$key" - done - parallel_wait_all 2>/dev/null || true - - local comments_combined="{" - local first=true - for key in $my_keys; do - local c - c=$(parallel_get "comments_${key}" 2>/dev/null) - if [[ -n "$c" && "$c" != *"error"* ]]; then - [[ "$first" == "true" ]] && first=false || comments_combined+="," - comments_combined+="\"${key}\":${c}" - fi - done - comments_combined+="}" - - local adf_py - adf_py="$(cd "$(dirname "${BASH_SOURCE[0]}")/../util" && pwd)/adf.py" - - python3 - "$sprint_json" "$issues_json" "$comments_combined" "$user_email" "$adf_py" <<'PYEOF' -import json, sys, importlib.util -from datetime import datetime, timedelta, timezone - -sprint = json.loads(sys.argv[1]) -all_issues = json.loads(sys.argv[2]) -all_comments = json.loads(sys.argv[3]) -user_email = sys.argv[4] -adf_py_path = sys.argv[5] - -spec = importlib.util.spec_from_file_location("adf", adf_py_path) -adf_mod = importlib.util.module_from_spec(spec) -spec.loader.exec_module(adf_mod) - -start = datetime.fromisoformat(sprint["startDate"].replace("Z", "+00:00")) -end = datetime.fromisoformat(sprint["endDate"].replace("Z", "+00:00")) -now = datetime.now(timezone.utc) -total_days = max((end - start).days, 1) -elapsed = min(max((now - start).days, 0), total_days) - -# Filter to my items -done = [] -in_progress = [] -blocked = [] -todo = [] - -for i in all_issues.get("issues", []): - f = i.get("fields", {}) - a = f.get("assignee") or {} - if a.get("emailAddress", "") != user_email and a.get("displayName", "") != user_email: - continue - - key = i.get("key", "") - status_cat = f.get("status", {}).get("statusCategory", {}).get("key", "") - status_name = f.get("status", {}).get("name", "") - is_blocked = (f.get("customfield_10517") or {}).get("value", "False") == "True" - br = f.get("customfield_10483") - br_text = adf_mod.adf_to_text(br).strip() if isinstance(br, dict) else "" - - item = {"key": key, "summary": f.get("summary", ""), "status": status_name, - "points": f.get("customfield_10028") or 0, "type": f.get("issuetype", {}).get("name", ""), - "blocked": is_blocked, "blockedReason": br_text} - - if status_cat == "done": - done.append(item) - elif is_blocked: - blocked.append(item) - elif status_cat == "new" or status_name in ("To Do", "NEW"): - todo.append(item) - else: - in_progress.append(item) - -# Recent comments on my items -my_comments = [] -cutoff = now - timedelta(days=7) -for key, cdata in all_comments.items(): - for c in cdata.get("comments", []): - created = c.get("created", "") - try: - dt = datetime.fromisoformat(created.replace("Z", "+00:00")) - if dt >= cutoff: - body = c.get("body", {}) - my_comments.append({ - "key": key, "author": c.get("author", {}).get("displayName", ""), - "created": created, - "body": adf_mod.adf_to_text(body).strip() if isinstance(body, dict) else str(body), - }) - except (ValueError, TypeError): - pass - -result = { - "sprint": {"id": sprint["id"], "name": sprint["name"], - "daysElapsed": elapsed, "daysTotal": total_days}, - "done": done, - "inProgress": in_progress, - "blocked": blocked, - "upNext": todo, - "recentComments": my_comments, - "summary": {"done": len(done), "inProgress": len(in_progress), - "blocked": len(blocked), "toDo": len(todo)}, -} -print(json.dumps(result)) -PYEOF -} diff --git a/plugins/node-team/scripts/lib/composite/pickup-data.sh b/plugins/node-team/scripts/lib/composite/pickup-data.sh deleted file mode 100644 index e69e643be..000000000 --- a/plugins/node-team/scripts/lib/composite/pickup-data.sh +++ /dev/null @@ -1,75 +0,0 @@ -#!/bin/bash -# Composite: pickup-data -# All available unassigned work: sprint items + bugs (with escalation tagging) -# 2 queries (was 3 — merged bugs + escalations into 1) -# Serves: /pickup - -[[ -n "${_COMPOSITE_PICKUP_LOADED:-}" ]] && return 0 -_COMPOSITE_PICKUP_LOADED=1 - -cmd_pickup_data() { - local team="${1:?Team required}" - shift - local sprint_ref="" - while [[ $# -gt 0 ]]; do - case "$1" in - --sprint) sprint_ref="${2:?--sprint requires a value}"; shift 2 ;; - *) shift ;; - esac - done - - team_config "$team" - - local sprint_json - sprint_json=$(resolve_sprint "$team" "$sprint_ref") || return 1 - - local sprint_id - sprint_id=$(echo "$sprint_json" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])") - - local bug_fields="[\"key\",\"summary\",\"status\",\"assignee\",\"priority\",\"issuetype\",\"${CF_STORY_POINTS}\"]" - - # 2 queries: sprint items + unassigned bugs - parallel_init - parallel_run "issues" cmd_sprint_issues "$sprint_id" - parallel_run "bugs" cmd_search \ - "project = OCPBUGS AND component in (${TEAM_BUG_COMPONENTS}) AND assignee is EMPTY AND status not in (CLOSED, Verified, Done) ORDER BY priority ASC, created ASC" 50 "$bug_fields" - parallel_wait_all || true - - python3 - "$sprint_json" "$(parallel_get issues)" "$(parallel_get bugs)" <<'PYEOF' -import json, sys - -sprint = json.loads(sys.argv[1]) -issues_data = json.loads(sys.argv[2]) -bugs_data = json.loads(sys.argv[3]) - -def extract(data): - items = [] - for i in data.get("issues", []): - f = i.get("fields", {}) - items.append({ - "key": i.get("key", ""), "summary": f.get("summary", ""), - "status": f.get("status", {}).get("name", ""), - "priority": f.get("priority", {}).get("name", ""), - "type": f.get("issuetype", {}).get("name", ""), - "points": f.get("customfield_10028") or 0, - "assignee": (f.get("assignee") or {}).get("displayName", "Unassigned"), - }) - return items - -# Unassigned sprint items (bot account is the default assignee — treat as unassigned) -BOT_ACCOUNTS = {"Node Team Bot Account"} -unassigned_sprint = [i for i in extract(issues_data) if i["assignee"] in ({"Unassigned"} | BOT_ACCOUNTS)] -unassigned_bugs = extract(bugs_data) - -result = { - "sprint": {"id": sprint["id"], "name": sprint["name"]}, - "unassignedSprintItems": unassigned_sprint, - "unassignedBugs": unassigned_bugs, - "summary": { - "sprintItems": len(unassigned_sprint), - "bugs": len(unassigned_bugs), - }, -} -print(json.dumps(result)) -PYEOF -} diff --git a/plugins/node-team/scripts/lib/composite/planning-data.sh b/plugins/node-team/scripts/lib/composite/planning-data.sh deleted file mode 100644 index d13975a2c..000000000 --- a/plugins/node-team/scripts/lib/composite/planning-data.sh +++ /dev/null @@ -1,105 +0,0 @@ -#!/bin/bash -# Composite: planning-data -# Full planning package: carryovers + scheduled next sprint + backlog + bugs -# Serves: /sprint-plan - -[[ -n "${_COMPOSITE_PLANNING_LOADED:-}" ]] && return 0 -_COMPOSITE_PLANNING_LOADED=1 - -cmd_planning_data() { - local team="${1:?Team required}" - - team_config "$team" - - # ── Sprint discovery (active preferred, fall back to last closed) ──────── - local active_sprint future_sprint - active_sprint=$(team_sprint_fallback "$team") || { echo "$active_sprint" >&2; return 1; } - - local active_id - active_id=$(echo "$active_sprint" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])") - - future_sprint=$(team_sprint "$team" future 2>/dev/null) || future_sprint='{"error":"No future sprint"}' - - local future_id="" - future_id=$(echo "$future_sprint" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('id',''))" 2>/dev/null) || true - - # ── Parallel: active issues + future issues + backlog + unscheduled bugs + roster - parallel_init - - parallel_run "active_issues" cmd_sprint_issues "$active_id" - - if [[ -n "$future_id" ]]; then - parallel_run "future_issues" cmd_sprint_issues "$future_id" - fi - - parallel_run "backlog" cmd_search \ - "project = OCPNODE AND sprint is EMPTY AND status not in (Closed, Done) AND type in (Story, Task, Spike) ORDER BY priority ASC, created DESC" 30 - - parallel_run "unscheduled_bugs" cmd_search \ - "project = OCPBUGS AND component in (${TEAM_BUG_COMPONENTS}) AND sprint is EMPTY AND status not in (Closed, Done, Verified) ORDER BY priority ASC, created DESC" 30 - - parallel_run "roster" team_roster "$team" - - parallel_wait_all || true - - local active_issues future_issues backlog bugs roster_json - active_issues=$(parallel_get "active_issues") - future_issues=$(parallel_get "future_issues" 2>/dev/null || echo '{"issues":[]}') - backlog=$(parallel_get "backlog") - bugs=$(parallel_get "unscheduled_bugs") - roster_json=$(parallel_get "roster") - - python3 - "$active_sprint" "$future_sprint" "$backlog" "$bugs" "$roster_json" "$future_issues" "$active_issues" <<'PYEOF' -import json, sys - -active_sprint = json.loads(sys.argv[1]) -future_sprint = json.loads(sys.argv[2]) -backlog_data = json.loads(sys.argv[3]) -bugs_data = json.loads(sys.argv[4]) -roster = json.loads(sys.argv[5]) -future_data = json.loads(sys.argv[6]) -active_data = json.loads(sys.argv[7]) - -def extract_items(data): - items = [] - for i in data.get("issues", []): - f = i.get("fields", {}) - items.append({ - "key": i.get("key", ""), - "summary": f.get("summary", ""), - "status": f.get("status", {}).get("name", ""), - "statusCategory": f.get("status", {}).get("statusCategory", {}).get("key", ""), - "assignee": (f.get("assignee") or {}).get("displayName", "Unassigned"), - "points": f.get("customfield_10028") or 0, - "type": f.get("issuetype", {}).get("name", ""), - "priority": f.get("priority", {}).get("name", ""), - }) - return items - -active_items = extract_items(active_data) -carryovers = [i for i in active_items if i["statusCategory"] != "done"] -done_items = [i for i in active_items if i["statusCategory"] == "done"] -scheduled = extract_items(future_data) -backlog_items = extract_items(backlog_data) -bug_items = extract_items(bugs_data) - -result = { - "activeSprint": active_sprint, - "futureSprint": future_sprint if "error" not in future_sprint else None, - "wrapUp": { - "done": done_items, - "carryovers": carryovers, - "doneCount": len(done_items), - "carryoverCount": len(carryovers), - "donePoints": sum(i["points"] for i in done_items), - "carryoverPoints": sum(i["points"] for i in carryovers), - }, - "scheduled": scheduled, - "backlogCandidates": backlog_items, - "unscheduledBugs": bug_items, - "roster": roster, -} - -print(json.dumps(result)) -PYEOF -} diff --git a/plugins/node-team/scripts/lib/composite/release-data.sh b/plugins/node-team/scripts/lib/composite/release-data.sh deleted file mode 100644 index 10cc3ff03..000000000 --- a/plugins/node-team/scripts/lib/composite/release-data.sh +++ /dev/null @@ -1,111 +0,0 @@ -#!/bin/bash -# Composite: release-data [version] -# Release readiness: blockers, open bugs, epics -# 2 queries for bugs+epics (was 4 — approved/proposed/all merged into 1) -# Serves: /release-check - -[[ -n "${_COMPOSITE_RELEASE_DATA_LOADED:-}" ]] && return 0 -_COMPOSITE_RELEASE_DATA_LOADED=1 - -cmd_release_data() { - local team="${1:?Team required}" - local version="${2:-}" - - team_config "$team" - - # ── Version discovery if not provided ──────────────────────────────────── - if [[ -z "$version" ]]; then - version=$(cmd_search "project = OCPNODE AND fixVersion is not EMPTY AND status not in (Closed, Done) ORDER BY fixVersion DESC" 10 | \ - python3 -c " -import sys, json -versions = set() -for i in json.load(sys.stdin).get('issues', []): - for v in i['fields'].get('fixVersions', []): - versions.add(v['name']) -if versions: - print(sorted(versions)[-1]) -else: - print('') -") - if [[ -z "$version" ]]; then - echo '{"error":"No active fixVersion found"}' >&2 - return 1 - fi - fi - - # ── 2 queries (was 4): all bugs + epics — categorize blockers in Python ── - parallel_init - - parallel_run "all_bugs" cmd_search \ - "project = OCPBUGS AND component in (${TEAM_BUG_COMPONENTS}) AND fixVersion = \"${version}\" AND status not in (Closed, Done, Verified) ORDER BY priority ASC" 100 - - parallel_run "epics" cmd_search \ - "project = OCPNODE AND issuetype = Epic AND component in (${TEAM_BUG_COMPONENTS}) AND fixVersion = \"${version}\" ORDER BY status ASC" 50 - - parallel_wait_all || true - - python3 - "$version" \ - "$(parallel_get all_bugs)" \ - "$(parallel_get epics)" \ - <<'PYEOF' -import json, sys - -version = sys.argv[1] - -def extract(data_str): - items = [] - for i in json.loads(data_str).get("issues", []): - f = i.get("fields", {}) - items.append({ - "key": i.get("key", ""), - "summary": f.get("summary", ""), - "status": f.get("status", {}).get("name", ""), - "assignee": (f.get("assignee") or {}).get("displayName", "Unassigned"), - "priority": f.get("priority", {}).get("name", ""), - "points": f.get("customfield_10028") or 0, - "releaseBlocker": f.get("customfield_10847"), - }) - return items - -all_bugs = extract(sys.argv[2]) -epics = extract(sys.argv[3]) - -# Shape assertion — warn if releaseBlocker format changed -for b in all_bugs[:5]: - rb = b.get("releaseBlocker") - if rb is not None and not isinstance(rb, dict): - print(f"SHAPE WARNING: releaseBlocker is {type(rb).__name__}, expected dict or None " - f"(on {b['key']}). Blocker categorization may be broken.", file=sys.stderr) - break - -# Categorize blockers from all bugs (was 2 separate JQL queries) -approved = [b for b in all_bugs - if isinstance(b.get("releaseBlocker"), dict) - and b["releaseBlocker"].get("value") == "Approved"] -proposed = [b for b in all_bugs - if isinstance(b.get("releaseBlocker"), dict) - and b["releaseBlocker"].get("value") == "Proposed"] - -# Canary: if all bugs have releaseBlocker set but none match known values, values may have changed -bugs_with_rb = [b for b in all_bugs if b.get("releaseBlocker") is not None] -if len(bugs_with_rb) > 5 and len(approved) == 0 and len(proposed) == 0: - print(f"CANARY: {len(bugs_with_rb)} bugs have releaseBlocker set but 0 match " - f"'Approved' or 'Proposed'. Field values may have changed.", file=sys.stderr) - -result = { - "version": version, - "summary": { - "approvedBlockers": len(approved), - "proposedBlockers": len(proposed), - "openBugs": len(all_bugs), - "epics": len(epics), - }, - "approvedBlockers": approved, - "proposedBlockers": proposed, - "openBugs": all_bugs, - "epics": epics, -} - -print(json.dumps(result)) -PYEOF -} diff --git a/plugins/node-team/scripts/lib/composite/roster-sync.sh b/plugins/node-team/scripts/lib/composite/roster-sync.sh deleted file mode 100644 index 295266c30..000000000 --- a/plugins/node-team/scripts/lib/composite/roster-sync.sh +++ /dev/null @@ -1,89 +0,0 @@ -#!/bin/bash -# Composite: roster-sync [--force] -# Downloads team-roster-*.json attachments from a Jira config issue to ~/.node-assistant/ - -[[ -n "${_COMPOSITE_ROSTER_SYNC_LOADED:-}" ]] && return 0 -_COMPOSITE_ROSTER_SYNC_LOADED=1 - -NODE_ASSISTANT_DIR="${HOME}/.node-assistant" -NODE_ASSISTANT_CONFIG_ISSUE="${NODE_ASSISTANT_CONFIG_ISSUE:-OCPNODE-4230}" - -cmd_roster_sync() { - local force=false - [[ "${1:-}" == "--force" ]] && force=true - - mkdir -p "$NODE_ASSISTANT_DIR" - - local issue_json - issue_json=$(_curl "${JIRA_BASE}/rest/api/3/issue/${NODE_ASSISTANT_CONFIG_ISSUE}?fields=attachment") - - local roster_attachments - roster_attachments=$(python3 -c " -import json, sys -data = json.loads(sys.argv[1]) -attachments = data.get('fields', {}).get('attachment', []) -matches = [] -for a in attachments: - name = a.get('filename', '') - if name.startswith('team-roster-') and name.endswith('.json'): - matches.append({'id': str(a['id']), 'filename': name}) -print(json.dumps(matches)) -" "$issue_json") - - local count - count=$(python3 -c "import json,sys; print(len(json.loads(sys.argv[1])))" "$roster_attachments") - - if [[ "$count" -eq 0 ]]; then - echo '{"synced":[],"skipped":[],"message":"No team-roster-*.json attachments found on '"${NODE_ASSISTANT_CONFIG_ISSUE}"'"}' - return 0 - fi - - local synced=() skipped=() - local max_age_seconds=$((7 * 86400)) - - while IFS= read -r line; do - local att_id att_filename - att_id=$(echo "$line" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])") - att_filename=$(echo "$line" | python3 -c "import json,sys; print(json.load(sys.stdin)['filename'])") - - local dest="${NODE_ASSISTANT_DIR}/${att_filename}" - - if [[ "$force" == "false" && -f "$dest" ]]; then - local file_age - file_age=$(( $(date +%s) - $(stat -f %m "$dest" 2>/dev/null || stat -c %Y "$dest" 2>/dev/null || echo 0) )) - if (( file_age < max_age_seconds )); then - skipped+=("$att_filename") - _log "INFO" "Skipping ${att_filename} (${file_age}s old, <7d)" - continue - fi - fi - - _log "INFO" "Downloading ${att_filename} (attachment ${att_id})" - local tmp - tmp="$(mktemp "${dest}.tmp.XXXXXX")" - if _curl -L -o "$tmp" "${JIRA_BASE}/rest/api/3/attachment/content/${att_id}"; then - mv "$tmp" "$dest" - synced+=("$att_filename") - else - rm -f "$tmp" - return 1 - fi - - done < <(python3 -c " -import json, sys -for item in json.loads(sys.argv[1]): - print(json.dumps(item)) -" "$roster_attachments") - - python3 -c " -import json, sys -synced = [x for x in sys.argv[1].split(',') if x] if sys.argv[1] else [] -skipped = [x for x in sys.argv[2].split(',') if x] if sys.argv[2] else [] -print(json.dumps({ - 'synced': synced, - 'skipped': skipped, - 'configIssue': sys.argv[3], - 'directory': sys.argv[4], -})) -" "$(IFS=,; echo "${synced[*]:-}")" "$(IFS=,; echo "${skipped[*]:-}")" "$NODE_ASSISTANT_CONFIG_ISSUE" "$NODE_ASSISTANT_DIR" -} diff --git a/plugins/node-team/scripts/lib/composite/sprint-dashboard.sh b/plugins/node-team/scripts/lib/composite/sprint-dashboard.sh deleted file mode 100644 index abe4d8f4e..000000000 --- a/plugins/node-team/scripts/lib/composite/sprint-dashboard.sh +++ /dev/null @@ -1,187 +0,0 @@ -#!/bin/bash -# Composite: sprint-dashboard [--stream] -# Returns sprint info + issues grouped by status + workload + blockers -# Serves: /sprint-status, /team-load, /sprint-review, /my-board - -[[ -n "${_COMPOSITE_SPRINT_DASHBOARD_LOADED:-}" ]] && return 0 -_COMPOSITE_SPRINT_DASHBOARD_LOADED=1 - -cmd_sprint_dashboard() { - local team="${1:?Team required (e.g., 'Node Devices' or 'Node Core')}" - shift - local stream=false sprint_ref="" - while [[ $# -gt 0 ]]; do - case "$1" in - --stream) stream=true; shift ;; - --sprint) sprint_ref="${2:?--sprint requires a value}"; shift 2 ;; - *) shift ;; - esac - done - - # ── Resolve team config ────────────────────────────────────────────────── - team_config "$team" - - # ── Get sprint info ───────────────────────────────────────────────────── - local sprint_json - sprint_json=$(resolve_sprint "$team" "$sprint_ref") || return 1 - - local sprint_id - sprint_id=$(echo "$sprint_json" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])") - - # ── Get sprint issues + roster in parallel ─────────────────────────────── - parallel_init - parallel_run "issues" cmd_sprint_issues "$sprint_id" - parallel_run "roster" team_roster "$team" - parallel_wait_all || true - - local issues_json roster_json - issues_json=$(parallel_get "issues") - roster_json=$(parallel_get "roster") - - # ── Process everything in Python for speed ─────────────────────────────── - python3 - "$sprint_json" "$roster_json" "$issues_json" <<'PYEOF' -import json, sys -from datetime import datetime, timezone - -sprint = json.loads(sys.argv[1]) -roster = json.loads(sys.argv[2]) -data = json.loads(sys.argv[3]) -issues = data.get("issues", []) - -# Sprint progress -start = datetime.fromisoformat(sprint["startDate"].replace("Z", "+00:00")) -end = datetime.fromisoformat(sprint["endDate"].replace("Z", "+00:00")) -now = datetime.now(timezone.utc) -total_days = max((end - start).days, 1) -elapsed_days = min(max((now - start).days, 0), total_days) -days_remaining = max(total_days - elapsed_days, 0) - -# Categorize issues -status_groups = {} -team_workload = {} -total_points = 0 -done_points = 0 -blocked_items = [] -at_risk = [] - -STATUS_ORDER = {"done": 0, "codeReview": 1, "inProgress": 2, "modified": 3, "toDo": 4, "other": 5} - -for issue in issues: - f = issue.get("fields", {}) - key = issue.get("key", "") - summary = f.get("summary", "") - status_name = f.get("status", {}).get("name", "Unknown") - status_cat = f.get("status", {}).get("statusCategory", {}).get("key", "") - assignee_name = (f.get("assignee") or {}).get("displayName", "Unassigned") - points = f.get("customfield_10028") or 0 - issue_type = f.get("issuetype", {}).get("name", "") - priority = f.get("priority", {}).get("name", "") - blocked_val = (f.get("customfield_10517") or {}).get("value", "False") - blocked_reason_adf = f.get("customfield_10483") - release_blocker = f.get("customfield_10847") - - total_points += points - - # Map status to group - if status_cat == "done": - group = "done" - done_points += points - elif status_name == "Code Review": - group = "codeReview" - elif status_name == "MODIFIED": - group = "modified" - elif status_cat == "indeterminate" or status_name == "In Progress": - group = "inProgress" - elif status_cat == "new" or status_name in ("To Do", "NEW"): - group = "toDo" - else: - group = "other" - - item = { - "key": key, - "summary": summary, - "status": status_name, - "statusGroup": group, - "assignee": assignee_name, - "points": points, - "type": issue_type, - "priority": priority, - "blocked": blocked_val == "True", - "releaseBlocker": release_blocker, - } - - status_groups.setdefault(group, []).append(item) - - # Track blocked items - if blocked_val == "True": - blocked_items.append(item) - - # At risk: not done, with sprint ending soon - if group not in ("done",) and days_remaining <= 3: - at_risk.append(item) - - # Workload tracking - wl = team_workload.setdefault(assignee_name, { - "member": assignee_name, - "toDo": 0, "inProgress": 0, "codeReview": 0, "modified": 0, - "done": 0, "other": 0, "total": 0, - "pointsDone": 0, "pointsTotal": 0, - }) - wl[group] = wl.get(group, 0) + 1 - wl["total"] += 1 - wl["pointsTotal"] += points - if group == "done": - wl["pointsDone"] += points - -# Sort groups by status order -by_status = {} -for group in sorted(status_groups.keys(), key=lambda g: STATUS_ORDER.get(g, 99)): - by_status[group] = status_groups[group] - -# Build roster with hasItems flag -roster_out = [] -active_members = set(team_workload.keys()) -for m in roster: - roster_out.append({ - "name": m["name"], - "github": m["github"], - "hasItems": m["name"] in active_members, - }) -# Add non-roster assignees -roster_names = {m["name"] for m in roster} -for name in active_members - roster_names: - if name != "Unassigned": - roster_out.append({"name": name, "github": "", "hasItems": True, "offRoster": True}) - -result = { - "sprint": { - "id": sprint["id"], - "name": sprint["name"], - "startDate": sprint["startDate"], - "endDate": sprint["endDate"], - "goal": sprint.get("goal", ""), - "daysElapsed": elapsed_days, - "daysTotal": total_days, - "daysRemaining": days_remaining, - }, - "summary": { - "total": len(issues), - "done": len(status_groups.get("done", [])), - "codeReview": len(status_groups.get("codeReview", [])), - "inProgress": len(status_groups.get("inProgress", [])), - "modified": len(status_groups.get("modified", [])), - "toDo": len(status_groups.get("toDo", [])), - "other": len(status_groups.get("other", [])), - "totalPoints": total_points, - "donePoints": done_points, - }, - "byStatus": by_status, - "blockers": blocked_items, - "atRisk": at_risk, - "teamWorkload": sorted(team_workload.values(), key=lambda w: w["total"], reverse=True), - "roster": roster_out, -} - -print(json.dumps(result)) -PYEOF -} diff --git a/plugins/node-team/scripts/lib/composite/standup-data.sh b/plugins/node-team/scripts/lib/composite/standup-data.sh deleted file mode 100644 index 9bc7f72e1..000000000 --- a/plugins/node-team/scripts/lib/composite/standup-data.sh +++ /dev/null @@ -1,295 +0,0 @@ -#!/bin/bash -# Composite: standup-data [--stream] -# Returns sprint dashboard + recent updates + per-member comments -# 1 data query + N comment fetches -# Serves: /standup, /my-standup, /team-member - -[[ -n "${_COMPOSITE_STANDUP_DATA_LOADED:-}" ]] && return 0 -_COMPOSITE_STANDUP_DATA_LOADED=1 - -cmd_standup_data() { - local team="${1:?Team required (e.g., 'Node Devices' or 'Node Core')}" - shift - local stream=false sprint_ref="" - while [[ $# -gt 0 ]]; do - case "$1" in - --stream) stream=true; shift ;; - --sprint) sprint_ref="${2:?--sprint requires a value}"; shift 2 ;; - *) shift ;; - esac - done - - # ── Resolve team config ────────────────────────────────────────────────── - team_config "$team" - - # ── Get sprint info ───────────────────────────────────────────────────── - local sprint_json - sprint_json=$(resolve_sprint "$team" "$sprint_ref") || return 1 - - local sprint_id - sprint_id=$(echo "$sprint_json" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])") - - # ── Parallel: sprint issues (with updated field) + new bugs + roster ──── - parallel_init - - parallel_run "issues" cmd_sprint_issues "$sprint_id" 100 "${ISSUE_FIELDS},updated" - parallel_run "roster" team_roster "$team" - - parallel_wait_all || true - - local issues_json roster_json - issues_json=$(parallel_get "issues") - roster_json=$(parallel_get "roster") - - # ── Get issue keys for comment fetching ────────────────────────────────── - local issue_keys - issue_keys=$(echo "$issues_json" | python3 -c " -import json, sys -data = json.load(sys.stdin) -keys = [i['key'] for i in data.get('issues', [])] -print(' '.join(keys)) -") - - # ── Fetch comments in parallel (batch of 5) ───────────────────────────── - # Re-init parallel for comment batch - parallel_cleanup 2>/dev/null || true - parallel_init - - local count=0 - for key in $issue_keys; do - parallel_run "comments_${key}" cmd_comments "$key" - count=$((count + 1)) - if (( count % 5 == 0 )); then - parallel_wait_all 2>/dev/null || true - fi - done - parallel_wait_all 2>/dev/null || true - - # Collect all comments into a JSON object keyed by issue key - local comments_combined="{" - local first=true - for key in $issue_keys; do - local c - c=$(parallel_get "comments_${key}" 2>/dev/null) - if [[ -n "$c" && "$c" != *"error"* ]]; then - if [[ "$first" == "true" ]]; then - first=false - else - comments_combined+="," - fi - comments_combined+="\"${key}\":${c}" - fi - done - comments_combined+="}" - - # ── Process everything in Python ───────────────────────────────────────── - local adf_py - adf_py="$(cd "$(dirname "${BASH_SOURCE[0]}")/../util" && pwd)/adf.py" - - python3 - "$sprint_json" "$roster_json" "$comments_combined" "$adf_py" "$issues_json" <<'PYEOF' -import json, sys, importlib.util -from datetime import datetime, timedelta, timezone -from collections import Counter - -sprint = json.loads(sys.argv[1]) -roster = json.loads(sys.argv[2]) -all_comments = json.loads(sys.argv[3]) -adf_py_path = sys.argv[4] -data = json.loads(sys.argv[5]) -issues = data.get("issues", []) - -# Load ADF converter -spec = importlib.util.spec_from_file_location("adf", adf_py_path) -adf_mod = importlib.util.module_from_spec(spec) -spec.loader.exec_module(adf_mod) - -# Sprint progress -start = datetime.fromisoformat(sprint["startDate"].replace("Z", "+00:00")) -end = datetime.fromisoformat(sprint["endDate"].replace("Z", "+00:00")) -now = datetime.now(timezone.utc) -total_days = max((end - start).days, 1) -elapsed_days = min(max((now - start).days, 0), total_days) -days_remaining = max(total_days - elapsed_days, 0) -cutoff = now - timedelta(days=7) - -# Categorize issues -STATUS_ORDER = {"done": 0, "codeReview": 1, "inProgress": 2, "modified": 3, "toDo": 4, "other": 5} -status_groups = {} -team_workload = {} -total_points = 0 -done_points = 0 -blocked_items = [] -at_risk = [] -_shape_warned = set() - -for issue in issues: - f = issue.get("fields", {}) - key = issue.get("key", "") - summary = f.get("summary", "") - status_name = f.get("status", {}).get("name", "Unknown") - status_cat = f.get("status", {}).get("statusCategory", {}).get("key", "") - assignee_name = (f.get("assignee") or {}).get("displayName", "Unassigned") - points = f.get("customfield_10028") or 0 - issue_type = f.get("issuetype", {}).get("name", "") - priority = f.get("priority", {}).get("name", "") - blocked_raw = f.get("customfield_10517") - if blocked_raw is not None and not isinstance(blocked_raw, dict) and "blocked" not in _shape_warned: - print(f"SHAPE WARNING: Blocked field (customfield_10517) is {type(blocked_raw).__name__}, " - f"expected dict or None (on {key}). Blocker detection may be broken.", file=sys.stderr) - _shape_warned.add("blocked") - blocked_val = (blocked_raw or {}).get("value", "False") if isinstance(blocked_raw, dict) else "False" - release_blocker = f.get("customfield_10847") - - total_points += points - - if status_cat == "done": - group = "done" - done_points += points - elif status_name == "Code Review": - group = "codeReview" - elif status_name == "MODIFIED": - group = "modified" - elif status_cat == "indeterminate" or status_name == "In Progress": - group = "inProgress" - elif status_cat == "new" or status_name in ("To Do", "NEW"): - group = "toDo" - else: - group = "other" - - # Latest comment for this issue - latest_comment = None - issue_comments = all_comments.get(key, {}).get("comments", []) - if issue_comments: - last_c = issue_comments[-1] - body_adf = last_c.get("body", {}) - body_text = adf_mod.adf_to_text(body_adf).strip() if isinstance(body_adf, dict) else str(body_adf) - latest_comment = { - "author": last_c.get("author", {}).get("displayName", "Unknown"), - "created": last_c.get("created", ""), - "body": body_text, - } - - item = { - "key": key, "summary": summary, "status": status_name, - "statusGroup": group, "assignee": assignee_name, - "points": points, "type": issue_type, "priority": priority, - "blocked": blocked_val == "True", "releaseBlocker": release_blocker, - "latestComment": latest_comment, - } - status_groups.setdefault(group, []).append(item) - - if blocked_val == "True": - blocked_items.append(item) - if group not in ("done",) and days_remaining <= 3: - at_risk.append(item) - - # Workload - wl = team_workload.setdefault(assignee_name, { - "member": assignee_name, - "toDo": 0, "inProgress": 0, "codeReview": 0, "modified": 0, - "done": 0, "other": 0, "total": 0, - "pointsDone": 0, "pointsTotal": 0, "commentCount7d": 0, - }) - wl[group] = wl.get(group, 0) + 1 - wl["total"] += 1 - wl["pointsTotal"] += points - if group == "done": - wl["pointsDone"] += points - -# Process comments — extract recent ones, count per member -for key, comment_data in all_comments.items(): - for c in comment_data.get("comments", []): - created = c.get("created", "") - author = c.get("author", {}).get("displayName", "Unknown") - try: - dt = datetime.fromisoformat(created.replace("Z", "+00:00")) - if dt >= cutoff: - if author in team_workload: - team_workload[author]["commentCount7d"] += 1 - else: - team_workload.setdefault(author, { - "member": author, "toDo": 0, "inProgress": 0, "codeReview": 0, - "modified": 0, "done": 0, "other": 0, "total": 0, - "pointsDone": 0, "pointsTotal": 0, "commentCount7d": 1, - }) - except (ValueError, TypeError): - pass - -# Recently updated keys (derived from sprint issues' updated field — was a separate query) -recent_keys = [] -for issue in issues: - updated = issue.get("fields", {}).get("updated", "") - if updated: - try: - dt = datetime.fromisoformat(updated.replace("Z", "+00:00")) - if dt >= cutoff: - recent_keys.append(issue.get("key", "")) - except (ValueError, TypeError): - pass - -# Build roster -roster_out = [] -active_members = set(team_workload.keys()) -for m in roster: - name = m["name"] - wl = team_workload.get(name, {}) - roster_out.append({ - "name": name, "github": m["github"], - "hasItems": name in active_members, - "sprintItems": wl.get("total", 0), - "commentCount7d": wl.get("commentCount7d", 0), - "statusSummary": {g: wl.get(g, 0) for g in STATUS_ORDER if wl.get(g, 0) > 0}, - }) -# Non-roster assignees -roster_names = {m["name"] for m in roster} -for name in active_members - roster_names - {"Unassigned"}: - wl = team_workload[name] - roster_out.append({ - "name": name, "github": "", "hasItems": True, "offRoster": True, - "sprintItems": wl.get("total", 0), - "commentCount7d": wl.get("commentCount7d", 0), - "statusSummary": {g: wl.get(g, 0) for g in STATUS_ORDER if wl.get(g, 0) > 0}, - }) - -by_status = {} -for group in sorted(status_groups.keys(), key=lambda g: STATUS_ORDER.get(g, 99)): - by_status[group] = sorted(status_groups[group], key=lambda i: i["assignee"]) - -# Group by assignee (alphabetical), sorted by status within each person -by_assignee = {} -for group_items in status_groups.values(): - for item in group_items: - by_assignee.setdefault(item["assignee"], []).append(item) -for name in by_assignee: - by_assignee[name].sort(key=lambda i: STATUS_ORDER.get(i["statusGroup"], 99)) - -result = { - "sprint": { - "id": sprint["id"], "name": sprint["name"], - "startDate": sprint["startDate"], "endDate": sprint["endDate"], - "goal": sprint.get("goal", ""), - "daysElapsed": elapsed_days, "daysTotal": total_days, "daysRemaining": days_remaining, - }, - "summary": { - "total": len(issues), - "done": len(status_groups.get("done", [])), - "codeReview": len(status_groups.get("codeReview", [])), - "inProgress": len(status_groups.get("inProgress", [])), - "modified": len(status_groups.get("modified", [])), - "toDo": len(status_groups.get("toDo", [])), - "other": len(status_groups.get("other", [])), - "totalPoints": total_points, - "donePoints": done_points, - }, - "byStatus": by_status, - "byAssignee": dict(sorted(by_assignee.items(), key=lambda x: (x[0] == "Unassigned", x[0].lower()))), - "blockers": blocked_items, - "atRisk": at_risk, - "recentlyUpdatedKeys": recent_keys, - "memberActivity": roster_out, - "teamWorkload": sorted(team_workload.values(), key=lambda w: w["total"], reverse=True), -} - -print(json.dumps(result)) -PYEOF -} diff --git a/plugins/node-team/scripts/lib/composite/team-activity.sh b/plugins/node-team/scripts/lib/composite/team-activity.sh deleted file mode 100644 index ea0c07007..000000000 --- a/plugins/node-team/scripts/lib/composite/team-activity.sh +++ /dev/null @@ -1,151 +0,0 @@ -#!/bin/bash -# Composite: team-activity -# Per-member sprint items + comment counts for the last 7 days -# Serves: supplements /standup, /team-member - -[[ -n "${_COMPOSITE_TEAM_ACTIVITY_LOADED:-}" ]] && return 0 -_COMPOSITE_TEAM_ACTIVITY_LOADED=1 - -cmd_team_activity() { - local team="${1:?Team required}" - shift - local sprint_ref="" - while [[ $# -gt 0 ]]; do - case "$1" in - --sprint) sprint_ref="${2:?--sprint requires a value}"; shift 2 ;; - *) shift ;; - esac - done - - team_config "$team" - - local sprint_json - sprint_json=$(resolve_sprint "$team" "$sprint_ref") || return 1 - - local sprint_id - sprint_id=$(echo "$sprint_json" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])") - - # ── Get issues + roster ────────────────────────────────────────────────── - parallel_init - parallel_run "issues" cmd_sprint_issues "$sprint_id" - parallel_run "roster" team_roster "$team" - parallel_wait_all || true - - local issues_json roster_json - issues_json=$(parallel_get "issues") - roster_json=$(parallel_get "roster") - - # ── Get issue keys and fetch comments in parallel ──────────────────────── - local issue_keys - issue_keys=$(echo "$issues_json" | python3 -c " -import json, sys -data = json.load(sys.stdin) -keys = [i['key'] for i in data.get('issues', [])] -print(' '.join(keys)) -") - - parallel_cleanup 2>/dev/null || true - parallel_init - - for key in $issue_keys; do - parallel_run "comments_${key}" cmd_comments "$key" - done - parallel_wait_all 2>/dev/null || true - - # Collect comments - local comments_combined="{" - local first=true - for key in $issue_keys; do - local c - c=$(parallel_get "comments_${key}" 2>/dev/null) - if [[ -n "$c" && "$c" != *"error"* ]]; then - [[ "$first" == "true" ]] && first=false || comments_combined+="," - comments_combined+="\"${key}\":${c}" - fi - done - comments_combined+="}" - - local adf_py - adf_py="$(cd "$(dirname "${BASH_SOURCE[0]}")/../util" && pwd)/adf.py" - - python3 - "$sprint_json" "$roster_json" "$comments_combined" "$adf_py" "$issues_json" <<'PYEOF' -import json, sys, importlib.util -from datetime import datetime, timedelta, timezone -from collections import Counter - -sprint = json.loads(sys.argv[1]) -roster = json.loads(sys.argv[2]) -all_comments = json.loads(sys.argv[3]) -adf_py_path = sys.argv[4] -data = json.loads(sys.argv[5]) -issues = data.get("issues", []) - -spec = importlib.util.spec_from_file_location("adf", adf_py_path) -adf_mod = importlib.util.module_from_spec(spec) -spec.loader.exec_module(adf_mod) - -cutoff = datetime.now(timezone.utc) - timedelta(days=7) - -# Build member activity -member_items = {} -member_comments = Counter() - -for issue in issues: - f = issue.get("fields", {}) - assignee = (f.get("assignee") or {}).get("displayName", "Unassigned") - status = f.get("status", {}).get("name", "") - member_items.setdefault(assignee, []).append({ - "key": issue.get("key", ""), - "summary": f.get("summary", ""), - "status": status, - "points": f.get("customfield_10028") or 0, - }) - -# Count recent comments -for key, comment_data in all_comments.items(): - for c in comment_data.get("comments", []): - created = c.get("created", "") - author = c.get("author", {}).get("displayName", "Unknown") - try: - dt = datetime.fromisoformat(created.replace("Z", "+00:00")) - if dt >= cutoff: - member_comments[author] += 1 - except (ValueError, TypeError): - pass - -# Build output -members = [] -roster_names = {m["name"] for m in roster} -all_names = roster_names | set(member_items.keys()) - {"Unassigned"} - -for m in roster: - name = m["name"] - items = member_items.get(name, []) - members.append({ - "name": name, - "github": m["github"], - "sprintItems": items, - "sprintItemCount": len(items), - "commentCount7d": member_comments.get(name, 0), - "onRoster": True, - }) - -for name in set(member_items.keys()) - roster_names - {"Unassigned"}: - items = member_items[name] - members.append({ - "name": name, - "github": "", - "sprintItems": items, - "sprintItemCount": len(items), - "commentCount7d": member_comments.get(name, 0), - "onRoster": False, - }) - -result = { - "sprint": {"id": sprint["id"], "name": sprint["name"]}, - "members": sorted(members, key=lambda m: m["sprintItemCount"], reverse=True), -} - -print(json.dumps(result)) -PYEOF -} diff --git a/plugins/node-team/scripts/lib/core.sh b/plugins/node-team/scripts/lib/core.sh deleted file mode 100644 index 15fe989d7..000000000 --- a/plugins/node-team/scripts/lib/core.sh +++ /dev/null @@ -1,88 +0,0 @@ -#!/bin/bash -# Core library: auth, HTTP, constants, logging -# Sourced by all other modules — never executed directly - -[[ -n "${_CORE_LOADED:-}" ]] && return 0 -_CORE_LOADED=1 - -# ── Constants ────────────────────────────────────────────────────────────────── - -JIRA_BASE="https://redhat.atlassian.net" -BOARD_ID="${JIRA_BOARD_ID:-7845}" - -# Custom field IDs -CF_SPRINT="customfield_10020" -CF_STORY_POINTS="customfield_10028" -CF_EPIC_LINK="customfield_10014" -CF_TARGET_VERSION="customfield_10855" -CF_RELEASE_BLOCKER="customfield_10847" -CF_SFDC_COUNTER="customfield_10978" -CF_SFDC_LINKS="customfield_10979" -CF_SEVERITY="customfield_10840" -CF_BLOCKED="customfield_10517" -CF_BLOCKED_REASON="customfield_10483" - -# Standard fields requested by search/sprint-issues -ISSUE_FIELDS="key,summary,status,assignee,priority,issuetype,fixVersions,components,${CF_SPRINT},${CF_STORY_POINTS},${CF_EPIC_LINK},${CF_BLOCKED},${CF_BLOCKED_REASON},${CF_RELEASE_BLOCKER}" -SEARCH_FIELDS_JSON="[\"key\",\"summary\",\"status\",\"assignee\",\"priority\",\"issuetype\",\"fixVersions\",\"components\",\"${CF_SPRINT}\",\"${CF_STORY_POINTS}\",\"${CF_EPIC_LINK}\",\"${CF_BLOCKED}\",\"${CF_BLOCKED_REASON}\",\"${CF_RELEASE_BLOCKER}\"]" - -# ── Logging ──────────────────────────────────────────────────────────────────── - -_log() { - local level="$1"; shift - echo "[$(date -u +%H:%M:%S)] ${level}: $*" >&2 -} - -# ── Python check ─────────────────────────────────────────────────────────────── - -_check_python() { - command -v python3 >/dev/null 2>&1 || { - echo '{"error":"python3 is required but not found"}' >&2 - exit 1 - } -} - -# ── Auth ─────────────────────────────────────────────────────────────────────── - -_init_auth() { - [[ -n "${_AUTH_INITIALIZED:-}" ]] && return 0 - _AUTH_INITIALIZED=1 - - JIRA_API_TOKEN="${JIRA_API_TOKEN:-$(security find-generic-password -s "JIRA_API_TOKEN" -w 2>/dev/null || true)}" - if [[ -z "$JIRA_API_TOKEN" ]]; then - echo '{"error": "JIRA_API_TOKEN not set (env var or Keychain)"}' >&2; exit 1 - fi - - JIRA_USER=$(security find-generic-password -s "JIRA_API_TOKEN" -g 2>&1 | grep "acct" | sed 's/.*="//;s/"//' 2>/dev/null) || true - if [[ -n "$JIRA_USER" && ! "$JIRA_USER" =~ "@" ]]; then - JIRA_USER="${JIRA_USER}@redhat.com" - fi - if [[ -z "$JIRA_USER" ]]; then - JIRA_USER="${JIRA_EMAIL:-$(git config user.email 2>/dev/null || echo "")}" - fi - if [[ -z "$JIRA_USER" ]]; then - echo '{"error": "Cannot determine Jira email. Set JIRA_EMAIL env var."}' >&2; exit 1 - fi - - AUTH="-u ${JIRA_USER}:${JIRA_API_TOKEN}" -} - -# ── HTTP ─────────────────────────────────────────────────────────────────────── - -_curl() { - _init_auth - curl -s $AUTH -H "Content-Type: application/json" "$@" -} - -# ── Utilities ────────────────────────────────────────────────────────────────── - -_jql_encode() { - python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$1" -} - -# ADF-to-text conversion via Python helper -_adf_to_text() { - local script_dir - script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - python3 "${script_dir}/util/adf.py" "$@" -} diff --git a/plugins/node-team/scripts/lib/team.sh b/plugins/node-team/scripts/lib/team.sh deleted file mode 100644 index 2faae052b..000000000 --- a/plugins/node-team/scripts/lib/team.sh +++ /dev/null @@ -1,295 +0,0 @@ -#!/bin/bash -# Team configuration: resolves team name to sprint filter, roster, bug components -# Sourced by jira.sh — requires core.sh - -[[ -n "${_TEAM_LOADED:-}" ]] && return 0 -_TEAM_LOADED=1 - -# Print a clear error when a roster file is missing, then exit -_roster_missing() { - local file="$1" - local example="${file%.json}.example.json" - cat >&2 <&2 - return 1 - ;; - esac -} - -# Load team roster as JSON array: [{name, github}, ...] -team_roster() { - local team="${1:-}" - [[ -n "$team" ]] && team_config "$team" - - local root_dir - root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" - - # "all" team: merge both rosters (deduplicated) - if [[ -z "$TEAM_ROSTER_FILE" ]]; then - local na_dir="${HOME}/.node-assistant" - local dra_file="${na_dir}/team-roster-dra.json" - local core_file="${na_dir}/team-roster-core.json" - [[ ! -f "$dra_file" ]] && dra_file="${root_dir}/config/team-roster-dra.json" - [[ ! -f "$core_file" ]] && core_file="${root_dir}/config/team-roster-core.json" - if [[ ! -f "$dra_file" || ! -f "$core_file" ]]; then - if type -t cmd_roster_sync >/dev/null 2>&1; then - _log "INFO" "Roster(s) not found, attempting auto-sync..." - cmd_roster_sync >/dev/null 2>&1 || true - [[ ! -f "$dra_file" && -f "${na_dir}/team-roster-dra.json" ]] && dra_file="${na_dir}/team-roster-dra.json" - [[ ! -f "$core_file" && -f "${na_dir}/team-roster-core.json" ]] && core_file="${na_dir}/team-roster-core.json" - fi - [[ ! -f "$dra_file" ]] && _roster_missing "$dra_file" - [[ ! -f "$core_file" ]] && _roster_missing "$core_file" - fi - python3 -c " -import json, sys -members = {} -for f in sys.argv[1:]: - with open(f) as fh: - for k, v in json.load(fh).get('members', {}).items(): - members[k] = v -print(json.dumps([{'name': k, 'github': v} for k, v in members.items()])) -" "$dra_file" "$core_file" - return 0 - fi - - if [[ ! -f "$TEAM_ROSTER_FILE" ]]; then - if type -t cmd_roster_sync >/dev/null 2>&1; then - _log "INFO" "Roster not found, attempting auto-sync..." - cmd_roster_sync >/dev/null 2>&1 || true - [[ -n "${1:-}" ]] && team_config "$1" - fi - if [[ ! -f "$TEAM_ROSTER_FILE" ]]; then - _roster_missing "$TEAM_ROSTER_FILE" - fi - fi - - python3 -c " -import json, sys -with open(sys.argv[1]) as f: - data = json.load(f) -members = [{'name': k, 'github': v} for k, v in data.get('members', {}).items()] -print(json.dumps(members)) -" "$TEAM_ROSTER_FILE" -} - -# Find the active (or specified state) sprint for a team -# Returns JSON: {id, name, startDate, endDate, goal} -team_sprint() { - local team="$1" - local state="${2:-active}" - - [[ -z "${TEAM_SPRINT_FILTER:-}" ]] && team_config "$team" - - local sprints - if type -t cached_sprints >/dev/null 2>&1; then - sprints=$(cached_sprints "$state") - else - sprints=$(cmd_sprints "$state") - fi - - python3 -c " -import json, sys -data = json.loads(sys.argv[1]) -team_filter = sys.argv[2] -for s in data.get('values', []): - if team_filter in s.get('name', ''): - print(json.dumps({ - 'id': s['id'], - 'name': s['name'], - 'startDate': s.get('startDate', ''), - 'endDate': s.get('endDate', ''), - 'goal': s.get('goal', '') - })) - sys.exit(0) -print(json.dumps({'error': f'No {team_filter} sprint found with state={sys.argv[3]}'}), file=sys.stderr) -sys.exit(1) -" "$sprints" "$TEAM_SPRINT_FILTER" "$state" -} - -# Resolve a sprint by name substring or numeric ID. -# If sprint_ref is empty, falls back to active → future. -# Usage: resolve_sprint "core" "" → active sprint for core -# resolve_sprint "core" "Sprint 288" → sprint matching "Sprint 288" -# resolve_sprint "core" "65617" → sprint with id 65617 -resolve_sprint() { - local team="$1" - local sprint_ref="${2:-}" - - [[ -z "${TEAM_SPRINT_FILTER:-}" ]] && team_config "$team" - - # No ref → default: active, then future - if [[ -z "$sprint_ref" ]]; then - local result - result=$(team_sprint "$team" active 2>/dev/null) && { echo "$result"; return 0; } - result=$(team_sprint "$team" future 2>/dev/null) && { echo "$result"; return 0; } - echo '{"error":"No active or future sprint found for '"$team"'"}' >&2 - return 1 - fi - - # Search active, future, and closed sprints for a match - local all_sprints - all_sprints=$( - for state in active future closed; do - cmd_sprints "$state" 2>/dev/null || true - done | python3 -c ' -import json, sys -out = [] -for line in sys.stdin.read().split("\n"): - line = line.strip() - if not line: - continue - try: - d = json.loads(line) - except json.JSONDecodeError: - continue - out.extend(d.get("values", [])) -print(json.dumps(out)) -' - ) - - python3 -c " -import json, sys - -ref = sys.argv[1] -sprints = json.loads(sys.argv[2] or '[]') - -# Try numeric ID first -if ref.isdigit(): - ref_id = int(ref) - for s in sprints: - if s.get('id') == ref_id: - print(json.dumps({'id': s['id'], 'name': s['name'], 'startDate': s.get('startDate',''), 'endDate': s.get('endDate',''), 'goal': s.get('goal','')})) - sys.exit(0) - -# Substring match on name (case-insensitive) -ref_lower = ref.lower() -for s in sprints: - if ref_lower in s.get('name', '').lower(): - print(json.dumps({'id': s['id'], 'name': s['name'], 'startDate': s.get('startDate',''), 'endDate': s.get('endDate',''), 'goal': s.get('goal','')})) - sys.exit(0) - -print(json.dumps({'error': f'No sprint matching \"{ref}\" found'}), file=sys.stderr) -sys.exit(1) -" "$sprint_ref" "$all_sprints" -} - -# Find the active sprint, falling back to the most recently closed sprint. -# Returns JSON: {id, name, startDate, endDate, goal, state} -# The "state" field indicates whether this is "active" or "closed" (fallback). -team_sprint_fallback() { - local team="$1" - - [[ -z "${TEAM_SPRINT_FILTER:-}" ]] && team_config "$team" - - # Try active first - local active_result - active_result=$(team_sprint "$team" active 2>/dev/null) && { - # Add state field so callers know this is a live active sprint - echo "$active_result" | python3 -c " -import json, sys -d = json.load(sys.stdin) -d['state'] = 'active' -print(json.dumps(d)) -" - return 0 - } - - # Fall back to most recently closed sprint - local closed_sprints - if type -t cached_sprints >/dev/null 2>&1; then - closed_sprints=$(cached_sprints "closed") - else - closed_sprints=$(cmd_sprints "closed") - fi - - python3 -c " -import json, sys -data = json.loads(sys.argv[1]) -team_filter = sys.argv[2] -# cmd_sprints already sorts by startDate descending, so first match is most recent -for s in data.get('values', []): - if team_filter in s.get('name', ''): - print(json.dumps({ - 'id': s['id'], - 'name': s['name'], - 'startDate': s.get('startDate', ''), - 'endDate': s.get('endDate', ''), - 'goal': s.get('goal', ''), - 'state': 'closed', - })) - sys.exit(0) -print(json.dumps({'error': f'No {team_filter} sprint found (active or closed)'}), file=sys.stderr) -sys.exit(1) -" "$closed_sprints" "$TEAM_SPRINT_FILTER" -} diff --git a/plugins/node-team/scripts/lib/util/adf.py b/plugins/node-team/scripts/lib/util/adf.py deleted file mode 100644 index 90fc68b03..000000000 --- a/plugins/node-team/scripts/lib/util/adf.py +++ /dev/null @@ -1,158 +0,0 @@ -#!/usr/bin/env python3 -"""Convert Atlassian Document Format (ADF) JSON to plain text. - -Usage: - echo '' | python3 adf.py # Convert raw ADF node - echo '' | python3 adf.py --field description # Extract ADF field from issue - echo '' | python3 adf.py --comments # Extract all comments with metadata - echo '' | python3 adf.py --issues # Extract from search/sprint-issues result -""" - -import json -import sys -from datetime import datetime, timedelta, timezone - - -def adf_to_text(node): - """Recursively convert an ADF node tree to plain text.""" - if isinstance(node, str): - return node - if not isinstance(node, dict): - return "" - - node_type = node.get("type", "") - text = "" - - if node_type == "text": - t = node.get("text", "") - # Check for link marks - for mark in node.get("marks", []): - if mark.get("type") == "link": - href = mark.get("attrs", {}).get("href", "") - if href and href != t: - t = f"{t} ({href})" - text = t - elif node_type in ("blockCard", "inlineCard", "embedCard"): - url = node.get("attrs", {}).get("url", "") - if url: - text = url + "\n" - elif node_type == "mediaInline": - alt = node.get("attrs", {}).get("alt", "") - text = f"[{alt or 'attachment'}]" - elif node_type == "mention": - text = "@" + node.get("attrs", {}).get("text", node.get("attrs", {}).get("id", "")) - - for child in node.get("content", []): - text += adf_to_text(child) - - if node_type in ("paragraph", "heading", "listItem", "blockquote"): - text += "\n" - elif node_type == "hardBreak": - text += "\n" - elif node_type in ("codeBlock",): - text += "\n" - return text - - -def extract_field(issue, field_name): - """Extract an ADF field from an issue JSON and convert to text.""" - fields = issue.get("fields", issue) - adf = fields.get(field_name) - if not adf: - return "" - if isinstance(adf, str): - return adf - return adf_to_text(adf).strip() - - -def extract_comments(data, since_days=None): - """Extract comments from a comments API response. - - Returns list of {author, date, body} dicts. - If since_days is set, only returns comments from the last N days. - """ - cutoff = None - if since_days is not None: - cutoff = datetime.now(timezone.utc) - timedelta(days=since_days) - - comments = data.get("comments", []) - results = [] - for c in comments: - created = c.get("created", "") - if cutoff and created: - try: - dt = datetime.fromisoformat(created.replace("Z", "+00:00")) - if dt < cutoff: - continue - except (ValueError, TypeError): - pass - - author = c.get("author", {}).get("displayName", "Unknown") - body_adf = c.get("body", {}) - body_text = adf_to_text(body_adf).strip() if isinstance(body_adf, dict) else str(body_adf) - - results.append({ - "author": author, - "created": created, - "body": body_text, - }) - return results - - -def extract_issues(data): - """Extract description text from each issue in a search/sprint-issues response.""" - issues = data.get("issues", []) - results = [] - for issue in issues: - key = issue.get("key", "") - fields = issue.get("fields", {}) - desc_adf = fields.get("description") - desc_text = "" - if isinstance(desc_adf, dict): - desc_text = adf_to_text(desc_adf).strip() - elif isinstance(desc_adf, str): - desc_text = desc_adf.strip() - - blocked_reason = fields.get("customfield_10483") - blocked_text = "" - if isinstance(blocked_reason, dict): - blocked_text = adf_to_text(blocked_reason).strip() - elif isinstance(blocked_reason, str): - blocked_text = blocked_reason.strip() - - results.append({ - "key": key, - "description": desc_text, - "blockedReason": blocked_text, - }) - return results - - -def main(): - args = sys.argv[1:] - data = json.load(sys.stdin) - - if "--field" in args: - idx = args.index("--field") - field_name = args[idx + 1] if idx + 1 < len(args) else "description" - print(extract_field(data, field_name)) - - elif "--comments" in args: - since = None - if "--since-days" in args: - si = args.index("--since-days") - since = int(args[si + 1]) if si + 1 < len(args) else None - results = extract_comments(data, since_days=since) - print(json.dumps(results)) - - elif "--issues" in args: - results = extract_issues(data) - print(json.dumps(results)) - - else: - # Raw ADF node conversion - print(adf_to_text(data).strip()) - - -if __name__ == "__main__": - main() diff --git a/plugins/node-team/scripts/lib/util/cache.sh b/plugins/node-team/scripts/lib/util/cache.sh deleted file mode 100644 index 5b7e96309..000000000 --- a/plugins/node-team/scripts/lib/util/cache.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/bin/bash -# File-based caching for sprint discovery and other slow queries -# Cache lives in $TMPDIR, scoped to process tree, auto-cleaned on exit - -[[ -n "${_CACHE_LOADED:-}" ]] && return 0 -_CACHE_LOADED=1 - -_CACHE_TTL="${JIRA_CACHE_TTL:-300}" # 5 minutes default -_CACHE_DIR="" - -_cache_init() { - if [[ -z "$_CACHE_DIR" ]]; then - _CACHE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/jira-cache-$$.XXXXXX") - local existing - existing=$(trap -p EXIT | sed -E "s/^trap -- '(.*)' EXIT$/\1/") - if [[ -n "$existing" ]]; then - trap "_cache_cleanup; ${existing}" EXIT - else - trap '_cache_cleanup' EXIT - fi - fi -} - -_cache_cleanup() { - if [[ -n "$_CACHE_DIR" && -d "$_CACHE_DIR" ]]; then - rm -rf "$_CACHE_DIR" - fi -} - -cache_get() { - _cache_init - local key="$1" - local file="${_CACHE_DIR}/${key}" - if [[ -f "$file" ]]; then - local age - age=$(( $(date +%s) - $(stat -f %m "$file" 2>/dev/null || stat -c %Y "$file" 2>/dev/null || echo 0) )) - if (( age < _CACHE_TTL )); then - cat "$file" - return 0 - fi - rm -f "$file" - fi - return 1 -} - -cache_set() { - _cache_init - local key="$1" - local value="$2" - echo "$value" > "${_CACHE_DIR}/${key}" -} - -# Cache-through wrapper for sprint discovery -cached_sprints() { - local state="${1:-active}" - local cache_key="sprints_${state}" - local cached - if cached=$(cache_get "$cache_key" 2>/dev/null); then - echo "$cached" - return 0 - fi - local result - result=$(cmd_sprints "$state") - cache_set "$cache_key" "$result" - echo "$result" -} diff --git a/plugins/node-team/scripts/lib/util/parallel.sh b/plugins/node-team/scripts/lib/util/parallel.sh deleted file mode 100644 index 33998e1b5..000000000 --- a/plugins/node-team/scripts/lib/util/parallel.sh +++ /dev/null @@ -1,128 +0,0 @@ -#!/bin/bash -# Parallel job management for composite commands -# Runs multiple API calls concurrently using background jobs + temp files - -[[ -n "${_PARALLEL_LOADED:-}" ]] && return 0 -_PARALLEL_LOADED=1 - -_PARALLEL_DIR="" -_PARALLEL_PID_LIST="" # space-separated "name:pid" pairs - -parallel_init() { - _PARALLEL_DIR=$(mktemp -d "${TMPDIR:-/tmp}/jira-parallel-$$.XXXXXX") - _PARALLEL_PID_LIST="" - trap 'parallel_cleanup' EXIT -} - -parallel_run() { - local name="$1" - shift - ( "$@" > "${_PARALLEL_DIR}/${name}.json" 2>"${_PARALLEL_DIR}/${name}.err" ) & - _PARALLEL_PID_LIST="${_PARALLEL_PID_LIST} ${name}:$!" -} - -parallel_wait_all() { - local failed=0 - local entry pid name - for entry in $_PARALLEL_PID_LIST; do - name="${entry%%:*}" - pid="${entry##*:}" - if ! wait "$pid" 2>/dev/null; then - failed=1 - _log "WARN" "Parallel job '${name}' failed (PID ${pid})" - fi - done - _PARALLEL_PID_LIST="" - return $failed -} - -parallel_get() { - local name="$1" - local outfile="${_PARALLEL_DIR}/${name}.json" - if [[ -f "$outfile" ]]; then - cat "$outfile" - else - echo "{\"error\":\"No result for job '${name}'\"}" - fi -} - -parallel_get_err() { - local name="$1" - local errfile="${_PARALLEL_DIR}/${name}.err" - if [[ -f "$errfile" && -s "$errfile" ]]; then - cat "$errfile" - fi -} - -parallel_cleanup() { - if [[ -n "${_PARALLEL_DIR:-}" && -d "${_PARALLEL_DIR:-}" ]]; then - rm -rf "$_PARALLEL_DIR" - fi - _PARALLEL_DIR="" - _PARALLEL_PID_LIST="" -} - -# Run a batch of commands with limited concurrency -parallel_batch() { - local concurrency="$1" - local func="$2" - shift 2 - local args=("$@") - local running=0 - - for arg in "${args[@]}"; do - parallel_run "$arg" "$func" "$arg" - running=$((running + 1)) - if (( running >= concurrency )); then - wait -n 2>/dev/null || true - running=$((running - 1)) - fi - done - parallel_wait_all -} - -# Stream results as JSON Lines — emit each completed job as a {_section, data} line -# Usage: parallel_stream_wait -# Polls every 200ms, emits results as jobs complete -parallel_stream_wait() { - local prefix="${1:-data}" - local emitted="" - local all_done=false - local start_ms=$(($(date +%s) * 1000)) - - while [[ "$all_done" != "true" ]]; do - all_done=true - for entry in $_PARALLEL_PID_LIST; do - local name="${entry%%:*}" - local pid="${entry##*:}" - - # Skip already emitted - [[ "$emitted" == *" ${name} "* ]] && continue - - # Check if done - if ! kill -0 "$pid" 2>/dev/null; then - # Job finished — emit result - local outfile="${_PARALLEL_DIR}/${name}.json" - if [[ -f "$outfile" && -s "$outfile" ]]; then - local elapsed_ms=$(( $(date +%s) * 1000 - start_ms )) - python3 -c " -import json, sys -with open(sys.argv[1]) as f: - data = json.load(f) -print(json.dumps({'_section': sys.argv[2], '_job': sys.argv[3], '_elapsed_ms': int(sys.argv[4]), 'data': data})) -" "$outfile" "$prefix" "$name" "$elapsed_ms" - fi - emitted="${emitted} ${name} " - else - all_done=false - fi - done - - [[ "$all_done" != "true" ]] && sleep 0.2 - done - - # Emit completion marker - local total_ms=$(( $(date +%s) * 1000 - start_ms )) - echo "{\"_section\":\"complete\",\"elapsed_ms\":${total_ms}}" - _PARALLEL_PID_LIST="" -} diff --git a/plugins/node-team/scripts/lib/util/retry.sh b/plugins/node-team/scripts/lib/util/retry.sh deleted file mode 100644 index 4e2d6b9d0..000000000 --- a/plugins/node-team/scripts/lib/util/retry.sh +++ /dev/null @@ -1,99 +0,0 @@ -#!/bin/bash -# Retry and error handling for HTTP requests -# Sourced by core.sh — provides _curl_with_retry wrapping _curl - -[[ -n "${_RETRY_LOADED:-}" ]] && return 0 -_RETRY_LOADED=1 - -_RETRY_MAX="${JIRA_RETRY_MAX:-3}" -_RETRY_TIMEOUT="${JIRA_TIMEOUT:-30}" - -_curl_with_retry() { - local attempt=0 - local delay=1 - local tmpfile hdrfile - tmpfile=$(mktemp) - hdrfile=$(mktemp) - trap "rm -f '$tmpfile' '$hdrfile'" RETURN - - while (( attempt < _RETRY_MAX )); do - attempt=$((attempt + 1)) - local http_code - - # Execute curl, capture body to tmpfile and HTTP code to variable - http_code=$(curl -s $AUTH -H "Content-Type: application/json" \ - --max-time "$_RETRY_TIMEOUT" \ - -w "%{http_code}" \ - -D "$hdrfile" \ - -o "$tmpfile" \ - "$@" 2>/dev/null) || { - # curl itself failed (timeout, DNS, connection error) - if (( attempt < _RETRY_MAX )); then - _log "WARN" "curl failed (attempt ${attempt}/${_RETRY_MAX}), retrying in ${delay}s..." - sleep "$delay" - delay=$((delay * 2)) - continue - fi - echo '{"error":"Request failed after retries","cause":"connection"}' >&2 - return 1 - } - - case "$http_code" in - 2[0-9][0-9]) - # Success — output the body - cat "$tmpfile" - return 0 - ;; - 429) - # Rate limited — respect Retry-After if present - local retry_after - retry_after=$(grep -i "^retry-after:" "$hdrfile" 2>/dev/null | awk '{print $2}' || echo "$delay") - retry_after=${retry_after:-$delay} - if (( attempt < _RETRY_MAX )); then - _log "WARN" "Rate limited (429), retrying in ${retry_after}s (attempt ${attempt}/${_RETRY_MAX})..." - sleep "$retry_after" - delay=$((delay * 2)) - continue - fi - _log "ERROR" "Rate limited after ${_RETRY_MAX} retries" - echo "{\"error\":\"Rate limited\",\"httpCode\":429}" >&2 - return 1 - ;; - 5[0-9][0-9]) - # Server error — retry with backoff - if (( attempt < _RETRY_MAX )); then - _log "WARN" "Server error (${http_code}), retrying in ${delay}s (attempt ${attempt}/${_RETRY_MAX})..." - sleep "$delay" - delay=$((delay * 2)) - continue - fi - _log "ERROR" "Server error ${http_code} after ${_RETRY_MAX} retries" - echo "{\"error\":\"Server error\",\"httpCode\":${http_code}}" >&2 - return 1 - ;; - 4[0-9][0-9]) - # Client error — do not retry (400, 401, 403, 404, etc.) - _log "ERROR" "Client error: HTTP ${http_code}" - cat "$tmpfile" >&2 - return 1 - ;; - *) - _log "ERROR" "Unexpected HTTP status: ${http_code}" - cat "$tmpfile" >&2 - return 1 - ;; - esac - done -} - -# Graceful fallback: returns partial result with error marker instead of failing -_graceful_fallback() { - local section="$1" - shift - local result - if result=$("$@" 2>/dev/null); then - echo "$result" - else - echo "{\"_section\":\"${section}\",\"error\":\"Failed to fetch ${section}\"}" - fi -} diff --git a/plugins/node-team/scripts/ocp-install.sh b/plugins/node-team/scripts/ocp-install.sh deleted file mode 100755 index 84f084997..000000000 --- a/plugins/node-team/scripts/ocp-install.sh +++ /dev/null @@ -1,810 +0,0 @@ -#!/usr/bin/env bash -# -# ocp-install.sh — OpenShift cluster lifecycle manager -# -# Usage: -# ./ocp-install.sh download -# ./ocp-install.sh create [cluster-name] -# ./ocp-install.sh destroy -# ./ocp-install.sh debug -# ./ocp-install.sh list [version] -# ./ocp-install.sh kubeconfig -# -# Types: regular, sno, gpu, sno-cpu -# Platform: GCP (openshift-gce-devel) -# -# Secrets: -# Pull secret read from OS secret store (OCP_PULL_SECRET). -# SSH key read from ~/.ssh/id_rsa.pub. -# -# One-time setup (macOS): -# security add-generic-password -a "$USER" -s "OCP_PULL_SECRET" \ -# -w "$(cat ~/clusters/pull-secret-gcp.txt | python3 -c \ -# "import sys,json; print(json.dumps(json.load(sys.stdin), separators=(',',':')))")" -# -# One-time setup (Linux): -# cat ~/clusters/pull-secret-gcp.txt | python3 -c \ -# "import sys,json; print(json.dumps(json.load(sys.stdin), separators=(',',':')))" | \ -# secret-tool store --label="OCP Pull Secret" service ocp-install username "$USER" key OCP_PULL_SECRET -# -set -euo pipefail - -CLUSTERS_DIR="${CLUSTERS_DIR:-$HOME/clusters}" -ARTIFACTS_BASE="https://openshift-release-artifacts.apps.ci.l2s4.p1.openshiftapps.com" -SSH_KEY_FILE="${SSH_KEY_FILE:-$HOME/.ssh/id_rsa.pub}" -GCP_PROJECT="openshift-gce-devel" -GCP_REGION="us-central1" -GCP_GPU_ZONE="us-central1-f" -BASE_DOMAIN="gcp.devcluster.openshift.com" -NAME_PREFIX="${OCP_NAME_PREFIX:-$USER}" - -# ─── helpers ──────────────────────────────────────────────────────────────── - -die() { echo "ERROR: $*" >&2; exit 1; } -info() { echo "==> $*"; } - -major_minor() { - # 4.21.3 → 4.21, 4.21.0-ec.1 → 4.21 - echo "$1" | sed -E 's/^([0-9]+\.[0-9]+).*/\1/' -} - -version_dir() { - echo "${CLUSTERS_DIR}/$(major_minor "$1")/${1}" -} - -installer_bin() { - echo "$(version_dir "$1")/openshift-install" -} - -next_cluster_dir() { - local vdir - vdir="$(version_dir "$1")" - local n=1 - while [[ -d "${vdir}/cluster${n}" ]]; do - ((n++)) - done - echo "cluster${n}" -} - -random_suffix() { - LC_ALL=C tr -dc 'a-z0-9' &1 \ - | grep '^password: "' | sed 's/^password: "//;s/"$//')" || true - ;; - Linux) - # Linux: read from GNOME Keyring / libsecret via secret-tool - secret="$(secret-tool lookup service ocp-install key OCP_PULL_SECRET 2>/dev/null)" || true - ;; - esac - fi - if [[ -z "$secret" ]]; then - # Fallback: try file - local fallback="${CLUSTERS_DIR}/pull-secret-gcp.txt" - if [[ -f "$fallback" ]]; then - secret="$(python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin), separators=(',',':')))" < "$fallback")" - else - if [[ "$(uname -s)" == "Darwin" ]]; then - die "Pull secret not found. Store it in Keychain:\n security add-generic-password -a \"\$USER\" -s \"OCP_PULL_SECRET\" -w '\$(cat pull-secret.json)'" - else - die "Pull secret not found. Store it with secret-tool:\n cat pull-secret.json | secret-tool store --label=\"OCP Pull Secret\" service ocp-install username \"\$USER\" key OCP_PULL_SECRET" - fi - fi - fi - echo "$secret" -} - -require_ssh_key() { - [[ -f "$SSH_KEY_FILE" ]] || die "SSH key not found at ${SSH_KEY_FILE}" -} - -require_installer() { - local bin - bin="$(installer_bin "$1")" - [[ -x "$bin" ]] || die "openshift-install not found for ${1}. Run: $0 download ${1}" -} - -# ─── download ─────────────────────────────────────────────────────────────── - -cmd_download() { - local version="${1:?Usage: $0 download }" - local vdir - vdir="$(version_dir "$version")" - local bin="${vdir}/openshift-install" - - if [[ -x "$bin" ]]; then - info "openshift-install already exists at ${bin}" - "${bin}" version - return 0 - fi - - mkdir -p "$vdir" - - # Detect platform - local os arch tarball - os="$(uname -s | tr '[:upper:]' '[:lower:]')" - arch="$(uname -m)" - - case "${os}-${arch}" in - darwin-arm64) tarball="openshift-install-mac-arm64-${version}.tar.gz" ;; - darwin-x86_64) tarball="openshift-install-mac-${version}.tar.gz" ;; - linux-x86_64) tarball="openshift-install-linux-${version}.tar.gz" ;; - linux-aarch64) tarball="openshift-install-linux-arm64-${version}.tar.gz" ;; - *) die "Unsupported platform: ${os}-${arch}" ;; - esac - - local url="${ARTIFACTS_BASE}/${version}/${tarball}" - - info "Downloading ${tarball} ..." - if ! curl -fSL -o "${vdir}/${tarball}" "$url"; then - # Fallback: try oc adm release extract - info "Direct download failed. Trying oc adm release extract ..." - if command -v oc &>/dev/null; then - local release_arch - case "$arch" in - arm64|aarch64) release_arch="aarch64" ;; - *) release_arch="x86_64" ;; - esac - oc adm release extract --tools \ - --to="$vdir" \ - "quay.io/openshift-release-dev/ocp-release:${version}-${release_arch}" || \ - die "Failed to download openshift-install for ${version}" - else - die "Download failed and 'oc' not found for fallback extraction" - fi - fi - - if [[ -f "${vdir}/${tarball}" ]]; then - info "Extracting ..." - tar xzf "${vdir}/${tarball}" -C "$vdir" openshift-install 2>/dev/null || \ - tar xzf "${vdir}/${tarball}" -C "$vdir" - fi - chmod +x "$bin" - - info "Done. openshift-install ${version}:" - "${bin}" version -} - -# ─── install-config generation ────────────────────────────────────────────── - -generate_config() { - local type="$1" cluster_name="$2" - local pull_secret ssh_key - - pull_secret="$(get_pull_secret)" - ssh_key="$(cat "$SSH_KEY_FILE")" - - case "$type" in - regular) - cat < "${install_dir}/install-config.yaml" - - # Back up (consumed during install) - cp "${install_dir}/install-config.yaml" "${install_dir}/install-config.yaml.backup" - - info "Generated install-config.yaml for type '${type}'" - info "" - - # Confirm before proceeding - echo "--- install-config.yaml (summary) ---" - grep -E '^\s*(name|replicas|type|region|cpuPartitioning):' "${install_dir}/install-config.yaml.backup" || true - echo "--------------------------------------" - echo "" - read -rp "Proceed with cluster creation? [y/N] " confirm - [[ "$confirm" =~ ^[Yy]$ ]] || { info "Aborted."; exit 0; } - - info "Creating cluster (this will take 30-45 minutes) ..." - local bin - bin="$(installer_bin "$version")" - "${bin}" create cluster --dir="$install_dir" --log-level=info - - info "" - info "Cluster created successfully!" - info "" - info "Kubeconfig: export KUBECONFIG=${install_dir}/auth/kubeconfig" - info "Console: $(grep -o 'https://console-openshift.*' "${install_dir}/.openshift_install.log" 2>/dev/null | tail -1 || echo 'check install log')" - info "" - info "To destroy: $0 destroy ${version} ${cluster_dir}" -} - -# ─── destroy ──────────────────────────────────────────────────────────────── - -cmd_destroy() { - local version="${1:?Usage: $0 destroy }" - local cluster_dir="${2:?Usage: $0 destroy }" - - require_installer "$version" - - local vdir install_dir bin - vdir="$(version_dir "$version")" - install_dir="${vdir}/${cluster_dir}" - bin="$(installer_bin "$version")" - - [[ -d "$install_dir" ]] || die "Cluster directory not found: ${install_dir}" - - info "Destroying cluster at ${install_dir} ..." - read -rp "Are you sure? This cannot be undone. [y/N] " confirm - [[ "$confirm" =~ ^[Yy]$ ]] || { info "Aborted."; exit 0; } - - "${bin}" destroy cluster --dir="$install_dir" --log-level=info - - info "Cluster destroyed." -} - -# ─── debug ────────────────────────────────────────────────────────────────── - -cmd_debug() { - local version="${1:?Usage: $0 debug }" - local cluster_dir="${2:?Usage: $0 debug }" - - local vdir install_dir - vdir="$(version_dir "$version")" - install_dir="${vdir}/${cluster_dir}" - - [[ -d "$install_dir" ]] || die "Cluster directory not found: ${install_dir}" - - # Resolve cluster name from backup config, metadata, or install log - local cluster_name="" infra_id="" - if [[ -f "${install_dir}/metadata.json" ]]; then - cluster_name="$(python3 -c "import json; print(json.load(open('${install_dir}/metadata.json'))['clusterName'])" 2>/dev/null || true)" - infra_id="$(python3 -c "import json; print(json.load(open('${install_dir}/metadata.json'))['infraID'])" 2>/dev/null || true)" - fi - if [[ -z "$cluster_name" ]] && [[ -f "${install_dir}/install-config.yaml.backup" ]]; then - cluster_name="$(grep '^\s*name:' "${install_dir}/install-config.yaml.backup" | head -1 | awk '{print $2}')" - fi - # Fallback: extract cluster name from install log (api..gcp.devcluster...) - if [[ -z "$cluster_name" ]] && [[ -f "${install_dir}/.openshift_install.log" ]]; then - cluster_name="$(grep -o 'api\.[^.]*\.gcp' "${install_dir}/.openshift_install.log" 2>/dev/null \ - | head -1 | sed 's/^api\.//;s/\.gcp$//' || true)" - fi - # Fallback: try to find infra_id from log (lines like "Deleted network -network") - if [[ -z "$infra_id" ]] && [[ -f "${install_dir}/.openshift_install.log" ]]; then - infra_id="$(grep -o 'Deleted network [^ ]*-network' "${install_dir}/.openshift_install.log" 2>/dev/null \ - | head -1 | sed 's/^Deleted network //;s/-network$//' || true)" - fi - - echo "" - echo "==========================================" - echo " Cluster Debug: ${cluster_dir}" - echo "==========================================" - echo " Install dir: ${install_dir}" - echo " Cluster name: ${cluster_name:-unknown}" - echo " Infra ID: ${infra_id:-unknown}" - echo " Version: ${version}" - echo "==========================================" - - # ── 1. Local log analysis ── - echo "" - info "LOCAL LOGS" - echo "" - - local install_log="${install_dir}/.openshift_install.log" - if [[ -f "$install_log" ]]; then - local log_size - log_size="$(wc -c < "$install_log" | tr -d ' ')" - echo " Install log: ${install_log} ($(( log_size / 1024 )) KB)" - echo "" - - # Check if install completed successfully - if grep -q 'Install complete' "$install_log" 2>/dev/null; then - echo " Status: Install completed successfully" - grep 'Install complete' "$install_log" - echo "" - elif grep -q 'Uninstallation complete' "$install_log" 2>/dev/null; then - echo " Status: Cluster was destroyed" - echo "" - fi - - # Show level=error lines (deduplicated) - local error_count - error_count="$(grep -c 'level=error' "$install_log" 2>/dev/null || echo 0)" - if [[ "$error_count" -gt 0 ]]; then - echo " --- Errors (${error_count} total, showing unique) ---" - grep 'level=error' "$install_log" | sed 's/time="[^"]*" //' | sort -u - echo "" - fi - - # Show level=fatal lines - if grep -q 'level=fatal' "$install_log" 2>/dev/null; then - echo " --- Fatal ---" - grep 'level=fatal' "$install_log" - echo "" - fi - - # Common failure patterns - echo " --- Failure Pattern Analysis ---" - if grep -q 'Bootstrap failed to complete' "$install_log" 2>/dev/null; then - echo " BOOTSTRAP FAILURE: Bootstrap host failed to create temporary control plane" - echo " Likely causes: SSH key mismatch, instance didn't boot, ignition failure" - echo " Next step: check serial console output with: $0 debug ${version} ${cluster_dir} --gcp" - fi - if grep -q 'context deadline exceeded' "$install_log" 2>/dev/null; then - echo " TIMEOUT: Cluster API connection timed out" - fi - if grep -q 'quota' "$install_log" 2>/dev/null; then - echo " QUOTA: Possible GCP quota exceeded" - grep -i 'quota' "$install_log" | head -3 - fi - if grep -q 'resourceInUseByAnotherResource' "$install_log" 2>/dev/null; then - echo " RESOURCE CONFLICT: GCP resources still in use (stale resources from prior install)" - fi - if grep -q 'unable to authenticate' "$install_log" 2>/dev/null; then - echo " SSH AUTH FAILURE: Could not SSH to bootstrap node (wrong key or agent not running)" - fi - echo "" - - # Last 10 non-debug lines for context - echo " --- Last 10 significant log lines ---" - grep -v 'level=debug' "$install_log" | tail -10 - echo "" - else - echo " No install log found at ${install_log}" - echo "" - fi - - # Log bundles - local -a bundles=("${install_dir}"/log-bundle-*.tar.gz) - if [[ -e "${bundles[0]}" ]]; then - echo " --- Log Bundles ---" - for b in "${bundles[@]}"; do - echo " $(basename "$b") ($(du -h "$b" | cut -f1))" - done - echo "" - echo " To extract and inspect a bundle:" - echo " mkdir /tmp/logbundle && tar xzf -C /tmp/logbundle" - echo " # Then check: bootstrap/journals/*, control-plane/*/journals/*" - echo "" - fi - - # ── 2. openshift-install gather bootstrap ── - local bin - bin="$(installer_bin "$version" 2>/dev/null || true)" - if [[ -x "$bin" ]] && [[ -f "${install_dir}/metadata.json" ]]; then - echo " --- Gather Bootstrap Logs ---" - echo " Run this to collect bootstrap logs from the running cluster:" - echo " ${bin} gather bootstrap --dir=${install_dir}" - echo "" - fi - - # ── 3. GCP diagnostics ── - if ! command -v gcloud &>/dev/null; then - echo " [gcloud not found — skipping GCP diagnostics]" - echo " Install: https://cloud.google.com/sdk/docs/install" - return 0 - fi - - # Determine the filter prefix (infra_id or cluster_name) - local filter_name="${infra_id:-$cluster_name}" - if [[ -z "$filter_name" ]]; then - echo " [Cannot determine cluster name/infraID — skipping GCP diagnostics]" - return 0 - fi - - echo "" - info "GCP DIAGNOSTICS (project: ${GCP_PROJECT})" - echo "" - - # 3a. List instances (single API call, filter locally for bootstrap/masters) - local all_instances - all_instances="$(gcloud compute instances list \ - --project="$GCP_PROJECT" \ - --filter="name~${filter_name}" \ - --format="value(name,zone,status,machineType.basename())" 2>/dev/null || true)" - - echo " --- Compute Instances ---" - if [[ -n "$all_instances" ]]; then - printf " %-40s %-25s %-10s %s\n" "NAME" "ZONE" "STATUS" "TYPE" - while IFS=$'\t' read -r iname izone istatus itype; do - printf " %-40s %-25s %-10s %s\n" "$iname" "$izone" "$istatus" "$itype" - done <<< "$all_instances" - else - echo " (no instances found or gcloud error)" - fi - echo "" - - # 3b. Serial port output for bootstrap (last 50 lines) - local bootstrap_instance - bootstrap_instance="$(echo "$all_instances" | grep 'bootstrap' | head -1)" - - if [[ -n "$bootstrap_instance" ]]; then - local bname bzone - bname="$(echo "$bootstrap_instance" | cut -f1)" - bzone="$(echo "$bootstrap_instance" | cut -f2)" - echo " --- Bootstrap Serial Console (last 50 lines) ---" - echo " Instance: ${bname} (${bzone})" - gcloud compute instances get-serial-port-output "$bname" \ - --project="$GCP_PROJECT" \ - --zone="$bzone" 2>/dev/null | tail -50 || echo " (could not retrieve serial output)" - echo "" - fi - - # Serial output for master nodes - local master_instances - master_instances="$(echo "$all_instances" | grep 'master')" - - if [[ -n "$master_instances" ]]; then - echo " --- Master Node Serial Console (last 20 lines each) ---" - while IFS=$'\t' read -r mname mzone; do - echo " Instance: ${mname} (${mzone})" - gcloud compute instances get-serial-port-output "$mname" \ - --project="$GCP_PROJECT" \ - --zone="$mzone" 2>/dev/null | tail -20 || echo " (could not retrieve serial output)" - echo "" - done <<< "$master_instances" - fi - - # 3c. GCP Cloud Logging (last 30 minutes of errors) - echo " --- GCP Cloud Logging (recent errors) ---" - gcloud logging read \ - "resource.type=gce_instance AND textPayload:\"${filter_name}\" AND severity>=ERROR" \ - --project="$GCP_PROJECT" \ - --limit=20 \ - --format="table(timestamp,textPayload)" \ - --freshness=30m 2>/dev/null || echo " (no log entries found or gcloud error)" - echo "" - - # 3d. Firewall rules - echo " --- Firewall Rules ---" - gcloud compute firewall-rules list \ - --project="$GCP_PROJECT" \ - --filter="name~${filter_name}" \ - --format="table(name,direction,allowed,targetTags)" 2>/dev/null || echo " (none found)" - echo "" - - # 3e. Disks (checking for orphaned disks) - echo " --- Persistent Disks ---" - gcloud compute disks list \ - --project="$GCP_PROJECT" \ - --filter="name~${filter_name}" \ - --format="table(name,zone.basename(),sizeGb,status,users.basename())" 2>/dev/null || echo " (none found)" - echo "" - - echo "==========================================" - echo " Debug complete" - echo "==========================================" -} - -# ─── list ─────────────────────────────────────────────────────────────────── - -cmd_list() { - local filter_version="${1:-}" - - echo "" - printf "%-12s %-16s %-10s %-30s\n" "VERSION" "CLUSTER" "STATUS" "PATH" - printf "%-12s %-16s %-10s %-30s\n" "-------" "-------" "------" "----" - - local minor_dirs - if [[ -n "$filter_version" ]]; then - minor_dirs="${CLUSTERS_DIR}/$(major_minor "$filter_version")" - else - minor_dirs="${CLUSTERS_DIR}/4.*" - fi - - for minor_dir in $minor_dirs; do - [[ -d "$minor_dir" ]] || continue - for ver_dir in "$minor_dir"/*/; do - [[ -d "$ver_dir" ]] || continue - ver_dir="${ver_dir%/}" - local version - version="$(basename "$ver_dir")" - # Skip if doesn't look like a version - [[ "$version" =~ ^[0-9]+\.[0-9]+ ]] || continue - - for cluster in "$ver_dir"/cluster*/; do - [[ -d "$cluster" ]] || continue - local cname status - cname="$(basename "$cluster")" - - if [[ -f "${cluster}/.openshift_install.log" ]] && grep -q 'Uninstallation complete' "${cluster}/.openshift_install.log" 2>/dev/null; then - status="DESTROYED" - elif [[ -f "${cluster}/auth/kubeconfig" ]]; then - status="ACTIVE" - elif [[ -f "${cluster}/metadata.json" ]]; then - status="ACTIVE" - elif [[ -f "${cluster}/install-config.yaml" ]]; then - status="CONFIG" - elif [[ -f "${cluster}/.openshift_install.log" ]] || compgen -G "${cluster}/log-bundle-*.tar.gz" &>/dev/null; then - status="DESTROYED" - elif [[ -f "${cluster}/install-config.yaml.backup" ]]; then - status="DESTROYED" - else - status="EMPTY" - fi - - printf "%-12s %-16s %-10s %-30s\n" "$version" "$cname" "$status" "$cluster" - done - done - done - echo "" -} - -# ─── kubeconfig ───────────────────────────────────────────────────────────── - -cmd_kubeconfig() { - local version="${1:?Usage: $0 kubeconfig }" - local cluster_dir="${2:?Usage: $0 kubeconfig }" - - local vdir install_dir kubeconfig - vdir="$(version_dir "$version")" - install_dir="${vdir}/${cluster_dir}" - kubeconfig="${install_dir}/auth/kubeconfig" - - [[ -f "$kubeconfig" ]] || die "Kubeconfig not found: ${kubeconfig}" - - echo "export KUBECONFIG=${kubeconfig}" -} - -# ─── main ─────────────────────────────────────────────────────────────────── - -usage() { - cat < [args] - -Commands: - download Download openshift-install for a version - create [cluster-name] Create a cluster - destroy Destroy a cluster - debug Diagnose a failed installation - list [version] List all clusters - kubeconfig Print KUBECONFIG export command - -Cluster types: - regular 3 control-plane + 3 workers (standard instances) - sno Single Node OpenShift with GPU (a2-highgpu-2g) - gpu 3 control-plane + 3 GPU workers (a2-highgpu-1g) - sno-cpu Single Node OpenShift, CPU only (cpuPartitioningMode) - -Environment variables: - CLUSTERS_DIR Base directory (default: ~/clusters) - SSH_KEY_FILE SSH public key (default: ~/.ssh/id_rsa.pub) - -Secrets: - Pull secret is read from the OS secret store (OCP_PULL_SECRET). - Falls back to \${CLUSTERS_DIR}/pull-secret-gcp.txt if not found. - - macOS (Keychain): - security add-generic-password -a "\$USER" -s "OCP_PULL_SECRET" \\ - -w "\$(cat pull-secret.json | python3 -c \\ - "import sys,json; print(json.dumps(json.load(sys.stdin), separators=(',',':')))")" - - Linux (secret-tool / libsecret): - cat pull-secret.json | python3 -c \\ - "import sys,json; print(json.dumps(json.load(sys.stdin), separators=(',',':')))" | \\ - secret-tool store --label="OCP Pull Secret" service ocp-install username "\$USER" key OCP_PULL_SECRET - -Examples: - $0 download 4.21.3 - $0 create 4.21.3 sno - $0 create 4.21.3 gpu mycluster01 - $0 list - $0 list 4.21 - $0 debug 4.21.3 cluster1 - $0 destroy 4.21.3 cluster1 - eval \$($0 kubeconfig 4.21.3 cluster1) - -Download source: ${ARTIFACTS_BASE} -EOF -} - -case "${1:-}" in - download) shift; cmd_download "$@" ;; - create) shift; cmd_create "$@" ;; - destroy) shift; cmd_destroy "$@" ;; - debug) shift; cmd_debug "$@" ;; - list) shift; cmd_list "$@" ;; - kubeconfig) shift; cmd_kubeconfig "$@" ;; - -h|--help|help|"") usage ;; - *) die "Unknown command: $1. Run '$0 --help' for usage." ;; -esac diff --git a/plugins/node-team/scripts/worktree.sh b/plugins/node-team/scripts/worktree.sh deleted file mode 100755 index ea3d811b2..000000000 --- a/plugins/node-team/scripts/worktree.sh +++ /dev/null @@ -1,351 +0,0 @@ -#!/bin/bash -# worktree.sh — create/remove/list parallel workspaces with all submodules -# -# Usage: -# worktree.sh sync # fetch + checkout main in all submodules -# worktree.sh create [base-branch] # create a workspace (runs sync first) -# worktree.sh pull # sync main + merge into worktree branches -# worktree.sh merge # merge worktree branches into main + update root -# worktree.sh remove # tear it down -# worktree.sh list # show active workspaces -# -# After creating: -# claude --cwd .worktrees/ -# -set -euo pipefail - -ROOT="$(cd "$(dirname "$0")" && git rev-parse --show-toplevel)" -WT_DIR="$ROOT/.worktrees" - -cmd_sync() { - echo "Syncing all submodules to latest remote main..." - - # Fetch + pull root repo - echo " root: fetching..." - git -C "$ROOT" fetch --quiet - - # Init submodules first (ensures .git dirs exist before we fetch) - git -C "$ROOT" submodule update --init --quiet - - # Each submodule: fetch, checkout its tracked branch, fast-forward if clean - git -C "$ROOT" submodule foreach --quiet ' - tracked=$(git config -f "$toplevel/.gitmodules" --get "submodule.$name.branch" 2>/dev/null || echo "main") - git fetch --quiet origin - git checkout "$tracked" --quiet 2>/dev/null || git checkout -b "$tracked" "origin/$tracked" --quiet - - # Only fast-forward — never rebase/merge to avoid conflict loops - if git merge-base --is-ancestor HEAD "origin/$tracked" 2>/dev/null; then - echo " $sm_path: fast-forwarding $tracked..." - git merge --ff-only --quiet "origin/$tracked" - elif git merge-base --is-ancestor "origin/$tracked" HEAD 2>/dev/null; then - echo " $sm_path: already ahead of origin/$tracked (local commits), skipping pull" - else - echo " $sm_path: WARNING — diverged from origin/$tracked, skipping pull (resolve manually)" - fi - ' - - echo "All submodules synced." - echo "" -} - -cmd_create() { - local name="${1:?usage: worktree.sh create [base-branch]}" - local base="${2:-HEAD}" - local ws="$WT_DIR/$name" - - if [ -d "$ws" ]; then - echo "error: workspace '$name' already exists at $ws" >&2 - exit 1 - fi - - # If root repo has no commits yet, create an initial one so worktrees have - # something to branch from (common when setting up the repo for the first time) - if ! git -C "$ROOT" rev-parse HEAD >/dev/null 2>&1; then - echo "No commits in root repo — creating initial commit..." - git -C "$ROOT" add -A - git -C "$ROOT" commit -m "Initial commit: register submodules" --quiet - fi - - # Sync all submodules to latest remote main before branching - cmd_sync - - echo "Creating workspace '$name' from $base..." - - # Root repo worktree - git -C "$ROOT" worktree add "$ws" -b "wt/$name" "$base" 2>/dev/null \ - || git -C "$ROOT" worktree add "$ws" "wt/$name" - - # Submodule worktrees — each sub-repo gets its own branch inside the workspace - _WT_NAME="$name" _WT_WS="$ws" git -C "$ROOT" submodule foreach --quiet ' - branch="wt/${_WT_NAME}" - git worktree add "${_WT_WS}/$sm_path" -b "$branch" HEAD 2>/dev/null \ - || git worktree add "${_WT_WS}/$sm_path" "$branch" - ' - - echo "" - echo "Workspace ready at: $ws" - echo "" - echo " claude --cwd $ws" - echo "" -} - -cmd_pull() { - local wsname="${1:?usage: worktree.sh pull }" - local ws="$WT_DIR/$wsname" - local branch="wt/$wsname" - - if [ ! -d "$ws" ]; then - echo "error: workspace '$wsname' not found at $ws" >&2 - exit 1 - fi - - # Sync main to latest remote first - cmd_sync - - echo "Merging main into workspace '$wsname'..." - echo "" - - # Merge main into each submodule's worktree branch - if ! _WT_NAME="$wsname" _WT_WS="$ws" git -C "$ROOT" submodule foreach --quiet ' - branch="wt/${_WT_NAME}" - tracked=$(git config -f "$toplevel/.gitmodules" --get "submodule.$name.branch" 2>/dev/null || echo "main") - - if [ ! -d "${_WT_WS}/$sm_path" ]; then - echo " $sm_path: not in workspace, skipping" - exit 0 - fi - - cur=$(git -C "${_WT_WS}/$sm_path" branch --show-current 2>/dev/null || echo "") - if [ "$cur" != "$branch" ]; then - echo " $sm_path: not on $branch (on $cur), skipping" - exit 0 - fi - - behind=$(git -C "${_WT_WS}/$sm_path" rev-list --count HEAD.."$tracked" 2>/dev/null || echo "0") - if [ "$behind" = "0" ]; then - echo " $sm_path: already up to date with $tracked" - exit 0 - fi - - echo " $sm_path: merging $tracked ($behind commit(s)) into $branch..." - if git -C "${_WT_WS}/$sm_path" merge "$tracked" --no-edit --quiet; then - echo " $sm_path: merged ✓" - else - echo " $sm_path: CONFLICT — resolve in ${_WT_WS}/$sm_path, then re-run pull" >&2 - exit 1 - fi - '; then - echo "" - echo "Pull stopped due to conflicts. Resolve them, then run:" - echo " worktree.sh pull $wsname" - exit 1 - fi - - # Update root worktree submodule pointers - git -C "$ws" add -A 2>/dev/null - if ! git -C "$ws" diff --cached --quiet 2>/dev/null; then - git -C "$ws" commit -m "Sync all submodules with main" - echo "" - echo "Root worktree pointers updated." - else - echo "" - echo "No submodule pointer changes." - fi - - echo "" - echo "Workspace '$wsname' is up to date with main." - echo "" -} - -cmd_merge() { - local name="${1:?usage: worktree.sh merge }" - local ws="$WT_DIR/$name" - local branch="wt/$name" - - if [ ! -d "$ws" ]; then - echo "error: workspace '$name' not found at $ws" >&2 - exit 1 - fi - - echo "Merging workspace '$name'..." - echo "" - - # --- Merge root repo's wt/ branch first --- - if git -C "$ROOT" rev-parse --verify "$branch" >/dev/null 2>&1; then - local root_main - root_main=$(git -C "$ROOT" rev-parse --abbrev-ref HEAD) - local root_ahead - root_ahead=$(git -C "$ROOT" rev-list --count "$root_main..$branch" 2>/dev/null || echo "0") - if [ "$root_ahead" != "0" ]; then - echo " root: merging $branch ($root_ahead commit(s)) into $root_main..." - if git -C "$ROOT" merge --ff-only "$branch" --quiet 2>/dev/null; then - echo " root: fast-forward merge ✓" - elif git -C "$ROOT" merge "$branch" --no-edit --quiet; then - echo " root: merge commit created ✓" - else - echo " root: CONFLICT — resolve manually, then re-run merge" >&2 - exit 1 - fi - else - echo " root: no changes on $branch, skipping" - fi - fi - - # --- Merge each submodule's wt/ branch into its tracked main branch --- - if ! _WT_NAME="$name" git -C "$ROOT" submodule foreach --quiet ' - branch="wt/${_WT_NAME}" - tracked=$(git config -f "$toplevel/.gitmodules" --get "submodule.$name.branch" 2>/dev/null || echo "main") - - # Check if the worktree branch exists locally - if ! git rev-parse --verify "$branch" >/dev/null 2>&1; then - echo " $sm_path: no branch $branch, skipping" - exit 0 - fi - - # Fetch remote and update local wt branch if remote has commits we lack - # (worktree agents may have pushed directly to origin/wt/) - git fetch --quiet origin 2>/dev/null || true - if git rev-parse --verify "origin/$branch" >/dev/null 2>&1; then - if ! git merge-base --is-ancestor "origin/$branch" "$branch" 2>/dev/null; then - echo " $sm_path: syncing $branch with remote..." - if git merge-base --is-ancestor "$branch" "origin/$branch" 2>/dev/null; then - git update-ref "refs/heads/$branch" "$(git rev-parse "origin/$branch")" - else - cur=$(git branch --show-current 2>/dev/null || echo "") - git checkout "$branch" --quiet - git merge --no-edit --quiet "origin/$branch" - [ -n "$cur" ] && git checkout "$cur" --quiet 2>/dev/null || git checkout "$tracked" --quiet - fi - fi - fi - - ahead=$(git rev-list --count "$tracked..$branch" 2>/dev/null || echo "0") - if [ "$ahead" = "0" ]; then - echo " $sm_path: no changes on $branch, skipping" - exit 0 - fi - - echo " $sm_path: merging $branch ($ahead commit(s)) into $tracked..." - git checkout "$tracked" --quiet - if git merge --ff-only "$branch" --quiet 2>/dev/null; then - echo " $sm_path: fast-forward merge ✓" - elif git merge "$branch" --no-edit --quiet; then - echo " $sm_path: merge commit created ✓" - else - echo " $sm_path: CONFLICT — resolve manually, then re-run merge" >&2 - exit 1 - fi - '; then - echo "" - echo "Merge stopped due to conflicts. Resolve them, then run:" - echo " worktree.sh merge $name" - exit 1 - fi - - # Reconcile: ensure each submodule's main includes the commit the root expects. - # This catches cases where the root merge fast-forwarded a submodule pointer - # but the submodule's wt/ branch was already deleted (from a prior remove), - # leaving the submodule's main behind. - echo "" - echo "Reconciling submodule branches with root pointers..." - git -C "$ROOT" submodule foreach --quiet ' - tracked=$(git config -f "$toplevel/.gitmodules" --get "submodule.$name.branch" 2>/dev/null || echo "main") - # What does the root repo expect this submodule to be at? - expected=$(git -C "$toplevel" ls-tree HEAD -- "$sm_path" | awk "{print \$3}") - current=$(git rev-parse HEAD 2>/dev/null || echo "") - if [ -n "$expected" ] && [ "$expected" != "$current" ]; then - if git merge-base --is-ancestor "$current" "$expected" 2>/dev/null; then - echo " $sm_path: fast-forwarding $tracked to match root pointer..." - git checkout "$tracked" --quiet 2>/dev/null || true - git merge --ff-only "$expected" --quiet 2>/dev/null || true - fi - fi - ' - - # Update root repo submodule pointers for any submodules that changed - echo "" - echo "Updating root repo submodule pointers..." - # Stage any submodule pointer changes - git -C "$ROOT" add -A 2>/dev/null - if ! git -C "$ROOT" diff --cached --quiet 2>/dev/null; then - git -C "$ROOT" commit -m "Merge workspace '$name' submodule updates" - echo "Root repo updated." - else - echo "No submodule pointer changes to commit." - fi - - echo "" - echo "Merge complete. You can now remove the workspace:" - echo " worktree.sh remove $name" - echo "" -} - -cmd_remove() { - local name="${1:?usage: worktree.sh remove }" - local ws="$WT_DIR/$name" - - if [ ! -d "$ws" ]; then - echo "error: workspace '$name' not found at $ws" >&2 - exit 1 - fi - - echo "Removing workspace '$name'..." - - # Remove submodule worktrees first - _WT_NAME="$name" _WT_WS="$ws" git -C "$ROOT" submodule foreach --quiet ' - if git worktree list --porcelain | grep -q "worktree ${_WT_WS}/$sm_path"; then - git worktree remove --force "${_WT_WS}/$sm_path" 2>/dev/null || true - fi - git branch -D "wt/${_WT_NAME}" 2>/dev/null || true - ' - - # Remove root worktree - git -C "$ROOT" worktree remove --force "$ws" 2>/dev/null || true - git -C "$ROOT" branch -D "wt/$name" 2>/dev/null || true - - # Clean up empty dir if anything remains - rm -rf "$ws" 2>/dev/null || true - - echo "Workspace '$name' removed." -} - -cmd_list() { - if [ ! -d "$WT_DIR" ]; then - echo "No workspaces. Create one with: worktree.sh create " - return - fi - - echo "Active workspaces:" - echo "" - for ws in "$WT_DIR"/*/; do - [ -d "$ws" ] || continue - local name="$(basename "$ws")" - echo " $name → $ws" - _WT_WS="$ws" git -C "$ROOT" submodule foreach --quiet ' - if [ -d "${_WT_WS}/$sm_path" ]; then - branch=$(git -C "${_WT_WS}/$sm_path" branch --show-current 2>/dev/null || echo "detached") - echo " $sm_path ($branch)" - fi - ' - echo "" - done -} - -case "${1:-help}" in - sync) cmd_sync ;; - create) shift; cmd_create "$@" ;; - pull) shift; cmd_pull "$@" ;; - merge) shift; cmd_merge "$@" ;; - remove) shift; cmd_remove "$@" ;; - list) cmd_list ;; - *) - echo "Usage: worktree.sh {sync|create|pull|merge|remove|list} [args...]" - echo "" - echo " sync — fetch + checkout main in all submodules" - echo " create [base] — sync + create parallel workspace" - echo " pull — sync main + merge into all worktree branches" - echo " merge — merge worktree branches into main + update root" - echo " remove — tear down workspace" - echo " list — show active workspaces" - ;; -esac diff --git a/plugins/node-team/scripts/worktree_test.sh b/plugins/node-team/scripts/worktree_test.sh deleted file mode 100755 index 08106e08a..000000000 --- a/plugins/node-team/scripts/worktree_test.sh +++ /dev/null @@ -1,270 +0,0 @@ -#!/usr/bin/env bash -# -# worktree_test.sh -- Integration tests for worktree.sh -# -# Creates a temporary workspace with bare repos and submodules, -# then runs through the critical worktree scenarios. -# -# Usage: -# ./scripts/worktree_test.sh -# - -# Clear any inherited git env vars that would confuse operations -unset GIT_DIR GIT_WORK_TREE GIT_CEILING_DIRECTORIES 2>/dev/null || true - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -WORKTREE_SH="${SCRIPT_DIR}/worktree.sh" -TEST_DIR="" -WORKSPACE="" -PASS=0 -FAIL=0 - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -setup() { - TEST_DIR="$(mktemp -d)" - echo "Test directory: ${TEST_DIR}" - echo "" - - export GIT_CONFIG_COUNT=1 - export GIT_CONFIG_KEY_0=protocol.file.allow - export GIT_CONFIG_VALUE_0=always - - for repo in repo-a repo-b; do - git init --bare "${TEST_DIR}/remotes/${repo}.git" --quiet - local seed="${TEST_DIR}/seed-${repo}" - mkdir -p "${seed}" - git -C "${seed}" init --quiet - echo "# ${repo}" > "${seed}/README.md" - git -C "${seed}" add README.md - git -C "${seed}" commit -m "initial commit" --quiet - git -C "${seed}" remote add origin "${TEST_DIR}/remotes/${repo}.git" - git -C "${seed}" push --quiet origin main 2>/dev/null - done - - local ws="${TEST_DIR}/workspace" - mkdir -p "${ws}" - git -C "${ws}" init --quiet - git -C "${ws}" submodule add "${TEST_DIR}/remotes/repo-a.git" repo-a 2>/dev/null - git -C "${ws}" submodule add "${TEST_DIR}/remotes/repo-b.git" repo-b 2>/dev/null - git -C "${ws}" commit -m "add submodules" --quiet - - cp "${WORKTREE_SH}" "${ws}/worktree.sh" - chmod +x "${ws}/worktree.sh" - WORKSPACE="${ws}" -} - -teardown() { - if [[ -n "${TEST_DIR:-}" && -d "${TEST_DIR:-}" ]]; then - rm -rf "${TEST_DIR}" - fi -} - -# Run git command against a repo path -repo_git() { - local path="$1" - shift - git -C "${path}" "$@" -} - -# Commit a file in a worktree submodule -wt_commit() { - local wt_path="$1" - local filename="$2" - local content="$3" - local message="$4" - echo "${content}" > "${wt_path}/${filename}" - repo_git "${wt_path}" add "${filename}" 2>&1 - repo_git "${wt_path}" commit -m "${message}" --quiet 2>&1 -} - -assert_file_exists() { - [[ -f "$1" ]] || { echo " ASSERT FAILED: file $1 missing"; return 1; } -} - -assert_commit_on_main() { - local repo_path="$1" - local pattern="$2" - local log - log="$(repo_git "${repo_path}" log --oneline main 2>/dev/null)" - echo "${log}" | grep -q "${pattern}" || { - echo " ASSERT FAILED: '${pattern}' not on main in ${repo_path}" - return 1 - } -} - -run_test() { - local test_name="$1" - local test_func="$2" - echo "--- ${test_name} ---" - # Each test gets a fresh workspace to avoid inter-test interference - teardown - setup - if ${test_func}; then - echo " PASS" - PASS=$((PASS + 1)) - else - echo " FAIL" - FAIL=$((FAIL + 1)) - fi - echo "" -} - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - -test_basic_create_merge() { - local w="${WORKSPACE}" - - "${w}/worktree.sh" create basic >/dev/null 2>&1 - - [[ -d "${w}/.worktrees/basic/repo-a" ]] || { echo " repo-a worktree missing"; return 1; } - [[ -d "${w}/.worktrees/basic/repo-b" ]] || { echo " repo-b worktree missing"; return 1; } - - wt_commit "${w}/.worktrees/basic/repo-a" "feature.txt" "feature A" "add feature A" || return 1 - wt_commit "${w}/.worktrees/basic/repo-b" "feature.txt" "feature B" "add feature B" || return 1 - - "${w}/worktree.sh" merge basic >/dev/null 2>&1 - - assert_commit_on_main "${w}/repo-a" "add feature A" || return 1 - assert_commit_on_main "${w}/repo-b" "add feature B" || return 1 - assert_file_exists "${w}/repo-a/feature.txt" || return 1 - assert_file_exists "${w}/repo-b/feature.txt" || return 1 - - return 0 -} - -test_remote_sync() { - local w="${WORKSPACE}" - - "${w}/worktree.sh" create remote-test >/dev/null 2>&1 - - # Local commit + push - wt_commit "${w}/.worktrees/remote-test/repo-a" "local.txt" "local work" "local commit" || return 1 - repo_git "${w}/.worktrees/remote-test/repo-a" push origin wt/remote-test --quiet 2>/dev/null - - # Agent pushes extra commits via separate clone - local agent="${TEST_DIR}/agent-clone" - git clone "${TEST_DIR}/remotes/repo-a.git" "${agent}" --quiet 2>/dev/null - git -C "${agent}" checkout wt/remote-test --quiet 2>/dev/null - echo "agent 1" > "${agent}/agent1.txt" - git -C "${agent}" add agent1.txt - git -C "${agent}" commit -m "agent commit 1" --quiet - echo "agent 2" > "${agent}/agent2.txt" - git -C "${agent}" add agent2.txt - git -C "${agent}" commit -m "agent commit 2" --quiet - git -C "${agent}" push origin wt/remote-test --quiet 2>/dev/null - rm -rf "${agent}" - - # Merge -- must pick up agent commits - "${w}/worktree.sh" merge remote-test >/dev/null 2>&1 - - assert_commit_on_main "${w}/repo-a" "local commit" || return 1 - assert_commit_on_main "${w}/repo-a" "agent commit 1" || return 1 - assert_commit_on_main "${w}/repo-a" "agent commit 2" || return 1 - assert_file_exists "${w}/repo-a/local.txt" || return 1 - assert_file_exists "${w}/repo-a/agent1.txt" || return 1 - assert_file_exists "${w}/repo-a/agent2.txt" || return 1 - - return 0 -} - -test_root_repo_merge() { - local w="${WORKSPACE}" - - "${w}/worktree.sh" create root-test >/dev/null 2>&1 - - # Verify root worktree branch - local root_branch - root_branch=$(repo_git "${w}/.worktrees/root-test" branch --show-current 2>/dev/null) - [[ "${root_branch}" == "wt/root-test" ]] || { echo " expected wt/root-test, got ${root_branch}"; return 1; } - - # Commit in root repo worktree - wt_commit "${w}/.worktrees/root-test" "DOCS.md" "# Root docs" "add root docs" || return 1 - - # Commit in submodule worktree - wt_commit "${w}/.worktrees/root-test/repo-b" "sub.txt" "sub work" "submodule work" || return 1 - - # DOCS.md should NOT exist on main yet - [[ ! -f "${w}/DOCS.md" ]] || { echo " DOCS.md should not exist on main yet"; return 1; } - - "${w}/worktree.sh" merge root-test >/dev/null 2>&1 - - assert_file_exists "${w}/DOCS.md" || return 1 - assert_commit_on_main "${w}" "add root docs" || return 1 - assert_commit_on_main "${w}/repo-b" "submodule work" || return 1 - assert_file_exists "${w}/repo-b/sub.txt" || return 1 - - return 0 -} - -test_remove_cleanup() { - local w="${WORKSPACE}" - - "${w}/worktree.sh" create cleanup-test >/dev/null 2>&1 - [[ -d "${w}/.worktrees/cleanup-test" ]] || { echo " workspace should exist"; return 1; } - - "${w}/worktree.sh" remove cleanup-test >/dev/null 2>&1 - - [[ ! -d "${w}/.worktrees/cleanup-test" ]] || { echo " workspace dir should be gone"; return 1; } - - repo_git "${w}/repo-a" show-ref --verify --quiet "refs/heads/wt/cleanup-test" 2>/dev/null && \ - { echo " wt/cleanup-test should be gone from repo-a"; return 1; } - repo_git "${w}" show-ref --verify --quiet "refs/heads/wt/cleanup-test" 2>/dev/null && \ - { echo " wt/cleanup-test should be gone from root"; return 1; } - - return 0 -} - -test_list() { - local w="${WORKSPACE}" - - "${w}/worktree.sh" create list-a >/dev/null 2>&1 - "${w}/worktree.sh" create list-b >/dev/null 2>&1 - - local output - output=$("${w}/worktree.sh" list 2>&1) - echo "${output}" | grep -q "list-a" || { echo " should contain list-a"; return 1; } - echo "${output}" | grep -q "list-b" || { echo " should contain list-b"; return 1; } - - return 0 -} - -test_no_changes_merge() { - local w="${WORKSPACE}" - - "${w}/worktree.sh" create no-changes >/dev/null 2>&1 - - local output - output=$("${w}/worktree.sh" merge no-changes 2>&1) - echo "${output}" | grep -qi "no changes\|skipping" || { echo " should report skipping"; return 1; } - - return 0 -} - -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- - -trap teardown EXIT - -echo "=========================================" -echo " worktree.sh integration tests" -echo "=========================================" -echo "" - -run_test "Basic create + commit + merge" test_basic_create_merge -run_test "Remote sync (agent push scenario)" test_remote_sync -run_test "Root repo branch merge" test_root_repo_merge -run_test "Remove + cleanup" test_remove_cleanup -run_test "List workspaces" test_list -run_test "Merge with no changes" test_no_changes_merge - -echo "=========================================" -echo " Results: ${PASS} passed, ${FAIL} failed" -echo "=========================================" - -[[ ${FAIL} -eq 0 ]] diff --git a/plugins/node-team/skills/node/SKILL.md b/plugins/node-team/skills/node/SKILL.md index 712e50b6f..df76697c0 100644 --- a/plugins/node-team/skills/node/SKILL.md +++ b/plugins/node-team/skills/node/SKILL.md @@ -1,7 +1,7 @@ --- name: node-team description: "OpenShift Node team assistant. Covers kubelet, MCO, CRI-O, crun, conmonrs, Kueue operator, Jira (OCPNODE/OCPBUGS), Red Hat KB/support cases, Prometheus, and K8s/OCP docs. Triggers on any OpenShift node-layer development, deployment, debugging, or workflow task." -allowed-tools: Bash(${CLAUDE_PLUGIN_ROOT}/scripts/*),Bash(curl:*) +allowed-tools: Bash(curl:*) --- ## How to use this skill diff --git a/plugins/node-team/skills/node/references/SETUP.md b/plugins/node-team/skills/node/references/SETUP.md index a07685fb9..54221c85f 100644 --- a/plugins/node-team/skills/node/references/SETUP.md +++ b/plugins/node-team/skills/node/references/SETUP.md @@ -38,9 +38,9 @@ If resuming work on a PR you've already fetched, check `git worktree list` first To investigate or fix a Jira issue: -1. Fetch the issue details to determine the component: +1. Fetch the issue details to determine the component (see [jira.md](../jira.md) for auth setup): ```bash - ./scripts/jira.sh get OCPNODE-1234 + curl -s -u "$JIRA_USER:$JIRA_API_TOKEN" "https://redhat.atlassian.net/rest/api/3/issue/OCPNODE-1234?fields=summary,components" ``` 2. Map the component to a repo (see Repo URLs below), confirm with the user, and clone if needed. 3. Create a worktree named after the ticket: diff --git a/plugins/node-team/skills/node/references/development/worktrees.md b/plugins/node-team/skills/node/references/development/worktrees.md index 5487b05a6..d0c669757 100644 --- a/plugins/node-team/skills/node/references/development/worktrees.md +++ b/plugins/node-team/skills/node/references/development/worktrees.md @@ -1,37 +1,53 @@ # Worktrees: Parallel Multi-Repo Workspaces -`scripts/worktree.sh` creates isolated workspaces with a `wt/` branch under `.worktrees//`. Works with any git repo — single repos or repos with submodules. When submodules are present, each one gets its own worktree and branch inside the workspace automatically. +Create isolated workspaces using `git worktree` with a `wt/` branch under `.worktrees//`. When submodules are present, each one gets its own worktree and branch inside the workspace. -## Commands +## Create a Workspace -| Command | What it does | -|---|---| -| `sync` | Fetch + fast-forward all submodules to their tracked branch (from `.gitmodules`) | -| `create [base]` | Sync, then create a workspace branching from `base` (default: `HEAD`) | -| `pull ` | Sync main, then merge main into every worktree branch (keeps you up to date) | -| `merge ` | Merge all `wt/` branches back into their tracked main branches | -| `remove ` | Delete the workspace directory and all `wt/` branches | -| `list` | Show active workspaces and their submodule branch status | +```bash +# Sync submodules first +git fetch --quiet origin +git submodule update --init --quiet +git submodule foreach --quiet 'git fetch --quiet origin; git checkout main --quiet 2>/dev/null; git merge --ff-only origin/main --quiet 2>/dev/null || true' -## Typical Workflow +# Create root worktree +git worktree add .worktrees/ -b wt/ HEAD +# Create submodule worktrees +git submodule foreach --quiet 'git worktree add "$toplevel/.worktrees//$sm_path" -b "wt/" HEAD' + +cd .worktrees// +``` + +## Merge Back + +```bash +# For each submodule: merge wt/ into main +git submodule foreach --quiet ' + git checkout main --quiet + git merge --ff-only wt/ --quiet 2>/dev/null || git merge wt/ --no-edit --quiet +' + +# Merge root +git checkout main +git merge --ff-only wt/ --quiet 2>/dev/null || git merge wt/ --no-edit --quiet + +# Update submodule pointers +git add -A && git diff --cached --quiet || git commit -m "Merge workspace " ``` -./scripts/worktree.sh create my-feature -cd .worktrees/my-feature/ -# work across repos... -# optionally pull in upstream changes: -./scripts/worktree.sh pull my-feature -# done — merge back and clean up: -./scripts/worktree.sh merge my-feature -./scripts/worktree.sh remove my-feature + +## Remove + +```bash +git submodule foreach --quiet 'git worktree remove --force "$toplevel/.worktrees//$sm_path" 2>/dev/null; git branch -D "wt/" 2>/dev/null' +git worktree remove --force .worktrees/ +git branch -D wt/ ``` ## Non-Obvious Details - **Branch prefix is `wt/`** — every workspace creates `wt/` branches in the root and all submodules. Don't manually create branches with this prefix. -- **`create` always syncs first** — it fetches and fast-forwards all submodules before branching, so your workspace starts from the latest remote state. -- **`merge` fetches remote `wt/` branches** — if an agent or CI pushed commits to `origin/wt/`, merge will pick them up before merging into main. This handles the case where worktree agents push directly. -- **`merge` reconciles submodule pointers** — after merging, it ensures each submodule's main branch matches the commit the root repo expects. This prevents pointer drift when branches were already cleaned up from a prior remove. -- **`sync` only fast-forwards** — it never rebases or creates merge commits. If a submodule has diverged from its remote, sync will warn and skip it so you can resolve manually. -- **`pull` skips submodules not on the worktree branch** — if you've manually switched a submodule to a different branch, pull won't touch it. -- **First-time repos** — if the root repo has no commits, `create` will make an initial commit automatically. +- **Always sync submodules before branching** — fetch and fast-forward all submodules to their tracked branch so your workspace starts from the latest remote state. +- **Remote agent pushes** — if an agent pushed commits to `origin/wt/`, fetch and merge them before merging into main: `git fetch origin; git merge origin/wt/`. +- **Reconcile submodule pointers after merge** — ensure each submodule's main matches the commit the root repo expects. Prevents pointer drift. +- **Only fast-forward during sync** — never rebase or create merge commits during sync. If a submodule has diverged, warn and skip. diff --git a/plugins/node-team/skills/node/references/jira.md b/plugins/node-team/skills/node/references/jira.md index 29231ad5c..16ac41bdb 100644 --- a/plugins/node-team/skills/node/references/jira.md +++ b/plugins/node-team/skills/node/references/jira.md @@ -1,65 +1,61 @@ # Node Team Jira Reference -Red Hat Jira: `redhat.atlassian.net`. The `jira.sh` script at `${CLAUDE_PLUGIN_ROOT}/scripts/jira.sh` wraps the REST API. - -## Scripts - -### Read commands - -| Command | What it does | -|---|---| -| `jira.sh get ` | Full issue details | -| `jira.sh search '' [limit]` | Search with JQL | -| `jira.sh comments ` | List comments | -| `jira.sh issue-deep-dive ` | Issue + comments + linked issues | -| `jira.sh find-user ` | Search for a user by name (roster + Jira API) | -| `jira.sh transitions ` | Available status transitions | -| `jira.sh sprints [state]` | List sprints (active\|future\|closed) | -| `jira.sh sprint-issues [limit]` | Issues in a sprint | -| `jira.sh health-check` | Validate custom field IDs | - -### Write commands - -| Command | What it does | -|---|---| -| `jira.sh create [extra-json]` | Create issue. Extra fields as JSON, e.g. `'{"description":...}'` | -| `jira.sh assign ` | Assign issue — resolves names via roster + Jira user search | -| `jira.sh comment "" ` | Add a comment to one or more issues | -| `jira.sh set-field ` | Set any field (string, number, or JSON value) | -| `jira.sh set-points ` | Set story points | -| `jira.sh link [title]` | Add a remote link | -| `jira.sh move-to-sprint ` | Move issue(s) to a sprint | -| `jira.sh transition ` | Perform a transition | -| `jira.sh close [comment] ` | Comment (optional) + close | -| `jira.sh start-sprint ` | Start a sprint | -| `jira.sh close-sprint ` | Close a sprint | - -### Composite / dashboard commands - -When the user names a specific sprint (e.g. "Sprint 288"), pass `--sprint ""`. Without it, the active sprint is used. The name is a substring match, so `--sprint "288"` works. - -| Command | What it does | -|---|---| -| `jira.sh sprint-dashboard [--sprint ]` | Sprint issues by status, workload, blockers | -| `jira.sh standup-data [--sprint ]` | Team standup (dashboard + recent updates) | -| `jira.sh team-activity [--sprint ]` | Per-member sprint items | -| `jira.sh my-board-data [--sprint ]` | My sprint board items | -| `jira.sh my-standup-data [--sprint ]` | My standup prep (board + bugs + comments) | -| `jira.sh pickup-data [--sprint ]` | Unassigned items to pick up | -| `jira.sh bug-overview ` | Untriaged, unassigned, blockers, new bugs | -| `jira.sh my-bugs-data ` | My assigned bugs | -| `jira.sh epic-progress ` | Epic children + completion stats | -| `jira.sh release-data [ver]` | Release readiness (blockers, bugs, epics) | -| `jira.sh planning-data ` | Sprint planning (carryovers + backlog + bugs) | -| `jira.sh carryover-report ` | Not-done items from previous sprint | -| `jira.sh roster-sync [--force]` | Download team rosters from Jira | - -Team values: `core`, `green`, `blue`, `dra`, `kueue`, `all` +Red Hat Jira: `redhat.atlassian.net`. REST API v3. Use `curl` directly. + +## Authentication + +API token from env or macOS Keychain: + +```bash +JIRA_API_TOKEN="${JIRA_API_TOKEN:-$(security find-generic-password -s "JIRA_API_TOKEN" -w 2>/dev/null)}" +JIRA_USER="${JIRA_EMAIL:-$(security find-generic-password -s "JIRA_API_TOKEN" -g 2>&1 | grep acct | sed 's/.*="//;s/"//')}" +[[ "$JIRA_USER" != *@* ]] && JIRA_USER="${JIRA_USER}@redhat.com" +: "${JIRA_USER:=$(git config user.email)}" +``` + +All requests: `curl -s -u "$JIRA_USER:$JIRA_API_TOKEN" -H "Content-Type: application/json"`. + +## REST API Endpoints + +Base: `https://redhat.atlassian.net` + +| Method | Path | Use | +|--------|------|-----| +| POST | `/rest/api/3/search/jql` | Search. Body: `{"jql":"...","maxResults":50,"fields":["key","summary",...]}` | +| GET | `/rest/api/3/issue/{key}` | Get issue. Optional `?fields=summary,status,...` | +| POST | `/rest/api/3/issue` | Create. Body: `{"fields":{"project":{"key":"OCPNODE"},"issuetype":{"name":"Story"},"summary":"..."}}` | +| PUT | `/rest/api/3/issue/{key}` | Update fields. Body: `{"fields":{"customfield_10028":5}}` | +| PUT | `/rest/api/3/issue/{key}/assignee` | Assign. Body: `{"accountId":"..."}` | +| GET | `/rest/api/3/issue/{key}/comment` | List comments | +| POST | `/rest/api/3/issue/{key}/comment` | Add comment (body in ADF format, see below) | +| GET | `/rest/api/3/issue/{key}/transitions` | Available transitions | +| POST | `/rest/api/3/issue/{key}/transitions` | Transition. Body: `{"transition":{"id":"31"}}` | +| POST | `/rest/api/3/issue/{key}/remotelink` | Add link. Body: `{"object":{"url":"...","title":"..."}}` | +| GET | `/rest/api/3/user/search?query={name}` | Find user by name | +| GET | `/rest/agile/1.0/board/7845/sprint?state=active` | List sprints (board 7845 = Node) | +| GET | `/rest/agile/1.0/sprint/{id}/issue?maxResults=100&fields=...` | Sprint issues | +| POST | `/rest/agile/1.0/sprint/{id}/issue` | Move to sprint. Body: `{"issues":["KEY-1","KEY-2"]}` | + +## ADF (Atlassian Document Format) + +Jira Cloud uses ADF for rich text fields (description, comments, blocked reason). When **posting** comments or creating issues with descriptions: + +```json +{ + "body": { + "version": 1, + "type": "doc", + "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Your text here"}]}] + } +} +``` + +When **reading** ADF from responses: recursively walk `content` arrays, extract `text` from `type: "text"` nodes. Handle: `marks` with `type: "link"` (append URL), `type: "mention"` (extract `attrs.text`), `type: "blockCard"/"inlineCard"` (extract `attrs.url`). Paragraphs, headings, list items end with newlines. ## Projects | Project | Tracks | -|---|---| +|---------|--------| | OCPNODE | Node team epics, stories, tasks, spikes | | OCPBUGS | Cross-team bugs (filter by Node components) | | RHOCPPRIO | Red Hat OpenShift Priority List (escalations) | @@ -68,134 +64,125 @@ Team values: `core`, `green`, `blue`, `dra`, `kueue`, `all` ## Components We Own -Defined in saved filter "Node Components": - Node, Node / CRI-O, Node / Kubelet, Node / CPU manager, Node / Memory manager, Node / Topology manager, Node / Numa aware Scheduling, Node / Device Manager, Node / Pod resource API, Node / Node Problem Detector, Node / Kueue, Node / Instaslice-operator ## Boards & Sprints -| ID | Board | Type | -|---|---|---| -| 7845 | Node board | scrum | -| 4383 | Node-Epics | kanban | -| 9874 | Node QE | scrum | +| ID | Board | +|----|-------| +| 7845 | Node board (scrum) | +| 4383 | Node-Epics (kanban) | +| 9874 | Node QE (scrum) | Sprint naming: `OCP Node Core Sprint N`, `OCP Node Devices Sprint N`, `OCP Kueue Sprint N`, `CNF Compute Sprint N` +Filter sprints to Node-related by checking if `"Node"` or `"Kueue"` appears in the sprint name. + Team queue: `aos-node@redhat.com` +## Team Roster + +Team member lists live in `~/.node-assistant/team-roster-{core,dra}.json`. Format: + +```json +{ + "description": "Node Core team members", + "members": { + "Jira Display Name": "github-handle", + "Another Person": "their-github-handle" + } +} +``` + +Use these to resolve display names for assignment, filter team activity, and exclude external CVE assignees. + +Bot account treated as unassigned: `Node Team Bot Account`. + ## Sub-teams -| Team | Filter | -|---|---| -| Core | `filter = "Node Core Team"` (`membersOf(OpenShift-Node-Team)`) | -| Green | `filter = "Node Green Team"` | -| Blue | `filter = "Node Blue Team"` | +| Team | Sprint filter | Roster file | Bug components | +|------|--------------|-------------|----------------| +| Core | `Node Core` | `team-roster-core.json` | All Node components | +| DRA/Devices | `Node Devices` | `team-roster-dra.json` | Node / Device Manager, Node / Instaslice-operator | + +## Custom Field IDs + +Use field names in JQL, IDs in REST API calls: + +| ID | Name | Notes | +|----|------|-------| +| `customfield_10014` | Epic Link | String key, e.g. `"OCPNODE-1234"` | +| `customfield_10011` | Epic Name | | +| `customfield_10020` | Sprint | Array of objects with `state` field (`active`/`closed`/`future`) | +| `customfield_10028` | Story Points | Number | +| `customfield_10001` | Team | | +| `customfield_10855` | Target Version | | +| `customfield_10840` | Severity | Object: `{"value": "Critical"}` | +| `customfield_10847` | Release Blocker | Object: `{"value": "Approved"}` or `{"value": "Proposed"}` | +| `customfield_10517` | Blocked | Object: `{"value": "True"}` or `{"value": "False"}` | +| `customfield_10483` | Blocked Reason | ADF document | +| `customfield_10978` | SFDC Cases Counter | Number | +| `customfield_10979` | SFDC Cases Links | | ## Saved Filters Use in JQL via `filter = "Name"`: | Name | ID | Scope | -|---|---|---| +|------|-----|-------| | Node Components | 91645 | Component list | -| Node Bugs | 83963 | Node component bugs in OCPBUGS/RHOCPPRIO/OCPNODE | +| Node Bugs | 83963 | Node component bugs | +| Node Core Team | 66331 | Core team members | | Node Green Team | 89708 | Green team assignees | | Node Blue Team | 64253 | Blue team assignees | -| Node Core Team | 66331 | Core team members | | Node Epics | 96318 | OCPNODE epics | | Node CR bugs | 94401 | Component regression bugs | -## Custom Field IDs - -Use field names in JQL, IDs for REST API calls: - -| ID | Name | -|---|---| -| `customfield_10014` | Epic Link | -| `customfield_10011` | Epic Name | -| `customfield_10020` | Sprint | -| `customfield_10028` | Story Points | -| `customfield_10001` | Team | -| `customfield_10022` | Target start | -| `customfield_10023` | Target end | -| `customfield_10855` | Target Version | -| `customfield_10840` | Severity | -| `customfield_10847` | Release Blocker | -| `customfield_10877` | Bugzilla Bug | -| `customfield_10875` | Git Pull Request | -| `customfield_10978` | SFDC Cases Counter | -| `customfield_10979` | SFDC Cases Links | -| `customfield_12313441` | SFDC Cases (legacy) | - ## Workflow Statuses Bug lifecycle: NEW → To Do → ASSIGNED → POST → Modified → ON_QA → Verified → CLOSED/Done Feature/epic: New → Planning → To Do → In Progress → Code Review → Review → Dev Complete → Done/Closed +Status grouping for dashboards: map `statusCategory` key `"done"` → done, status name `"Code Review"` → codeReview, `"MODIFIED"` → modified, `statusCategory` `"indeterminate"` → inProgress, `statusCategory` `"new"` → toDo, else → other. + ## Key Field Meanings | Field Value | Meaning | -|---|---| +|-------------|---------| | Priority: Undefined | Untriaged — needs prioritization | | Release Blocker: Proposed | Someone thinks this blocks the release | | Release Blocker: Approved | Confirmed release blocker | -| Customer Impact: Customer Escalated | Customer-reported or escalated | | SFDC Cases Counter (not empty) | Has linked support cases | -| Special Handling: contract-priority | Contractual obligation | ## Bug Triage Definitions Base all queries on `filter = "Node Bugs"` and append: | Category | JQL Clause | -|---|---| +|----------|-----------| | Untriaged | `priority = Undefined OR "Release Blocker" = Proposed OR assignee in ("aos-node@redhat.com")` | | Blocker? | `"Release Blocker" = Proposed OR priority = Blocker AND "Release Blocker" is EMPTY` | | Blocker+ | `"Release Blocker" = Approved OR priority = Blocker` | | Customer Issues | `"Customer Impact" = "Customer Escalated" OR "SFDC Cases Counter" is not EMPTY` | -| Escalations | `project = "Red Hat OpenShift Priority List" OR "Customer Impact" = "Customer Escalated" OR labels in (shift_telco5g)` | | CVE | `labels in (SecurityTracking) OR issuetype in (Vulnerability, Weakness)` | | CR | `labels = component-regression` | -## Common Workflows - -### Creating and assigning an issue - -```bash -# Create an Epic in OCPNODE -jira.sh create OCPNODE Epic "My epic summary" '{"customfield_10011":"Epic Name Here"}' - -# Create a Story linked to an epic -jira.sh create OCPNODE Story "My story summary" '{"customfield_10014":"OCPNODE-1234"}' - -# Assign by display name (resolves via roster + Jira API) -jira.sh assign OCPNODE-1234 "John Smith" +## Carryover Detection -# Assign by accountId (if you already have it) -jira.sh assign OCPNODE-1234 "712020:abc-def-123" +Count closed sprints in `customfield_10020` array to detect carryovers: ``` - -### Finding a user - -```bash -# Searches team rosters first, then Jira user directory -jira.sh find-user "Sabuj" +sprints_carried = count of items in customfield_10020 where state == "closed" ``` -### Standup and bug review - -```bash -# Team standup — present interactively, one person at a time, alphabetically -standup-data core # then present as a table, one-by-one +## External CVE Filtering -# Bug overview — single table sorted by latest update -bug-overview core # present with bug-id, description, priority, status, assignee -``` +Exclude from bug counts: bugs with "CVE" in summary AND status "ASSIGNED" AND assignee not in team roster AND assignee != "Unassigned". These are handled by other teams. ## Gotchas - Epic children: use `"Epic Link" = EPIC-KEY` in JQL (not `parentEpic`). -- `issueFunction` (e.g. `issueFunction in commented("by currentUser()")`) does **not exist** on Jira Cloud. Workaround: `watcher = currentUser() AND comment ~ "keyword"`. +- `issueFunction` does **not exist** on Jira Cloud. Workaround: `watcher = currentUser() AND comment ~ "keyword"`. - Always confirm with the user before any write operation (create, edit, comment, transition). +- Release Blocker and Blocked fields are objects (`{"value":"True"}`), not strings. Check shape before accessing `.value`. +- When listing sprints, filter to Node-relevant by checking if sprint name contains "Node" or "Kueue", then sort by `startDate` descending.