#!/bin/bash # gantt-tasks library - Full CRUD for Gantt Board tasks # Location: ~/.agents/skills/gantt-tasks/lib/tasks.sh # Refactored to use API endpoints with machine token auth # Configuration GANTT_API_URL="${GANTT_API_URL:-https://gantt-board.twisteddevices.com/api}" GANTT_MACHINE_TOKEN="${GANTT_MACHINE_TOKEN:-}" MAX_ID="9c29cc99-81a1-4e75-8dff-cd7cc5ceb5aa" # Max user ID # Debug function debug() { echo "[DEBUG] $*" >&2 } # Error handler error_exit() { echo "❌ $1" >&2 return 1 } # Make API call with machine token _api_call() { local method="$1" local endpoint="$2" local data="${3:-}" if [[ -z "$GANTT_MACHINE_TOKEN" ]]; then error_exit "GANTT_MACHINE_TOKEN not set" return 1 fi local url="${GANTT_API_URL}${endpoint}" local curl_opts=( -s -H "Content-Type: application/json" -H "Authorization: Bearer ${GANTT_MACHINE_TOKEN}" ) if [[ -n "$data" ]]; then curl_opts+=(-d "$data") fi curl "${curl_opts[@]}" -X "$method" "$url" 2>/dev/null } # Resolve project name to ID resolve_project_id() { local PROJECT_NAME="$1" # Special case: Research Project if [[ "$PROJECT_NAME" == "Research" || "$PROJECT_NAME" == "Research Project" ]]; then echo "a1b2c3d4-0003-0000-0000-000000000003" return 0 fi # Query by name via API local RESULT=$(_api_call "GET" "/projects" | jq -r --arg q "$PROJECT_NAME" ' .projects | map(select((.name // "") | ascii_downcase | contains($q | ascii_downcase))) | .[0].id // empty ') if [[ -n "$RESULT" ]]; then echo "$RESULT" return 0 fi # Return empty if not found echo "" return 1 } # Resolve user name to ID resolve_user_id() { local USER_NAME="$1" # Current mappings case "$USER_NAME" in "Max"|"max") echo "9c29cc99-81a1-4e75-8dff-cd7cc5ceb5aa" ;; "Matt"|"matt"|"Matt Bruce") echo "0a3e400c-3932-48ae-9b65-f3f9c6f26fe9" ;; *) echo "$MAX_ID" ;; # Default to Max esac } # Get current sprint (contains today's date) sprint_get_current() { local TODAY=$(date +%Y-%m-%d) # Get current sprint via API local RESULT=$(_api_call "GET" "/sprints" | jq -r --arg today "$TODAY" ' .sprints | map(select(.startDate <= $today and .endDate >= $today)) | .[0].id // empty ') if [[ -n "$RESULT" && "$RESULT" != "null" ]]; then echo "$RESULT" return 0 fi # Fallback: get most recent sprint RESULT=$(_api_call "GET" "/sprints" | jq -r ' .sprints | sort_by(.startDate) | reverse | .[0].id // empty ') echo "$RESULT" } # Resolve sprint name/ID resolve_sprint_id() { local SPRINT="$1" if [[ "$SPRINT" == "current" ]]; then sprint_get_current return 0 fi # If it looks like a UUID, use it directly if [[ "$SPRINT" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]]; then echo "$SPRINT" return 0 fi # Query by name via API local RESULT=$(_api_call "GET" "/sprints" | jq -r --arg q "$SPRINT" ' .sprints | map(select((.name // "") | ascii_downcase | contains($q | ascii_downcase))) | .[0].id // empty ') echo "$RESULT" } # Create a new task task_create() { local TITLE="" local DESCRIPTION="" local TYPE="task" local STATUS="open" local PRIORITY="medium" local PROJECT="" local SPRINT="" local ASSIGNEE="Max" local DUE_DATE="" local TAGS="" # Parse arguments while [[ $# -gt 0 ]]; do case $1 in --title) TITLE="$2"; shift 2 ;; --description) DESCRIPTION="$2"; shift 2 ;; --type) TYPE="$2"; shift 2 ;; --status) STATUS="$2"; shift 2 ;; --priority) PRIORITY="$2"; shift 2 ;; --project) PROJECT="$2"; shift 2 ;; --sprint) SPRINT="$2"; shift 2 ;; --assignee) ASSIGNEE="$2"; shift 2 ;; --due-date) DUE_DATE="$2"; shift 2 ;; --tags) TAGS="$2"; shift 2 ;; *) shift ;; esac done # Validate [[ -z "$TITLE" ]] && error_exit "--title is required" && return 1 # Resolve IDs local PROJECT_ID="a1b2c3d4-0003-0000-0000-000000000003" if [[ -n "$PROJECT" ]]; then PROJECT_ID=$(resolve_project_id "$PROJECT") fi local SPRINT_ID="" if [[ -n "$SPRINT" ]]; then SPRINT_ID=$(resolve_sprint_id "$SPRINT") fi local ASSIGNEE_ID=$(resolve_user_id "$ASSIGNEE") # Build JSON payload local JSONPayload=$(jq -n \ --arg title "$TITLE" \ --arg type "$TYPE" \ --arg status "$STATUS" \ --arg priority "$PRIORITY" \ --arg assigneeId "$ASSIGNEE_ID" \ --arg projectId "$PROJECT_ID" \ '{ title: $title, type: $type, status: $status, priority: $priority, assigneeId: $assigneeId, projectId: $projectId, comments: [], tags: [] }') # Add optional fields [[ -n "$DESCRIPTION" ]] && JSONPayload=$(echo "$JSONPayload" | jq --arg desc "$DESCRIPTION" '. + {description: $desc}') [[ -n "$SPRINT_ID" ]] && JSONPayload=$(echo "$JSONPayload" | jq --arg sid "$SPRINT_ID" '. + {sprintId: $sid}') [[ -n "$DUE_DATE" ]] && JSONPayload=$(echo "$JSONPayload" | jq --arg dd "$DUE_DATE" '. + {dueDate: $dd}') [[ -n "$TAGS" ]] && JSONPayload=$(echo "$JSONPayload" | jq --argjson tags "[$(echo "$TAGS" | sed 's/,/","/g' | sed 's/^/"/' | sed 's/$/"/')]" '. + {tags: $tags}') # Create task via API local Response=$(_api_call "POST" "/tasks" "{\"task\": $JSONPayload}") local TaskId=$(echo "$Response" | jq -r '.task.id // .id // empty') if [[ -n "$TaskId" && "$TaskId" != "null" ]]; then echo "$TaskId" return 0 fi error_exit "Failed to create task: $(echo "$Response" | jq -r '.error // "Unknown error"')" return 1 } # Get task details task_get() { local TASK_ID="$1" local Response=$(_api_call "GET" "/tasks") echo "$Response" | jq --arg id "$TASK_ID" '.tasks | map(select(.id == $id)) | .[0] // empty' } # List tasks with filters task_list() { local StatusFilter="" local ProjectFilter="" local SprintFilter="" local AssigneeFilter="" local PriorityFilter="" local JsonOutput=false # Parse arguments while [[ $# -gt 0 ]]; do case $1 in --status) StatusFilter="$2"; shift 2 ;; --project) ProjectFilter="$2"; shift 2 ;; --sprint) SprintFilter="$2"; shift 2 ;; --assignee) AssigneeFilter="$2"; shift 2 ;; --priority) PriorityFilter="$2"; shift 2 ;; --json) JsonOutput=true; shift ;; *) shift ;; esac done # Get all tasks via API local Response=$(_api_call "GET" "/tasks") local Tasks=$(echo "$Response" | jq '.tasks // []') # Apply filters in jq local Filtered="$Tasks" if [[ -n "$StatusFilter" ]]; then # Handle comma-separated status values if [[ "$StatusFilter" =~ , ]]; then Filtered=$(echo "$Filtered" | jq --arg statuses "$StatusFilter" ' [split(",")] as $statusList | map(select(.status as $s | $statusList | index($s))) ') else Filtered=$(echo "$Filtered" | jq --arg status "$StatusFilter" 'map(select(.status == $status))') fi fi if [[ -n "$ProjectFilter" ]]; then local ProjectId=$(resolve_project_id "$ProjectFilter") [[ -n "$ProjectId" ]] && Filtered=$(echo "$Filtered" | jq --arg pid "$ProjectId" 'map(select(.projectId == $pid))') fi if [[ -n "$SprintFilter" ]]; then local SprintId=$(resolve_sprint_id "$SprintFilter") [[ -n "$SprintId" ]] && Filtered=$(echo "$Filtered" | jq --arg sid "$SprintId" 'map(select(.sprintId == $sid))') fi if [[ -n "$AssigneeFilter" ]]; then local AssigneeId=$(resolve_user_id "$AssigneeFilter") Filtered=$(echo "$Filtered" | jq --arg aid "$AssigneeId" 'map(select(.assigneeId == $aid))') fi if [[ -n "$PriorityFilter" ]]; then Filtered=$(echo "$Filtered" | jq --arg pri "$PriorityFilter" 'map(select(.priority == $pri))') fi if [[ "$JsonOutput" == true ]]; then echo "{\"tasks\": $Filtered}" else # Pretty print echo "$Filtered" | jq -r '.[] | "\(.status) | \(.priority) | \(.title) | \(.id)"' 2>/dev/null fi } # Update task task_update() { local TASK_ID="$1" shift # First get the current task local CurrentTask=$(task_get "$TASK_ID") [[ -z "$CurrentTask" ]] && error_exit "Task not found: $TASK_ID" && return 1 # Build updates local Updates="{}" while [[ $# -gt 0 ]]; do case $1 in --title) Updates=$(echo "$Updates" | jq --arg v "$2" '. + {title: $v}'); shift 2 ;; --description) Updates=$(echo "$Updates" | jq --arg v "$2" '. + {description: $v}'); shift 2 ;; --status) Updates=$(echo "$Updates" | jq --arg v "$2" '. + {status: $v}'); shift 2 ;; --priority) Updates=$(echo "$Updates" | jq --arg v "$2" '. + {priority: $v}'); shift 2 ;; --due-date) Updates=$(echo "$Updates" | jq --arg v "$2" '. + {dueDate: $v}'); shift 2 ;; *) shift ;; esac done # Merge current task with updates local MergedTask=$(echo "$CurrentTask" | jq --argjson updates "$Updates" '. + $updates') # Send update via API local Payload=$(jq -n --argjson task "$MergedTask" '{task: $task}') local Response=$(_api_call "POST" "/tasks" "$Payload") if echo "$Response" | jq -e '.error' >/dev/null 2>&1; then error_exit "Update failed: $(echo "$Response" | jq -r '.error')" return 1 fi echo "✅ Task updated" } # Delete task task_delete() { local TASK_ID="$1" _api_call "DELETE" "/tasks" "{\"id\": \"$TASK_ID\"}" >/dev/null echo "✅ Task deleted" } # Add comment to task task_add_comment() { local TASK_ID="$1" local TEXT="$2" local UseChecklist=false local StatusPrefix="" shift 2 while [[ $# -gt 0 ]]; do case $1 in --checklist) UseChecklist=true; shift ;; --status) StatusPrefix="$2"; shift 2 ;; *) shift ;; esac done # Format comment local Now=$(date +"%Y-%m-%d %H:%M") local StatusEmoji="🔄" [[ -n "$StatusPrefix" ]] && StatusEmoji="$StatusPrefix" local CommentText if [[ "$UseChecklist" == true ]]; then CommentText=$(cat </dev/null || date +%s)" \ --arg text "$CommentText" \ --arg author "$MAX_ID" \ '{ id: $id, text: $text, createdAt: now | todate, commentAuthorId: $author, replies: [] }') # Append to comments local UpdatedComments=$(echo "$CurrentComments" | jq --argjson new "$NewComment" '. + [$new]') # Merge and update task local MergedTask=$(echo "$CurrentTask" | jq --argjson comments "$UpdatedComments" '. + {comments: $comments}') local Payload=$(jq -n --argjson task "$MergedTask" '{task: $task}') _api_call "POST" "/tasks" "$Payload" >/dev/null echo "✅ Comment added" } # Attach file to task (stores content in attachments field) task_attach_file() { local TASK_ID="$1" local FILE_PATH="$2" local Description="${3:-Attached file}" [[ ! -f "$FILE_PATH" ]] && error_exit "File not found: $FILE_PATH" && return 1 # Read file content local Content=$(cat "$FILE_PATH") local FileName=$(basename "$FILE_PATH") # Get current task with attachments local CurrentTask=$(task_get "$TASK_ID") local CurrentAttachments=$(echo "$CurrentTask" | jq '.attachments // []') # Build new attachment local NewAttachment=$(jq -n \ --arg id "$(uuidgen 2>/dev/null || date +%s)" \ --arg name "$FileName" \ --arg type "$(file -b --mime-type "$FILE_PATH" 2>/dev/null || echo 'application/octet-stream')" \ --arg dataUrl "data:text/plain;base64,$(base64 -i "$FILE_PATH" | tr -d '\n')" \ '{ id: $id, name: $name, type: $type, dataUrl: $dataUrl, uploadedAt: now | todate }') # Append to attachments local UpdatedAttachments=$(echo "$CurrentAttachments" | jq --argjson new "$NewAttachment" '. + [$new]') # Merge and update task local MergedTask=$(echo "$CurrentTask" | jq --argjson attachments "$UpdatedAttachments" '. + {attachments: $attachments}') local Payload=$(jq -n --argjson task "$MergedTask" '{task: $task}') _api_call "POST" "/tasks" "$Payload" >/dev/null echo "✅ File attached: $FileName" } # Update an existing comment by ID task_update_comment() { local TASK_ID="$1" local COMMENT_ID="$2" local NEW_TEXT="$3" # Get current task local CurrentTask=$(task_get "$TASK_ID") local AllComments=$(echo "$CurrentTask" | jq '.comments // []') # Find and update the specific comment, keep others unchanged local UpdatedComments=$(echo "$AllComments" | jq --arg cid "$COMMENT_ID" --arg text "$NEW_TEXT" \ 'map(if .id == $cid then .text = $text else . end)') # Merge and update task local MergedTask=$(echo "$CurrentTask" | jq --argjson comments "$UpdatedComments" '. + {comments: $comments}') local Payload=$(jq -n --argjson task "$MergedTask" '{task: $task}') _api_call "POST" "/tasks" "$Payload" >/dev/null echo "✅ Comment updated" } # Export all functions export -f resolve_project_id export -f resolve_user_id export -f sprint_get_current export -f resolve_sprint_id export -f task_create export -f task_get export -f task_list export -f task_update export -f task_delete export -f task_add_comment export -f task_update_comment export -f task_attach_file export -f _api_call