-
Notifications
You must be signed in to change notification settings - Fork 0
Description
Work Unit 004: GitHub API Implementation (REST + GraphQL)
Behavioral Goal
As a developer using sow in Claude Code web VMs, I need GitHub operations (issues, PRs, branch linking) to work via the GitHub API instead of requiring the gh CLI, so that I can perform all repository management tasks in ephemeral browser-based environments where the gh CLI is not available.
Success Criteria:
- GitHubAPI struct implements the GitHubClient interface completely
- All GitHub operations work via REST API or GraphQL without gh CLI
- Token authentication via GITHUB_TOKEN environment variable
- Owner/repo information extracted from git remote URL
- Factory returns GitHubAPI when GITHUB_TOKEN is set
- All interface methods return identical data structures to GitHubCLI
- Rate limit errors are handled gracefully with clear user guidance
- Operations behave identically from consumer perspective (CLI vs API is transparent)
- Error messages are clear and actionable
Existing Code Context
This work unit builds upon the GitHubClient interface extracted in work unit 002. The interface abstraction allows us to provide an API-based implementation that functions identically to the existing gh CLI-based implementation from the consumer's perspective.
Foundation from Work Unit 002
Work unit 002 created the following foundation that this work unit depends on:
GitHubClient Interface (cli/internal/sow/github_client.go):
type GitHubClient interface {
CheckAvailability() error
ListIssues(label, state string) ([]Issue, error)
GetIssue(number int) (*Issue, error)
CreateIssue(title, body string, labels []string) (*Issue, error)
GetLinkedBranches(number int) ([]LinkedBranch, error)
CreateLinkedBranch(issueNumber int, branchName string, checkout bool) (string, error)
CreatePullRequest(title, body string, draft bool) (number int, url string, error)
UpdatePullRequest(number int, title, body string) error
MarkPullRequestReady(number int) error
}Data Structures (defined in github_client.go or github_cli.go):
Issue- GitHub issue with number, title, body, state, URL, labelsLinkedBranch- Branch name and URL for issue-linked branches
Factory Pattern (cli/internal/sow/github_factory.go):
Currently returns an error when GITHUB_TOKEN is present, with a placeholder for this work unit:
func NewGitHubClient() (GitHubClient, error) {
if token := os.Getenv("GITHUB_TOKEN"); token != "" {
// Work unit 004: Return GitHubAPI client
return nil, errors.New("API client not yet implemented")
}
return NewGitHubCLI(exec.NewLocal("gh")), nil
}GitHubCLI Implementation Patterns to Follow
The existing GitHubCLI implementation at cli/internal/sow/github_cli.go (renamed from github.go) demonstrates the patterns that GitHubAPI should match:
Error Handling Pattern:
- Custom error types with descriptive messages
- Unwrap() support for error chains
- Examples from GitHubCLI:
ErrGHNotInstalled- Clear message with installation URLErrGHNotAuthenticated- Actionable guidance ("Run: gh auth login")ErrGHCommand- Includes command that failed and stderr output
Data Parsing Pattern:
- JSON unmarshaling for structured data (issues, PRs)
- String parsing for simple outputs (branch names from URLs)
- Graceful handling of empty results (e.g., no linked branches returns empty slice, not error)
Method Structure Pattern:
All methods in GitHubCLI follow this structure:
- Check availability via
Ensure()(calls CheckInstalled and CheckAuthenticated) - Execute operation via executor interface
- Parse response
- Return typed data or error
Example from GitHubCLI:
func (g *GitHubCLI) GetIssue(number int) (*Issue, error) {
if err := g.Ensure(); err != nil {
return nil, err
}
stdout, stderr, err := g.gh.Run("issue", "view", fmt.Sprintf("%d", number), ...)
if err != nil {
return nil, ErrGHCommand{...}
}
var issue Issue
if err := json.Unmarshal([]byte(stdout), &issue); err != nil {
return nil, fmt.Errorf("failed to parse issue: %w", err)
}
return &issue, nil
}Key Files Created by Work Unit 002
cli/internal/sow/github_client.go- Interface definition and data typescli/internal/sow/github_cli.go- CLI implementation (existing code, renamed)cli/internal/sow/github_factory.go- Factory with GITHUB_TOKEN detectioncli/internal/sow/github_mock.go- Mock implementation for testing
Key Files This Work Unit Will Create
cli/internal/sow/github_api.go- GitHubAPI implementation (new)cli/internal/sow/github_api_test.go- API tests with mock HTTP (new)
Key Files This Work Unit Will Modify
cli/internal/sow/github_factory.go- Update to return GitHubAPI when GITHUB_TOKEN presentcli/go.mod- Add go-github, githubv4, and ensure oauth2 dependencies
Design Context
The design document at .sow/knowledge/designs/claude-code-web-integration.md (Section 4: GitHub Integration) specifies the dual GitHub client architecture. This work unit implements the API client half of that design.
API Mapping from Design Document
From design document lines 248-258, the API mapping table shows how each gh CLI command translates to API calls:
| Operation | gh CLI Command | GitHub API Endpoint |
|---|---|---|
| List issues | gh issue list |
REST /repos/{owner}/{repo}/issues |
| Get issue | gh issue view N |
REST /repos/{owner}/{repo}/issues/{number} |
| Create issue | gh issue create |
REST POST /repos/{owner}/{repo}/issues |
| Create PR | gh pr create |
REST POST /repos/{owner}/{repo}/pulls |
| Create draft PR | gh pr create --draft |
REST POST /repos/{owner}/{repo}/pulls with draft: true |
| Update PR | gh pr edit N |
REST PATCH /repos/{owner}/{repo}/pulls/{number} |
| Mark PR ready | gh pr ready N |
REST PATCH /repos/{owner}/{repo}/pulls/{number} with draft: false |
| Link branch | gh issue develop |
GraphQL createLinkedBranch mutation |
| Get linked branches | gh issue develop --list |
GraphQL query on issue's linkedBranches connection |
Key Insight: Most operations use REST API (go-github library), but branch linking requires GraphQL (githubv4 library) because the linked branches feature is only available via GitHub's GraphQL API.
Authentication from Design Document
From design document lines 224-228:
- Token-based authentication via
GITHUB_TOKENenvironment variable - Use
golang.org/x/oauth2for token handling (already in dependencies) - HTTP client configured with oauth2.StaticTokenSource
- Extract owner/repo from git remote URL (no manual configuration)
Rate Limiting from Discovery Document
From discovery document lines 698-703:
- Authenticated tokens: 5000 requests/hour
- Unauthenticated: 60 requests/hour
- User-provided GITHUB_TOKEN ensures high rate limit
- Rate limit errors should suggest checking token validity
- Future enhancement: response caching (not in this work unit)
Implementation Approach
The API implementation follows the same interface contract as GitHubCLI but uses HTTP requests instead of executing shell commands.
Architecture Overview
GitHubAPI struct
├── token: string (from GITHUB_TOKEN)
├── owner: string (from git remote)
├── repo: string (from git remote)
├── restClient: *github.Client (go-github)
└── graphqlClient: *githubv4.Client (githubv4)
Both clients are initialized with the same oauth2 HTTP client to share authentication.
Step 1: Dependencies
Add to cli/go.mod:
require (
github.com/google/go-github/v66 v66.0.0
github.com/shurcooL/githubv4 v0.0.0-20231126234147-1cffa1f02456
golang.org/x/oauth2 v0.XX.0 // likely already present as indirect
)Library choices:
go-github/v66- Official Google-maintained REST client, comprehensive, well-testedgithubv4- Most popular GraphQL client for GitHub, simple APIoauth2- Standard library for OAuth2, handles token refresh
Step 2: Core Structure
Create cli/internal/sow/github_api.go:
package sow
import (
"context"
"net/http"
"github.com/google/go-github/v66/github"
"github.com/shurcooL/githubv4"
"golang.org/x/oauth2"
)
// GitHubAPI implements GitHubClient using GitHub REST and GraphQL APIs.
//
// This implementation is used in Claude Code web VMs where the gh CLI
// is not available. It requires a GITHUB_TOKEN environment variable.
type GitHubAPI struct {
token string
owner string
repo string
restClient *github.Client
graphqlClient *githubv4.Client
}
// NewGitHubAPI creates a GitHub client backed by REST and GraphQL APIs.
//
// Parameters:
// - token: GitHub personal access token (from GITHUB_TOKEN env var)
// - owner: Repository owner (extracted from git remote)
// - repo: Repository name (extracted from git remote)
//
// The token must have repo scope for full functionality.
func NewGitHubAPI(token, owner, repo string) *GitHubAPI {
// Create oauth2 HTTP client
ctx := context.Background()
ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})
httpClient := oauth2.NewClient(ctx, ts)
return &GitHubAPI{
token: token,
owner: owner,
repo: repo,
restClient: github.NewClient(httpClient),
graphqlClient: githubv4.NewClient(httpClient),
}
}
// Interface compliance check
var _ GitHubClient = (*GitHubAPI)(nil)Step 3: Error Types
Add API-specific error types to match GitHubCLI patterns:
// ErrGitHubAPI is returned when a GitHub API call fails.
type ErrGitHubAPI struct {
Operation string
Message string
Err error
}
func (e ErrGitHubAPI) Error() string {
if e.Message != "" {
return fmt.Sprintf("GitHub API %s failed: %s", e.Operation, e.Message)
}
return fmt.Sprintf("GitHub API %s failed: %v", e.Operation, e.Err)
}
func (e ErrGitHubAPI) Unwrap() error {
return e.Err
}
// ErrNoGitHubToken is returned when GITHUB_TOKEN is not set.
type ErrNoGitHubToken struct{}
func (e ErrNoGitHubToken) Error() string {
return "GITHUB_TOKEN environment variable not set. Create a token at: https://github.com/settings/tokens"
}
// ErrInvalidGitHubToken is returned when the token is invalid or lacks permissions.
type ErrInvalidGitHubToken struct {
Reason string
}
func (e ErrInvalidGitHubToken) Error() string {
return fmt.Sprintf("GitHub token invalid or lacks permissions: %s", e.Reason)
}Step 4: Implement Interface Methods
CheckAvailability:
Validates token and connectivity by making a simple API call:
func (g *GitHubAPI) CheckAvailability() error {
ctx := context.Background()
// Verify token by fetching repository info
_, resp, err := g.restClient.Repositories.Get(ctx, g.owner, g.repo)
if err != nil {
// Check for authentication errors
if resp != nil && resp.StatusCode == 401 {
return ErrInvalidGitHubToken{"Token authentication failed"}
}
return ErrGitHubAPI{
Operation: "check availability",
Err: err,
}
}
return nil
}REST API Methods (ListIssues, GetIssue, CreateIssue, CreatePullRequest, UpdatePullRequest, MarkPullRequestReady):
- Use
g.restClient.Issues.*andg.restClient.PullRequests.*methods - Convert between go-github types and sow Issue/PR types
- Handle pagination for ListIssues (limit to 1000 like gh CLI)
- Parse response data into expected structures
GraphQL Methods (GetLinkedBranches, CreateLinkedBranch):
- Use
g.graphqlClient.Query()andg.graphqlClient.Mutate() - Define GraphQL query/mutation structs
- Handle the linkedBranches connection
- Parse GraphQL responses into LinkedBranch structs
Step 5: Update Factory
Modify cli/internal/sow/github_factory.go:
func NewGitHubClient() (GitHubClient, error) {
if token := os.Getenv("GITHUB_TOKEN"); token != "" {
// Extract owner/repo from git remote
owner, repo, err := getRepoInfo()
if err != nil {
return nil, fmt.Errorf("failed to extract repo info for API client: %w", err)
}
return NewGitHubAPI(token, owner, repo), nil
}
// Default to CLI client
return NewGitHubCLI(exec.NewLocal("gh")), nil
}
// getRepoInfo extracts owner and repo from git remote URL.
func getRepoInfo() (owner, repo string, err error) {
// Execute: git remote get-url origin
// Parse URL (supports https and ssh formats):
// https://github.com/owner/repo.git
// [email protected]:owner/repo.git
// Return owner, repo (without .git suffix)
}Step 6: Testing Strategy
Unit Tests with Mock HTTP:
- Use
httptest.NewServer()to mock GitHub API responses - Test each interface method independently
- Verify correct API endpoints are called
- Test error handling (401, 404, rate limits, etc.)
Example test structure:
func TestGitHubAPI_GetIssue(t *testing.T) {
// Create mock HTTP server
mux := http.NewServeMux()
mux.HandleFunc("/repos/owner/repo/issues/123", func(w http.ResponseWriter, r *http.Request) {
// Verify Authorization header
// Return mock issue JSON
fmt.Fprint(w, `{"number": 123, "title": "Test Issue", ...}`)
})
server := httptest.NewServer(mux)
defer server.Close()
// Create client pointing to mock server
client := newTestGitHubAPI(server.URL, "owner", "repo")
// Execute and assert
issue, err := client.GetIssue(123)
// Assertions...
}GraphQL Testing:
Similar approach but mock GraphQL endpoint with query/mutation validation.
Integration with Factory:
Test that factory returns GitHubAPI when GITHUB_TOKEN is set:
func TestNewGitHubClient_WithToken_ReturnsAPI(t *testing.T) {
os.Setenv("GITHUB_TOKEN", "test-token")
defer os.Unsetenv("GITHUB_TOKEN")
// Mock git remote get-url
client, err := NewGitHubClient()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, ok := client.(*GitHubAPI); !ok {
t.Errorf("expected *GitHubAPI, got %T", client)
}
}Dependencies
Depends On
Work Unit 002 (GitHub Client Interface Extraction) - REQUIRED
- Provides the GitHubClient interface that this work unit implements
- Provides Issue and LinkedBranch data structures
- Provides factory pattern (NewGitHubClient) to update
- Provides error handling patterns to match
Why this dependency exists: The API client must implement the same interface as the CLI client to ensure they're interchangeable. Without the interface definition, we risk creating incompatible implementations.
Depended On By
Work Unit 005 (In-Place Project Initialization) and other work units may indirectly benefit from this work, but there's no strict dependency. The API client enables sow to work in web VMs, which is foundational to the entire Claude Code web integration.
Acceptance Criteria
Objective, measurable criteria that reviewers will verify:
- ✅ GitHubAPI struct exists: Defined in
cli/internal/sow/github_api.gowith token, owner, repo, restClient, graphqlClient fields - ✅ Interface compliance:
var _ GitHubClient = (*GitHubAPI)(nil)compiles without error - ✅ All methods implemented: Every GitHubClient interface method has a working implementation
- ✅ REST operations work: ListIssues, GetIssue, CreateIssue, CreatePullRequest, UpdatePullRequest, MarkPullRequestReady use go-github
- ✅ GraphQL operations work: GetLinkedBranches, CreateLinkedBranch use githubv4 with proper queries/mutations
- ✅ Token authentication: GITHUB_TOKEN environment variable is used for auth via oauth2
- ✅ Repository extraction: Owner/repo extracted from git remote URL (supports both https and ssh formats)
- ✅ Factory integration: NewGitHubClient() returns GitHubAPI when GITHUB_TOKEN is set, GitHubCLI otherwise
- ✅ Error handling: Rate limits, auth failures, network errors return clear, actionable error messages
- ✅ Data structure compatibility: All methods return identical Issue/LinkedBranch structures to GitHubCLI
- ✅ Dependencies added: go.mod includes go-github/v66 and githubv4
- ✅ Comprehensive tests: Unit tests with mock HTTP cover all interface methods and error cases
- ✅ CheckAvailability validates token: Makes an API call to verify token is valid and has repo access
- ✅ Documentation: All exported types and methods have clear godoc comments with usage examples
Testing Requirements
Unit Tests (github_api_test.go)
Interface Compliance:
func TestGitHubAPI_ImplementsInterface(t *testing.T) {
var _ GitHubClient = (*GitHubAPI)(nil)
}REST API Tests (with httptest.Server):
- TestGitHubAPI_CheckAvailability
- TestGitHubAPI_ListIssues
- TestGitHubAPI_GetIssue
- TestGitHubAPI_CreateIssue
- TestGitHubAPI_CreatePullRequest_Draft
- TestGitHubAPI_CreatePullRequest_Ready
- TestGitHubAPI_UpdatePullRequest
- TestGitHubAPI_MarkPullRequestReady
GraphQL Tests (with mock GraphQL endpoint):
- TestGitHubAPI_GetLinkedBranches
- TestGitHubAPI_GetLinkedBranches_Empty (no branches linked)
- TestGitHubAPI_CreateLinkedBranch
Error Handling Tests:
- TestGitHubAPI_CheckAvailability_InvalidToken (401 response)
- TestGitHubAPI_GetIssue_NotFound (404 response)
- TestGitHubAPI_RateLimitError (403 with rate limit headers)
- TestGitHubAPI_NetworkError
Repository Extraction Tests:
- TestGetRepoInfo_HTTPS (https://github.com/owner/repo.git)
- TestGetRepoInfo_SSH ([email protected]:owner/repo.git)
- TestGetRepoInfo_WithoutGitSuffix
- TestGetRepoInfo_InvalidFormat
Factory Tests (github_factory_test.go)
Update existing factory tests to verify GitHubAPI is returned:
- TestNewGitHubClient_WithToken_ReturnsAPI
- TestNewGitHubClient_WithToken_InvalidRepo (can't extract owner/repo)
- TestNewGitHubClient_WithoutToken_ReturnsCLI (existing test)
Coverage Target
- All interface methods: 100%
- Error paths: 100%
- Repository extraction: 100%
- Overall: >90% for github_api.go
Implementation Notes
go-github Usage Patterns
Creating Issues:
issueRequest := &github.IssueRequest{
Title: github.String(title),
Body: github.String(body),
Labels: &labels,
}
issue, resp, err := g.restClient.Issues.Create(ctx, g.owner, g.repo, issueRequest)Listing Issues with Filters:
opts := &github.IssueListByRepoOptions{
State: state,
Labels: []string{label},
ListOptions: github.ListOptions{PerPage: 100}, // Pagination
}
issues, resp, err := g.restClient.Issues.ListByRepo(ctx, g.owner, g.repo, opts)Creating Pull Requests:
pr := &github.NewPullRequest{
Title: github.String(title),
Body: github.String(body),
Draft: github.Bool(draft),
Head: github.String(headBranch),
Base: github.String(baseBranch),
}
pr, resp, err := g.restClient.PullRequests.Create(ctx, g.owner, g.repo, pr)Updating Pull Requests:
update := &github.PullRequest{
Title: github.String(title),
Body: github.String(body),
}
pr, resp, err := g.restClient.PullRequests.Edit(ctx, g.owner, g.repo, number, update)Marking PR Ready:
update := &github.PullRequest{
Draft: github.Bool(false),
}
pr, resp, err := g.restClient.PullRequests.Edit(ctx, g.owner, g.repo, number, update)githubv4 Usage for Branch Linking
Query for Linked Branches:
var query struct {
Repository struct {
Issue struct {
LinkedBranches struct {
Nodes []struct {
Ref struct {
Name string
}
}
} `graphql:"linkedBranches(first: 100)"`
} `graphql:"issue(number: $issueNumber)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
variables := map[string]interface{}{
"owner": githubv4.String(g.owner),
"repo": githubv4.String(g.repo),
"issueNumber": githubv4.Int(issueNumber),
}
err := g.graphqlClient.Query(context.Background(), &query, variables)Mutation for Creating Linked Branch:
var mutation struct {
CreateLinkedBranch struct {
LinkedBranch struct {
Ref struct {
Name string
}
}
} `graphql:"createLinkedBranch(input: $input)"`
}
input := githubv4.CreateLinkedBranchInput{
RepositoryID: repoID,
IssueID: issueID,
Name: githubv4.String(branchName),
}
err := g.graphqlClient.Mutate(context.Background(), &mutation, input, nil)Note: GraphQL requires node IDs (not just numbers), so you may need to query for repository and issue IDs first.
Rate Limit Handling
Check rate limit from response headers:
if resp.StatusCode == 403 {
if resp.Rate.Remaining == 0 {
return ErrGitHubAPI{
Operation: operation,
Message: fmt.Sprintf("Rate limit exceeded. Resets at %v", resp.Rate.Reset),
}
}
}Suggest checking token if rate limit hit:
- User tokens: 5000 req/hour
- If hitting rate limits, may indicate token is invalid or missing scopes
Repository Extraction from Git Remote
func getRepoInfo() (owner, repo string, err error) {
// Execute: git remote get-url origin
cmd := exec.Command("git", "remote", "get-url", "origin")
output, err := cmd.Output()
if err != nil {
return "", "", fmt.Errorf("failed to get git remote: %w", err)
}
url := strings.TrimSpace(string(output))
// Parse https://github.com/owner/repo.git
if strings.HasPrefix(url, "https://") {
parts := strings.Split(url, "/")
if len(parts) < 2 {
return "", "", fmt.Errorf("invalid remote URL: %s", url)
}
owner = parts[len(parts)-2]
repo = strings.TrimSuffix(parts[len(parts)-1], ".git")
return owner, repo, nil
}
// Parse [email protected]:owner/repo.git
if strings.HasPrefix(url, "git@") {
parts := strings.SplitN(url, ":", 2)
if len(parts) != 2 {
return "", "", fmt.Errorf("invalid SSH remote URL: %s", url)
}
pathParts := strings.Split(parts[1], "/")
if len(pathParts) != 2 {
return "", "", fmt.Errorf("invalid SSH remote path: %s", parts[1])
}
owner = pathParts[0]
repo = strings.TrimSuffix(pathParts[1], ".git")
return owner, repo, nil
}
return "", "", fmt.Errorf("unsupported remote URL format: %s", url)
}Context Handling
All API calls should use context.Background() for simplicity. Future enhancement could add timeout/cancellation support via context.
Pagination Considerations
For ListIssues, handle pagination to match gh CLI behavior (limit 1000):
var allIssues []*github.Issue
opts := &github.IssueListByRepoOptions{
State: state,
Labels: []string{label},
ListOptions: github.ListOptions{PerPage: 100},
}
for {
issues, resp, err := g.restClient.Issues.ListByRepo(ctx, g.owner, g.repo, opts)
if err != nil {
return nil, err
}
allIssues = append(allIssues, issues...)
if resp.NextPage == 0 || len(allIssues) >= 1000 {
break
}
opts.Page = resp.NextPage
}Data Type Conversion
Convert between go-github types and sow types:
func toSowIssue(ghIssue *github.Issue) *Issue {
issue := &Issue{
Number: ghIssue.GetNumber(),
Title: ghIssue.GetTitle(),
Body: ghIssue.GetBody(),
State: ghIssue.GetState(),
URL: ghIssue.GetHTMLURL(),
Labels: make([]struct{Name string}, len(ghIssue.Labels)),
}
for i, label := range ghIssue.Labels {
issue.Labels[i].Name = label.GetName()
}
return issue
}Security Considerations
- Never log the token value
- Use oauth2 library for token management (handles header formatting)
- Validate token has required scopes (repo scope for full functionality)
- Token should be read-only from environment (not stored in struct permanently)
Out of Scope
Not included in this work unit:
- Response caching (future enhancement for rate limit optimization)
- Token refresh or OAuth flow (user provides static token via env var)
- SSH key authentication (only token-based auth)
- GitHub Enterprise support (only github.com)
- Webhook operations or other advanced GitHub features
- Retry logic for transient failures (future enhancement)
- Background operations or job queuing
- Token permission validation beyond CheckAvailability
Summary
This work unit implements the API-based GitHub client that enables sow to function in Claude Code web VMs. By implementing the GitHubClient interface established in work unit 002, the API client is a drop-in replacement for the CLI client with automatic environment detection via the factory pattern.
Key Deliverables:
- GitHubAPI implementation using go-github (REST) and githubv4 (GraphQL)
- Token-based authentication via GITHUB_TOKEN
- Automatic owner/repo extraction from git remote
- Factory integration with environment auto-detection
- Error handling matching GitHubCLI patterns
- Comprehensive test coverage with mock HTTP
Implementation Strategy:
- Follow GitHubCLI patterns for consistency
- Use established go-github and githubv4 libraries
- Test with mock HTTP servers (no real API calls in tests)
- Focus on behavioral compatibility with CLI client
- Provide clear, actionable error messages
This implementation completes the dual GitHub client architecture, allowing sow to work seamlessly in both local development (with gh CLI) and web VMs (with GitHub API).