Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,25 @@ Repository Resolution must occur before publication.

---

# Authentication Resolution

Responsibilities:

* initiate device flow login
* obtain user access token
* persist token locally
* load token for publishing and diagnostics

Outputs:

```text
GitHub App User Token
```

Authentication Resolution is not responsible for issue creation or repository selection.

---

# Issue Construction

Responsibilities:
Expand Down
2 changes: 2 additions & 0 deletions FRAMEWORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ Never:
* commit secrets
* store secrets in repository files

GitHub App device flow tokens must be stored locally with restricted file permissions.

---

# Markdown Processing
Expand Down
16 changes: 16 additions & 0 deletions PRODUCT_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,12 @@ The product shall provide a diagnostic command that validates publishing prerequ

---

## FR-011

The product shall support GitHub App login through device flow authentication.

---

# Issue Construction Rules

## Title Rule
Expand Down Expand Up @@ -195,6 +201,16 @@ Publisher information must be preserved as part of the generated issue.

---

# Authentication

The product uses a GitHub App user access token obtained through device flow authentication.

The user authenticates with the `ai-issue login` command.

The token is stored locally and reused for publishing and diagnostics.

---

# User Flow

## Publish Issue
Expand Down
14 changes: 14 additions & 0 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,20 @@ Acceptance Criteria:

---

### Authentication

* [ ] Implement GitHub App device flow login
* [ ] Store and load GitHub App token locally
* [ ] Use stored token for publishing and diagnostics

Acceptance Criteria:

* `ai-issue login` obtains a GitHub App user access token
* Token is persisted locally
* Publishing works without manual PAT setup

---

### Issue Construction

* [X] Construct publishable issue
Expand Down
124 changes: 119 additions & 5 deletions internal/adapter/github/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import (
"fmt"
"io"
"net/http"
"os"
"net/url"
"strings"

"github.com/replworks/ai-issue/internal/config"
"github.com/replworks/ai-issue/internal/extraction"
)

Expand All @@ -18,14 +19,36 @@ type Client struct {
BaseURL string
}

var (
deviceCodeEndpoint = "https://github.com/login/device/code"
accessTokenEndpoint = "https://github.com/login/oauth/access_token"
)

func SetDeviceFlowEndpointsForTest(device, access string) {
deviceCodeEndpoint = device
accessTokenEndpoint = access
}

func RestoreDeviceFlowEndpoints(device, access string) {
deviceCodeEndpoint = device
accessTokenEndpoint = access
}

func DeviceFlowEndpointsForTest() (string, string) {
return deviceCodeEndpoint, accessTokenEndpoint
}

type APIError struct {
Message string `json:"message"`
}

func NewClient() (*Client, error) {
token := os.Getenv("GITHUB_TOKEN")
token, err := config.LoadToken()
if err != nil {
return nil, extraction.NewError("auth", err.Error())
}
if token == "" {
return nil, extraction.NewError("auth", "GITHUB_TOKEN environment variable is required. Set it with your publisher token.")
return nil, extraction.NewError("auth", "GitHub App token is required. Run `ai-issue login` first.")
}
return &Client{
Token: token,
Expand All @@ -34,6 +57,93 @@ func NewClient() (*Client, error) {
}, nil
}

type DeviceCodeResponse struct {
DeviceCode string
UserCode string
VerificationURI string
ExpiresIn int
Interval int
}

type AccessTokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
Scope string `json:"scope"`
Error string `json:"error"`
}

func RequestDeviceCode(clientID string) (*DeviceCodeResponse, error) {
form := url.Values{}
form.Set("client_id", clientID)

req, err := http.NewRequest(http.MethodPost, deviceCodeEndpoint, strings.NewReader(form.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create device code request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to request device code: %w", err)
}
defer func() { _ = resp.Body.Close() }()

body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("device code request failed: %s", githubErrorMessage(body))
}

var decoded struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
if err := json.Unmarshal(body, &decoded); err != nil {
return nil, fmt.Errorf("failed to decode device code response: %w", err)
}
return &DeviceCodeResponse{
DeviceCode: decoded.DeviceCode,
UserCode: decoded.UserCode,
VerificationURI: decoded.VerificationURI,
ExpiresIn: decoded.ExpiresIn,
Interval: decoded.Interval,
}, nil
}

func ExchangeDeviceCode(clientID, deviceCode string) (*AccessTokenResponse, error) {
form := url.Values{}
form.Set("client_id", clientID)
form.Set("device_code", deviceCode)
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")

req, err := http.NewRequest(http.MethodPost, accessTokenEndpoint, strings.NewReader(form.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create access token request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to exchange device code: %w", err)
}
defer func() { _ = resp.Body.Close() }()

body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("access token request failed: %s", githubErrorMessage(body))
}

var decoded AccessTokenResponse
if err := json.Unmarshal(body, &decoded); err != nil {
return nil, fmt.Errorf("failed to decode access token response: %w", err)
}
return &decoded, nil
}

