Browse Source

feat(github-ops): Surface unseen open issues, advisory at push-time

Closes the blind spot where externally-filed issues go unnoticed (e.g.
flarecrawl #2/#3 sat ~2 months unseen across many pushes).

- scripts/check-issues.sh — read-only 'gh issue list' that flags issues
  authored by someone other than the repo owner (external) and issues
  untouched for N days (stale). --json envelope, semantic exits
  (0 nothing-missed / 10 issues-to-see / 7 unavailable / 2 usage / 5 no-gh).
- Wired into push-gate/preflight.sh as a post-gate ADVISORY step: every
  push to a GitHub remote surfaces unseen external/stale issues. Read-only,
  timeout-bounded, silent when gh absent/unauthed or remote isn't GitHub,
  and provably never changes the gate verdict (|| issue_rc=$? under set -e).
- github-ops gains a 6-assertion offline test suite (contract + graceful
  skip paths). Suites 8 -> 9.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0xDarkMatter 3 weeks ago
parent
commit
089550521d

+ 8 - 0
CHANGELOG.md

@@ -8,6 +8,14 @@ feature releases live in the README "Recent Updates" section.
 ## [Unreleased]
 
 ### Added
+- **github-ops open-issue awareness** - `scripts/check-issues.sh` surfaces open
+  issues you may not have seen (externally-authored + stale), read-only via
+  `gh issue list`. Wired into the pre-push gate (`push-gate/preflight.sh`) as a
+  post-gate **advisory** step: every push to a GitHub remote now flags unseen
+  external/stale issues for that repo. Timeout-bounded, never changes the gate
+  verdict, silent when gh is absent/unauthed or the remote isn't GitHub. Exit 10
+  = issues to look at, 7 = unavailable (advisory). github-ops gains a 6-assertion
+  offline test suite.
 - **`okf-ops` skill** - assess, validate, and adopt the Open Knowledge Format
   (OKF) across markdown+frontmatter knowledge bases. `assess-okf.py` (read-only)
   scans a doc tree for OKF-readiness — frontmatter coverage, `type` presence, a

+ 13 - 3
skills/github-ops/SKILL.md

@@ -330,9 +330,19 @@ When adding any of the above, keep the boundary discipline: anything talking to
 | `references/metadata-checklist.md` | Audit checklist source of truth |
 | `references/issue-ops.md` | Issue operation playbooks (view/triage/comment/create/close) + preview templates |
 | `references/pr-ops.md` | PR operation playbooks (create/review/merge) + pre-merge gate + merge-strategy decision tree |
-| `scripts/` | (empty; reserved — extract patterns into scripts when they repeat across uses) |
+| `scripts/check-issues.sh` | Surface open issues you may not have seen (externally-authored + stale) for a repo or remote. Read-only `gh issue list`; flags author≠owner and untouched-for-N-days |
 | `assets/` | (empty; reserved for README templates / snippets) |
 
-## Why no scripts yet
+## Open-issue awareness (the blind spot)
 
-Initial implementation uses inline `gh`, `jq`, `git -C` calls rather than wrapping them in scripts. Once usage reveals patterns that repeat verbatim across invocations (CHANGELOG extraction is the most likely candidate), extract those into `scripts/` and reference them here. Premature script extraction obscures what the skill is actually doing.
+You don't see issues other people file — your own you know about; a stranger's bug report from two months ago is the gap. `scripts/check-issues.sh` closes it:
+
+```bash
+bash scripts/check-issues.sh --repo 0xDarkMatter/flarecrawl   # one repo
+bash scripts/check-issues.sh --remote origin --stale-days 14  # derive from a remote
+bash scripts/check-issues.sh --json | jq '.data[] | select(.external)'
+```
+
+Exit `0` = nothing you're missing (no open issues, or all are yours and fresh); `10` = external/stale issues present (the things to look at); `7` = unavailable (not a GitHub remote, gh unauthed/offline) — advisory, never a hard failure; `2` usage; `5` gh not installed.
+
+**Wired into the pre-push gate** ([push-gate](../push-gate/)): `preflight.sh` calls this in `--advisory` mode as a post-gate step, so every push surfaces unseen external/stale issues for the target remote. It is **read-only, timeout-bounded, and never affects the gate verdict** — silent when gh is absent/unauthed or the remote isn't GitHub. Run it standalone any time, or across repos, to find what you've missed. For acting on what it surfaces (view/triage/comment/close), see `references/issue-ops.md`.

+ 109 - 0
skills/github-ops/scripts/check-issues.sh

@@ -0,0 +1,109 @@
+#!/usr/bin/env bash
+# Surface open GitHub issues you may not have seen — externally-authored and stale.
+#
+# Read-only (gh issue list). Built to flag the blind spot: issues filed by someone
+# other than the repo owner, and issues left untouched for a while. Designed to run
+# advisory at push-time without ever gating the push.
+#
+# Usage:   check-issues.sh [--repo OWNER/REPO | --remote NAME] [--stale-days N]
+#                          [--limit N] [--advisory] [--json] [-h|--help]
+# Input:   argv only. Default repo = derived from the 'origin' remote of the cwd.
+# Output:  stdout = data (human summary, or --json envelope). Framing on stderr.
+# Stderr:  headers, the advisory banner, skip notices, errors.
+# Exit:    0 nothing you're missing (no open issues, or all are yours and fresh)
+#          2 usage
+#          5 gh not installed (standalone mode; --advisory downgrades this to a skip)
+#          7 unavailable — not a GitHub remote, gh not authed, offline, rate-limited,
+#            or the lookup timed out (ADVISORY signal; never a real failure)
+#          10 open external and/or stale issues present (the thing to look at)
+#
+# Examples:
+#   check-issues.sh                                  # origin of the cwd
+#   check-issues.sh --repo 0xDarkMatter/flarecrawl
+#   check-issues.sh --remote origin --stale-days 14
+#   check-issues.sh --json | jq '.data[] | select(.external)'
+#   check-issues.sh --advisory --remote origin       # compact, silent when clean
+set -uo pipefail
+
+EX_OK=0; EX_USAGE=2; EX_MISSING_DEP=5; EX_UNAVAILABLE=7; EX_FINDINGS=10
+GH_TIMEOUT="${GH_TIMEOUT:-15}"   # seconds; bounds the network call
+
+REPO=""; REMOTE="origin"; STALE_DAYS=30; LIMIT=50; ADVISORY=0; JSON=0
+while [ $# -gt 0 ]; do
+  case "$1" in
+    --repo)       REPO="${2:?--repo needs OWNER/REPO}"; shift 2 ;;
+    --remote)     REMOTE="${2:?--remote needs a name}"; shift 2 ;;
+    --stale-days) STALE_DAYS="${2:?--stale-days needs N}"; shift 2 ;;
+    --limit)      LIMIT="${2:?--limit needs N}"; shift 2 ;;
+    --advisory)   ADVISORY=1; shift ;;
+    --json)       JSON=1; shift ;;
+    -h|--help)    sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit "$EX_OK" ;;
+    *) echo "check-issues: unknown argument: $1" >&2; exit "$EX_USAGE" ;;
+  esac
+done
+
+# In advisory mode, ANY inability to check is a silent skip (never disturb a push).
+skip() { # message
+  [ "$ADVISORY" -eq 1 ] || echo "check-issues: $1" >&2
+  exit "$EX_UNAVAILABLE"
+}
+
+command -v gh >/dev/null 2>&1 || {
+  [ "$ADVISORY" -eq 1 ] && exit "$EX_UNAVAILABLE"
+  echo "check-issues: gh not installed (https://cli.github.com)" >&2
+  exit "$EX_MISSING_DEP"
+}
+
+# Resolve OWNER/REPO from the remote if not given explicitly.
+if [ -z "$REPO" ]; then
+  url="$(git remote get-url "$REMOTE" 2>/dev/null)" || skip "no '$REMOTE' remote here"
+  case "$url" in
+    *github.com[:/]*)
+      # strip everything up to github.com<sep>, then a trailing .git and/or slash
+      REPO="$(printf '%s' "$url" | sed -E 's#^.*github\.com[:/]+##; s#\.git$##; s#/$##')" ;;
+    *) skip "remote '$REMOTE' is not a github.com repo" ;;
+  esac
+fi
+OWNER="${REPO%%/*}"
+
+# Bounded, read-only lookup. Any failure (auth/offline/rate-limit/timeout) -> skip/7.
+runner() { if command -v timeout >/dev/null 2>&1; then timeout "$GH_TIMEOUT" "$@"; else "$@"; fi; }
+raw="$(runner gh issue list --repo "$REPO" --state open --limit "$LIMIT" \
+        --json number,title,author,createdAt,updatedAt,labels 2>/dev/null)" \
+  || skip "gh issue list failed for $REPO (not authed / offline / rate-limited?)"
+[ -n "$raw" ] || skip "empty response from gh for $REPO"
+
+# Classify with jq: external = author.login != owner; stale = updatedAt older than N days.
+command -v jq >/dev/null 2>&1 || skip "jq not installed"
+analysis="$(printf '%s' "$raw" | jq -c --arg owner "$OWNER" --argjson stale "$STALE_DAYS" '
+  (now - ($stale * 86400)) as $cutoff
+  | map(. + {
+      external: (.author.login != $owner),
+      stale: ((.updatedAt | sub("\\.[0-9]+";"") | strptime("%Y-%m-%dT%H:%M:%SZ") | mktime) < $cutoff)
+    })
+  | { total: length,
+      flagged: map(select(.external or .stale)),
+    }' 2>/dev/null)" || skip "could not parse gh output"
+
+total="$(printf '%s' "$analysis" | jq -r '.total')"
+flagged_n="$(printf '%s' "$analysis" | jq -r '.flagged | length')"
+
+if [ "$JSON" -eq 1 ]; then
+  printf '%s' "$analysis" | jq -c --arg repo "$REPO" \
+    '{data: .flagged, meta: {repo: $repo, total_open: .total, flagged: (.flagged|length), schema: "claude-mods.github-ops.check-issues/v1"}}'
+fi
+
+# Human / advisory output (stderr framing; the data above is the stdout product).
+if [ "$flagged_n" -eq 0 ]; then
+  [ "$ADVISORY" -eq 1 ] || echo "check-issues: $REPO — $total open, none external or stale." >&2
+  exit "$EX_OK"
+fi
+
+{
+  echo "OPEN ISSUES worth a look — $REPO ($flagged_n of $total open flagged):"
+  printf '%s' "$analysis" | jq -r '.flagged[]
+    | "  #\(.number)  [\(if .external then "external" else "yours" end)\(if .stale then ",stale" else "" end)]  by \(.author.login)  \(.title)"'
+  echo "  → gh issue view <n> --repo $REPO    (read-only; this never blocks a push)"
+} >&2
+
+exit "$EX_FINDINGS"

