From 12d4bbef9c5cbef246571da76ed89799ee85ad6a Mon Sep 17 00:00:00 2001 From: pikann22 Date: Sun, 25 Oct 2026 05:26:28 +0000 Subject: [PATCH 1/5] feat: implement wire contract for Git provider with operations and types - Added wire.go to define operations and their handling for the Git provider. - Introduced required and optional operation wrappers for better management of provider methods. - Created wiretypes.go to list all request, response, and event types for contract testing. - Added wireshape_test.go to validate JSON shapes against a golden file for consistency. - Implemented check-mutants.sh script to ensure known-bad providers are caught by the conformance suite. --- gitprovider/conformance/conformance.go | 1149 +++++++++++++++++ gitprovider/doc.go | 37 + gitprovider/errors.go | 115 ++ gitprovider/fakeprovider/fake.go | 715 +++++++++++ gitprovider/fakeprovider/fake_test.go | 13 + gitprovider/fakeprovider/harness.go | 102 ++ gitprovider/fakeprovider/mutants_test.go | 175 +++ gitprovider/fakeprovider/variants.go | 296 +++++ gitprovider/gitprovider_test.go | 85 ++ gitprovider/provider.go | 136 ++ gitprovider/serve/serve.go | 30 + gitprovider/serve/serve_test.go | 53 + gitprovider/testdata/wire-shapes.golden.json | 1179 ++++++++++++++++++ gitprovider/types.go | 940 ++++++++++++++ gitprovider/wire.go | 142 +++ gitprovider/wireshape_test.go | 93 ++ gitprovider/wiretypes.go | 41 + scripts/check-mutants.sh | 14 + 18 files changed, 5315 insertions(+) create mode 100644 gitprovider/conformance/conformance.go create mode 100644 gitprovider/doc.go create mode 100644 gitprovider/errors.go create mode 100644 gitprovider/fakeprovider/fake.go create mode 100644 gitprovider/fakeprovider/fake_test.go create mode 100644 gitprovider/fakeprovider/harness.go create mode 100644 gitprovider/fakeprovider/mutants_test.go create mode 100644 gitprovider/fakeprovider/variants.go create mode 100644 gitprovider/gitprovider_test.go create mode 100644 gitprovider/provider.go create mode 100644 gitprovider/serve/serve.go create mode 100644 gitprovider/serve/serve_test.go create mode 100644 gitprovider/testdata/wire-shapes.golden.json create mode 100644 gitprovider/types.go create mode 100644 gitprovider/wire.go create mode 100644 gitprovider/wireshape_test.go create mode 100644 gitprovider/wiretypes.go create mode 100755 scripts/check-mutants.sh diff --git a/gitprovider/conformance/conformance.go b/gitprovider/conformance/conformance.go new file mode 100644 index 0000000..939fbfe --- /dev/null +++ b/gitprovider/conformance/conformance.go @@ -0,0 +1,1149 @@ +// Package conformance is the test suite every Git provider must pass. +// +// A provider author supplies a [Harness]: a constructor that returns the +// provider wired to a fake backend (recorded fixtures / a local HTTP stub) and +// the [Fixtures] describing that backend's seed data. Run executes the suite as +// subtests. The suite checks behaviour core depends on — not the platform's +// semantics — so a passing provider is one core can drive safely: +// +// - capabilities are honest (every optional interface has its flag, and the +// other way round); +// - webhooks are verified, parsed deterministically, and never leak secrets; +// - errors are classified onto the SPI error set and never contain secrets; +// - reads and writes behave consistently (paging, truncation, idempotency, +// validation, emulated batch review); +// - unsupported operations answer "unsupported" instead of panicking. +// +// See gitprovider/fakeprovider for a harness that passes in full. +package conformance + +import ( + "context" + "encoding/json" + "errors" + "sort" + "strings" + "testing" + "time" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" +) + +// Harness wires a provider to its test backend. +type Harness struct { + // New returns a fresh provider with the seed data in Fixtures. It is called + // once per subtest so mutations never leak between tests. + New func(t *testing.T) gp.Provider + Fixtures Fixtures +} + +// Want is the expectation for one normalized event. +type Want struct { + Kind gp.EventKind + Action string + RepoPath string + SubjectKind gp.SubjectKind // empty: not checked + SubjectNumber string // empty: not checked + ActorLogin string + ActorKind gp.ActorKind + // Addressed lists, in any order, how the fixture identity was addressed. + Addressed []gp.AddressVia + // CheckConclusion is checked for EventCheck. + CheckConclusion string +} + +// WebhookCase is one canned delivery and the events it must produce. An empty +// Want asserts the delivery is dropped (no events). +type WebhookCase struct { + Name string + Request gp.WebhookRequest + Want []Want +} + +// Fixtures describe the backend's seed data. +type Fixtures struct { + Identity gp.IdentityRef + // Repo is a repository the identity can see, holding the change request, + // issue and thread below. + Repo gp.RepoRef + InvisibleRepo gp.RepoRef // a repository the identity cannot see + RepoURL string // a URL or path that ResolveRepository must resolve to Repo + Candidates []gp.IdentityRef + // ExpectedRepoPaths are all repositories the identity can see (for paging). + ExpectedRepoPaths []string + + ChangeRequest string // an existing change request number + MissingCR string // a number that does not exist + ChangedFiles []string // all files of ChangeRequest + HeadSHA string // ref with checks + FailingCheckID string // a check with logs + IssueNumber string + ThreadID string + // Actors maps logins to the permission GetActorPermission must return. + Actors map[string]gp.Permission + + // Webhooks are canned deliveries; BadSignature must fail verification. + Webhooks []WebhookCase + BadSignature gp.WebhookRequest + + // Secrets are values that must never appear in events or error messages + // (webhook secrets, stored tokens). + Secrets []string + + // Setup: a host and the parameters that complete setup successfully. + SetupHost string + SetupCompleteParams map[string]string + + // Writes run only when true (point real providers at a sandbox). + MutationsAllowed bool + NewBranchHead string // an existing branch with no open change request + BaseBranch string + File struct{ Ref, Path string } + SearchQuery string // a query that matches at least one file + Reviewer string // a login that can be requested as reviewer + + // AccessGrantParams are the callback parameters that complete an access + // grant started by BeginAccessGrant (providers declaring AccessGrants). + AccessGrantParams map[string]string + AccessOwner string // an owner to pass to BeginAccessGrant +} + +// Run executes the suite. +func Run(t *testing.T, h Harness) { + t.Helper() + if h.New == nil { + t.Fatal("conformance: Harness.New is required") + } + t.Run("describe", func(t *testing.T) { testDescribe(t, h) }) + t.Run("wire", func(t *testing.T) { testWire(t, h) }) + t.Run("setup", func(t *testing.T) { testSetup(t, h) }) + t.Run("repositories", func(t *testing.T) { testRepositories(t, h) }) + t.Run("webhooks", func(t *testing.T) { testWebhooks(t, h) }) + t.Run("credentials", func(t *testing.T) { testCredentials(t, h) }) + t.Run("policy-inputs", func(t *testing.T) { testPolicyInputs(t, h) }) + t.Run("reads", func(t *testing.T) { testReads(t, h) }) + t.Run("unsupported", func(t *testing.T) { testUnsupported(t, h) }) + t.Run("access-grants", func(t *testing.T) { testAccessGrants(t, h) }) + if h.Fixtures.MutationsAllowed { + t.Run("writes", func(t *testing.T) { testWrites(t, h) }) + t.Run("optional-writes", func(t *testing.T) { testOptionalWrites(t, h) }) + } +} + +// ── helpers ────────────────────────────────────────────────────────────────── + +func target(f Fixtures) gp.Target { return gp.Target{Identity: f.Identity, Repo: f.Repo} } + +func mustCode(t *testing.T, f Fixtures, err error, codes ...gp.ErrorCode) { + t.Helper() + if err == nil { + t.Fatalf("expected an error with code %v, got success", codes) + } + var pe *gp.Error + if !errors.As(err, &pe) { + t.Fatalf("error is not a *gitprovider.Error (unclassified): %T %v — map platform failures with gitprovider.Errorf", err, err) + } + for _, c := range codes { + if pe.Code == c { + noLeak(t, f, pe.Message) + return + } + } + t.Fatalf("error code = %q, want one of %v (message %q)", pe.Code, codes, pe.Message) +} + +func noLeak(t *testing.T, f Fixtures, text string) { + t.Helper() + for _, s := range f.Secrets { + if s != "" && strings.Contains(text, s) { + t.Fatalf("a secret leaked into output: %q", truncate(text)) + } + } +} + +func truncate(s string) string { + if len(s) > 120 { + return s[:120] + "…" + } + return s +} + +func isValidTrigger(k gp.TriggerKind) bool { + for _, v := range gp.AllTriggerKinds { + if v == k { + return true + } + } + return false +} + +func validEventKind(k gp.EventKind) bool { + for _, v := range gp.AllEventKinds { + if v == k { + return true + } + } + return false +} + +func ctx() context.Context { return context.Background() } + +// ── describe ───────────────────────────────────────────────────────────────── + +func testDescribe(t *testing.T, h Harness) { + p := h.New(t) + info := p.Describe() + if info.SPIVersion != gp.SPIVersion { + t.Errorf("SPIVersion = %q, want %q", info.SPIVersion, gp.SPIVersion) + } + if info.ID == "" || info.DisplayName == "" { + t.Error("ID and DisplayName are required") + } + if len(info.Hosts) == 0 { + t.Error("at least one default host is required") + } + if info.Terms.ChangeRequest == "" || info.Terms.ChangeRequestPlural == "" || info.Terms.Repository == "" { + t.Error("Terms must name the change request (singular and plural) and repository") + } + c := info.Capabilities + if len(c.IdentityKinds) == 0 { + t.Error("IdentityKinds must not be empty") + } + for _, k := range c.IdentityKinds { + if k != gp.IdentityApp && k != gp.IdentityBotUser && k != gp.IdentityToken { + t.Errorf("unknown identity kind %q", k) + } + } + if c.CredentialModel != gp.CredentialEphemeral && c.CredentialModel != gp.CredentialStatic { + t.Errorf("CredentialModel %q is invalid", c.CredentialModel) + } + if c.WebhookScope != gp.WebhookApp && c.WebhookScope != gp.WebhookGroup && c.WebhookScope != gp.WebhookRepo { + t.Errorf("WebhookScope %q is invalid", c.WebhookScope) + } + if c.StatusKind != gp.StatusCheckRun && c.StatusKind != gp.StatusCommitStatus && c.StatusKind != gp.StatusComment { + t.Errorf("StatusKind %q is invalid", c.StatusKind) + } + seen := map[gp.TriggerKind]bool{} + for _, k := range c.Triggers { + if !isValidTrigger(k) { + t.Errorf("unknown trigger %q", k) + } + if seen[k] { + t.Errorf("duplicate trigger %q", k) + } + seen[k] = true + } + for _, g := range c.CIConfigGlobs { + if strings.TrimSpace(g) == "" { + t.Error("CIConfigGlobs must not contain empty patterns") + } + } + // A webhook the provider must manage itself implies it can manage it. + if c.WebhookScope != gp.WebhookApp && !c.WebhookManagement { + t.Log("note: repo/group-scoped webhooks without WebhookManagement mean users must register webhooks by hand") + } + + // Capability honesty, both directions. + impl := gp.Implemented(p) + check := func(name string, declared, implemented bool) { + switch { + case declared && !implemented: + t.Errorf("capability %s is declared but the optional interface is not implemented", name) + case !declared && implemented: + t.Errorf("optional interface for %s is implemented but the capability is not declared (core would never call it)", name) + } + } + check("resolve_threads", c.ResolveThreads, impl.ResolveThreads) + check("file_read", c.FileRead, impl.FileRead) + check("code_search", c.CodeSearch, impl.CodeSearch) + check("labels", c.Labels, impl.Labels) + check("reviewer_requests", c.ReviewerRequests, impl.ReviewerRequests) + check("assignees", c.Assignees, impl.Assignees) + check("merge", c.Merge, impl.Merge) + check("rerun_checks", c.RerunChecks, impl.RerunChecks) + check("reactions", c.Reactions, impl.Reactions) + check("webhook_management", c.WebhookManagement, impl.WebhookManagement) + check("identity_deletion", c.IdentityDeletion, impl.IdentityDeletion) +} + +// ── wire ───────────────────────────────────────────────────────────────────── + +func testWire(t *testing.T, h Harness) { + p := h.New(t) + + status, body := gp.Dispatch(ctx(), p, "describe", nil) + if status != 200 { + t.Fatalf("describe status %d: %s", status, body) + } + var env gp.Envelope + if err := json.Unmarshal(body, &env); err != nil || !env.OK { + t.Fatalf("describe envelope: %v %s", err, body) + } + want, _ := json.Marshal(p.Describe()) + if string(env.Result) != string(want) { + t.Errorf("describe over the wire differs from Describe():\n got %s\nwant %s", env.Result, want) + } + + status, body = gp.Dispatch(ctx(), p, "no_such_operation", nil) + if status != gp.CodeUnsupported.HTTPStatus() || !strings.Contains(string(body), string(gp.CodeUnsupported)) { + t.Errorf("unknown operation: status %d body %s, want unsupported", status, body) + } + + // Every operation survives a malformed body without panicking and says so + // with a validation (or unsupported) error. + for _, op := range gp.Operations() { + if op.Name == "describe" { + continue + } + status, body := gp.Dispatch(ctx(), p, op.Name, []byte(`{not json`)) + var e gp.Envelope + if err := json.Unmarshal(body, &e); err != nil || e.OK || e.Error == nil { + t.Errorf("%s with malformed body: status %d body %s", op.Name, status, body) + continue + } + if e.Error.Code != gp.CodeValidation && e.Error.Code != gp.CodeUnsupported { + t.Errorf("%s with malformed body: code %q, want validation or unsupported", op.Name, e.Error.Code) + } + } +} + +// ── setup ──────────────────────────────────────────────────────────────────── + +func testSetup(t *testing.T, h Harness) { + f := h.Fixtures + if f.SetupHost == "" { + t.Skip("Fixtures.SetupHost not set") + } + p := h.New(t) + step, err := p.BeginIdentitySetup(ctx(), gp.BeginSetupRequest{Scope: gp.SetupShared, Host: f.SetupHost, + ReturnURL: "https://paca.test/return", CallbackURL: "https://paca.test/cb", WebhookURL: "https://paca.test/hook"}) + if err != nil { + t.Fatalf("BeginIdentitySetup: %v", err) + } + switch step.Kind { + case gp.StepRedirect, gp.StepFormPost: + if step.URL == "" { + t.Error("redirect/form_post steps need a URL") + } + case gp.StepTokenForm: + if step.Prompt == "" { + t.Error("token_form steps need a Prompt saying which scopes the token needs") + } + if len(step.Form) == 0 { + t.Error("token_form steps must list their inputs in Form") + } + for _, ff := range step.Form { + if ff.Name == "" || ff.Label == "" { + t.Errorf("form field needs a name and a label: %+v", ff) + } + } + case gp.StepDone: + default: + t.Errorf("unknown step kind %q", step.Kind) + } + if _, err := p.BeginIdentitySetup(ctx(), gp.BeginSetupRequest{Scope: gp.SetupShared, Host: "unknown.invalid"}); err == nil { + t.Error("an unknown host must be rejected") + } else { + mustCode(t, f, err, gp.CodeValidation) + } + if p.Describe().Capabilities.MultipleIdentities == false { + _, err := p.BeginIdentitySetup(ctx(), gp.BeginSetupRequest{Scope: gp.SetupAgent, Host: f.SetupHost, AgentID: "a1", AgentName: "dev"}) + mustCode(t, f, err, gp.CodeUnsupported) + } + if len(f.SetupCompleteParams) > 0 { + id, err := p.CompleteIdentitySetup(ctx(), gp.CompleteSetupRequest{Scope: gp.SetupShared, Host: f.SetupHost, State: step.State, Params: f.SetupCompleteParams}) + if err != nil { + t.Fatalf("CompleteIdentitySetup: %v", err) + } + if id.Ref.ID == "" || id.Host == "" || id.DisplayName == "" || id.Kind == "" { + t.Errorf("incomplete identity: %+v", id) + } + noLeak(t, f, mustJSON(id)) + } + // Missing required inputs must be rejected, not stored as an empty identity. + _, err = p.CompleteIdentitySetup(ctx(), gp.CompleteSetupRequest{Scope: gp.SetupShared, Host: f.SetupHost}) + mustCode(t, f, err, gp.CodeValidation, gp.CodeInvalidSignature, gp.CodeForbidden) +} + +func mustJSON(v any) string { b, _ := json.Marshal(v); return string(b) } + +// ── repositories ───────────────────────────────────────────────────────────── + +func testRepositories(t *testing.T, h Harness) { + f := h.Fixtures + p := h.New(t) + + all, err := p.ListRepositories(ctx(), gp.ListReposRequest{Identity: f.Identity}) + if err != nil { + t.Fatalf("ListRepositories: %v", err) + } + var paths []string + for _, r := range all.Items { + paths = append(paths, r.Ref.FullPath) + if r.Ref.Host == "" || r.Ref.FullPath == "" || r.Ref.CloneURL == "" { + t.Errorf("repository %+v needs host, full_path and clone_url", r.Ref) + } + if strings.Contains(r.Ref.CloneURL, "@") { + t.Errorf("clone_url %q must be credential-free", r.Ref.CloneURL) + } + } + sort.Strings(paths) + if len(f.ExpectedRepoPaths) > 0 { + want := append([]string(nil), f.ExpectedRepoPaths...) + sort.Strings(want) + if strings.Join(paths, ",") != strings.Join(want, ",") { + t.Errorf("repositories = %v, want %v", paths, want) + } + } + + // Paging with page size 1 must visit exactly the same repositories. + var paged []string + cursor := "" + for i := 0; i < 100; i++ { + page, err := p.ListRepositories(ctx(), gp.ListReposRequest{Identity: f.Identity, PageSize: 1, Cursor: cursor}) + if err != nil { + t.Fatalf("paged ListRepositories: %v", err) + } + if len(page.Items) > 1 { + t.Fatalf("PageSize 1 returned %d items", len(page.Items)) + } + for _, r := range page.Items { + paged = append(paged, r.Ref.FullPath) + } + if page.NextCursor == "" { + break + } + cursor = page.NextCursor + } + sort.Strings(paged) + if strings.Join(paged, ",") != strings.Join(paths, ",") { + t.Errorf("paging visited %v, a single page returned %v", paged, paths) + } + + _, err = p.ListRepositories(ctx(), gp.ListReposRequest{Identity: gp.IdentityRef{ID: "no-such-identity"}}) + mustCode(t, f, err, gp.CodeForbidden, gp.CodeNotFound) + + if f.RepoURL != "" { + res, err := p.ResolveRepository(ctx(), gp.ResolveRepoRequest{PathOrURL: f.RepoURL, Candidates: f.Candidates}) + if err != nil { + t.Fatalf("ResolveRepository: %v", err) + } + if res.Repository.Ref.FullPath != f.Repo.FullPath { + t.Errorf("resolved %q, want %q", res.Repository.Ref.FullPath, f.Repo.FullPath) + } + if res.Identity.ID == "" { + t.Error("ResolveRepository must say which identity can see the repository") + } + _, err = p.ResolveRepository(ctx(), gp.ResolveRepoRequest{PathOrURL: f.RepoURL}) + mustCode(t, f, err, gp.CodeForbidden, gp.CodeNotFound) + } + _, err = p.ResolveRepository(ctx(), gp.ResolveRepoRequest{PathOrURL: "definitely/not-a-repo-xyz", Candidates: f.Candidates}) + mustCode(t, f, err, gp.CodeNotFound, gp.CodeForbidden) +} + +// ── webhooks ───────────────────────────────────────────────────────────────── + +func testWebhooks(t *testing.T, h Harness) { + f := h.Fixtures + p := h.New(t) + caps := p.Describe().Capabilities + + if f.BadSignature.Body != nil || len(f.BadSignature.Headers) > 0 { + evs, err := p.VerifyAndParseWebhook(ctx(), f.BadSignature) + mustCode(t, f, err, gp.CodeInvalidSignature) + if len(evs) != 0 { + t.Error("a delivery that fails verification must produce no events") + } + } else { + t.Error("Fixtures.BadSignature is required: every provider must verify deliveries") + } + if len(f.Webhooks) == 0 { + t.Fatal("Fixtures.Webhooks is required") + } + + for _, wc := range f.Webhooks { + t.Run(wc.Name, func(t *testing.T) { + evs, err := p.VerifyAndParseWebhook(ctx(), wc.Request) + if err != nil { + t.Fatalf("VerifyAndParseWebhook: %v", err) + } + if len(evs) != len(wc.Want) { + t.Fatalf("got %d events, want %d: %s", len(evs), len(wc.Want), mustJSON(evs)) + } + ids := map[string]bool{} + for i, ev := range evs { + checkEventInvariants(t, f, caps, ev) + if ids[ev.ID] { + t.Errorf("duplicate event id %q within one delivery", ev.ID) + } + ids[ev.ID] = true + checkWant(t, ev, wc.Want[i], caps) + } + // Deterministic: the same delivery yields the same event ids (core's + // idempotency depends on it). + again, err := p.VerifyAndParseWebhook(ctx(), wc.Request) + if err != nil || len(again) != len(evs) { + t.Fatalf("second parse: %v (%d events)", err, len(again)) + } + for i := range evs { + if again[i].ID != evs[i].ID { + t.Errorf("event id is not deterministic: %q vs %q", again[i].ID, evs[i].ID) + } + } + }) + } +} + +func checkEventInvariants(t *testing.T, f Fixtures, caps gp.Capabilities, ev gp.Event) { + t.Helper() + if ev.ID == "" { + t.Error("event id is required") + } + if !validEventKind(ev.Kind) { + t.Errorf("unknown event kind %q", ev.Kind) + } + if ev.Action == "" { + t.Error("event action is required") + } + if ev.Repo.FullPath == "" || ev.Repo.Host == "" { + t.Errorf("event repo needs host and full_path: %+v", ev.Repo) + } + if ev.Actor.Login == "" { + t.Error("event actor login is required") + } + if ev.Actor.Kind != gp.ActorUser && ev.Actor.Kind != gp.ActorBot { + t.Errorf("actor kind %q is invalid", ev.Actor.Kind) + } + if ev.Actor.Permission != "" && !ev.Actor.Permission.Valid() { + t.Errorf("actor permission %q is invalid", ev.Actor.Permission) + } + if ev.Subject != nil { + if ev.Subject.Kind != gp.SubjectIssue && ev.Subject.Kind != gp.SubjectChangeRequest { + t.Errorf("subject kind %q is invalid", ev.Subject.Kind) + } + if ev.Subject.Number == "" { + t.Error("subject number is required") + } + } + if ev.Kind == gp.EventCheck && (ev.Check == nil || ev.Check.Conclusion == "") { + t.Error("check events need check.conclusion") + } + if ev.Kind == gp.EventPush && (ev.Push == nil || ev.Push.Ref == "") { + t.Error("push events need push.ref (the branch name)") + } + for _, a := range ev.Addressed { + if a.IdentityID != f.Identity.ID { + t.Errorf("addressed identity %q is not the fixture identity %q", a.IdentityID, f.Identity.ID) + } + switch a.Via { + case gp.ViaMention, gp.ViaReviewRequest, gp.ViaAssignment, gp.ViaLabel: + default: + t.Errorf("unknown addressed.via %q", a.Via) + continue + } + if need := triggerFor(a.Via); !caps.SupportsTrigger(need) { + t.Errorf("event reports being addressed via %q but the %q trigger is not declared in capabilities", a.Via, need) + } + } + noLeak(t, f, mustJSON(ev)) +} + +func triggerFor(v gp.AddressVia) gp.TriggerKind { + switch v { + case gp.ViaMention: + return gp.TriggerMention + case gp.ViaReviewRequest: + return gp.TriggerReviewRequest + case gp.ViaAssignment: + return gp.TriggerAssignment + } + return gp.TriggerLabel +} + +func checkWant(t *testing.T, ev gp.Event, w Want, caps gp.Capabilities) { + t.Helper() + if ev.Kind != w.Kind { + t.Errorf("kind = %q, want %q", ev.Kind, w.Kind) + } + if w.Action != "" && ev.Action != w.Action { + t.Errorf("action = %q, want %q", ev.Action, w.Action) + } + if w.RepoPath != "" && ev.Repo.FullPath != w.RepoPath { + t.Errorf("repo = %q, want %q", ev.Repo.FullPath, w.RepoPath) + } + if w.SubjectKind != "" && (ev.Subject == nil || ev.Subject.Kind != w.SubjectKind) { + t.Errorf("subject kind mismatch: %+v want %q", ev.Subject, w.SubjectKind) + } + if w.SubjectNumber != "" && (ev.Subject == nil || ev.Subject.Number != w.SubjectNumber) { + t.Errorf("subject number mismatch: %+v want %q", ev.Subject, w.SubjectNumber) + } + if w.ActorLogin != "" && ev.Actor.Login != w.ActorLogin { + t.Errorf("actor = %q, want %q", ev.Actor.Login, w.ActorLogin) + } + if w.ActorKind != "" && ev.Actor.Kind != w.ActorKind { + t.Errorf("actor kind = %q, want %q", ev.Actor.Kind, w.ActorKind) + } + var got, want []string + for _, a := range ev.Addressed { + got = append(got, string(a.Via)) + } + for _, v := range w.Addressed { + // A way of being addressed is only expected from providers that declare + // the matching trigger; the same fixture serves rich and basic platforms. + if caps.SupportsTrigger(triggerFor(v)) { + want = append(want, string(v)) + } + } + sort.Strings(got) + sort.Strings(want) + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("addressed = %v, want %v", got, want) + } + if w.CheckConclusion != "" && (ev.Check == nil || ev.Check.Conclusion != w.CheckConclusion) { + t.Errorf("check conclusion mismatch: %+v want %q", ev.Check, w.CheckConclusion) + } +} + +// ── credentials ────────────────────────────────────────────────────────────── + +func testCredentials(t *testing.T, h Harness) { + f := h.Fixtures + p := h.New(t) + caps := p.Describe().Capabilities + + for _, access := range []gp.Access{gp.AccessRead, gp.AccessWrite} { + c, err := p.MintCredential(ctx(), gp.MintRequest{Target: target(f), Access: access, TTLSeconds: 300}) + if err != nil { + t.Fatalf("MintCredential(%s): %v", access, err) + } + if c.Secret == "" || c.Username == "" { + t.Errorf("credential needs username and secret: username=%q secret-empty=%v", c.Username, c.Secret == "") + } + if caps.CredentialModel == gp.CredentialEphemeral { + if c.ExpiresAt.IsZero() { + t.Error("ephemeral credentials must carry ExpiresAt") + } else if d := time.Until(c.ExpiresAt); d <= 0 || d > 65*time.Minute { + t.Errorf("ephemeral credential lifetime %v is outside (0, 65m]", d) + } + } + } + _, err := p.MintCredential(ctx(), gp.MintRequest{Target: target(f), Access: "admin"}) + mustCode(t, f, err, gp.CodeValidation) + + if f.InvisibleRepo.FullPath != "" { + _, err = p.MintCredential(ctx(), gp.MintRequest{Target: gp.Target{Identity: f.Identity, Repo: f.InvisibleRepo}, Access: gp.AccessRead}) + mustCode(t, f, err, gp.CodeNotFound, gp.CodeForbidden) + } +} + +// ── policy inputs ──────────────────────────────────────────────────────────── + +func testPolicyInputs(t *testing.T, h Harness) { + f := h.Fixtures + p := h.New(t) + for login, want := range f.Actors { + got, err := p.GetActorPermission(ctx(), gp.ActorPermissionRequest{Target: target(f), Login: login}) + if err != nil { + t.Fatalf("GetActorPermission(%s): %v", login, err) + } + if !got.Valid() { + t.Errorf("GetActorPermission(%s) = %q, not a defined permission", login, got) + } + if got != want { + t.Errorf("GetActorPermission(%s) = %q, want %q", login, got, want) + } + } + // An unknown login has no access — not an error. + got, err := p.GetActorPermission(ctx(), gp.ActorPermissionRequest{Target: target(f), Login: "nobody-at-all-xyz"}) + if err != nil || got != gp.PermissionNone { + t.Errorf("unknown login: %q, %v; want none and no error", got, err) + } + if _, err := p.GetBranchProtection(ctx(), gp.BranchProtectionRequest{Target: target(f), Branch: f.Repo.DefaultBranch}); err != nil { + t.Errorf("GetBranchProtection: %v", err) + } +} + +// ── reads ──────────────────────────────────────────────────────────────────── + +func testReads(t *testing.T, h Harness) { + f := h.Fixtures + p := h.New(t) + tg := target(f) + + cr, err := p.GetChangeRequest(ctx(), gp.CRRequest{Target: tg, Number: f.ChangeRequest}) + if err != nil { + t.Fatalf("GetChangeRequest: %v", err) + } + switch cr.State { + case gp.CROpen, gp.CRClosed, gp.CRMerged: + default: + t.Errorf("state %q is invalid", cr.State) + } + if cr.Number != f.ChangeRequest || cr.Title == "" || cr.Head.Ref == "" || cr.Base.Ref == "" || cr.URL == "" || cr.Author == "" { + t.Errorf("incomplete change request: %+v", cr) + } + _, err = p.GetChangeRequest(ctx(), gp.CRRequest{Target: tg, Number: f.MissingCR}) + mustCode(t, f, err, gp.CodeNotFound) + + // Changed files: all pages, no duplicates, exactly the expected set. + var files []string + seen := map[string]bool{} + cursor := "" + for i := 0; i < 100; i++ { + page, err := p.ListChangedFiles(ctx(), gp.PagedCRRequest{Target: tg, Number: f.ChangeRequest, Cursor: cursor}) + if err != nil { + t.Fatalf("ListChangedFiles: %v", err) + } + for _, cf := range page.Items { + if seen[cf.Path] { + t.Errorf("file %q listed twice across pages", cf.Path) + } + seen[cf.Path] = true + files = append(files, cf.Path) + } + if page.NextCursor == "" { + break + } + cursor = page.NextCursor + } + sort.Strings(files) + want := append([]string(nil), f.ChangedFiles...) + sort.Strings(want) + if len(want) > 0 && strings.Join(files, ",") != strings.Join(want, ",") { + t.Errorf("changed files = %v, want %v", files, want) + } + + full, err := p.GetDiff(ctx(), gp.DiffRequest{Target: tg, Number: f.ChangeRequest}) + if err != nil || full.Text == "" { + t.Fatalf("GetDiff: %v (empty=%v)", err, full.Text == "") + } + if len(files) > 0 { + one, err := p.GetDiff(ctx(), gp.DiffRequest{Target: tg, Number: f.ChangeRequest, Path: files[0]}) + if err != nil || one.Text == "" || len(one.Text) > len(full.Text) { + t.Errorf("per-file GetDiff: %v len=%d (whole=%d)", err, len(one.Text), len(full.Text)) + } + _, err = p.GetDiff(ctx(), gp.DiffRequest{Target: tg, Number: f.ChangeRequest, Path: "no/such/file.xyz"}) + mustCode(t, f, err, gp.CodeNotFound) + } + if len(full.Text) > 20 { + cut, err := p.GetDiff(ctx(), gp.DiffRequest{Target: tg, Number: f.ChangeRequest, MaxBytes: 10}) + if err != nil { + t.Fatalf("GetDiff with MaxBytes: %v", err) + } + if len(cut.Text) > 10 || !cut.Truncated { + t.Errorf("MaxBytes=10 returned %d bytes truncated=%v; want <=10 and truncated (silent truncation hides content)", len(cut.Text), cut.Truncated) + } + } + + threads, err := p.ListThreads(ctx(), gp.PagedCRRequest{Target: tg, Number: f.ChangeRequest}) + if err != nil { + t.Fatalf("ListThreads: %v", err) + } + for _, th := range threads.Items { + if th.ID == "" || len(th.Comments) == 0 { + t.Errorf("thread needs an id and at least one comment: %+v", th) + } + for _, c := range th.Comments { + if c.ID == "" || c.Author == "" || (c.AuthorKind != gp.ActorUser && c.AuthorKind != gp.ActorBot) { + t.Errorf("incomplete thread comment: %+v", c) + } + } + } + if f.IssueNumber != "" { + is, err := p.GetIssue(ctx(), gp.IssueRequest{Target: tg, Number: f.IssueNumber}) + if err != nil || is.Title == "" || is.URL == "" { + t.Errorf("GetIssue: %+v %v", is, err) + } + _, err = p.GetIssue(ctx(), gp.IssueRequest{Target: tg, Number: f.MissingCR}) + mustCode(t, f, err, gp.CodeNotFound) + } + if _, err := p.ListComments(ctx(), gp.ListCommentsRequest{Target: tg, Subject: gp.SubjectRef{Kind: gp.SubjectChangeRequest, Number: f.ChangeRequest}}); err != nil { + t.Errorf("ListComments: %v", err) + } + + if f.HeadSHA != "" { + checks, err := p.GetChecks(ctx(), gp.ChecksRequest{Target: tg, Ref: f.HeadSHA}) + if err != nil { + t.Fatalf("GetChecks: %v", err) + } + for _, c := range checks { + if c.ID == "" || c.Name == "" || !c.State.Valid() { + t.Errorf("invalid check: %+v", c) + } + } + } + if f.FailingCheckID != "" { + logs, err := p.GetCheckLogs(ctx(), gp.CheckLogsRequest{Target: tg, CheckID: f.FailingCheckID}) + if err != nil || logs.Text == "" { + t.Errorf("GetCheckLogs: %v empty=%v", err, logs.Text == "") + } + tail, err := p.GetCheckLogs(ctx(), gp.CheckLogsRequest{Target: tg, CheckID: f.FailingCheckID, TailBytes: 5}) + if err != nil || len(tail.Text) > 5 || (len(logs.Text) > 5 && !tail.Truncated) { + t.Errorf("tail-limited logs: %q truncated=%v err=%v", tail.Text, tail.Truncated, err) + } + _, err = p.GetCheckLogs(ctx(), gp.CheckLogsRequest{Target: tg, CheckID: "no-such-check"}) + mustCode(t, f, err, gp.CodeNotFound) + } + // Reads on a repository the identity cannot see never succeed. + if f.InvisibleRepo.FullPath != "" { + _, err = p.GetChangeRequest(ctx(), gp.CRRequest{Target: gp.Target{Identity: f.Identity, Repo: f.InvisibleRepo}, Number: f.ChangeRequest}) + mustCode(t, f, err, gp.CodeNotFound, gp.CodeForbidden) + } + + // Optional reads, only when declared. + caps := p.Describe().Capabilities + if fr, ok := p.(gp.FileReader); ok && caps.FileRead && f.File.Path != "" { + file, err := fr.GetFile(ctx(), gp.FileRequest{Target: tg, Ref: f.File.Ref, Path: f.File.Path}) + if err != nil || len(file.Content) == 0 { + t.Errorf("GetFile: %v", err) + } + _, err = fr.GetFile(ctx(), gp.FileRequest{Target: tg, Ref: f.File.Ref, Path: "no/such/file.xyz"}) + mustCode(t, f, err, gp.CodeNotFound) + if len(file.Content) > 4 { + cut, err := fr.GetFile(ctx(), gp.FileRequest{Target: tg, Ref: f.File.Ref, Path: f.File.Path, MaxBytes: 4}) + if err != nil || len(cut.Content) > 4 || !cut.Truncated { + t.Errorf("GetFile MaxBytes=4: len=%d truncated=%v err=%v", len(cut.Content), cut.Truncated, err) + } + } + } + if cs, ok := p.(gp.CodeSearcher); ok && caps.CodeSearch && f.SearchQuery != "" { + hits, err := cs.SearchCode(ctx(), gp.CodeSearchRequest{Target: tg, Query: f.SearchQuery}) + if err != nil || len(hits.Items) == 0 { + t.Errorf("SearchCode: %v hits=%d", err, len(hits.Items)) + } + } +} + +// ── unsupported ────────────────────────────────────────────────────────────── + +func testUnsupported(t *testing.T, h Harness) { + p := h.New(t) + caps := mustJSONMap(p.Describe().Capabilities) + for _, op := range gp.Operations() { + if op.Capability == "" { + continue + } + declared, _ := caps[op.Capability].(bool) + if declared { + continue + } + status, body := gp.Dispatch(ctx(), p, op.Name, []byte(`{}`)) + var e gp.Envelope + if err := json.Unmarshal(body, &e); err != nil || e.OK || e.Error == nil || e.Error.Code != gp.CodeUnsupported || status != gp.CodeUnsupported.HTTPStatus() { + t.Errorf("%s is not declared (%s=false) but answered status %d: %s; want unsupported", op.Name, op.Capability, status, body) + } + } +} + +func mustJSONMap(v any) map[string]any { + var m map[string]any + _ = json.Unmarshal([]byte(mustJSON(v)), &m) + return m +} + +// ── access grants ──────────────────────────────────────────────────────────── + +func testAccessGrants(t *testing.T, h Harness) { + f := h.Fixtures + p := h.New(t) + ag, ok := p.(gp.AccessGranter) + if !p.Describe().Capabilities.AccessGrants || !ok { + t.Skip("provider does not declare access_grants") + } + begin := gp.BeginAccessRequest{Identity: f.Identity, Owner: f.AccessOwner, ReturnURL: "https://paca.test/return", CallbackURL: "https://paca.test/cb"} + step, err := ag.BeginAccessGrant(ctx(), begin) + if err != nil { + t.Fatalf("BeginAccessGrant: %v", err) + } + switch step.Kind { + case gp.StepRedirect, gp.StepFormPost: + if step.URL == "" { + t.Error("redirect/form_post steps need a URL") + } + default: + t.Errorf("an access grant is a trip to the platform: step kind %q", step.Kind) + } + if step.State == "" { + t.Error("the step needs a State: it is how the callback is matched to this grant") + } + noLeak(t, f, mustJSON(step)) + + bad := begin + bad.Identity = gp.IdentityRef{ID: "no-such-identity"} + _, err = ag.BeginAccessGrant(ctx(), bad) + mustCode(t, f, err, gp.CodeNotFound, gp.CodeForbidden, gp.CodeValidation) + + // A callback with no state, or a state this provider never issued, grants nothing. + _, err = ag.CompleteAccessGrant(ctx(), gp.CompleteAccessRequest{Identity: f.Identity, Params: f.AccessGrantParams}) + mustCode(t, f, err, gp.CodeValidation, gp.CodeForbidden) + _, err = ag.CompleteAccessGrant(ctx(), gp.CompleteAccessRequest{Identity: f.Identity, State: "never-issued", Params: f.AccessGrantParams}) + mustCode(t, f, err, gp.CodeForbidden, gp.CodeNotFound, gp.CodeValidation) + + if len(f.AccessGrantParams) == 0 { + return + } + grant, err := ag.CompleteAccessGrant(ctx(), gp.CompleteAccessRequest{Identity: f.Identity, State: step.State, Params: f.AccessGrantParams}) + if err != nil { + t.Fatalf("CompleteAccessGrant: %v", err) + } + noLeak(t, f, mustJSON(grant)) + // A state works once: replaying a callback must not grant again. + _, err = ag.CompleteAccessGrant(ctx(), gp.CompleteAccessRequest{Identity: f.Identity, State: step.State, Params: f.AccessGrantParams}) + mustCode(t, f, err, gp.CodeForbidden, gp.CodeNotFound, gp.CodeValidation) +} + +// ── writes ─────────────────────────────────────────────────────────────────── + +func testWrites(t *testing.T, h Harness) { + f := h.Fixtures + tg := target(f) + + t.Run("open-change-request", func(t *testing.T) { + p := h.New(t) + caps := p.Describe().Capabilities + if f.NewBranchHead == "" { + t.Skip("Fixtures.NewBranchHead not set") + } + cr, err := p.OpenChangeRequest(ctx(), gp.OpenCRRequest{Target: tg, Title: "Conformance", Body: "body", Head: f.NewBranchHead, Base: f.BaseBranch}) + if err != nil { + t.Fatalf("OpenChangeRequest: %v", err) + } + if cr.Number == "" || cr.State != gp.CROpen || cr.Head.Ref != f.NewBranchHead || cr.URL == "" { + t.Errorf("unexpected change request: %+v", cr) + } + // A second open for the same branch must not silently create another. + _, err = p.OpenChangeRequest(ctx(), gp.OpenCRRequest{Target: tg, Title: "Again", Head: f.NewBranchHead, Base: f.BaseBranch}) + mustCode(t, f, err, gp.CodeConflict, gp.CodeValidation) + _, err = p.OpenChangeRequest(ctx(), gp.OpenCRRequest{Target: tg, Head: f.NewBranchHead, Base: f.BaseBranch}) + mustCode(t, f, err, gp.CodeValidation) + if caps.DraftChangeRequests { + d, err := p.OpenChangeRequest(ctx(), gp.OpenCRRequest{Target: tg, Title: "Draft", Head: "draft-branch", Base: f.BaseBranch, Draft: true}) + if err != nil || !d.Draft { + t.Errorf("draft change request: %+v %v", d, err) + } + } + if caps.IdempotentWrites { + a, errA := p.OpenChangeRequest(ctx(), gp.OpenCRRequest{Target: tg, Title: "Idem", Head: "idem-branch", Base: f.BaseBranch, IdempotencyKey: "k1"}) + b, errB := p.OpenChangeRequest(ctx(), gp.OpenCRRequest{Target: tg, Title: "Idem", Head: "idem-branch", Base: f.BaseBranch, IdempotencyKey: "k1"}) + if errA != nil || errB != nil || a.Number != b.Number { + t.Errorf("idempotent open: %v %v numbers %q/%q", errA, errB, a.Number, b.Number) + } + } + title := "Renamed" + up, err := p.UpdateChangeRequest(ctx(), gp.UpdateCRRequest{Target: tg, Number: cr.Number, Title: &title}) + if err != nil || up.Title != title { + t.Errorf("UpdateChangeRequest: %+v %v", up, err) + } + bad := gp.CRMerged + _, err = p.UpdateChangeRequest(ctx(), gp.UpdateCRRequest{Target: tg, Number: cr.Number, State: &bad}) + mustCode(t, f, err, gp.CodeValidation) + }) + + t.Run("comments", func(t *testing.T) { + p := h.New(t) + caps := p.Describe().Capabilities + subj := gp.SubjectRef{Kind: gp.SubjectChangeRequest, Number: f.ChangeRequest} + r, err := p.Comment(ctx(), gp.CommentRequest{Target: tg, Subject: subj, Body: "hello"}) + if err != nil || r.ID == "" { + t.Fatalf("Comment: %+v %v (core indexes comments by id, so an id is mandatory)", r, err) + } + _, err = p.Comment(ctx(), gp.CommentRequest{Target: tg, Subject: subj, Body: " "}) + mustCode(t, f, err, gp.CodeValidation) + _, err = p.Comment(ctx(), gp.CommentRequest{Target: tg, Subject: gp.SubjectRef{Kind: gp.SubjectChangeRequest, Number: f.MissingCR}, Body: "x"}) + mustCode(t, f, err, gp.CodeNotFound) + if caps.IdempotentWrites { + a, _ := p.Comment(ctx(), gp.CommentRequest{Target: tg, Subject: subj, Body: "same", IdempotencyKey: "c1"}) + b, _ := p.Comment(ctx(), gp.CommentRequest{Target: tg, Subject: subj, Body: "same", IdempotencyKey: "c1"}) + if a.ID == "" || a.ID != b.ID { + t.Errorf("idempotent comment returned %q then %q", a.ID, b.ID) + } + } + if f.ThreadID != "" { + rr, err := p.ReplyThread(ctx(), gp.ReplyThreadRequest{Target: tg, Number: f.ChangeRequest, ThreadID: f.ThreadID, Body: "done"}) + if err != nil || rr.ID == "" { + t.Errorf("ReplyThread: %+v %v", rr, err) + } + _, err = p.ReplyThread(ctx(), gp.ReplyThreadRequest{Target: tg, Number: f.ChangeRequest, ThreadID: "no-such-thread", Body: "x"}) + mustCode(t, f, err, gp.CodeNotFound) + } + }) + + t.Run("review", func(t *testing.T) { + p := h.New(t) + caps := p.Describe().Capabilities + inline := []gp.InlineComment{} + if len(f.ChangedFiles) > 0 { + inline = append(inline, gp.InlineComment{Path: f.ChangedFiles[0], Line: 1, Side: "new", Body: "nit"}) + } + res, err := p.SubmitReview(ctx(), gp.SubmitReviewRequest{Target: tg, Number: f.ChangeRequest, Verdict: gp.VerdictComment, Summary: "looks fine", Comments: inline}) + if err != nil { + t.Fatalf("SubmitReview: %v", err) + } + if res.Posted != len(inline) { + t.Errorf("Posted = %d, want %d", res.Posted, len(inline)) + } + if res.Emulated == caps.BatchReview { + t.Errorf("Emulated=%v contradicts BatchReview=%v: a provider without batch review must emulate and report it", res.Emulated, caps.BatchReview) + } + _, err = p.SubmitReview(ctx(), gp.SubmitReviewRequest{Target: tg, Number: f.ChangeRequest, Verdict: "shrug"}) + mustCode(t, f, err, gp.CodeValidation) + _, err = p.SubmitReview(ctx(), gp.SubmitReviewRequest{Target: tg, Number: f.ChangeRequest, Verdict: gp.VerdictComment, + Comments: []gp.InlineComment{{Path: "not/in/the/diff.go", Line: 1, Body: "x"}}}) + mustCode(t, f, err, gp.CodeValidation) + _, err = p.SubmitReview(ctx(), gp.SubmitReviewRequest{Target: tg, Number: f.MissingCR, Verdict: gp.VerdictComment, Summary: "x"}) + mustCode(t, f, err, gp.CodeNotFound) + }) + + t.Run("status", func(t *testing.T) { + p := h.New(t) + subj := gp.SubjectRef{Kind: gp.SubjectChangeRequest, Number: f.ChangeRequest} + st, err := p.SetStatus(ctx(), gp.SetStatusRequest{Target: tg, Subject: subj, SHA: f.HeadSHA, State: gp.RunQueued, Title: "Paca / dev"}) + if err != nil || st.StatusRef == "" { + t.Fatalf("SetStatus: %+v %v", st, err) + } + upd, err := p.SetStatus(ctx(), gp.SetStatusRequest{Target: tg, Subject: subj, SHA: f.HeadSHA, State: gp.RunSuccess, Title: "Paca / dev", Summary: "done", StatusRef: st.StatusRef}) + if err != nil || upd.StatusRef == "" { + t.Errorf("updating a status by its reference: %+v %v", upd, err) + } + }) +} + +func testOptionalWrites(t *testing.T, h Harness) { + f := h.Fixtures + tg := target(f) + probe := h.New(t) + caps := probe.Describe().Capabilities + subj := gp.SubjectRef{Kind: gp.SubjectChangeRequest, Number: f.ChangeRequest} + + if caps.ResolveThreads && f.ThreadID != "" { + t.Run("resolve-thread", func(t *testing.T) { + p := h.New(t) + tr := p.(gp.ThreadResolver) + if _, err := tr.ResolveThread(ctx(), gp.ResolveThreadRequest{Target: tg, Number: f.ChangeRequest, ThreadID: f.ThreadID, Resolved: true}); err != nil { + t.Fatalf("ResolveThread: %v", err) + } + page, err := p.ListThreads(ctx(), gp.PagedCRRequest{Target: tg, Number: f.ChangeRequest}) + if err != nil { + t.Fatal(err) + } + resolved := false + for _, th := range page.Items { + if th.ID == f.ThreadID { + resolved = th.Resolved + } + } + if !resolved { + t.Error("thread is not reported resolved after ResolveThread") + } + _, err = tr.ResolveThread(ctx(), gp.ResolveThreadRequest{Target: tg, Number: f.ChangeRequest, ThreadID: "no-such-thread", Resolved: true}) + mustCode(t, f, err, gp.CodeNotFound) + }) + } + if caps.Labels { + t.Run("labels", func(t *testing.T) { + p := h.New(t) + if _, err := p.(gp.LabelManager).AddLabels(ctx(), gp.LabelsRequest{Target: tg, Subject: subj, Labels: []string{"paca"}}); err != nil { + t.Errorf("AddLabels: %v", err) + } + cr, err := p.GetChangeRequest(ctx(), gp.CRRequest{Target: tg, Number: f.ChangeRequest}) + if err != nil || !contains(cr.Labels, "paca") { + t.Errorf("label not visible afterwards: %v %v", cr.Labels, err) + } + }) + } + if caps.ReviewerRequests && f.Reviewer != "" { + t.Run("reviewers", func(t *testing.T) { + p := h.New(t) + if _, err := p.(gp.ReviewerRequester).RequestReviewers(ctx(), gp.PeopleRequest{Target: tg, Subject: subj, Logins: []string{f.Reviewer}}); err != nil { + t.Errorf("RequestReviewers: %v", err) + } + }) + } + if caps.Assignees && f.IssueNumber != "" { + t.Run("assignees", func(t *testing.T) { + p := h.New(t) + if _, err := p.(gp.AssigneeSetter).SetAssignees(ctx(), gp.PeopleRequest{Target: tg, Subject: gp.SubjectRef{Kind: gp.SubjectIssue, Number: f.IssueNumber}, Logins: []string{f.Identity.Handle}}); err != nil { + t.Errorf("SetAssignees: %v", err) + } + }) + } + if caps.Merge { + t.Run("merge", func(t *testing.T) { + p := h.New(t) + m := p.(gp.Merger) + cr, err := p.GetChangeRequest(ctx(), gp.CRRequest{Target: tg, Number: f.ChangeRequest}) + if err != nil { + t.Fatal(err) + } + // The expected head is mandatory and a mismatch must refuse. + _, err = m.MergeChangeRequest(ctx(), gp.MergeRequest{Target: tg, Number: f.ChangeRequest}) + mustCode(t, f, err, gp.CodeValidation) + _, err = m.MergeChangeRequest(ctx(), gp.MergeRequest{Target: tg, Number: f.ChangeRequest, ExpectedHeadSHA: "stale-sha"}) + mustCode(t, f, err, gp.CodeConflict) + if _, err := m.MergeChangeRequest(ctx(), gp.MergeRequest{Target: tg, Number: f.ChangeRequest, ExpectedHeadSHA: cr.Head.SHA, Method: "squash"}); err != nil { + t.Fatalf("merge with the right head: %v", err) + } + after, _ := p.GetChangeRequest(ctx(), gp.CRRequest{Target: tg, Number: f.ChangeRequest}) + if after.State != gp.CRMerged { + t.Errorf("state after merge = %q", after.State) + } + }) + } + if caps.RerunChecks && f.HeadSHA != "" { + t.Run("rerun-checks", func(t *testing.T) { + p := h.New(t) + if _, err := p.(gp.CheckRerunner).RerunChecks(ctx(), gp.RerunRequest{Target: tg, Ref: f.HeadSHA}); err != nil { + t.Errorf("RerunChecks: %v", err) + } + }) + } + if caps.Reactions { + t.Run("react", func(t *testing.T) { + p := h.New(t) + r := p.(gp.Reactor) + if _, err := r.React(ctx(), gp.ReactRequest{Target: tg, CommentID: "c-1", Emoji: "eyes"}); err != nil { + t.Errorf("React: %v", err) + } + _, err := r.React(ctx(), gp.ReactRequest{Target: tg, CommentID: "c-1", Emoji: "not-an-emoji"}) + mustCode(t, f, err, gp.CodeValidation) + }) + } + if caps.WebhookManagement { + t.Run("webhook-management", func(t *testing.T) { + p := h.New(t) + w := p.(gp.WebhookManager) + req := gp.EnsureWebhookRequest{Target: tg, URL: "https://paca.test/hook"} + a, err := w.EnsureWebhook(ctx(), req) + if err != nil || a.ID == "" { + t.Fatalf("EnsureWebhook: %+v %v", a, err) + } + b, err := w.EnsureWebhook(ctx(), req) + if err != nil || b.ID != a.ID { + t.Errorf("EnsureWebhook must be idempotent for the same URL: %q then %q (%v)", a.ID, b.ID, err) + } + if _, err := w.RemoveWebhook(ctx(), gp.RemoveWebhookRequest{Target: tg, Webhook: a}); err != nil { + t.Errorf("RemoveWebhook: %v", err) + } + if _, err := w.RemoveWebhook(ctx(), gp.RemoveWebhookRequest{Target: tg, Webhook: a}); err != nil { + t.Errorf("removing an already-removed webhook must not fail: %v", err) + } + }) + } + if caps.IdentityDeletion { + t.Run("delete-identity", func(t *testing.T) { + p := h.New(t) + if _, err := p.(gp.IdentityDeleter).DeleteIdentity(ctx(), gp.DeleteIdentityRequest{Identity: f.Identity}); err != nil { + t.Errorf("DeleteIdentity: %v", err) + } + }) + } +} + +func contains(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + return false +} diff --git a/gitprovider/doc.go b/gitprovider/doc.go new file mode 100644 index 0000000..8c93b2b --- /dev/null +++ b/gitprovider/doc.go @@ -0,0 +1,37 @@ +// Package gitprovider is the Git Provider SPI (version "gitprovider.v1"): the +// contract a Paca plugin implements to adapt one source-control platform +// (GitHub, GitLab, Bitbucket, Gitea, …) to Paca's platform-neutral Git +// integration. +// +// Paca core owns everything that is not platform-specific — the webhook +// endpoint and inbox, trigger routing, actor policy, agent runs, status +// comments, audit, the MCP tools and the UI. A provider only answers the +// platform-specific questions: how to verify and parse a webhook, how to mint a +// credential, how to read and write change requests, reviews, comments and +// checks. +// +// # Writing a provider +// +// 1. Implement [Provider] (required operations) and any of the optional +// interfaces ([ThreadResolver], [FileReader], …) the platform supports. +// 2. Return an honest [Info]: every optional interface you implement must have +// its capability flag set, and every flag you set must be backed by an +// implementation. The conformance suite checks both directions. +// 3. Mount it from your plugin's Init with serve.Register(ctx, provider) +// (package gitprovider/serve). +// 4. Pass the conformance suite (package gitprovider/conformance) using +// recorded webhook fixtures and a fake HTTP backend. The reference +// implementation in gitprovider/fakeprovider shows the expected behaviour. +// +// # Conventions +// +// - Operations that do not apply return [ErrUnsupported] (and the matching +// capability is false); core never calls them. +// - Map platform failures onto the [ErrorCode] set so core can retry and back +// off uniformly. Never include credentials or webhook secrets in an error. +// - Writes accept an IdempotencyKey; honour it where the platform allows and +// say so with [Capabilities.IdempotentWrites]. +// - Text that originates from users or repository content (comment bodies, +// diffs, logs, file contents) is untrusted. Return it verbatim; core fences +// it before it reaches a model. +package gitprovider diff --git a/gitprovider/errors.go b/gitprovider/errors.go new file mode 100644 index 0000000..49088db --- /dev/null +++ b/gitprovider/errors.go @@ -0,0 +1,115 @@ +package gitprovider + +import ( + "encoding/json" + "errors" + "fmt" +) + +// ErrorCode is the closed set of failure classes core understands. Providers +// map every platform failure onto one of them so retry and back-off behave the +// same everywhere. +type ErrorCode string + +const ( + CodeNotFound ErrorCode = "not_found" + CodeForbidden ErrorCode = "forbidden" // the identity lacks access + CodeInvalidSignature ErrorCode = "invalid_signature" // webhook failed verification + CodeConflict ErrorCode = "conflict" // e.g. branch exists, head moved + CodeRateLimited ErrorCode = "rate_limited" + CodeValidation ErrorCode = "validation" // bad request from core or the platform + CodeUnsupported ErrorCode = "unsupported" + CodeTransient ErrorCode = "transient" // retryable (5xx, timeouts) + CodeInternal ErrorCode = "internal" +) + +// AllErrorCodes lists every defined code. +var AllErrorCodes = []ErrorCode{ + CodeNotFound, CodeForbidden, CodeInvalidSignature, CodeConflict, + CodeRateLimited, CodeValidation, CodeUnsupported, CodeTransient, CodeInternal, +} + +// Error is a provider failure. Message must never contain credentials, webhook +// secrets or raw payloads. +type Error struct { + Code ErrorCode `json:"code"` + Message string `json:"message"` + RetryAfterSeconds int `json:"retry_after_seconds,omitempty"` +} + +func (e *Error) Error() string { return fmt.Sprintf("gitprovider: %s: %s", e.Code, e.Message) } + +// Retryable reports whether the same call may succeed later. +func (e *Error) Retryable() bool { return e.Code == CodeRateLimited || e.Code == CodeTransient } + +// Is makes errors.Is(err, ErrUnsupported) match any *Error with that code. +func (e *Error) Is(target error) bool { + t, ok := target.(*Error) + return ok && t.Code == e.Code +} + +// Sentinels for errors.Is. +var ( + ErrNotFound = &Error{Code: CodeNotFound} + ErrForbidden = &Error{Code: CodeForbidden} + ErrInvalidSignature = &Error{Code: CodeInvalidSignature} + ErrConflict = &Error{Code: CodeConflict} + ErrRateLimited = &Error{Code: CodeRateLimited} + ErrValidation = &Error{Code: CodeValidation} + ErrUnsupported = &Error{Code: CodeUnsupported, Message: "operation not supported by this provider"} + ErrTransient = &Error{Code: CodeTransient} +) + +// Errorf builds an *Error with a formatted message. +func Errorf(code ErrorCode, format string, args ...any) *Error { + return &Error{Code: code, Message: fmt.Sprintf(format, args...)} +} + +// RateLimited builds a rate-limit error with a retry hint. +func RateLimited(retryAfterSeconds int, msg string) *Error { + return &Error{Code: CodeRateLimited, Message: msg, RetryAfterSeconds: retryAfterSeconds} +} + +// AsError converts any error to an *Error, wrapping unknown errors as +// [CodeInternal] with a generic message so internals never leak. +func AsError(err error) *Error { + if err == nil { + return nil + } + var e *Error + if errors.As(err, &e) { + return e + } + return &Error{Code: CodeInternal, Message: "internal provider error"} +} + +// HTTPStatus maps a code to the status the serve adapter returns. +func (c ErrorCode) HTTPStatus() int { + switch c { + case CodeNotFound: + return 404 + case CodeForbidden: + return 403 + case CodeInvalidSignature: + return 401 + case CodeConflict: + return 409 + case CodeRateLimited: + return 429 + case CodeValidation: + return 400 + case CodeUnsupported: + return 501 + case CodeTransient: + return 503 + default: + return 500 + } +} + +// Envelope is the JSON every operation answers with. +type Envelope struct { + OK bool `json:"ok"` + Result json.RawMessage `json:"result,omitempty"` + Error *Error `json:"error,omitempty"` +} diff --git a/gitprovider/fakeprovider/fake.go b/gitprovider/fakeprovider/fake.go new file mode 100644 index 0000000..7542f49 --- /dev/null +++ b/gitprovider/fakeprovider/fake.go @@ -0,0 +1,715 @@ +// Package fakeprovider is the reference implementation of the Git Provider SPI: +// a complete, in-memory "platform" with deterministic seed data. It exists to +// (1) show provider authors what correct behaviour looks like, (2) give core and +// plugin tests a provider that needs no network, and (3) prove that the +// conformance suite can be satisfied by both a full and a minimal provider. +// +// Two types are provided: [Full] implements every optional interface and +// declares every capability; [Minimal] implements only the required surface and +// declares an honest, smaller capability set (static credentials, repo-scoped +// webhooks, comment-only status, emulated batch review). +// +// The fake's webhook format is deliberately simple: a JSON body plus +// `x-fake-signature: sha256=`. +package fakeprovider + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" +) + +// Seed data, exported so fixtures and tests can refer to it. +const ( + Host = "fake.test" + IdentityID = "ident-1" + IdentityHandle = "paca-bot" + WebhookSecret = "whsec_fake_secret_value" + StaticSecret = "static_fake_secret_value" + RepoPath = "acme/web" + SeedCRNumber = "1" + SeedHeadSHA = "abc123" +) + +var allRepoPaths = []string{"acme/web", "acme/api", "tools/docs"} + +type cr struct { + gp.ChangeRequest + files []gp.ChangedFile + threads []*gp.Thread + merged bool +} + +type repoState struct { + ref gp.RepoRef + private bool + crs map[string]*cr + issues map[string]*gp.Issue + comments map[string][]gp.Comment // subject key → comments + perms map[string]gp.Permission + files map[string][]byte // path → content (any ref) + protected map[string]bool + checks map[string][]gp.Check // sha → checks + reactions []string + hooks map[string]string + nextID int + statuses map[string]gp.SetStatusRequest +} + +// Core implements the required [gp.Provider] surface against an in-memory world. +type Core struct { + mu sync.Mutex + caps gp.Capabilities + repos map[string]*repoState + idem map[string]json.RawMessage + seq int + grants map[string]bool // access-grant states → used (Full only) + // Deleted records identities removed through DeleteIdentity (Full only). + Deleted []string + // Now is the clock; tests may override. + Now func() time.Time +} + +func newCore(caps gp.Capabilities) *Core { + c := &Core{caps: caps, repos: map[string]*repoState{}, idem: map[string]json.RawMessage{}, Now: time.Now} + for i, path := range allRepoPaths { + c.repos[path] = c.seedRepo(path, i == 2) + } + return c +} + +func (c *Core) seedRepo(path string, private bool) *repoState { + r := &repoState{ + ref: gp.RepoRef{Host: Host, FullPath: path, ExternalID: "ext-" + strings.ReplaceAll(path, "/", "-"), + DefaultBranch: "main", CloneURL: "https://" + Host + "/" + path + ".git"}, + private: private, + crs: map[string]*cr{}, + issues: map[string]*gp.Issue{}, + comments: map[string][]gp.Comment{}, + perms: map[string]gp.Permission{"octocat": gp.PermissionWrite, "reader": gp.PermissionRead, "owner": gp.PermissionAdmin, IdentityHandle: gp.PermissionWrite}, + files: map[string][]byte{"README.md": []byte("# " + path + "\n"), "src/main.go": []byte("package main\n\nfunc main() {}\n")}, + protected: map[string]bool{"main": true}, + checks: map[string][]gp.Check{SeedHeadSHA: {{ID: "chk-1", Name: "ci/test", State: gp.CheckFailure, Required: true, URL: "https://" + Host + "/chk-1"}, {ID: "chk-2", Name: "ci/lint", State: gp.CheckSuccess}}}, + hooks: map[string]string{}, + statuses: map[string]gp.SetStatusRequest{}, + } + r.crs[SeedCRNumber] = &cr{ + ChangeRequest: gp.ChangeRequest{Number: SeedCRNumber, Title: "Add feature", Body: "Implements the feature.", State: gp.CROpen, + Head: gp.Branch{Ref: "feat/x", SHA: SeedHeadSHA, Repo: path}, Base: gp.Branch{Ref: "main", SHA: "base000", Repo: path}, + Author: "octocat", URL: "https://" + Host + "/" + path + "/pulls/1", Labels: []string{"enhancement"}}, + files: []gp.ChangedFile{ + {Path: "src/main.go", Status: "modified", Additions: 3, Deletions: 1}, + {Path: "src/util.go", Status: "added", Additions: 10}, + {Path: "docs/guide.md", Status: "added", Additions: 5}, + }, + threads: []*gp.Thread{{ID: "thr-1", Path: "src/main.go", Line: 3, Comments: []gp.Comment{ + {ID: "c-1", Author: "reader", AuthorKind: gp.ActorUser, Body: "Why not use a constant here?"}}}}, + } + r.issues["7"] = &gp.Issue{Number: "7", Title: "A bug", Body: "It crashes.", State: "open", Author: "octocat", URL: "https://" + Host + "/" + path + "/issues/7"} + return r +} + +func (c *Core) repo(t gp.Target) (*repoState, error) { + r, ok := c.repos[t.Repo.FullPath] + if !ok || t.Identity.ID != IdentityID { + return nil, gp.Errorf(gp.CodeNotFound, "repository not found") + } + return r, nil +} + +func (c *Core) next(prefix string) string { c.seq++; return fmt.Sprintf("%s-%d", prefix, c.seq) } + +// idemKey namespaces a caller-supplied idempotency key; no key, no memoisation. +func idemKey(op, key string) string { + if key == "" { + return "" + } + return op + ":" + key +} + +// once runs fn at most once per idempotency key when the provider declares +// IdempotentWrites; otherwise it always runs. +func once[T any](c *Core, key string, fn func() (T, error)) (T, error) { + if key == "" || !c.caps.IdempotentWrites { + return fn() + } + if raw, ok := c.idem[key]; ok { + var v T + _ = json.Unmarshal(raw, &v) + return v, nil + } + v, err := fn() + if err == nil { + raw, _ := json.Marshal(v) + c.idem[key] = raw + } + return v, err +} + +// Describe implements gp.Provider. +func (c *Core) Describe() gp.Info { + return gp.Info{SPIVersion: gp.SPIVersion, ID: "com.paca.fake", DisplayName: "Fake Git", Icon: "git", + Hosts: []string{Host}, SelfHostedHosts: true, + Terms: gp.Terms{ChangeRequest: "Change request", ChangeRequestPlural: "Change requests", Repository: "Repository"}, + Capabilities: c.caps} +} + +// BeginIdentitySetup implements gp.Provider. +func (c *Core) BeginIdentitySetup(_ context.Context, req gp.BeginSetupRequest) (gp.SetupStep, error) { + if req.Host != Host { + return gp.SetupStep{}, gp.Errorf(gp.CodeValidation, "unknown host %q", req.Host) + } + if req.Scope == gp.SetupAgent && !c.caps.MultipleIdentities { + return gp.SetupStep{}, gp.Errorf(gp.CodeUnsupported, "per-agent identities are not supported") + } + return gp.SetupStep{Kind: gp.StepTokenForm, Prompt: "Paste a token with repo access", State: "setup-" + string(req.Scope), + Form: []gp.FormField{{Name: "token", Label: "Access token", Help: "Needs repository read/write access", Secret: true, Required: true}}}, nil +} + +// CompleteIdentitySetup implements gp.Provider. +func (c *Core) CompleteIdentitySetup(_ context.Context, req gp.CompleteSetupRequest) (gp.Identity, error) { + if req.State == "" || req.Params["token"] == "" { + return gp.Identity{}, gp.Errorf(gp.CodeValidation, "state and token are required") + } + return gp.Identity{Ref: gp.IdentityRef{ID: IdentityID, Handle: IdentityHandle}, Host: Host, Kind: gp.IdentityBotUser, DisplayName: "Paca Bot"}, nil +} + +// ListRepositories implements gp.Provider. +func (c *Core) ListRepositories(_ context.Context, req gp.ListReposRequest) (gp.RepositoryPage, error) { + c.mu.Lock() + defer c.mu.Unlock() + if req.Identity.ID != IdentityID { + return gp.RepositoryPage{}, gp.Errorf(gp.CodeForbidden, "unknown identity") + } + var paths []string + for p := range c.repos { + if req.Query == "" || strings.Contains(p, req.Query) { + paths = append(paths, p) + } + } + sort.Strings(paths) + start, _ := strconv.Atoi(req.Cursor) + size := req.PageSize + if size <= 0 { + size = 50 + } + end := start + size + next := "" + if end < len(paths) { + next = strconv.Itoa(end) + } else { + end = len(paths) + } + var page gp.RepositoryPage + if start < len(paths) { + for _, p := range paths[start:end] { + r := c.repos[p] + page.Items = append(page.Items, gp.Repository{Ref: r.ref, Private: r.private}) + } + } + page.NextCursor = next + return page, nil +} + +var urlPath = regexp.MustCompile(`^(?:https?://[^/]+/)?([^?#]+?)(?:\.git)?/?$`) + +// ResolveRepository implements gp.Provider. +func (c *Core) ResolveRepository(_ context.Context, req gp.ResolveRepoRequest) (gp.ResolvedRepository, error) { + c.mu.Lock() + defer c.mu.Unlock() + m := urlPath.FindStringSubmatch(strings.TrimSpace(req.PathOrURL)) + if m == nil { + return gp.ResolvedRepository{}, gp.Errorf(gp.CodeValidation, "not a repository path or URL") + } + r, ok := c.repos[m[1]] + if !ok { + return gp.ResolvedRepository{}, gp.Errorf(gp.CodeNotFound, "repository not found") + } + for _, cand := range req.Candidates { + if cand.ID == IdentityID { + return gp.ResolvedRepository{Repository: gp.Repository{Ref: r.ref, Private: r.private}, Identity: cand}, nil + } + } + return gp.ResolvedRepository{}, gp.Errorf(gp.CodeForbidden, "no candidate identity can see this repository") +} + +// ── webhooks ───────────────────────────────────────────────────────────────── + +type fakeHook struct { + Delivery string `json:"delivery"` + Type string `json:"type"` + Action string `json:"action"` + Repo string `json:"repo"` + Subject string `json:"subject"` + Number string `json:"number"` + Actor string `json:"actor"` + ActorKind string `json:"actor_kind"` + Text string `json:"text"` + Path string `json:"path"` + Line int `json:"line"` + Thread string `json:"thread"` + Comment string `json:"comment"` + HeadRef string `json:"head_ref"` + HeadSHA string `json:"head_sha"` + Base string `json:"base"` + Draft bool `json:"draft"` + Label string `json:"label"` + Check string `json:"check"` + Created bool `json:"created"` + Deleted bool `json:"deleted"` + Result string `json:"conclusion"` + Requested string `json:"requested"` + Assignee string `json:"assignee"` +} + +// Sign returns the signature header value for a webhook body. +func Sign(body []byte) string { + m := hmac.New(sha256.New, []byte(WebhookSecret)) + m.Write(body) + return "sha256=" + hex.EncodeToString(m.Sum(nil)) +} + +func mentions(text, handle string) bool { + re := regexp.MustCompile(`(?i)(^|[^\w@])@` + regexp.QuoteMeta(handle) + `\b`) + return re.MatchString(text) +} + +// VerifyAndParseWebhook implements gp.Provider. +func (c *Core) VerifyAndParseWebhook(_ context.Context, req gp.WebhookRequest) ([]gp.Event, error) { + sig := req.Headers["x-fake-signature"] + if !hmac.Equal([]byte(sig), []byte(Sign(req.Body))) { + return nil, gp.Errorf(gp.CodeInvalidSignature, "signature mismatch") + } + var h fakeHook + if err := json.Unmarshal(req.Body, &h); err != nil { + return nil, gp.Errorf(gp.CodeValidation, "malformed payload") + } + c.mu.Lock() + defer c.mu.Unlock() + r, ok := c.repos[h.Repo] + if !ok { + return nil, nil // not a repository this identity can see: nothing to report + } + kind := map[string]gp.EventKind{"comment": gp.EventComment, "change_request": gp.EventChangeRequest, "review": gp.EventReview, + "check": gp.EventCheck, "push": gp.EventPush, "label": gp.EventLabel, "assignment": gp.EventAssignment, "review_request": gp.EventReviewRequest}[h.Type] + if kind == "" { + return nil, nil // unmapped event types are dropped, not guessed + } + actorKind := gp.ActorUser + if h.ActorKind == "bot" { + actorKind = gp.ActorBot + } + ev := gp.Event{ID: h.Delivery + ":" + h.Type, Kind: kind, Action: h.Action, Repo: r.ref, + Actor: gp.Actor{Login: h.Actor, Kind: actorKind, Permission: r.perms[h.Actor]}, Text: h.Text, Label: h.Label, RawRef: h.Delivery} + if h.Number != "" { + sk := gp.SubjectKind(h.Subject) + if sk == "" { + sk = gp.SubjectChangeRequest + } + sub := &gp.EventSubject{Kind: sk, Number: h.Number, Base: h.Base, Draft: h.Draft} + if h.HeadRef != "" { + sub.Head = &gp.Branch{Ref: h.HeadRef, SHA: h.HeadSHA, Repo: h.Repo} + } + ev.Subject = sub + } + if h.Path != "" || h.Thread != "" || h.Comment != "" { + ev.Location = &gp.Location{Path: h.Path, Line: h.Line, ThreadID: h.Thread, CommentID: h.Comment} + } + if kind == gp.EventPush { + ev.Push = &gp.PushInfo{Ref: h.HeadRef, SHA: h.HeadSHA, Created: h.Created, Deleted: h.Deleted} + } + if kind == gp.EventCheck { + ev.Check = &gp.CheckInfo{Name: h.Check, Conclusion: h.Result, SHA: h.HeadSHA} + } + // Only report ways of being addressed that this provider declares: core + // trusts `addressed` and capabilities to agree. + if c.caps.SupportsTrigger(gp.TriggerMention) && mentions(h.Text, IdentityHandle) { + ev.Addressed = append(ev.Addressed, gp.Addressed{IdentityID: IdentityID, Via: gp.ViaMention}) + } + if c.caps.SupportsTrigger(gp.TriggerReviewRequest) && h.Requested == IdentityHandle { + ev.Addressed = append(ev.Addressed, gp.Addressed{IdentityID: IdentityID, Via: gp.ViaReviewRequest}) + } + if c.caps.SupportsTrigger(gp.TriggerAssignment) && h.Assignee == IdentityHandle { + ev.Addressed = append(ev.Addressed, gp.Addressed{IdentityID: IdentityID, Via: gp.ViaAssignment}) + } + return []gp.Event{ev}, nil +} + +// ── credentials and policy ─────────────────────────────────────────────────── + +// MintCredential implements gp.Provider. +func (c *Core) MintCredential(_ context.Context, req gp.MintRequest) (gp.Credential, error) { + c.mu.Lock() + defer c.mu.Unlock() + if _, err := c.repo(req.Target); err != nil { + return gp.Credential{}, err + } + if req.Access != gp.AccessRead && req.Access != gp.AccessWrite { + return gp.Credential{}, gp.Errorf(gp.CodeValidation, "access must be read or write") + } + if c.caps.CredentialModel == gp.CredentialStatic { + return gp.Credential{Username: "oauth2", Secret: StaticSecret}, nil + } + ttl := time.Duration(req.TTLSeconds) * time.Second + if ttl <= 0 || ttl > time.Hour { + ttl = 10 * time.Minute + } + return gp.Credential{Username: "x-access-token", Secret: c.next("tok-" + string(req.Access)), ExpiresAt: c.Now().Add(ttl)}, nil +} + +// GetActorPermission implements gp.Provider. +func (c *Core) GetActorPermission(_ context.Context, req gp.ActorPermissionRequest) (gp.Permission, error) { + c.mu.Lock() + defer c.mu.Unlock() + r, err := c.repo(req.Target) + if err != nil { + return gp.PermissionNone, err + } + if p, ok := r.perms[req.Login]; ok { + return p, nil + } + return gp.PermissionNone, nil +} + +// GetBranchProtection implements gp.Provider. +func (c *Core) GetBranchProtection(_ context.Context, req gp.BranchProtectionRequest) (gp.BranchProtection, error) { + c.mu.Lock() + defer c.mu.Unlock() + r, err := c.repo(req.Target) + if err != nil { + return gp.BranchProtection{}, err + } + return gp.BranchProtection{Protected: r.protected[req.Branch], RequiresReview: r.protected[req.Branch]}, nil +} + +// ── reads ──────────────────────────────────────────────────────────────────── + +func (c *Core) cr(t gp.Target, n string) (*repoState, *cr, error) { + r, err := c.repo(t) + if err != nil { + return nil, nil, err + } + x, ok := r.crs[n] + if !ok { + return nil, nil, gp.Errorf(gp.CodeNotFound, "change request not found") + } + return r, x, nil +} + +// GetChangeRequest implements gp.Provider. +func (c *Core) GetChangeRequest(_ context.Context, req gp.CRRequest) (gp.ChangeRequest, error) { + c.mu.Lock() + defer c.mu.Unlock() + _, x, err := c.cr(req.Target, req.Number) + if err != nil { + return gp.ChangeRequest{}, err + } + return x.ChangeRequest, nil +} + +// ListChangedFiles implements gp.Provider. +func (c *Core) ListChangedFiles(_ context.Context, req gp.PagedCRRequest) (gp.ChangedFilePage, error) { + c.mu.Lock() + defer c.mu.Unlock() + _, x, err := c.cr(req.Target, req.Number) + if err != nil { + return gp.ChangedFilePage{}, err + } + start, _ := strconv.Atoi(req.Cursor) + const size = 2 + end := start + size + page := gp.ChangedFilePage{} + if end < len(x.files) { + page.NextCursor = strconv.Itoa(end) + } else { + end = len(x.files) + } + if start < len(x.files) { + page.Items = append(page.Items, x.files[start:end]...) + } + return page, nil +} + +// GetDiff implements gp.Provider. +func (c *Core) GetDiff(_ context.Context, req gp.DiffRequest) (gp.Diff, error) { + c.mu.Lock() + defer c.mu.Unlock() + _, x, err := c.cr(req.Target, req.Number) + if err != nil { + return gp.Diff{}, err + } + var b strings.Builder + found := req.Path == "" + for _, f := range x.files { + if req.Path != "" && f.Path != req.Path { + continue + } + found = true + fmt.Fprintf(&b, "diff --git a/%s b/%s\n--- a/%s\n+++ b/%s\n@@ -1,1 +1,%d @@\n+changed %s\n", f.Path, f.Path, f.Path, f.Path, f.Additions, f.Path) + } + if !found { + return gp.Diff{}, gp.Errorf(gp.CodeNotFound, "file is not part of this change request") + } + out := b.String() + if req.MaxBytes > 0 && len(out) > req.MaxBytes { + return gp.Diff{Text: out[:req.MaxBytes], Truncated: true}, nil + } + return gp.Diff{Text: out}, nil +} + +// ListThreads implements gp.Provider. +func (c *Core) ListThreads(_ context.Context, req gp.PagedCRRequest) (gp.ThreadPage, error) { + c.mu.Lock() + defer c.mu.Unlock() + _, x, err := c.cr(req.Target, req.Number) + if err != nil { + return gp.ThreadPage{}, err + } + page := gp.ThreadPage{} + for _, t := range x.threads { + page.Items = append(page.Items, *t) + } + return page, nil +} + +func subjectKey(s gp.SubjectRef) string { return string(s.Kind) + "#" + s.Number } + +// ListComments implements gp.Provider. +func (c *Core) ListComments(_ context.Context, req gp.ListCommentsRequest) (gp.CommentPage, error) { + c.mu.Lock() + defer c.mu.Unlock() + r, err := c.repo(req.Target) + if err != nil { + return gp.CommentPage{}, err + } + return gp.CommentPage{Items: append([]gp.Comment(nil), r.comments[subjectKey(req.Subject)]...)}, nil +} + +// GetIssue implements gp.Provider. +func (c *Core) GetIssue(_ context.Context, req gp.IssueRequest) (gp.Issue, error) { + c.mu.Lock() + defer c.mu.Unlock() + r, err := c.repo(req.Target) + if err != nil { + return gp.Issue{}, err + } + i, ok := r.issues[req.Number] + if !ok { + return gp.Issue{}, gp.Errorf(gp.CodeNotFound, "issue not found") + } + return *i, nil +} + +// GetChecks implements gp.Provider. +func (c *Core) GetChecks(_ context.Context, req gp.ChecksRequest) ([]gp.Check, error) { + c.mu.Lock() + defer c.mu.Unlock() + r, err := c.repo(req.Target) + if err != nil { + return nil, err + } + return append([]gp.Check(nil), r.checks[req.Ref]...), nil +} + +// GetCheckLogs implements gp.Provider. +func (c *Core) GetCheckLogs(_ context.Context, req gp.CheckLogsRequest) (gp.Logs, error) { + c.mu.Lock() + defer c.mu.Unlock() + r, err := c.repo(req.Target) + if err != nil { + return gp.Logs{}, err + } + for _, list := range r.checks { + for _, ch := range list { + if ch.ID == req.CheckID { + log := "step 1 ok\nstep 2 FAILED: expected 1, got 2\n" + if req.TailBytes > 0 && len(log) > req.TailBytes { + return gp.Logs{Text: log[len(log)-req.TailBytes:], Truncated: true}, nil + } + return gp.Logs{Text: log}, nil + } + } + } + return gp.Logs{}, gp.Errorf(gp.CodeNotFound, "check not found") +} + +// ── writes ─────────────────────────────────────────────────────────────────── + +// OpenChangeRequest implements gp.Provider. +func (c *Core) OpenChangeRequest(_ context.Context, req gp.OpenCRRequest) (gp.ChangeRequest, error) { + c.mu.Lock() + defer c.mu.Unlock() + r, err := c.repo(req.Target) + if err != nil { + return gp.ChangeRequest{}, err + } + if strings.TrimSpace(req.Title) == "" || req.Head == "" || req.Base == "" { + return gp.ChangeRequest{}, gp.Errorf(gp.CodeValidation, "title, head and base are required") + } + if req.Draft && !c.caps.DraftChangeRequests { + return gp.ChangeRequest{}, gp.Errorf(gp.CodeValidation, "draft change requests are not supported") + } + return once(c, idemKey("open", req.IdempotencyKey), func() (gp.ChangeRequest, error) { + for _, existing := range r.crs { + if existing.Head.Ref == req.Head && existing.State == gp.CROpen { + return gp.ChangeRequest{}, gp.Errorf(gp.CodeConflict, "an open change request already exists for this branch") + } + } + r.nextID++ + n := strconv.Itoa(100 + r.nextID) + x := &cr{ChangeRequest: gp.ChangeRequest{Number: n, Title: req.Title, Body: req.Body, State: gp.CROpen, Draft: req.Draft, + Head: gp.Branch{Ref: req.Head, SHA: "sha-" + n, Repo: req.Target.Repo.FullPath}, Base: gp.Branch{Ref: req.Base}, + Author: IdentityHandle, URL: "https://" + Host + "/" + req.Target.Repo.FullPath + "/pulls/" + n, + Labels: req.Labels, Reviewers: req.Reviewers}} + r.crs[n] = x + return x.ChangeRequest, nil + }) +} + +// UpdateChangeRequest implements gp.Provider. +func (c *Core) UpdateChangeRequest(_ context.Context, req gp.UpdateCRRequest) (gp.ChangeRequest, error) { + c.mu.Lock() + defer c.mu.Unlock() + _, x, err := c.cr(req.Target, req.Number) + if err != nil { + return gp.ChangeRequest{}, err + } + if req.Title != nil { + x.Title = *req.Title + } + if req.Body != nil { + x.Body = *req.Body + } + if req.Draft != nil { + x.Draft = *req.Draft + } + if req.State != nil { + if *req.State != gp.CROpen && *req.State != gp.CRClosed { + return gp.ChangeRequest{}, gp.Errorf(gp.CodeValidation, "state must be open or closed") + } + x.State = *req.State + } + return x.ChangeRequest, nil +} + +// Comment implements gp.Provider. +func (c *Core) Comment(_ context.Context, req gp.CommentRequest) (gp.CommentResult, error) { + c.mu.Lock() + defer c.mu.Unlock() + r, err := c.repo(req.Target) + if err != nil { + return gp.CommentResult{}, err + } + if strings.TrimSpace(req.Body) == "" { + return gp.CommentResult{}, gp.Errorf(gp.CodeValidation, "comment body is required") + } + if req.Subject.Kind == gp.SubjectChangeRequest { + if _, ok := r.crs[req.Subject.Number]; !ok { + return gp.CommentResult{}, gp.Errorf(gp.CodeNotFound, "change request not found") + } + } else if _, ok := r.issues[req.Subject.Number]; !ok { + return gp.CommentResult{}, gp.Errorf(gp.CodeNotFound, "issue not found") + } + return once(c, idemKey("comment", req.IdempotencyKey), func() (gp.CommentResult, error) { + id := c.next("cm") + r.comments[subjectKey(req.Subject)] = append(r.comments[subjectKey(req.Subject)], + gp.Comment{ID: id, Author: IdentityHandle, AuthorKind: gp.ActorBot, Body: req.Body}) + return gp.CommentResult{ID: id, URL: "https://" + Host + "/c/" + id}, nil + }) +} + +// ReplyThread implements gp.Provider. +func (c *Core) ReplyThread(_ context.Context, req gp.ReplyThreadRequest) (gp.CommentResult, error) { + c.mu.Lock() + defer c.mu.Unlock() + _, x, err := c.cr(req.Target, req.Number) + if err != nil { + return gp.CommentResult{}, err + } + for _, t := range x.threads { + if t.ID == req.ThreadID { + return once(c, idemKey("reply", req.IdempotencyKey), func() (gp.CommentResult, error) { + id := c.next("cm") + t.Comments = append(t.Comments, gp.Comment{ID: id, Author: IdentityHandle, AuthorKind: gp.ActorBot, Body: req.Body}) + return gp.CommentResult{ID: id}, nil + }) + } + } + return gp.CommentResult{}, gp.Errorf(gp.CodeNotFound, "thread not found") +} + +// SubmitReview implements gp.Provider. With BatchReview=false the review is +// emulated: inline comments are posted one by one, then the summary. +func (c *Core) SubmitReview(_ context.Context, req gp.SubmitReviewRequest) (gp.ReviewResult, error) { + c.mu.Lock() + defer c.mu.Unlock() + _, x, err := c.cr(req.Target, req.Number) + if err != nil { + return gp.ReviewResult{}, err + } + switch req.Verdict { + case gp.VerdictComment, gp.VerdictApprove, gp.VerdictRequestChanges: + default: + return gp.ReviewResult{}, gp.Errorf(gp.CodeValidation, "unknown verdict %q", req.Verdict) + } + if req.Verdict == gp.VerdictApprove && x.Author == IdentityHandle { + return gp.ReviewResult{}, gp.Errorf(gp.CodeConflict, "an author cannot approve their own change request") + } + if req.Verdict == gp.VerdictRequestChanges && strings.TrimSpace(req.Summary) == "" && len(req.Comments) == 0 { + return gp.ReviewResult{}, gp.Errorf(gp.CodeValidation, "requesting changes needs a summary or comments") + } + known := map[string]bool{} + for _, f := range x.files { + known[f.Path] = true + } + for _, ic := range req.Comments { + if !known[ic.Path] || ic.Line <= 0 { + return gp.ReviewResult{}, gp.Errorf(gp.CodeValidation, "inline comment on %s:%d is not on the diff", ic.Path, ic.Line) + } + } + return once(c, idemKey("review", req.IdempotencyKey), func() (gp.ReviewResult, error) { + id := c.next("rv") + return gp.ReviewResult{ID: id, URL: "https://" + Host + "/r/" + id, Posted: len(req.Comments), Emulated: !c.caps.BatchReview}, nil + }) +} + +// SetStatus implements gp.Provider. +func (c *Core) SetStatus(_ context.Context, req gp.SetStatusRequest) (gp.StatusResult, error) { + c.mu.Lock() + defer c.mu.Unlock() + r, err := c.repo(req.Target) + if err != nil { + return gp.StatusResult{}, err + } + ref := req.StatusRef + if ref == "" { + ref = c.next("st") + } else if _, ok := r.statuses[ref]; !ok { + return gp.StatusResult{}, gp.Errorf(gp.CodeNotFound, "status not found") + } + r.statuses[ref] = req + return gp.StatusResult{StatusRef: ref}, nil +} + +// StatusOf lets tests observe the last state set under a reference. +func (c *Core) StatusOf(repo, ref string) (gp.SetStatusRequest, bool) { + c.mu.Lock() + defer c.mu.Unlock() + r, ok := c.repos[repo] + if !ok { + return gp.SetStatusRequest{}, false + } + s, ok := r.statuses[ref] + return s, ok +} diff --git a/gitprovider/fakeprovider/fake_test.go b/gitprovider/fakeprovider/fake_test.go new file mode 100644 index 0000000..066ff17 --- /dev/null +++ b/gitprovider/fakeprovider/fake_test.go @@ -0,0 +1,13 @@ +package fakeprovider_test + +import ( + "testing" + + "github.com/Paca-AI/plugin-sdk-go/gitprovider/conformance" + "github.com/Paca-AI/plugin-sdk-go/gitprovider/fakeprovider" +) + +// The full fake (every optional capability) and the minimal fake (none) must +// both pass: the suite has to work for rich and basic platforms alike. +func TestConformance_Full(t *testing.T) { conformance.Run(t, fakeprovider.Harness(false)) } +func TestConformance_Minimal(t *testing.T) { conformance.Run(t, fakeprovider.Harness(true)) } diff --git a/gitprovider/fakeprovider/harness.go b/gitprovider/fakeprovider/harness.go new file mode 100644 index 0000000..70f9e56 --- /dev/null +++ b/gitprovider/fakeprovider/harness.go @@ -0,0 +1,102 @@ +package fakeprovider + +import ( + "encoding/json" + "testing" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" + "github.com/Paca-AI/plugin-sdk-go/gitprovider/conformance" +) + +func delivery(h fakeHook) gp.WebhookRequest { + body, _ := json.Marshal(h) + return gp.WebhookRequest{ + Identity: gp.IdentityRef{ID: IdentityID}, + Method: "POST", + Headers: map[string]string{"x-fake-signature": Sign(body)}, + Body: body, + } +} + +// Fixtures returns the conformance fixtures for the fake's seed data. It is also +// the template for a real provider's fixtures: canned deliveries recorded from +// the platform, with the expectations a correct adapter must produce. +func Fixtures() conformance.Fixtures { + identity := gp.IdentityRef{ID: IdentityID, Handle: IdentityHandle} + repo := gp.RepoRef{Host: Host, FullPath: RepoPath, DefaultBranch: "main"} + bad := delivery(fakeHook{Delivery: "d-bad", Type: "comment", Repo: RepoPath, Number: "1", Actor: "octocat", Text: "@paca-bot hi"}) + bad.Headers["x-fake-signature"] = "sha256=deadbeef" + + return conformance.Fixtures{ + Identity: identity, Repo: repo, + InvisibleRepo: gp.RepoRef{Host: Host, FullPath: "secret/hidden"}, + RepoURL: "https://" + Host + "/" + RepoPath + ".git", + Candidates: []gp.IdentityRef{identity}, + ExpectedRepoPaths: allRepoPaths, + + ChangeRequest: SeedCRNumber, MissingCR: "999", + ChangedFiles: []string{"src/main.go", "src/util.go", "docs/guide.md"}, + HeadSHA: SeedHeadSHA, FailingCheckID: "chk-1", + IssueNumber: "7", ThreadID: "thr-1", + Actors: map[string]gp.Permission{"octocat": gp.PermissionWrite, "reader": gp.PermissionRead, "owner": gp.PermissionAdmin}, + + BadSignature: bad, + Secrets: []string{WebhookSecret, StaticSecret}, + Webhooks: []conformance.WebhookCase{ + {Name: "mention-in-comment", + Request: delivery(fakeHook{Delivery: "d1", Type: "comment", Action: "created", Repo: RepoPath, Subject: "change_request", Number: "1", Actor: "octocat", Text: "@paca-bot please fix the test", Path: "src/main.go", Line: 3, Thread: "thr-1", Comment: "c-9"}), + Want: []conformance.Want{{Kind: gp.EventComment, Action: "created", RepoPath: RepoPath, SubjectKind: gp.SubjectChangeRequest, SubjectNumber: "1", ActorLogin: "octocat", ActorKind: gp.ActorUser, Addressed: []gp.AddressVia{gp.ViaMention}}}}, + {Name: "comment-without-mention", + Request: delivery(fakeHook{Delivery: "d2", Type: "comment", Action: "created", Repo: RepoPath, Subject: "issue", Number: "7", Actor: "reader", Text: "thanks!"}), + Want: []conformance.Want{{Kind: gp.EventComment, SubjectKind: gp.SubjectIssue, SubjectNumber: "7", ActorLogin: "reader", ActorKind: gp.ActorUser}}}, + {Name: "email-address-is-not-a-mention", + Request: delivery(fakeHook{Delivery: "d3", Type: "comment", Action: "created", Repo: RepoPath, Number: "1", Actor: "octocat", Text: "mail me at dev@paca-bot.example"}), + Want: []conformance.Want{{Kind: gp.EventComment, ActorLogin: "octocat"}}}, + {Name: "bot-actor", + Request: delivery(fakeHook{Delivery: "d4", Type: "comment", Action: "created", Repo: RepoPath, Number: "1", Actor: "dependabot", ActorKind: "bot", Text: "bump"}), + Want: []conformance.Want{{Kind: gp.EventComment, ActorLogin: "dependabot", ActorKind: gp.ActorBot}}}, + {Name: "review-requested-from-bot", + Request: delivery(fakeHook{Delivery: "d5", Type: "review_request", Action: "requested", Repo: RepoPath, Number: "1", Actor: "octocat", Requested: IdentityHandle}), + Want: []conformance.Want{{Kind: gp.EventReviewRequest, Action: "requested", ActorLogin: "octocat", Addressed: []gp.AddressVia{gp.ViaReviewRequest}}}}, + {Name: "assigned-to-bot", + Request: delivery(fakeHook{Delivery: "d6", Type: "assignment", Action: "assigned", Repo: RepoPath, Subject: "issue", Number: "7", Actor: "octocat", Assignee: IdentityHandle}), + Want: []conformance.Want{{Kind: gp.EventAssignment, SubjectKind: gp.SubjectIssue, Addressed: []gp.AddressVia{gp.ViaAssignment}}}}, + {Name: "ci-failed", + Request: delivery(fakeHook{Delivery: "d7", Type: "check", Action: "completed", Repo: RepoPath, Number: "1", Actor: "ci", ActorKind: "bot", HeadSHA: SeedHeadSHA, Check: "ci/test", Result: "failure"}), + Want: []conformance.Want{{Kind: gp.EventCheck, Action: "completed", ActorKind: gp.ActorBot, CheckConclusion: "failure"}}}, + {Name: "branch-created-by-push", + Request: delivery(fakeHook{Delivery: "d10", Type: "push", Action: "created", Repo: RepoPath, Actor: "octocat", HeadRef: "feat/PACA-12-x", HeadSHA: "def456", Created: true}), + Want: []conformance.Want{{Kind: gp.EventPush, Action: "created", ActorLogin: "octocat"}}}, + {Name: "unmapped-event-is-dropped", + Request: delivery(fakeHook{Delivery: "d8", Type: "star", Repo: RepoPath, Actor: "octocat"}), + Want: nil}, + {Name: "unknown-repository-is-dropped", + Request: delivery(fakeHook{Delivery: "d9", Type: "comment", Action: "created", Repo: "nobody/knows", Number: "1", Actor: "octocat", Text: "@paca-bot"}), + Want: nil}, + }, + + SetupHost: Host, + SetupCompleteParams: map[string]string{"token": "pasted-token"}, + + MutationsAllowed: true, + NewBranchHead: "feat/new", BaseBranch: "main", + File: struct{ Ref, Path string }{Ref: "main", Path: "README.md"}, + SearchQuery: "package main", + Reviewer: "reader", + + AccessGrantParams: map[string]string{"owner": "acme"}, AccessOwner: "acme", + } +} + +// Harness returns a conformance harness for the full or minimal fake. +func Harness(minimal bool) conformance.Harness { + return conformance.Harness{ + New: func(t *testing.T) gp.Provider { + if minimal { + return NewMinimal() + } + return New() + }, + Fixtures: Fixtures(), + } +} diff --git a/gitprovider/fakeprovider/mutants_test.go b/gitprovider/fakeprovider/mutants_test.go new file mode 100644 index 0000000..19c1e37 --- /dev/null +++ b/gitprovider/fakeprovider/mutants_test.go @@ -0,0 +1,175 @@ +//go:build mutants + +package fakeprovider_test + +// Mutation checks for the conformance suite: each provider below has ONE +// deliberate defect, and the suite is expected to FAIL on it. Run with +// +// go test -tags mutants ./gitprovider/fakeprovider/ -run TestMutant +// +// and confirm every TestMutant_* reports FAIL (scripts/check-mutants.sh does). + +import ( + "context" + "errors" + "testing" + "time" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" + "github.com/Paca-AI/plugin-sdk-go/gitprovider/conformance" + "github.com/Paca-AI/plugin-sdk-go/gitprovider/fakeprovider" +) + +func run(t *testing.T, mk func() gp.Provider) { + h := fakeprovider.Harness(false) + h.New = func(*testing.T) gp.Provider { return mk() } + conformance.Run(t, h) +} + +type noVerify struct{ *fakeprovider.Full } + +func (n noVerify) VerifyAndParseWebhook(ctx context.Context, r gp.WebhookRequest) ([]gp.Event, error) { + r.Headers = map[string]string{"x-fake-signature": fakeprovider.Sign(r.Body)} // accepts anything + return n.Full.VerifyAndParseWebhook(ctx, r) +} +func TestMutant_NoSignatureVerification(t *testing.T) { + run(t, func() gp.Provider { return noVerify{fakeprovider.New()} }) +} + +type leaky struct{ *fakeprovider.Full } + +func (l leaky) GetChangeRequest(ctx context.Context, r gp.CRRequest) (gp.ChangeRequest, error) { + cr, err := l.Full.GetChangeRequest(ctx, r) + if err != nil { + return cr, gp.Errorf(gp.CodeNotFound, "not found (token %s)", fakeprovider.StaticSecret) + } + return cr, nil +} +func TestMutant_ErrorLeaksSecret(t *testing.T) { + run(t, func() gp.Provider { return leaky{fakeprovider.New()} }) +} + +type unclassified struct{ *fakeprovider.Full } + +func (u unclassified) GetIssue(ctx context.Context, r gp.IssueRequest) (gp.Issue, error) { + if is, err := u.Full.GetIssue(ctx, r); err != nil { + return is, errors.New("HTTP 404 from upstream") + } else { + return is, nil + } +} +func TestMutant_UnclassifiedError(t *testing.T) { + run(t, func() gp.Provider { return unclassified{fakeprovider.New()} }) +} + +type dishonest struct{ *fakeprovider.Full } + +func (d dishonest) Describe() gp.Info { + i := d.Full.Describe() + i.Capabilities.Merge = false // implements Merger but hides it + return i +} +func TestMutant_DishonestCapability(t *testing.T) { + run(t, func() gp.Provider { return dishonest{fakeprovider.New()} }) +} + +type noExpiry struct{ *fakeprovider.Full } + +func (n noExpiry) MintCredential(ctx context.Context, r gp.MintRequest) (gp.Credential, error) { + c, err := n.Full.MintCredential(ctx, r) + c.ExpiresAt = time.Time{} + return c, err +} +func TestMutant_EphemeralWithoutExpiry(t *testing.T) { + run(t, func() gp.Provider { return noExpiry{fakeprovider.New()} }) +} + +type silentTrunc struct{ *fakeprovider.Full } + +func (s silentTrunc) GetDiff(ctx context.Context, r gp.DiffRequest) (gp.Diff, error) { + max := r.MaxBytes + r.MaxBytes = 0 // fetch it all, then cut without saying so + d, err := s.Full.GetDiff(ctx, r) + if err == nil && max > 0 && len(d.Text) > max { + d.Text, d.Truncated = d.Text[:max], false + } + return d, err +} +func TestMutant_SilentTruncation(t *testing.T) { + run(t, func() gp.Provider { return silentTrunc{fakeprovider.New()} }) +} + +type unstableIDs struct { + *fakeprovider.Full + n int +} + +func (u *unstableIDs) VerifyAndParseWebhook(ctx context.Context, r gp.WebhookRequest) ([]gp.Event, error) { + evs, err := u.Full.VerifyAndParseWebhook(ctx, r) + for i := range evs { + u.n++ + evs[i].ID += string(rune('a' + u.n%26)) + } + return evs, err +} +func TestMutant_NonDeterministicEventIDs(t *testing.T) { + run(t, func() gp.Provider { return &unstableIDs{Full: fakeprovider.New()} }) +} + +type secretInEvent struct{ *fakeprovider.Full } + +func (s secretInEvent) VerifyAndParseWebhook(ctx context.Context, r gp.WebhookRequest) ([]gp.Event, error) { + evs, err := s.Full.VerifyAndParseWebhook(ctx, r) + for i := range evs { + evs[i].RawRef = fakeprovider.WebhookSecret + } + return evs, err +} +func TestMutant_SecretInEvent(t *testing.T) { + run(t, func() gp.Provider { return secretInEvent{fakeprovider.New()} }) +} + +type mergeNoGuard struct{ *fakeprovider.Full } + +func (m mergeNoGuard) MergeChangeRequest(ctx context.Context, r gp.MergeRequest) (gp.MergeResult, error) { + cr, _ := m.Full.GetChangeRequest(ctx, gp.CRRequest{Target: r.Target, Number: r.Number}) + r.ExpectedHeadSHA = cr.Head.SHA // ignores the caller's expected head + return m.Full.MergeChangeRequest(ctx, r) +} +func TestMutant_MergeIgnoresExpectedHead(t *testing.T) { + run(t, func() gp.Provider { return mergeNoGuard{fakeprovider.New()} }) +} + +// A grant callback that works for any state, or more than once, lets a +// replayed or forged redirect grant access. +type replayableGrant struct{ *fakeprovider.Full } + +func (r replayableGrant) CompleteAccessGrant(ctx context.Context, req gp.CompleteAccessRequest) (gp.AccessGrant, error) { + return gp.AccessGrant{Owner: req.Params["owner"]}, nil +} +func TestMutant_AccessGrantAcceptsAnyState(t *testing.T) { + run(t, func() gp.Provider { return replayableGrant{fakeprovider.New()} }) +} + +type reusableGrant struct { + *fakeprovider.Full + step gp.SetupStep +} + +func (r *reusableGrant) BeginAccessGrant(ctx context.Context, req gp.BeginAccessRequest) (gp.SetupStep, error) { + s, err := r.Full.BeginAccessGrant(ctx, req) + if err == nil { + r.step = s + } + return s, err +} +func (r *reusableGrant) CompleteAccessGrant(ctx context.Context, req gp.CompleteAccessRequest) (gp.AccessGrant, error) { + g, err := r.Full.CompleteAccessGrant(ctx, req) + if err != nil && req.State == r.step.State && r.step.State != "" { + return gp.AccessGrant{Owner: req.Params["owner"]}, nil // forgives a replay + } + return g, err +} +func TestMutant_AccessGrantStateIsReusable(t *testing.T) { + run(t, func() gp.Provider { return &reusableGrant{Full: fakeprovider.New()} }) +} diff --git a/gitprovider/fakeprovider/variants.go b/gitprovider/fakeprovider/variants.go new file mode 100644 index 0000000..38eb743 --- /dev/null +++ b/gitprovider/fakeprovider/variants.go @@ -0,0 +1,296 @@ +package fakeprovider + +import ( + "context" + "net/url" + "strconv" + "strings" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" +) + +// Minimal implements only the required surface and declares the smaller, +// honest capability set of a basic platform. +type Minimal struct{ *Core } + +// NewMinimal returns a provider with static credentials, repo-scoped webhooks, +// comment-only status, emulated batch review, and only mention triggers. +func NewMinimal() *Minimal { + return &Minimal{newCore(gp.Capabilities{ + IdentityKinds: []gp.IdentityKind{gp.IdentityToken}, CredentialModel: gp.CredentialStatic, + WebhookScope: gp.WebhookRepo, StatusKind: gp.StatusComment, + Triggers: []gp.TriggerKind{gp.TriggerMention}, + NestedPaths: true, + })} +} + +// Full implements every optional interface and declares every capability. +type Full struct{ *Core } + +// New returns the full-featured fake. +func New() *Full { + return &Full{newCore(gp.Capabilities{ + IdentityKinds: []gp.IdentityKind{gp.IdentityApp, gp.IdentityBotUser}, CredentialModel: gp.CredentialEphemeral, + WebhookScope: gp.WebhookApp, StatusKind: gp.StatusCheckRun, + Triggers: gp.AllTriggerKinds, + MultipleIdentities: true, + ResolveThreads: true, FileRead: true, CodeSearch: true, Labels: true, ReviewerRequests: true, Assignees: true, + Merge: true, RerunChecks: true, Reactions: true, WebhookManagement: true, IdentityDeletion: true, AccessGrants: true, + BatchReview: true, IdempotentWrites: true, HiddenMarkers: true, ForkChangeRequests: true, DraftChangeRequests: true, + CIConfigGlobs: []string{".fake-ci/**"}, + })} +} + +// ResolveThread implements gp.ThreadResolver. +func (f *Full) ResolveThread(_ context.Context, req gp.ResolveThreadRequest) (gp.Empty, error) { + f.mu.Lock() + defer f.mu.Unlock() + _, x, err := f.cr(req.Target, req.Number) + if err != nil { + return gp.Empty{}, err + } + for _, t := range x.threads { + if t.ID == req.ThreadID { + t.Resolved = req.Resolved + return gp.Empty{}, nil + } + } + return gp.Empty{}, gp.Errorf(gp.CodeNotFound, "thread not found") +} + +// GetFile implements gp.FileReader. +func (f *Full) GetFile(_ context.Context, req gp.FileRequest) (gp.File, error) { + f.mu.Lock() + defer f.mu.Unlock() + r, err := f.repo(req.Target) + if err != nil { + return gp.File{}, err + } + b, ok := r.files[req.Path] + if !ok { + return gp.File{}, gp.Errorf(gp.CodeNotFound, "file not found") + } + if req.MaxBytes > 0 && len(b) > req.MaxBytes { + return gp.File{Path: req.Path, Content: b[:req.MaxBytes], Truncated: true}, nil + } + return gp.File{Path: req.Path, Content: b, SHA: "blob-" + req.Path}, nil +} + +// SearchCode implements gp.CodeSearcher. +func (f *Full) SearchCode(_ context.Context, req gp.CodeSearchRequest) (gp.CodeHits, error) { + f.mu.Lock() + defer f.mu.Unlock() + r, err := f.repo(req.Target) + if err != nil { + return gp.CodeHits{}, err + } + if strings.TrimSpace(req.Query) == "" { + return gp.CodeHits{}, gp.Errorf(gp.CodeValidation, "query is required") + } + var hits gp.CodeHits + for p, b := range r.files { + if strings.Contains(string(b), req.Query) { + hits.Items = append(hits.Items, gp.CodeHit{Path: p, Line: 1, Snippet: req.Query}) + } + } + return hits, nil +} + +func (f *Full) subject(t gp.Target, s gp.SubjectRef) (*repoState, error) { + r, err := f.repo(t) + if err != nil { + return nil, err + } + if s.Kind == gp.SubjectChangeRequest { + if _, ok := r.crs[s.Number]; !ok { + return nil, gp.Errorf(gp.CodeNotFound, "change request not found") + } + } else if _, ok := r.issues[s.Number]; !ok { + return nil, gp.Errorf(gp.CodeNotFound, "issue not found") + } + return r, nil +} + +// AddLabels implements gp.LabelManager. +func (f *Full) AddLabels(_ context.Context, req gp.LabelsRequest) (gp.Empty, error) { + f.mu.Lock() + defer f.mu.Unlock() + r, err := f.subject(req.Target, req.Subject) + if err != nil { + return gp.Empty{}, err + } + if req.Subject.Kind == gp.SubjectChangeRequest { + x := r.crs[req.Subject.Number] + x.Labels = append(x.Labels, req.Labels...) + } else { + i := r.issues[req.Subject.Number] + i.Labels = append(i.Labels, req.Labels...) + } + return gp.Empty{}, nil +} + +// RequestReviewers implements gp.ReviewerRequester. +func (f *Full) RequestReviewers(_ context.Context, req gp.PeopleRequest) (gp.Empty, error) { + f.mu.Lock() + defer f.mu.Unlock() + r, err := f.subject(req.Target, req.Subject) + if err != nil { + return gp.Empty{}, err + } + if req.Subject.Kind != gp.SubjectChangeRequest { + return gp.Empty{}, gp.Errorf(gp.CodeValidation, "reviewers apply to change requests") + } + x := r.crs[req.Subject.Number] + x.Reviewers = append(x.Reviewers, req.Logins...) + return gp.Empty{}, nil +} + +// SetAssignees implements gp.AssigneeSetter. +func (f *Full) SetAssignees(_ context.Context, req gp.PeopleRequest) (gp.Empty, error) { + f.mu.Lock() + defer f.mu.Unlock() + r, err := f.subject(req.Target, req.Subject) + if err != nil { + return gp.Empty{}, err + } + if req.Subject.Kind == gp.SubjectIssue { + r.issues[req.Subject.Number].Assignees = req.Logins + } + return gp.Empty{}, nil +} + +// MergeChangeRequest implements gp.Merger. +func (f *Full) MergeChangeRequest(_ context.Context, req gp.MergeRequest) (gp.MergeResult, error) { + f.mu.Lock() + defer f.mu.Unlock() + _, x, err := f.cr(req.Target, req.Number) + if err != nil { + return gp.MergeResult{}, err + } + if req.ExpectedHeadSHA == "" { + return gp.MergeResult{}, gp.Errorf(gp.CodeValidation, "expected_head_sha is required") + } + if x.Head.SHA != req.ExpectedHeadSHA { + return gp.MergeResult{}, gp.Errorf(gp.CodeConflict, "head moved since it was reviewed") + } + if x.State != gp.CROpen { + return gp.MergeResult{}, gp.Errorf(gp.CodeConflict, "change request is not open") + } + x.State = gp.CRMerged + return gp.MergeResult{SHA: "merge-" + x.Head.SHA}, nil +} + +// RerunChecks implements gp.CheckRerunner. +func (f *Full) RerunChecks(_ context.Context, req gp.RerunRequest) (gp.Empty, error) { + f.mu.Lock() + defer f.mu.Unlock() + r, err := f.repo(req.Target) + if err != nil { + return gp.Empty{}, err + } + list, ok := r.checks[req.Ref] + if !ok { + return gp.Empty{}, gp.Errorf(gp.CodeNotFound, "no checks for this ref") + } + for i := range list { + if list[i].State == gp.CheckFailure && (req.CheckID == "" || list[i].ID == req.CheckID) { + list[i].State = gp.CheckPending + } + } + return gp.Empty{}, nil +} + +// React implements gp.Reactor. +func (f *Full) React(_ context.Context, req gp.ReactRequest) (gp.Empty, error) { + f.mu.Lock() + defer f.mu.Unlock() + r, err := f.repo(req.Target) + if err != nil { + return gp.Empty{}, err + } + switch req.Emoji { + case "eyes", "rocket", "confused", "+1": + default: + return gp.Empty{}, gp.Errorf(gp.CodeValidation, "unsupported emoji") + } + r.reactions = append(r.reactions, req.CommentID+":"+req.Emoji) + return gp.Empty{}, nil +} + +// EnsureWebhook implements gp.WebhookManager. +func (f *Full) EnsureWebhook(_ context.Context, req gp.EnsureWebhookRequest) (gp.WebhookRef, error) { + f.mu.Lock() + defer f.mu.Unlock() + r, err := f.repo(req.Target) + if err != nil { + return gp.WebhookRef{}, err + } + if req.URL == "" { + return gp.WebhookRef{}, gp.Errorf(gp.CodeValidation, "url is required") + } + for id, u := range r.hooks { + if u == req.URL { + return gp.WebhookRef{ID: id}, nil // idempotent + } + } + id := f.next("hook") + r.hooks[id] = req.URL + return gp.WebhookRef{ID: id}, nil +} + +// RemoveWebhook implements gp.WebhookManager. +func (f *Full) RemoveWebhook(_ context.Context, req gp.RemoveWebhookRequest) (gp.Empty, error) { + f.mu.Lock() + defer f.mu.Unlock() + r, err := f.repo(req.Target) + if err != nil { + return gp.Empty{}, err + } + delete(r.hooks, req.Webhook.ID) // removing a missing hook is not an error + return gp.Empty{}, nil +} + +// DeleteIdentity implements gp.IdentityDeleter. +func (f *Full) DeleteIdentity(_ context.Context, req gp.DeleteIdentityRequest) (gp.Empty, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.Deleted = append(f.Deleted, req.Identity.ID) + return gp.Empty{}, nil +} + +// BeginAccessGrant implements gp.AccessGranter. +func (f *Full) BeginAccessGrant(_ context.Context, req gp.BeginAccessRequest) (gp.SetupStep, error) { + if req.Identity.ID != IdentityID { + return gp.SetupStep{}, gp.Errorf(gp.CodeNotFound, "identity not found") + } + if req.CallbackURL == "" { + return gp.SetupStep{}, gp.Errorf(gp.CodeValidation, "callback_url is required") + } + f.mu.Lock() + defer f.mu.Unlock() + f.seq++ + state := "grant-" + strconv.Itoa(f.seq) + if f.grants == nil { + f.grants = map[string]bool{} + } + f.grants[state] = false + return gp.SetupStep{Kind: gp.StepRedirect, URL: "https://fake.example/grant?owner=" + url.QueryEscape(req.Owner), State: state}, nil +} + +// CompleteAccessGrant implements gp.AccessGranter. A state works once. +func (f *Full) CompleteAccessGrant(_ context.Context, req gp.CompleteAccessRequest) (gp.AccessGrant, error) { + if req.Identity.ID != IdentityID { + return gp.AccessGrant{}, gp.Errorf(gp.CodeNotFound, "identity not found") + } + if req.State == "" { + return gp.AccessGrant{}, gp.Errorf(gp.CodeValidation, "state is required") + } + f.mu.Lock() + defer f.mu.Unlock() + used, known := f.grants[req.State] + if !known || used { + return gp.AccessGrant{}, gp.Errorf(gp.CodeForbidden, "unknown or already used state") + } + f.grants[req.State] = true + return gp.AccessGrant{Owner: req.Params["owner"]}, nil +} diff --git a/gitprovider/gitprovider_test.go b/gitprovider/gitprovider_test.go new file mode 100644 index 0000000..0e2ee40 --- /dev/null +++ b/gitprovider/gitprovider_test.go @@ -0,0 +1,85 @@ +package gitprovider_test + +import ( + "encoding/json" + "errors" + "fmt" + "testing" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" + "github.com/Paca-AI/plugin-sdk-go/gitprovider/fakeprovider" +) + +func TestPermissionOrdering(t *testing.T) { + order := []gp.Permission{gp.PermissionNone, gp.PermissionRead, gp.PermissionTriage, gp.PermissionWrite, gp.PermissionMaintain, gp.PermissionAdmin} + for i, a := range order { + for j, b := range order { + if got, want := a.AtLeast(b), i >= j; got != want { + t.Errorf("%s.AtLeast(%s) = %v, want %v", a, b, got, want) + } + } + } + if gp.Permission("owner").Valid() || gp.Permission("").AtLeast(gp.PermissionNone) { + t.Error("an undefined permission must never satisfy a requirement, not even 'none'") + } +} + +func TestAsError_NeverLeaksInternals(t *testing.T) { + e := gp.AsError(fmt.Errorf("dial tcp 10.0.0.5: token ghs_secret rejected")) + if e.Code != gp.CodeInternal || e.Message == "" || contains(e.Message, "ghs_secret") || contains(e.Message, "10.0.0.5") { + t.Errorf("unclassified errors must become a generic internal error, got %+v", e) + } + if !errors.Is(gp.Errorf(gp.CodeUnsupported, "x"), gp.ErrUnsupported) { + t.Error("errors.Is must match on code") + } + if !gp.RateLimited(30, "slow down").Retryable() || gp.Errorf(gp.CodeForbidden, "no").Retryable() { + t.Error("only rate_limited and transient are retryable") + } +} + +func TestOperationsTable(t *testing.T) { + // Every optional operation names a capability that exists in Capabilities. + raw, _ := json.Marshal(gp.Capabilities{}) + var caps map[string]any + _ = json.Unmarshal(raw, &caps) + seen := map[string]bool{} + for _, op := range gp.Operations() { + if seen[op.Name] { + t.Errorf("duplicate operation %q", op.Name) + } + seen[op.Name] = true + if op.Capability != "" { + if _, ok := caps[op.Capability]; !ok { + t.Errorf("%s references capability %q which is not a Capabilities field", op.Name, op.Capability) + } + } + } + // Every capability that gates an operation is covered by the table, so a new + // optional interface cannot be added without a wire operation. + for _, flag := range []string{"resolve_threads", "file_read", "code_search", "labels", "reviewer_requests", "assignees", "merge", "rerun_checks", "reactions", "webhook_management", "identity_deletion", "access_grants"} { + found := false + for _, op := range gp.Operations() { + if op.Capability == flag { + found = true + } + } + if !found { + t.Errorf("capability %q gates no operation", flag) + } + } + if impl := gp.Implemented(fakeprovider.New()); !(impl.Merge && impl.WebhookManagement && impl.IdentityDeletion) { + t.Error("Implemented() must see the full fake's optional interfaces") + } + if impl := gp.Implemented(fakeprovider.NewMinimal()); impl.Merge || impl.FileRead { + t.Error("Implemented() must not report optional interfaces the minimal fake lacks") + } +} + +func contains(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/gitprovider/provider.go b/gitprovider/provider.go new file mode 100644 index 0000000..2a27ed8 --- /dev/null +++ b/gitprovider/provider.go @@ -0,0 +1,136 @@ +package gitprovider + +import "context" + +// Provider is the required surface of a Git platform adapter. Everything here +// exists on every platform Paca targets; platform-specific extras are the +// optional interfaces below. +type Provider interface { + // Describe returns static information and capabilities. + Describe() Info + + // ── identity setup ── + BeginIdentitySetup(ctx context.Context, req BeginSetupRequest) (SetupStep, error) + CompleteIdentitySetup(ctx context.Context, req CompleteSetupRequest) (Identity, error) + + // ── repositories ── + ListRepositories(ctx context.Context, req ListReposRequest) (RepositoryPage, error) + ResolveRepository(ctx context.Context, req ResolveRepoRequest) (ResolvedRepository, error) + + // ── webhooks ── + + // VerifyAndParseWebhook checks the delivery's signature/secret and returns + // normalized events (possibly none). It is the only place a platform's + // webhook format lives. A failed verification returns [CodeInvalidSignature]. + VerifyAndParseWebhook(ctx context.Context, req WebhookRequest) ([]Event, error) + + // ── credentials and policy inputs ── + MintCredential(ctx context.Context, req MintRequest) (Credential, error) + GetActorPermission(ctx context.Context, req ActorPermissionRequest) (Permission, error) + GetBranchProtection(ctx context.Context, req BranchProtectionRequest) (BranchProtection, error) + + // ── reads ── + GetChangeRequest(ctx context.Context, req CRRequest) (ChangeRequest, error) + ListChangedFiles(ctx context.Context, req PagedCRRequest) (ChangedFilePage, error) + GetDiff(ctx context.Context, req DiffRequest) (Diff, error) + ListThreads(ctx context.Context, req PagedCRRequest) (ThreadPage, error) + ListComments(ctx context.Context, req ListCommentsRequest) (CommentPage, error) + GetIssue(ctx context.Context, req IssueRequest) (Issue, error) + GetChecks(ctx context.Context, req ChecksRequest) ([]Check, error) + GetCheckLogs(ctx context.Context, req CheckLogsRequest) (Logs, error) + + // ── writes ── + OpenChangeRequest(ctx context.Context, req OpenCRRequest) (ChangeRequest, error) + UpdateChangeRequest(ctx context.Context, req UpdateCRRequest) (ChangeRequest, error) + Comment(ctx context.Context, req CommentRequest) (CommentResult, error) + ReplyThread(ctx context.Context, req ReplyThreadRequest) (CommentResult, error) + SubmitReview(ctx context.Context, req SubmitReviewRequest) (ReviewResult, error) + SetStatus(ctx context.Context, req SetStatusRequest) (StatusResult, error) +} + +// Optional interfaces. Implement one only when the platform supports it, and +// set the matching [Capabilities] flag; the conformance suite checks that the +// two agree. + +// ThreadResolver resolves and reopens review threads (capability ResolveThreads). +type ThreadResolver interface { + ResolveThread(ctx context.Context, req ResolveThreadRequest) (Empty, error) +} + +// FileReader reads files at a ref without a checkout (capability FileRead). +type FileReader interface { + GetFile(ctx context.Context, req FileRequest) (File, error) +} + +// CodeSearcher searches repository code (capability CodeSearch). +type CodeSearcher interface { + SearchCode(ctx context.Context, req CodeSearchRequest) (CodeHits, error) +} + +// LabelManager adds labels (capability Labels). +type LabelManager interface { + AddLabels(ctx context.Context, req LabelsRequest) (Empty, error) +} + +// ReviewerRequester requests reviewers (capability ReviewerRequests). +type ReviewerRequester interface { + RequestReviewers(ctx context.Context, req PeopleRequest) (Empty, error) +} + +// AssigneeSetter sets assignees (capability Assignees). +type AssigneeSetter interface { + SetAssignees(ctx context.Context, req PeopleRequest) (Empty, error) +} + +// Merger merges change requests (capability Merge). +type Merger interface { + MergeChangeRequest(ctx context.Context, req MergeRequest) (MergeResult, error) +} + +// CheckRerunner re-runs failed checks (capability RerunChecks). +type CheckRerunner interface { + RerunChecks(ctx context.Context, req RerunRequest) (Empty, error) +} + +// Reactor adds emoji reactions (capability Reactions). +type Reactor interface { + React(ctx context.Context, req ReactRequest) (Empty, error) +} + +// WebhookManager registers webhooks on repo/group-scoped platforms (capability +// WebhookManagement). +type WebhookManager interface { + EnsureWebhook(ctx context.Context, req EnsureWebhookRequest) (WebhookRef, error) + RemoveWebhook(ctx context.Context, req RemoveWebhookRequest) (Empty, error) +} + +// IdentityDeleter cleans up an identity remotely (capability IdentityDeletion). +type IdentityDeleter interface { + DeleteIdentity(ctx context.Context, req DeleteIdentityRequest) (Empty, error) +} + +// AccessGranter lets a user grant an identity access to one more account or +// organization (capability AccessGrants). +type AccessGranter interface { + BeginAccessGrant(ctx context.Context, req BeginAccessRequest) (SetupStep, error) + CompleteAccessGrant(ctx context.Context, req CompleteAccessRequest) (AccessGrant, error) +} + +// Implemented reports which optional capabilities p actually implements, so +// tooling can compare them with what [Info] declares. +func Implemented(p Provider) Capabilities { + var c Capabilities + _, c.ResolveThreads = p.(ThreadResolver) + _, c.FileRead = p.(FileReader) + _, c.CodeSearch = p.(CodeSearcher) + _, c.Labels = p.(LabelManager) + _, c.ReviewerRequests = p.(ReviewerRequester) + _, c.Assignees = p.(AssigneeSetter) + _, c.Merge = p.(Merger) + _, c.RerunChecks = p.(CheckRerunner) + _, c.Reactions = p.(Reactor) + _, c.WebhookManagement = p.(WebhookManager) + _, c.IdentityDeletion = p.(IdentityDeleter) + _, c.AccessGrants = p.(AccessGranter) + return c +} diff --git a/gitprovider/serve/serve.go b/gitprovider/serve/serve.go new file mode 100644 index 0000000..100d925 --- /dev/null +++ b/gitprovider/serve/serve.go @@ -0,0 +1,30 @@ +// Package serve mounts a [gitprovider.Provider] on a plugin's routes. +package serve + +import ( + "context" + + plugin "github.com/Paca-AI/plugin-sdk-go" + "github.com/Paca-AI/plugin-sdk-go/gitprovider" +) + +// RoutePrefix is where provider operations are mounted. The host treats +// `/_git/*` as internal-only: browsers cannot reach it; only core services (and +// the MCP server, after core's own checks) call it. +const RoutePrefix = "/_git" + +// Register mounts every operation as POST /_git/:op. Call it from the plugin's +// Init: +// +// func (p *myPlugin) Init(ctx *plugin.Context) error { +// serve.Register(ctx, newMyProvider(ctx)) +// return nil +// } +func Register(ctx *plugin.Context, p gitprovider.Provider) { + ctx.Route("POST", RoutePrefix+"/:op", func(req *plugin.Request, res *plugin.Response) { + status, body := gitprovider.Dispatch(context.Background(), p, req.PathParam("op"), req.Body) + res.StatusCode = status + res.Headers["Content-Type"] = "application/json" + res.Body = body + }) +} diff --git a/gitprovider/serve/serve_test.go b/gitprovider/serve/serve_test.go new file mode 100644 index 0000000..4f0f0ba --- /dev/null +++ b/gitprovider/serve/serve_test.go @@ -0,0 +1,53 @@ +package serve_test + +import ( + "encoding/json" + "testing" + + plugin "github.com/Paca-AI/plugin-sdk-go" + "github.com/Paca-AI/plugin-sdk-go/gitprovider" + "github.com/Paca-AI/plugin-sdk-go/gitprovider/fakeprovider" + "github.com/Paca-AI/plugin-sdk-go/gitprovider/serve" + "github.com/Paca-AI/plugin-sdk-go/plugintest" +) + +type host struct{ p gitprovider.Provider } + +func (h host) Init(ctx *plugin.Context) error { serve.Register(ctx, h.p); return nil } +func (host) Shutdown() {} + +func setup(t *testing.T, p gitprovider.Provider) *plugintest.Context { + t.Helper() + tc := plugintest.NewContext(t) + if err := (host{p}).Init(tc.PluginContext()); err != nil { + t.Fatal(err) + } + return tc +} + +func TestRegister_DescribeOverRoute(t *testing.T) { + tc := setup(t, fakeprovider.New()) + res := tc.Call("POST", "/_git/describe", plugintest.Request{PathParams: map[string]string{"op": "describe"}}) + if res.StatusCode != 200 { + t.Fatalf("status %d: %s", res.StatusCode, res.BodyString()) + } + var env gitprovider.Envelope + if err := json.Unmarshal(res.Body, &env); err != nil || !env.OK { + t.Fatalf("envelope: %v %s", err, res.BodyString()) + } + var info gitprovider.Info + if err := json.Unmarshal(env.Result, &info); err != nil || info.SPIVersion != gitprovider.SPIVersion { + t.Fatalf("info: %+v err=%v", info, err) + } +} + +func TestRegister_ErrorsCarryHTTPStatus(t *testing.T) { + tc := setup(t, fakeprovider.New()) + res := tc.Call("POST", "/_git/get_change_request", plugintest.Request{ + PathParams: map[string]string{"op": "get_change_request"}, + Body: []byte(`{"target":{"repo":{"full_path":"nope/none"}},"number":"1"}`), + }) + if res.StatusCode != 404 { + t.Fatalf("want 404, got %d: %s", res.StatusCode, res.BodyString()) + } +} diff --git a/gitprovider/testdata/wire-shapes.golden.json b/gitprovider/testdata/wire-shapes.golden.json new file mode 100644 index 0000000..b9f5e08 --- /dev/null +++ b/gitprovider/testdata/wire-shapes.golden.json @@ -0,0 +1,1179 @@ +{ + "spi_version": "gitprovider.v1", + "types": { + "AccessGrant": { + "owner": "string" + }, + "Actor": { + "kind": "string", + "login": "string", + "permission": "string" + }, + "ActorPermissionRequest": { + "login": "string", + "target": { + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "repo": { + "clone_url": "string", + "default_branch": "string", + "external_id": "string", + "full_path": "string", + "host": "string", + "provider_ref": "bytes" + } + } + }, + "Addressed": { + "identity_id": "string", + "via": "string" + }, + "BeginAccessRequest": { + "callback_url": "string", + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "owner": "string", + "return_url": "string" + }, + "BeginSetupRequest": { + "agent_id": "string", + "agent_name": "string", + "callback_url": "string", + "host": "string", + "params": { + "*": "string" + }, + "return_url": "string", + "scope": "string", + "webhook_url": "string" + }, + "Branch": { + "from_fork": "bool", + "ref": "string", + "repo": "string", + "sha": "string" + }, + "BranchProtection": { + "allows_force_push": "bool", + "protected": "bool", + "requires_review": "bool", + "requires_status_checks": "bool" + }, + "BranchProtectionRequest": { + "branch": "string", + "target": { + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "repo": { + "clone_url": "string", + "default_branch": "string", + "external_id": "string", + "full_path": "string", + "host": "string", + "provider_ref": "bytes" + } + } + }, + "CRRequest": { + "number": "string", + "target": { + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "repo": { + "clone_url": "string", + "default_branch": "string", + "external_id": "string", + "full_path": "string", + "host": "string", + "provider_ref": "bytes" + } + } + }, + "Capabilities": { + "access_grants": "bool", + "assignees": "bool", + "batch_review": "bool", + "ci_config_globs": [ + "string" + ], + "code_search": "bool", + "credential_model": "string", + "draft_change_requests": "bool", + "file_read": "bool", + "fork_change_requests": "bool", + "hidden_markers": "bool", + "idempotent_writes": "bool", + "identity_deletion": "bool", + "identity_kinds": [ + "string" + ], + "labels": "bool", + "merge": "bool", + "multiple_identities": "bool", + "nested_paths": "bool", + "reactions": "bool", + "rerun_checks": "bool", + "resolve_threads": "bool", + "reviewer_requests": "bool", + "status_kind": "string", + "triggers": [ + "string" + ], + "webhook_management": "bool", + "webhook_scope": "string" + }, + "ChangeRequest": { + "author": "string", + "base": { + "from_fork": "bool", + "ref": "string", + "repo": "string", + "sha": "string" + }, + "body": "string", + "draft": "bool", + "head": { + "from_fork": "bool", + "ref": "string", + "repo": "string", + "sha": "string" + }, + "labels": [ + "string" + ], + "number": "string", + "reviewers": [ + "string" + ], + "state": "string", + "title": "string", + "updated_at": "time", + "url": "string" + }, + "ChangedFile": { + "additions": "number", + "deletions": "number", + "old_path": "string", + "path": "string", + "status": "string" + }, + "ChangedFilePage": { + "items": [ + { + "additions": "number", + "deletions": "number", + "old_path": "string", + "path": "string", + "status": "string" + } + ], + "next_cursor": "string" + }, + "Check": { + "id": "string", + "name": "string", + "required": "bool", + "state": "string", + "summary": "string", + "url": "string" + }, + "CheckInfo": { + "conclusion": "string", + "name": "string", + "run_id": "string", + "sha": "string" + }, + "CheckLogsRequest": { + "check_id": "string", + "tail_bytes": "number", + "target": { + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "repo": { + "clone_url": "string", + "default_branch": "string", + "external_id": "string", + "full_path": "string", + "host": "string", + "provider_ref": "bytes" + } + } + }, + "ChecksRequest": { + "ref": "string", + "target": { + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "repo": { + "clone_url": "string", + "default_branch": "string", + "external_id": "string", + "full_path": "string", + "host": "string", + "provider_ref": "bytes" + } + } + }, + "CodeHit": { + "line": "number", + "path": "string", + "snippet": "string" + }, + "CodeHits": { + "items": [ + { + "line": "number", + "path": "string", + "snippet": "string" + } + ] + }, + "CodeSearchRequest": { + "query": "string", + "target": { + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "repo": { + "clone_url": "string", + "default_branch": "string", + "external_id": "string", + "full_path": "string", + "host": "string", + "provider_ref": "bytes" + } + } + }, + "Comment": { + "author": "string", + "author_kind": "string", + "body": "string", + "created_at": "time", + "id": "string", + "url": "string" + }, + "CommentPage": { + "items": [ + { + "author": "string", + "author_kind": "string", + "body": "string", + "created_at": "time", + "id": "string", + "url": "string" + } + ], + "next_cursor": "string" + }, + "CommentRequest": { + "body": "string", + "idempotency_key": "string", + "subject": { + "kind": "string", + "number": "string" + }, + "target": { + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "repo": { + "clone_url": "string", + "default_branch": "string", + "external_id": "string", + "full_path": "string", + "host": "string", + "provider_ref": "bytes" + } + } + }, + "CommentResult": { + "id": "string", + "url": "string" + }, + "CompleteAccessRequest": { + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "params": { + "*": "string" + }, + "state": "string" + }, + "CompleteSetupRequest": { + "host": "string", + "params": { + "*": "string" + }, + "scope": "string", + "state": "string" + }, + "Credential": { + "expires_at": "time", + "secret": "string", + "username": "string" + }, + "DeleteIdentityRequest": { + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + } + }, + "Diff": { + "text": "string", + "truncated": "bool" + }, + "DiffRequest": { + "max_bytes": "number", + "number": "string", + "path": "string", + "target": { + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "repo": { + "clone_url": "string", + "default_branch": "string", + "external_id": "string", + "full_path": "string", + "host": "string", + "provider_ref": "bytes" + } + } + }, + "EnsureWebhookRequest": { + "target": { + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "repo": { + "clone_url": "string", + "default_branch": "string", + "external_id": "string", + "full_path": "string", + "host": "string", + "provider_ref": "bytes" + } + }, + "url": "string" + }, + "Envelope": { + "error": { + "code": "string", + "message": "string", + "retry_after_seconds": "number" + }, + "ok": "bool", + "result": "bytes" + }, + "Error": { + "code": "string", + "message": "string", + "retry_after_seconds": "number" + }, + "Event": { + "action": "string", + "actor": { + "kind": "string", + "login": "string", + "permission": "string" + }, + "addressed": [ + { + "identity_id": "string", + "via": "string" + } + ], + "check": { + "conclusion": "string", + "name": "string", + "run_id": "string", + "sha": "string" + }, + "id": "string", + "kind": "string", + "label": "string", + "location": { + "comment_id": "string", + "line": "number", + "path": "string", + "thread_id": "string" + }, + "push": { + "created": "bool", + "deleted": "bool", + "ref": "string", + "sha": "string" + }, + "raw_ref": "string", + "repo": { + "clone_url": "string", + "default_branch": "string", + "external_id": "string", + "full_path": "string", + "host": "string", + "provider_ref": "bytes" + }, + "subject": { + "author": "string", + "base": "string", + "draft": "bool", + "head": { + "from_fork": "bool", + "ref": "string", + "repo": "string", + "sha": "string" + }, + "kind": "string", + "number": "string" + }, + "text": "string" + }, + "EventSubject": { + "author": "string", + "base": "string", + "draft": "bool", + "head": { + "from_fork": "bool", + "ref": "string", + "repo": "string", + "sha": "string" + }, + "kind": "string", + "number": "string" + }, + "File": { + "content": "bytes", + "path": "string", + "sha": "string", + "truncated": "bool" + }, + "FileRequest": { + "max_bytes": "number", + "path": "string", + "ref": "string", + "target": { + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "repo": { + "clone_url": "string", + "default_branch": "string", + "external_id": "string", + "full_path": "string", + "host": "string", + "provider_ref": "bytes" + } + } + }, + "FormField": { + "help": "string", + "label": "string", + "name": "string", + "required": "bool", + "secret": "bool" + }, + "Identity": { + "display_name": "string", + "expires_at": "time", + "host": "string", + "kind": "string", + "next": { + "fields": { + "*": "string" + }, + "form": [ + { + "help": "string", + "label": "string", + "name": "string", + "required": "bool", + "secret": "bool" + } + ], + "identity": "recursive:Identity", + "kind": "string", + "prompt": "string", + "state": "string", + "url": "string" + }, + "ref": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + } + }, + "IdentityRef": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "Info": { + "capabilities": { + "access_grants": "bool", + "assignees": "bool", + "batch_review": "bool", + "ci_config_globs": [ + "string" + ], + "code_search": "bool", + "credential_model": "string", + "draft_change_requests": "bool", + "file_read": "bool", + "fork_change_requests": "bool", + "hidden_markers": "bool", + "idempotent_writes": "bool", + "identity_deletion": "bool", + "identity_kinds": [ + "string" + ], + "labels": "bool", + "merge": "bool", + "multiple_identities": "bool", + "nested_paths": "bool", + "reactions": "bool", + "rerun_checks": "bool", + "resolve_threads": "bool", + "reviewer_requests": "bool", + "status_kind": "string", + "triggers": [ + "string" + ], + "webhook_management": "bool", + "webhook_scope": "string" + }, + "display_name": "string", + "hosts": [ + "string" + ], + "icon": "string", + "id": "string", + "self_hosted_hosts": "bool", + "spi_version": "string", + "terms": { + "change_request": "string", + "change_request_plural": "string", + "repository": "string" + } + }, + "InlineComment": { + "body": "string", + "line": "number", + "path": "string", + "side": "string" + }, + "Issue": { + "assignees": [ + "string" + ], + "author": "string", + "body": "string", + "labels": [ + "string" + ], + "number": "string", + "state": "string", + "title": "string", + "url": "string" + }, + "IssueRequest": { + "number": "string", + "target": { + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "repo": { + "clone_url": "string", + "default_branch": "string", + "external_id": "string", + "full_path": "string", + "host": "string", + "provider_ref": "bytes" + } + } + }, + "LabelsRequest": { + "labels": [ + "string" + ], + "subject": { + "kind": "string", + "number": "string" + }, + "target": { + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "repo": { + "clone_url": "string", + "default_branch": "string", + "external_id": "string", + "full_path": "string", + "host": "string", + "provider_ref": "bytes" + } + } + }, + "ListCommentsRequest": { + "cursor": "string", + "subject": { + "kind": "string", + "number": "string" + }, + "target": { + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "repo": { + "clone_url": "string", + "default_branch": "string", + "external_id": "string", + "full_path": "string", + "host": "string", + "provider_ref": "bytes" + } + } + }, + "ListReposRequest": { + "cursor": "string", + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "page_size": "number", + "query": "string" + }, + "Location": { + "comment_id": "string", + "line": "number", + "path": "string", + "thread_id": "string" + }, + "Logs": { + "text": "string", + "truncated": "bool" + }, + "MergeRequest": { + "expected_head_sha": "string", + "method": "string", + "number": "string", + "target": { + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "repo": { + "clone_url": "string", + "default_branch": "string", + "external_id": "string", + "full_path": "string", + "host": "string", + "provider_ref": "bytes" + } + } + }, + "MergeResult": { + "sha": "string" + }, + "MintRequest": { + "access": "string", + "target": { + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "repo": { + "clone_url": "string", + "default_branch": "string", + "external_id": "string", + "full_path": "string", + "host": "string", + "provider_ref": "bytes" + } + }, + "ttl_seconds": "number" + }, + "OpenCRRequest": { + "base": "string", + "body": "string", + "draft": "bool", + "head": "string", + "idempotency_key": "string", + "labels": [ + "string" + ], + "reviewers": [ + "string" + ], + "target": { + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "repo": { + "clone_url": "string", + "default_branch": "string", + "external_id": "string", + "full_path": "string", + "host": "string", + "provider_ref": "bytes" + } + }, + "title": "string" + }, + "PagedCRRequest": { + "cursor": "string", + "number": "string", + "target": { + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "repo": { + "clone_url": "string", + "default_branch": "string", + "external_id": "string", + "full_path": "string", + "host": "string", + "provider_ref": "bytes" + } + } + }, + "PeopleRequest": { + "logins": [ + "string" + ], + "subject": { + "kind": "string", + "number": "string" + }, + "target": { + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "repo": { + "clone_url": "string", + "default_branch": "string", + "external_id": "string", + "full_path": "string", + "host": "string", + "provider_ref": "bytes" + } + } + }, + "PushInfo": { + "created": "bool", + "deleted": "bool", + "ref": "string", + "sha": "string" + }, + "ReactRequest": { + "comment_id": "string", + "emoji": "string", + "target": { + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "repo": { + "clone_url": "string", + "default_branch": "string", + "external_id": "string", + "full_path": "string", + "host": "string", + "provider_ref": "bytes" + } + } + }, + "RemoveWebhookRequest": { + "target": { + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "repo": { + "clone_url": "string", + "default_branch": "string", + "external_id": "string", + "full_path": "string", + "host": "string", + "provider_ref": "bytes" + } + }, + "webhook": { + "id": "string" + } + }, + "ReplyThreadRequest": { + "body": "string", + "idempotency_key": "string", + "number": "string", + "target": { + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "repo": { + "clone_url": "string", + "default_branch": "string", + "external_id": "string", + "full_path": "string", + "host": "string", + "provider_ref": "bytes" + } + }, + "thread_id": "string" + }, + "RepoRef": { + "clone_url": "string", + "default_branch": "string", + "external_id": "string", + "full_path": "string", + "host": "string", + "provider_ref": "bytes" + }, + "Repository": { + "description": "string", + "private": "bool", + "ref": { + "clone_url": "string", + "default_branch": "string", + "external_id": "string", + "full_path": "string", + "host": "string", + "provider_ref": "bytes" + } + }, + "RepositoryPage": { + "items": [ + { + "description": "string", + "private": "bool", + "ref": { + "clone_url": "string", + "default_branch": "string", + "external_id": "string", + "full_path": "string", + "host": "string", + "provider_ref": "bytes" + } + } + ], + "next_cursor": "string" + }, + "RerunRequest": { + "check_id": "string", + "ref": "string", + "target": { + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "repo": { + "clone_url": "string", + "default_branch": "string", + "external_id": "string", + "full_path": "string", + "host": "string", + "provider_ref": "bytes" + } + } + }, + "ResolveRepoRequest": { + "candidates": [ + { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + } + ], + "host": "string", + "path_or_url": "string" + }, + "ResolveThreadRequest": { + "number": "string", + "resolved": "bool", + "target": { + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "repo": { + "clone_url": "string", + "default_branch": "string", + "external_id": "string", + "full_path": "string", + "host": "string", + "provider_ref": "bytes" + } + }, + "thread_id": "string" + }, + "ResolvedRepository": { + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "repository": { + "description": "string", + "private": "bool", + "ref": { + "clone_url": "string", + "default_branch": "string", + "external_id": "string", + "full_path": "string", + "host": "string", + "provider_ref": "bytes" + } + } + }, + "ReviewResult": { + "emulated": "bool", + "id": "string", + "posted": "number", + "url": "string" + }, + "SetStatusRequest": { + "sha": "string", + "state": "string", + "status_ref": "string", + "subject": { + "kind": "string", + "number": "string" + }, + "summary": "string", + "target": { + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "repo": { + "clone_url": "string", + "default_branch": "string", + "external_id": "string", + "full_path": "string", + "host": "string", + "provider_ref": "bytes" + } + }, + "title": "string", + "url": "string" + }, + "SetupStep": { + "fields": { + "*": "string" + }, + "form": [ + { + "help": "string", + "label": "string", + "name": "string", + "required": "bool", + "secret": "bool" + } + ], + "identity": { + "display_name": "string", + "expires_at": "time", + "host": "string", + "kind": "string", + "next": "recursive:SetupStep", + "ref": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + } + }, + "kind": "string", + "prompt": "string", + "state": "string", + "url": "string" + }, + "StatusResult": { + "status_ref": "string" + }, + "SubjectRef": { + "kind": "string", + "number": "string" + }, + "SubmitReviewRequest": { + "comments": [ + { + "body": "string", + "line": "number", + "path": "string", + "side": "string" + } + ], + "head_sha": "string", + "idempotency_key": "string", + "number": "string", + "summary": "string", + "target": { + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "repo": { + "clone_url": "string", + "default_branch": "string", + "external_id": "string", + "full_path": "string", + "host": "string", + "provider_ref": "bytes" + } + }, + "verdict": "string" + }, + "Target": { + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "repo": { + "clone_url": "string", + "default_branch": "string", + "external_id": "string", + "full_path": "string", + "host": "string", + "provider_ref": "bytes" + } + }, + "Terms": { + "change_request": "string", + "change_request_plural": "string", + "repository": "string" + }, + "Thread": { + "comments": [ + { + "author": "string", + "author_kind": "string", + "body": "string", + "created_at": "time", + "id": "string", + "url": "string" + } + ], + "id": "string", + "line": "number", + "outdated": "bool", + "path": "string", + "resolved": "bool" + }, + "ThreadPage": { + "items": [ + { + "comments": [ + { + "author": "string", + "author_kind": "string", + "body": "string", + "created_at": "time", + "id": "string", + "url": "string" + } + ], + "id": "string", + "line": "number", + "outdated": "bool", + "path": "string", + "resolved": "bool" + } + ], + "next_cursor": "string" + }, + "UpdateCRRequest": { + "body": "string", + "draft": "bool", + "number": "string", + "state": "string", + "target": { + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "repo": { + "clone_url": "string", + "default_branch": "string", + "external_id": "string", + "full_path": "string", + "host": "string", + "provider_ref": "bytes" + } + }, + "title": "string" + }, + "WebhookRef": { + "id": "string" + }, + "WebhookRequest": { + "body": "bytes", + "headers": { + "*": "string" + }, + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "method": "string", + "query": { + "*": "string" + } + } + } +} diff --git a/gitprovider/types.go b/gitprovider/types.go new file mode 100644 index 0000000..28d8725 --- /dev/null +++ b/gitprovider/types.go @@ -0,0 +1,940 @@ +package gitprovider + +import ( + "encoding/json" + "time" +) + +// SPIVersion identifies this revision of the contract. A provider reports the +// version it was written against in [Info.SPIVersion]; core refuses a provider +// whose major version it does not speak. +const SPIVersion = "gitprovider.v1" + +// ── Enumerations ───────────────────────────────────────────────────────────── + +// Permission is a neutral, ordered access level. Providers map their own roles +// onto it (e.g. GitLab Guest→read, Reporter→triage, Developer→write, +// Maintainer→maintain, Owner→admin). +type Permission string + +const ( + PermissionNone Permission = "none" + PermissionRead Permission = "read" + PermissionTriage Permission = "triage" + PermissionWrite Permission = "write" + PermissionMaintain Permission = "maintain" + PermissionAdmin Permission = "admin" +) + +var permissionRank = map[Permission]int{ + PermissionNone: 0, PermissionRead: 1, PermissionTriage: 2, + PermissionWrite: 3, PermissionMaintain: 4, PermissionAdmin: 5, +} + +// Valid reports whether p is one of the defined levels. +func (p Permission) Valid() bool { _, ok := permissionRank[p]; return ok } + +// Rank orders permissions; unknown values rank below [PermissionNone]. +func (p Permission) Rank() int { + if r, ok := permissionRank[p]; ok { + return r + } + return -1 +} + +// AtLeast reports whether p grants at least the level of min. +func (p Permission) AtLeast(min Permission) bool { return p.Rank() >= min.Rank() && p.Valid() } + +// IdentityKind is how a bot identity exists on the platform. +type IdentityKind string + +const ( + IdentityApp IdentityKind = "app" // GitHub App style + IdentityBotUser IdentityKind = "bot_user" // a user/bot account with an access token + IdentityToken IdentityKind = "token" // a bare personal/service token +) + +// CredentialModel says whether credentials can be minted per use. +type CredentialModel string + +const ( + // CredentialEphemeral platforms mint short-lived tokens down-scoped to a + // repository and access level (GitHub installation tokens). + CredentialEphemeral CredentialModel = "ephemeral" + // CredentialStatic platforms only have long-lived scoped tokens; the + // provider keeps the secret and core never hands it to an agent. + CredentialStatic CredentialModel = "static" +) + +// WebhookScope is where the platform registers webhooks. +type WebhookScope string + +const ( + WebhookApp WebhookScope = "app" // one webhook for everything the identity can see + WebhookGroup WebhookScope = "group" // per group/workspace/organization + WebhookRepo WebhookScope = "repo" // per repository +) + +// StatusKind is how a run's state is shown on the platform. +type StatusKind string + +const ( + StatusCheckRun StatusKind = "check_run" + StatusCommitStatus StatusKind = "commit_status" + StatusComment StatusKind = "comment" +) + +// Access is the credential access level requested for an operation. +type Access string + +const ( + AccessRead Access = "read" + AccessWrite Access = "write" +) + +// TriggerKind names a way the platform can address a bot or report work. +type TriggerKind string + +const ( + TriggerMention TriggerKind = "mention" + TriggerLabel TriggerKind = "label" + TriggerReviewRequest TriggerKind = "review_request" + TriggerAssignment TriggerKind = "assignment" + TriggerCIFailure TriggerKind = "ci_failure" + TriggerOwnCRFeedback TriggerKind = "own_cr_feedback" + TriggerAutoReview TriggerKind = "auto_review" +) + +// AllTriggerKinds lists every defined trigger kind. +var AllTriggerKinds = []TriggerKind{ + TriggerMention, TriggerLabel, TriggerReviewRequest, TriggerAssignment, + TriggerCIFailure, TriggerOwnCRFeedback, TriggerAutoReview, +} + +// ── References ─────────────────────────────────────────────────────────────── + +// IdentityRef names a bot identity. ProviderRef is opaque to core: the provider +// puts whatever it needs (an installation id, a token id) there and gets it back +// on every call. +type IdentityRef struct { + ID string `json:"id"` + Handle string `json:"handle,omitempty"` + ProviderRef json.RawMessage `json:"provider_ref,omitempty"` +} + +// RepoRef addresses a repository in a platform-neutral way. +type RepoRef struct { + Host string `json:"host"` + FullPath string `json:"full_path"` // "owner/name" or "group/subgroup/project" + ExternalID string `json:"external_id,omitempty"` + DefaultBranch string `json:"default_branch,omitempty"` + CloneURL string `json:"clone_url,omitempty"` // HTTPS, token-free + ProviderRef json.RawMessage `json:"provider_ref,omitempty"` +} + +// Target is the identity to act as plus the repository to act on. Core resolves +// the identity; a provider never chooses one. +type Target struct { + Identity IdentityRef `json:"identity"` + Repo RepoRef `json:"repo"` +} + +// SubjectKind is what a comment or status attaches to. +type SubjectKind string + +const ( + SubjectIssue SubjectKind = "issue" + SubjectChangeRequest SubjectKind = "change_request" +) + +// SubjectRef names an issue or change request. +type SubjectRef struct { + Kind SubjectKind `json:"kind"` + Number string `json:"number"` +} + +// ── Describe ───────────────────────────────────────────────────────────────── + +// Terms is the vocabulary the UI uses for this platform. +type Terms struct { + ChangeRequest string `json:"change_request"` // "Pull request" / "Merge request" + ChangeRequestPlural string `json:"change_request_plural"` // "Pull requests" + Repository string `json:"repository"` // "Repository" / "Project" +} + +// Info describes a provider. It is static. +type Info struct { + SPIVersion string `json:"spi_version"` + ID string `json:"id"` // the plugin id, e.g. "com.paca.github" + DisplayName string `json:"display_name"` + Icon string `json:"icon,omitempty"` + Hosts []string `json:"hosts"` // default hosts, e.g. ["github.com"] + SelfHostedHosts bool `json:"self_hosted_hosts"` + Terms Terms `json:"terms"` + Capabilities Capabilities `json:"capabilities"` +} + +// Capabilities declares what the provider can do. Core's UI, tools and router +// read these instead of hard-coding platform names; the conformance suite +// verifies they match the implemented interfaces. +type Capabilities struct { + IdentityKinds []IdentityKind `json:"identity_kinds"` + CredentialModel CredentialModel `json:"credential_model"` + WebhookScope WebhookScope `json:"webhook_scope"` + StatusKind StatusKind `json:"status_kind"` + Triggers []TriggerKind `json:"triggers"` + // MultipleIdentities is true when more than one identity per host can be + // created (needed for per-agent identities). + MultipleIdentities bool `json:"multiple_identities"` + + // Optional operations (each backed by an optional interface). + ResolveThreads bool `json:"resolve_threads"` + FileRead bool `json:"file_read"` + CodeSearch bool `json:"code_search"` + Labels bool `json:"labels"` + ReviewerRequests bool `json:"reviewer_requests"` + Assignees bool `json:"assignees"` + Merge bool `json:"merge"` + RerunChecks bool `json:"rerun_checks"` + Reactions bool `json:"reactions"` + WebhookManagement bool `json:"webhook_management"` + IdentityDeletion bool `json:"identity_deletion"` + // AccessGrants is true when an identity must be granted access to each + // account or organization before it can see its repositories, and a user + // does that on the platform (GitHub: installing the App on an account). + // Core then offers the grant flow when a repository's owner is not covered. + AccessGrants bool `json:"access_grants"` + + // Behaviours of required operations. + BatchReview bool `json:"batch_review"` // false: SubmitReview emulates + IdempotentWrites bool `json:"idempotent_writes"` + HiddenMarkers bool `json:"hidden_markers"` // HTML-comment markers survive + ForkChangeRequests bool `json:"fork_change_requests"` + DraftChangeRequests bool `json:"draft_change_requests"` + NestedPaths bool `json:"nested_paths"` + + // CIConfigGlobs are repository paths agents must not modify (CI + // configuration), e.g. ".github/workflows/**", ".gitlab-ci.yml". + CIConfigGlobs []string `json:"ci_config_globs,omitempty"` +} + +// SupportsTrigger reports whether the provider declared trigger k. +func (c Capabilities) SupportsTrigger(k TriggerKind) bool { + for _, t := range c.Triggers { + if t == k { + return true + } + } + return false +} + +// ── Identity setup ─────────────────────────────────────────────────────────── + +// SetupScope says whom an identity is for. +type SetupScope string + +const ( + SetupShared SetupScope = "shared" // the host-wide shared identity + SetupAgent SetupScope = "agent" // one agent's own identity +) + +// BeginSetupRequest starts creating an identity. +type BeginSetupRequest struct { + Scope SetupScope `json:"scope"` + Host string `json:"host"` + AgentID string `json:"agent_id,omitempty"` + AgentName string `json:"agent_name,omitempty"` // default bot name + ReturnURL string `json:"return_url"` // where the browser returns when done + CallbackURL string `json:"callback_url"` // the Paca page the platform redirects to + WebhookURL string `json:"webhook_url"` // core's endpoint for this identity + Params map[string]string `json:"params,omitempty"` +} + +// SetupStepKind is the next thing core must do to continue setup. +type SetupStepKind string + +const ( + StepRedirect SetupStepKind = "redirect" // send the browser to URL + StepFormPost SetupStepKind = "form_post" // POST Fields to URL from the browser (manifest flow) + StepTokenForm SetupStepKind = "token_form" // ask the user to paste a token (Prompt says which scopes) + StepDone SetupStepKind = "done" +) + +// FormField is one input of a [StepTokenForm] step. Platforms with static +// credentials often need more than one secret (a read-only and a write token), +// which is why the form is a list rather than a single token box. +type FormField struct { + Name string `json:"name"` // the key in CompleteSetupRequest.Params + Label string `json:"label"` + Help string `json:"help,omitempty"` // e.g. the scopes the token needs + Secret bool `json:"secret,omitempty"` + Required bool `json:"required,omitempty"` +} + +// SetupStep is the provider's instruction for the next setup step. +type SetupStep struct { + Kind SetupStepKind `json:"kind"` + URL string `json:"url,omitempty"` + Fields map[string]string `json:"fields,omitempty"` // form_post: the fields to POST + // Form lists the inputs to show for a token_form step. + Form []FormField `json:"form,omitempty"` + Prompt string `json:"prompt,omitempty"` + State string `json:"state,omitempty"` // opaque, echoed to CompleteIdentitySetup + Identity *Identity `json:"identity,omitempty"` +} + +// CompleteSetupRequest finishes setup from a callback or a submitted token. +type CompleteSetupRequest struct { + Scope SetupScope `json:"scope"` + Host string `json:"host"` + State string `json:"state"` + Params map[string]string `json:"params"` // callback query parameters, or {"token": "..."} + // Next is set when the flow has further legs (GitHub: manifest → install). +} + +// Identity is a created identity as core stores it. Secrets stay with the +// provider. +type Identity struct { + Ref IdentityRef `json:"ref"` + Host string `json:"host"` + Kind IdentityKind `json:"kind"` + DisplayName string `json:"display_name"` + // ExpiresAt is when the identity's credentials stop working, for platforms + // whose tokens expire (zero = does not expire). Core warns before it passes. + ExpiresAt time.Time `json:"expires_at,omitempty"` + // Next, when set, is a further step to run before the identity is usable + // (GitHub: install the app). + Next *SetupStep `json:"next,omitempty"` +} + +// BeginAccessRequest starts granting an identity access to one account or +// organization (capability AccessGrants). The step it returns is the same +// shape as identity setup: a redirect or form post to the platform, which +// returns to CallbackURL. +type BeginAccessRequest struct { + Identity IdentityRef `json:"identity"` + // Owner is the account or organization (its login or path) the user wants + // to use, when known, so the platform can pre-select it. + Owner string `json:"owner,omitempty"` + ReturnURL string `json:"return_url"` + CallbackURL string `json:"callback_url"` +} + +// CompleteAccessRequest finishes a grant from the platform's redirect. +type CompleteAccessRequest struct { + Identity IdentityRef `json:"identity"` + State string `json:"state"` + Params map[string]string `json:"params"` // callback query parameters +} + +// AccessGrant is what became accessible to the identity. +type AccessGrant struct { + // Owner is the account or organization now covered ("" if the platform did + // not say). + Owner string `json:"owner"` +} + +// DeleteIdentityRequest asks the provider to forget an identity. +type DeleteIdentityRequest struct { + Identity IdentityRef `json:"identity"` +} + +// ── Repositories ───────────────────────────────────────────────────────────── + +// Repository is a repository an identity can see. +type Repository struct { + Ref RepoRef `json:"ref"` + Private bool `json:"private"` + Description string `json:"description,omitempty"` +} + +// ListReposRequest pages the repositories an identity can see. +type ListReposRequest struct { + Identity IdentityRef `json:"identity"` + Query string `json:"query,omitempty"` + Cursor string `json:"cursor,omitempty"` + PageSize int `json:"page_size,omitempty"` +} + +// RepositoryPage is one page of repositories. +type RepositoryPage struct { + Items []Repository `json:"items"` + NextCursor string `json:"next_cursor,omitempty"` +} + +// ResolveRepoRequest normalizes a user-supplied URL or path and finds which of +// the candidate identities can see it. +type ResolveRepoRequest struct { + Host string `json:"host,omitempty"` + PathOrURL string `json:"path_or_url"` + Candidates []IdentityRef `json:"candidates"` +} + +// ResolvedRepository is the answer to ResolveRepository. +type ResolvedRepository struct { + Repository Repository `json:"repository"` + Identity IdentityRef `json:"identity"` +} + +// ── Webhooks ───────────────────────────────────────────────────────────────── + +// WebhookRequest is a delivery as received by core's endpoint. +type WebhookRequest struct { + Identity IdentityRef `json:"identity"` // the identity the endpoint belongs to + Method string `json:"method"` + Headers map[string]string `json:"headers"` // keys lower-cased + Query map[string]string `json:"query,omitempty"` + Body []byte `json:"body"` +} + +// EventKind classifies a normalized event. +type EventKind string + +const ( + EventComment EventKind = "comment" // issue/CR comment, review comment, review body + EventChangeRequest EventKind = "change_request" // opened, edited, ready, synchronized, closed, merged + EventReview EventKind = "review" // a review was submitted + EventCheck EventKind = "check" // a CI result completed + EventPush EventKind = "push" + EventLabel EventKind = "label" + EventAssignment EventKind = "assignment" + EventReviewRequest EventKind = "review_request" +) + +// AllEventKinds lists every defined event kind. +var AllEventKinds = []EventKind{ + EventComment, EventChangeRequest, EventReview, EventCheck, EventPush, + EventLabel, EventAssignment, EventReviewRequest, +} + +// ActorKind distinguishes people from bots. +type ActorKind string + +const ( + ActorUser ActorKind = "user" + ActorBot ActorKind = "bot" +) + +// Actor is who caused an event. +type Actor struct { + Login string `json:"login"` + Kind ActorKind `json:"kind"` + // Permission is the actor's access to the repository when the provider + // already knows it. Empty means "ask GetActorPermission". + Permission Permission `json:"permission,omitempty"` +} + +// Branch is one side of a change request. +type Branch struct { + Ref string `json:"ref"` + SHA string `json:"sha,omitempty"` + Repo string `json:"repo,omitempty"` // full path of the head repository + FromFork bool `json:"from_fork,omitempty"` +} + +// EventSubject is what an event is about. +type EventSubject struct { + Kind SubjectKind `json:"kind"` + Number string `json:"number"` + Head *Branch `json:"head,omitempty"` + Base string `json:"base,omitempty"` + Draft bool `json:"draft,omitempty"` + Author string `json:"author,omitempty"` +} + +// Location pins a review comment to code. +type Location struct { + Path string `json:"path,omitempty"` + Line int `json:"line,omitempty"` + ThreadID string `json:"thread_id,omitempty"` + CommentID string `json:"comment_id,omitempty"` +} + +// CheckInfo describes a completed CI result. +type CheckInfo struct { + Name string `json:"name"` + Conclusion string `json:"conclusion"` // success | failure | cancelled | skipped + RunID string `json:"run_id,omitempty"` + SHA string `json:"sha,omitempty"` +} + +// AddressVia is how an identity was addressed. +type AddressVia string + +const ( + ViaMention AddressVia = "mention" + ViaReviewRequest AddressVia = "review_request" + ViaAssignment AddressVia = "assignment" + ViaLabel AddressVia = "label" +) + +// PushInfo describes a push or branch event. +type PushInfo struct { + Ref string `json:"ref"` // branch name without refs/heads/ + SHA string `json:"sha,omitempty"` + Created bool `json:"created,omitempty"` + Deleted bool `json:"deleted,omitempty"` +} + +// Addressed records that a specific identity was addressed. The provider +// decides this (mention syntax differs per platform); core only sees which +// identity and how. +type Addressed struct { + IdentityID string `json:"identity_id"` + Via AddressVia `json:"via"` +} + +// Event is a normalized webhook event. Text is untrusted. +type Event struct { + ID string `json:"id"` // unique per delivery (and per fan-out item) + Kind EventKind `json:"kind"` + Action string `json:"action"` + Repo RepoRef `json:"repo"` + Subject *EventSubject `json:"subject,omitempty"` + Actor Actor `json:"actor"` + Text string `json:"text,omitempty"` + Location *Location `json:"location,omitempty"` + Check *CheckInfo `json:"check,omitempty"` + Push *PushInfo `json:"push,omitempty"` // for EventPush + Label string `json:"label,omitempty"` + Addressed []Addressed `json:"addressed,omitempty"` + // RawRef is a provider-private id for debugging; never shown to agents. + RawRef string `json:"raw_ref,omitempty"` +} + +// ── Credentials, actor, protection ─────────────────────────────────────────── + +// MintRequest asks for a credential to use git or the API against a repository. +type MintRequest struct { + Target Target `json:"target"` + Access Access `json:"access"` + TTLSeconds int `json:"ttl_seconds,omitempty"` +} + +// Credential authenticates one git/API use. Core never persists it and never +// returns it to an agent; for [CredentialStatic] platforms it is the stored +// secret and core hands it only to the credential helper for one invocation. +type Credential struct { + Username string `json:"username"` + Secret string `json:"secret"` + ExpiresAt time.Time `json:"expires_at,omitempty"` +} + +// ActorPermissionRequest asks for a login's access to a repository. +type ActorPermissionRequest struct { + Target Target `json:"target"` + Login string `json:"login"` +} + +// BranchProtectionRequest asks about a branch's protection rules. +type BranchProtectionRequest struct { + Target Target `json:"target"` + Branch string `json:"branch"` +} + +// BranchProtection summarizes protection relevant to agents. +type BranchProtection struct { + Protected bool `json:"protected"` + RequiresReview bool `json:"requires_review,omitempty"` + RequiresStatusChecks bool `json:"requires_status_checks,omitempty"` + AllowsForcePush bool `json:"allows_force_push,omitempty"` +} + +// ── Reads ──────────────────────────────────────────────────────────────────── + +// CRState is a change request's lifecycle state. +type CRState string + +const ( + CROpen CRState = "open" + CRClosed CRState = "closed" + CRMerged CRState = "merged" +) + +// ChangeRequest is a pull/merge request. +type ChangeRequest struct { + Number string `json:"number"` + Title string `json:"title"` + Body string `json:"body,omitempty"` + State CRState `json:"state"` + Draft bool `json:"draft,omitempty"` + Head Branch `json:"head"` + Base Branch `json:"base"` + Author string `json:"author"` + URL string `json:"url"` + Labels []string `json:"labels,omitempty"` + Reviewers []string `json:"reviewers,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` +} + +// CRRequest addresses one change request. +type CRRequest struct { + Target Target `json:"target"` + Number string `json:"number"` +} + +// PagedCRRequest addresses one change request with paging. +type PagedCRRequest struct { + Target Target `json:"target"` + Number string `json:"number"` + Cursor string `json:"cursor,omitempty"` +} + +// ChangedFile is one file of a change request. +type ChangedFile struct { + Path string `json:"path"` + OldPath string `json:"old_path,omitempty"` + Status string `json:"status"` // added | modified | removed | renamed + Additions int `json:"additions"` + Deletions int `json:"deletions"` +} + +// ChangedFilePage is one page of changed files. +type ChangedFilePage struct { + Items []ChangedFile `json:"items"` + NextCursor string `json:"next_cursor,omitempty"` +} + +// DiffRequest asks for a change request's diff, whole or for one file. +type DiffRequest struct { + Target Target `json:"target"` + Number string `json:"number"` + Path string `json:"path,omitempty"` + MaxBytes int `json:"max_bytes,omitempty"` +} + +// Diff is unified diff text. Truncated is true when MaxBytes cut it short. +type Diff struct { + Text string `json:"text"` + Truncated bool `json:"truncated,omitempty"` +} + +// Comment is one comment. +type Comment struct { + ID string `json:"id"` + Author string `json:"author"` + AuthorKind ActorKind `json:"author_kind"` + Body string `json:"body"` + URL string `json:"url,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` +} + +// Thread is a review discussion. +type Thread struct { + ID string `json:"id"` + Path string `json:"path,omitempty"` + Line int `json:"line,omitempty"` + Resolved bool `json:"resolved"` + Outdated bool `json:"outdated,omitempty"` + Comments []Comment `json:"comments"` +} + +// ThreadPage is one page of threads. +type ThreadPage struct { + Items []Thread `json:"items"` + NextCursor string `json:"next_cursor,omitempty"` +} + +// ListCommentsRequest pages the comments of an issue or change request. +type ListCommentsRequest struct { + Target Target `json:"target"` + Subject SubjectRef `json:"subject"` + Cursor string `json:"cursor,omitempty"` +} + +// CommentPage is one page of comments. +type CommentPage struct { + Items []Comment `json:"items"` + NextCursor string `json:"next_cursor,omitempty"` +} + +// IssueRequest addresses one issue. +type IssueRequest struct { + Target Target `json:"target"` + Number string `json:"number"` +} + +// Issue is an issue. +type Issue struct { + Number string `json:"number"` + Title string `json:"title"` + Body string `json:"body,omitempty"` + State string `json:"state"` // open | closed + Author string `json:"author"` + URL string `json:"url"` + Labels []string `json:"labels,omitempty"` + Assignees []string `json:"assignees,omitempty"` +} + +// CheckState is the normalized state of a CI result. +type CheckState string + +const ( + CheckPending CheckState = "pending" + CheckSuccess CheckState = "success" + CheckFailure CheckState = "failure" + CheckCancelled CheckState = "cancelled" + CheckSkipped CheckState = "skipped" +) + +// Valid reports whether s is a defined check state. +func (s CheckState) Valid() bool { + switch s { + case CheckPending, CheckSuccess, CheckFailure, CheckCancelled, CheckSkipped: + return true + } + return false +} + +// Check is a CI result mapped from check runs, pipelines or statuses. +type Check struct { + ID string `json:"id"` + Name string `json:"name"` + State CheckState `json:"state"` + Required bool `json:"required"` + URL string `json:"url,omitempty"` + Summary string `json:"summary,omitempty"` +} + +// ChecksRequest asks for the CI results of a ref (usually a head SHA). +type ChecksRequest struct { + Target Target `json:"target"` + Ref string `json:"ref"` +} + +// CheckLogsRequest asks for a check's log, tail-limited. +type CheckLogsRequest struct { + Target Target `json:"target"` + CheckID string `json:"check_id"` + TailBytes int `json:"tail_bytes,omitempty"` +} + +// Logs is log text. Truncated is true when the tail limit cut it. +type Logs struct { + Text string `json:"text"` + Truncated bool `json:"truncated,omitempty"` +} + +// FileRequest reads a file at a ref without a checkout. +type FileRequest struct { + Target Target `json:"target"` + Ref string `json:"ref"` + Path string `json:"path"` + MaxBytes int `json:"max_bytes,omitempty"` +} + +// File is a file's content. +type File struct { + Path string `json:"path"` + Content []byte `json:"content"` + SHA string `json:"sha,omitempty"` + Truncated bool `json:"truncated,omitempty"` +} + +// CodeSearchRequest searches a repository's code. +type CodeSearchRequest struct { + Target Target `json:"target"` + Query string `json:"query"` +} + +// CodeHit is one search result. +type CodeHit struct { + Path string `json:"path"` + Line int `json:"line,omitempty"` + Snippet string `json:"snippet,omitempty"` +} + +// CodeHits is the result of a search. +type CodeHits struct { + Items []CodeHit `json:"items"` +} + +// ── Writes ─────────────────────────────────────────────────────────────────── + +// OpenCRRequest opens a change request from a pushed branch. +type OpenCRRequest struct { + Target Target `json:"target"` + Title string `json:"title"` + Body string `json:"body,omitempty"` + Head string `json:"head"` + Base string `json:"base"` + Draft bool `json:"draft,omitempty"` + Labels []string `json:"labels,omitempty"` + Reviewers []string `json:"reviewers,omitempty"` + IdempotencyKey string `json:"idempotency_key,omitempty"` +} + +// UpdateCRRequest edits a change request; nil fields are left alone. +type UpdateCRRequest struct { + Target Target `json:"target"` + Number string `json:"number"` + Title *string `json:"title,omitempty"` + Body *string `json:"body,omitempty"` + Draft *bool `json:"draft,omitempty"` + State *CRState `json:"state,omitempty"` // open | closed +} + +// CommentRequest posts a top-level comment. Core appends the footer. +type CommentRequest struct { + Target Target `json:"target"` + Subject SubjectRef `json:"subject"` + Body string `json:"body"` + IdempotencyKey string `json:"idempotency_key,omitempty"` +} + +// CommentResult identifies a posted comment so core can index it. +type CommentResult struct { + ID string `json:"id"` + URL string `json:"url,omitempty"` +} + +// ReplyThreadRequest replies inside a review thread. +type ReplyThreadRequest struct { + Target Target `json:"target"` + Number string `json:"number"` + ThreadID string `json:"thread_id"` + Body string `json:"body"` + IdempotencyKey string `json:"idempotency_key,omitempty"` +} + +// Verdict is a review's overall outcome. +type Verdict string + +const ( + VerdictComment Verdict = "comment" + VerdictApprove Verdict = "approve" + VerdictRequestChanges Verdict = "request_changes" +) + +// InlineComment is a review comment pinned to a line of the diff. +type InlineComment struct { + Path string `json:"path"` + Line int `json:"line"` + Side string `json:"side,omitempty"` // new | old + Body string `json:"body"` +} + +// SubmitReviewRequest submits a review. Providers without batch review emulate +// it (post the comments, then a summary) and say so in the result. +type SubmitReviewRequest struct { + Target Target `json:"target"` + Number string `json:"number"` + Verdict Verdict `json:"verdict"` + Summary string `json:"summary,omitempty"` + Comments []InlineComment `json:"comments,omitempty"` + HeadSHA string `json:"head_sha,omitempty"` + IdempotencyKey string `json:"idempotency_key,omitempty"` +} + +// ReviewResult reports what was posted. +type ReviewResult struct { + ID string `json:"id,omitempty"` + URL string `json:"url,omitempty"` + Posted int `json:"posted"` // inline comments accepted + Emulated bool `json:"emulated"` // true when batch review was emulated +} + +// RunState is the state of an agent run shown on the platform. +type RunState string + +const ( + RunQueued RunState = "queued" + RunInProgress RunState = "in_progress" + RunSuccess RunState = "success" + RunFailure RunState = "failure" + RunCancelled RunState = "cancelled" +) + +// SetStatusRequest shows run state on the platform (check run, commit status or +// comment, per [Capabilities.StatusKind]). StatusRef is returned by the first +// call and passed back to update in place. +type SetStatusRequest struct { + Target Target `json:"target"` + Subject SubjectRef `json:"subject"` + SHA string `json:"sha,omitempty"` + State RunState `json:"state"` + Title string `json:"title"` + Summary string `json:"summary,omitempty"` + URL string `json:"url,omitempty"` + StatusRef string `json:"status_ref,omitempty"` +} + +// StatusResult carries the reference to update the status later. +type StatusResult struct { + StatusRef string `json:"status_ref"` +} + +// ── Optional operations ────────────────────────────────────────────────────── + +// ResolveThreadRequest resolves or reopens a thread. +type ResolveThreadRequest struct { + Target Target `json:"target"` + Number string `json:"number"` + ThreadID string `json:"thread_id"` + Resolved bool `json:"resolved"` +} + +// LabelsRequest adds labels to a subject. +type LabelsRequest struct { + Target Target `json:"target"` + Subject SubjectRef `json:"subject"` + Labels []string `json:"labels"` +} + +// PeopleRequest requests reviewers or sets assignees on a subject. +type PeopleRequest struct { + Target Target `json:"target"` + Subject SubjectRef `json:"subject"` + Logins []string `json:"logins"` +} + +// MergeRequest merges a change request. ExpectedHeadSHA guards against merging +// something other than what was reviewed. +type MergeRequest struct { + Target Target `json:"target"` + Number string `json:"number"` + Method string `json:"method,omitempty"` // merge | squash | rebase + ExpectedHeadSHA string `json:"expected_head_sha"` +} + +// MergeResult reports the merge commit. +type MergeResult struct { + SHA string `json:"sha,omitempty"` +} + +// RerunRequest re-runs failed checks of a ref (or one check). +type RerunRequest struct { + Target Target `json:"target"` + Ref string `json:"ref"` + CheckID string `json:"check_id,omitempty"` +} + +// ReactRequest adds an emoji reaction to a comment. +type ReactRequest struct { + Target Target `json:"target"` + CommentID string `json:"comment_id"` + Emoji string `json:"emoji"` // eyes | rocket | confused | +1 +} + +// EnsureWebhookRequest registers a webhook on a repository (repo/group scoped +// platforms only). The signing secret is the provider's own business: it +// generates one per identity at setup, keeps it, and verifies deliveries with +// it — core never sees or stores webhook secrets. +type EnsureWebhookRequest struct { + Target Target `json:"target"` + URL string `json:"url"` +} + +// WebhookRef identifies a registered webhook for later removal. +type WebhookRef struct { + ID string `json:"id"` +} + +// RemoveWebhookRequest removes a webhook registered by EnsureWebhook. +type RemoveWebhookRequest struct { + Target Target `json:"target"` + Webhook WebhookRef `json:"webhook"` +} + +// Empty is the result of operations that return nothing. +type Empty struct{} diff --git a/gitprovider/wire.go b/gitprovider/wire.go new file mode 100644 index 0000000..35bd7b6 --- /dev/null +++ b/gitprovider/wire.go @@ -0,0 +1,142 @@ +package gitprovider + +import ( + "context" + "encoding/json" + "net/http" +) + +// Operation is one entry of the wire contract. +type Operation struct { + Name string + // Capability is empty for required operations, else the Capabilities flag + // (JSON name) that must be true for the operation to be available. + Capability string +} + +type opFunc func(ctx context.Context, p Provider, body []byte) (any, error) + +type opEntry struct { + Operation + call opFunc +} + +func decode[Req any](body []byte) (Req, error) { + var r Req + if len(body) == 0 { + return r, nil + } + if err := json.Unmarshal(body, &r); err != nil { + return r, Errorf(CodeValidation, "malformed request body") + } + return r, nil +} + +// required wraps a method of Provider. +func required[Req, Res any](name string, fn func(Provider, context.Context, Req) (Res, error)) opEntry { + return opEntry{Operation{Name: name}, func(ctx context.Context, p Provider, body []byte) (any, error) { + req, err := decode[Req](body) + if err != nil { + return nil, err + } + return fn(p, ctx, req) + }} +} + +// optional wraps a method of an optional interface; a provider that does not +// implement I answers [ErrUnsupported]. +func optional[I any, Req, Res any](name, capability string, fn func(I, context.Context, Req) (Res, error)) opEntry { + return opEntry{Operation{Name: name, Capability: capability}, func(ctx context.Context, p Provider, body []byte) (any, error) { + impl, ok := p.(I) + if !ok { + return nil, ErrUnsupported + } + req, err := decode[Req](body) + if err != nil { + return nil, err + } + return fn(impl, ctx, req) + }} +} + +var operations = []opEntry{ + {Operation{Name: "describe"}, func(_ context.Context, p Provider, _ []byte) (any, error) { return p.Describe(), nil }}, + required("begin_identity_setup", Provider.BeginIdentitySetup), + required("complete_identity_setup", Provider.CompleteIdentitySetup), + required("list_repositories", Provider.ListRepositories), + required("resolve_repository", Provider.ResolveRepository), + required("verify_and_parse_webhook", Provider.VerifyAndParseWebhook), + required("mint_credential", Provider.MintCredential), + required("get_actor_permission", Provider.GetActorPermission), + required("get_branch_protection", Provider.GetBranchProtection), + required("get_change_request", Provider.GetChangeRequest), + required("list_changed_files", Provider.ListChangedFiles), + required("get_diff", Provider.GetDiff), + required("list_threads", Provider.ListThreads), + required("list_comments", Provider.ListComments), + required("get_issue", Provider.GetIssue), + required("get_checks", Provider.GetChecks), + required("get_check_logs", Provider.GetCheckLogs), + required("open_change_request", Provider.OpenChangeRequest), + required("update_change_request", Provider.UpdateChangeRequest), + required("comment", Provider.Comment), + required("reply_thread", Provider.ReplyThread), + required("submit_review", Provider.SubmitReview), + required("set_status", Provider.SetStatus), + optional("resolve_thread", "resolve_threads", ThreadResolver.ResolveThread), + optional("get_file", "file_read", FileReader.GetFile), + optional("search_code", "code_search", CodeSearcher.SearchCode), + optional("add_labels", "labels", LabelManager.AddLabels), + optional("request_reviewers", "reviewer_requests", ReviewerRequester.RequestReviewers), + optional("set_assignees", "assignees", AssigneeSetter.SetAssignees), + optional("merge_change_request", "merge", Merger.MergeChangeRequest), + optional("rerun_checks", "rerun_checks", CheckRerunner.RerunChecks), + optional("react", "reactions", Reactor.React), + optional("ensure_webhook", "webhook_management", WebhookManager.EnsureWebhook), + optional("remove_webhook", "webhook_management", WebhookManager.RemoveWebhook), + optional("delete_identity", "identity_deletion", IdentityDeleter.DeleteIdentity), + optional("begin_access_grant", "access_grants", AccessGranter.BeginAccessGrant), + optional("complete_access_grant", "access_grants", AccessGranter.CompleteAccessGrant), +} + +var opIndex = func() map[string]opEntry { + m := make(map[string]opEntry, len(operations)) + for _, o := range operations { + m[o.Name] = o + } + return m +}() + +// Operations lists the wire contract, in a stable order. +func Operations() []Operation { + out := make([]Operation, len(operations)) + for i, o := range operations { + out[i] = o.Operation + } + return out +} + +// Dispatch runs one operation by wire name against p and returns the HTTP +// status and the JSON [Envelope]. It never panics on bad input: unknown +// operations and malformed bodies become typed errors. +func Dispatch(ctx context.Context, p Provider, op string, body []byte) (int, []byte) { + entry, ok := opIndex[op] + if !ok { + return fail(Errorf(CodeUnsupported, "unknown operation %q", op)) + } + result, err := entry.call(ctx, p, body) + if err != nil { + return fail(AsError(err)) + } + raw, mErr := json.Marshal(result) + if mErr != nil { + return fail(Errorf(CodeInternal, "could not encode result")) + } + out, _ := json.Marshal(Envelope{OK: true, Result: raw}) + return http.StatusOK, out +} + +func fail(e *Error) (int, []byte) { + out, _ := json.Marshal(Envelope{OK: false, Error: e}) + return e.Code.HTTPStatus(), out +} diff --git a/gitprovider/wireshape_test.go b/gitprovider/wireshape_test.go new file mode 100644 index 0000000..50351b6 --- /dev/null +++ b/gitprovider/wireshape_test.go @@ -0,0 +1,93 @@ +package gitprovider_test + +import ( + "encoding/json" + "flag" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" +) + +var update = flag.Bool("update", false, "rewrite testdata/wire-shapes.golden.json") + +// shapeOf describes a type's JSON shape: field name -> JSON type, recursively. +// It is deliberately independent of Go field names, ordering and type names, so +// two modules with their own copies of the types can agree on it. +func shapeOf(t reflect.Type) any { return shapeOfSeen(t, map[reflect.Type]bool{}) } + +func shapeOfSeen(t reflect.Type, seen map[reflect.Type]bool) any { + switch t.Kind() { + case reflect.Pointer: + return shapeOfSeen(t.Elem(), seen) + case reflect.Struct: + if t.PkgPath() == "time" { + return "time" + } + if seen[t] { + return "recursive:" + t.Name() // Identity <-> SetupStep refer to each other + } + seen[t] = true + defer delete(seen, t) + out := map[string]any{} + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + name := strings.Split(f.Tag.Get("json"), ",")[0] + if name == "-" || !f.IsExported() { + continue + } + if name == "" { + name = f.Name + } + out[name] = shapeOfSeen(f.Type, seen) + } + return out + case reflect.Slice: + if t.Elem().Kind() == reflect.Uint8 { + return "bytes" + } + return []any{shapeOfSeen(t.Elem(), seen)} + case reflect.Map: + return map[string]any{"*": shapeOfSeen(t.Elem(), seen)} + case reflect.String: + return "string" + case reflect.Bool: + return "bool" + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint32, reflect.Uint64, reflect.Float32, reflect.Float64: + return "number" + case reflect.Interface: + return "any" + } + return t.Kind().String() +} + +func TestWireShapesMatchGolden(t *testing.T) { + got := map[string]any{"spi_version": gp.SPIVersion} + shapes := map[string]any{} + for name, typ := range gp.WireTypes() { + shapes[name] = shapeOf(typ) + } + got["types"] = shapes + raw, _ := json.MarshalIndent(got, "", " ") + raw = append(raw, '\n') + path := filepath.Join("testdata", "wire-shapes.golden.json") + if *update { + if err := os.MkdirAll("testdata", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, raw, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("missing golden file (run `go test ./gitprovider -run WireShapes -update`): %v", err) + } + if string(want) != string(raw) { + t.Fatalf("the wire contract changed. If that is intended, bump SPIVersion when it is breaking, regenerate with -update, and copy testdata/wire-shapes.golden.json into services/api/internal/platform/gitprovider/testdata/ so core's copy of the types is updated to match.") + } +} diff --git a/gitprovider/wiretypes.go b/gitprovider/wiretypes.go new file mode 100644 index 0000000..a7317eb --- /dev/null +++ b/gitprovider/wiretypes.go @@ -0,0 +1,41 @@ +package gitprovider + +import "reflect" + +// WireTypes lists every request, response and event type that crosses the +// provider wire, by name. It exists for contract tests: Paca core keeps its own +// copy of these types (it does not import this module), and both sides assert +// their JSON shapes against the same golden file (testdata/wire-shapes.golden.json) +// so a drift in either is caught by a failing test, not by a production error. +func WireTypes() map[string]reflect.Type { + t := func(v any) reflect.Type { return reflect.TypeOf(v) } + return map[string]reflect.Type{ + "Info": t(Info{}), "Capabilities": t(Capabilities{}), "Terms": t(Terms{}), + "BeginSetupRequest": t(BeginSetupRequest{}), "SetupStep": t(SetupStep{}), "FormField": t(FormField{}), + "CompleteSetupRequest": t(CompleteSetupRequest{}), "Identity": t(Identity{}), "IdentityRef": t(IdentityRef{}), + "DeleteIdentityRequest": t(DeleteIdentityRequest{}), + "BeginAccessRequest": t(BeginAccessRequest{}), "CompleteAccessRequest": t(CompleteAccessRequest{}), "AccessGrant": t(AccessGrant{}), + "RepoRef": t(RepoRef{}), "Target": t(Target{}), "SubjectRef": t(SubjectRef{}), + "Repository": t(Repository{}), "ListReposRequest": t(ListReposRequest{}), "RepositoryPage": t(RepositoryPage{}), + "ResolveRepoRequest": t(ResolveRepoRequest{}), "ResolvedRepository": t(ResolvedRepository{}), + "WebhookRequest": t(WebhookRequest{}), "Event": t(Event{}), "EventSubject": t(EventSubject{}), + "Actor": t(Actor{}), "PushInfo": t(PushInfo{}), "Branch": t(Branch{}), "Location": t(Location{}), "CheckInfo": t(CheckInfo{}), "Addressed": t(Addressed{}), + "MintRequest": t(MintRequest{}), "Credential": t(Credential{}), + "ActorPermissionRequest": t(ActorPermissionRequest{}), "BranchProtectionRequest": t(BranchProtectionRequest{}), "BranchProtection": t(BranchProtection{}), + "ChangeRequest": t(ChangeRequest{}), "CRRequest": t(CRRequest{}), "PagedCRRequest": t(PagedCRRequest{}), + "ChangedFile": t(ChangedFile{}), "ChangedFilePage": t(ChangedFilePage{}), "DiffRequest": t(DiffRequest{}), "Diff": t(Diff{}), + "Comment": t(Comment{}), "Thread": t(Thread{}), "ThreadPage": t(ThreadPage{}), + "ListCommentsRequest": t(ListCommentsRequest{}), "CommentPage": t(CommentPage{}), + "IssueRequest": t(IssueRequest{}), "Issue": t(Issue{}), + "Check": t(Check{}), "ChecksRequest": t(ChecksRequest{}), "CheckLogsRequest": t(CheckLogsRequest{}), "Logs": t(Logs{}), + "FileRequest": t(FileRequest{}), "File": t(File{}), "CodeSearchRequest": t(CodeSearchRequest{}), "CodeHit": t(CodeHit{}), "CodeHits": t(CodeHits{}), + "OpenCRRequest": t(OpenCRRequest{}), "UpdateCRRequest": t(UpdateCRRequest{}), + "CommentRequest": t(CommentRequest{}), "CommentResult": t(CommentResult{}), "ReplyThreadRequest": t(ReplyThreadRequest{}), + "InlineComment": t(InlineComment{}), "SubmitReviewRequest": t(SubmitReviewRequest{}), "ReviewResult": t(ReviewResult{}), + "SetStatusRequest": t(SetStatusRequest{}), "StatusResult": t(StatusResult{}), + "ResolveThreadRequest": t(ResolveThreadRequest{}), "LabelsRequest": t(LabelsRequest{}), "PeopleRequest": t(PeopleRequest{}), + "MergeRequest": t(MergeRequest{}), "MergeResult": t(MergeResult{}), "RerunRequest": t(RerunRequest{}), "ReactRequest": t(ReactRequest{}), + "EnsureWebhookRequest": t(EnsureWebhookRequest{}), "WebhookRef": t(WebhookRef{}), "RemoveWebhookRequest": t(RemoveWebhookRequest{}), + "Error": t(Error{}), "Envelope": t(Envelope{}), + } +} diff --git a/scripts/check-mutants.sh b/scripts/check-mutants.sh new file mode 100755 index 0000000..3436a73 --- /dev/null +++ b/scripts/check-mutants.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# Verifies the Git provider conformance suite catches known-bad providers: +# every TestMutant_* must FAIL. Exit status is non-zero if any mutant survives. +cd "$(dirname "$0")/.." || exit 1 +out=$(go test -tags mutants ./gitprovider/fakeprovider/ -run 'TestMutant' -v 2>&1) +survived=0 +for name in $(echo "$out" | grep -o '^=== RUN TestMutant_[A-Za-z]*$' | awk '{print $3}'); do + if echo "$out" | grep -q -- "--- FAIL: $name "; then + echo "caught $name" + else + echo "SURVIVED $name"; survived=1 + fi +done +exit $survived From 120265a86398ff53dde2b36c680a3cab8ce221ff Mon Sep 17 00:00:00 2001 From: pikann22 Date: Sun, 25 Oct 2026 05:30:28 +0000 Subject: [PATCH 2/5] feat: improve identity setup check and refine test assertions for capabilities --- gitprovider/conformance/conformance.go | 2 +- gitprovider/fakeprovider/fake.go | 1 - gitprovider/gitprovider_test.go | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/gitprovider/conformance/conformance.go b/gitprovider/conformance/conformance.go index 939fbfe..bb60554 100644 --- a/gitprovider/conformance/conformance.go +++ b/gitprovider/conformance/conformance.go @@ -346,7 +346,7 @@ func testSetup(t *testing.T, h Harness) { } else { mustCode(t, f, err, gp.CodeValidation) } - if p.Describe().Capabilities.MultipleIdentities == false { + if !p.Describe().Capabilities.MultipleIdentities { _, err := p.BeginIdentitySetup(ctx(), gp.BeginSetupRequest{Scope: gp.SetupAgent, Host: f.SetupHost, AgentID: "a1", AgentName: "dev"}) mustCode(t, f, err, gp.CodeUnsupported) } diff --git a/gitprovider/fakeprovider/fake.go b/gitprovider/fakeprovider/fake.go index 7542f49..f3c3dd8 100644 --- a/gitprovider/fakeprovider/fake.go +++ b/gitprovider/fakeprovider/fake.go @@ -48,7 +48,6 @@ type cr struct { gp.ChangeRequest files []gp.ChangedFile threads []*gp.Thread - merged bool } type repoState struct { diff --git a/gitprovider/gitprovider_test.go b/gitprovider/gitprovider_test.go index 0e2ee40..9b63c06 100644 --- a/gitprovider/gitprovider_test.go +++ b/gitprovider/gitprovider_test.go @@ -67,7 +67,7 @@ func TestOperationsTable(t *testing.T) { t.Errorf("capability %q gates no operation", flag) } } - if impl := gp.Implemented(fakeprovider.New()); !(impl.Merge && impl.WebhookManagement && impl.IdentityDeletion) { + if impl := gp.Implemented(fakeprovider.New()); !impl.Merge || !impl.WebhookManagement || !impl.IdentityDeletion { t.Error("Implemented() must see the full fake's optional interfaces") } if impl := gp.Implemented(fakeprovider.NewMinimal()); impl.Merge || impl.FileRead { From cf65e7621964c006f3fd9d722048460c845e1af2 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Sun, 25 Oct 2026 05:44:53 +0000 Subject: [PATCH 3/5] feat: enhance Git provider with improved error handling, UTF-8 compliance, and new request validation --- gitprovider/conformance/conformance.go | 55 ++++++++++++++++++++------ gitprovider/fakeprovider/fake.go | 22 ++++++++++- gitprovider/gitprovider_test.go | 29 ++++++++++++++ gitprovider/serve/serve.go | 20 +++++++++- gitprovider/serve/serve_test.go | 21 ++++++++++ gitprovider/types.go | 12 ++++-- gitprovider/wire.go | 30 ++++++++++++-- 7 files changed, 167 insertions(+), 22 deletions(-) diff --git a/gitprovider/conformance/conformance.go b/gitprovider/conformance/conformance.go index bb60554..3f78bd5 100644 --- a/gitprovider/conformance/conformance.go +++ b/gitprovider/conformance/conformance.go @@ -21,10 +21,12 @@ import ( "context" "encoding/json" "errors" + "net/url" "sort" "strings" "testing" "time" + "unicode/utf8" gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" ) @@ -87,7 +89,8 @@ type Fixtures struct { BadSignature gp.WebhookRequest // Secrets are values that must never appear in events or error messages - // (webhook secrets, stored tokens). + // (webhook secrets, stored tokens). List every token the harness uses: the + // suite can only detect leaks of values it is told about. Secrets []string // Setup: a host and the parameters that complete setup successfully. @@ -97,10 +100,15 @@ type Fixtures struct { // Writes run only when true (point real providers at a sandbox). MutationsAllowed bool NewBranchHead string // an existing branch with no open change request - BaseBranch string - File struct{ Ref, Path string } - SearchQuery string // a query that matches at least one file - Reviewer string // a login that can be requested as reviewer + // DraftBranchHead and IdempotentBranchHead are further existing branches + // with no open change request, used for the draft and idempotency checks. + // Defaults ("draft-branch", "idem-branch") suit the fake backend only. + DraftBranchHead string + IdempotentBranchHead string + BaseBranch string + File struct{ Ref, Path string } + SearchQuery string // a query that matches at least one file + Reviewer string // a login that can be requested as reviewer // AccessGrantParams are the callback parameters that complete an access // grant started by BeginAccessGrant (providers declaring AccessGrants). @@ -161,9 +169,16 @@ func noLeak(t *testing.T, f Fixtures, text string) { } } +func orDefault(v, def string) string { + if v != "" { + return v + } + return def +} + func truncate(s string) string { if len(s) > 120 { - return s[:120] + "…" + return strings.ToValidUTF8(s[:120], "") + "…" } return s } @@ -264,6 +279,7 @@ func testDescribe(t *testing.T, h Harness) { check("reactions", c.Reactions, impl.Reactions) check("webhook_management", c.WebhookManagement, impl.WebhookManagement) check("identity_deletion", c.IdentityDeletion, impl.IdentityDeletion) + check("access_grants", c.AccessGrants, impl.AccessGrants) } // ── wire ───────────────────────────────────────────────────────────────────── @@ -289,6 +305,19 @@ func testWire(t *testing.T, h Harness) { t.Errorf("unknown operation: status %d body %s, want unsupported", status, body) } + // An empty body is a validation error, not a call with a zero-value request. + for _, op := range gp.Operations() { + if op.Name == "describe" { + continue + } + _, body := gp.Dispatch(ctx(), p, op.Name, nil) + var e gp.Envelope + if err := json.Unmarshal(body, &e); err != nil || e.OK || e.Error == nil || + (e.Error.Code != gp.CodeValidation && e.Error.Code != gp.CodeUnsupported) { + t.Errorf("%s with an empty body: %s, want validation or unsupported", op.Name, body) + } + } + // Every operation survives a malformed body without panicking and says so // with a validation (or unsupported) error. for _, op := range gp.Operations() { @@ -383,7 +412,7 @@ func testRepositories(t *testing.T, h Harness) { if r.Ref.Host == "" || r.Ref.FullPath == "" || r.Ref.CloneURL == "" { t.Errorf("repository %+v needs host, full_path and clone_url", r.Ref) } - if strings.Contains(r.Ref.CloneURL, "@") { + if u, err := url.Parse(r.Ref.CloneURL); err == nil && u.User != nil { t.Errorf("clone_url %q must be credential-free", r.Ref.CloneURL) } } @@ -624,6 +653,8 @@ func testCredentials(t *testing.T, h Harness) { t.Error("ephemeral credentials must carry ExpiresAt") } else if d := time.Until(c.ExpiresAt); d <= 0 || d > 65*time.Minute { t.Errorf("ephemeral credential lifetime %v is outside (0, 65m]", d) + } else if d > 10*time.Minute { + t.Errorf("ephemeral credential lifetime %v exceeds the requested TTL of 5m (10m allowed for clock skew)", d) } } } @@ -730,7 +761,7 @@ func testReads(t *testing.T, h Harness) { if err != nil { t.Fatalf("GetDiff with MaxBytes: %v", err) } - if len(cut.Text) > 10 || !cut.Truncated { + if len(cut.Text) > 10 || !cut.Truncated || !utf8.ValidString(cut.Text) { t.Errorf("MaxBytes=10 returned %d bytes truncated=%v; want <=10 and truncated (silent truncation hides content)", len(cut.Text), cut.Truncated) } } @@ -778,7 +809,7 @@ func testReads(t *testing.T, h Harness) { t.Errorf("GetCheckLogs: %v empty=%v", err, logs.Text == "") } tail, err := p.GetCheckLogs(ctx(), gp.CheckLogsRequest{Target: tg, CheckID: f.FailingCheckID, TailBytes: 5}) - if err != nil || len(tail.Text) > 5 || (len(logs.Text) > 5 && !tail.Truncated) { + if err != nil || len(tail.Text) > 5 || !utf8.ValidString(tail.Text) || (len(logs.Text) > 5 && !tail.Truncated) { t.Errorf("tail-limited logs: %q truncated=%v err=%v", tail.Text, tail.Truncated, err) } _, err = p.GetCheckLogs(ctx(), gp.CheckLogsRequest{Target: tg, CheckID: "no-such-check"}) @@ -917,14 +948,14 @@ func testWrites(t *testing.T, h Harness) { _, err = p.OpenChangeRequest(ctx(), gp.OpenCRRequest{Target: tg, Head: f.NewBranchHead, Base: f.BaseBranch}) mustCode(t, f, err, gp.CodeValidation) if caps.DraftChangeRequests { - d, err := p.OpenChangeRequest(ctx(), gp.OpenCRRequest{Target: tg, Title: "Draft", Head: "draft-branch", Base: f.BaseBranch, Draft: true}) + d, err := p.OpenChangeRequest(ctx(), gp.OpenCRRequest{Target: tg, Title: "Draft", Head: orDefault(f.DraftBranchHead, "draft-branch"), Base: f.BaseBranch, Draft: true}) if err != nil || !d.Draft { t.Errorf("draft change request: %+v %v", d, err) } } if caps.IdempotentWrites { - a, errA := p.OpenChangeRequest(ctx(), gp.OpenCRRequest{Target: tg, Title: "Idem", Head: "idem-branch", Base: f.BaseBranch, IdempotencyKey: "k1"}) - b, errB := p.OpenChangeRequest(ctx(), gp.OpenCRRequest{Target: tg, Title: "Idem", Head: "idem-branch", Base: f.BaseBranch, IdempotencyKey: "k1"}) + a, errA := p.OpenChangeRequest(ctx(), gp.OpenCRRequest{Target: tg, Title: "Idem", Head: orDefault(f.IdempotentBranchHead, "idem-branch"), Base: f.BaseBranch, IdempotencyKey: "k1"}) + b, errB := p.OpenChangeRequest(ctx(), gp.OpenCRRequest{Target: tg, Title: "Idem", Head: orDefault(f.IdempotentBranchHead, "idem-branch"), Base: f.BaseBranch, IdempotencyKey: "k1"}) if errA != nil || errB != nil || a.Number != b.Number { t.Errorf("idempotent open: %v %v numbers %q/%q", errA, errB, a.Number, b.Number) } diff --git a/gitprovider/fakeprovider/fake.go b/gitprovider/fakeprovider/fake.go index f3c3dd8..3e3c007 100644 --- a/gitprovider/fakeprovider/fake.go +++ b/gitprovider/fakeprovider/fake.go @@ -26,6 +26,7 @@ import ( "strings" "sync" "time" + "unicode/utf8" gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" ) @@ -127,6 +128,23 @@ func (c *Core) repo(t gp.Target) (*repoState, error) { return r, nil } +// cutHead keeps at most n bytes from the start of s, on a rune boundary. +func cutHead(s string, n int) string { + for n > 0 && n < len(s) && !utf8.RuneStart(s[n]) { + n-- + } + return s[:n] +} + +// cutTail keeps at most n bytes from the end of s, on a rune boundary. +func cutTail(s string, n int) string { + i := len(s) - n + for i < len(s) && !utf8.RuneStart(s[i]) { + i++ + } + return s[i:] +} + func (c *Core) next(prefix string) string { c.seq++; return fmt.Sprintf("%s-%d", prefix, c.seq) } // idemKey namespaces a caller-supplied idempotency key; no key, no memoisation. @@ -462,7 +480,7 @@ func (c *Core) GetDiff(_ context.Context, req gp.DiffRequest) (gp.Diff, error) { } out := b.String() if req.MaxBytes > 0 && len(out) > req.MaxBytes { - return gp.Diff{Text: out[:req.MaxBytes], Truncated: true}, nil + return gp.Diff{Text: cutHead(out, req.MaxBytes), Truncated: true}, nil } return gp.Diff{Text: out}, nil } @@ -534,7 +552,7 @@ func (c *Core) GetCheckLogs(_ context.Context, req gp.CheckLogsRequest) (gp.Logs if ch.ID == req.CheckID { log := "step 1 ok\nstep 2 FAILED: expected 1, got 2\n" if req.TailBytes > 0 && len(log) > req.TailBytes { - return gp.Logs{Text: log[len(log)-req.TailBytes:], Truncated: true}, nil + return gp.Logs{Text: cutTail(log, req.TailBytes), Truncated: true}, nil } return gp.Logs{Text: log}, nil } diff --git a/gitprovider/gitprovider_test.go b/gitprovider/gitprovider_test.go index 9b63c06..98fd3d0 100644 --- a/gitprovider/gitprovider_test.go +++ b/gitprovider/gitprovider_test.go @@ -1,9 +1,11 @@ package gitprovider_test import ( + "context" "encoding/json" "errors" "fmt" + "strings" "testing" gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" @@ -83,3 +85,30 @@ func contains(s, sub string) bool { } return false } + +type panicProvider struct{ gp.Provider } + +func (panicProvider) Describe() gp.Info { panic("boom") } + +func TestDispatch_RecoversFromPanic(t *testing.T) { + status, body := gp.Dispatch(context.Background(), panicProvider{}, "describe", nil) + if status != 500 || strings.Contains(string(body), "boom") { + t.Fatalf("status %d body %s; want a generic 500", status, body) + } +} + +// undeclared embeds a provider but reports the merge capability as off. +type undeclared struct{ gp.Provider } + +func (u undeclared) Describe() gp.Info { + i := u.Provider.Describe() + i.Capabilities.Merge = false + return i +} + +func TestDispatch_UndeclaredCapabilityIsUnsupported(t *testing.T) { + status, _ := gp.Dispatch(context.Background(), undeclared{fakeprovider.New()}, "merge_change_request", []byte(`{}`)) + if status != 501 { + t.Fatalf("status %d; want 501 for an undeclared capability", status) + } +} diff --git a/gitprovider/serve/serve.go b/gitprovider/serve/serve.go index 100d925..aebd0ed 100644 --- a/gitprovider/serve/serve.go +++ b/gitprovider/serve/serve.go @@ -3,6 +3,9 @@ package serve import ( "context" + "encoding/json" + "strconv" + "time" plugin "github.com/Paca-AI/plugin-sdk-go" "github.com/Paca-AI/plugin-sdk-go/gitprovider" @@ -13,6 +16,10 @@ import ( // the MCP server, after core's own checks) call it. const RoutePrefix = "/_git" +// callTimeout bounds one operation: plugin.Request carries no context, so +// without it a stalled platform call would outlive the caller's own timeout. +const callTimeout = 60 * time.Second + // Register mounts every operation as POST /_git/:op. Call it from the plugin's // Init: // @@ -20,11 +27,22 @@ const RoutePrefix = "/_git" // serve.Register(ctx, newMyProvider(ctx)) // return nil // } +// +// The route is only safe because the host refuses to expose `/_git/*` to +// browsers; the SDK performs no caller authentication of its own. func Register(ctx *plugin.Context, p gitprovider.Provider) { ctx.Route("POST", RoutePrefix+"/:op", func(req *plugin.Request, res *plugin.Response) { - status, body := gitprovider.Dispatch(context.Background(), p, req.PathParam("op"), req.Body) + cctx, cancel := context.WithTimeout(context.Background(), callTimeout) + defer cancel() + status, body := gitprovider.Dispatch(cctx, p, req.PathParam("op"), req.Body) res.StatusCode = status res.Headers["Content-Type"] = "application/json" + if status == 429 || status == 503 { + var env gitprovider.Envelope + if json.Unmarshal(body, &env) == nil && env.Error != nil && env.Error.RetryAfterSeconds > 0 { + res.Headers["Retry-After"] = strconv.Itoa(env.Error.RetryAfterSeconds) + } + } res.Body = body }) } diff --git a/gitprovider/serve/serve_test.go b/gitprovider/serve/serve_test.go index 4f0f0ba..2facbcb 100644 --- a/gitprovider/serve/serve_test.go +++ b/gitprovider/serve/serve_test.go @@ -1,6 +1,7 @@ package serve_test import ( + "context" "encoding/json" "testing" @@ -51,3 +52,23 @@ func TestRegister_ErrorsCarryHTTPStatus(t *testing.T) { t.Fatalf("want 404, got %d: %s", res.StatusCode, res.BodyString()) } } + +type rateLimited struct{ gitprovider.Provider } + +func (rateLimited) GetIssue(context.Context, gitprovider.IssueRequest) (gitprovider.Issue, error) { + return gitprovider.Issue{}, gitprovider.RateLimited(7, "slow down") +} + +func TestRegister_RateLimitSetsRetryAfter(t *testing.T) { + tc := setup(t, rateLimited{fakeprovider.New()}) + res := tc.Call("POST", "/_git/get_issue", plugintest.Request{ + PathParams: map[string]string{"op": "get_issue"}, + Body: []byte(`{}`), + }) + if res.StatusCode != 429 { + t.Fatalf("want 429, got %d: %s", res.StatusCode, res.BodyString()) + } + if got := res.Headers["Retry-After"]; got != "7" { + t.Fatalf("Retry-After = %q, want 7", got) + } +} diff --git a/gitprovider/types.go b/gitprovider/types.go index 28d8725..780e4d7 100644 --- a/gitprovider/types.go +++ b/gitprovider/types.go @@ -289,7 +289,6 @@ type CompleteSetupRequest struct { Host string `json:"host"` State string `json:"state"` Params map[string]string `json:"params"` // callback query parameters, or {"token": "..."} - // Next is set when the flow has further legs (GitHub: manifest → install). } // Identity is a created identity as core stores it. Secrets stay with the @@ -301,7 +300,7 @@ type Identity struct { DisplayName string `json:"display_name"` // ExpiresAt is when the identity's credentials stop working, for platforms // whose tokens expire (zero = does not expire). Core warns before it passes. - ExpiresAt time.Time `json:"expires_at,omitempty"` + ExpiresAt time.Time `json:"expires_at,omitzero"` // Next, when set, is a further step to run before the identity is usable // (GitHub: install the app). Next *SetupStep `json:"next,omitempty"` @@ -517,7 +516,7 @@ type MintRequest struct { type Credential struct { Username string `json:"username"` Secret string `json:"secret"` - ExpiresAt time.Time `json:"expires_at,omitempty"` + ExpiresAt time.Time `json:"expires_at,omitzero"` } // ActorPermissionRequest asks for a login's access to a repository. @@ -564,7 +563,7 @@ type ChangeRequest struct { URL string `json:"url"` Labels []string `json:"labels,omitempty"` Reviewers []string `json:"reviewers,omitempty"` - UpdatedAt time.Time `json:"updated_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitzero"` } // CRRequest addresses one change request. @@ -595,6 +594,11 @@ type ChangedFilePage struct { NextCursor string `json:"next_cursor,omitempty"` } +// Size limits (MaxBytes, TailBytes) count bytes. Text results (Diff, Logs) are +// cut on a UTF-8 boundary, so the result is always valid UTF-8 and may be a few +// bytes shorter than the limit; Truncated reports that anything was cut. File +// content is raw bytes and is cut exactly at the limit. + // DiffRequest asks for a change request's diff, whole or for one file. type DiffRequest struct { Target Target `json:"target"` diff --git a/gitprovider/wire.go b/gitprovider/wire.go index 35bd7b6..b87f038 100644 --- a/gitprovider/wire.go +++ b/gitprovider/wire.go @@ -24,7 +24,7 @@ type opEntry struct { func decode[Req any](body []byte) (Req, error) { var r Req if len(body) == 0 { - return r, nil + return r, Errorf(CodeValidation, "request body is required") } if err := json.Unmarshal(body, &r); err != nil { return r, Errorf(CodeValidation, "malformed request body") @@ -119,11 +119,20 @@ func Operations() []Operation { // Dispatch runs one operation by wire name against p and returns the HTTP // status and the JSON [Envelope]. It never panics on bad input: unknown // operations and malformed bodies become typed errors. -func Dispatch(ctx context.Context, p Provider, op string, body []byte) (int, []byte) { +func Dispatch(ctx context.Context, p Provider, op string, body []byte) (status int, out []byte) { + // A panicking provider must not take the plugin down or leak internals. + defer func() { + if r := recover(); r != nil { + status, out = fail(Errorf(CodeInternal, "internal provider error")) + } + }() entry, ok := opIndex[op] if !ok { return fail(Errorf(CodeUnsupported, "unknown operation %q", op)) } + if entry.Capability != "" && !declared(p, entry.Capability) { + return fail(ErrUnsupported) + } result, err := entry.call(ctx, p, body) if err != nil { return fail(AsError(err)) @@ -132,10 +141,25 @@ func Dispatch(ctx context.Context, p Provider, op string, body []byte) (int, []b if mErr != nil { return fail(Errorf(CodeInternal, "could not encode result")) } - out, _ := json.Marshal(Envelope{OK: true, Result: raw}) + out, _ = json.Marshal(Envelope{OK: true, Result: raw}) return http.StatusOK, out } +// declared reports whether p's Describe() sets the capability flag (by JSON +// name) to true. Optional operations are only served when it is. +func declared(p Provider, capability string) bool { + raw, err := json.Marshal(p.Describe().Capabilities) + if err != nil { + return false + } + var flags map[string]any + if json.Unmarshal(raw, &flags) != nil { + return false + } + v, _ := flags[capability].(bool) + return v +} + func fail(e *Error) (int, []byte) { out, _ := json.Marshal(Envelope{OK: false, Error: e}) return e.Code.HTTPStatus(), out From 359e200d538e1789982e67598e5c7aa18f9f3ceb Mon Sep 17 00:00:00 2001 From: pikann22 Date: Sun, 25 Oct 2026 06:57:13 +0000 Subject: [PATCH 4/5] feat: add branch management capabilities with GetBranch and CreateBranch implementations --- gitprovider/conformance/conformance.go | 29 +++++++++++ gitprovider/fakeprovider/fake.go | 2 + gitprovider/fakeprovider/variants.go | 52 +++++++++++++++++++- gitprovider/provider.go | 10 ++++ gitprovider/testdata/wire-shapes.golden.json | 51 ++++++++++++++++++- gitprovider/types.go | 29 +++++++++++ gitprovider/wire.go | 2 + gitprovider/wiretypes.go | 1 + 8 files changed, 173 insertions(+), 3 deletions(-) diff --git a/gitprovider/conformance/conformance.go b/gitprovider/conformance/conformance.go index 3f78bd5..9899e70 100644 --- a/gitprovider/conformance/conformance.go +++ b/gitprovider/conformance/conformance.go @@ -279,6 +279,7 @@ func testDescribe(t *testing.T, h Harness) { check("reactions", c.Reactions, impl.Reactions) check("webhook_management", c.WebhookManagement, impl.WebhookManagement) check("identity_deletion", c.IdentityDeletion, impl.IdentityDeletion) + check("branches", c.Branches, impl.Branches) check("access_grants", c.AccessGrants, impl.AccessGrants) } @@ -1070,6 +1071,34 @@ func testOptionalWrites(t *testing.T, h Harness) { mustCode(t, f, err, gp.CodeNotFound) }) } + if caps.Branches { + t.Run("branches", func(t *testing.T) { + p := h.New(t) + bm := p.(gp.BranchManager) + def := tg.Repo.DefaultBranch + base, err := bm.GetBranch(ctx(), gp.GetBranchRequest{Target: tg, Name: def}) + if err != nil || base.Name != def || base.SHA == "" { + t.Fatalf("GetBranch(default): %+v %v", base, err) + } + _, err = bm.GetBranch(ctx(), gp.GetBranchRequest{Target: tg, Name: "no-such-branch"}) + mustCode(t, f, err, gp.CodeNotFound) + + made, err := bm.CreateBranch(ctx(), gp.CreateBranchRequest{Target: tg, Name: "paca/conformance"}) + if err != nil || made.Name != "paca/conformance" || made.SHA != base.SHA { + t.Fatalf("CreateBranch from the default branch: %+v %v (want sha %s)", made, err, base.SHA) + } + got, err := bm.GetBranch(ctx(), gp.GetBranchRequest{Target: tg, Name: "paca/conformance"}) + if err != nil || got.SHA != made.SHA { + t.Errorf("created branch not visible: %+v %v", got, err) + } + _, err = bm.CreateBranch(ctx(), gp.CreateBranchRequest{Target: tg, Name: "paca/conformance"}) + mustCode(t, f, err, gp.CodeConflict) + _, err = bm.CreateBranch(ctx(), gp.CreateBranchRequest{Target: tg, Name: "paca/from-missing", From: "no-such-branch"}) + mustCode(t, f, err, gp.CodeNotFound) + _, err = bm.CreateBranch(ctx(), gp.CreateBranchRequest{Target: tg, Name: "bad name~^"}) + mustCode(t, f, err, gp.CodeValidation) + }) + } if caps.Labels { t.Run("labels", func(t *testing.T) { p := h.New(t) diff --git a/gitprovider/fakeprovider/fake.go b/gitprovider/fakeprovider/fake.go index 3e3c007..8ae7f86 100644 --- a/gitprovider/fakeprovider/fake.go +++ b/gitprovider/fakeprovider/fake.go @@ -65,6 +65,7 @@ type repoState struct { hooks map[string]string nextID int statuses map[string]gp.SetStatusRequest + branches map[string]string // branch → commit sha } // Core implements the required [gp.Provider] surface against an in-memory world. @@ -103,6 +104,7 @@ func (c *Core) seedRepo(path string, private bool) *repoState { checks: map[string][]gp.Check{SeedHeadSHA: {{ID: "chk-1", Name: "ci/test", State: gp.CheckFailure, Required: true, URL: "https://" + Host + "/chk-1"}, {ID: "chk-2", Name: "ci/lint", State: gp.CheckSuccess}}}, hooks: map[string]string{}, statuses: map[string]gp.SetStatusRequest{}, + branches: map[string]string{"main": "base000", "feat/x": SeedHeadSHA}, } r.crs[SeedCRNumber] = &cr{ ChangeRequest: gp.ChangeRequest{Number: SeedCRNumber, Title: "Add feature", Body: "Implements the feature.", State: gp.CROpen, diff --git a/gitprovider/fakeprovider/variants.go b/gitprovider/fakeprovider/variants.go index 38eb743..bfca88a 100644 --- a/gitprovider/fakeprovider/variants.go +++ b/gitprovider/fakeprovider/variants.go @@ -35,12 +35,62 @@ func New() *Full { Triggers: gp.AllTriggerKinds, MultipleIdentities: true, ResolveThreads: true, FileRead: true, CodeSearch: true, Labels: true, ReviewerRequests: true, Assignees: true, - Merge: true, RerunChecks: true, Reactions: true, WebhookManagement: true, IdentityDeletion: true, AccessGrants: true, + Merge: true, RerunChecks: true, Reactions: true, Branches: true, WebhookManagement: true, IdentityDeletion: true, AccessGrants: true, BatchReview: true, IdempotentWrites: true, HiddenMarkers: true, ForkChangeRequests: true, DraftChangeRequests: true, CIConfigGlobs: []string{".fake-ci/**"}, })} } +// validBranchName reports whether name is acceptable as a branch name (the +// fake's rule is git's core one: no spaces or ref-special characters). +func validBranchName(name string) bool { + if name == "" || strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") || strings.Contains(name, "..") || strings.HasSuffix(name, ".lock") { + return false + } + return !strings.ContainsAny(name, " ~^:?*[\\") +} + +// GetBranch implements gp.BranchManager. +func (f *Full) GetBranch(_ context.Context, req gp.GetBranchRequest) (gp.BranchInfo, error) { + f.mu.Lock() + defer f.mu.Unlock() + r, err := f.repo(req.Target) + if err != nil { + return gp.BranchInfo{}, err + } + sha, ok := r.branches[req.Name] + if !ok { + return gp.BranchInfo{}, gp.Errorf(gp.CodeNotFound, "branch not found") + } + return gp.BranchInfo{Name: req.Name, SHA: sha, URL: "https://" + Host + "/" + r.ref.FullPath + "/tree/" + req.Name}, nil +} + +// CreateBranch implements gp.BranchManager. +func (f *Full) CreateBranch(_ context.Context, req gp.CreateBranchRequest) (gp.BranchInfo, error) { + f.mu.Lock() + defer f.mu.Unlock() + r, err := f.repo(req.Target) + if err != nil { + return gp.BranchInfo{}, err + } + if !validBranchName(req.Name) { + return gp.BranchInfo{}, gp.Errorf(gp.CodeValidation, "invalid branch name") + } + from := req.From + if from == "" { + from = r.ref.DefaultBranch + } + sha, ok := r.branches[from] + if !ok { + return gp.BranchInfo{}, gp.Errorf(gp.CodeNotFound, "source branch not found") + } + if _, taken := r.branches[req.Name]; taken { + return gp.BranchInfo{}, gp.Errorf(gp.CodeConflict, "branch already exists") + } + r.branches[req.Name] = sha + return gp.BranchInfo{Name: req.Name, SHA: sha, URL: "https://" + Host + "/" + r.ref.FullPath + "/tree/" + req.Name}, nil +} + // ResolveThread implements gp.ThreadResolver. func (f *Full) ResolveThread(_ context.Context, req gp.ResolveThreadRequest) (gp.Empty, error) { f.mu.Lock() diff --git a/gitprovider/provider.go b/gitprovider/provider.go index 2a27ed8..ef324ed 100644 --- a/gitprovider/provider.go +++ b/gitprovider/provider.go @@ -116,6 +116,15 @@ type AccessGranter interface { CompleteAccessGrant(ctx context.Context, req CompleteAccessRequest) (AccessGrant, error) } +// BranchManager looks up and creates branches (capability Branches). GetBranch +// returns not_found for a missing branch; CreateBranch returns conflict when the +// name is taken, not_found when From does not exist and validation for an +// invalid name. +type BranchManager interface { + GetBranch(ctx context.Context, req GetBranchRequest) (BranchInfo, error) + CreateBranch(ctx context.Context, req CreateBranchRequest) (BranchInfo, error) +} + // Implemented reports which optional capabilities p actually implements, so // tooling can compare them with what [Info] declares. func Implemented(p Provider) Capabilities { @@ -129,6 +138,7 @@ func Implemented(p Provider) Capabilities { _, c.Merge = p.(Merger) _, c.RerunChecks = p.(CheckRerunner) _, c.Reactions = p.(Reactor) + _, c.Branches = p.(BranchManager) _, c.WebhookManagement = p.(WebhookManager) _, c.IdentityDeletion = p.(IdentityDeleter) _, c.AccessGrants = p.(AccessGranter) diff --git a/gitprovider/testdata/wire-shapes.golden.json b/gitprovider/testdata/wire-shapes.golden.json index b9f5e08..34cec50 100644 --- a/gitprovider/testdata/wire-shapes.golden.json +++ b/gitprovider/testdata/wire-shapes.golden.json @@ -59,6 +59,11 @@ "repo": "string", "sha": "string" }, + "BranchInfo": { + "name": "string", + "sha": "string", + "url": "string" + }, "BranchProtection": { "allows_force_push": "bool", "protected": "bool", @@ -105,6 +110,7 @@ "access_grants": "bool", "assignees": "bool", "batch_review": "bool", + "branches": "bool", "ci_config_globs": [ "string" ], @@ -331,6 +337,26 @@ "scope": "string", "state": "string" }, + "CreateBranchRequest": { + "from": "string", + "idempotency_key": "string", + "name": "string", + "target": { + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "repo": { + "clone_url": "string", + "default_branch": "string", + "external_id": "string", + "full_path": "string", + "host": "string", + "provider_ref": "bytes" + } + } + }, "Credential": { "expires_at": "time", "secret": "string", @@ -453,7 +479,8 @@ "sha": "string" }, "kind": "string", - "number": "string" + "number": "string", + "url": "string" }, "text": "string" }, @@ -468,7 +495,8 @@ "sha": "string" }, "kind": "string", - "number": "string" + "number": "string", + "url": "string" }, "File": { "content": "bytes", @@ -503,6 +531,24 @@ "required": "bool", "secret": "bool" }, + "GetBranchRequest": { + "name": "string", + "target": { + "identity": { + "handle": "string", + "id": "string", + "provider_ref": "bytes" + }, + "repo": { + "clone_url": "string", + "default_branch": "string", + "external_id": "string", + "full_path": "string", + "host": "string", + "provider_ref": "bytes" + } + } + }, "Identity": { "display_name": "string", "expires_at": "time", @@ -543,6 +589,7 @@ "access_grants": "bool", "assignees": "bool", "batch_review": "bool", + "branches": "bool", "ci_config_globs": [ "string" ], diff --git a/gitprovider/types.go b/gitprovider/types.go index 780e4d7..3a61c06 100644 --- a/gitprovider/types.go +++ b/gitprovider/types.go @@ -199,6 +199,9 @@ type Capabilities struct { Reactions bool `json:"reactions"` WebhookManagement bool `json:"webhook_management"` IdentityDeletion bool `json:"identity_deletion"` + // Branches is true when branches can be looked up and created through the + // provider (core uses it to link and create a task's branch). + Branches bool `json:"branches"` // AccessGrants is true when an identity must be granted access to each // account or organization before it can see its repositories, and a user // does that on the platform (GitHub: installing the App on an account). @@ -439,6 +442,9 @@ type EventSubject struct { Base string `json:"base,omitempty"` Draft bool `json:"draft,omitempty"` Author string `json:"author,omitempty"` + // URL is the subject's web page, when the platform's event carries it; core + // shows it as the link to the pull/merge request. + URL string `json:"url,omitempty"` } // Location pins a review comment to code. @@ -942,3 +948,26 @@ type RemoveWebhookRequest struct { // Empty is the result of operations that return nothing. type Empty struct{} + +// ── Branches ───────────────────────────────────────────────────────────────── + +// GetBranchRequest asks whether a branch exists. +type GetBranchRequest struct { + Target Target `json:"target"` + Name string `json:"name"` +} + +// CreateBranchRequest creates a branch from another branch (or the default). +type CreateBranchRequest struct { + Target Target `json:"target"` + Name string `json:"name"` + From string `json:"from,omitempty"` // empty = the repository's default branch + IdempotencyKey string `json:"idempotency_key,omitempty"` +} + +// BranchInfo is a branch and the commit it points at. +type BranchInfo struct { + Name string `json:"name"` + SHA string `json:"sha"` + URL string `json:"url,omitempty"` +} diff --git a/gitprovider/wire.go b/gitprovider/wire.go index b87f038..4f9c208 100644 --- a/gitprovider/wire.go +++ b/gitprovider/wire.go @@ -92,6 +92,8 @@ var operations = []opEntry{ optional("merge_change_request", "merge", Merger.MergeChangeRequest), optional("rerun_checks", "rerun_checks", CheckRerunner.RerunChecks), optional("react", "reactions", Reactor.React), + optional("get_branch", "branches", BranchManager.GetBranch), + optional("create_branch", "branches", BranchManager.CreateBranch), optional("ensure_webhook", "webhook_management", WebhookManager.EnsureWebhook), optional("remove_webhook", "webhook_management", WebhookManager.RemoveWebhook), optional("delete_identity", "identity_deletion", IdentityDeleter.DeleteIdentity), diff --git a/gitprovider/wiretypes.go b/gitprovider/wiretypes.go index a7317eb..fc3d607 100644 --- a/gitprovider/wiretypes.go +++ b/gitprovider/wiretypes.go @@ -35,6 +35,7 @@ func WireTypes() map[string]reflect.Type { "SetStatusRequest": t(SetStatusRequest{}), "StatusResult": t(StatusResult{}), "ResolveThreadRequest": t(ResolveThreadRequest{}), "LabelsRequest": t(LabelsRequest{}), "PeopleRequest": t(PeopleRequest{}), "MergeRequest": t(MergeRequest{}), "MergeResult": t(MergeResult{}), "RerunRequest": t(RerunRequest{}), "ReactRequest": t(ReactRequest{}), + "GetBranchRequest": t(GetBranchRequest{}), "CreateBranchRequest": t(CreateBranchRequest{}), "BranchInfo": t(BranchInfo{}), "EnsureWebhookRequest": t(EnsureWebhookRequest{}), "WebhookRef": t(WebhookRef{}), "RemoveWebhookRequest": t(RemoveWebhookRequest{}), "Error": t(Error{}), "Envelope": t(Envelope{}), } From c922b58e814f0562f6efbbdc73609f89420215cc Mon Sep 17 00:00:00 2001 From: pikann22 Date: Sun, 25 Oct 2026 07:02:11 +0000 Subject: [PATCH 5/5] feat: enhance branch creation logic with idempotency support and improved validation --- gitprovider/conformance/conformance.go | 45 ++++++++++++++----- gitprovider/fakeprovider/mutants_test.go | 26 +++++++++++ gitprovider/fakeprovider/variants.go | 56 +++++++++++++++++------- gitprovider/types.go | 3 ++ 4 files changed, 101 insertions(+), 29 deletions(-) diff --git a/gitprovider/conformance/conformance.go b/gitprovider/conformance/conformance.go index 9899e70..1c751f7 100644 --- a/gitprovider/conformance/conformance.go +++ b/gitprovider/conformance/conformance.go @@ -105,10 +105,14 @@ type Fixtures struct { // Defaults ("draft-branch", "idem-branch") suit the fake backend only. DraftBranchHead string IdempotentBranchHead string - BaseBranch string - File struct{ Ref, Path string } - SearchQuery string // a query that matches at least one file - Reviewer string // a login that can be requested as reviewer + // NewBranchName is a branch name CreateBranch can create (providers + // declaring Branches). Default "paca/conformance". The suite never deletes + // it, so a sandbox run needs a fresh name each time or manual cleanup. + NewBranchName string + BaseBranch string + File struct{ Ref, Path string } + SearchQuery string // a query that matches at least one file + Reviewer string // a login that can be requested as reviewer // AccessGrantParams are the callback parameters that complete an access // grant started by BeginAccessGrant (providers declaring AccessGrants). @@ -555,6 +559,11 @@ func checkEventInvariants(t *testing.T, f Fixtures, caps gp.Capabilities, ev gp. t.Error("subject number is required") } } + if ev.Subject != nil && ev.Subject.URL != "" { + if u, err := url.Parse(ev.Subject.URL); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + t.Errorf("subject url %q must be an absolute http(s) URL", ev.Subject.URL) + } + } if ev.Kind == gp.EventCheck && (ev.Check == nil || ev.Check.Conclusion == "") { t.Error("check events need check.conclusion") } @@ -1075,28 +1084,40 @@ func testOptionalWrites(t *testing.T, h Harness) { t.Run("branches", func(t *testing.T) { p := h.New(t) bm := p.(gp.BranchManager) - def := tg.Repo.DefaultBranch + def := orDefault(tg.Repo.DefaultBranch, f.BaseBranch) + name := orDefault(f.NewBranchName, "paca/conformance") base, err := bm.GetBranch(ctx(), gp.GetBranchRequest{Target: tg, Name: def}) if err != nil || base.Name != def || base.SHA == "" { t.Fatalf("GetBranch(default): %+v %v", base, err) } _, err = bm.GetBranch(ctx(), gp.GetBranchRequest{Target: tg, Name: "no-such-branch"}) mustCode(t, f, err, gp.CodeNotFound) + _, err = bm.GetBranch(ctx(), gp.GetBranchRequest{Target: tg}) + mustCode(t, f, err, gp.CodeValidation) - made, err := bm.CreateBranch(ctx(), gp.CreateBranchRequest{Target: tg, Name: "paca/conformance"}) - if err != nil || made.Name != "paca/conformance" || made.SHA != base.SHA { + made, err := bm.CreateBranch(ctx(), gp.CreateBranchRequest{Target: tg, Name: name}) + if err != nil || made.Name != name || made.SHA != base.SHA { t.Fatalf("CreateBranch from the default branch: %+v %v (want sha %s)", made, err, base.SHA) } - got, err := bm.GetBranch(ctx(), gp.GetBranchRequest{Target: tg, Name: "paca/conformance"}) + got, err := bm.GetBranch(ctx(), gp.GetBranchRequest{Target: tg, Name: name}) if err != nil || got.SHA != made.SHA { t.Errorf("created branch not visible: %+v %v", got, err) } - _, err = bm.CreateBranch(ctx(), gp.CreateBranchRequest{Target: tg, Name: "paca/conformance"}) + _, err = bm.CreateBranch(ctx(), gp.CreateBranchRequest{Target: tg, Name: name}) mustCode(t, f, err, gp.CodeConflict) - _, err = bm.CreateBranch(ctx(), gp.CreateBranchRequest{Target: tg, Name: "paca/from-missing", From: "no-such-branch"}) + _, err = bm.CreateBranch(ctx(), gp.CreateBranchRequest{Target: tg, Name: name + "-from-missing", From: "no-such-branch"}) mustCode(t, f, err, gp.CodeNotFound) - _, err = bm.CreateBranch(ctx(), gp.CreateBranchRequest{Target: tg, Name: "bad name~^"}) - mustCode(t, f, err, gp.CodeValidation) + for _, bad := range []string{"bad name~^", "-leading-dash", "a..b", "trailing."} { + _, err = bm.CreateBranch(ctx(), gp.CreateBranchRequest{Target: tg, Name: bad}) + mustCode(t, f, err, gp.CodeValidation) + } + if caps.IdempotentWrites { + a, errA := bm.CreateBranch(ctx(), gp.CreateBranchRequest{Target: tg, Name: name + "-idem", IdempotencyKey: "b1"}) + b, errB := bm.CreateBranch(ctx(), gp.CreateBranchRequest{Target: tg, Name: name + "-idem", IdempotencyKey: "b1"}) + if errA != nil || errB != nil || a != b { + t.Errorf("idempotent branch create: %+v %v / %+v %v", a, errA, b, errB) + } + } }) } if caps.Labels { diff --git a/gitprovider/fakeprovider/mutants_test.go b/gitprovider/fakeprovider/mutants_test.go index 19c1e37..d288fb5 100644 --- a/gitprovider/fakeprovider/mutants_test.go +++ b/gitprovider/fakeprovider/mutants_test.go @@ -173,3 +173,29 @@ func (r *reusableGrant) CompleteAccessGrant(ctx context.Context, req gp.Complete func TestMutant_AccessGrantStateIsReusable(t *testing.T) { run(t, func() gp.Provider { return &reusableGrant{Full: fakeprovider.New()} }) } + +// A branch create that forgives an existing name hides lost races. +type branchOverwrite struct{ *fakeprovider.Full } + +func (b branchOverwrite) CreateBranch(ctx context.Context, r gp.CreateBranchRequest) (gp.BranchInfo, error) { + info, err := b.Full.CreateBranch(ctx, r) + var pe *gp.Error + if errors.As(err, &pe) && pe.Code == gp.CodeConflict { + return b.Full.GetBranch(ctx, gp.GetBranchRequest{Target: r.Target, Name: r.Name}) + } + return info, err +} +func TestMutant_BranchCreateForgivesExisting(t *testing.T) { + run(t, func() gp.Provider { return branchOverwrite{fakeprovider.New()} }) +} + +// A branch create that ignores From always branches off the default. +type branchIgnoresFrom struct{ *fakeprovider.Full } + +func (b branchIgnoresFrom) CreateBranch(ctx context.Context, r gp.CreateBranchRequest) (gp.BranchInfo, error) { + r.From = "" + return b.Full.CreateBranch(ctx, r) +} +func TestMutant_BranchCreateIgnoresFrom(t *testing.T) { + run(t, func() gp.Provider { return branchIgnoresFrom{fakeprovider.New()} }) +} diff --git a/gitprovider/fakeprovider/variants.go b/gitprovider/fakeprovider/variants.go index bfca88a..33e5985 100644 --- a/gitprovider/fakeprovider/variants.go +++ b/gitprovider/fakeprovider/variants.go @@ -41,13 +41,30 @@ func New() *Full { })} } -// validBranchName reports whether name is acceptable as a branch name (the -// fake's rule is git's core one: no spaces or ref-special characters). +// validBranchName reports whether name is acceptable as a branch name. It +// follows git's check-ref-format rules closely enough for the fake: no control +// characters, spaces or ref-special characters, no empty, leading-dash, +// leading-dot or trailing-dot/slash components, no "..", "//", "@{" or ".lock". func validBranchName(name string) bool { - if name == "" || strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") || strings.Contains(name, "..") || strings.HasSuffix(name, ".lock") { + if name == "" || name == "@" || strings.HasPrefix(name, "-") || strings.HasPrefix(name, "/") || + strings.HasSuffix(name, "/") || strings.HasSuffix(name, ".") || + strings.Contains(name, "..") || strings.Contains(name, "//") || strings.Contains(name, "@{") { return false } - return !strings.ContainsAny(name, " ~^:?*[\\") + for _, r := range name { + if r < 0x20 || r == 0x7f { + return false + } + } + if strings.ContainsAny(name, " ~^:?*[\\") { + return false + } + for _, part := range strings.Split(name, "/") { + if strings.HasPrefix(part, ".") || strings.HasSuffix(part, ".lock") { + return false + } + } + return true } // GetBranch implements gp.BranchManager. @@ -58,6 +75,9 @@ func (f *Full) GetBranch(_ context.Context, req gp.GetBranchRequest) (gp.BranchI if err != nil { return gp.BranchInfo{}, err } + if req.Name == "" { + return gp.BranchInfo{}, gp.Errorf(gp.CodeValidation, "branch name is required") + } sha, ok := r.branches[req.Name] if !ok { return gp.BranchInfo{}, gp.Errorf(gp.CodeNotFound, "branch not found") @@ -76,19 +96,21 @@ func (f *Full) CreateBranch(_ context.Context, req gp.CreateBranchRequest) (gp.B if !validBranchName(req.Name) { return gp.BranchInfo{}, gp.Errorf(gp.CodeValidation, "invalid branch name") } - from := req.From - if from == "" { - from = r.ref.DefaultBranch - } - sha, ok := r.branches[from] - if !ok { - return gp.BranchInfo{}, gp.Errorf(gp.CodeNotFound, "source branch not found") - } - if _, taken := r.branches[req.Name]; taken { - return gp.BranchInfo{}, gp.Errorf(gp.CodeConflict, "branch already exists") - } - r.branches[req.Name] = sha - return gp.BranchInfo{Name: req.Name, SHA: sha, URL: "https://" + Host + "/" + r.ref.FullPath + "/tree/" + req.Name}, nil + return once(f.Core, idemKey("branch", req.IdempotencyKey), func() (gp.BranchInfo, error) { + from := req.From + if from == "" { + from = r.ref.DefaultBranch + } + sha, ok := r.branches[from] + if !ok { + return gp.BranchInfo{}, gp.Errorf(gp.CodeNotFound, "source branch not found") + } + if _, taken := r.branches[req.Name]; taken { + return gp.BranchInfo{}, gp.Errorf(gp.CodeConflict, "branch already exists") + } + r.branches[req.Name] = sha + return gp.BranchInfo{Name: req.Name, SHA: sha, URL: "https://" + Host + "/" + r.ref.FullPath + "/tree/" + req.Name}, nil + }) } // ResolveThread implements gp.ThreadResolver. diff --git a/gitprovider/types.go b/gitprovider/types.go index 3a61c06..0fc9add 100644 --- a/gitprovider/types.go +++ b/gitprovider/types.go @@ -958,6 +958,9 @@ type GetBranchRequest struct { } // CreateBranchRequest creates a branch from another branch (or the default). +// With IdempotencyKey, and when the provider declares IdempotentWrites, a retry +// of a call that already succeeded returns the original result instead of +// conflict. type CreateBranchRequest struct { Target Target `json:"target"` Name string `json:"name"`