refactor: rewrite parse-deps.py as pure bash, remove only Python from repo
Replace lib/parse-deps.py with lib/parse-deps.sh to keep the toolchain all-bash. Rewrite supervisor P3b cycle detection and P3c stale dep check as pure bash using associative arrays and DFS. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6cf580c010
commit
98f0c40106
4 changed files with 108 additions and 153 deletions
|
|
@ -115,8 +115,8 @@ dep_is_merged() {
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
get_deps() {
|
get_deps() {
|
||||||
local issue_body="$1"
|
local issue_body="$1"
|
||||||
# Shared parser: lib/parse-deps.py (single source of truth)
|
# Shared parser: lib/parse-deps.sh (single source of truth)
|
||||||
echo "$issue_body" | python3 "${FACTORY_ROOT}/lib/parse-deps.py"
|
echo "$issue_body" | bash "${FACTORY_ROOT}/lib/parse-deps.sh"
|
||||||
}
|
}
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Extract dependency issue numbers from an issue body.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
echo "$ISSUE_BODY" | python3 lib/parse-deps.py
|
|
||||||
python3 lib/parse-deps.py "$ISSUE_BODY"
|
|
||||||
python3 lib/parse-deps.py --json < issues.json
|
|
||||||
|
|
||||||
Modes:
|
|
||||||
stdin/arg: reads a single issue body, prints one dep number per line
|
|
||||||
--json: reads a JSON array of issues from stdin, prints JSON
|
|
||||||
dep graph: {"issue_num": [dep1, dep2], ...}
|
|
||||||
|
|
||||||
Matches the same logic as dev-poll.sh get_deps():
|
|
||||||
- Sections: ## Dependencies / ## Depends on / ## Blocked by
|
|
||||||
- Inline: "depends on #NNN" / "blocked by #NNN" anywhere
|
|
||||||
- Ignores: ## Related (safe for sibling cross-references)
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
|
|
||||||
|
|
||||||
def parse_deps(body):
|
|
||||||
"""Return sorted list of unique dependency issue numbers from an issue body."""
|
|
||||||
deps = set()
|
|
||||||
in_section = False
|
|
||||||
for line in (body or "").split("\n"):
|
|
||||||
if re.match(r"^##?\s*(Depends on|Blocked by|Dependencies)", line, re.IGNORECASE):
|
|
||||||
in_section = True
|
|
||||||
continue
|
|
||||||
if in_section and re.match(r"^##?\s", line):
|
|
||||||
in_section = False
|
|
||||||
if in_section:
|
|
||||||
deps.update(int(m) for m in re.findall(r"#(\d+)", line))
|
|
||||||
if re.search(r"(depends on|blocked by)", line, re.IGNORECASE):
|
|
||||||
deps.update(int(m) for m in re.findall(r"#(\d+)", line))
|
|
||||||
return sorted(deps)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
if len(sys.argv) > 1 and sys.argv[1] == "--json":
|
|
||||||
issues = json.load(sys.stdin)
|
|
||||||
graph = {}
|
|
||||||
for issue in issues:
|
|
||||||
num = issue["number"]
|
|
||||||
deps = parse_deps(issue.get("body", ""))
|
|
||||||
deps = [d for d in deps if d != num]
|
|
||||||
if deps:
|
|
||||||
graph[num] = deps
|
|
||||||
json.dump(graph, sys.stdout)
|
|
||||||
print()
|
|
||||||
else:
|
|
||||||
if len(sys.argv) > 1:
|
|
||||||
body = sys.argv[1]
|
|
||||||
else:
|
|
||||||
body = sys.stdin.read()
|
|
||||||
for dep in parse_deps(body):
|
|
||||||
print(dep)
|
|
||||||
27
lib/parse-deps.sh
Executable file
27
lib/parse-deps.sh
Executable file
|
|
@ -0,0 +1,27 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# parse-deps.sh — Extract dependency issue numbers from an issue body
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# echo "$ISSUE_BODY" | bash lib/parse-deps.sh
|
||||||
|
#
|
||||||
|
# Output: one dep number per line, sorted and deduplicated
|
||||||
|
#
|
||||||
|
# Matches:
|
||||||
|
# - Sections: ## Dependencies / ## Depends on / ## Blocked by
|
||||||
|
# - Inline: "depends on #NNN" / "blocked by #NNN" anywhere
|
||||||
|
# - Ignores: ## Related (safe for sibling cross-references)
|
||||||
|
|
||||||
|
BODY=$(cat)
|
||||||
|
|
||||||
|
{
|
||||||
|
# Extract #NNN from dependency sections
|
||||||
|
echo "$BODY" | awk '
|
||||||
|
BEGIN { IGNORECASE=1 }
|
||||||
|
/^##? *(Depends on|Blocked by|Dependencies)/ { capture=1; next }
|
||||||
|
capture && /^##? / { capture=0 }
|
||||||
|
capture { print }
|
||||||
|
' | grep -oP '#\K[0-9]+' || true
|
||||||
|
|
||||||
|
# Also check inline deps on same line as keyword
|
||||||
|
echo "$BODY" | grep -iE '(depends on|blocked by)' | grep -oP '#\K[0-9]+' || true
|
||||||
|
} | sort -un
|
||||||
|
|
@ -297,57 +297,61 @@ status "P3: checking for circular dependencies"
|
||||||
BACKLOG_FOR_DEPS=$(codeberg_api GET "/issues?state=open&labels=backlog&type=issues&limit=50" 2>/dev/null || true)
|
BACKLOG_FOR_DEPS=$(codeberg_api GET "/issues?state=open&labels=backlog&type=issues&limit=50" 2>/dev/null || true)
|
||||||
if [ -n "$BACKLOG_FOR_DEPS" ] && [ "$BACKLOG_FOR_DEPS" != "null" ] && [ "$(echo "$BACKLOG_FOR_DEPS" | jq 'length' 2>/dev/null || echo 0)" -gt 0 ]; then
|
if [ -n "$BACKLOG_FOR_DEPS" ] && [ "$BACKLOG_FOR_DEPS" != "null" ] && [ "$(echo "$BACKLOG_FOR_DEPS" | jq 'length' 2>/dev/null || echo 0)" -gt 0 ]; then
|
||||||
|
|
||||||
PARSE_DEPS="${FACTORY_ROOT}/lib/parse-deps.py"
|
PARSE_DEPS="${FACTORY_ROOT}/lib/parse-deps.sh"
|
||||||
|
ISSUE_COUNT=$(echo "$BACKLOG_FOR_DEPS" | jq 'length')
|
||||||
|
|
||||||
CYCLES=$(echo "$BACKLOG_FOR_DEPS" | python3 -c '
|
# Build dep graph: DEPS_OF[issue_num]="dep1 dep2 ..."
|
||||||
import sys, json, importlib.util
|
declare -A DEPS_OF
|
||||||
|
declare -A BACKLOG_NUMS
|
||||||
|
for i in $(seq 0 $((ISSUE_COUNT - 1))); do
|
||||||
|
NUM=$(echo "$BACKLOG_FOR_DEPS" | jq -r ".[$i].number")
|
||||||
|
BODY=$(echo "$BACKLOG_FOR_DEPS" | jq -r ".[$i].body // \"\"")
|
||||||
|
ISSUE_DEPS=$(echo "$BODY" | bash "$PARSE_DEPS" | grep -v "^${NUM}$" || true)
|
||||||
|
[ -n "$ISSUE_DEPS" ] && DEPS_OF[$NUM]="$ISSUE_DEPS"
|
||||||
|
BACKLOG_NUMS[$NUM]=1
|
||||||
|
done
|
||||||
|
|
||||||
spec = importlib.util.spec_from_file_location("parse_deps", sys.argv[1])
|
# DFS cycle detection using color marking (0=white, 1=gray, 2=black)
|
||||||
mod = importlib.util.module_from_spec(spec)
|
declare -A NODE_COLOR
|
||||||
spec.loader.exec_module(mod)
|
for node in "${!DEPS_OF[@]}"; do NODE_COLOR[$node]=0; done
|
||||||
|
|
||||||
issues = json.load(sys.stdin)
|
FOUND_CYCLES=""
|
||||||
graph = {}
|
declare -A SEEN_CYCLES
|
||||||
for issue in issues:
|
|
||||||
num = issue["number"]
|
|
||||||
deps = [d for d in mod.parse_deps(issue.get("body", "")) if d != num]
|
|
||||||
if deps:
|
|
||||||
graph[num] = set(deps)
|
|
||||||
|
|
||||||
WHITE, GRAY, BLACK = 0, 1, 2
|
dfs_detect_cycle() {
|
||||||
color = {n: WHITE for n in graph}
|
local node="$1" path="$2"
|
||||||
cycles = []
|
NODE_COLOR[$node]=1
|
||||||
|
for dep in ${DEPS_OF[$node]:-}; do
|
||||||
|
[ -z "${NODE_COLOR[$dep]+x}" ] && continue # not in graph
|
||||||
|
if [ "${NODE_COLOR[$dep]}" = "1" ]; then
|
||||||
|
# Cycle found — normalize for dedup
|
||||||
|
local cycle_key=$(echo "$path $dep" | tr ' ' '\n' | sort -n | tr '\n' ' ')
|
||||||
|
if [ -z "${SEEN_CYCLES[$cycle_key]+x}" ]; then
|
||||||
|
SEEN_CYCLES[$cycle_key]=1
|
||||||
|
# Extract cycle portion from path (from $dep onward)
|
||||||
|
local in_cycle=0 cycle_str=""
|
||||||
|
for p in $path $dep; do
|
||||||
|
[ "$p" = "$dep" ] && in_cycle=1
|
||||||
|
[ "$in_cycle" = "1" ] && cycle_str="${cycle_str:+$cycle_str -> }#${p}"
|
||||||
|
done
|
||||||
|
FOUND_CYCLES="${FOUND_CYCLES}${cycle_str}\n"
|
||||||
|
fi
|
||||||
|
elif [ "${NODE_COLOR[$dep]}" = "0" ]; then
|
||||||
|
dfs_detect_cycle "$dep" "$path $dep"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
NODE_COLOR[$node]=2
|
||||||
|
}
|
||||||
|
|
||||||
def dfs(u, path):
|
for node in "${!DEPS_OF[@]}"; do
|
||||||
color[u] = GRAY
|
[ "${NODE_COLOR[$node]:-2}" = "0" ] && dfs_detect_cycle "$node" "$node"
|
||||||
path.append(u)
|
done
|
||||||
for v in graph.get(u, set()):
|
|
||||||
if v not in color:
|
|
||||||
continue
|
|
||||||
if color[v] == GRAY:
|
|
||||||
cycles.append(path[path.index(v):] + [v])
|
|
||||||
elif color[v] == WHITE:
|
|
||||||
dfs(v, path)
|
|
||||||
path.pop()
|
|
||||||
color[u] = BLACK
|
|
||||||
|
|
||||||
for node in list(graph.keys()):
|
if [ -n "$FOUND_CYCLES" ]; then
|
||||||
if color.get(node) == WHITE:
|
echo -e "$FOUND_CYCLES" | while IFS= read -r cycle; do
|
||||||
dfs(node, [])
|
|
||||||
|
|
||||||
seen = set()
|
|
||||||
for cycle in cycles:
|
|
||||||
key = tuple(sorted(set(cycle)))
|
|
||||||
if key not in seen:
|
|
||||||
seen.add(key)
|
|
||||||
print(" -> ".join(f"#{n}" for n in cycle))
|
|
||||||
' "$PARSE_DEPS" 2>/dev/null || true)
|
|
||||||
|
|
||||||
if [ -n "$CYCLES" ]; then
|
|
||||||
while IFS= read -r cycle; do
|
|
||||||
[ -z "$cycle" ] && continue
|
[ -z "$cycle" ] && continue
|
||||||
p3 "Circular dependency deadlock: ${cycle}"
|
p3 "Circular dependency deadlock: ${cycle}"
|
||||||
done <<< "$CYCLES"
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
@ -355,59 +359,42 @@ for cycle in cycles:
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
status "P3: checking for stale dependencies"
|
status "P3: checking for stale dependencies"
|
||||||
|
|
||||||
STALE_DEPS=$(echo "$BACKLOG_FOR_DEPS" | CODEBERG_TOKEN="$CODEBERG_TOKEN" CODEBERG_API="$CODEBERG_API" python3 -c '
|
NOW_EPOCH=$(date +%s)
|
||||||
import sys, json, os, importlib.util
|
THIRTY_DAYS=$((30 * 86400))
|
||||||
from datetime import datetime, timezone
|
declare -A DEP_CACHE
|
||||||
from urllib.request import Request, urlopen
|
|
||||||
|
|
||||||
spec = importlib.util.spec_from_file_location("parse_deps", sys.argv[1])
|
for issue_num in "${!DEPS_OF[@]}"; do
|
||||||
mod = importlib.util.module_from_spec(spec)
|
for dep in ${DEPS_OF[$issue_num]}; do
|
||||||
spec.loader.exec_module(mod)
|
# Check cache first
|
||||||
|
if [ -n "${DEP_CACHE[$dep]+x}" ]; then
|
||||||
issues = json.load(sys.stdin)
|
DEP_INFO="${DEP_CACHE[$dep]}"
|
||||||
token = os.environ.get("CODEBERG_TOKEN", "")
|
else
|
||||||
api = os.environ.get("CODEBERG_API", "")
|
DEP_JSON=$(codeberg_api GET "/issues/${dep}" 2>/dev/null || true)
|
||||||
issue_map = {i["number"]: i for i in issues}
|
[ -z "$DEP_JSON" ] && continue
|
||||||
now = datetime.now(timezone.utc)
|
DEP_STATE=$(echo "$DEP_JSON" | jq -r '.state // "unknown"')
|
||||||
|
DEP_CREATED=$(echo "$DEP_JSON" | jq -r '.created_at // ""')
|
||||||
checked = {}
|
DEP_TITLE=$(echo "$DEP_JSON" | jq -r '.title // ""' | head -c 50)
|
||||||
for issue in issues:
|
DEP_INFO="${DEP_STATE}|${DEP_CREATED}|${DEP_TITLE}"
|
||||||
num = issue["number"]
|
DEP_CACHE[$dep]="$DEP_INFO"
|
||||||
deps = [d for d in mod.parse_deps(issue.get("body", "")) if d != num]
|
|
||||||
for dep in deps:
|
|
||||||
if dep in checked:
|
|
||||||
dep_data = checked[dep]
|
|
||||||
elif dep in issue_map:
|
|
||||||
dep_data = issue_map[dep]
|
|
||||||
checked[dep] = dep_data
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
req = Request(f"{api}/issues/{dep}",
|
|
||||||
headers={"Authorization": f"token {token}"})
|
|
||||||
with urlopen(req, timeout=5) as resp:
|
|
||||||
dep_data = json.loads(resp.read())
|
|
||||||
checked[dep] = dep_data
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
if dep_data.get("state") != "open":
|
|
||||||
continue
|
|
||||||
created = dep_data.get("created_at", "")
|
|
||||||
try:
|
|
||||||
created_dt = datetime.fromisoformat(created.replace("Z", "+00:00"))
|
|
||||||
age_days = (now - created_dt).days
|
|
||||||
if age_days > 30:
|
|
||||||
dep_title = dep_data.get("title", "")[:50]
|
|
||||||
print(f"#{num} blocked by #{dep} \"{dep_title}\" (open {age_days} days)")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
' "$PARSE_DEPS" 2>/dev/null || true)
|
|
||||||
|
|
||||||
if [ -n "$STALE_DEPS" ]; then
|
|
||||||
while IFS= read -r stale; do
|
|
||||||
[ -z "$stale" ] && continue
|
|
||||||
p3 "Stale dependency: ${stale}"
|
|
||||||
done <<< "$STALE_DEPS"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
DEP_STATE="${DEP_INFO%%|*}"
|
||||||
|
[ "$DEP_STATE" != "open" ] && continue
|
||||||
|
|
||||||
|
DEP_REST="${DEP_INFO#*|}"
|
||||||
|
DEP_CREATED="${DEP_REST%%|*}"
|
||||||
|
DEP_TITLE="${DEP_REST#*|}"
|
||||||
|
|
||||||
|
[ -z "$DEP_CREATED" ] && continue
|
||||||
|
CREATED_EPOCH=$(date -d "$DEP_CREATED" +%s 2>/dev/null || echo 0)
|
||||||
|
AGE_DAYS=$(( (NOW_EPOCH - CREATED_EPOCH) / 86400 ))
|
||||||
|
if [ "$AGE_DAYS" -gt 30 ]; then
|
||||||
|
p3 "Stale dependency: #${issue_num} blocked by #${dep} \"${DEP_TITLE}\" (open ${AGE_DAYS} days)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
unset DEPS_OF BACKLOG_NUMS NODE_COLOR SEEN_CYCLES DEP_CACHE
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue