housebroken-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
housebroken/cli.py ADDED
@@ -0,0 +1,92 @@
1
+ """Console entry point: run the packaged bash dispatcher.
2
+
3
+ The gate scripts are bash and stay bash. This module only finds a bash, points
4
+ HOUSEBROKEN_SCRIPTS at the copy of the scripts inside the installed package,
5
+ and hands every argument to bin/housebroken unchanged. Three subcommands are
6
+ answered in Python because they are about the package, not about a gate:
7
+ version, skill-path and install-skill.
8
+ """
9
+
10
+ import contextlib
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ from importlib import metadata, resources
16
+ from pathlib import Path
17
+
18
+ DISTRIBUTION = "housebroken-cli"
19
+
20
+
21
+ def find_bash():
22
+ bash = shutil.which("bash")
23
+ if bash:
24
+ return bash
25
+ if os.name == "nt":
26
+ git = shutil.which("git")
27
+ if git:
28
+ git_root = Path(git).resolve().parent.parent
29
+ for candidate in (
30
+ git_root / "bin" / "bash.exe",
31
+ git_root / "usr" / "bin" / "bash.exe",
32
+ ):
33
+ if candidate.is_file():
34
+ return str(candidate)
35
+ return None
36
+
37
+
38
+ def package_version():
39
+ try:
40
+ return metadata.version(DISTRIBUTION)
41
+ except metadata.PackageNotFoundError:
42
+ from housebroken import __version__
43
+
44
+ return __version__
45
+
46
+
47
+ def install_skill(skill_md):
48
+ target = Path(os.path.expanduser("~")) / ".claude" / "skills" / "housebroken" / "SKILL.md"
49
+ target.parent.mkdir(parents=True, exist_ok=True)
50
+ shutil.copyfile(skill_md, target)
51
+ print(f"installed {target}")
52
+
53
+
54
+ def main():
55
+ args = sys.argv[1:]
56
+ cmd = args[0] if args else "help"
57
+
58
+ if cmd == "version" or cmd == "--version":
59
+ print(f"housebroken {package_version()}")
60
+ return 0
61
+
62
+ with contextlib.ExitStack() as stack:
63
+ root = stack.enter_context(resources.as_file(resources.files("housebroken")))
64
+
65
+ if cmd == "skill-path":
66
+ print(root / "skill" / "SKILL.md")
67
+ return 0
68
+ if cmd == "install-skill":
69
+ install_skill(root / "skill" / "SKILL.md")
70
+ return 0
71
+
72
+ bash = find_bash()
73
+ if bash is None:
74
+ print(
75
+ "housebroken: no bash found. Install Git Bash on Windows "
76
+ "(https://git-scm.com/download/win) or bash on Unix, and make "
77
+ "sure bash is on PATH.",
78
+ file=sys.stderr,
79
+ )
80
+ return 2
81
+
82
+ scripts = root / "scripts"
83
+ env = dict(os.environ)
84
+ env["HOUSEBROKEN_SCRIPTS"] = str(scripts)
85
+ completed = subprocess.run(
86
+ [bash, str(scripts / "housebroken"), *args], env=env
87
+ )
88
+ return completed.returncode
89
+
90
+
91
+ if __name__ == "__main__":
92
+ sys.exit(main())
@@ -0,0 +1,109 @@
1
+ #!/bin/bash
2
+ # check-ai-policy.sh - independently verify a repo's stance on AI-assisted
3
+ # contributions. Scouts report a policy; this reads it. Never adopt a target on
4
+ # a scout's description of its policy (cold-cohort-2026-08-09 lesson, applied to
5
+ # a new criterion).
6
+ #
7
+ # Checks a branch of the real repo, not a memory of it, and then the
8
+ # organization's .github repository, where an org-wide policy often lives.
9
+ # Prints every hit with its file so the operator reads the sentence, not a
10
+ # verdict. The branch is overridable because some projects keep the policy on a
11
+ # development branch and not on the default branch.
12
+ #
13
+ # Usage:
14
+ # ./check-ai-policy.sh [--branch <name>] owner/repo [owner/repo ...]
15
+ # ./check-ai-policy.sh --help
16
+ #
17
+ # Environment:
18
+ # HOUSEBROKEN_HOME work directory, default $HOME/.housebroken. Not written
19
+ # by this script; it prints to stdout only.
20
+ #
21
+ # Assumes gh is installed and authenticated. Read-only: GET calls only.
22
+ set -u
23
+
24
+ usage() {
25
+ cat <<'EOF'
26
+ usage: check-ai-policy.sh [--branch <name>] owner/repo [owner/repo ...]
27
+ check-ai-policy.sh --help
28
+
29
+ --branch <name> read the policy files from this branch instead of the
30
+ repository default branch; some projects keep the policy
31
+ on a development branch
32
+
33
+ environment:
34
+ HOUSEBROKEN_HOME work directory, default $HOME/.housebroken (unused here)
35
+ EOF
36
+ }
37
+
38
+ FILES="CONTRIBUTING.md CONTRIBUTING.rst CONTRIBUTING .github/CONTRIBUTING.md
39
+ docs/CONTRIBUTING.md docs/contributing.md CODE_OF_CONDUCT.md
40
+ .github/CODE_OF_CONDUCT.md README.md AI.md AI_POLICY.md POLICY.md
41
+ .github/AI_POLICY.md .github/AI.md .github/POLICY.md .github/PULL_REQUEST_TEMPLATE.md .github/pull_request_template.md
42
+ .github/ISSUE_TEMPLATE/config.yml CLAUDE.md AGENTS.md .cursorrules"
43
+
44
+ PATTERN='[Aa][Ii]-generated|[Aa]rtificial [Ii]ntelligence|\bLLM|[Ll]arge [Ll]anguage [Mm]odel|Copilot|ChatGPT|[Cc]laude|[Gg]enerative|AI assistance|AI-assisted|AI tool|AI slop|machine-generated'
45
+
46
+ branch_opt=""
47
+ repos=()
48
+ while [ "$#" -gt 0 ]; do
49
+ case "$1" in
50
+ --help|-h) usage; exit 0 ;;
51
+ --branch) shift; [ "$#" -gt 0 ] || { echo "check-ai-policy.sh: --branch needs a name" >&2; exit 1; }
52
+ branch_opt="$1" ;;
53
+ --branch=*) branch_opt="${1#--branch=}" ;;
54
+ --*) usage >&2; echo "check-ai-policy.sh: unknown option $1" >&2; exit 1 ;;
55
+ */*) repos+=("$1") ;;
56
+ *) usage >&2; echo "check-ai-policy.sh: '$1' is not owner/repo" >&2; exit 1 ;;
57
+ esac
58
+ shift
59
+ done
60
+ [ "${#repos[@]}" -gt 0 ] || { usage >&2; exit 1; }
61
+
62
+ command -v gh >/dev/null 2>&1 || { echo "check-ai-policy.sh: gh is not on PATH" >&2; exit 1; }
63
+
64
+ # print every policy hit in one repo at one ref; returns 1 when nothing matched
65
+ scan() {
66
+ local target="$1" ref="$2" label="$3" found=0 f body hits
67
+ for f in $FILES; do
68
+ body=$(gh api "repos/$target/contents/$f?ref=$ref" --jq '.content' 2>/dev/null | base64 -d 2>/dev/null)
69
+ [ -z "$body" ] && continue
70
+ hits=$(printf '%s' "$body" | grep -nEi "$PATTERN" | head -12)
71
+ if [ -n "$hits" ]; then
72
+ found=1
73
+ echo " --- $label$f ---"
74
+ printf '%s\n' "$hits" | cut -c1-300 | sed 's/^/ /'
75
+ else
76
+ echo " (clean) $label$f"
77
+ fi
78
+ done
79
+ [ "$found" -eq 1 ]
80
+ }
81
+
82
+ status=0
83
+ for repo in "${repos[@]}"; do
84
+ echo "=================================================================="
85
+ echo "$repo"
86
+ branch="$branch_opt"
87
+ if [ -z "$branch" ]; then
88
+ branch=$(gh api "repos/$repo" --jq .default_branch 2>/dev/null) || branch=""
89
+ if [ -z "$branch" ]; then
90
+ echo "check-ai-policy.sh: cannot read $repo from GitHub" >&2
91
+ status=1
92
+ continue
93
+ fi
94
+ fi
95
+ echo " branch: $branch"
96
+ found_any=0
97
+ scan "$repo" "$branch" "" && found_any=1
98
+
99
+ # an org-wide policy often lives in the organization's .github repository
100
+ org="${repo%%/*}"
101
+ org_branch=$(gh api "repos/$org/.github" --jq .default_branch 2>/dev/null) || org_branch=""
102
+ if [ -n "$org_branch" ]; then
103
+ echo " org repo: $org/.github ($org_branch)"
104
+ scan "$org/.github" "$org_branch" "$org/.github: " && found_any=1
105
+ fi
106
+
107
+ [ "$found_any" -eq 0 ] && echo " RESULT: no AI/LLM language in any policy file checked"
108
+ done
109
+ exit "$status"
@@ -0,0 +1,83 @@
1
+ #!/bin/bash
2
+ # comment-census.sh - added code lines against added comment lines, per branch.
3
+ #
4
+ # A patch that carries a paragraph of comments into a file that has none reads
5
+ # as machine-written and gets closed. This counts, for every branch in every
6
+ # clone under a directory, the added non-comment lines and the added comment
7
+ # lines, so a pull request can be made to match the comment density of the file
8
+ # it lands in. Three maintainers asked for exactly this, in three separate
9
+ # reviews, before it became a script.
10
+ #
11
+ # Usage:
12
+ # bash comment-census.sh [CLONE_ROOT]
13
+ # bash comment-census.sh --help
14
+ #
15
+ # CLONE_ROOT is any directory whose immediate subdirectories are git clones.
16
+ # Defaults to $HOUSEBROKEN_CLONES, then to $HOUSEBROKEN_HOME/clones.
17
+ #
18
+ # Environment:
19
+ # HOUSEBROKEN_CLONES default clone root
20
+ # HOUSEBROKEN_HOME workshop root, default $HOME/.housebroken
21
+ #
22
+ # Assumes: each clone has an origin remote whose default branch is discoverable
23
+ # (origin/HEAD, else origin/main, else origin/master); every other local branch
24
+ # is a candidate patch. Tests, markdown and lock files are excluded from the
25
+ # code and comment counts. Comment detection covers //, /* */, * and #.
26
+ set -u
27
+
28
+ usage() {
29
+ sed -n '2,/^set -u$/p' "$0" | sed 's/^# \{0,1\}//; /^set -u$/d'
30
+ }
31
+
32
+ case "${1:-}" in
33
+ --help|-h) usage; exit 0 ;;
34
+ esac
35
+
36
+ HOUSEBROKEN_HOME="${HOUSEBROKEN_HOME:-$HOME/.housebroken}"
37
+ root="${1:-${HOUSEBROKEN_CLONES:-$HOUSEBROKEN_HOME/clones}}"
38
+
39
+ if [ ! -d "$root" ]; then
40
+ echo "comment-census.sh: not a directory: $root" >&2
41
+ echo "give a clone root as an argument or set HOUSEBROKEN_CLONES" >&2
42
+ exit 1
43
+ fi
44
+
45
+ printf '%-28s %-36s %5s %5s %5s\n' repo branch code cmnt tests
46
+
47
+ found=0
48
+ for d in "$root"/*/; do
49
+ [ -d "$d/.git" ] || continue
50
+ found=1
51
+ name=$(basename "$d")
52
+ base=$(git -C "$d" symbolic-ref -q --short refs/remotes/origin/HEAD || true)
53
+ if [ -z "$base" ]; then
54
+ if git -C "$d" rev-parse --verify -q origin/main >/dev/null; then
55
+ base=origin/main
56
+ elif git -C "$d" rev-parse --verify -q origin/master >/dev/null; then
57
+ base=origin/master
58
+ else
59
+ echo "comment-census.sh: no default branch in $d, skipped" >&2
60
+ continue
61
+ fi
62
+ fi
63
+ default_branch="${base#origin/}"
64
+ while read -r br; do
65
+ [ -n "$br" ] || continue
66
+ [ "$br" = "$default_branch" ] && continue
67
+ [ "$(git -C "$d" rev-list --count "$base..$br")" = "0" ] && continue
68
+ added=$(git -C "$d" diff "$base..$br" -- . ':(exclude)*test*' ':(exclude)*Test*' ':(exclude)*_test.go' ':(exclude)Tests/*' ':(exclude)*.md' ':(exclude)*.sum' | grep '^+' | grep -v '^+++' || true)
69
+ if [ -z "$added" ]; then
70
+ code=0; cmnt=0
71
+ else
72
+ code=$(printf '%s\n' "$added" | grep -v -E '^\+\s*(//|/\*|\*|#|///|\*/)' | grep -c -v -E '^\+\s*$' || true)
73
+ cmnt=$(printf '%s\n' "$added" | grep -c -E '^\+\s*(//|/\*|\*|///|\*/)' || true)
74
+ fi
75
+ tests=$(git -C "$d" diff "$base..$br" --numstat | awk '/[Tt]est/{s+=$1} END{print s+0}')
76
+ printf '%-28s %-36s %5s %5s %5s\n' "$name" "$br" "$code" "$cmnt" "$tests"
77
+ done < <(git -C "$d" for-each-ref --format='%(refname:short)' refs/heads/)
78
+ done
79
+
80
+ if [ "$found" = 0 ]; then
81
+ echo "comment-census.sh: no git clones found under $root" >&2
82
+ exit 1
83
+ fi
@@ -0,0 +1,85 @@
1
+ #!/bin/bash
2
+ # distinct-outside.sh - distinct outside humans whose PRs were merged in the
3
+ # last N days. A project that has merged none is closed to outsiders whatever
4
+ # its README says, so this number is read before a target is adopted.
5
+ #
6
+ # Outside means the PR's author_association is CONTRIBUTOR, FIRST_TIMER,
7
+ # FIRST_TIME_CONTRIBUTOR or NONE; everything else is counted as team. Known bot
8
+ # accounts are dropped. Only the two most recently updated pages of closed PRs
9
+ # are read, so a very busy repo is a lower bound on a long window.
10
+ #
11
+ # Usage:
12
+ # bash distinct-outside.sh [--days N] owner/repo [owner/repo ...]
13
+ # bash distinct-outside.sh --help
14
+ #
15
+ # Environment:
16
+ # HOUSEBROKEN_HOME work directory, default $HOME/.housebroken. The PR pages
17
+ # are staged in its cache/ subdirectory, created if missing.
18
+ #
19
+ # Assumes gh is installed and authenticated, and jq is on PATH. Read-only: GET
20
+ # calls only.
21
+ set -u
22
+
23
+ usage() {
24
+ cat <<'EOF'
25
+ usage: distinct-outside.sh [--days N] owner/repo [owner/repo ...]
26
+ distinct-outside.sh --help
27
+
28
+ --days N window in days, default 120
29
+
30
+ environment:
31
+ HOUSEBROKEN_HOME work directory, default $HOME/.housebroken
32
+ EOF
33
+ }
34
+
35
+ days=120
36
+ repos=()
37
+ while [ "$#" -gt 0 ]; do
38
+ case "$1" in
39
+ --help|-h) usage; exit 0 ;;
40
+ --days) shift; [ "$#" -gt 0 ] || { echo "distinct-outside.sh: --days needs a number" >&2; exit 1; }
41
+ days="$1" ;;
42
+ --days=*) days="${1#--days=}" ;;
43
+ --*) usage >&2; echo "distinct-outside.sh: unknown option $1" >&2; exit 1 ;;
44
+ */*) repos+=("$1") ;;
45
+ *) usage >&2; echo "distinct-outside.sh: '$1' is not owner/repo" >&2; exit 1 ;;
46
+ esac
47
+ shift
48
+ done
49
+ [ "${#repos[@]}" -gt 0 ] || { usage >&2; exit 1; }
50
+ case "$days" in
51
+ ''|*[!0-9]*) echo "distinct-outside.sh: --days must be a whole number, got '$days'" >&2; exit 1 ;;
52
+ esac
53
+
54
+ command -v gh >/dev/null 2>&1 || { echo "distinct-outside.sh: gh is not on PATH" >&2; exit 1; }
55
+ command -v jq >/dev/null 2>&1 || { echo "distinct-outside.sh: jq is not on PATH" >&2; exit 1; }
56
+
57
+ home="${HOUSEBROKEN_HOME:-$HOME/.housebroken}"
58
+ cache="$home/cache"
59
+ mkdir -p "$cache" || { echo "distinct-outside.sh: cannot create $cache" >&2; exit 1; }
60
+
61
+ cutoff=$(date -u -d "$days days ago" '+%Y-%m-%dT%H:%M:%SZ') ||
62
+ { echo "distinct-outside.sh: cannot compute the cutoff date" >&2; exit 1; }
63
+
64
+ for repo in "${repos[@]}"; do
65
+ json="$cache/prs-${repo%%/*}-${repo##*/}.json"
66
+ : >"$json"
67
+ for page in 1 2; do
68
+ gh api "repos/$repo/pulls?state=closed&per_page=100&sort=updated&direction=desc&page=$page" >>"$json" ||
69
+ { echo "distinct-outside.sh: cannot list closed PRs for $repo" >&2; exit 1; }
70
+ done
71
+ jq -s -r --arg repo "$repo" --arg cutoff "$cutoff" --arg days "$days" '
72
+ def bot: ascii_downcase | test("dependabot|renovate|github-actions|pre-commit-ci|allcontributors|snyk|\\[bot\\]");
73
+ def tally: reduce .[] as $l ({}; .[$l] += 1);
74
+ (add // [])
75
+ | map(select(.merged_at != null and .merged_at >= $cutoff))
76
+ | map(select((.user.login // "") | bot | not))
77
+ | (map(select(.author_association as $a
78
+ | ["CONTRIBUTOR","FIRST_TIME_CONTRIBUTOR","FIRST_TIMER","NONE"] | index($a)))
79
+ | map(.user.login) | tally) as $out
80
+ | (map(select(.author_association as $a
81
+ | ["CONTRIBUTOR","FIRST_TIME_CONTRIBUTOR","FIRST_TIMER","NONE"] | index($a) | not))
82
+ | map(.user.login) | tally) as $team
83
+ | "\($repo): \($out | length) distinct outside humans in \($days)d -> \($out | tojson) | team merges \($team | tojson)"
84
+ ' "$json" || { echo "distinct-outside.sh: cannot parse the PR list for $repo" >&2; exit 1; }
85
+ done
@@ -0,0 +1,118 @@
1
+ #!/bin/bash
2
+ # file-pr.sh - the only way a PR gets filed: gh pr create behind two gates.
3
+ #
4
+ # Gate one is prior art. typer #1946 (closed 2026-08-31) duplicated an open PR
5
+ # and swift-http-types #153 (closed 2026-09-04) reargued a closed issue, both
6
+ # because the check was a memory rather than a file. This refuses unless
7
+ # prior-art-<owner>-<repo>.md exists under $HOUSEBROKEN_HOME/prior-art/ and is
8
+ # under 24 hours old.
9
+ # Gate two is the prose. Tool footers, session trailers and em or en dashes have
10
+ # no place in somebody else's repository, so a body carrying one is refused.
11
+ # Written 2026-09-07.
12
+ #
13
+ # Usage:
14
+ # bash file-pr.sh owner/repo <gh pr create args...>
15
+ # FILE_PR_DRY=1 bash file-pr.sh owner/repo ... # print the command, run nothing
16
+ # bash file-pr.sh --help
17
+ #
18
+ # Environment:
19
+ # HOUSEBROKEN_HOME work directory, default $HOME/.housebroken. The prior art
20
+ # is read from its prior-art/ subdirectory, where
21
+ # prior-art.sh --out puts it.
22
+ # FILE_PR_DRY set to 1 to print the gh command instead of running it.
23
+ #
24
+ # Assumes gh is installed and authenticated. This is the one script here that
25
+ # writes to GitHub, and only after both gates pass and only when FILE_PR_DRY
26
+ # is not 1.
27
+ set -u
28
+
29
+ usage() {
30
+ cat <<'EOF'
31
+ usage: file-pr.sh owner/repo <gh pr create args...>
32
+ file-pr.sh --help
33
+
34
+ owner/repo the upstream repository the PR is filed against
35
+ the remaining arguments are passed to gh pr create unchanged
36
+
37
+ gates:
38
+ prior art $HOUSEBROKEN_HOME/prior-art/prior-art-<owner>-<repo>.md must
39
+ exist and be under 24 hours old
40
+ prose no tool footer, session trailer, co-author trailer, em dash or
41
+ en dash in --body or --body-file
42
+
43
+ environment:
44
+ HOUSEBROKEN_HOME work directory, default $HOME/.housebroken
45
+ FILE_PR_DRY 1 prints the gh command and files nothing
46
+ EOF
47
+ }
48
+
49
+ if [ "$#" -eq 0 ] || [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then
50
+ usage
51
+ [ "$#" -eq 0 ] && exit 2
52
+ exit 0
53
+ fi
54
+
55
+ command -v gh >/dev/null 2>&1 || { echo "REFUSED: gh is not on PATH" >&2; exit 2; }
56
+
57
+ repo="$1"
58
+ shift
59
+ case "$repo" in
60
+ */*) : ;;
61
+ *) echo "REFUSED: first argument must be owner/repo" >&2; exit 2 ;;
62
+ esac
63
+ [ "$#" -gt 0 ] || { echo "REFUSED: no gh pr create arguments given" >&2; exit 2; }
64
+
65
+ home="${HOUSEBROKEN_HOME:-$HOME/.housebroken}"
66
+ art="$home/prior-art/prior-art-${repo%%/*}-${repo##*/}.md"
67
+ if [ ! -f "$art" ]; then
68
+ echo "REFUSED: no prior art at $art - run prior-art.sh $repo <terms> --out first" >&2
69
+ exit 2
70
+ fi
71
+ now=$(date +%s)
72
+ made=$(date -r "$art" +%s) || { echo "REFUSED: cannot read the age of $art" >&2; exit 2; }
73
+ if [ $((now - made)) -gt 86400 ]; then
74
+ echo "REFUSED: prior art $art is $(( (now - made) / 3600 ))h old - rerun prior-art.sh --out" >&2
75
+ exit 2
76
+ fi
77
+
78
+ em=$(printf '\xe2\x80\x94') # em dash U+2014, built at runtime to keep this file ASCII
79
+ en=$(printf '\xe2\x80\x93') # en dash U+2013
80
+ banned_re="Generated with|Claude-Session|Co-Authored-By|$em|$en"
81
+ check_text() {
82
+ local what="$1" text="$2" hit
83
+ hit=$(printf '%s' "$text" | grep -oE "$banned_re" | head -1) || true
84
+ if [ -n "$hit" ]; then
85
+ echo "REFUSED: $what contains '$hit' - strip it before filing" >&2
86
+ exit 2
87
+ fi
88
+ }
89
+
90
+ prev=""
91
+ for arg in "$@"; do
92
+ case "$prev" in
93
+ --body-file|-F)
94
+ [ -f "$arg" ] || { echo "REFUSED: body file $arg does not exist" >&2; exit 2; }
95
+ check_text "body file $arg" "$(cat "$arg")"
96
+ ;;
97
+ --body|-b)
98
+ check_text "--body" "$arg"
99
+ ;;
100
+ esac
101
+ case "$arg" in
102
+ --body-file=*)
103
+ f="${arg#--body-file=}"
104
+ [ -f "$f" ] || { echo "REFUSED: body file $f does not exist" >&2; exit 2; }
105
+ check_text "body file $f" "$(cat "$f")"
106
+ ;;
107
+ --body=*) check_text "--body" "${arg#--body=}" ;;
108
+ esac
109
+ prev="$arg"
110
+ done
111
+
112
+ if [ "${FILE_PR_DRY:-0}" = "1" ]; then
113
+ printf 'DRY RUN, not filed:\ngh pr create -R %s' "$repo"
114
+ for arg in "$@"; do printf ' %q' "$arg"; done
115
+ printf '\n'
116
+ exit 0
117
+ fi
118
+ gh pr create -R "$repo" "$@"
@@ -0,0 +1,82 @@
1
+ #!/bin/bash
2
+ # fork-hygiene.sh - clean up after ourselves on GitHub (rule adopted 2026-09-06):
3
+ # delete the fork branch of every upstream PR that is merged or closed, and list
4
+ # the forks that carry no PR at all, which are deleted by hand when the work is
5
+ # over. Never touches the branch of an open PR, never touches our own repositories.
6
+ #
7
+ # Usage:
8
+ # bash fork-hygiene.sh # deletes the branches it names
9
+ # bash fork-hygiene.sh --dry-run # prints what it would delete, deletes nothing
10
+ # bash fork-hygiene.sh --help
11
+ #
12
+ # Environment:
13
+ # HOUSEBROKEN_USER GitHub login whose forks are cleaned, default the
14
+ # authenticated user
15
+ #
16
+ # Assumes: gh is installed and authenticated with delete access to the fork,
17
+ # that our forks live under that login, and that a closed or merged PR's head
18
+ # branch has no other use. -n is accepted as a synonym for --dry-run.
19
+ set -u
20
+
21
+ usage() {
22
+ sed -n '2,/^set -u$/p' "$0" | sed 's/^# \{0,1\}//; /^set -u$/d'
23
+ }
24
+
25
+ DRY=0
26
+ case "${1:-}" in
27
+ --help|-h) usage; exit 0 ;;
28
+ --dry-run|-n) DRY=1 ;;
29
+ "") ;;
30
+ *) echo "fork-hygiene.sh: unknown argument: $1" >&2; usage >&2; exit 2 ;;
31
+ esac
32
+
33
+ ME="${HOUSEBROKEN_USER:-$(gh api user --jq .login)}"
34
+ if [ -z "$ME" ]; then
35
+ echo "fork-hygiene.sh: cannot determine the GitHub login; set HOUSEBROKEN_USER" >&2
36
+ exit 1
37
+ fi
38
+
39
+ if ! closed="$(gh search prs --author "$ME" --state closed --json repository,number --limit 100 \
40
+ --jq '.[] | "\(.repository.nameWithOwner) \(.number)"')"; then
41
+ echo "fork-hygiene.sh: gh search prs failed for author $ME" >&2
42
+ exit 1
43
+ fi
44
+
45
+ while read -r repo n; do
46
+ [ -n "$repo" ] || continue
47
+ case "$repo" in "$ME"/*) continue ;; esac
48
+ info=$(gh pr view "$n" --repo "$repo" --json headRefName,headRepository,headRepositoryOwner,state \
49
+ --jq '"\(.headRepositoryOwner.login)|\(.headRepository.name)|\(.headRefName)|\(.state)"' 2>/dev/null) || {
50
+ echo "fork-hygiene.sh: gh pr view $repo#$n failed" >&2
51
+ continue
52
+ }
53
+ IFS='|' read -r owner fork branch state <<< "$info"
54
+ if [ "$owner" != "$ME" ] || [ -z "$branch" ]; then continue; fi
55
+ gh api "repos/$ME/$fork/git/refs/heads/$branch" >/dev/null 2>&1 || continue
56
+ if [ "$DRY" = 1 ]; then
57
+ echo "would delete $ME/$fork $branch ($state $repo#$n)"
58
+ continue
59
+ fi
60
+ if gh api -X DELETE "repos/$ME/$fork/git/refs/heads/$branch" >/dev/null 2>&1; then
61
+ echo "deleted $ME/$fork $branch ($state $repo#$n)"
62
+ else
63
+ echo "FAILED $ME/$fork $branch" >&2
64
+ fi
65
+ done <<< "$closed"
66
+
67
+ echo "--- forks with no PR (delete by hand when a wave is over):"
68
+ forks=$(mktemp) || { echo "fork-hygiene.sh: mktemp failed" >&2; exit 1; }
69
+ prrepos=$(mktemp) || { echo "fork-hygiene.sh: mktemp failed" >&2; exit 1; }
70
+ trap 'rm -f "$forks" "$prrepos"' EXIT
71
+
72
+ if ! gh repo list "$ME" --fork --limit 200 --json name,parent \
73
+ --jq '.[] | "\(.parent.owner.login)/\(.parent.name)"' | sort -u > "$forks"; then
74
+ echo "fork-hygiene.sh: gh repo list failed for $ME" >&2
75
+ exit 1
76
+ fi
77
+ if ! gh search prs --author "$ME" --json repository --limit 200 \
78
+ --jq '.[].repository.nameWithOwner' | sort -u > "$prrepos"; then
79
+ echo "fork-hygiene.sh: gh search prs failed for author $ME" >&2
80
+ exit 1
81
+ fi
82
+ comm -23 "$forks" "$prrepos" | tr '\n' ' '; echo
@@ -0,0 +1,110 @@
1
+ #!/bin/bash
2
+ # housebroken - one command name for the eight gate scripts.
3
+ #
4
+ # The skill file, and any future adapter for another agent host, must be able
5
+ # to say `housebroken prior-art owner/repo term` without knowing where the
6
+ # scripts live. This dispatcher is that stable name: it finds the scripts
7
+ # directory, maps a short subcommand to a script, and hands over every
8
+ # remaining argument unchanged. Nothing else. No gate logic lives here.
9
+ #
10
+ # Usage:
11
+ # housebroken <subcommand> [args...]
12
+ # housebroken help
13
+ # housebroken version
14
+ #
15
+ # Environment:
16
+ # HOUSEBROKEN_SCRIPTS directory holding the eight *.sh gate scripts. When
17
+ # unset, the scripts directory next to this file
18
+ # (../scripts), then this file's own directory, then
19
+ # $HOUSEBROKEN_HOME/scripts are tried in that order.
20
+ # HOUSEBROKEN_HOME workshop root, default $HOME/.housebroken. The gate
21
+ # scripts read it too; it is passed through untouched.
22
+ #
23
+ # Assumes bash. The scripts themselves assume gh and jq.
24
+ set -u
25
+
26
+ self_dir=$(cd "$(dirname "$0")" && pwd)
27
+ HOUSEBROKEN_HOME="${HOUSEBROKEN_HOME:-$HOME/.housebroken}"
28
+
29
+ candidates=()
30
+ if [ -n "${HOUSEBROKEN_SCRIPTS:-}" ]; then
31
+ candidates+=("$HOUSEBROKEN_SCRIPTS")
32
+ fi
33
+ candidates+=("$self_dir/../scripts" "$self_dir" "$HOUSEBROKEN_HOME/scripts")
34
+
35
+ scripts_dir=""
36
+ for d in "${candidates[@]}"; do
37
+ if [ -f "$d/prior-art.sh" ]; then
38
+ scripts_dir=$(cd "$d" && pwd)
39
+ break
40
+ fi
41
+ done
42
+
43
+ door() {
44
+ cat <<'EOF'
45
+ usage: housebroken <subcommand> [args...]
46
+
47
+ 1 policy read the repository's AI-contribution policy where it lives
48
+ 1 outside count outside contributors merged in the last 120 days
49
+ 2 prior-art issues and pull requests on the touched files, rulings quoted
50
+ 6 census added code lines against added comment lines, per branch
51
+ 9 file the only way a pull request gets filed
52
+ 10 verify the filed pull request is what was meant, and CI settled
53
+ 10 sweep every open pull request where the ball is in your court
54
+ 11 hygiene delete fork branches after merge or close, list orphan forks
55
+
56
+ help this list
57
+ version the installed housebroken version
58
+
59
+ Step numbers are the steps of "How it works" in the README. Every argument
60
+ after the subcommand is passed to the script unchanged.
61
+ EOF
62
+ }
63
+
64
+ version() {
65
+ vpaths=("$self_dir/../VERSION" "$self_dir/VERSION")
66
+ if [ -n "$scripts_dir" ]; then
67
+ vpaths=("$scripts_dir/../VERSION" "$scripts_dir/VERSION" "${vpaths[@]}")
68
+ fi
69
+ for v in "${vpaths[@]}"; do
70
+ if [ -f "$v" ]; then
71
+ head -1 "$v"
72
+ return 0
73
+ fi
74
+ done
75
+ echo "housebroken (unversioned)"
76
+ }
77
+
78
+ cmd="${1:-help}"
79
+ case "$cmd" in
80
+ help|--help|-h) door; exit 0 ;;
81
+ version|--version) version; exit 0 ;;
82
+ esac
83
+ shift
84
+
85
+ case "$cmd" in
86
+ policy) script=check-ai-policy.sh ;;
87
+ outside) script=distinct-outside.sh ;;
88
+ prior-art) script=prior-art.sh ;;
89
+ census) script=comment-census.sh ;;
90
+ file) script=file-pr.sh ;;
91
+ verify) script=verify-filed-pr.sh ;;
92
+ sweep) script=pr-sweep.sh ;;
93
+ hygiene) script=fork-hygiene.sh ;;
94
+ *)
95
+ echo "housebroken: unknown subcommand: $cmd (try: housebroken help)" >&2
96
+ exit 2
97
+ ;;
98
+ esac
99
+
100
+ if [ -z "$scripts_dir" ] || [ ! -f "$scripts_dir/$script" ]; then
101
+ {
102
+ echo "housebroken: cannot find $script. Looked in:"
103
+ for d in "${candidates[@]}"; do echo " $d"; done
104
+ echo "Set HOUSEBROKEN_SCRIPTS to the directory holding the gate scripts."
105
+ } >&2
106
+ exit 2
107
+ fi
108
+
109
+ export HOUSEBROKEN_HOME
110
+ exec bash "$scripts_dir/$script" "$@"