Skip to content

Commit e4ba4c0

Browse files
authored
fix: properly check write access for fine-grained PATs (#4)
* fix: update repo references from jwm4 to ambient-code Update remaining references to the old jwm4 repo: - review_roadmap/main.py: footer link in generated roadmaps - tests/test_main.py: test assertion for footer URL - ADR/phase4_plan.md: pip install example URL * fix: properly check write access for fine-grained PATs The write access check was incorrectly passing for tokens that lacked write permissions because it only checked the repository permissions object, which reflects user access rather than token-specific access. Changes: - Add WriteAccessStatus enum and WriteAccessResult model for detailed access check results (GRANTED, DENIED, UNCERTAIN) - For classic PATs: verify OAuth scopes via X-OAuth-Scopes header - For fine-grained PATs: perform live write test using reactions (create and immediately delete an 'eyes' reaction on the PR) - Improve error messages with troubleshooting guidance - Add comprehensive GitHub token setup documentation to README The live write test allows definitive verification for fine-grained PATs, which cannot be checked via headers since they don't include scope info and the permissions object reflects user access, not token configuration.
1 parent 6f6b27f commit e4ba4c0

File tree

6 files changed

+502
-30
lines changed

6 files changed

+502
-30
lines changed

README.md

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ cp env.example .env
3737

3838
| Variable | Description |
3939
|----------|-------------|
40-
| `GITHUB_TOKEN` | GitHub personal access token for API access |
40+
| `GITHUB_TOKEN` | GitHub personal access token for API access ([see below](#github-token-setup)) |
4141
| `ANTHROPIC_API_KEY` | Anthropic API key (required if provider is `anthropic`) |
4242
| `OPENAI_API_KEY` | OpenAI API key (required if provider is `openai`) |
4343
| `GOOGLE_API_KEY` | Google API key (required if provider is `google`) |
@@ -50,6 +50,56 @@ cp env.example .env
5050

5151
> **Note**: The `.env` file values are overridden by any matching environment variables in your shell. If you do not want to have a .env file, you can just skip these steps and set these variables in your environment instead.
5252
53+
### GitHub Token Setup
54+
55+
A GitHub personal access token (PAT) is required to fetch PR data. The type of token and permissions needed depend on your use case:
56+
57+
#### Read-Only Access (default)
58+
59+
For generating roadmaps without posting comments, a token with **read access to repositories** is sufficient.
60+
61+
#### Write Access (for `--post` flag)
62+
63+
To post roadmaps as PR comments using the `--post` flag, your token needs **write access**. The specific requirements differ by token type:
64+
65+
| Token Type | Required Permissions |
66+
|------------|---------------------|
67+
| **Fine-grained PAT** | Repository access → Pull requests: **Read and write** |
68+
| **Classic PAT** | `repo` scope (full repo access) or `public_repo` (public repos only) |
69+
70+
#### Creating a Fine-Grained Personal Access Token (Recommended)
71+
72+
Fine-grained tokens are more secure because they limit access to specific repositories.
73+
74+
1. Go to [GitHub Settings → Developer settings → Personal access tokens → Fine-grained tokens](https://github.com/settings/tokens?type=beta)
75+
2. Click **"Generate new token"**
76+
3. Configure the token:
77+
- **Token name**: e.g., `review-roadmap`
78+
- **Expiration**: Choose an appropriate duration
79+
- **Repository access**: Select "Only select repositories" and choose the repos you want to analyze
80+
- **Permissions**:
81+
- **Pull requests**: Read-only (for basic usage) or Read and write (for `--post`)
82+
- **Contents**: Read-only (required to fetch file contents for context expansion)
83+
4. Click **"Generate token"** and copy the token value
84+
85+
#### Creating a Classic Personal Access Token
86+
87+
Classic tokens are simpler but grant broader access.
88+
89+
1. Go to [GitHub Settings → Developer settings → Personal access tokens → Tokens (classic)](https://github.com/settings/tokens)
90+
2. Click **"Generate new token"****"Generate new token (classic)"**
91+
3. Configure the token:
92+
- **Note**: e.g., `review-roadmap`
93+
- **Expiration**: Choose an appropriate duration
94+
- **Scopes**:
95+
- `repo` — Full access (works for both public and private repos)
96+
- Or `public_repo` — Only for public repositories
97+
4. Click **"Generate token"** and copy the token value
98+
99+
#### Important: Token Scopes vs. User Permissions
100+
101+
Even if you have collaborator access to a repository, your token must also have the correct **OAuth scopes** to perform write operations. A common mistake is using a token with read-only scopes while expecting write access to work based on your user permissions. The tool checks both your repository permissions and your token's scopes before attempting to post comments.
102+
53103
## Usage
54104

55105
Run the tool using the CLI:
@@ -69,7 +119,7 @@ review_roadmap {PR link in the form owner/repo/pr_number or just a URL to the PR
69119

70120
You can use both `-o` and `-p` together—the roadmap will be generated once and saved to both the file and the PR comment.
71121

72-
> **Note:** To use `--post`, your `GITHUB_TOKEN` must have write access to the repository. For fine-grained personal access tokens, ensure the **"Pull requests"** permission is set to **"Read and write"**. For classic tokens, the `repo` scope is required.
122+
> **Note:** The `--post` flag requires a token with write access. See [GitHub Token Setup](#github-token-setup) for details.
73123
74124
### Examples
75125

review_roadmap/github/client.py

Lines changed: 119 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
import httpx
1111

1212
from review_roadmap.config import settings
13-
from review_roadmap.models import PRContext, PRMetadata, FileDiff, PRComment
13+
from review_roadmap.models import (
14+
PRContext, PRMetadata, FileDiff, PRComment,
15+
WriteAccessResult, WriteAccessStatus,
16+
)
1417

1518

1619
class GitHubClient:
@@ -220,17 +223,60 @@ def get_file_content(self, owner: str, repo: str, path: str, ref: str) -> str:
220223
raw_resp.raise_for_status()
221224
return raw_resp.text
222225

223-
def check_write_access(self, owner: str, repo: str) -> bool:
224-
"""Check if the authenticated user has write access to the repository.
226+
def _test_write_with_reaction(self, owner: str, repo: str, pr_number: int) -> bool:
227+
"""Test write access by creating and immediately deleting a reaction.
225228
226-
Used to validate permissions before attempting to post a PR comment.
229+
This is a non-destructive way to verify the token can write to the PR.
230+
Uses the 'eyes' emoji (👀) as it's unobtrusive if cleanup fails.
227231
228232
Args:
229233
owner: Repository owner.
230234
repo: Repository name.
235+
pr_number: PR number to test against.
231236
232237
Returns:
233-
True if the user has push or admin access, False otherwise.
238+
True if write access is confirmed, False otherwise.
239+
"""
240+
try:
241+
# Create a reaction on the PR (PRs are issues in GitHub's API)
242+
create_resp = self.client.post(
243+
f"/repos/{owner}/{repo}/issues/{pr_number}/reactions",
244+
json={"content": "eyes"}
245+
)
246+
247+
if create_resp.status_code in (200, 201):
248+
# Successfully created - now clean it up
249+
reaction_id = create_resp.json().get("id")
250+
if reaction_id:
251+
self.client.delete(
252+
f"/repos/{owner}/{repo}/issues/{pr_number}/reactions/{reaction_id}"
253+
)
254+
return True
255+
elif create_resp.status_code == 403:
256+
return False
257+
else:
258+
# Unexpected status - treat as uncertain
259+
return False
260+
except Exception:
261+
return False
262+
263+
def check_write_access(
264+
self, owner: str, repo: str, pr_number: Optional[int] = None
265+
) -> WriteAccessResult:
266+
"""Check if the authenticated token can write to the repository.
267+
268+
Validates:
269+
1. The user has push/admin permissions on the repository
270+
2. The token has the required OAuth scopes (for classic PATs/OAuth tokens)
271+
3. For fine-grained PATs: performs a live test using reactions if pr_number provided
272+
273+
Args:
274+
owner: Repository owner.
275+
repo: Repository name.
276+
pr_number: Optional PR number for live write test (used for fine-grained PATs).
277+
278+
Returns:
279+
WriteAccessResult with status, confidence level, and explanation.
234280
235281
Raises:
236282
httpx.HTTPStatusError: If the repository request fails.
@@ -239,8 +285,75 @@ def check_write_access(self, owner: str, repo: str) -> bool:
239285
resp.raise_for_status()
240286
repo_data = resp.json()
241287

288+
# Check user's role-based permissions
242289
permissions = repo_data.get("permissions", {})
243-
return permissions.get("push", False) or permissions.get("admin", False)
290+
has_role_access = permissions.get("push", False) or permissions.get("admin", False)
291+
292+
if not has_role_access:
293+
return WriteAccessResult(
294+
status=WriteAccessStatus.DENIED,
295+
is_fine_grained_pat=False,
296+
message="Your user account does not have write access to this repository."
297+
)
298+
299+
# Check token's OAuth scopes (only present for classic PATs and OAuth tokens)
300+
oauth_scopes_header = resp.headers.get("X-OAuth-Scopes")
301+
302+
if oauth_scopes_header is not None:
303+
# Classic PAT or OAuth token - can verify scopes definitively
304+
scopes = [s.strip() for s in oauth_scopes_header.split(",") if s.strip()]
305+
is_private = repo_data.get("private", False)
306+
307+
if is_private:
308+
has_token_scope = "repo" in scopes
309+
required_scope = "repo"
310+
else:
311+
has_token_scope = "repo" in scopes or "public_repo" in scopes
312+
required_scope = "repo or public_repo"
313+
314+
if has_token_scope:
315+
return WriteAccessResult(
316+
status=WriteAccessStatus.GRANTED,
317+
is_fine_grained_pat=False,
318+
message="Classic token with correct scopes verified."
319+
)
320+
else:
321+
return WriteAccessResult(
322+
status=WriteAccessStatus.DENIED,
323+
is_fine_grained_pat=False,
324+
message=f"Token lacks required scope ({required_scope}). Current scopes: {', '.join(scopes) or 'none'}"
325+
)
326+
327+
# No X-OAuth-Scopes header = fine-grained PAT
328+
# Try a live write test if we have a PR number
329+
if pr_number is not None:
330+
if self._test_write_with_reaction(owner, repo, pr_number):
331+
return WriteAccessResult(
332+
status=WriteAccessStatus.GRANTED,
333+
is_fine_grained_pat=True,
334+
message="Fine-grained PAT write access verified via live test."
335+
)
336+
else:
337+
return WriteAccessResult(
338+
status=WriteAccessStatus.DENIED,
339+
is_fine_grained_pat=True,
340+
message=(
341+
"Fine-grained PAT detected but write test failed. "
342+
f"Ensure your token has 'Pull requests: Read and write' permission "
343+
f"for {owner}/{repo}."
344+
)
345+
)
346+
347+
# No PR number provided - can't do live test
348+
return WriteAccessResult(
349+
status=WriteAccessStatus.UNCERTAIN,
350+
is_fine_grained_pat=True,
351+
message=(
352+
"Fine-grained PAT detected. Cannot verify if this token is configured "
353+
f"for {owner}/{repo}. If posting fails, ensure your token has "
354+
"'Pull requests: Read and write' permission for this specific repository."
355+
)
356+
)
244357

245358
def post_pr_comment(self, owner: str, repo: str, pr_number: int, body: str) -> Dict[str, Any]:
246359
"""Post a comment on a pull request.

review_roadmap/main.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from review_roadmap.agent.graph import build_graph
66
from review_roadmap.config import settings
77
from review_roadmap.logging import configure_logging
8+
from review_roadmap.models import WriteAccessStatus
89

910
app = typer.Typer(add_completion=False)
1011
console = Console()
@@ -61,14 +62,20 @@ def generate(
6162
if post:
6263
console.print(f"[bold blue]Checking write access for {owner}/{repo}...[/bold blue]")
6364
try:
64-
has_access = gh_client.check_write_access(owner, repo)
65-
if not has_access:
65+
# Pass pr_number to enable live write test for fine-grained PATs
66+
access_result = gh_client.check_write_access(owner, repo, pr_number)
67+
68+
if access_result.status == WriteAccessStatus.DENIED:
6669
console.print(
67-
"[red]Error: Your GitHub token does not have write access to this repository.[/red]\n"
70+
f"[red]Error: {access_result.message}[/red]\n"
6871
"[yellow]To use --post, your token needs 'Pull requests: Read and write' permission.[/yellow]"
6972
)
7073
raise typer.Exit(code=1)
71-
console.print("[green]Write access confirmed.[/green]")
74+
elif access_result.status == WriteAccessStatus.UNCERTAIN:
75+
console.print(f"[yellow]Warning: {access_result.message}[/yellow]")
76+
console.print("[yellow]Proceeding, but posting may fail...[/yellow]")
77+
else:
78+
console.print("[green]Write access confirmed.[/green]")
7279
except typer.Exit:
7380
raise
7481
except Exception as e:
@@ -108,6 +115,12 @@ def generate(
108115
console.print(f"[bold green]Roadmap posted to PR #{pr_number}[/bold green]")
109116
except Exception as e:
110117
console.print(f"[red]Error posting comment to PR: {e}[/red]")
118+
if "403" in str(e):
119+
console.print(
120+
"\n[yellow]This usually means your token lacks write access to this repository.[/yellow]\n"
121+
"[yellow]If using a fine-grained PAT, ensure it is configured for this specific repository[/yellow]\n"
122+
f"[yellow]with 'Pull requests: Read and write' permission for {owner}/{repo}.[/yellow]"
123+
)
111124
raise typer.Exit(code=1)
112125

113126
# If neither output nor post, print to console

review_roadmap/models.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,38 @@
44
fetched from GitHub's API, including PR metadata, file diffs, and comments.
55
"""
66

7+
from enum import Enum
78
from typing import List, Optional
89
from pydantic import BaseModel, Field
910

1011

12+
class WriteAccessStatus(str, Enum):
13+
"""Result of checking write access to a repository."""
14+
15+
GRANTED = "granted"
16+
"""Write access confirmed with high confidence (classic PAT with correct scopes)."""
17+
18+
DENIED = "denied"
19+
"""Write access definitely not available (missing permissions or scopes)."""
20+
21+
UNCERTAIN = "uncertain"
22+
"""Cannot reliably determine access (fine-grained PAT - may or may not work)."""
23+
24+
25+
class WriteAccessResult(BaseModel):
26+
"""Result of a write access check.
27+
28+
Attributes:
29+
status: The access status (granted, denied, or uncertain).
30+
is_fine_grained_pat: True if the token appears to be a fine-grained PAT.
31+
message: Human-readable explanation of the result.
32+
"""
33+
34+
status: WriteAccessStatus
35+
is_fine_grained_pat: bool = False
36+
message: str = ""
37+
38+
1139
class FileDiff(BaseModel):
1240
"""Represents a single file change in a Pull Request.
1341

0 commit comments

Comments
 (0)