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
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ go 1.26.1
require golang.org/x/time v0.15.0

require golang.org/x/sync v0.20.0

require github.com/hidetzu/prism v0.3.0
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
github.com/hidetzu/prism v0.3.0 h1:1NRls+ILiZ4HMe97Pekg6xNb8b29SPCBc61hXnDQjPs=
github.com/hidetzu/prism v0.3.0/go.mod h1:glc8z2WfzGkO0XfO/c5JxDAY0xYu2KjD34EESm0UfOY=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
Expand Down
57 changes: 57 additions & 0 deletions internal/usecase/analyze.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Package usecase holds the thin application-layer adapters between HTTP
// handlers and github.com/hidetzu/prism/pkg/prism. Handlers depend on
// small interfaces defined in the handler package (accept interfaces,
// return structs, per docs/development_rules.md §10); the concrete
// implementations live here.
package usecase

import (
"context"

"github.com/hidetzu/prism/pkg/prism"
)

// AnalyzeInput carries the HTTP-layer-agnostic inputs that a handler
// extracts from its validated AnalyzeRequest and hands to the usecase.
// Handler-specific concerns (JSON field names, HTTP headers, error
// response shape) stay in the handler package.
type AnalyzeInput struct {
PullRequestURL string
GitHubToken string
}

// Analyzer is the thin adapter between HTTP handlers and pkg/prism.Analyze.
//
// The pkg/prism entry point is stored as a function field rather than an
// interface wrapper. That keeps the indirection minimal (no extra type to
// maintain and no wrapping layer at call time) and still lets tests inject
// a fake via field assignment.
type Analyzer struct {
analyze func(ctx context.Context, opts prism.AnalyzeOptions) (prism.Result, error)
}

// NewAnalyzer constructs an Analyzer that delegates to the real
// pkg/prism.Analyze. Production code should always use this constructor.
func NewAnalyzer() *Analyzer {
return &Analyzer{analyze: prism.Analyze}
}

// Analyze invokes pkg/prism.Analyze with the GitHub provider fixed and
// patches excluded. Phase 2 is GitHub only (Phase 2 instruction §5), so
// Provider is always "github"; the auto-detection path in pkg/prism is
// deliberately bypassed so the provider is explicit in logs and debug
// traces. IncludePatches stays at the pkg/prism default (false) to keep
// response bodies lightweight; a future API flag can opt in per request.
//
// The returned Result is passed through as-is. pkg/prism's sentinel errors
// (ErrInvalidInput, ErrUnsupportedProvider, ErrAuthRequired,
// ErrUpstreamFailure) also flow through unchanged so the HTTP layer can
// map them to the correct response.Code via errors.Is.
func (a *Analyzer) Analyze(ctx context.Context, in AnalyzeInput) (prism.Result, error) {
return a.analyze(ctx, prism.AnalyzeOptions{
Provider: "github",
PRURL: in.PullRequestURL,
GitHubToken: in.GitHubToken,
IncludePatches: false,
})
}
142 changes: 142 additions & 0 deletions internal/usecase/analyze_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package usecase

import (
"context"
"errors"
"testing"

"github.com/hidetzu/prism/pkg/prism"
)

// fakeAnalyzer returns an Analyzer whose underlying prism.Analyze call is
// replaced with fn. Tests use this to observe the AnalyzeOptions handed
// into pkg/prism and to return canned results or errors.
func fakeAnalyzer(fn func(ctx context.Context, opts prism.AnalyzeOptions) (prism.Result, error)) *Analyzer {
return &Analyzer{analyze: fn}
}

