diff --git a/dev/dev-poll.sh b/dev/dev-poll.sh index 91a39c2..d08e08d 100755 --- a/dev/dev-poll.sh +++ b/dev/dev-poll.sh @@ -115,8 +115,8 @@ dep_is_merged() { # ============================================================================= get_deps() { local issue_body="$1" - # Shared parser: lib/parse-deps.py (single source of truth) - echo "$issue_body" | python3 "${FACTORY_ROOT}/lib/parse-deps.py" + # Shared parser: lib/parse-deps.sh (single source of truth) + echo "$issue_body" | bash "${FACTORY_ROOT}/lib/parse-deps.sh" } # ============================================================================= diff --git a/lib/parse-deps.py b/lib/parse-deps.py deleted file mode 100755 index 3214688..0000000 --- a/lib/parse-deps.py +++ /dev/null @@ -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) diff --git a/lib/parse-deps.sh b/lib/parse-deps.sh new file mode 100755 index 0000000..c0c58b4 --- /dev/null +++ b/lib/parse-deps.sh @@ -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 diff --git a/supervisor/supervisor-poll.sh b/supervisor/supervisor-poll.sh index 8c8f99b..b1d44df 100755 --- a/supervisor/supervisor-poll.sh +++ b/supervisor/supervisor-poll.sh @@ -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) 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 ' -import sys, json, importlib.util + # Build dep graph: DEPS_OF[issue_num]="dep1 dep2 ..." + 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]) -mod = importlib.util.module_from_spec(spec) -spec.loader.exec_module(mod) + # DFS cycle detection using color marking (0=white, 1=gray, 2=black) + declare -A NODE_COLOR + for node in "${!DEPS_OF[@]}"; do NODE_COLOR[$node]=0; done -issues = json.load(sys.stdin) -graph = {} -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) + FOUND_CYCLES="" + declare -A SEEN_CYCLES -WHITE, GRAY, BLACK = 0, 1, 2 -color = {n: WHITE for n in graph} -cycles = [] + dfs_detect_cycle() { + local node="$1" path="$2" + 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): - color[u] = GRAY - path.append(u) - 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 "${!DEPS_OF[@]}"; do + [ "${NODE_COLOR[$node]:-2}" = "0" ] && dfs_detect_cycle "$node" "$node" + done -for node in list(graph.keys()): - if color.get(node) == WHITE: - 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 + if [ -n "$FOUND_CYCLES" ]; then + echo -e "$FOUND_CYCLES" | while IFS= read -r cycle; do [ -z "$cycle" ] && continue p3 "Circular dependency deadlock: ${cycle}" - done <<< "$CYCLES" + done fi # =========================================================================== @@ -355,59 +359,42 @@ for cycle in cycles: # =========================================================================== status "P3: checking for stale dependencies" - STALE_DEPS=$(echo "$BACKLOG_FOR_DEPS" | CODEBERG_TOKEN="$CODEBERG_TOKEN" CODEBERG_API="$CODEBERG_API" python3 -c ' -import sys, json, os, importlib.util -from datetime import datetime, timezone -from urllib.request import Request, urlopen + NOW_EPOCH=$(date +%s) + THIRTY_DAYS=$((30 * 86400)) + declare -A DEP_CACHE -spec = importlib.util.spec_from_file_location("parse_deps", sys.argv[1]) -mod = importlib.util.module_from_spec(spec) -spec.loader.exec_module(mod) + for issue_num in "${!DEPS_OF[@]}"; do + for dep in ${DEPS_OF[$issue_num]}; do + # Check cache first + if [ -n "${DEP_CACHE[$dep]+x}" ]; then + DEP_INFO="${DEP_CACHE[$dep]}" + else + DEP_JSON=$(codeberg_api GET "/issues/${dep}" 2>/dev/null || true) + [ -z "$DEP_JSON" ] && continue + DEP_STATE=$(echo "$DEP_JSON" | jq -r '.state // "unknown"') + DEP_CREATED=$(echo "$DEP_JSON" | jq -r '.created_at // ""') + DEP_TITLE=$(echo "$DEP_JSON" | jq -r '.title // ""' | head -c 50) + DEP_INFO="${DEP_STATE}|${DEP_CREATED}|${DEP_TITLE}" + DEP_CACHE[$dep]="$DEP_INFO" + fi -issues = json.load(sys.stdin) -token = os.environ.get("CODEBERG_TOKEN", "") -api = os.environ.get("CODEBERG_API", "") -issue_map = {i["number"]: i for i in issues} -now = datetime.now(timezone.utc) + DEP_STATE="${DEP_INFO%%|*}" + [ "$DEP_STATE" != "open" ] && continue -checked = {} -for issue in issues: - num = issue["number"] - 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) + DEP_REST="${DEP_INFO#*|}" + DEP_CREATED="${DEP_REST%%|*}" + DEP_TITLE="${DEP_REST#*|}" - if [ -n "$STALE_DEPS" ]; then - while IFS= read -r stale; do - [ -z "$stale" ] && continue - p3 "Stale dependency: ${stale}" - done <<< "$STALE_DEPS" - fi + [ -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 # =============================================================================