141 lines
7.1 KiB
Python
141 lines
7.1 KiB
Python
import json,re,os,glob,collections,datetime,sys
|
|
import yaml
|
|
import os; S=os.environ.get('TRIAGE_SCRATCH','.')
|
|
repos=json.load(open(f'{S}/repos.json')); repos=repos if isinstance(repos,list) else repos['items']
|
|
FM=re.compile(r'^---\n(.*?)\n---',re.S); TB=re.compile(r'```task\n(.*?)```',re.S)
|
|
parse_warnings=[]
|
|
def kv(block):
|
|
try:
|
|
d=yaml.safe_load(block)
|
|
except yaml.YAMLError:
|
|
# Historic records mix prose into task fences. Preserve their fields,
|
|
# but expose the degraded parse rather than silently certify the scan.
|
|
parse_warnings.append(f)
|
|
d={}
|
|
for match in re.finditer(r'^([A-Za-z_]+):\s*([^\n]*)$',block,re.M):
|
|
key,value=match.groups()
|
|
if not value.strip():
|
|
tail=block[match.end():]
|
|
items=re.match(r'\n((?:[ \t]+-[ \t]+[^\n]*\n?)+)',tail)
|
|
value='\n'+items.group(1) if items else ''
|
|
try: d[key]=yaml.safe_load(value)
|
|
except yaml.YAMLError: d[key]=value.strip().strip('"')
|
|
if not isinstance(d,dict): raise ValueError('work record must be a YAML mapping')
|
|
return d
|
|
def lst(v):
|
|
if not v: return []
|
|
if isinstance(v,list): return [str(x) for x in v]
|
|
v=str(v)
|
|
v=v.strip()
|
|
if v.startswith('['): return [x.strip().strip('"\'') for x in v[1:-1].split(',') if x.strip()]
|
|
return [v]
|
|
WP={}; T={}
|
|
for r in repos:
|
|
p=r.get('local_path')
|
|
if not p or not os.path.isdir(p): continue
|
|
for f in glob.glob(f'{p}/workplans/*.md')+glob.glob(f'{p}/workplans/archived/*.md'):
|
|
txt=open(f,errors='ignore').read(); m=FM.match(txt)
|
|
if not m: continue
|
|
fm=kv(m.group(1))
|
|
# multi-line depends_on in frontmatter
|
|
dm=re.search(r'^depends_on:\s*\n((?:\s+-\s+.*\n)+)',m.group(1)+'\n',re.M)
|
|
wdeps=[x.strip()[2:].strip().strip('"') for x in dm.group(1).splitlines()] if dm else lst(fm.get('depends_on'))
|
|
wid=fm.get('id')
|
|
if not wid or fm.get('type') not in (None,'workplan'): continue
|
|
WP[wid]={'repo':r['slug'],'file':f,'status':fm.get('status'),'flavor':fm.get('flavor'),'depends_on':wdeps,'title':fm.get('title')}
|
|
for tb in TB.findall(txt):
|
|
d=kv(tb); tid=d.get('id')
|
|
if not tid: continue
|
|
T[tid]={'wp':wid,'repo':r['slug'],'status':d.get('status'),'depends_on':lst(d.get('depends_on')),'needs_human':str(d.get('needs_human','')).lower()=='true','blocked_on':d.get('blocked_on') or None,'wait_until':str(d['wait_until']) if d.get('wait_until') else None,'decision_id':d.get('decision_id') or None,'reason':d.get('blocking_reason') or '', 'hub_id':d.get('state_hub_task_id')}
|
|
json.dump({'WP':WP,'T':T,'parse_warnings':sorted(set(parse_warnings))},open(f'{S}/files.json','w'))
|
|
TW={'finished','archived'}; TT={'done','cancel'}
|
|
def term(x):
|
|
if x in T: return T[x]['status'] in TT
|
|
if x in WP: return WP[x]['status'] in TW
|
|
return None
|
|
openwp={k:v for k,v in WP.items() if v['status'] in ('blocked','active','ready','proposed','backlog')}
|
|
print('files: workplans',len(WP),'open',len(openwp),collections.Counter(v['status'] for v in openwp.values()))
|
|
print('open tasks',collections.Counter(t['status'] for t in T.values() if t['wp'] in openwp))
|
|
today=datetime.date.today()
|
|
print('\n=== A. satisfied waits in open workplans (file view) ===')
|
|
for tid,t in sorted(T.items()):
|
|
if t['status']!='wait' or t['wp'] not in openwp: continue
|
|
tg=t['depends_on']; rs=[]
|
|
if tg and all(term(x) for x in tg): rs.append('deps terminal: '+','.join(tg))
|
|
if t['wait_until'] and datetime.date.fromisoformat(t['wait_until'][:10])<today: rs.append('wait_until passed '+t['wait_until'])
|
|
unres=[x for x in tg if term(x) is None]
|
|
if unres: rs.append('UNRESOLVED '+','.join(unres))
|
|
if rs: print(f"{tid:28} {t['repo']:22} human={t['needs_human']} on={t['blocked_on']} | {'; '.join(rs)}")
|
|
print('\n=== B. blocked workplans without any wait task ===')
|
|
bywp=collections.defaultdict(list)
|
|
for tid,t in T.items(): bywp[t['wp']].append((tid,t['status']))
|
|
for k,v in sorted(openwp.items()):
|
|
if v['status']=='blocked' and not any(s=='wait' for _,s in bywp[k]):
|
|
print(k, v['repo'], v['flavor'], [x for x in bywp[k] if x[1] not in TT])
|
|
print('\n=== B2. non-blocked open workplans whose only open tasks are wait ===')
|
|
for k,v in sorted(openwp.items()):
|
|
op=[s for _,s in bywp[k] if s not in TT]
|
|
if v['status']!='blocked' and op and all(s=='wait' for s in op): print(k,v['repo'],v['status'],len(op))
|
|
print('\n=== B3. open workplans with zero open tasks ===')
|
|
for k,v in sorted(openwp.items()):
|
|
op=[s for _,s in bywp[k] if s not in TT]
|
|
if not op: print(k,v['repo'],v['status'],v['flavor'],len(bywp[k]))
|
|
print('\n=== C. unqualified waits in open non-residual workplans ===')
|
|
for tid,t in sorted(T.items()):
|
|
if t['status']=='wait' and t['wp'] in openwp and openwp[t['wp']]['flavor']!='residual' and not t['depends_on'] and not t['needs_human'] and not t['blocked_on'] and not t['wait_until']:
|
|
print(tid,t['repo'],'|',t['reason'][:90])
|
|
# graph
|
|
dep=collections.defaultdict(list)
|
|
for tid,t in T.items():
|
|
if t['wp'] in openwp and t['status'] not in TT:
|
|
for x in t['depends_on']: dep[tid].append(x)
|
|
for k,v in openwp.items():
|
|
for x in v['depends_on']: dep[k].append(x)
|
|
rev=collections.defaultdict(set)
|
|
for a,bs in dep.items():
|
|
for b in bs: rev[b].add(a)
|
|
def wpof(x): return T[x]['wp'] if x in T else x
|
|
def fanin(x):
|
|
seen=set(); st=[x]; out=set()
|
|
while st:
|
|
n=st.pop()
|
|
nxt=set(rev.get(n,()))
|
|
if n in T: nxt.add(T[n]['wp']) # a task gates its workplan
|
|
if n in WP: nxt|= {tid for tid,t in T.items() if t['wp']==n and t['status'] not in TT and False}
|
|
for p in nxt:
|
|
if p in seen: continue
|
|
seen.add(p); st.append(p)
|
|
w=wpof(p)
|
|
if w!=wpof(x) and w in openwp: out.add(w)
|
|
return out
|
|
print('\n=== E. roots by transitive open-workplan fan-in (file view) ===')
|
|
nodes=set(dep)|{b for bs in dep.values() for b in bs}
|
|
rows=[]
|
|
for n in nodes:
|
|
if term(n): continue
|
|
if any(not term(b) for b in dep.get(n,[])): continue
|
|
f=fanin(n)
|
|
if f: rows.append((len(f),n,sorted(f)))
|
|
rows.sort(key=lambda r:(-r[0],r[1]))
|
|
for n,k,f in rows[:45]:
|
|
t=T.get(k);
|
|
desc=(f"wait h={t['needs_human']} on={t['blocked_on']} dec={t['decision_id']} | {t['reason'][:70]}" if t and t['status']=='wait' else (f"{t['status']}" if t else f"WP {WP[k]['status']} {WP[k]['repo']}" if k in WP else 'UNKNOWN'))
|
|
print(f"{n:3} {k:28} {desc}\n -> {', '.join(f[:12])}{' ...' if len(f)>12 else ''}")
|
|
print('\n=== F. cycles (file view) ===')
|
|
color={}
|
|
def dfs(n,path):
|
|
color[n]=1; path.append(n)
|
|
for d in dep.get(n,[]):
|
|
if term(d): continue
|
|
if color.get(d)==1: print(' -> '.join(path[path.index(d):]+[d]))
|
|
elif d not in color: dfs(d,path)
|
|
path.pop(); color[n]=2
|
|
for n in list(dep):
|
|
if n not in color and not term(n): dfs(n,[])
|
|
print('\n=== G. human gates by fan-in ===')
|
|
rows=[]
|
|
for tid,t in T.items():
|
|
if t['status']=='wait' and t['wp'] in openwp and t['needs_human']: rows.append((len(fanin(tid)),tid,t))
|
|
rows.sort(key=lambda r:-r[0])
|
|
for n,tid,t in rows: print(f"{n:3} {tid:28} {t['repo']:22} dec={t['decision_id']} | {t['reason'][:110]}")
|
|
print(len(rows),'human gates')
|