Skip to content

Commit 2e6a8e6

Browse files
authored
feat: add multi-token support for fine-grained PATs (#11)
* feat: add multi-token support for fine-grained PATs Add REVIEW_ROADMAP_GITHUB_TOKENS environment variable that accepts a comma-separated list of GitHub tokens. When --post is used, each token is tested for write access until one with the correct permissions is found. This is useful when working with multiple repositories that each have their own fine-grained PAT, eliminating the need to change .env when switching between projects. Changes: - config.py: Add REVIEW_ROADMAP_GITHUB_TOKENS setting with helper methods - client.py: Add find_working_token() function to search for working token - main.py: Use multi-token search when --post flag is provided - env.example: Document new environment variable - tests: Add comprehensive tests for new functionality * fix: use monkeypatch in config tests to isolate from CI env vars The CI environment sets GITHUB_TOKEN as an env var, which pydantic-settings picks up even with _env_file=None. Use monkeypatch.delenv() to ensure tests are isolated from any environment variables.
1 parent 194ca5f commit 2e6a8e6

File tree

7 files changed

+568
-65
lines changed

7 files changed

+568
-65
lines changed

env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
# Note: If you have these environment variables set in your own environment, the ones in your environment will take precedence over any you put here.
22

33
# GitHub Configuration
4+
# Option 1: Single token (simplest)
45
GITHUB_TOKEN=your_github_token_here
56

7+
# Option 2: Multiple tokens (for working across multiple projects with fine-grained PATs)
8+
# Comma-separated list of tokens. When --post is used, each token is tested for write access
9+
# and the first one that works is used. This is useful if you have separate fine-grained PATs
10+
# for different repositories. Takes precedence over GITHUB_TOKEN for write operations.
11+
# REVIEW_ROADMAP_GITHUB_TOKENS=ghp_token1,ghp_token2,ghp_token3
12+
613
# Application Settings
714

815
REVIEW_ROADMAP_LOG_LEVEL=INFO

review_roadmap/config.py

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77

88
import os
99
from pathlib import Path
10-
from typing import Optional
10+
from typing import List, Optional
11+
from pydantic import model_validator
1112
from pydantic_settings import BaseSettings, SettingsConfigDict
1213

1314

@@ -21,6 +22,9 @@ class Settings(BaseSettings):
2122
2223
Attributes:
2324
GITHUB_TOKEN: GitHub API token for fetching PR data.
25+
REVIEW_ROADMAP_GITHUB_TOKENS: Comma-separated list of GitHub tokens.
26+
When set, these are tried in order during write access checks.
27+
Takes precedence over GITHUB_TOKEN for write operations.
2428
REVIEW_ROADMAP_LLM_PROVIDER: LLM provider to use. Options:
2529
'anthropic', 'anthropic-vertex', 'openai', 'google'.
2630
REVIEW_ROADMAP_MODEL_NAME: Model name (e.g., 'claude-opus-4-5', 'gpt-4o').
@@ -39,7 +43,60 @@ class Settings(BaseSettings):
3943
)
4044

4145
# GitHub
42-
GITHUB_TOKEN: str
46+
GITHUB_TOKEN: Optional[str] = None
47+
REVIEW_ROADMAP_GITHUB_TOKENS: Optional[str] = None
48+
49+
@model_validator(mode="after")
50+
def validate_github_token(self) -> "Settings":
51+
"""Ensure at least one GitHub token is configured."""
52+
if not self.GITHUB_TOKEN and not self.REVIEW_ROADMAP_GITHUB_TOKENS:
53+
raise ValueError(
54+
"Either GITHUB_TOKEN or REVIEW_ROADMAP_GITHUB_TOKENS must be set"
55+
)
56+
return self
57+
58+
def get_github_tokens(self) -> List[str]:
59+
"""Get the list of GitHub tokens to try, in order of precedence.
60+
61+
When REVIEW_ROADMAP_GITHUB_TOKENS is set, those tokens take precedence
62+
and are returned first. GITHUB_TOKEN (if set and not already in the list)
63+
is appended as a fallback.
64+
65+
Returns:
66+
List of unique, non-empty GitHub tokens to try.
67+
"""
68+
tokens: List[str] = []
69+
70+
# REVIEW_ROADMAP_GITHUB_TOKENS takes precedence
71+
if self.REVIEW_ROADMAP_GITHUB_TOKENS:
72+
tokens.extend(
73+
t.strip()
74+
for t in self.REVIEW_ROADMAP_GITHUB_TOKENS.split(",")
75+
if t.strip()
76+
)
77+
78+
# Add GITHUB_TOKEN as fallback if not already included
79+
if self.GITHUB_TOKEN and self.GITHUB_TOKEN not in tokens:
80+
tokens.append(self.GITHUB_TOKEN)
81+
82+
return tokens
83+
84+
def get_default_github_token(self) -> str:
85+
"""Get the default GitHub token for read operations.
86+
87+
Returns the first available token (REVIEW_ROADMAP_GITHUB_TOKENS
88+
takes precedence over GITHUB_TOKEN).
89+
90+
Returns:
91+
The first available GitHub token.
92+
93+
Raises:
94+
ValueError: If no tokens are configured.
95+
"""
96+
tokens = self.get_github_tokens()
97+
if not tokens:
98+
raise ValueError("No GitHub tokens configured")
99+
return tokens[0]
43100