func TestAnalyzer_Analyze_PassesCorrectOptions(t *testing.T) {
var got prism.AnalyzeOptions
a := fakeAnalyzer(func(_ context.Context, opts prism.AnalyzeOptions) (prism.Result, error) {
got = opts
return prism.Result{
PR: prism.PRInfo{
Provider: "github",
Repository: "owner/repo",
ID: "123",
URL: "https://github.com/owner/repo/pull/123",
},
}, nil
})

result, err := a.Analyze(context.Background(), AnalyzeInput{
PullRequestURL: "https://github.com/owner/repo/pull/123",
GitHubToken: "ghp_example",
})
if err != nil {
t.Fatalf("Analyze() err = %v", err)
}

if got.Provider != "github" {
t.Errorf("Provider = %q, want github", got.Provider)
}
if got.PRURL != "https://github.com/owner/repo/pull/123" {
t.Errorf("PRURL = %q", got.PRURL)
}
if got.GitHubToken != "ghp_example" {
t.Errorf("GitHubToken = %q, want ghp_example", got.GitHubToken)
}
if got.IncludePatches {
t.Error("IncludePatches must remain false (pkg/prism default)")
}
if got.Mode != "" {
t.Errorf("Mode = %q, want empty for Analyze", got.Mode)
}
if got.Language != "" {
t.Errorf("Language = %q, want empty for Analyze", got.Language)
}

if result.PR.Repository != "owner/repo" {
t.Errorf("result.PR.Repository = %q, want owner/repo", result.PR.Repository)
}
}

func TestAnalyzer_Analyze_EmptyTokenIsPassedThrough(t *testing.T) {
// Phase 2 supports unauthenticated public-repo calls; the adapter must
// not substitute a default token for an empty input.
var gotToken string
a := fakeAnalyzer(func(_ context.Context, opts prism.AnalyzeOptions) (prism.Result, error) {
gotToken = opts.GitHubToken
return prism.Result{}, nil
})

if _, err := a.Analyze(context.Background(), AnalyzeInput{
PullRequestURL: "https://github.com/owner/repo/pull/1",
}); err != nil {
t.Fatalf("Analyze() err = %v", err)
}
if gotToken != "" {
t.Errorf("GitHubToken = %q, want empty string", gotToken)
}
}

func TestAnalyzer_Analyze_ErrorPassthrough(t *testing.T) {
cases := []struct {
name string
err error
}{
{"invalid input", prism.ErrInvalidInput},
{"unsupported provider", prism.ErrUnsupportedProvider},
{"auth required", prism.ErrAuthRequired},
{"upstream failure", prism.ErrUpstreamFailure},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
a := fakeAnalyzer(func(_ context.Context, _ prism.AnalyzeOptions) (prism.Result, error) {
return prism.Result{}, tc.err
})
_, err := a.Analyze(context.Background(), AnalyzeInput{
PullRequestURL: "https://github.com/owner/repo/pull/1",
})
if !errors.Is(err, tc.err) {
t.Errorf("errors.Is(err, %v) = false, err = %v", tc.err, err)
}
})
}
}

func TestAnalyzer_Analyze_ContextPropagated(t *testing.T) {
// pkg/prism cancellation depends on the context reaching the call
// site, so verify the adapter forwards ctx rather than dropping it.
type ctxKey struct{}
want := "marker"
ctx := context.WithValue(context.Background(), ctxKey{}, want)

var observed any
a := fakeAnalyzer(func(ctx context.Context, _ prism.AnalyzeOptions) (prism.Result, error) {
observed = ctx.Value(ctxKey{})
return prism.Result{}, nil
})

if _, err := a.Analyze(ctx, AnalyzeInput{
PullRequestURL: "https://github.com/owner/repo/pull/1",
}); err != nil {
t.Fatalf("Analyze() err = %v", err)
}
if observed != want {
t.Errorf("context value in downstream call = %v, want %q", observed, want)
}
}

func TestNewAnalyzer_UsesRealPrism(t *testing.T) {
// Smoke check: the constructor must return a non-nil Analyzer whose
// analyze field is populated. We do not actually invoke pkg/prism
// here (that would reach the network).
a := NewAnalyzer()
if a == nil {
t.Fatal("NewAnalyzer() = nil")
}
if a.analyze == nil {
t.Error("analyze function field must be populated by NewAnalyzer")
}
}
Loading