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
61 changes: 46 additions & 15 deletions internal/doctor/codebase.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ type CIStatus struct {

// CodebaseOptions configures codebase health analysis.
type CodebaseOptions struct {
ForgeInfo forge.ForgeInfo
GHClient *github.Client
ForgeInfo forge.ForgeInfo
ForgeClient forge.Client

// RunGHCmd overrides gh CLI execution for testing.
RunGHCmd func(args ...string) ([]byte, error)
Expand Down Expand Up @@ -72,23 +72,21 @@ func AnalyzeCodebase(ctx context.Context, opts CodebaseOptions) (*CodebaseHealth

health := &CodebaseHealth{}

// Analyze PRs
if opts.GHClient != nil {
prs, err := opts.GHClient.ListPullRequests(ctx, opts.ForgeInfo.Owner, opts.ForgeInfo.Repo, github.ListPullRequestsOptions{
if opts.ForgeClient != nil {
prs, err := opts.ForgeClient.ListPullRequests(ctx, opts.ForgeInfo.Owner, opts.ForgeInfo.Repo, forge.ListPullRequestsOptions{
State: "open",
PerPage: 100,
})
if err == nil {
health.PRs = analyzePRs(prs, opts.now())
}

// Analyze issues (list issues API includes PRs, we filter them out)
issues, err := opts.GHClient.ListIssues(ctx, opts.ForgeInfo.Owner, opts.ForgeInfo.Repo, github.ListIssuesOptions{
issues, err := opts.ForgeClient.ListIssues(ctx, opts.ForgeInfo.Owner, opts.ForgeInfo.Repo, forge.ListIssuesOptions{
State: "open",
PerPage: 100,
})
if err == nil {
health.Issues = analyzeIssues(ctx, issues, opts.GHClient)
health.Issues = analyzeIssues(ctx, issues, opts.ForgeClient)
}
}

Expand All @@ -98,7 +96,7 @@ func AnalyzeCodebase(ctx context.Context, opts CodebaseOptions) (*CodebaseHealth
return health, nil
}

func analyzePRs(prs []*github.PullRequest, now time.Time) PRSummary {
func analyzePRs(prs []*forge.PullRequest, now time.Time) PRSummary {
summary := PRSummary{Open: len(prs)}
staleThreshold := now.AddDate(0, 0, -14)

Expand All @@ -114,13 +112,17 @@ func analyzePRs(prs []*github.PullRequest, now time.Time) PRSummary {
return summary
}

func analyzeIssues(ctx context.Context, issues []*github.Issue, client *github.Client) IssueSummary {
func analyzeIssues(ctx context.Context, issues []*forge.Issue, client forge.Client) IssueSummary {
var summary IssueSummary

analyzer := github.NewAnalyzer(client)
// Issue quality analysis requires the underlying GitHub client
var analyzer *github.Analyzer
if gc, ok := client.(*forge.GitHubClient); ok {
analyzer = github.NewAnalyzer(gc.UnwrapGitHub())
}

for _, issue := range issues {
if issue.IsPullRequest() {
if issue.IsPR {
continue
}
summary.Open++
Expand All @@ -129,14 +131,43 @@ func analyzeIssues(ctx context.Context, issues []*github.Issue, client *github.C
summary.Unassigned++
}

analysis := analyzer.AnalyzeIssue(ctx, issue)
if analysis.QualityScore < 50 {
summary.PoorQuality++
if analyzer != nil {
// Convert forge.Issue back to github.Issue for the analyzer
ghIssue := forgeIssueToGitHub(issue)
analysis := analyzer.AnalyzeIssue(ctx, ghIssue)
if analysis.QualityScore < 50 {
summary.PoorQuality++
}
}
}
return summary
}

// forgeIssueToGitHub converts a forge.Issue to a github.Issue for GitHub-specific analysis.
func forgeIssueToGitHub(fi *forge.Issue) *github.Issue {
gi := &github.Issue{
Number: fi.Number,
Title: fi.Title,
Body: fi.Body,
State: fi.State,
Comments: fi.Comments,
CreatedAt: fi.CreatedAt,
UpdatedAt: fi.UpdatedAt,
ClosedAt: fi.ClosedAt,
HTMLURL: fi.HTMLURL,
}
if fi.Author != "" {
gi.User = &github.User{Login: fi.Author}
}
for _, name := range fi.Labels {
gi.Labels = append(gi.Labels, &github.Label{Name: name})
}
for _, login := range fi.Assignees {
gi.Assignees = append(gi.Assignees, &github.User{Login: login})
}
return gi
}

// ghRunResult matches the JSON output of `gh run list --json`.
type ghRunResult struct {
Status string `json:"status"`
Expand Down
3 changes: 2 additions & 1 deletion internal/doctor/codebase_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,14 +75,15 @@ func TestAnalyzeCodebase_WithMockServer(t *testing.T) {
BaseURL: server.URL,
Token: "test-token",
})
forgeClient := forge.NewGitHubClient(client)

result, err := AnalyzeCodebase(context.Background(), CodebaseOptions{
ForgeInfo: forge.ForgeInfo{
Type: forge.ForgeGitHub,
Owner: "test",
Repo: "repo",
},
GHClient: client,
ForgeClient: forgeClient,
Now: func() time.Time { return now },
RunGHCmd: func(args ...string) ([]byte, error) {
runs := []ghRunResult{
Expand Down
9 changes: 4 additions & 5 deletions internal/doctor/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"os/exec"

"github.com/recinq/wave/internal/forge"
"github.com/recinq/wave/internal/github"
"github.com/recinq/wave/internal/manifest"
"github.com/recinq/wave/internal/onboarding"
)
Expand Down Expand Up @@ -65,8 +64,8 @@ type Options struct {
Fix bool
SkipCodebase bool

// GHClient is the GitHub API client for codebase analysis.
GHClient *github.Client
// ForgeClient is the forge API client for codebase analysis.
ForgeClient forge.Client

// LookPath overrides exec.LookPath for testing.
LookPath func(file string) (string, error)
Expand Down Expand Up @@ -144,8 +143,8 @@ func RunChecks(ctx context.Context, opts Options) (*Report, error) {
// 6. Codebase health (forge API)
if !opts.SkipCodebase && fi.Type != forge.ForgeUnknown {
codebase, err := AnalyzeCodebase(ctx, CodebaseOptions{
ForgeInfo: fi,
GHClient: opts.GHClient,
ForgeInfo: fi,
ForgeClient: opts.ForgeClient,
})
if err == nil && codebase != nil {
report.Codebase = codebase
Expand Down
18 changes: 18 additions & 0 deletions internal/forge/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package forge

import (
"context"
"errors"
)

// ErrNotSupported is returned by stub implementations for unsupported forges.
var ErrNotSupported = errors.New("forge type not yet supported")

// Client is a read-only interface for forge issue/PR operations.
type Client interface {
GetIssue(ctx context.Context, owner, repo string, number int) (*Issue, error)
ListIssues(ctx context.Context, owner, repo string, opts ListIssuesOptions) ([]*Issue, error)
GetPullRequest(ctx context.Context, owner, repo string, number int) (*PullRequest, error)
ListPullRequests(ctx context.Context, owner, repo string, opts ListPullRequestsOptions) ([]*PullRequest, error)
ForgeType() ForgeType
}
59 changes: 59 additions & 0 deletions internal/forge/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package forge

import (
"context"
"errors"
"testing"
)

func TestGitHubClient_ImplementsClient(t *testing.T) {
// Compile-time interface check
var _ Client = (*GitHubClient)(nil)
}

func TestUnsupportedClient_ImplementsClient(t *testing.T) {
var _ Client = (*UnsupportedClient)(nil)
}

func TestUnsupportedClient_ReturnsErrNotSupported(t *testing.T) {
ctx := context.Background()
client := NewUnsupportedClient(ForgeGitLab)

if client.ForgeType() != ForgeGitLab {
t.Errorf("ForgeType() = %v, want %v", client.ForgeType(), ForgeGitLab)
}

_, err := client.GetIssue(ctx, "owner", "repo", 1)
if !errors.Is(err, ErrNotSupported) {
t.Errorf("GetIssue() error = %v, want ErrNotSupported", err)
}

_, err = client.ListIssues(ctx, "owner", "repo", ListIssuesOptions{})
if !errors.Is(err, ErrNotSupported) {
t.Errorf("ListIssues() error = %v, want ErrNotSupported", err)
}

_, err = client.GetPullRequest(ctx, "owner", "repo", 1)
if !errors.Is(err, ErrNotSupported) {
t.Errorf("GetPullRequest() error = %v, want ErrNotSupported", err)
}

_, err = client.ListPullRequests(ctx, "owner", "repo", ListPullRequestsOptions{})
if !errors.Is(err, ErrNotSupported) {
t.Errorf("ListPullRequests() error = %v, want ErrNotSupported", err)
}
}

func TestUnsupportedClient_ErrorContainsForgeType(t *testing.T) {
client := NewUnsupportedClient(ForgeBitbucket)
_, err := client.GetIssue(context.Background(), "o", "r", 1)
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, ErrNotSupported) {
t.Errorf("expected ErrNotSupported, got %v", err)
}
if got := err.Error(); got == "" {
t.Error("expected non-empty error message")
}
}
147 changes: 147 additions & 0 deletions internal/forge/github.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package forge

import (
"context"

"github.com/recinq/wave/internal/github"
)

// GitHubClient adapts *github.Client to the forge.Client interface.
type GitHubClient struct {
client *github.Client
}

// NewGitHubClient wraps an existing github.Client. Panics if client is nil.
func NewGitHubClient(client *github.Client) *GitHubClient {
if client == nil {
panic("forge: NewGitHubClient called with nil github.Client")
}
return &GitHubClient{client: client}
}

// UnwrapGitHub returns the underlying *github.Client for GitHub-specific operations.
func (g *GitHubClient) UnwrapGitHub() *github.Client {
return g.client
}

func (g *GitHubClient) ForgeType() ForgeType {
return ForgeGitHub
}

func (g *GitHubClient) GetIssue(ctx context.Context, owner, repo string, number int) (*Issue, error) {
gi, err := g.client.GetIssue(ctx, owner, repo, number)
if err != nil {
return nil, err
}
return convertGitHubIssue(gi), nil
}

func (g *GitHubClient) ListIssues(ctx context.Context, owner, repo string, opts ListIssuesOptions) ([]*Issue, error) {
ghOpts := github.ListIssuesOptions{
State: opts.State,
Labels: opts.Labels,
Sort: opts.Sort,
PerPage: opts.PerPage,
Page: opts.Page,
}
ghIssues, err := g.client.ListIssues(ctx, owner, repo, ghOpts)
if err != nil {
return nil, err
}
result := make([]*Issue, 0, len(ghIssues))
for _, gi := range ghIssues {
result = append(result, convertGitHubIssue(gi))
}
return result, nil
}

func (g *GitHubClient) GetPullRequest(ctx context.Context, owner, repo string, number int) (*PullRequest, error) {
gp, err := g.client.GetPullRequest(ctx, owner, repo, number)
if err != nil {
return nil, err
}
return convertGitHubPR(gp), nil
}

func (g *GitHubClient) ListPullRequests(ctx context.Context, owner, repo string, opts ListPullRequestsOptions) ([]*PullRequest, error) {
ghOpts := github.ListPullRequestsOptions{
State: opts.State,
Sort: opts.Sort,
PerPage: opts.PerPage,
Page: opts.Page,
}
ghPRs, err := g.client.ListPullRequests(ctx, owner, repo, ghOpts)
if err != nil {
return nil, err
}
result := make([]*PullRequest, 0, len(ghPRs))
for _, gp := range ghPRs {
result = append(result, convertGitHubPR(gp))
}
return result, nil
}

func convertGitHubIssue(gi *github.Issue) *Issue {
issue := &Issue{
Number: gi.Number,
Title: gi.Title,
Body: gi.Body,
State: gi.State,
Comments: gi.Comments,
CreatedAt: gi.CreatedAt,
UpdatedAt: gi.UpdatedAt,
ClosedAt: gi.ClosedAt,
HTMLURL: gi.HTMLURL,
IsPR: gi.IsPullRequest(),
}
if gi.User != nil {
issue.Author = gi.User.Login
}
for _, l := range gi.Labels {
if l != nil {
issue.Labels = append(issue.Labels, l.Name)
}
}
for _, a := range gi.Assignees {
if a != nil {
issue.Assignees = append(issue.Assignees, a.Login)
}
}
return issue
}

func convertGitHubPR(gp *github.PullRequest) *PullRequest {
pr := &PullRequest{
Number: gp.Number,
Title: gp.Title,
Body: gp.Body,
State: gp.State,
Draft: gp.Draft,
Merged: gp.Merged,
Additions: gp.Additions,
Deletions: gp.Deletions,
ChangedFiles: gp.ChangedFiles,
Commits: gp.Commits,
Comments: gp.Comments,
CreatedAt: gp.CreatedAt,
UpdatedAt: gp.UpdatedAt,
ClosedAt: gp.ClosedAt,
MergedAt: gp.MergedAt,
HTMLURL: gp.HTMLURL,
}
if gp.User != nil {
pr.Author = gp.User.Login
}
for _, l := range gp.Labels {
if l != nil {
pr.Labels = append(pr.Labels, l.Name)
}
}
if gp.Head != nil {
pr.HeadBranch = gp.Head.Ref
}
if gp.Base != nil {
pr.BaseBranch = gp.Base.Ref
}
return pr
}
Loading
Loading