44101
# LLM Configuration (prefixed to avoid conflicts with shell environment)
45102
REVIEW_ROADMAP_LLM_PROVIDER: str = "anthropic"

review_roadmap/github/client.py

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
generate review roadmaps.
66
"""
77

8+
from dataclasses import dataclass
89
from typing import Any, Dict, List, Optional, Tuple
910

1011
import httpx
@@ -16,6 +17,21 @@
1617
)
1718

1819

20+
@dataclass
21+
class TokenSearchResult:
22+
"""Result of searching for a token with write access.
23+
24+
Attributes:
25+
token: The token that was found with write access, or None if no token worked.
26+
access_result: The WriteAccessResult from checking the successful token,
27+
or the last failed result if no token worked.
28+
tokens_tried: Number of tokens that were tested.
29+
"""
30+
token: Optional[str]
31+
access_result: WriteAccessResult
32+
tokens_tried: int
33+
34+
1935
class GitHubClient:
2036
"""Synchronous GitHub API client for PR data retrieval.
2137
@@ -37,9 +53,10 @@ def __init__(self, token: Optional[str] = None):
3753
"""Initialize the GitHub client.
3854
3955
Args:
40-
token: GitHub API token. If not provided, uses GITHUB_TOKEN from settings.
56+
token: GitHub API token. If not provided, uses the first available
57+
token from settings (REVIEW_ROADMAP_GITHUB_TOKENS takes precedence).
4158
"""
42-
self.token = token or settings.GITHUB_TOKEN
59+
self.token = token or settings.get_default_github_token()
4360
self.headers = {
4461
"Authorization": f"Bearer {self.token}",
4562
"Accept": "application/vnd.github.v3+json",
@@ -471,3 +488,79 @@ def post_pr_comment(self, owner: str, repo: str, pr_number: int, body: str) -> D
471488
)
472489
resp.raise_for_status()
473490
return resp.json()
491+
492+
493+
def find_working_token(
494+
owner: str, repo: str, pr_number: int
495+
) -> TokenSearchResult:
496+
"""Find a GitHub token with write access from the configured tokens.
497+
498+
Iterates through all configured tokens (from REVIEW_ROADMAP_GITHUB_TOKENS
499+
and GITHUB_TOKEN) and tests each one for write access using the
500+
check_write_access method with a live reaction test.
501+
502+
Args:
503+
owner: Repository owner.
504+
repo: Repository name.
505+
pr_number: PR number for live write testing.
506+
507+
Returns:
508+
TokenSearchResult containing:
509+
- token: The first token with GRANTED status, or None if none worked
510+
- access_result: The WriteAccessResult from the successful check,
511+
or the last failed result if no token worked
512+
- tokens_tried: Number of tokens that were tested
513+
514+
Example:
515+
>>> result = find_working_token("owner", "repo", 123)
516+
>>> if result.token:
517+
... client = GitHubClient(token=result.token)
518+
... client.post_pr_comment(owner, repo, 123, "Hello!")
519+
"""
520+
tokens = settings.get_github_tokens()
521+
522+
if not tokens:
523+
return TokenSearchResult(
524+
token=None,
525+
access_result=WriteAccessResult(
526+
status=WriteAccessStatus.DENIED,
527+
is_fine_grained_pat=False,
528+
message="No GitHub tokens configured."
529+
),
530+
tokens_tried=0
531+
)
532+
533+
last_result: Optional[WriteAccessResult] = None
534+
535+
for i, token in enumerate(tokens, start=1):
536+
client = GitHubClient(token=token)
537+
try:
538+
result = client.check_write_access(owner, repo, pr_number)
539+
last_result = result
540+
541+
if result.status == WriteAccessStatus.GRANTED:
542+
return TokenSearchResult(
543+
token=token,
544+
access_result=result,
545+
tokens_tried=i
546+
)
547+
except Exception:
548+
# Token failed to even check access (e.g., network error, invalid token)
549+
# Continue to next token
550+
last_result = WriteAccessResult(
551+
status=WriteAccessStatus.DENIED,
552+
is_fine_grained_pat=False,
553+
message=f"Token failed basic validation (may be invalid or revoked)."
554+
)
555+
continue
556+
557+
# No token worked - return the last result
558+
return TokenSearchResult(
559+
token=None,
560+
access_result=last_result or WriteAccessResult(
561+
status=WriteAccessStatus.DENIED,
562+
is_fine_grained_pat=False,
563+
message="No tokens were successfully tested."
564+
),
565+
tokens_tried=len(tokens)
566+
)