+ 39 - 0
skills/github-ops/tests/run.sh

@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+# Offline self-test for github-ops scripts. No network required — exercises the
+# contract + the gate-safety skip paths (graceful exit 7), not live GitHub data.
+set -uo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+SCRIPTS="$(cd "$HERE/.." && pwd)/scripts"
+CI="$SCRIPTS/check-issues.sh"
+
+pass=0; fail=0
+ok() { echo "  PASS  $1"; pass=$((pass+1)); }
+no() { echo "  FAIL  $1"; fail=$((fail+1)); }
+expect() { if [ "$2" = "$3" ]; then ok "$1 (exit $3)"; else no "$1 (want $2 got $3)"; fi; }
+
+echo "-- check-issues.sh (offline contract + skip paths) --"
+
+bash -n "$CI" && ok "bash -n clean" || no "bash -n"
+
+bash "$CI" --help >/dev/null 2>&1; expect "--help" 0 $?
+bash "$CI" --frobnicate >/dev/null 2>&1; expect "unknown flag -> usage" 2 $?
+
+# Non-github remote must skip with exit 7 and NEVER hit the network.
+T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT
+git -C "$T" init -q
+git -C "$T" remote add origin "/some/local/path.git"
+( cd "$T" && bash "$CI" --remote origin >/dev/null 2>&1 ); expect "non-github remote -> unavailable" 7 $?
+
+# Advisory mode on a non-github remote must be SILENT (no stderr) and exit 7 —
+# this is the gate-safety contract: an unusable check never disturbs a push.
+out="$( cd "$T" && bash "$CI" --advisory --remote origin 2>&1 )"; rc=$?
+if [ "$rc" -eq 7 ] && [ -z "$out" ]; then ok "advisory non-github -> silent exit 7"
+else no "advisory non-github (rc=$rc, stderr='$out')"; fi
+
+# Missing remote -> skip 7 (git remote get-url fails; no network).
+( cd "$T" && bash "$CI" --remote nope-xyz >/dev/null 2>&1 ); expect "missing remote -> unavailable" 7 $?
+
+echo
+echo "=== $pass passed, $fail failed ==="
+[ "$fail" -eq 0 ]