func (c *Client) CreateIssue(repo, title, body string, labels []string) (string, error) {
if c == nil {
return "", fmt.Errorf("GitHub client is not initialized")
Expand Down Expand Up @@ -64,7 +174,7 @@ func (c *Client) CreateIssue(repo, title, body string, labels []string) (string,
if err != nil {
return "", fmt.Errorf("failed to create GitHub issue request: %w", err)
}
req.Header.Set("Authorization", "token "+c.Token)
req.Header.Set("Authorization", "Bearer "+c.Token)
req.Header.Set("Accept", "application/vnd.github.v3+json")
req.Header.Set("Content-Type", "application/json")

Expand Down Expand Up @@ -102,7 +212,7 @@ func (c *Client) CheckRepositoryAccess(repo string) error {
if err != nil {
return fmt.Errorf("failed to create GitHub repository access request: %w", err)
}
req.Header.Set("Authorization", "token "+c.Token)
req.Header.Set("Authorization", "Bearer "+c.Token)
req.Header.Set("Accept", "application/vnd.github+json")

resp, err := c.HTTPClient.Do(req)
Expand All @@ -118,6 +228,8 @@ func (c *Client) CheckRepositoryAccess(repo string) error {
return &RepositoryAccessError{
Status: http.StatusText(resp.StatusCode),
Message: githubErrorMessage(body),
Headers: resp.Header.Clone(),
RawBody: strings.TrimSpace(string(body)),
}
}

Expand All @@ -127,6 +239,8 @@ func (c *Client) CheckRepositoryAccess(repo string) error {
type RepositoryAccessError struct {
Status string
Message string
Headers http.Header
RawBody string
}

func (e *RepositoryAccessError) Error() string {
Expand Down
14 changes: 10 additions & 4 deletions internal/adapter/github/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ func TestCreateIssueSuccess(t *testing.T) {
if gotPath != "/repos/company/backend/issues" {
t.Fatalf("path = %q, want %q", gotPath, "/repos/company/backend/issues")
}
if gotAuth != "token token-123" {
t.Fatalf("auth = %q, want %q", gotAuth, "token token-123")
if gotAuth != "Bearer token-123" {
t.Fatalf("auth = %q, want %q", gotAuth, "Bearer token-123")
}
if len(gotLabels) != 1 || gotLabels[0] != "ai-generated" {
t.Fatalf("labels = %v, want [ai-generated]", gotLabels)
Expand Down Expand Up @@ -110,8 +110,8 @@ func TestCheckRepositoryAccessSuccess(t *testing.T) {
if gotPath != "/repos/company/backend" {
t.Fatalf("path = %q, want %q", gotPath, "/repos/company/backend")
}
if gotAuth != "token token-123" {
t.Fatalf("auth = %q, want %q", gotAuth, "token token-123")
if gotAuth != "Bearer token-123" {
t.Fatalf("auth = %q, want %q", gotAuth, "Bearer token-123")
}
if gotAccept != "application/vnd.github+json" {
t.Fatalf("accept = %q, want %q", gotAccept, "application/vnd.github+json")
Expand Down Expand Up @@ -146,4 +146,10 @@ func TestCheckRepositoryAccessFailureParsesMessage(t *testing.T) {
if accessErr.Message != "fine-grained tokens are not allowed for this org" {
t.Fatalf("message = %q, want %q", accessErr.Message, "fine-grained tokens are not allowed for this org")
}
if accessErr.RawBody == "" {
t.Fatal("expected raw body to be preserved")
}
if accessErr.Headers == nil {
t.Fatal("expected response headers to be preserved")
}
}
60 changes: 60 additions & 0 deletions internal/adapter/github/device_flow_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package github

import (
"net/http"
"net/http/httptest"
"testing"
)

func TestRequestAndExchangeDeviceCode(t *testing.T) {
oldDevice := deviceCodeEndpoint
oldAccess := accessTokenEndpoint
defer func() {
deviceCodeEndpoint = oldDevice
accessTokenEndpoint = oldAccess
}()

var gotDevicePath string
var gotAccessPath string

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/login/device/code":
gotDevicePath = r.URL.Path
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"device_code":"device-123","user_code":"WDJB-MJHT","verification_uri":"https://github.com/login/device","expires_in":900,"interval":5}`))
case "/login/oauth/access_token":
gotAccessPath = r.URL.Path
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"token-123","token_type":"bearer","scope":""}`))
default:
t.Fatalf("unexpected path: %s", r.URL.Path)
}
}))
defer server.Close()

deviceCodeEndpoint = server.URL + "/login/device/code"
accessTokenEndpoint = server.URL + "/login/oauth/access_token"

device, err := RequestDeviceCode("client-123")
if err != nil {
t.Fatalf("RequestDeviceCode error: %v", err)
}
if device.DeviceCode != "device-123" || device.UserCode != "WDJB-MJHT" {
t.Fatalf("unexpected device response: %#v", device)
}
if gotDevicePath != "/login/device/code" {
t.Fatalf("gotDevicePath = %q", gotDevicePath)
}

token, err := ExchangeDeviceCode("client-123", device.DeviceCode)
if err != nil {
t.Fatalf("ExchangeDeviceCode error: %v", err)
}
if token.AccessToken != "token-123" {
t.Fatalf("token = %#v", token)
}
if gotAccessPath != "/login/oauth/access_token" {
t.Fatalf("gotAccessPath = %q", gotAccessPath)
}
}
Loading
Loading