review_roadmap/main.py

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import typer
22
from rich.console import Console
33
from rich.markdown import Markdown
4-
from review_roadmap.github.client import GitHubClient
4+
from review_roadmap.github.client import GitHubClient, find_working_token
55
from review_roadmap.agent.graph import build_graph
66
from review_roadmap.config import settings
77
from review_roadmap.logging import configure_logging
@@ -59,27 +59,49 @@ def generate(
5959
console.print("[red]Invalid PR format. Use 'owner/repo/number' or a full URL.[/red]")
6060
raise typer.Exit(code=1)
6161

62-
# Initialize GitHub client
62+
# Initialize GitHub client (default token for read operations)
6363
gh_client = GitHubClient()
64+
65+
# Token with write access (may differ from default if using multi-token)
66+
write_token = None
6467

6568
# Check write access early if posting is requested (fail fast before LLM generation)
6669
if post:
67-
console.print(f"[bold blue]Checking write access for {owner}/{repo}...[/bold blue]")
70+
tokens = settings.get_github_tokens()
71+
72+
if len(tokens) > 1:
73+
console.print(f"[bold blue]Searching {len(tokens)} tokens for write access to {owner}/{repo}...[/bold blue]")
74+
else:
75+
console.print(f"[bold blue]Checking write access for {owner}/{repo}...[/bold blue]")
76+
6877
try:
69-
# Pass pr_number to enable live write test for fine-grained PATs
70-
access_result = gh_client.check_write_access(owner, repo, pr_number)
78+
# Search through available tokens for one with write access
79+
search_result = find_working_token(owner, repo, pr_number)
7180

72-
if access_result.status == WriteAccessStatus.DENIED:
73-
console.print(
74-
f"[red]Error: {access_result.message}[/red]\n"
75-
"[yellow]To use --post, your token needs 'Pull requests: Read and write' permission.[/yellow]"
76-
)
77-
raise typer.Exit(code=1)
78-
elif access_result.status == WriteAccessStatus.UNCERTAIN:
79-
console.print(f"[yellow]Warning: {access_result.message}[/yellow]")
81+
if search_result.token:
82+
write_token = search_result.token
83+
if search_result.tokens_tried > 1:
84+
console.print(f"[green]Write access confirmed (token {search_result.tokens_tried} of {len(tokens)}).[/green]")
85+
else:
86+
console.print("[green]Write access confirmed.[/green]")
87+
# Update client to use the working token for subsequent operations
88+
gh_client = GitHubClient(token=write_token)
89+
elif search_result.access_result.status == WriteAccessStatus.UNCERTAIN:
90+
console.print(f"[yellow]Warning: {search_result.access_result.message}[/yellow]")
8091
console.print("[yellow]Proceeding, but posting may fail...[/yellow]")
8192
else:
82-
console.print("[green]Write access confirmed.[/green]")
93+
if search_result.tokens_tried > 1:
94+
console.print(
95+
f"[red]Error: None of the {search_result.tokens_tried} configured tokens have write access.[/red]\n"
96+
f"[red]Last error: {search_result.access_result.message}[/red]\n"
97+
"[yellow]To use --post, at least one token needs 'Pull requests: Read and write' permission.[/yellow]"
98+
)
99+
else:
100+
console.print(
101+
f"[red]Error: {search_result.access_result.message}[/red]\n"
102+
"[yellow]To use --post, your token needs 'Pull requests: Read and write' permission.[/yellow]"
103+
)
104+
raise typer.Exit(code=1)
83105
except typer.Exit:
84106
raise
85107
except Exception as e:

0 commit comments

Comments
 (0)