spareparts-cli 0.1.0__tar.gz
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.
- spareparts_cli-0.1.0/.github/dependabot.yml +31 -0
- spareparts_cli-0.1.0/.github/workflows/dependency-scan.yml +46 -0
- spareparts_cli-0.1.0/.github/workflows/homebrew.yml +110 -0
- spareparts_cli-0.1.0/.github/workflows/publish.yml +82 -0
- spareparts_cli-0.1.0/.github/workflows/secret-scan.yml +81 -0
- spareparts_cli-0.1.0/.github/workflows/test.yml +92 -0
- spareparts_cli-0.1.0/.gitignore +11 -0
- spareparts_cli-0.1.0/PKG-INFO +227 -0
- spareparts_cli-0.1.0/PROMPTS.md +61 -0
- spareparts_cli-0.1.0/README.md +202 -0
- spareparts_cli-0.1.0/pyproject.toml +37 -0
- spareparts_cli-0.1.0/src/spareparts/__init__.py +3 -0
- spareparts_cli-0.1.0/src/spareparts/__main__.py +4 -0
- spareparts_cli-0.1.0/src/spareparts/cli.py +94 -0
- spareparts_cli-0.1.0/src/spareparts/modules/__init__.py +1 -0
- spareparts_cli-0.1.0/src/spareparts/modules/lgtm/__init__.py +218 -0
- spareparts_cli-0.1.0/src/spareparts/modules/lgtm/ask.py +209 -0
- spareparts_cli-0.1.0/src/spareparts/modules/lgtm/config.py +189 -0
- spareparts_cli-0.1.0/src/spareparts/modules/lgtm/diff.py +134 -0
- spareparts_cli-0.1.0/src/spareparts/modules/lgtm/generator.py +263 -0
- spareparts_cli-0.1.0/src/spareparts/modules/lgtm/git.py +179 -0
- spareparts_cli-0.1.0/src/spareparts/modules/lgtm/hook.py +238 -0
- spareparts_cli-0.1.0/src/spareparts/modules/lgtm/prompts.py +98 -0
- spareparts_cli-0.1.0/src/spareparts/modules/lgtm/screen.py +71 -0
- spareparts_cli-0.1.0/src/spareparts/prompts/lgtm-questions.v1.json +103 -0
- spareparts_cli-0.1.0/src/spareparts/providers/__init__.py +128 -0
- spareparts_cli-0.1.0/src/spareparts/providers/_anthropic.py +69 -0
- spareparts_cli-0.1.0/src/spareparts/providers/_gemini.py +86 -0
- spareparts_cli-0.1.0/src/spareparts/providers/_openai.py +92 -0
- spareparts_cli-0.1.0/tests/test_ask.py +141 -0
- spareparts_cli-0.1.0/tests/test_config.py +83 -0
- spareparts_cli-0.1.0/tests/test_diff.py +71 -0
- spareparts_cli-0.1.0/tests/test_generate_flow.py +185 -0
- spareparts_cli-0.1.0/tests/test_generator.py +92 -0
- spareparts_cli-0.1.0/tests/test_git.py +49 -0
- spareparts_cli-0.1.0/tests/test_hook.py +277 -0
- spareparts_cli-0.1.0/tests/test_prompts.py +96 -0
- spareparts_cli-0.1.0/tests/test_providers.py +165 -0
- spareparts_cli-0.1.0/tests/test_screen.py +39 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
version: 2
|
|
2
|
+
|
|
3
|
+
# The upgrade half of dependency scanning. `dependency-scan.yml` fails a build
|
|
4
|
+
# that is already vulnerable; this opens the pull request that fixes it.
|
|
5
|
+
updates:
|
|
6
|
+
- package-ecosystem: pip
|
|
7
|
+
directory: /
|
|
8
|
+
schedule:
|
|
9
|
+
interval: weekly
|
|
10
|
+
day: monday
|
|
11
|
+
open-pull-requests-limit: 5
|
|
12
|
+
commit-message:
|
|
13
|
+
prefix: chore
|
|
14
|
+
groups:
|
|
15
|
+
# One PR for the whole set. Three vendor SDKs on separate weekly PRs is
|
|
16
|
+
# how a repository trains its owner to stop reading them.
|
|
17
|
+
python:
|
|
18
|
+
patterns: ["*"]
|
|
19
|
+
|
|
20
|
+
# Workflow actions pin to major tags that move, so this mostly catches
|
|
21
|
+
# deprecations (a runner dropping node16 actions, say) before they break CI.
|
|
22
|
+
- package-ecosystem: github-actions
|
|
23
|
+
directory: /
|
|
24
|
+
schedule:
|
|
25
|
+
interval: weekly
|
|
26
|
+
day: monday
|
|
27
|
+
commit-message:
|
|
28
|
+
prefix: chore
|
|
29
|
+
groups:
|
|
30
|
+
actions:
|
|
31
|
+
patterns: ["*"]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
name: Dependency scan
|
|
2
|
+
|
|
3
|
+
# Known-vulnerable dependencies, checked against the Python Packaging Advisory
|
|
4
|
+
# Database and OSV by pip-audit (https://pypi.org/project/pip-audit/).
|
|
5
|
+
#
|
|
6
|
+
# Self-contained, like the secret scan: no shared workflow, no GitHub Advanced
|
|
7
|
+
# Security. Dependabot (see .github/dependabot.yml) opens the upgrade PRs; this
|
|
8
|
+
# job is what fails a build that ships a vulnerable dependency in the meantime.
|
|
9
|
+
#
|
|
10
|
+
# The schedule matters more than the PR trigger. A dependency does not become
|
|
11
|
+
# vulnerable when someone edits pyproject.toml; it becomes vulnerable when an
|
|
12
|
+
# advisory is published, which can be months after the last commit here.
|
|
13
|
+
|
|
14
|
+
on:
|
|
15
|
+
pull_request:
|
|
16
|
+
push:
|
|
17
|
+
branches: [main]
|
|
18
|
+
schedule:
|
|
19
|
+
- cron: "0 7 * * 1"
|
|
20
|
+
workflow_dispatch:
|
|
21
|
+
|
|
22
|
+
jobs:
|
|
23
|
+
audit:
|
|
24
|
+
name: pip-audit
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
permissions:
|
|
27
|
+
contents: read
|
|
28
|
+
steps:
|
|
29
|
+
- uses: actions/checkout@v7
|
|
30
|
+
|
|
31
|
+
- uses: actions/setup-python@v7
|
|
32
|
+
with:
|
|
33
|
+
python-version: "3.12"
|
|
34
|
+
|
|
35
|
+
# `all`, not the bare install: the extras are dependencies this project
|
|
36
|
+
# tells people to install, so an advisory against one of them is ours.
|
|
37
|
+
- name: Install every declared dependency
|
|
38
|
+
run: |
|
|
39
|
+
python -m pip install --upgrade pip
|
|
40
|
+
pip install -e '.[all]' pip-audit
|
|
41
|
+
|
|
42
|
+
- name: Audit
|
|
43
|
+
# --skip-editable skips this project itself, which has no PyPI release
|
|
44
|
+
# to look up. Everything it pulled in is still audited, and the summary
|
|
45
|
+
# pip-audit prints lists exactly what was skipped and why.
|
|
46
|
+
run: pip-audit --skip-editable
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
name: Update Homebrew tap
|
|
2
|
+
|
|
3
|
+
# On every release tag, point the sparepartslabs/homebrew-tap formula at the new
|
|
4
|
+
# PyPI sdist. Runs alongside publish.yml (same v* trigger) and waits for the
|
|
5
|
+
# sdist to appear on PyPI before reading its canonical URL + sha256.
|
|
6
|
+
|
|
7
|
+
on:
|
|
8
|
+
push:
|
|
9
|
+
tags:
|
|
10
|
+
- "v*"
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
bump:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- name: Resolve version
|
|
17
|
+
id: v
|
|
18
|
+
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
|
19
|
+
|
|
20
|
+
- name: Fetch sdist url + sha256 from PyPI (wait for publish)
|
|
21
|
+
id: pypi
|
|
22
|
+
run: |
|
|
23
|
+
VERSION="${{ steps.v.outputs.version }}"
|
|
24
|
+
for i in $(seq 1 30); do
|
|
25
|
+
JSON=$(curl -fsSL "https://pypi.org/pypi/spareparts-cli/${VERSION}/json" || true)
|
|
26
|
+
URL=$(printf '%s' "$JSON" | jq -r '.urls[]? | select(.packagetype=="sdist") | .url')
|
|
27
|
+
SHA=$(printf '%s' "$JSON" | jq -r '.urls[]? | select(.packagetype=="sdist") | .digests.sha256')
|
|
28
|
+
if [ -n "$URL" ] && [ "$URL" != "null" ]; then break; fi
|
|
29
|
+
echo "sdist for ${VERSION} not on PyPI yet, retrying ($i)..."
|
|
30
|
+
sleep 10
|
|
31
|
+
done
|
|
32
|
+
if [ -z "$URL" ] || [ "$URL" = "null" ]; then
|
|
33
|
+
echo "::error::spareparts-cli ${VERSION} sdist not found on PyPI"; exit 1
|
|
34
|
+
fi
|
|
35
|
+
echo "url=$URL" >> "$GITHUB_OUTPUT"
|
|
36
|
+
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
|
|
37
|
+
|
|
38
|
+
- name: Checkout tap
|
|
39
|
+
uses: actions/checkout@v4
|
|
40
|
+
with:
|
|
41
|
+
repository: sparepartslabs/homebrew-tap
|
|
42
|
+
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
|
43
|
+
|
|
44
|
+
- name: Update formula (url, sha256, and vendored resources)
|
|
45
|
+
env:
|
|
46
|
+
VERSION: ${{ steps.v.outputs.version }}
|
|
47
|
+
URL: ${{ steps.pypi.outputs.url }}
|
|
48
|
+
SHA: ${{ steps.pypi.outputs.sha }}
|
|
49
|
+
run: |
|
|
50
|
+
F=Formula/spareparts-cli.rb
|
|
51
|
+
sed -i -E "s#^ url \".*\"# url \"${URL}\"#" "$F"
|
|
52
|
+
sed -i -E "s#^ sha256 \".*\"# sha256 \"${SHA}\"#" "$F"
|
|
53
|
+
|
|
54
|
+
# Refresh the vendored `resource` blocks. `brew update-python-resources`
|
|
55
|
+
# can't be used on release day (it skips PyPI uploads less than a day
|
|
56
|
+
# old), so resolve the deps in a throwaway venv and read each sdist
|
|
57
|
+
# url+sha256 from PyPI.
|
|
58
|
+
#
|
|
59
|
+
# `[all]` on purpose: pip installing this package brings no vendor SDK,
|
|
60
|
+
# because none of the three is the assumed one. A brew install has no
|
|
61
|
+
# extras syntax to offer, so the bottle carries all three and the CLI
|
|
62
|
+
# uses whichever key you have set.
|
|
63
|
+
python3 - "$F" "$VERSION" <<'PY'
|
|
64
|
+
import json, re, subprocess, sys, tempfile, urllib.request, venv
|
|
65
|
+
|
|
66
|
+
formula, version = sys.argv[1], sys.argv[2]
|
|
67
|
+
tmp = tempfile.mkdtemp()
|
|
68
|
+
venv.create(f"{tmp}/v", with_pip=True)
|
|
69
|
+
pip = f"{tmp}/v/bin/pip"
|
|
70
|
+
subprocess.check_call([pip, "install", "-q", f"spareparts-cli[all]=={version}"])
|
|
71
|
+
freeze = subprocess.check_output([pip, "list", "--format=freeze"]).decode()
|
|
72
|
+
|
|
73
|
+
skip = {"spareparts-cli", "pip", "setuptools", "wheel"}
|
|
74
|
+
deps = []
|
|
75
|
+
for line in freeze.splitlines():
|
|
76
|
+
if "==" in line:
|
|
77
|
+
name, ver = line.split("==", 1)
|
|
78
|
+
if name.lower() not in skip:
|
|
79
|
+
deps.append((name, ver))
|
|
80
|
+
|
|
81
|
+
blocks = []
|
|
82
|
+
for name, ver in sorted(deps, key=lambda x: x[0].lower()):
|
|
83
|
+
d = json.load(urllib.request.urlopen(f"https://pypi.org/pypi/{name}/{ver}/json"))
|
|
84
|
+
sd = next((u for u in d["urls"] if u["packagetype"] == "sdist"), None)
|
|
85
|
+
if not sd:
|
|
86
|
+
sys.exit(f"no sdist for {name} {ver}")
|
|
87
|
+
blocks.append(
|
|
88
|
+
f' resource "{d["info"]["name"]}" do\n'
|
|
89
|
+
f' url "{sd["url"]}"\n'
|
|
90
|
+
f' sha256 "{sd["digests"]["sha256"]}"\n'
|
|
91
|
+
f' end'
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
text = open(formula).read()
|
|
95
|
+
# drop existing resource blocks, then insert fresh ones after depends_on
|
|
96
|
+
text = re.sub(r'\n resource "[^"]+" do\n(?:.*\n)*? end\n', '\n', text)
|
|
97
|
+
inject = "\n" + "\n\n".join(blocks) + "\n"
|
|
98
|
+
text = re.sub(r'( depends_on "python@[^"]+"\n)', r'\1' + inject, text, count=1)
|
|
99
|
+
text = re.sub(r'\n{3,}', '\n\n', text) # collapse blank runs (idempotent)
|
|
100
|
+
open(formula, "w").write(text)
|
|
101
|
+
print(f"refreshed {len(blocks)} resources: {', '.join(n for n, _ in deps)}")
|
|
102
|
+
PY
|
|
103
|
+
|
|
104
|
+
git config user.name "github-actions[bot]"
|
|
105
|
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
106
|
+
if git diff --quiet; then
|
|
107
|
+
echo "formula already up to date"; exit 0
|
|
108
|
+
fi
|
|
109
|
+
git commit -am "spareparts-cli ${VERSION}"
|
|
110
|
+
git push
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
# Releases are tags. `git tag v0.2.0 && git push --tags` publishes 0.2.0 and
|
|
4
|
+
# nothing else does, so what is on PyPI is always something someone chose to
|
|
5
|
+
# put there.
|
|
6
|
+
|
|
7
|
+
on:
|
|
8
|
+
push:
|
|
9
|
+
tags:
|
|
10
|
+
- "v*" # e.g. v0.1.0
|
|
11
|
+
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
id-token: write # PyPI Trusted Publishing (OIDC)
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
publish:
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
environment: pypi
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v4
|
|
22
|
+
|
|
23
|
+
- uses: actions/setup-python@v5
|
|
24
|
+
with:
|
|
25
|
+
python-version: "3.12"
|
|
26
|
+
|
|
27
|
+
# A tag that disagrees with pyproject publishes a version nobody asked
|
|
28
|
+
# for, under a name that already means something else. Cheaper to fail
|
|
29
|
+
# here than to yank a release: PyPI will not let the number be reused.
|
|
30
|
+
- name: The tag must match the version in pyproject
|
|
31
|
+
run: |
|
|
32
|
+
python - <<'PY'
|
|
33
|
+
import os, sys, tomllib
|
|
34
|
+
tag = os.environ["GITHUB_REF_NAME"].removeprefix("v")
|
|
35
|
+
with open("pyproject.toml", "rb") as f:
|
|
36
|
+
declared = tomllib.load(f)["project"]["version"]
|
|
37
|
+
if tag != declared:
|
|
38
|
+
sys.exit(f"tag {tag} does not match pyproject version {declared}")
|
|
39
|
+
print(f"releasing {declared}")
|
|
40
|
+
PY
|
|
41
|
+
|
|
42
|
+
- name: Install build tools
|
|
43
|
+
run: pip install build
|
|
44
|
+
|
|
45
|
+
- name: Build sdist and wheel
|
|
46
|
+
run: python -m build
|
|
47
|
+
|
|
48
|
+
# Trusted Publishing, so there is no API token to store or rotate. PyPI
|
|
49
|
+
# is configured to trust this repository, this workflow file, and the
|
|
50
|
+
# `pypi` environment above; changing any of those three breaks the
|
|
51
|
+
# release until PyPI's publisher settings are updated to match.
|
|
52
|
+
- name: Publish to PyPI
|
|
53
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
54
|
+
|
|
55
|
+
# A tag is a git object with no page. A release is where someone deciding
|
|
56
|
+
# whether to install this reads what changed and whether it is still alive,
|
|
57
|
+
# and PyPI shows them the current version and nothing about the last one.
|
|
58
|
+
#
|
|
59
|
+
# Its own job so that `publish` keeps `contents: read`: the job holding the
|
|
60
|
+
# PyPI identity token has no business also being able to write to the repo.
|
|
61
|
+
release:
|
|
62
|
+
needs: publish
|
|
63
|
+
runs-on: ubuntu-latest
|
|
64
|
+
permissions:
|
|
65
|
+
contents: write
|
|
66
|
+
steps:
|
|
67
|
+
- uses: actions/checkout@v4
|
|
68
|
+
with:
|
|
69
|
+
# Notes are generated from what changed since the previous tag, which
|
|
70
|
+
# a shallow clone cannot see.
|
|
71
|
+
fetch-depth: 0
|
|
72
|
+
|
|
73
|
+
# --generate-notes writes them from the pull requests merged since that
|
|
74
|
+
# tag, so there is no changelog to maintain by hand and no second place
|
|
75
|
+
# for the history to be wrong.
|
|
76
|
+
#
|
|
77
|
+
# The sdist and wheel are deliberately not attached: PyPI already serves
|
|
78
|
+
# them, and a second copy is one more thing that can disagree.
|
|
79
|
+
- name: Create the GitHub release
|
|
80
|
+
env:
|
|
81
|
+
GH_TOKEN: ${{ github.token }}
|
|
82
|
+
run: gh release create "$GITHUB_REF_NAME" --generate-notes --verify-tag
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
name: Secret scan
|
|
2
|
+
|
|
3
|
+
# Detection is TruffleHog (https://github.com/trufflesecurity/trufflehog): it
|
|
4
|
+
# verifies found credentials against live services, so a match reported here is
|
|
5
|
+
# far more likely to be a real, active secret than a shape-based false positive.
|
|
6
|
+
#
|
|
7
|
+
# Self-contained by design. This repository depends on nothing private to run
|
|
8
|
+
# its own CI, so the steps are inlined rather than called from a shared workflow.
|
|
9
|
+
|
|
10
|
+
on:
|
|
11
|
+
pull_request:
|
|
12
|
+
push:
|
|
13
|
+
branches: [main]
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
secrets:
|
|
17
|
+
name: trufflehog
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
permissions:
|
|
20
|
+
contents: read
|
|
21
|
+
env:
|
|
22
|
+
TRUFFLEHOG_VERSION: "3.95.9"
|
|
23
|
+
steps:
|
|
24
|
+
- name: Check out full history
|
|
25
|
+
uses: actions/checkout@v7
|
|
26
|
+
with:
|
|
27
|
+
fetch-depth: 0
|
|
28
|
+
|
|
29
|
+
- name: Resolve scan base
|
|
30
|
+
id: base
|
|
31
|
+
env:
|
|
32
|
+
EVENT_NAME: ${{ github.event_name }}
|
|
33
|
+
PR_BASE: ${{ github.event.pull_request.base.sha }}
|
|
34
|
+
PUSH_BEFORE: ${{ github.event.before }}
|
|
35
|
+
run: |
|
|
36
|
+
# A pull request scans everything its head adds over its base. A push
|
|
37
|
+
# scans the pushed range; a first push (all-zero "before") has no base,
|
|
38
|
+
# so scan the whole history that arrived with it.
|
|
39
|
+
if [ "$EVENT_NAME" = "pull_request" ]; then
|
|
40
|
+
echo "since=$PR_BASE" >> "$GITHUB_OUTPUT"
|
|
41
|
+
elif [ "$PUSH_BEFORE" = "0000000000000000000000000000000000000000" ] || [ -z "$PUSH_BEFORE" ]; then
|
|
42
|
+
echo "since=" >> "$GITHUB_OUTPUT"
|
|
43
|
+
else
|
|
44
|
+
echo "since=$PUSH_BEFORE" >> "$GITHUB_OUTPUT"
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
- name: Install TruffleHog
|
|
48
|
+
run: |
|
|
49
|
+
curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh \
|
|
50
|
+
| sh -s -- -b /usr/local/bin "v${TRUFFLEHOG_VERSION}"
|
|
51
|
+
trufflehog --version
|
|
52
|
+
|
|
53
|
+
- name: Scan for secrets
|
|
54
|
+
env:
|
|
55
|
+
SINCE: ${{ steps.base.outputs.since }}
|
|
56
|
+
run: |
|
|
57
|
+
# --results=verified,unknown: fail on live-verified credentials AND on
|
|
58
|
+
# findings TruffleHog cannot verify (private keys, offline tokens), so a
|
|
59
|
+
# real secret with no live endpoint to check is not silently ignored.
|
|
60
|
+
# --fail exits non-zero when any such result is found; --no-update keeps
|
|
61
|
+
# CI from self-upgrading the pinned binary.
|
|
62
|
+
#
|
|
63
|
+
# --exclude-detectors=Lob: Lob's detector matches any `test_`-prefixed
|
|
64
|
+
# token of about thirty characters, and Lob's API verifies those, so
|
|
65
|
+
# four pytest function names in this repository (for instance
|
|
66
|
+
# `test_the_revisit_note_points_at_the_file`) report as live, verified
|
|
67
|
+
# credentials. Nothing here talks to Lob, so the detector costs
|
|
68
|
+
# nothing to drop; the alternative, excluding the paths, would blind
|
|
69
|
+
# the scan to every test file instead.
|
|
70
|
+
set -- git "file://." --results=verified,unknown --fail --no-update \
|
|
71
|
+
--exclude-detectors=Lob
|
|
72
|
+
if [ -n "$SINCE" ]; then
|
|
73
|
+
set -- "$@" --since-commit "$SINCE"
|
|
74
|
+
fi
|
|
75
|
+
# A repo may ship a .trufflehog-exclude file (newline-separated path
|
|
76
|
+
# regexes) to suppress known-safe committed matches, e.g. throwaway
|
|
77
|
+
# keys in test fixtures or example connection strings in docs.
|
|
78
|
+
if [ -f .trufflehog-exclude ]; then
|
|
79
|
+
set -- "$@" --exclude-paths .trufflehog-exclude
|
|
80
|
+
fi
|
|
81
|
+
trufflehog "$@"
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
push:
|
|
6
|
+
branches: [main]
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
pytest:
|
|
10
|
+
# What the check reads as on a PR: "Tests / pytest on Python 3.11". The
|
|
11
|
+
# default would be `pytest (3.11)`, a bare number next to two others.
|
|
12
|
+
name: pytest on Python ${{ matrix.python-version }}
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
strategy:
|
|
15
|
+
fail-fast: false
|
|
16
|
+
matrix:
|
|
17
|
+
# The floor pyproject declares, through the current release. `sp` is
|
|
18
|
+
# installed onto whatever interpreter a contributor already has, so the
|
|
19
|
+
# supported range is a promise rather than an aspiration.
|
|
20
|
+
python-version: ["3.11", "3.12", "3.13"]
|
|
21
|
+
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v7
|
|
24
|
+
|
|
25
|
+
- uses: actions/setup-python@v7
|
|
26
|
+
with:
|
|
27
|
+
python-version: ${{ matrix.python-version }}
|
|
28
|
+
|
|
29
|
+
- name: Install
|
|
30
|
+
run: pip install -e '.[dev]'
|
|
31
|
+
|
|
32
|
+
- name: Run tests
|
|
33
|
+
run: pytest -q
|
|
34
|
+
|
|
35
|
+
# A bare `pip install spareparts-cli` pulls Anthropic and nothing else, which
|
|
36
|
+
# is the install most people get. If an OpenAI or Gemini import ever leaks out
|
|
37
|
+
# of its adapter and into a shared path, it surfaces here as a crash on
|
|
38
|
+
# `--help` rather than in a stranger's terminal.
|
|
39
|
+
bare-install:
|
|
40
|
+
name: bare install runs with no provider extras
|
|
41
|
+
runs-on: ubuntu-latest
|
|
42
|
+
steps:
|
|
43
|
+
- uses: actions/checkout@v7
|
|
44
|
+
|
|
45
|
+
- uses: actions/setup-python@v7
|
|
46
|
+
with:
|
|
47
|
+
python-version: "3.12"
|
|
48
|
+
|
|
49
|
+
- name: Install without provider extras
|
|
50
|
+
run: pip install .
|
|
51
|
+
|
|
52
|
+
- name: The CLI must run with only the default vendor installed
|
|
53
|
+
run: |
|
|
54
|
+
if python -c "import openai" 2>/dev/null; then
|
|
55
|
+
echo "openai is installed; this job is not testing what it claims"
|
|
56
|
+
exit 1
|
|
57
|
+
fi
|
|
58
|
+
sp --help
|
|
59
|
+
sp lgtm --help
|
|
60
|
+
|
|
61
|
+
build:
|
|
62
|
+
name: build the wheel and check its contents
|
|
63
|
+
runs-on: ubuntu-latest
|
|
64
|
+
steps:
|
|
65
|
+
- uses: actions/checkout@v7
|
|
66
|
+
|
|
67
|
+
- uses: actions/setup-python@v7
|
|
68
|
+
with:
|
|
69
|
+
python-version: "3.12"
|
|
70
|
+
|
|
71
|
+
- name: Build sdist and wheel
|
|
72
|
+
run: |
|
|
73
|
+
pip install build
|
|
74
|
+
python -m build
|
|
75
|
+
|
|
76
|
+
- name: Check the distribution
|
|
77
|
+
run: |
|
|
78
|
+
pip install twine
|
|
79
|
+
twine check dist/*
|
|
80
|
+
|
|
81
|
+
# The prompt files are data, not code, and a packaging change that drops
|
|
82
|
+
# them leaves a CLI that imports fine and then cannot generate anything.
|
|
83
|
+
- name: The wheel must carry the prompt files
|
|
84
|
+
run: |
|
|
85
|
+
python - <<'PY'
|
|
86
|
+
import pathlib, zipfile
|
|
87
|
+
wheel = next(iter(sorted(pathlib.Path("dist").glob("*.whl"))))
|
|
88
|
+
names = zipfile.ZipFile(wheel).namelist()
|
|
89
|
+
prompts = [n for n in names if "/prompts/" in n and n.endswith(".json")]
|
|
90
|
+
assert prompts, f"no prompt JSON in {wheel.name}: {names}"
|
|
91
|
+
print("\n".join(prompts))
|
|
92
|
+
PY
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: spareparts-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: sp — the Spare Parts command line
|
|
5
|
+
Author: Spare Parts Labs
|
|
6
|
+
License: UNLICENSED
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: pyyaml>=6.0
|
|
9
|
+
Provides-Extra: all
|
|
10
|
+
Requires-Dist: anthropic>=0.72; extra == 'all'
|
|
11
|
+
Requires-Dist: google-genai>=2.0; extra == 'all'
|
|
12
|
+
Requires-Dist: openai>=2.0; extra == 'all'
|
|
13
|
+
Provides-Extra: anthropic
|
|
14
|
+
Requires-Dist: anthropic>=0.72; extra == 'anthropic'
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: anthropic>=0.72; extra == 'dev'
|
|
17
|
+
Requires-Dist: google-genai>=2.0; extra == 'dev'
|
|
18
|
+
Requires-Dist: openai>=2.0; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
20
|
+
Provides-Extra: gemini
|
|
21
|
+
Requires-Dist: google-genai>=2.0; extra == 'gemini'
|
|
22
|
+
Provides-Extra: openai
|
|
23
|
+
Requires-Dist: openai>=2.0; extra == 'openai'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# sp
|
|
27
|
+
|
|
28
|
+
The Spare Parts command line.
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
pip install ".[anthropic]" # or [openai], or [gemini], or [all]
|
|
32
|
+
sp
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Modules
|
|
36
|
+
|
|
37
|
+
| Module | What it does |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `sp lgtm` | Prove you read a diff before you merge it |
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## `sp lgtm`
|
|
44
|
+
|
|
45
|
+
Generates a few multiple-choice questions about a range of commits and asks
|
|
46
|
+
them, in your terminal. The questions are about what the change *does* — what a
|
|
47
|
+
new guard prevents, what the error path now returns, which edit can touch
|
|
48
|
+
existing rows — never about statistics, naming, or formatting.
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
sp lgtm # what this branch adds since it left main
|
|
52
|
+
sp lgtm main...feature/x # someone else's branch, before you merge it
|
|
53
|
+
sp lgtm -n 3 -d hard
|
|
54
|
+
sp lgtm --dry-run # what it would ask about, no model call
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Generation is three model calls, so expect it to take a moment — this is a thing
|
|
58
|
+
you run before a merge, not on every commit.
|
|
59
|
+
|
|
60
|
+
### Providers
|
|
61
|
+
|
|
62
|
+
Anthropic, OpenAI and Gemini, and no vendor is the assumed one. Install the SDK
|
|
63
|
+
for whichever you use:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
pip install ".[anthropic]" # or [openai], or [gemini], or [all]
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Name nobody and `sp` uses whichever key you have set. With more than one set it
|
|
70
|
+
picks in the order below, which is a tie-break rather than a ranking; name
|
|
71
|
+
`provider:` in `.github/lgtm.yml` to decide it yourself.
|
|
72
|
+
|
|
73
|
+
| Vendor | Key | Default model | Typical run |
|
|
74
|
+
|---|---|---|---|
|
|
75
|
+
| `anthropic` | `ANTHROPIC_API_KEY` | `claude-opus-5` | ~55s |
|
|
76
|
+
| `openai` | `OPENAI_API_KEY` | `gpt-5.5` | ~65s |
|
|
77
|
+
| `gemini` | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | `gemini-pro-latest` | ~115s |
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
sp lgtm -p openai
|
|
81
|
+
sp lgtm -p gemini --model gemini-3.6-flash
|
|
82
|
+
sp lgtm -p anthropic --verifier openai # see below
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
All three are exercised against live APIs, on a 32KB diff, and the timings above
|
|
86
|
+
are from those runs.
|
|
87
|
+
|
|
88
|
+
The defaults were chosen by listing each vendor's models with a live key. Gemini
|
|
89
|
+
uses the tracking alias because the vendor currently publishes no plain
|
|
90
|
+
`gemini-3.x-pro` — only `-image` variants — so pinning a pro model would mean
|
|
91
|
+
pinning to 2.5 indefinitely. The other two pin, because a default that changes
|
|
92
|
+
underneath a quiz changes what the quiz asks.
|
|
93
|
+
|
|
94
|
+
### Two vendors are better than one
|
|
95
|
+
|
|
96
|
+
`sp lgtm` writes a question with one call and then asks a second call to refute
|
|
97
|
+
it. A candidate that can't be refuted survives; everything else is dropped.
|
|
98
|
+
|
|
99
|
+
Both calls going to the same model is the weak version of that check — a model
|
|
100
|
+
asked to find fault with its own reasoning mostly doesn't. If you have keys for
|
|
101
|
+
two vendors, split them:
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
sp lgtm --provider anthropic --verifier openai
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
That is the strongest arrangement available, and it is why the provider layer
|
|
108
|
+
exists rather than a bare `--model` flag. It costs one extra vendor's tokens and
|
|
109
|
+
nothing else — the verifier sees the question and the diff, never the
|
|
110
|
+
proposer's reasoning.
|
|
111
|
+
|
|
112
|
+
It also costs no extra wall-clock in practice: proposing and verifying are
|
|
113
|
+
sequential either way, and the cross-vendor run above came in at 68s against
|
|
114
|
+
55s for Anthropic alone.
|
|
115
|
+
|
|
116
|
+
### It is a self-check, not a gate
|
|
117
|
+
|
|
118
|
+
`sp lgtm` has no way to stop you doing anything. The answers live in the same
|
|
119
|
+
process as the questions, on your machine, and you can skip the whole thing.
|
|
120
|
+
That is on purpose: the version that actually gates is the
|
|
121
|
+
[GitHub Action](https://github.com/sparepartslabs/spareparts-lgtm), which asks
|
|
122
|
+
the *reviewer* after they approve and holds a check run open until they answer.
|
|
123
|
+
|
|
124
|
+
What local gets in exchange is the thing the Action can't have: the code is
|
|
125
|
+
checked out. Press `?` on any question to print the hunk it came from. Wrong
|
|
126
|
+
answers are never a failure — you get told which files to look at again, and it
|
|
127
|
+
re-asks, as many times as you like.
|
|
128
|
+
|
|
129
|
+
### Configuration
|
|
130
|
+
|
|
131
|
+
Reads `.github/lgtm.yml` from the repo you're in, the same file the Action uses,
|
|
132
|
+
so a repo is configured once:
|
|
133
|
+
|
|
134
|
+
```yaml
|
|
135
|
+
questions: 2 # 1-5
|
|
136
|
+
difficulty: medium # easy | medium | hard
|
|
137
|
+
provider: anthropic # anthropic | openai | gemini, or vendor:model
|
|
138
|
+
verifier: openai # optional; defaults to the proposer
|
|
139
|
+
exemptPaths:
|
|
140
|
+
- "docs/**"
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`provider` and `verifier` are read here but validated by the provider layer, so
|
|
144
|
+
a typo is reported with the list of known vendors rather than silently ignored.
|
|
145
|
+
|
|
146
|
+
Keys that only mean something to the Action (`enforce`, `webConcepts`,
|
|
147
|
+
`surfaceReading`, `answerQuestions`, `exemptReviewers`) are accepted and
|
|
148
|
+
ignored. `-n` and `-d` override the file.
|
|
149
|
+
|
|
150
|
+
Lockfiles, `dist/`, `vendor/`, `*.pbxproj` and friends are never quizzed.
|
|
151
|
+
|
|
152
|
+
### As a git hook
|
|
153
|
+
|
|
154
|
+
```sh
|
|
155
|
+
sp lgtm install # pre-push, advisory
|
|
156
|
+
sp lgtm install --hook pre-commit # earlier, and once per commit
|
|
157
|
+
sp lgtm install --blocking # wrong answers stop the push
|
|
158
|
+
sp lgtm uninstall
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
**`pre-push` is the default.** The old framing for this tool was "the person
|
|
162
|
+
answering didn't write the code" — that's why it quizzes a *reviewer*. That
|
|
163
|
+
framing is dated: when a model wrote the diff, nobody in the loop wrote it, and
|
|
164
|
+
the author is as much a reader as anyone. The useful question isn't who typed
|
|
165
|
+
it, it's where the last cheap moment to catch it is — and that's before the code
|
|
166
|
+
leaves your machine, which is left of anything the Action can do.
|
|
167
|
+
|
|
168
|
+
It also matches how the cost lands. Generation is three model calls and about a
|
|
169
|
+
minute; per *push* that's fine, per *commit* it taxes every checkpoint you save.
|
|
170
|
+
`--hook pre-commit` is there if you want it, and quizzes the staged diff.
|
|
171
|
+
|
|
172
|
+
**Advisory by default.** A hook that costs a minute *and* can stop you is one
|
|
173
|
+
you delete within a week. `--blocking` is opt-in, and even then only a wrong
|
|
174
|
+
answer (exit 1) blocks — "couldn't ask" (exit 2: no API key, vendor outage,
|
|
175
|
+
nothing quizzable) never costs you a push.
|
|
176
|
+
|
|
177
|
+
Escape hatches, in the order you'll want them:
|
|
178
|
+
|
|
179
|
+
```sh
|
|
180
|
+
SP_LGTM_SKIP=1 git push ... # skip this once
|
|
181
|
+
git push --no-verify ... # skip every hook
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
#### What it reads
|
|
185
|
+
|
|
186
|
+
`pre-push` quizzes exactly what you're about to push. Git names the refs on
|
|
187
|
+
stdin, so the hook takes `<remote sha>..<local sha>` — the commits the remote
|
|
188
|
+
doesn't have yet. A branch the remote has never seen has no such range, so it
|
|
189
|
+
falls back to what the branch adds since it left the default branch. A push that
|
|
190
|
+
only *deletes* a remote branch reads nothing at all.
|
|
191
|
+
|
|
192
|
+
That ref list arrives on the same stdin the quiz needs for answers, so the hook
|
|
193
|
+
consumes it before attaching `/dev/tty`.
|
|
194
|
+
|
|
195
|
+
It skips itself silently when there's no terminal — a rebase, a GUI client, CI.
|
|
196
|
+
Git runs hooks with stdin closed; where there's no tty, nobody can answer and
|
|
197
|
+
nobody failed.
|
|
198
|
+
|
|
199
|
+
`sp lgtm install` refuses to overwrite a hook it didn't write (`--force`
|
|
200
|
+
overrides), and honours `core.hooksPath` — writing to an assumed `.git/hooks`
|
|
201
|
+
when that's set installs a hook that never runs, which looks exactly like
|
|
202
|
+
success.
|
|
203
|
+
|
|
204
|
+
### Exit codes
|
|
205
|
+
|
|
206
|
+
| Code | Meaning |
|
|
207
|
+
|---|---|
|
|
208
|
+
| 0 | Confirmed |
|
|
209
|
+
| 1 | Not confirmed — wrong answers, or you quit |
|
|
210
|
+
| 2 | Couldn't ask — no API key, git failed, nothing quizzable |
|
|
211
|
+
|
|
212
|
+
If you wire this into a git hook, treat only `1` as a failure. A tool that
|
|
213
|
+
cannot run must not block a commit.
|
|
214
|
+
|
|
215
|
+
## Development
|
|
216
|
+
|
|
217
|
+
```sh
|
|
218
|
+
python3 -m venv .venv && .venv/bin/pip install -e ".[dev]"
|
|
219
|
+
.venv/bin/python -m pytest
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
`lgtm`'s diff parsing, config, screening and generator are a port of the
|
|
223
|
+
TypeScript in
|
|
224
|
+
[`spareparts-lgtm`](https://github.com/sparepartslabs/spareparts-lgtm). The
|
|
225
|
+
prompts in `generator.py` are the part worth keeping identical — changing the
|
|
226
|
+
wording here without changing it there produces two tools that disagree about
|
|
227
|
+
the same diff.
|