diff --git a/internal/app/pulls.go b/internal/app/pulls.go index 6e5b7e4..1e53c17 100644 --- a/internal/app/pulls.go +++ b/internal/app/pulls.go @@ -24,6 +24,8 @@ const maxPullPatchSize = 100 << 20 // PullPatch contains the latest decompressed patch and its target branch. type PullPatch struct { URI string + Title string + Body string TargetBranch string Patch []byte } @@ -306,15 +308,23 @@ func (s *Service) createPullComment(ctx context.Context, pullURI, body string) ( // PullPatch fetches a pull request's latest patch, decompressed and ready to // apply or stream. func (s *Service) PullPatch(ctx context.Context, t Target, rkey string) (*PullPatch, error) { - repoDid, err := s.repoDID(ctx, t) + repo, err := s.resolveRepo(ctx, t) if err != nil { return nil, err } - pulls, err := s.appview.ListPulls(ctx, repoDid, tangled.ListOpts{ + return s.pullPatch(ctx, repo, rkey) +} + +func (s *Service) pullPatch(ctx context.Context, repo *tangled.Repo, rkey string) (*PullPatch, error) { + repoDID := stringValue(repo.Value.RepoDid) + if repoDID == "" { + return nil, fmt.Errorf("repository has no repository DID") + } + pulls, err := s.appview.ListPulls(ctx, repoDID, tangled.ListOpts{ Limit: defaultListLimit, }) if err != nil { - return nil, fmt.Errorf("list PRs for %s: %w", t, err) + return nil, fmt.Errorf("list PRs for repository %q: %w", repoDID, err) } pull, err := findByRKey(pulls.Items, rkey, "pull request") if err != nil { @@ -324,11 +334,20 @@ func (s *Service) PullPatch(ctx context.Context, t Target, rkey string) (*PullPa if err != nil { return nil, err } + if record.Target == nil || record.Target.Branch == "" { + return nil, fmt.Errorf("pull request %q has no target branch", rkey) + } patch, err := s.downloadPullPatch(ctx, extractDID(pull.URI), patchCID) if err != nil { return nil, err } - return &PullPatch{URI: pull.URI, TargetBranch: pullTargetBranch(record.Target), Patch: patch}, nil + return &PullPatch{ + URI: pull.URI, + Title: record.Title, + Body: stringValue(record.Body), + TargetBranch: pullTargetBranch(record.Target), + Patch: patch, + }, nil } func latestPullPatch(pull *tangled.ListItem, rkey string) (tangledlex.RepoPull, string, error) { @@ -419,22 +438,39 @@ func (s *Service) MergePull(ctx context.Context, t Target, rkey string) (*StateR if err != nil { return nil, err } - pullURI, repoURI, err := s.targetRecord(ctx, t, pullCollection, rkey) + repo, err := s.resolveRepo(ctx, t) if err != nil { return nil, err } - knotHost, err := s.repoKnot(ctx, repoURI) + pull, err := s.pullPatch(ctx, repo, rkey) if err != nil { return nil, err } + repoDID := stringValue(repo.Value.RepoDid) + if repoDID == "" { + return nil, fmt.Errorf("repository %q has no repository DID", t.String()) + } + repoName := stringValue(repo.Value.Name) + if repoName == "" { + repoName = t.Repo + } + knotHost := repo.Value.Knot + if knotHost == "" { + return nil, fmt.Errorf("repository %q has no knot", t.String()) + } token, err := atClient.GetServiceAuth(ctx, "did:web:"+knotHost, "sh.tangled.repo.merge") if err != nil { return nil, err } - if err := s.knot.New(knotHost, token).Merge(ctx, knot.MergeInput{Repo: repoURI, Pull: pullURI}); err != nil { + commitMessage := pull.Title + commitBody := pull.Body + if err := s.knot.New(knotHost, token).Merge(ctx, knot.MergeInput{ + DID: extractDID(repo.URI), Name: repoName, Repo: repoDID, Branch: pull.TargetBranch, Patch: string(pull.Patch), + CommitMessage: &commitMessage, CommitBody: optionalString(commitBody), + }); err != nil { return nil, err } - if err := putState(ctx, atClient, did, rkey, pullCollection, pullURI, "merged"); err != nil { + if err := putState(ctx, atClient, did, rkey, pullCollection, pull.URI, "merged"); err != nil { return nil, fmt.Errorf("record merged pull request status: %w", err) } return &StateResult{Rkey: rkey, State: "merged"}, nil diff --git a/internal/app/service_test.go b/internal/app/service_test.go index 121657b..569f792 100644 --- a/internal/app/service_test.go +++ b/internal/app/service_test.go @@ -22,6 +22,8 @@ import ( "github.com/bluesky-social/indigo/atproto/atclient" "github.com/bluesky-social/indigo/atproto/identity" "github.com/bluesky-social/indigo/atproto/syntax" + lexutil "github.com/bluesky-social/indigo/lex/util" + "github.com/ipfs/go-cid" ) func TestCreateRepoRecordsDefaultBranchOutcome(t *testing.T) { @@ -109,15 +111,36 @@ func TestForkRepoCleansUpWhenRecordWriteFails(t *testing.T) { func TestMergePullReportsStatusWriteFailureAfterMerge(t *testing.T) { pds := &testPDS{putErr: errors.New("PDS unavailable")} knotClient := &testKnot{} + patchServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + _, _ = writer.Write(gzipContents(t, []byte("patch"))) + })) + defer patchServer.Close() service := testService(pds, &testGit{}, knotClient) + service.resolver = testResolver{ + identity: &identity.Identity{DID: syntax.DID("did:plc:owner")}, + pdsURL: patchServer.URL, + } + service.httpClient = patchServer.Client() + patchCID := cid.MustParse("bafybeigdyrzt5m6b5nkn55vsgzzfw5cfs2tidw6zqugycdkyybf2z7kz4q") + pullRecord := tangledlex.RepoPull{ + Title: "Example", CreatedAt: "2026-07-29T00:00:00Z", + Target: &tangledlex.RepoPull_Target{Repo: "did:plc:repo", Branch: "master"}, + Rounds: []*tangledlex.RepoPull_Round{{PatchBlob: &lexutil.LexBlob{Ref: lexutil.LexLink(patchCID)}}}, + } service.appview = testAppview{ repo: &tangled.Repo{ URI: "at://did:plc:owner/sh.tangled.repo/example", - Value: tangledlex.Repo{Knot: "knot.example", RepoDid: optionalString("did:plc:repo")}, + Value: tangledlex.Repo{Name: optionalString("example"), Knot: "knot.example", RepoDid: optionalString("did:plc:repo")}, }, pulls: &tangled.List{Items: []tangled.ListItem{{ - URI: "at://did:plc:owner/sh.tangled.repo.pull/pr-1", - Value: json.RawMessage(`{"title":"Example"}`), + URI: "at://did:plc:owner/sh.tangled.repo.pull/pr-1", + Value: func() json.RawMessage { + value, err := json.Marshal(pullRecord) + if err != nil { + t.Fatal(err) + } + return value + }(), }}}, } @@ -128,6 +151,9 @@ func TestMergePullReportsStatusWriteFailureAfterMerge(t *testing.T) { if knotClient.mergeCalls != 1 { t.Fatalf("MergePull() calls = %d, want 1", knotClient.mergeCalls) } + if knotClient.mergeInput.DID != "did:plc:owner" || knotClient.mergeInput.Name != "example" || knotClient.mergeInput.Repo != "did:plc:repo" || knotClient.mergeInput.Branch != "master" || knotClient.mergeInput.Patch != "patch" { + t.Fatalf("MergeInput = %+v", knotClient.mergeInput) + } } func TestDownloadPullPatch(t *testing.T) { @@ -417,6 +443,7 @@ type testKnot struct { deleteErr error deleteCalls int mergeCalls int + mergeInput knot.MergeInput createCalls int } @@ -431,8 +458,9 @@ func (k *testKnot) DeleteRepo(context.Context, knot.DeleteRepoInput) error { func (k *testKnot) SetDefaultBranch(context.Context, knot.SetDefaultBranchInput) error { return k.setDefaultBranchErr } -func (k *testKnot) Merge(context.Context, knot.MergeInput) error { +func (k *testKnot) Merge(_ context.Context, input knot.MergeInput) error { k.mergeCalls++ + k.mergeInput = input return nil } diff --git a/knot/merge.go b/knot/merge.go index f992b26..c2adcfd 100644 --- a/knot/merge.go +++ b/knot/merge.go @@ -9,8 +9,15 @@ import ( // MergeInput is the argument to sh.tangled.repo.merge. type MergeInput struct { - Repo string `json:"repo"` - Pull string `json:"pull"` + DID string `json:"did"` + Name string `json:"name"` + Repo string `json:"repo,omitempty"` + Branch string `json:"branch"` + Patch string `json:"patch"` + AuthorName *string `json:"authorName,omitempty"` + AuthorEmail *string `json:"authorEmail,omitempty"` + CommitMessage *string `json:"commitMessage,omitempty"` + CommitBody *string `json:"commitBody,omitempty"` } // Merge applies a pull request on the knot. diff --git a/knot/merge_test.go b/knot/merge_test.go new file mode 100644 index 0000000..89cf285 --- /dev/null +++ b/knot/merge_test.go @@ -0,0 +1,34 @@ +package knot + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestMergePostsCurrentInput(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/xrpc/sh.tangled.repo.merge" || request.Method != http.MethodPost { + t.Fatalf("request = %s %s", request.Method, request.URL.Path) + } + var input MergeInput + if err := json.NewDecoder(request.Body).Decode(&input); err != nil { + t.Fatalf("decode input: %v", err) + } + if input.DID != "did:plc:owner" || input.Name != "tg" || input.Repo != "did:plc:repo" || input.Branch != "master" || input.Patch != "patch" { + t.Fatalf("merge input = %+v", input) + } + writer.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := NewWithClient(strings.TrimPrefix(server.URL, "https://"), "token", server.Client()) + if err := client.Merge(context.Background(), MergeInput{ + DID: "did:plc:owner", Name: "tg", Repo: "did:plc:repo", Branch: "master", Patch: "patch", + }); err != nil { + t.Fatalf("Merge() error = %v", err) + } +}