+ 1 - 0
skills/push-gate/SKILL.md

@@ -34,6 +34,7 @@ Step 5  →  Check divergence (non-ff ⇒ require user to rebase first)
 Step 6  →  Secret scan  ────────┐
 Step 7  →  Forbidden-file scan  │ refuse on any hit
 Step 8  →  Size advisory        │
+        →  Open-issue advisory (github-ops; informational, never gates)
 Step 9  →  Explicit confirm     │
 Step 10 →  git push <remote> <branch>
 Step 11 →  Post-push verify (ls-remote matches pushed SHA)

+ 19 - 0
skills/push-gate/scripts/preflight.sh

@@ -10,6 +10,10 @@
 #   4  non-ff divergence
 #   5  missing dep (gitleaks / rg)
 #   6  bad invocation (missing remote/branch or unknown remote)
+#
+# After all gates pass, an advisory open-issue check (github-ops/check-issues.sh)
+# surfaces unseen external/stale issues for the target remote. It is read-only,
+# timeout-bounded, and NEVER changes the exit code — purely informational.
 
 set -euo pipefail
 
@@ -148,6 +152,21 @@ else
   echo "STEP 8  OK    ${COMMIT_COUNT} commits"
 fi
 
+# ── Step 9: open-issue advisory (informational; does NOT gate) ────────────────
+# Surface externally-authored / stale issues you may not have seen, for the remote
+# you're pushing to. Read-only, timeout-bounded, silent when gh is absent/unauthed
+# or the remote isn't GitHub. Its exit never affects the gate verdict.
+ISSUE_CHECK="$SCRIPT_DIR/../../github-ops/scripts/check-issues.sh"
+if [ -f "$ISSUE_CHECK" ]; then
+  issue_rc=0
+  bash "$ISSUE_CHECK" --advisory --remote "$REMOTE" || issue_rc=$?
+  if [ "$issue_rc" -eq 10 ]; then
+    echo "ISSUES  NOTE  open issues flagged above (advisory — does not block the push)"
+  else
+    echo "ISSUES  OK    no unseen open issues (or check unavailable)"
+  fi
+fi
+
 divider
 echo "push-gate: ALL GATES PASSED"
 echo ""