#!/usr/bin/env bash # # get-tasks # # Lists tasks from docs/tasks.md sorted by ID. # Supports --accept, --reject, and --sort [asc|desc] (defaults to descending). # Defaults to Triage tasks, or Triage & Planning with -p / --planning. # Uses filter-table if available, with a fast built-in fallback. # set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" TASKS_FILE="${TASKS_FILE:-}" if [[ -z "$TASKS_FILE" ]]; then for cand in \ "$REPO_ROOT/docs/tasks.md" \ "$REPO_ROOT/docs/Tasks.md"; do if [[ -f "$cand" ]]; then TASKS_FILE="$cand" break fi done fi if [[ -z "$TASKS_FILE" ]]; then TASKS_FILE="$REPO_ROOT/docs/tasks.md" fi ACCEPTS=() REJECTS=() MODE="" SORT_DIR="desc" while [[ $# -gt 0 ]]; do case "$1" in -h|--help) echo "usage: get-tasks [-h] [-p|--planning] [--all] [--accept PREDICATE] [--reject PREDICATE] [--sort {asc,desc}] [-s|--select COLS] [-f|--file PATH]" echo "" echo "Lists tasks from docs/tasks.md sorted by ID." echo "" echo "options:" echo " -h, --help Show this help message and exit" echo " -p, --planning Show tasks in Triage and Planning status" echo " --all Show all tasks regardless of status" echo " --accept PREDICATE Include rows matching predicate (e.g. 'Status:Fixed', 'Project:Engine', 'Epic:poc')" echo " Epic filters accept frontmatter values or labels" echo " --reject PREDICATE Exclude rows matching predicate (e.g. 'Status:Cancelled')" echo " --sort [asc|desc] Sort order by task ID (default: desc)" echo " -s, --select COLS Columns to display (e.g. 'ID,Title,Project,Epic,Status')" echo " -f, --file PATH Path to tasks.md (overrides TASKS_FILE)" exit 0 ;; -p|--planning) MODE="planning" shift ;; --all) MODE="all" shift ;; -f|--file) if [[ $# -lt 2 ]]; then echo "Error: $1 requires a file path" >&2 exit 1 fi TASKS_FILE="$2" shift 2 ;; --accept) if [[ $# -lt 2 ]]; then echo "Error: --accept requires a predicate argument" >&2 exit 1 fi ACCEPTS+=("$2") shift 2 ;; --accept=*) ACCEPTS+=("${1#*=}") shift ;; --reject) if [[ $# -lt 2 ]]; then echo "Error: $1 requires a predicate argument" >&2 exit 1 fi REJECTS+=("$2") shift 2 ;; --reject=*) REJECTS+=("${1#*=}") shift ;; -s|--select) if [[ $# -lt 2 ]]; then echo "Error: $1 requires a column list argument" >&2 exit 1 fi SELECT_COLS="$2" shift 2 ;; --select=*) SELECT_COLS="${1#*=}" shift ;; --sort) if [[ -n "${2:-}" && "$2" != -* ]]; then SORT_DIR="$2" shift 2 else SORT_DIR="desc" shift fi ;; --sort=*) SORT_DIR="${1#*=}" shift ;; *) echo "Error: Unknown option '$1'" >&2 echo "Run 'get-tasks --help' for usage." >&2 exit 1 ;; esac done if [ "$SORT_DIR" = "asc" ]; then SORT_FLAG="ID" else SORT_FLAG="-ID" fi # Apply default accept if no explicit accept was provided if [ ${#ACCEPTS[@]} -eq 0 ]; then if [ "$MODE" = "planning" ]; then ACCEPTS+=("Status:Triage,Planning") elif [ "$MODE" = "all" ]; then : # No default filter else ACCEPTS+=("Status:Triage") fi fi if [ $((${#ACCEPTS[@]} + ${#REJECTS[@]})) -gt 0 ]; then ACCEPT_COUNT=${#ACCEPTS[@]} NORMALIZED_PREDICATES="$(python3 "$SCRIPT_DIR/lib/filter_predicates.py" "$TASKS_FILE" "${ACCEPTS[@]}" "${REJECTS[@]}")" ACCEPTS=() REJECTS=() PREDICATE_INDEX=0 while IFS= read -r predicate; do if [ "$PREDICATE_INDEX" -lt "$ACCEPT_COUNT" ]; then ACCEPTS+=("$predicate") else REJECTS+=("$predicate") fi PREDICATE_INDEX=$((PREDICATE_INDEX + 1)) done <<< "$NORMALIZED_PREDICATES" fi if command -v filter-table >/dev/null 2>&1; then FILTER_ARGS=("$TASKS_FILE") for acc in "${ACCEPTS[@]}"; do FILTER_ARGS+=(--accept "$acc") done for rej in "${REJECTS[@]}"; do FILTER_ARGS+=(--reject "$rej") done FILTER_ARGS+=(--sort "$SORT_FLAG") if [ -n "$SELECT_COLS" ]; then FILTER_ARGS+=(--select "$SELECT_COLS") elif [ "$MODE" = "all" ]; then ALL_COLS="ID,Title,Project" if grep -q "| Subproject" "$TASKS_FILE"; then ALL_COLS="$ALL_COLS,Subproject" fi if grep -q "| Epic" "$TASKS_FILE"; then ALL_COLS="$ALL_COLS,Epic" fi FILTER_ARGS+=(--select "$ALL_COLS,Status") else FILTER_ARGS+=(--select "ID,Status,Title") fi exec filter-table "${FILTER_ARGS[@]}" else # Python fallback implementation python3 - "$TASKS_FILE" "$SORT_DIR" "$MODE" "${SELECT_COLS:-}" "${#ACCEPTS[@]}" "${ACCEPTS[@]}" "${REJECTS[@]}" <<'PY' import sys, re with open(sys.argv[1], 'r', encoding='utf-8') as f: lines = f.readlines() sort_dir, mode, select_cols_str = sys.argv[2:5] accept_count = int(sys.argv[5]) accepts = sys.argv[6:6 + accept_count] rejects = sys.argv[6 + accept_count:] def parse_predicates(pred_list): preds = [] for p in pred_list: if ':' in p: col, vals = p.split(':', 1) allowed = {v.strip().lower() for v in vals.split(',') if v.strip()} preds.append((col.strip().lower(), allowed)) return preds parsed_accepts = parse_predicates(list(accepts) if isinstance(accepts, (list, tuple)) else []) parsed_rejects = parse_predicates(list(rejects) if isinstance(rejects, (list, tuple)) else []) in_table = False headers = [] header_display = [] raw_rows = [] for line in lines: if line.startswith('| ID'): in_table = True header_display = [h.strip() for h in line.strip().split('|')[1:-1]] headers = [h.lower() for h in header_display] continue if in_table: if not line.strip() or line.startswith('## Detailed Tasks') or not line.startswith('|'): break parts = [p.strip() for p in line.strip().split('|')[1:-1]] if len(parts) >= len(headers) and not parts[0].startswith(':-'): row_dict = {headers[i]: parts[i] for i in range(len(headers))} # Check accepts accept_ok = True for col, allowed in parsed_accepts: val = row_dict.get(col, '').lower() if val not in allowed: accept_ok = False break if not accept_ok: continue # Check rejects reject_hit = False for col, disallowed in parsed_rejects: val = row_dict.get(col, '').lower() if val in disallowed: reject_hit = True break if reject_hit: continue raw_rows.append(row_dict) reverse_sort = (sort_dir != 'asc') raw_rows.sort( key=lambda r: int(re.search(r'\d+$', r.get('id', '0')).group(0)) if re.search(r'\d+$', r.get('id', '0')) else 0, reverse=reverse_sort ) if select_cols_str: req_cols = [c.strip() for c in select_cols_str.split(',') if c.strip()] out_headers = [c for c in req_cols if c.lower() in headers] else: if mode == 'all': out_headers = ['ID', 'Title', 'Project'] if 'subproject' in headers: out_headers.append('Subproject') if 'epic' in headers: out_headers.append('Epic') out_headers.append('Status') else: out_headers = ['ID', 'Title'] col_keys = [c.lower() for c in out_headers] col_widths = [len(h) for h in out_headers] formatted_rows = [] for r in raw_rows: row_vals = [] for k in col_keys: val = r.get(k, '') if k == 'title' and not select_cols_str and mode != 'all': clean_t = val.strip('*').strip('_').strip() st = r.get('status', '').lower() val = f'**{clean_t}**' if st == 'pending' else f'*{clean_t}*' if st in ('triage', 'planning') else clean_t row_vals.append(val) formatted_rows.append(row_vals) for idx, v in enumerate(row_vals): col_widths[idx] = max(col_widths[idx], len(v)) hdr_line = '| ' + ' | '.join(f'{out_headers[i]:<{col_widths[i]}}' for i in range(len(out_headers))) + ' |' sep_line = '| ' + ' | '.join(':' + '-' * (col_widths[i] - 1) for i in range(len(out_headers))) + ' |' print(hdr_line) print(sep_line) for row in formatted_rows: print('| ' + ' | '.join(f'{row[i]:<{col_widths[i]}}' for i in range(len(out_headers))) + ' |') PY fi