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.
Files changed (79) hide show
  1. pr_context_engine-0.1.4/.claude/commands/publish-pypi.md +58 -0
  2. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/.github/workflows/pr-review.yml +4 -2
  3. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/CHANGELOG.md +20 -0
  4. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/PKG-INFO +42 -12
  5. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/README.md +41 -11
  6. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/action.yml +28 -5
  7. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/pyproject.toml +1 -1
  8. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/analyzers/risk_scorer.py +47 -1
  9. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/briefing/prompt_templates.py +6 -0
  10. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/github_api/comment_poster.py +18 -2
  11. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/unit/test_risk_scorer.py +103 -0
  12. pr_context_engine-0.1.2/PROJECT.md +0 -481
  13. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/.env.example +0 -0
  14. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  15. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  16. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/.github/pull_request_template.md +0 -0
  17. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/.github/workflows/release.yml +0 -0
  18. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/.gitignore +0 -0
  19. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/.python-version +0 -0
  20. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/CODE_OF_CONDUCT.md +0 -0
  21. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/CONFIG.md +0 -0
  22. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/CONTRIBUTING.md +0 -0
  23. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/LICENSE +0 -0
  24. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/docs/architecture.md +0 -0
  25. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/docs/design-decisions.md +0 -0
  26. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/__init__.py +0 -0
  27. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/analyzers/__init__.py +0 -0
  28. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/analyzers/ast_walker.py +0 -0
  29. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/analyzers/diff_parser.py +0 -0
  30. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/briefing/__init__.py +0 -0
  31. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/briefing/generator.py +0 -0
  32. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/cli.py +0 -0
  33. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/config.py +0 -0
  34. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/context/__init__.py +0 -0
  35. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/context/codebase_index.py +0 -0
  36. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/context/git_history.py +0 -0
  37. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/fixes/__init__.py +0 -0
  38. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/fixes/confidence.py +0 -0
  39. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/fixes/fix_generator.py +0 -0
  40. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/github_api/__init__.py +0 -0
  41. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/llm/__init__.py +0 -0
  42. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/llm/anthropic_provider.py +0 -0
  43. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/llm/base.py +0 -0
  44. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/llm/gemini_provider.py +0 -0
  45. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/llm/groq_provider.py +0 -0
  46. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/src/llm/ollama_provider.py +0 -0
  47. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/__init__.py +0 -0
  48. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/__init__.py +0 -0
  49. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/fixtures/01-simple-refactor.json +0 -0
  50. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/fixtures/02-auth-middleware.json +0 -0
  51. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/fixtures/03-db-migration.json +0 -0
  52. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/fixtures/04-config-update.json +0 -0
  53. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/fixtures/05-public-api-deleted.json +0 -0
  54. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/fixtures/06-hardcoded-api-key.json +0 -0
  55. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/fixtures/07-token-in-url.json +0 -0
  56. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/fixtures/08-retry-no-limit.json +0 -0
  57. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/fixtures/09-missing-null-check.json +0 -0
  58. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/fixtures/10-trivial-docfix.json +0 -0
  59. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/fixtures/11-multi-flag.json +0 -0
  60. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/fixtures/12-new-endpoint.json +0 -0
  61. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/fixtures/13-auth-bypass.json +0 -0
  62. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/fixtures/14-env-file-update.json +0 -0
  63. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/fixtures/15-dependency-update.json +0 -0
  64. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/rubric.md +0 -0
  65. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/eval/test_briefings.py +0 -0
  66. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/unit/__init__.py +0 -0
  67. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/unit/test_anthropic_provider.py +0 -0
  68. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/unit/test_ast_walker.py +0 -0
  69. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/unit/test_briefing_generator.py +0 -0
  70. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/unit/test_codebase_index.py +0 -0
  71. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/unit/test_config.py +0 -0
  72. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/unit/test_diff_parser.py +0 -0
  73. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/unit/test_failover_provider.py +0 -0
  74. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/unit/test_fix_generator.py +0 -0
  75. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/unit/test_gemini_provider.py +0 -0
  76. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/unit/test_git_history.py +0 -0
  77. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/unit/test_groq_provider.py +0 -0
  78. {pr_context_engine-0.1.2 → pr_context_engine-0.1.4}/tests/unit/test_ollama_provider.py +0 -0
  79. {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: groq
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.2
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. Get a free [Groq API key](https://console.groq.com/keys) no credit card.
65
- 2. Add it as a secret: **Settings → Secrets → Actions → New secret** → `GROQ_API_KEY`.
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
- Set `LLM_PROVIDER` to any of `groq` (default), `gemini`, `ollama`, or `anthropic`. Nothing downstream changes.
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
- | Provider | Key env var | Notes |
167
- |---|---|---|
168
- | `groq` *(default)* | `GROQ_API_KEY` | Free, ~1 000 req/day, fast |
169
- | `gemini` | `GEMINI_API_KEY` | Free-tier fallback; auto-engaged on Groq 429 |
170
- | `ollama` | — | Local, offline, no rate limits |
171
- | `anthropic` | `ANTHROPIC_API_KEY` | BYO key, no free tier |
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, the tool fails over to Gemini on any Groq 429 or error and logs which provider was used in the PR comment footer. See [ADR-7](docs/design-decisions.md#adr-7-provider-failover-order-and-motivation).
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. Get a free [Groq API key](https://console.groq.com/keys) no credit card.
35
- 2. Add it as a secret: **Settings → Secrets → Actions → New secret** → `GROQ_API_KEY`.
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
- Set `LLM_PROVIDER` to any of `groq` (default), `gemini`, `ollama`, or `anthropic`. Nothing downstream changes.
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
- | Provider | Key env var | Notes |
137
- |---|---|---|
138
- | `groq` *(default)* | `GROQ_API_KEY` | Free, ~1 000 req/day, fast |
139
- | `gemini` | `GEMINI_API_KEY` | Free-tier fallback; auto-engaged on Groq 429 |
140
- | `ollama` | — | Local, offline, no rate limits |
141
- | `anthropic` | `ANTHROPIC_API_KEY` | BYO key, no free tier |
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, the tool fails over to Gemini on any Groq 429 or error and logs which provider was used in the PR comment footer. See [ADR-7](docs/design-decisions.md#adr-7-provider-failover-order-and-motivation).
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). Required unless gemini-api-key is set.
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. Used as failover when Groq is rate-limited.
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: GitHub token with pull-requests:write. Defaults to the built-in GITHUB_TOKEN.
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: Primary LLM provider — groq | gemini | ollama | anthropic (default groq).
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
- GITHUB_TOKEN: ${{ inputs.github-token }}
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: >
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pr-context-engine"
3
- version = "0.1.2"
3
+ version = "0.1.4"
4
4
  description = "An AI tool that reads every PR and posts a senior-engineer-style briefing."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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
- pull_request = gh.get_repo(repo).get_pull(pr_number)
57
- pull_request.create_issue_comment(body)
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]))