From 31f93110b697572ea858241cab12c1b0baecebc9 Mon Sep 17 00:00:00 2001 From: hidetzu Date: Sun, 12 Apr 2026 07:29:07 +0900 Subject: [PATCH] feat(usecase): add analyze adapter for pkg/prism MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First piece of the /v1/analyze endpoint. Thin adapter between HTTP handlers and pkg/prism.Analyze, shaped so that handlers will depend on a small interface defined in the handler package (accept interfaces, return structs, per docs/development_rules.md §10); the concrete implementation lives here in internal/usecase. - AnalyzeInput carries the HTTP-layer-agnostic inputs that a handler extracts from its validated AnalyzeRequest. - Analyzer stores pkg/prism.Analyze in a function field rather than wrapping it in an interface. That keeps the indirection minimal and still lets tests inject a fake via field assignment without maintaining an extra wrapper type. - NewAnalyzer wires the real pkg/prism.Analyze as the function field. - Analyze always sets Provider: "github" (Phase 2 is GitHub only per the Phase 2 instruction §5) and keeps IncludePatches at the pkg/prism default of false. Provider auto-detection is deliberately bypassed so the provider is explicit in logs and debug traces. - pkg/prism's sentinel errors flow through unchanged so the handler layer can map them to response.Code via errors.Is. Tests cover: - Happy path with full AnalyzeOptions field inspection (Provider, PRURL, GitHubToken, IncludePatches false, Mode/Language empty) - Empty GitHubToken passthrough (Phase 2 supports public repos without authentication) - Error passthrough for all four pkg/prism sentinels (ErrInvalidInput, ErrUnsupportedProvider, ErrAuthRequired, ErrUpstreamFailure) - Context propagation to the underlying call - NewAnalyzer constructor smoke check (non-nil Analyzer with non-nil function field) Adds github.com/hidetzu/prism v0.3.0 as the first direct pkg/prism dependency. The adapter is not yet wired to an HTTP handler; PR 8 will add /v1/analyze and pull it in. --- go.mod | 2 + go.sum | 2 + internal/usecase/analyze.go | 57 +++++++++++++ internal/usecase/analyze_test.go | 142 +++++++++++++++++++++++++++++++ 4 files changed, 203 insertions(+) create mode 100644 internal/usecase/analyze.go create mode 100644 internal/usecase/analyze_test.go diff --git a/go.mod b/go.mod index 5a828de..0c44491 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 1cf3113..68c8337 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/usecase/analyze.go b/internal/usecase/analyze.go new file mode 100644 index 0000000..7c814f2 --- /dev/null +++ b/internal/usecase/analyze.go @@ -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, + }) +} diff --git a/internal/usecase/analyze_test.go b/internal/usecase/analyze_test.go new file mode 100644 index 0000000..8da3faa --- /dev/null +++ b/internal/usecase/analyze_test.go @@ -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") + } +}