Initial commit — briefing CLI tool
Terminal dashboard for Nodie daily/weekly work summaries with: - Color-coded task display with creation dates and "days ago" format - NEW badge highlighting for tasks created today - Grouped sections: New Today, Needs Work, Waiting, Done - Interactive mode: type a task number to see full details - Calendar rendering with NOW/DONE/NEXT markers - Git commit summary per repo Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
531
briefing
Executable file
531
briefing
Executable file
@@ -0,0 +1,531 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Nodie Briefing — terminal display with interactive mode
|
||||
Usage: briefing [7|30] [client]
|
||||
briefing — 7-day daily briefing
|
||||
briefing 30 — 30-day weekly review
|
||||
briefing 7 OHS — 7-day, OHS only
|
||||
"""
|
||||
|
||||
import json, sys, subprocess, re, readline
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
API_KEY = "b666180ef00fffd23b9a606a4bd8deddea274d2e69fab975583d3d966fbfafed"
|
||||
BASE_URL = "https://teacup.nodie.co.za/api/v1"
|
||||
|
||||
# ANSI
|
||||
BOLD = "\033[1m"
|
||||
DIM = "\033[2m"
|
||||
ITALIC = "\033[3m"
|
||||
UNDER = "\033[4m"
|
||||
RESET = "\033[0m"
|
||||
WHITE = "\033[97m"
|
||||
GREY = "\033[90m"
|
||||
CYAN = "\033[36m"
|
||||
GREEN = "\033[32m"
|
||||
YELLOW = "\033[33m"
|
||||
RED = "\033[31m"
|
||||
MAGENTA = "\033[35m"
|
||||
BG_YELLOW = "\033[43m"
|
||||
BG_GREEN = "\033[42m"
|
||||
BLACK = "\033[30m"
|
||||
|
||||
SAST = timezone(timedelta(hours=2))
|
||||
|
||||
# Priority colors
|
||||
PRI_COLOR = {
|
||||
'critical': RED,
|
||||
'high': RED,
|
||||
'medium': YELLOW,
|
||||
'low': GREY,
|
||||
None: GREY,
|
||||
'': GREY,
|
||||
}
|
||||
|
||||
# Status colors
|
||||
STATUS_COLOR = {
|
||||
'in_progress': GREEN,
|
||||
'todo': CYAN,
|
||||
'hold_to_discuss': MAGENTA,
|
||||
'waitingfortesting': YELLOW,
|
||||
'willdemo': YELLOW,
|
||||
'done': DIM,
|
||||
'completed': DIM,
|
||||
}
|
||||
|
||||
|
||||
def days_ago(created_at_str):
|
||||
"""Return human-friendly age string."""
|
||||
if not created_at_str:
|
||||
return ""
|
||||
try:
|
||||
created = datetime.strptime(created_at_str, "%Y-%m-%d %H:%M:%S").replace(tzinfo=SAST)
|
||||
now = datetime.now(SAST)
|
||||
delta = now - created
|
||||
if delta.days == 0:
|
||||
return "today"
|
||||
elif delta.days == 1:
|
||||
return "1d ago"
|
||||
elif delta.days < 7:
|
||||
return f"{delta.days}d ago"
|
||||
elif delta.days < 30:
|
||||
weeks = delta.days // 7
|
||||
return f"{weeks}w ago"
|
||||
else:
|
||||
months = delta.days // 30
|
||||
return f"{months}mo ago"
|
||||
except (ValueError, TypeError):
|
||||
return ""
|
||||
|
||||
|
||||
def is_new(created_at_str):
|
||||
"""True if created today."""
|
||||
if not created_at_str:
|
||||
return False
|
||||
try:
|
||||
created = datetime.strptime(created_at_str, "%Y-%m-%d %H:%M:%S").replace(tzinfo=SAST)
|
||||
now = datetime.now(SAST)
|
||||
return created.date() == now.date()
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def format_priority(pri):
|
||||
color = PRI_COLOR.get(pri, GREY)
|
||||
return f"{color}{pri or 'unset'}{RESET}"
|
||||
|
||||
|
||||
def format_status(status):
|
||||
color = STATUS_COLOR.get(status, GREY)
|
||||
return f"{color}{status or 'unset'}{RESET}"
|
||||
|
||||
|
||||
def new_badge():
|
||||
return f" {BG_YELLOW}{BLACK} NEW {RESET}"
|
||||
|
||||
|
||||
def print_task(task, indent=" "):
|
||||
"""Print a single task line with metadata."""
|
||||
nid = task['nodesid']
|
||||
name = task['node_name']
|
||||
status = task.get('status')
|
||||
priority = task.get('priority')
|
||||
created = task.get('created_at', '')
|
||||
server = task.get('server')
|
||||
assigned = task.get('on_behalf_of')
|
||||
age = days_ago(created)
|
||||
is_today = is_new(created)
|
||||
|
||||
# Task number + name
|
||||
badge = new_badge() if is_today else ""
|
||||
line = f"{indent}{YELLOW}#{nid}{RESET} {BOLD}{WHITE}{name}{RESET}{badge}"
|
||||
print(line)
|
||||
|
||||
# Meta line
|
||||
parts = [format_status(status), format_priority(priority)]
|
||||
if age:
|
||||
age_color = GREEN if is_today else GREY
|
||||
parts.append(f"{age_color}{age}{RESET}")
|
||||
if server:
|
||||
parts.append(f"{CYAN}{server}{RESET}")
|
||||
if assigned:
|
||||
# Shorten email format
|
||||
short = assigned.split(' (')[0] if ' (' in assigned else assigned
|
||||
parts.append(f"{DIM}For: {short}{RESET}")
|
||||
|
||||
print(f"{indent} {' | '.join(parts)}")
|
||||
|
||||
# Children summary
|
||||
cs = task.get('children_summary')
|
||||
if cs and cs.get('total', 0) > 0:
|
||||
child_parts = []
|
||||
for key in ['in_progress', 'todo', 'waitingfortesting', 'done', 'completed']:
|
||||
if cs.get(key, 0) > 0:
|
||||
child_parts.append(f"{cs[key]} {key}")
|
||||
if child_parts:
|
||||
print(f"{indent} {GREY}Subtasks: {', '.join(child_parts)}{RESET}")
|
||||
|
||||
# Latest note preview
|
||||
note = task.get('latest_note')
|
||||
if note:
|
||||
text = note.get('content', '')
|
||||
if len(text) > 100:
|
||||
text = text[:100] + "..."
|
||||
print(f"{indent} {DIM}{ITALIC}{text}{RESET}")
|
||||
|
||||
|
||||
def render_calendar(cal_data):
|
||||
"""Render calendar section from raw data."""
|
||||
if not cal_data:
|
||||
return
|
||||
|
||||
print(f"\n{BOLD}{CYAN}{'─' * 60}{RESET}")
|
||||
print(f"{BOLD}{CYAN}Calendar{RESET}")
|
||||
print(f"{BOLD}{CYAN}{'─' * 60}{RESET}")
|
||||
|
||||
now = datetime.now(SAST)
|
||||
|
||||
for date_str, events in cal_data.items():
|
||||
try:
|
||||
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
||||
if dt.date() == now.date():
|
||||
label = f"{dt.strftime('%a %d %b')} (today)"
|
||||
elif dt.date() == (now + timedelta(days=1)).date():
|
||||
label = f"{dt.strftime('%a %d %b')} (tomorrow)"
|
||||
else:
|
||||
label = dt.strftime('%a %d %b')
|
||||
except ValueError:
|
||||
label = date_str
|
||||
|
||||
print(f"\n {BOLD}{WHITE}{label}{RESET}")
|
||||
|
||||
for ev in events:
|
||||
start = ev.get('start_datetime', '')
|
||||
end = ev.get('end_datetime', '')
|
||||
title = ev.get('title', '')
|
||||
|
||||
# Format times
|
||||
try:
|
||||
s = datetime.strptime(start, "%Y-%m-%d %H:%M:%S")
|
||||
e = datetime.strptime(end, "%Y-%m-%d %H:%M:%S")
|
||||
time_str = f"{s.strftime('%H:%M')}–{e.strftime('%H:%M')}"
|
||||
|
||||
# Status marker
|
||||
if s.replace(tzinfo=SAST) < now < e.replace(tzinfo=SAST):
|
||||
marker = f" {BG_GREEN}{BLACK} NOW {RESET}"
|
||||
elif e.replace(tzinfo=SAST) < now:
|
||||
marker = f" {GREY}[DONE]{RESET}"
|
||||
elif s.replace(tzinfo=SAST) > now and (s.replace(tzinfo=SAST) - now).seconds < 3600:
|
||||
marker = f" {CYAN}[NEXT]{RESET}"
|
||||
else:
|
||||
marker = ""
|
||||
except ValueError:
|
||||
time_str = start
|
||||
marker = ""
|
||||
|
||||
print(f" {DIM}{time_str}{RESET} {title}{marker}")
|
||||
|
||||
|
||||
def render_tasks(tasks):
|
||||
"""Render tasks grouped by category, with NEW section first."""
|
||||
if not tasks:
|
||||
return
|
||||
|
||||
# Separate new tasks
|
||||
new_tasks = [t for t in tasks if t.get('category') == 'actionable' and is_new(t.get('created_at', ''))]
|
||||
actionable = [t for t in tasks if t.get('category') == 'actionable' and not is_new(t.get('created_at', ''))]
|
||||
waiting = [t for t in tasks if t.get('category') == 'waiting']
|
||||
done = [t for t in tasks if t.get('category') == 'done']
|
||||
|
||||
# New today
|
||||
if new_tasks:
|
||||
print(f"\n{BOLD}{RED}{'─' * 60}{RESET}")
|
||||
print(f"{BOLD}{RED}New Today ({len(new_tasks)}){RESET}")
|
||||
print(f"{BOLD}{RED}{'─' * 60}{RESET}")
|
||||
by_client = group_by_client(new_tasks)
|
||||
for client, projects in by_client.items():
|
||||
print(f"\n {BOLD}{WHITE}{client}{RESET}")
|
||||
for project, ptasks in projects.items():
|
||||
if project:
|
||||
print(f" {BOLD}{GREY}{project}{RESET}")
|
||||
for t in ptasks:
|
||||
print_task(t, " ")
|
||||
|
||||
# Actionable
|
||||
if actionable:
|
||||
print(f"\n{BOLD}{CYAN}{'─' * 60}{RESET}")
|
||||
print(f"{BOLD}{CYAN}Needs Work ({len(actionable)}){RESET}")
|
||||
print(f"{BOLD}{CYAN}{'─' * 60}{RESET}")
|
||||
by_client = group_by_client(actionable)
|
||||
for client, projects in by_client.items():
|
||||
print(f"\n {BOLD}{WHITE}{client}{RESET}")
|
||||
for project, ptasks in projects.items():
|
||||
if project:
|
||||
print(f" {BOLD}{GREY}{project}{RESET}")
|
||||
for t in ptasks:
|
||||
print_task(t, " ")
|
||||
|
||||
# Waiting
|
||||
if waiting:
|
||||
print(f"\n{BOLD}{YELLOW}{'─' * 60}{RESET}")
|
||||
print(f"{BOLD}{YELLOW}Waiting on Others ({len(waiting)}){RESET}")
|
||||
print(f"{BOLD}{YELLOW}{'─' * 60}{RESET}")
|
||||
by_client = group_by_client(waiting)
|
||||
for client, projects in by_client.items():
|
||||
print(f"\n {BOLD}{WHITE}{client}{RESET}")
|
||||
for project, ptasks in projects.items():
|
||||
if project:
|
||||
print(f" {BOLD}{GREY}{project}{RESET}")
|
||||
for t in ptasks:
|
||||
print_task(t, " ")
|
||||
|
||||
# Done
|
||||
if done:
|
||||
print(f"\n{BOLD}{GREEN}{'─' * 60}{RESET}")
|
||||
print(f"{BOLD}{GREEN}Recently Completed ({len(done)}){RESET}")
|
||||
print(f"{BOLD}{GREEN}{'─' * 60}{RESET}")
|
||||
by_client = group_by_client(done)
|
||||
for client, projects in by_client.items():
|
||||
print(f"\n {BOLD}{WHITE}{client}{RESET}")
|
||||
for project, ptasks in projects.items():
|
||||
if project:
|
||||
print(f" {BOLD}{GREY}{project}{RESET}")
|
||||
for t in ptasks:
|
||||
print_task(t, " ")
|
||||
|
||||
|
||||
def group_by_client(tasks):
|
||||
"""Group tasks by client > project, preserving order."""
|
||||
result = {}
|
||||
for t in tasks:
|
||||
client = t.get('client') or 'Unknown'
|
||||
project = t.get('project') or ''
|
||||
if client not in result:
|
||||
result[client] = {}
|
||||
if project not in result[client]:
|
||||
result[client][project] = []
|
||||
result[client][project].append(t)
|
||||
return result
|
||||
|
||||
|
||||
def render_commits(commits):
|
||||
"""Render git commit summary."""
|
||||
if not commits:
|
||||
return
|
||||
|
||||
total = sum(len(c) for c in commits.values())
|
||||
print(f"\n{BOLD}{CYAN}{'─' * 60}{RESET}")
|
||||
print(f"{BOLD}{CYAN}Commits ({total}){RESET}")
|
||||
print(f"{BOLD}{CYAN}{'─' * 60}{RESET}")
|
||||
|
||||
for repo, repo_commits in commits.items():
|
||||
print(f"\n {BOLD}{WHITE}{repo}{RESET} {GREY}({len(repo_commits)}){RESET}")
|
||||
for c in repo_commits[:5]: # Show max 5 per repo
|
||||
sha = c.get('sha', '')[:7]
|
||||
date = c.get('date', '')[:10]
|
||||
msg = c.get('message', '').split('\n')[0]
|
||||
if len(msg) > 70:
|
||||
msg = msg[:70] + "..."
|
||||
print(f" {GREY}{sha} {date}{RESET} {msg}")
|
||||
if len(repo_commits) > 5:
|
||||
print(f" {DIM}... +{len(repo_commits) - 5} more{RESET}")
|
||||
|
||||
|
||||
def render_summary(summary, range_days):
|
||||
"""Render the summary header."""
|
||||
now = datetime.now(SAST)
|
||||
print(f"\n{BOLD}{WHITE}{now.strftime('%a %d %b, %H:%M')}{RESET}")
|
||||
|
||||
a = summary.get('actionable', 0)
|
||||
w = summary.get('waiting', 0)
|
||||
b = summary.get('blocked', 0)
|
||||
d = summary.get('done', 0)
|
||||
m = summary.get('meetings_today', 0)
|
||||
|
||||
parts = []
|
||||
if a: parts.append(f"{CYAN}{a} actionable{RESET}")
|
||||
if w: parts.append(f"{YELLOW}{w} waiting{RESET}")
|
||||
if b: parts.append(f"{RED}{b} blocked{RESET}")
|
||||
if d: parts.append(f"{GREEN}{d} done{RESET}")
|
||||
print(f" {' | '.join(parts)} — {range_days}-day window")
|
||||
if m:
|
||||
print(f" {MAGENTA}{m} meeting{'s' if m != 1 else ''} today{RESET}")
|
||||
|
||||
|
||||
def fetch_task_detail(nid):
|
||||
"""Fetch full task detail for interactive mode."""
|
||||
result = subprocess.run(
|
||||
["curl", "-s", "-H", f"Authorization: Bearer {API_KEY}",
|
||||
f"{BASE_URL}/nodes/{nid}"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
if data.get('success'):
|
||||
return data['data']
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def show_task_detail(nid):
|
||||
"""Show full task detail in interactive mode."""
|
||||
print(f"\n {DIM}Fetching #{nid}...{RESET}")
|
||||
data = fetch_task_detail(nid)
|
||||
if not data:
|
||||
print(f" {RED}Could not fetch task #{nid}{RESET}")
|
||||
return
|
||||
|
||||
node = data.get('node', {})
|
||||
details = data.get('details', {})
|
||||
bc = data.get('breadcrumb', {})
|
||||
children = data.get('children', [])
|
||||
|
||||
# Header
|
||||
print(f"\n {BOLD}{WHITE}#{node.get('nodesid')} — {node.get('node_name')}{RESET}")
|
||||
print(f" {GREY}{'─' * 50}{RESET}")
|
||||
|
||||
# Breadcrumb
|
||||
bc_parts = []
|
||||
if bc.get('client'):
|
||||
bc_parts.append(bc['client'].get('node_name', ''))
|
||||
if bc.get('project'):
|
||||
bc_parts.append(bc['project'].get('node_name', ''))
|
||||
if bc.get('category'):
|
||||
bc_parts.append(bc['category'].get('node_name', ''))
|
||||
if bc_parts:
|
||||
print(f" {DIM}{' > '.join(bc_parts)}{RESET}")
|
||||
|
||||
# Dates
|
||||
created = node.get('created_at', '')
|
||||
updated = node.get('updated_at', '')
|
||||
created_by = node.get('created_by', '')
|
||||
age = days_ago(created)
|
||||
print(f" {DIM}Created: {created} ({age}){' by ' + created_by if created_by else ''}{RESET}")
|
||||
if updated != created:
|
||||
print(f" {DIM}Updated: {updated} ({days_ago(updated)}){RESET}")
|
||||
|
||||
# Details
|
||||
if isinstance(details, dict):
|
||||
status = details.get('status') or details.get('taskStatus') or details.get('subtaskStatus')
|
||||
priority = details.get('priority') or details.get('taskPriority') or details.get('subtaskPriority')
|
||||
server = details.get('server')
|
||||
assigned = details.get('on_behalf_of')
|
||||
completion = details.get('completion') or details.get('taskCompletion') or details.get('subtaskCompletion')
|
||||
|
||||
meta = []
|
||||
if status: meta.append(f"Status: {format_status(status)}")
|
||||
if priority: meta.append(f"Priority: {format_priority(priority)}")
|
||||
if completion and completion != '0': meta.append(f"Completion: {completion}%")
|
||||
if server: meta.append(f"Server: {CYAN}{server}{RESET}")
|
||||
if assigned: meta.append(f"For: {assigned}")
|
||||
for m in meta:
|
||||
print(f" {m}")
|
||||
|
||||
# Description
|
||||
desc = details.get('description')
|
||||
if desc:
|
||||
print(f"\n {WHITE}{desc}{RESET}")
|
||||
|
||||
# Note content (from ticket)
|
||||
note = details.get('note_content')
|
||||
if note:
|
||||
# Strip HTML
|
||||
clean = re.sub(r'<[^>]+>', ' ', note)
|
||||
clean = re.sub(r'\s+', ' ', clean).strip()
|
||||
if len(clean) > 500:
|
||||
clean = clean[:500] + "..."
|
||||
print(f"\n {DIM}{clean}{RESET}")
|
||||
|
||||
# Children
|
||||
if children:
|
||||
subtasks = [c for c in children if c.get('node_typesid') in (4, 5)]
|
||||
notes = [c for c in children if c.get('node_typesid') == 6]
|
||||
|
||||
if subtasks:
|
||||
print(f"\n {BOLD}Subtasks ({len(subtasks)}):{RESET}")
|
||||
for st in subtasks:
|
||||
st_details = st.get('details', {})
|
||||
st_status = ''
|
||||
if isinstance(st_details, dict):
|
||||
st_status = st_details.get('status') or st_details.get('subtaskStatus') or ''
|
||||
print(f" {YELLOW}#{st['nodesid']}{RESET} {st['node_name']} {GREY}[{st_status}]{RESET}")
|
||||
|
||||
if notes:
|
||||
print(f"\n {BOLD}Notes ({len(notes)}):{RESET}")
|
||||
for n in notes[-3:]: # Show last 3 notes
|
||||
n_details = n.get('details', {})
|
||||
content = ''
|
||||
if isinstance(n_details, dict):
|
||||
content = n_details.get('note_content') or n_details.get('description') or ''
|
||||
content = re.sub(r'<[^>]+>', ' ', content)
|
||||
content = re.sub(r'\s+', ' ', content).strip()
|
||||
if len(content) > 200:
|
||||
content = content[:200] + "..."
|
||||
n_age = days_ago(n.get('created_at', ''))
|
||||
print(f" {GREY}{n.get('created_at', '')[:10]} ({n_age}){RESET}")
|
||||
print(f" {DIM}{content}{RESET}")
|
||||
|
||||
print(f"\n {DIM}URL: https://teacup.nodie.co.za/?sn={node.get('nodesid')}{RESET}")
|
||||
|
||||
|
||||
def interactive_mode(tasks):
|
||||
"""Interactive prompt — type a task number to see details."""
|
||||
# Build lookup
|
||||
task_ids = set()
|
||||
for t in tasks:
|
||||
task_ids.add(str(t['nodesid']))
|
||||
|
||||
print(f"\n{BOLD}{CYAN}{'─' * 60}{RESET}")
|
||||
print(f"{DIM}Type a task number for details, or 'q' to quit{RESET}")
|
||||
print(f"{BOLD}{CYAN}{'─' * 60}{RESET}")
|
||||
|
||||
while True:
|
||||
try:
|
||||
inp = input(f"\n{BOLD}{CYAN}#{RESET} ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print()
|
||||
break
|
||||
|
||||
if not inp or inp.lower() in ('q', 'quit', 'exit'):
|
||||
break
|
||||
|
||||
# Strip # prefix if present
|
||||
inp = inp.lstrip('#')
|
||||
|
||||
if inp.isdigit():
|
||||
show_task_detail(int(inp))
|
||||
else:
|
||||
print(f" {DIM}Enter a task number (e.g. 3047) or 'q' to quit{RESET}")
|
||||
|
||||
|
||||
def main():
|
||||
range_days = "7"
|
||||
client = None
|
||||
|
||||
args = sys.argv[1:]
|
||||
if args and args[0].isdigit():
|
||||
range_days = args.pop(0)
|
||||
if args:
|
||||
client = args[0]
|
||||
|
||||
url = f"{BASE_URL}/briefing?range={range_days}"
|
||||
if client:
|
||||
url += f"&client={client}"
|
||||
|
||||
result = subprocess.run(
|
||||
["curl", "-s", "-H", f"Authorization: Bearer {API_KEY}", url],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
|
||||
if not result.stdout.strip():
|
||||
print(f"{RED}Empty response from API{RESET}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
print(f"{RED}Invalid JSON response{RESET}")
|
||||
print(result.stdout[:500])
|
||||
sys.exit(1)
|
||||
|
||||
if not data.get("success"):
|
||||
print(f"{RED}API error: {data.get('message', 'Unknown')}{RESET}")
|
||||
sys.exit(1)
|
||||
|
||||
raw = data["data"]
|
||||
|
||||
# Render from raw JSON data
|
||||
render_summary(raw.get('summary', {}), int(range_days))
|
||||
render_calendar(raw.get('calendar', {}))
|
||||
render_tasks(raw.get('tasks', []))
|
||||
render_commits(raw.get('git_commits', {}))
|
||||
|
||||
# Interactive mode
|
||||
tasks = raw.get('tasks', [])
|
||||
if tasks and sys.stdin.isatty():
|
||||
interactive_mode(tasks)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user