pr-context-engine 0.1.2__tar.gz → 0.1.4__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.
- pr_context_engine-0.1.4/.claude/commands/publish-pypi.md +58 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/.github/workflows/pr-review.yml +4 -2
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/CHANGELOG.md +20 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/PKG-INFO +42 -12
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/README.md +41 -11
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/action.yml +28 -5
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/pyproject.toml +1 -1
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/analyzers/risk_scorer.py +47 -1
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/briefing/prompt_templates.py +6 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/github_api/comment_poster.py +18 -2
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/unit/test_risk_scorer.py +103 -0
- pr_context_engine-0.1.2/PROJECT.md +0 -481
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/.env.example +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/.github/pull_request_template.md +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/.github/workflows/release.yml +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/.gitignore +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/.python-version +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/CODE_OF_CONDUCT.md +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/CONFIG.md +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/CONTRIBUTING.md +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/LICENSE +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/docs/architecture.md +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/docs/design-decisions.md +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/__init__.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/analyzers/__init__.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/analyzers/ast_walker.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/analyzers/diff_parser.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/briefing/__init__.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/briefing/generator.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/cli.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/config.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/context/__init__.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/context/codebase_index.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/context/git_history.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/fixes/__init__.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/fixes/confidence.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/fixes/fix_generator.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/github_api/__init__.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/llm/__init__.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/llm/anthropic_provider.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/llm/base.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/llm/gemini_provider.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/llm/groq_provider.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/llm/ollama_provider.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/__init__.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/__init__.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/fixtures/01-simple-refactor.json +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/fixtures/02-auth-middleware.json +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/fixtures/03-db-migration.json +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/fixtures/04-config-update.json +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/fixtures/05-public-api-deleted.json +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/fixtures/06-hardcoded-api-key.json +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/fixtures/07-token-in-url.json +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/fixtures/08-retry-no-limit.json +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/fixtures/09-missing-null-check.json +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/fixtures/10-trivial-docfix.json +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/fixtures/11-multi-flag.json +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/fixtures/12-new-endpoint.json +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/fixtures/13-auth-bypass.json +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/fixtures/14-env-file-update.json +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/fixtures/15-dependency-update.json +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/rubric.md +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/test_briefings.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/unit/__init__.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/unit/test_anthropic_provider.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/unit/test_ast_walker.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/unit/test_briefing_generator.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/unit/test_codebase_index.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/unit/test_config.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/unit/test_diff_parser.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/unit/test_failover_provider.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/unit/test_fix_generator.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/unit/test_gemini_provider.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/unit/test_git_history.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/unit/test_groq_provider.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/unit/test_ollama_provider.py +0 -0
- {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/uv.lock +0 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Publish a new release to PyPI — bumps version, updates CHANGELOG, commits, tags, and merges to main. Usage: /publish-pypi [patch|minor|major] (default: patch)
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Publish a new version of pr-context-engine to PyPI using the project's GitHub Actions release flow.
|
|
6
|
+
|
|
7
|
+
**Bump type**: $ARGUMENTS (default to `patch` if empty)
|
|
8
|
+
|
|
9
|
+
Follow these steps exactly, in order:
|
|
10
|
+
|
|
11
|
+
## 1. Read current version
|
|
12
|
+
Read `pyproject.toml` and extract the current `version` field.
|
|
13
|
+
|
|
14
|
+
## 2. Compute new version
|
|
15
|
+
Apply the bump type to the current version (semver):
|
|
16
|
+
- `patch` — increment the third number (0.1.2 → 0.1.3)
|
|
17
|
+
- `minor` — increment the second number, reset patch (0.1.2 → 0.2.0)
|
|
18
|
+
- `major` — increment the first number, reset minor and patch (0.1.2 → 1.0.0)
|
|
19
|
+
|
|
20
|
+
## 3. Update pyproject.toml
|
|
21
|
+
Edit the `version` field in `pyproject.toml` to the new version.
|
|
22
|
+
|
|
23
|
+
## 4. Update CHANGELOG.md
|
|
24
|
+
Read `CHANGELOG.md`. Under `## Unreleased`, look at what's there.
|
|
25
|
+
- If `## Unreleased` has content, move it into a new dated section `## X.Y.Z — YYYY-MM-DD` (use today's date) inserted below `## Unreleased`.
|
|
26
|
+
- If `## Unreleased` is empty, create the new section anyway and populate it by summarising the commits since the last tag: run `git log $(git describe --tags --abbrev=0)..HEAD --oneline` to get them, then write a short changelog entry under `### Fixed`, `### Added`, or `### Changed` as appropriate.
|
|
27
|
+
- Leave `## Unreleased` as an empty section above the new version section.
|
|
28
|
+
|
|
29
|
+
## 5. Commit the version bump
|
|
30
|
+
Stage only `pyproject.toml` and `CHANGELOG.md`, then commit with message:
|
|
31
|
+
`chore: bump version to X.Y.Z`
|
|
32
|
+
|
|
33
|
+
Do NOT commit anything else.
|
|
34
|
+
|
|
35
|
+
## 6. Push the current branch
|
|
36
|
+
Run `git push origin <current-branch>`.
|
|
37
|
+
|
|
38
|
+
## 7. Tag and push the tag
|
|
39
|
+
```
|
|
40
|
+
git tag vX.Y.Z
|
|
41
|
+
git push origin vX.Y.Z
|
|
42
|
+
```
|
|
43
|
+
This triggers the `release.yml` GitHub Actions workflow, which builds and publishes to PyPI via OIDC trusted publishing — no credentials needed.
|
|
44
|
+
|
|
45
|
+
## 8. Merge to main
|
|
46
|
+
```
|
|
47
|
+
git checkout main
|
|
48
|
+
git merge <previous-branch> --ff-only
|
|
49
|
+
git push origin main
|
|
50
|
+
git checkout <previous-branch>
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## 9. Confirm
|
|
54
|
+
Report:
|
|
55
|
+
- New version published: `X.Y.Z`
|
|
56
|
+
- Tag pushed: `vX.Y.Z`
|
|
57
|
+
- GitHub Actions release workflow URL: `https://github.com/paramahastha/pr-context-engine/actions`
|
|
58
|
+
- PyPI package URL: `https://pypi.org/project/pr-context-engine/X.Y.Z/`
|
|
@@ -21,6 +21,8 @@ jobs:
|
|
|
21
21
|
|
|
22
22
|
brief:
|
|
23
23
|
needs: lint
|
|
24
|
+
# Fork PRs get a read-only GITHUB_TOKEN regardless of `permissions:` — skip rather than 401.
|
|
25
|
+
if: github.event.pull_request.head.repo.full_name == github.repository
|
|
24
26
|
runs-on: ubuntu-latest
|
|
25
27
|
permissions:
|
|
26
28
|
pull-requests: write
|
|
@@ -56,9 +58,9 @@ jobs:
|
|
|
56
58
|
- name: Generate briefing
|
|
57
59
|
env:
|
|
58
60
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
59
|
-
LLM_PROVIDER:
|
|
61
|
+
LLM_PROVIDER: gemini
|
|
62
|
+
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
|
60
63
|
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
|
61
|
-
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} # fallback (Milestone 7)
|
|
62
64
|
run: >
|
|
63
65
|
uv run pr-context-engine review
|
|
64
66
|
--pr ${{ github.event.pull_request.number }}
|
|
@@ -6,6 +6,26 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Thi
|
|
|
6
6
|
|
|
7
7
|
## Unreleased
|
|
8
8
|
|
|
9
|
+
## 0.1.4 — 2026-05-23
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **Risk scorer false positives** — Comment lines (including `#no-space` Python comments) and bare type/struct/class declarations are now skipped before the auth-keyword check, eliminating spurious `modifies_auth` flags on struct definitions and comment blocks.
|
|
14
|
+
- **Auth flag noise** — `modifies_auth` is now deduplicated to one hit per file; large new store files no longer flood the briefing with dozens of identical flags.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **`large_new_file` flag** — New files with 300+ added lines are flagged for explicit review.
|
|
19
|
+
- **Briefing prompt improvements** — LLM is instructed to cover all change threads (not just the largest file), name changed type signatures explicitly, and avoid asking questions whose answers are already visible in the diff.
|
|
20
|
+
|
|
21
|
+
## 0.1.3 — 2026-05-23
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **GitHub Action 401 error** — `action.yml` now falls back to `github.token` when the `github-token` input is not explicitly passed, preventing Bad credentials errors in consumer workflows.
|
|
26
|
+
- **Error messages** — `post_pr_comment` now catches `GithubException` 401/403 and raises a `RuntimeError` with an actionable message (missing `permissions: pull-requests: write` vs. invalid token) instead of a raw PyGithub traceback.
|
|
27
|
+
- **Fork PR 401** — `pr-review.yml` skips the `brief` job for fork PRs; GitHub's security model forces a read-only token on `pull_request` events from forks regardless of the `permissions:` block.
|
|
28
|
+
|
|
9
29
|
## 0.1.2 — 2026-05-23
|
|
10
30
|
|
|
11
31
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pr-context-engine
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: An AI tool that reads every PR and posts a senior-engineer-style briefing.
|
|
5
5
|
Project-URL: Homepage, https://github.com/paramahastha/pr-context-engine
|
|
6
6
|
Project-URL: Repository, https://github.com/paramahastha/pr-context-engine
|
|
@@ -61,8 +61,8 @@ pr-context-engine quickstart # checks keys, scopes, prints what's missin
|
|
|
61
61
|
|
|
62
62
|
### Option A — GitHub Action (recommended)
|
|
63
63
|
|
|
64
|
-
1.
|
|
65
|
-
2. Add it as a secret: **Settings → Secrets → Actions → New secret
|
|
64
|
+
1. Pick a provider and get an API key (see table below).
|
|
65
|
+
2. Add it as a secret: **Settings → Secrets → Actions → New secret**.
|
|
66
66
|
3. Enable write permissions: **Settings → Actions → General → Workflow permissions → Read and write**.
|
|
67
67
|
4. Add this to `.github/workflows/pr-briefing.yml`:
|
|
68
68
|
|
|
@@ -80,11 +80,13 @@ jobs:
|
|
|
80
80
|
steps:
|
|
81
81
|
- uses: paramahastha/pr-context-engine@main
|
|
82
82
|
with:
|
|
83
|
-
groq-api-key: ${{ secrets.GROQ_API_KEY }}
|
|
83
|
+
groq-api-key: ${{ secrets.GROQ_API_KEY }} # default provider
|
|
84
84
|
```
|
|
85
85
|
|
|
86
86
|
That's it. Every new PR gets a briefing comment automatically.
|
|
87
87
|
|
|
88
|
+
> **Using a different provider?** Set `llm-provider` to match your key — see [Switching LLM providers](#switching-llm-providers) below.
|
|
89
|
+
|
|
88
90
|
### Option B — CLI (any CI or local)
|
|
89
91
|
|
|
90
92
|
```bash
|
|
@@ -161,16 +163,44 @@ See [docs/architecture.md](docs/architecture.md) for the full Mermaid diagram an
|
|
|
161
163
|
|
|
162
164
|
## Switching LLM providers
|
|
163
165
|
|
|
164
|
-
|
|
166
|
+
| Provider | Secret name | `llm-provider` value | Notes |
|
|
167
|
+
|---|---|---|---|
|
|
168
|
+
| `groq` *(default)* | `GROQ_API_KEY` | `groq` | Free, ~1 000 req/day, fast |
|
|
169
|
+
| `gemini` | `GEMINI_API_KEY` | `gemini` | Free-tier, ~1 500 req/day |
|
|
170
|
+
| `anthropic` | `ANTHROPIC_API_KEY` | `anthropic` | BYO key, no free tier |
|
|
171
|
+
| `ollama` | — | `ollama` | Local, offline, no rate limits |
|
|
172
|
+
|
|
173
|
+
**You must set both `llm-provider` and the matching API key input.** Providing only the key without `llm-provider` will fail because the default provider is `groq`.
|
|
174
|
+
|
|
175
|
+
**GitHub Action examples:**
|
|
165
176
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
177
|
+
```yaml
|
|
178
|
+
# Gemini
|
|
179
|
+
- uses: paramahastha/pr-context-engine@main
|
|
180
|
+
with:
|
|
181
|
+
llm-provider: gemini
|
|
182
|
+
gemini-api-key: ${{ secrets.GEMINI_API_KEY }}
|
|
183
|
+
|
|
184
|
+
# Anthropic
|
|
185
|
+
- uses: paramahastha/pr-context-engine@main
|
|
186
|
+
with:
|
|
187
|
+
llm-provider: anthropic
|
|
188
|
+
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
189
|
+
|
|
190
|
+
# Ollama (self-hosted)
|
|
191
|
+
- uses: paramahastha/pr-context-engine@main
|
|
192
|
+
with:
|
|
193
|
+
llm-provider: ollama
|
|
194
|
+
ollama-base-url: http://my-ollama-host:11434
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
**CLI / env var:**
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
LLM_PROVIDER=gemini GEMINI_API_KEY=<key> pr-context-engine review --pr 42 --repo owner/name
|
|
201
|
+
```
|
|
172
202
|
|
|
173
|
-
**Automatic failover:** if `GEMINI_API_KEY` is set,
|
|
203
|
+
**Automatic failover:** if `GEMINI_API_KEY` is set alongside any other provider, Gemini is used as a fallback on rate-limit errors. The PR comment footer shows which provider was actually used. See [ADR-7](docs/design-decisions.md#adr-7-provider-failover-order-and-motivation).
|
|
174
204
|
|
|
175
205
|
## Fix suggestions (opt-in)
|
|
176
206
|
|
|
@@ -31,8 +31,8 @@ pr-context-engine quickstart # checks keys, scopes, prints what's missin
|
|
|
31
31
|
|
|
32
32
|
### Option A — GitHub Action (recommended)
|
|
33
33
|
|
|
34
|
-
1.
|
|
35
|
-
2. Add it as a secret: **Settings → Secrets → Actions → New secret
|
|
34
|
+
1. Pick a provider and get an API key (see table below).
|
|
35
|
+
2. Add it as a secret: **Settings → Secrets → Actions → New secret**.
|
|
36
36
|
3. Enable write permissions: **Settings → Actions → General → Workflow permissions → Read and write**.
|
|
37
37
|
4. Add this to `.github/workflows/pr-briefing.yml`:
|
|
38
38
|
|
|
@@ -50,11 +50,13 @@ jobs:
|
|
|
50
50
|
steps:
|
|
51
51
|
- uses: paramahastha/pr-context-engine@main
|
|
52
52
|
with:
|
|
53
|
-
groq-api-key: ${{ secrets.GROQ_API_KEY }}
|
|
53
|
+
groq-api-key: ${{ secrets.GROQ_API_KEY }} # default provider
|
|
54
54
|
```
|
|
55
55
|
|
|
56
56
|
That's it. Every new PR gets a briefing comment automatically.
|
|
57
57
|
|
|
58
|
+
> **Using a different provider?** Set `llm-provider` to match your key — see [Switching LLM providers](#switching-llm-providers) below.
|
|
59
|
+
|
|
58
60
|
### Option B — CLI (any CI or local)
|
|
59
61
|
|
|
60
62
|
```bash
|
|
@@ -131,16 +133,44 @@ See [docs/architecture.md](docs/architecture.md) for the full Mermaid diagram an
|
|
|
131
133
|
|
|
132
134
|
## Switching LLM providers
|
|
133
135
|
|
|
134
|
-
|
|
136
|
+
| Provider | Secret name | `llm-provider` value | Notes |
|
|
137
|
+
|---|---|---|---|
|
|
138
|
+
| `groq` *(default)* | `GROQ_API_KEY` | `groq` | Free, ~1 000 req/day, fast |
|
|
139
|
+
| `gemini` | `GEMINI_API_KEY` | `gemini` | Free-tier, ~1 500 req/day |
|
|
140
|
+
| `anthropic` | `ANTHROPIC_API_KEY` | `anthropic` | BYO key, no free tier |
|
|
141
|
+
| `ollama` | — | `ollama` | Local, offline, no rate limits |
|
|
142
|
+
|
|
143
|
+
**You must set both `llm-provider` and the matching API key input.** Providing only the key without `llm-provider` will fail because the default provider is `groq`.
|
|
144
|
+
|
|
145
|
+
**GitHub Action examples:**
|
|
135
146
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
147
|
+
```yaml
|
|
148
|
+
# Gemini
|
|
149
|
+
- uses: paramahastha/pr-context-engine@main
|
|
150
|
+
with:
|
|
151
|
+
llm-provider: gemini
|
|
152
|
+
gemini-api-key: ${{ secrets.GEMINI_API_KEY }}
|
|
153
|
+
|
|
154
|
+
# Anthropic
|
|
155
|
+
- uses: paramahastha/pr-context-engine@main
|
|
156
|
+
with:
|
|
157
|
+
llm-provider: anthropic
|
|
158
|
+
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
159
|
+
|
|
160
|
+
# Ollama (self-hosted)
|
|
161
|
+
- uses: paramahastha/pr-context-engine@main
|
|
162
|
+
with:
|
|
163
|
+
llm-provider: ollama
|
|
164
|
+
ollama-base-url: http://my-ollama-host:11434
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
**CLI / env var:**
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
LLM_PROVIDER=gemini GEMINI_API_KEY=<key> pr-context-engine review --pr 42 --repo owner/name
|
|
171
|
+
```
|
|
142
172
|
|
|
143
|
-
**Automatic failover:** if `GEMINI_API_KEY` is set,
|
|
173
|
+
**Automatic failover:** if `GEMINI_API_KEY` is set alongside any other provider, Gemini is used as a fallback on rate-limit errors. The PR comment footer shows which provider was actually used. See [ADR-7](docs/design-decisions.md#adr-7-provider-failover-order-and-motivation).
|
|
144
174
|
|
|
145
175
|
## Fix suggestions (opt-in)
|
|
146
176
|
|
|
@@ -4,13 +4,27 @@ author: paramahastha
|
|
|
4
4
|
|
|
5
5
|
inputs:
|
|
6
6
|
groq-api-key:
|
|
7
|
-
description: Groq API key (free at https://console.groq.com/keys).
|
|
7
|
+
description: Groq API key (free at https://console.groq.com/keys).
|
|
8
8
|
required: false
|
|
9
9
|
gemini-api-key:
|
|
10
|
-
description: Google Gemini API key
|
|
10
|
+
description: Google Gemini API key (https://aistudio.google.com/apikey).
|
|
11
11
|
required: false
|
|
12
|
+
anthropic-api-key:
|
|
13
|
+
description: Anthropic API key. Required when llm-provider=anthropic.
|
|
14
|
+
required: false
|
|
15
|
+
ollama-base-url:
|
|
16
|
+
description: Ollama server URL. Used when llm-provider=ollama.
|
|
17
|
+
required: false
|
|
18
|
+
default: "http://localhost:11434"
|
|
19
|
+
ollama-model:
|
|
20
|
+
description: Ollama model name. Used when llm-provider=ollama.
|
|
21
|
+
required: false
|
|
22
|
+
default: "qwen2.5-coder:7b"
|
|
12
23
|
github-token:
|
|
13
|
-
description:
|
|
24
|
+
description: >
|
|
25
|
+
GitHub token with pull-requests:write permission. Defaults to the built-in GITHUB_TOKEN.
|
|
26
|
+
Your calling workflow MUST include `permissions: pull-requests: write` or the comment
|
|
27
|
+
post will fail with a 401/403 error.
|
|
14
28
|
required: false
|
|
15
29
|
default: ${{ github.token }}
|
|
16
30
|
enable-fixes:
|
|
@@ -18,7 +32,10 @@ inputs:
|
|
|
18
32
|
required: false
|
|
19
33
|
default: "false"
|
|
20
34
|
llm-provider:
|
|
21
|
-
description:
|
|
35
|
+
description: >
|
|
36
|
+
Primary LLM provider — groq | gemini | anthropic | ollama (default: groq).
|
|
37
|
+
Must match the API key you provide: set gemini + gemini-api-key,
|
|
38
|
+
anthropic + anthropic-api-key, etc.
|
|
22
39
|
required: false
|
|
23
40
|
default: "groq"
|
|
24
41
|
|
|
@@ -53,9 +70,15 @@ runs:
|
|
|
53
70
|
- name: Generate briefing
|
|
54
71
|
shell: bash
|
|
55
72
|
env:
|
|
56
|
-
|
|
73
|
+
# Prefer the explicit input; fall back to the calling workflow's built-in token.
|
|
74
|
+
# The calling workflow MUST grant `permissions: pull-requests: write` for
|
|
75
|
+
# the token to have enough scope to post a comment.
|
|
76
|
+
GITHUB_TOKEN: ${{ inputs.github-token || github.token }}
|
|
57
77
|
GROQ_API_KEY: ${{ inputs.groq-api-key }}
|
|
58
78
|
GEMINI_API_KEY: ${{ inputs.gemini-api-key }}
|
|
79
|
+
ANTHROPIC_API_KEY: ${{ inputs.anthropic-api-key }}
|
|
80
|
+
OLLAMA_BASE_URL: ${{ inputs.ollama-base-url }}
|
|
81
|
+
OLLAMA_MODEL: ${{ inputs.ollama-model }}
|
|
59
82
|
LLM_PROVIDER: ${{ inputs.llm-provider }}
|
|
60
83
|
ENABLE_FIXES: ${{ inputs.enable-fixes }}
|
|
61
84
|
run: >
|
|
@@ -27,6 +27,18 @@ _FUNC_DEF_RE = re.compile(
|
|
|
27
27
|
|
|
28
28
|
_MIGRATION_MARKERS = ("migrations/", "alembic/", "alembic_migrations/")
|
|
29
29
|
|
|
30
|
+
# Lines that look auth-related by keyword but carry no real risk:
|
|
31
|
+
# - comment lines (// # /* * <!--)
|
|
32
|
+
# - bare type/struct/class/interface declarations
|
|
33
|
+
_COMMENT_LINE_RE = re.compile(r"^\s*(?://|/\*|\*+/?\s|#|<!--)")
|
|
34
|
+
_TYPE_DECL_RE = re.compile(
|
|
35
|
+
r"^\s*type\s+\w+\s+(?:struct|interface)\b" # Go struct/interface
|
|
36
|
+
r"|^\s*type\s+\w+\s*(?:=|\b(?:int|string|float|bool)\b)" # Go type alias/definition
|
|
37
|
+
r"|^\s*(?:class|interface)\s+\w+" # Python / JS / TS
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
_LARGE_NEW_FILE_THRESHOLD = 300 # added lines; new files above this warrant explicit review
|
|
41
|
+
|
|
30
42
|
|
|
31
43
|
@dataclass
|
|
32
44
|
class RiskFlag:
|
|
@@ -64,6 +76,26 @@ def _is_config(path: str) -> bool:
|
|
|
64
76
|
return False
|
|
65
77
|
|
|
66
78
|
|
|
79
|
+
def _dedup_flags(flags: list[RiskFlag]) -> list[RiskFlag]:
|
|
80
|
+
"""Deduplicate flags where one occurrence per file is enough.
|
|
81
|
+
|
|
82
|
+
modifies_auth fires once per matching line and can produce dozens of hits
|
|
83
|
+
in a large file (e.g. a new store implementation). Keeping only the first
|
|
84
|
+
match per file preserves the signal while eliminating fix-suggestion spam.
|
|
85
|
+
Other flag types are kept as-is because each occurrence is independently
|
|
86
|
+
meaningful (e.g. multiple deletes_public_api deletions).
|
|
87
|
+
"""
|
|
88
|
+
seen_auth_files: set[str] = set()
|
|
89
|
+
result: list[RiskFlag] = []
|
|
90
|
+
for flag in flags:
|
|
91
|
+
if flag.flag == "modifies_auth":
|
|
92
|
+
if flag.file in seen_auth_files:
|
|
93
|
+
continue
|
|
94
|
+
seen_auth_files.add(flag.file)
|
|
95
|
+
result.append(flag)
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
|
|
67
99
|
def score(changes: list[FileChange]) -> list[RiskFlag]:
|
|
68
100
|
"""Return all risk flags detected across the list of file changes."""
|
|
69
101
|
flags: list[RiskFlag] = []
|
|
@@ -79,6 +111,16 @@ def score(changes: list[FileChange]) -> list[RiskFlag]:
|
|
|
79
111
|
RiskFlag(flag="changes_config", file=change.path, line=None, snippet=change.path)
|
|
80
112
|
)
|
|
81
113
|
|
|
114
|
+
if change.is_new_file and len(change.added_lines) >= _LARGE_NEW_FILE_THRESHOLD:
|
|
115
|
+
flags.append(
|
|
116
|
+
RiskFlag(
|
|
117
|
+
flag="large_new_file",
|
|
118
|
+
file=change.path,
|
|
119
|
+
line=None,
|
|
120
|
+
snippet=f"{len(change.added_lines)} lines added — review for correctness and completeness",
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
|
|
82
124
|
for hunk in change.hunks:
|
|
83
125
|
new_lineno = hunk.new_start
|
|
84
126
|
old_lineno = hunk.old_start
|
|
@@ -86,6 +128,10 @@ def score(changes: list[FileChange]) -> list[RiskFlag]:
|
|
|
86
128
|
for raw in hunk.lines:
|
|
87
129
|
if raw.startswith("+") and not raw.startswith("+++"):
|
|
88
130
|
content = raw[1:]
|
|
131
|
+
stripped = content.strip()
|
|
132
|
+
if _COMMENT_LINE_RE.match(stripped) or _TYPE_DECL_RE.match(stripped):
|
|
133
|
+
new_lineno += 1
|
|
134
|
+
continue
|
|
89
135
|
if _AUTH_RE.search(content):
|
|
90
136
|
flags.append(
|
|
91
137
|
RiskFlag(
|
|
@@ -118,4 +164,4 @@ def score(changes: list[FileChange]) -> list[RiskFlag]:
|
|
|
118
164
|
new_lineno += 1
|
|
119
165
|
old_lineno += 1
|
|
120
166
|
|
|
121
|
-
return flags
|
|
167
|
+
return _dedup_flags(flags)
|
|
@@ -18,9 +18,13 @@ Produce a briefing with exactly four sections:
|
|
|
18
18
|
|
|
19
19
|
1. WHAT CHANGED — 2-3 sentences. Describe the *intent* of the change, not the
|
|
20
20
|
lines. Do not list files. If you can't tell the intent, say so.
|
|
21
|
+
If the PR has multiple distinct threads (e.g. both new infrastructure and
|
|
22
|
+
model/API changes), cover all threads — do not describe only the largest file.
|
|
21
23
|
|
|
22
24
|
2. BLAST RADIUS — Which callers, services, contracts, or data could break?
|
|
23
25
|
Be specific. If the change is internal and self-contained, write "Self-contained."
|
|
26
|
+
If any public-facing struct field or type signature changed, name it explicitly
|
|
27
|
+
(e.g. "Snapshot.Configs changed from map[string]Config to map[string]ConfigEntry").
|
|
24
28
|
|
|
25
29
|
3. RISK FLAGS — Bullet list. Only include flags that are actually present.
|
|
26
30
|
If none, write "None."
|
|
@@ -29,6 +33,8 @@ Produce a briefing with exactly four sections:
|
|
|
29
33
|
approving. Questions must be answerable and specific. Bad question:
|
|
30
34
|
"Did you test this?" Good question: "The new retry loop in fetch_user
|
|
31
35
|
has no backoff — is that intentional given this is called per-request?"
|
|
36
|
+
Do NOT ask questions whose answer is already visible in the diff (e.g. do
|
|
37
|
+
not ask about WAL mode if the schema already sets PRAGMA journal_mode=WAL).
|
|
32
38
|
|
|
33
39
|
Rules:
|
|
34
40
|
- Be terse. Aim for under 200 words total.
|
|
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING
|
|
|
11
11
|
|
|
12
12
|
import requests
|
|
13
13
|
from github import Auth, Github
|
|
14
|
+
from github import GithubException
|
|
14
15
|
|
|
15
16
|
from src.github_api import GITHUB_API_URL
|
|
16
17
|
|
|
@@ -53,8 +54,23 @@ def post_pr_comment(repo: str, pr_number: int, body: str, github_token: str) ->
|
|
|
53
54
|
"""
|
|
54
55
|
logger.info("Posting comment to %s PR #%d", repo, pr_number)
|
|
55
56
|
gh = Github(auth=Auth.Token(github_token))
|
|
56
|
-
|
|
57
|
-
|
|
57
|
+
try:
|
|
58
|
+
pull_request = gh.get_repo(repo).get_pull(pr_number)
|
|
59
|
+
pull_request.create_issue_comment(body)
|
|
60
|
+
except GithubException as exc:
|
|
61
|
+
if exc.status == 401:
|
|
62
|
+
raise RuntimeError(
|
|
63
|
+
"GitHub returned 401 Bad credentials. "
|
|
64
|
+
"Ensure your workflow grants `permissions: pull-requests: write` "
|
|
65
|
+
"and that GITHUB_TOKEN (or github-token input) is a valid token."
|
|
66
|
+
) from exc
|
|
67
|
+
if exc.status == 403:
|
|
68
|
+
raise RuntimeError(
|
|
69
|
+
"GitHub returned 403 Forbidden. "
|
|
70
|
+
"The token lacks `pull-requests: write` permission. "
|
|
71
|
+
"Add `permissions: pull-requests: write` to the calling workflow job."
|
|
72
|
+
) from exc
|
|
73
|
+
raise
|
|
58
74
|
|
|
59
75
|
|
|
60
76
|
def format_fix_section(suggestions: list[FixSuggestion], extra_count: int = 0) -> str:
|
|
@@ -4,6 +4,20 @@ from src.analyzers.diff_parser import FileChange, Hunk
|
|
|
4
4
|
from src.analyzers.risk_scorer import score
|
|
5
5
|
|
|
6
6
|
|
|
7
|
+
def _new_file_change(path: str, num_lines: int) -> FileChange:
|
|
8
|
+
lines = [f"+ x_{i} = {i}" for i in range(num_lines)]
|
|
9
|
+
h = Hunk(old_start=0, old_count=0, new_start=1, new_count=num_lines, lines=lines)
|
|
10
|
+
added = [ln[1:] for ln in lines]
|
|
11
|
+
return FileChange(
|
|
12
|
+
path=path,
|
|
13
|
+
language="python",
|
|
14
|
+
added_lines=added,
|
|
15
|
+
removed_lines=[],
|
|
16
|
+
hunks=[h],
|
|
17
|
+
is_new_file=True,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
7
21
|
def _hunk(old_start: int, new_start: int, lines: list[str]) -> Hunk:
|
|
8
22
|
added = sum(1 for ln in lines if ln.startswith("+") and not ln.startswith("+++"))
|
|
9
23
|
removed = sum(1 for ln in lines if ln.startswith("-") and not ln.startswith("---"))
|
|
@@ -176,3 +190,92 @@ def test_no_flags_for_plain_change():
|
|
|
176
190
|
c = _change("src/constants.py", [h])
|
|
177
191
|
flags = score([c])
|
|
178
192
|
assert flags == []
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ── comment and type-decl false-positive suppression ─────────────────────────
|
|
196
|
+
|
|
197
|
+
def test_python_comment_without_space_not_flagged():
|
|
198
|
+
# Previously `#\s` required a space, so `#token` slipped through.
|
|
199
|
+
h = _hunk(1, 1, ['+#auth_token = request.headers.get("X-Token")'])
|
|
200
|
+
c = _change("src/auth.py", [h])
|
|
201
|
+
assert not any(f.flag == "modifies_auth" for f in score([c]))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_python_comment_with_space_not_flagged():
|
|
205
|
+
h = _hunk(1, 1, ['+# store the auth token here'])
|
|
206
|
+
c = _change("src/auth.py", [h])
|
|
207
|
+
assert not any(f.flag == "modifies_auth" for f in score([c]))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_go_line_comment_not_flagged():
|
|
211
|
+
h = _hunk(1, 1, ['+// token is validated upstream'])
|
|
212
|
+
c = _change("pkg/handler.go", [h], language="go")
|
|
213
|
+
assert not any(f.flag == "modifies_auth" for f in score([c]))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_go_struct_decl_not_flagged():
|
|
217
|
+
h = _hunk(1, 1, ['+type AuthStore struct {'])
|
|
218
|
+
c = _change("pkg/store.go", [h], language="go")
|
|
219
|
+
assert not any(f.flag == "modifies_auth" for f in score([c]))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_python_class_decl_not_flagged():
|
|
223
|
+
h = _hunk(1, 1, ['+class AuthManager:'])
|
|
224
|
+
c = _change("src/auth.py", [h])
|
|
225
|
+
assert not any(f.flag == "modifies_auth" for f in score([c]))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_python_class_with_base_not_flagged():
|
|
229
|
+
h = _hunk(1, 1, ['+class AuthManager(BaseManager):'])
|
|
230
|
+
c = _change("src/auth.py", [h])
|
|
231
|
+
assert not any(f.flag == "modifies_auth" for f in score([c]))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ── modifies_auth deduplication ───────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
def test_auth_deduped_within_file():
|
|
237
|
+
lines = ['+ token_a = get_token()', '+ token_b = get_token()', '+ password = "secret"']
|
|
238
|
+
h = _hunk(1, 1, lines)
|
|
239
|
+
c = _change("src/auth.py", [h])
|
|
240
|
+
auth_flags = [f for f in score([c]) if f.flag == "modifies_auth"]
|
|
241
|
+
assert len(auth_flags) == 1
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_auth_not_deduped_across_files():
|
|
245
|
+
h = _hunk(1, 1, ['+ token = get_token()'])
|
|
246
|
+
c1 = _change("src/auth.py", [h])
|
|
247
|
+
c2 = _change("src/user.py", [h])
|
|
248
|
+
auth_flags = [f for f in score([c1, c2]) if f.flag == "modifies_auth"]
|
|
249
|
+
assert len(auth_flags) == 2
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def test_auth_dedup_preserves_first_match_line():
|
|
253
|
+
lines = ['+ password_a = "x"', '+ password_b = "y"']
|
|
254
|
+
h = _hunk(10, 10, lines)
|
|
255
|
+
c = _change("src/auth.py", [h])
|
|
256
|
+
auth_flags = [f for f in score([c]) if f.flag == "modifies_auth"]
|
|
257
|
+
assert auth_flags[0].line == 10
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ── large_new_file ────────────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
def test_large_new_file_flagged():
|
|
263
|
+
c = _new_file_change("src/new_store.py", 300)
|
|
264
|
+
assert any(f.flag == "large_new_file" for f in score([c]))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def test_boundary_new_file_flagged():
|
|
268
|
+
c = _new_file_change("src/new_store.py", 301)
|
|
269
|
+
assert any(f.flag == "large_new_file" for f in score([c]))
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_small_new_file_not_flagged():
|
|
273
|
+
c = _new_file_change("src/tiny.py", 50)
|
|
274
|
+
assert not any(f.flag == "large_new_file" for f in score([c]))
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def test_large_existing_file_not_flagged():
|
|
278
|
+
lines = [f"+ x_{i} = {i}" for i in range(300)]
|
|
279
|
+
h = _hunk(1, 1, lines)
|
|
280
|
+
c = _change("src/existing.py", [h])
|
|
281
|
+
assert not any(f.flag == "large_new_file" for f in score([c]))
|