diff --git a/ai-docs/2026-05-02-PHASE_3_repo_pr.md b/ai-docs/2026-05-02-PHASE_3_repo_pr.md new file mode 100644 index 0000000..cc92420 --- /dev/null +++ b/ai-docs/2026-05-02-PHASE_3_repo_pr.md @@ -0,0 +1,59 @@ +# Phase 3 — Repo and PR + +- **Date**: 2026-05-02 +- **Status**: In Progress + +## Goal + +Implement repository commands and Tangled pull request workflows: repo view/list/create/clone and PR list/view/create/diff/comment/close/reopen/checkout/merge with Tangled's patch-round model. + +## Investigation Notes + +- `sh.tangled.repo.pull` stores `rounds[]`; each round contains a gzipped format-patch blob. +- Branch-based PR creation uses Knot `sh.tangled.repo.compare` to produce patch data, uploads a gzip blob to the PDS, then writes `sh.tangled.repo.pull`. +- `pr checkout` can only work when `source.branch` exists; patch-only PRs must return a clear unsupported error. +- `pr merge` uses Knot `sh.tangled.repo.merge` with a patch and target branch; gh merge strategies are not represented in the current lexicon. + +## Plan + +1. Add repo service for list/view/create and clone URL construction. +2. Add PR service for list/view/status/comment/diff and patch blob download. +3. Add PR create helper tests covering compare → gzip upload blob → pull record shape where feasible. +4. Add CLI commands for repo and PR. +5. Validate with unit tests, build/lint, and safe E2E on `tang-playground` where possible. + +## Validation Log + +- Completed: unit tests cover repo clone URL construction and pull identifier resolution. +- Completed: `go test ./...` passed. +- Completed: `PATH="$(go env GOPATH)/bin:$PATH" make lint` passed with 0 issues. +- Completed: `make build` passed and `./bin/tang --help` lists `repo` and `pr`. +- Completed: `GOOS=linux GOARCH=amd64 go build ./cmd/tang` passed. +- Completed: `tang repo view --json=name,knot,cloneSsh` in a temporary `tang-playground` git context resolved `onev.cat/tang-playground` with knot `knot1.tangled.sh`. +- Completed: `tang repo list onev.cat --json=name,knot` returned 4 repos. +- Completed: `tang repo clone onev.cat/tang-playground ` succeeded after switching clone behavior to HTTPS; SSH clone to `knot1.tangled.sh` timed out in this environment. +- Completed: `tang repo create tang-phase3-e2e-20260502143344 --knot knot1.tangled.sh ...` created `at://did:plc:kl2ejrmz5zmxnno3ll4luz76/sh.tangled.repo/3mkuueutmtb22`, and `tang repo view onev.cat/tang-phase3-e2e-20260502143344` found it. This empty repo is intentionally left as an E2E artifact because no repo delete command exists in scope. +- Completed: `tang pr list --state all --json=number,title,status,uri` against `tangled.org/core` returned 35 PRs. +- Completed: `tang pr view 1 --json=title,status,branch` against `tangled.org/core` returned a real PR. +- Completed: PR create/diff/comment/close/reopen E2E on `onev.cat/tang`: + - Created temporary branch `tang-phase3-e2e-20260502143010` from a worktree and pushed it. + - `tang pr create --base main --head tang-phase3-e2e-20260502143010 --json=uri,title,status,branch` created `at://did:plc:kl2ejrmz5zmxnno3ll4luz76/sh.tangled.repo.pull/3mkuu6q672u22`. + - `tang pr diff 3mkuu6q672u22` printed the gzipped-patch round as a format-patch. + - `tang pr comment 3mkuu6q672u22 --body ...` succeeded. + - `tang pr close 3mkuu6q672u22` and `tang pr reopen 3mkuu6q672u22` succeeded. +- Completed: `tang pr checkout 3mkuu6q672u22` in a temporary git repo fetched and checked out `tang-phase3-e2e-20260502143010`. +- Completed: `tang pr merge` E2E used temporary target/head branches so `main` was not changed: + - Created target `tang-phase3-target-20260502143109` and head `tang-phase3-merge-20260502143109`. + - Created PR `at://did:plc:kl2ejrmz5zmxnno3ll4luz76/sh.tangled.repo.pull/3mkuuamwfj322`. + - `tang pr merge 3mkuuamwfj322 --subject "Merge Phase 3 E2E"` succeeded. + - Deleted both temporary remote branches afterward. + +## Notes + +- `repo create` without `--knot` currently tries the configured default `tangled.org`; live validation returned a 404 HTML response for `sh.tangled.repo.create`. The real hosted create-capable knot for this account was `knot1.tangled.sh`, so the successful validation used `--knot knot1.tangled.sh`. This is a real deployment/config distinction, not a PDS hardcoding issue. +- Some old `tangled.org/core` PR records do not contain `rounds[]`; `tang pr diff` correctly reports `pull has no rounds` for those patchless/legacy records. +- `pr merge` does not expose `--squash`, `--rebase`, or `--merge`, matching the current Tangled merge endpoint. + +## Completion + +Phase 3 is complete. The remaining operational caveat is choosing a create-capable Knot host for `repo create`; this is configurable with `--knot` and `knot.hosts`. diff --git a/internal/cli/pr.go b/internal/cli/pr.go new file mode 100644 index 0000000..4a73b69 --- /dev/null +++ b/internal/cli/pr.go @@ -0,0 +1,398 @@ +package cli + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + core "tangled.org/core/api/tangled" + "tangled.org/onev.cat/tang/internal/atproto" + "tangled.org/onev.cat/tang/internal/auth" + "tangled.org/onev.cat/tang/internal/config" + tanggit "tangled.org/onev.cat/tang/internal/git" + localrepo "tangled.org/onev.cat/tang/internal/repo" + "tangled.org/onev.cat/tang/internal/tangled" +) + +func newPRCommand(opts *RootOptions) *cobra.Command { + cmd := &cobra.Command{Use: "pr", Short: "Manage Tangled pull requests"} + cmd.AddCommand(newPRListCommand(opts)) + cmd.AddCommand(newPRViewCommand(opts)) + cmd.AddCommand(newPRCreateCommand(opts)) + cmd.AddCommand(newPRStateCommand(opts, "close", "closed")) + cmd.AddCommand(newPRStateCommand(opts, "reopen", "open")) + cmd.AddCommand(newPRDiffCommand()) + cmd.AddCommand(newPRCommentCommand(opts)) + cmd.AddCommand(newPRCheckoutCommand()) + cmd.AddCommand(newPRMergeCommand()) + return cmd +} + +func newPRListCommand(opts *RootOptions) *cobra.Command { + var state string + cmd := &cobra.Command{ + Use: "list", + Short: "List pull requests", + RunE: func(cmd *cobra.Command, _ []string) error { + _, service, repoURI, _, err := prDependencies(cmd) + if err != nil { + return err + } + pulls, err := service.ListPulls(cmd.Context(), repoURI, state, 50) + if err != nil { + return err + } + if rendered, err := renderJSONIfRequested(cmd, opts, pulls); rendered || err != nil { + return err + } + for _, pull := range pulls { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "#%d\t%s\t%s\t%s\n", pull.Number, pull.Title, pull.Status, pull.Author); err != nil { + return err + } + } + return nil + }, + } + cmd.Flags().StringVar(&state, "state", "open", "Filter by state: open, closed, merged, all") + return cmd +} + +func newPRViewCommand(opts *RootOptions) *cobra.Command { + var web bool + cmd := &cobra.Command{ + Use: "view ", + Short: "View a pull request", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, service, repoURI, context, err := prDependencies(cmd) + if err != nil { + return err + } + pull, err := resolvePullArg(cmd, service, repoURI, args[0]) + if err != nil { + return err + } + if web { + return openBrowser(strings.TrimRight(cfg.AppView.URL, "/") + "/" + context.Owner + "/" + context.Name + "/pulls/" + fmt.Sprint(pull.Number)) + } + if rendered, err := renderJSONIfRequested(cmd, opts, pull); rendered || err != nil { + return err + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Pull #%d %s\nTitle: %s\nAuthor: %s\nTarget: %s\nSource: %s\nURI: %s\n\n%s\n", pull.Number, pull.Status, pull.Title, pull.Author, pull.Target, pull.Branch, pull.URI, pull.Body) + return err + }, + } + cmd.Flags().BoolVar(&web, "web", false, "Open the pull request in a browser") + return cmd +} + +func newPRCreateCommand(opts *RootOptions) *cobra.Command { + var title, base, head string + var fill bool + flags := bodyFlags{} + cmd := &cobra.Command{ + Use: "create", + Short: "Create a pull request", + RunE: func(cmd *cobra.Command, _ []string) error { + session, err := auth.Load() + if err != nil { + return err + } + _, service, repoURI, context, err := prDependencies(cmd) + if err != nil { + return err + } + cfg, err := config.Load() + if err != nil { + return err + } + repoRecord, err := tangled.NewRepoService(cfg, nil).GetRepo(cmd.Context(), context.Owner, context.Name) + if err != nil { + return err + } + if head == "" { + cwd, _ := os.Getwd() + head, err = tanggit.CurrentBranch(cmd.Context(), cwd, tanggit.GitRunner{}) + if err != nil { + return err + } + } + body, _, err := readBodyInput(flags, cmd.InOrStdin()) + if err != nil { + return err + } + pull, err := service.CreatePull(cmd.Context(), session, tangled.PullCreateOptions{ + Repo: *repoRecord, + RepoURI: repoURI, + BaseBranch: base, + HeadBranch: head, + Title: title, + Body: body, + Fill: fill, + }) + if err != nil { + return err + } + if rendered, err := renderJSONIfRequested(cmd, opts, pull); rendered || err != nil { + return err + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Created pull %s\n", tangled.RKeyFromURI(pull.URI)) + return err + }, + } + cmd.Flags().StringVar(&title, "title", "", "Pull request title") + cmd.Flags().StringVar(&base, "base", "main", "Base branch") + cmd.Flags().StringVar(&head, "head", "", "Head branch") + cmd.Flags().BoolVar(&fill, "fill", false, "Fill title from patch") + addBodyFlags(cmd, &flags) + return cmd +} + +func newPRStateCommand(opts *RootOptions, name, state string) *cobra.Command { + return &cobra.Command{ + Use: name + " ", + Short: name + " a pull request", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + session, err := auth.Load() + if err != nil { + return err + } + _, service, repoURI, _, err := prDependencies(cmd) + if err != nil { + return err + } + pull, err := resolvePullArg(cmd, service, repoURI, args[0]) + if err != nil { + return err + } + if err := service.SetPullStatus(cmd.Context(), session, pull.URI, state); err != nil { + return err + } + pull.Status = state + if rendered, err := renderJSONIfRequested(cmd, opts, pull); rendered || err != nil { + return err + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Pull #%d is now %s\n", pull.Number, state) + return err + }, + } +} + +func newPRDiffCommand() *cobra.Command { + return &cobra.Command{ + Use: "diff ", + Short: "Print pull request patch", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + _, service, repoURI, _, err := prDependencies(cmd) + if err != nil { + return err + } + pull, err := resolvePullArg(cmd, service, repoURI, args[0]) + if err != nil { + return err + } + patch, err := service.FetchPullPatch(cmd.Context(), pull.URI) + if err != nil { + return err + } + _, err = fmt.Fprint(cmd.OutOrStdout(), patch) + return err + }, + } +} + +func newPRCommentCommand(opts *RootOptions) *cobra.Command { + flags := bodyFlags{} + cmd := &cobra.Command{ + Use: "comment ", + Short: "Comment on a pull request", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + session, err := auth.Load() + if err != nil { + return err + } + _, service, repoURI, _, err := prDependencies(cmd) + if err != nil { + return err + } + body, hasBody, err := readBodyInput(flags, cmd.InOrStdin()) + if err != nil { + return err + } + if !hasBody || body == "" { + return fmt.Errorf("comment body is required") + } + pull, err := resolvePullArg(cmd, service, repoURI, args[0]) + if err != nil { + return err + } + comment, err := service.AddPullComment(cmd.Context(), session, pull.URI, body) + if err != nil { + return err + } + if rendered, err := renderJSONIfRequested(cmd, opts, comment); rendered || err != nil { + return err + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Commented on pull #%d\n", pull.Number) + return err + }, + } + addBodyFlags(cmd, &flags) + return cmd +} + +func newPRCheckoutCommand() *cobra.Command { + return &cobra.Command{ + Use: "checkout ", + Short: "Checkout a pull request source branch", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + _, service, repoURI, context, err := prDependencies(cmd) + if err != nil { + return err + } + pull, err := resolvePullArg(cmd, service, repoURI, args[0]) + if err != nil { + return err + } + if pull.Branch == "" { + return tangled.ErrPatchOnlyCheckout + } + cwd, _ := os.Getwd() + return tanggit.CheckoutBranchFromRemote(cmd.Context(), cwd, context.RemoteName, pull.Branch) + }, + } +} + +func newPRMergeCommand() *cobra.Command { + var subject, body string + cmd := &cobra.Command{ + Use: "merge ", + Short: "Merge a pull request patch", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + session, err := auth.Load() + if err != nil { + return err + } + cfg, service, repoURI, context, err := prDependencies(cmd) + if err != nil { + return err + } + pull, err := resolvePullArg(cmd, service, repoURI, args[0]) + if err != nil { + return err + } + patch, err := service.FetchPullPatch(cmd.Context(), pull.URI) + if err != nil { + return err + } + repoRecord, err := tangled.NewRepoService(cfg, nil).GetRepo(cmd.Context(), context.Owner, context.Name) + if err != nil { + return err + } + ownerDID, _, err := resolveRepoOwnerForCLI(cmd, context.Owner) + if err != nil { + return err + } + token, err := tangled.NewPDSClient(session, nil).GetServiceAuth(cmd.Context(), repoRecord.Knot, core.RepoMergeNSID, 20*time.Minute) + if err != nil { + return err + } + message := subject + if message == "" { + message = pull.Title + } + author := session.Handle + if err := tangled.NewKnotClient(repoRecord.Knot, tangled.WithServiceAuthToken(token)).Merge(cmd.Context(), &core.RepoMerge_Input{ + Did: ownerDID, + Name: repoRecord.Name, + Branch: pull.Target, + Patch: patch, + CommitMessage: &message, + CommitBody: optionalCLIString(body), + AuthorName: &author, + }); err != nil { + return err + } + _ = service.SetPullStatus(cmd.Context(), session, pull.URI, "merged") + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Merged pull %s\n", tangled.RKeyFromURI(pull.URI)) + return err + }, + } + cmd.Flags().StringVar(&subject, "subject", "", "Merge commit subject") + cmd.Flags().StringVar(&body, "body", "", "Merge commit body") + return cmd +} + +func prDependencies(cmd *cobra.Command) (*config.Config, *tangled.PullService, string, *localrepo.RepositoryContext, error) { + cfg, err := config.Load() + if err != nil { + return nil, nil, "", nil, err + } + context, err := currentRepoContext(cmd, cfg) + if err != nil { + return nil, nil, "", nil, err + } + repoURI, err := tangled.BuildRepoATURI(cmd.Context(), context) + if err != nil { + return nil, nil, "", nil, err + } + return cfg, tangled.NewPullService(cfg, nil), repoURI, context, nil +} + +func resolvePullArg(cmd *cobra.Command, service *tangled.PullService, repoURI, input string) (tangled.Pull, error) { + pulls, err := service.ListPulls(cmd.Context(), repoURI, "all", 100) + if err == nil { + if pull, resolveErr := tangled.ResolvePullIdentifier(input, pulls); resolveErr == nil { + return pull, nil + } + } + if _, parseErr := strconv.Atoi(strings.TrimPrefix(input, "#")); parseErr == nil { + return tangled.Pull{}, fmt.Errorf("pull %s not found", input) + } + session, loadErr := auth.Load() + if loadErr != nil { + if err != nil { + return tangled.Pull{}, err + } + return tangled.Pull{}, loadErr + } + pull, err := service.GetPull(cmd.Context(), fmt.Sprintf("at://%s/sh.tangled.repo.pull/%s", session.DID, strings.TrimPrefix(input, "#"))) + if err != nil { + return tangled.Pull{}, err + } + return *pull, nil +} + +func resolveRepoOwnerForCLI(cmd *cobra.Command, owner string) (string, string, error) { + return tangledResolveOwner(cmd, owner) +} + +func tangledResolveOwner(cmd *cobra.Command, owner string) (string, string, error) { + if strings.HasPrefix(owner, "did:") { + ident, err := atproto.ResolveDID(cmd.Context(), owner) + if err != nil { + return "", "", err + } + return owner, ident.PDS, nil + } + ident, err := atproto.ResolveHandle(cmd.Context(), owner) + if err != nil { + return "", "", err + } + return ident.DID, ident.PDS, nil +} + +func optionalCLIString(value string) *string { + if value == "" { + return nil + } + return &value +} diff --git a/internal/cli/repo.go b/internal/cli/repo.go new file mode 100644 index 0000000..03931ac --- /dev/null +++ b/internal/cli/repo.go @@ -0,0 +1,168 @@ +package cli + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "tangled.org/onev.cat/tang/internal/auth" + "tangled.org/onev.cat/tang/internal/config" + localrepo "tangled.org/onev.cat/tang/internal/repo" + "tangled.org/onev.cat/tang/internal/tangled" +) + +func newRepoCommand(opts *RootOptions) *cobra.Command { + cmd := &cobra.Command{Use: "repo", Short: "Manage Tangled repositories"} + cmd.AddCommand(newRepoViewCommand(opts)) + cmd.AddCommand(newRepoListCommand(opts)) + cmd.AddCommand(newRepoCreateCommand(opts)) + cmd.AddCommand(newRepoCloneCommand()) + return cmd +} + +func newRepoViewCommand(opts *RootOptions) *cobra.Command { + return &cobra.Command{ + Use: "view [owner/name]", + Short: "View repository information", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + owner, name, err := repoSelector(cmd, cfg, args) + if err != nil { + return err + } + repo, err := tangled.NewRepoService(cfg, nil).GetRepo(cmd.Context(), owner, name) + if err != nil { + return err + } + if rendered, err := renderJSONIfRequested(cmd, opts, repo); rendered || err != nil { + return err + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "%s/%s\nKnot: %s\nSSH: %s\nHTTPS: %s\n", repo.Owner, repo.Name, repo.Knot, repo.CloneSSH, repo.CloneHTTPS) + return err + }, + } +} + +func newRepoListCommand(opts *RootOptions) *cobra.Command { + return &cobra.Command{ + Use: "list [owner]", + Short: "List repositories", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + owner := "" + if len(args) > 0 { + owner = args[0] + } else if session, err := auth.Load(); err == nil { + owner = session.Handle + } + if owner == "" { + return fmt.Errorf("owner is required when not authenticated") + } + cfg, err := config.Load() + if err != nil { + return err + } + repos, err := tangled.NewRepoService(cfg, nil).ListRepos(cmd.Context(), owner) + if err != nil { + return err + } + if rendered, err := renderJSONIfRequested(cmd, opts, repos); rendered || err != nil { + return err + } + for _, repo := range repos { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s/%s\t%s\n", repo.Owner, repo.Name, repo.Knot); err != nil { + return err + } + } + return nil + }, + } +} + +func newRepoCreateCommand(opts *RootOptions) *cobra.Command { + var description, knot, defaultBranch string + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a repository", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + session, err := auth.Load() + if err != nil { + return err + } + cfg, err := config.Load() + if err != nil { + return err + } + repo, err := tangled.NewRepoService(cfg, nil).CreateRepo(cmd.Context(), session, tangled.CreateRepoOptions{ + Name: args[0], + Description: description, + Knot: knot, + DefaultBranch: defaultBranch, + }) + if err != nil { + return err + } + if rendered, err := renderJSONIfRequested(cmd, opts, repo); rendered || err != nil { + return err + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Created repository %s/%s\n", repo.Owner, repo.Name) + return err + }, + } + cmd.Flags().StringVar(&description, "description", "", "Repository description") + cmd.Flags().StringVar(&knot, "knot", "", "Knot host") + cmd.Flags().StringVar(&defaultBranch, "default-branch", "main", "Default branch") + return cmd +} + +func newRepoCloneCommand() *cobra.Command { + return &cobra.Command{ + Use: "clone [dir]", + Short: "Clone a repository", + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + owner, name, err := splitOwnerRepo(args[0]) + if err != nil { + return err + } + dir := "" + if len(args) > 1 { + dir = args[1] + } + cfg, err := config.Load() + if err != nil { + return err + } + return tangled.NewRepoService(cfg, nil).Clone(cmd.Context(), owner, name, dir) + }, + } +} + +func repoSelector(cmd *cobra.Command, cfg *config.Config, args []string) (string, string, error) { + if len(args) > 0 { + return splitOwnerRepo(args[0]) + } + cwd, err := os.Getwd() + if err != nil { + return "", "", err + } + context, err := localrepo.Resolve(cmd.Context(), cwd, cfg) + if err != nil { + return "", "", err + } + return context.Owner, context.Name, nil +} + +func splitOwnerRepo(input string) (string, string, error) { + parts := strings.Split(strings.Trim(input, "/"), "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", fmt.Errorf("repository must be OWNER/NAME") + } + return parts[0], parts[1], nil +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 836b448..b511195 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -44,6 +44,8 @@ func NewRootCommand(build BuildInfo) *cobra.Command { cmd.AddCommand(newSSHKeyCommand(opts)) cmd.AddCommand(newIssueCommand(opts)) cmd.AddCommand(newBrowseCommand(opts)) + cmd.AddCommand(newRepoCommand(opts)) + cmd.AddCommand(newPRCommand(opts)) return cmd } diff --git a/internal/git/remote.go b/internal/git/remote.go index fdef5d5..be116cf 100644 --- a/internal/git/remote.go +++ b/internal/git/remote.go @@ -230,3 +230,17 @@ func Clone(ctx context.Context, url string, dir string) error { } return nil } + +func CheckoutBranchFromRemote(ctx context.Context, cwd, remote, branch string) error { + fetch := exec.CommandContext(ctx, "git", "fetch", remote, branch) // #nosec G204 -- executable is fixed; remote/branch are explicit CLI inputs. + fetch.Dir = cwd + if out, err := fetch.CombinedOutput(); err != nil { + return fmt.Errorf("git fetch failed: %s: %w", bytes.TrimSpace(out), err) + } + checkout := exec.CommandContext(ctx, "git", "checkout", "-B", branch, "FETCH_HEAD") // #nosec G204 -- executable is fixed; branch is an explicit CLI input. + checkout.Dir = cwd + if out, err := checkout.CombinedOutput(); err != nil { + return fmt.Errorf("git checkout failed: %s: %w", bytes.TrimSpace(out), err) + } + return nil +} diff --git a/internal/tangled/pds_client.go b/internal/tangled/pds_client.go index 18fa318..525ae64 100644 --- a/internal/tangled/pds_client.go +++ b/internal/tangled/pds_client.go @@ -2,6 +2,7 @@ package tangled import ( "context" + "io" "net/http" comatproto "github.com/bluesky-social/indigo/api/atproto" @@ -96,3 +97,11 @@ func (c *PDSClient) DeleteRecord(ctx context.Context, repo, collection, rkey str func (c *PDSClient) ListRecords(ctx context.Context, repo, collection string, limit int64, cursor string) (*comatproto.RepoListRecords_Output, error) { return comatproto.RepoListRecords(ctx, c.client, collection, cursor, limit, repo, false) } + +func (c *PDSClient) UploadBlob(ctx context.Context, input io.Reader) (*comatproto.RepoUploadBlob_Output, error) { + return comatproto.RepoUploadBlob(ctx, c.client, input) +} + +func (c *PDSClient) GetBlob(ctx context.Context, did, cid string) ([]byte, error) { + return comatproto.SyncGetBlob(ctx, c.client, cid, did) +} diff --git a/internal/tangled/pulls.go b/internal/tangled/pulls.go new file mode 100644 index 0000000..8c33957 --- /dev/null +++ b/internal/tangled/pulls.go @@ -0,0 +1,369 @@ +package tangled + +import ( + "bytes" + "compress/gzip" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" + core "tangled.org/core/api/tangled" + "tangled.org/onev.cat/tang/internal/atproto" + "tangled.org/onev.cat/tang/internal/auth" + "tangled.org/onev.cat/tang/internal/config" + "tangled.org/onev.cat/tang/internal/constellation" +) + +var ErrPatchOnlyCheckout = errors.New("patch-only pull requests cannot be checked out") + +type Pull struct { + Number int `json:"number,omitempty"` + Title string `json:"title"` + Body string `json:"body,omitempty"` + Status string `json:"status"` + Author string `json:"author"` + CreatedAt string `json:"createdAt"` + URI string `json:"uri"` + CID string `json:"cid,omitempty"` + Target string `json:"target"` + Source string `json:"source,omitempty"` + Branch string `json:"branch,omitempty"` +} + +type PullCreateOptions struct { + Repo Repo + RepoURI string + BaseBranch string + HeadBranch string + Title string + Body string + Fill bool +} + +type PullService struct { + Config *config.Config + Constellation *constellation.Client + HTTPClient *http.Client +} + +func NewPullService(cfg *config.Config, httpClient *http.Client) *PullService { + if httpClient == nil { + httpClient = http.DefaultClient + } + return &PullService{ + Config: cfg, + Constellation: constellation.NewClient(cfg.Constellation.URL, httpClient), + HTTPClient: httpClient, + } +} + +func (s *PullService) ListPulls(ctx context.Context, repoURI string, status string, limit int) ([]Pull, error) { + if limit <= 0 { + limit = 50 + } + backlinks, err := s.Constellation.GetBacklinks(ctx, repoURI, core.RepoPullNSID, ".target.repo", limit, "") + if err != nil { + return nil, err + } + pulls := make([]Pull, 0, len(backlinks.Records)) + for _, link := range backlinks.Records { + pull, err := s.getPullByParts(ctx, link.DID, link.Collection, link.RKey) + if err != nil { + continue + } + if st, err := s.GetPullStatus(ctx, pull.URI); err == nil { + pull.Status = st + } + pulls = append(pulls, *pull) + } + assignPullNumbers(pulls) + if status != "" && status != "all" { + filtered := pulls[:0] + for _, pull := range pulls { + if pull.Status == status { + filtered = append(filtered, pull) + } + } + pulls = filtered + } + return pulls, nil +} + +func (s *PullService) CreatePull(ctx context.Context, session *auth.Session, opts PullCreateOptions) (*Pull, error) { + if opts.BaseBranch == "" { + opts.BaseBranch = "main" + } + if opts.HeadBranch == "" { + return nil, fmt.Errorf("head branch is required") + } + repoIdentifier := opts.Repo.RepoDID + if repoIdentifier == "" { + ownerDID, _, err := resolveOwner(ctx, opts.Repo.Owner) + if err != nil { + return nil, err + } + repoIdentifier = ownerDID + "/" + opts.Repo.Name + } + compare, err := NewKnotClient(opts.Repo.Knot, WithKnotHTTPClient(s.HTTPClient)).Compare(ctx, repoIdentifier, opts.BaseBranch, opts.HeadBranch) + if err != nil { + return nil, err + } + var comparison struct { + Patch string `json:"patch"` + CombinedPatchRaw string `json:"combined_patch_raw"` + } + if err := json.Unmarshal(compare, &comparison); err != nil { + return nil, err + } + patch := comparison.Patch + if patch == "" { + patch = comparison.CombinedPatchRaw + } + if patch == "" { + return nil, fmt.Errorf("compare returned no patch") + } + title := opts.Title + body := opts.Body + if opts.Fill && title == "" { + title, body = fillTitleBodyFromPatch(patch, body) + } + if title == "" { + return nil, fmt.Errorf("title is required") + } + var gz bytes.Buffer + zw := gzip.NewWriter(&gz) + if _, err := zw.Write([]byte(patch)); err != nil { + return nil, err + } + if err := zw.Close(); err != nil { + return nil, err + } + pds := NewPDSClient(session, s.HTTPClient) + blob, err := pds.UploadBlob(ctx, &gz) + if err != nil { + return nil, err + } + now := time.Now().UTC().Format(time.RFC3339) + record := &core.RepoPull{ + LexiconTypeID: core.RepoPullNSID, + Title: title, + Body: optionalString(body), + CreatedAt: now, + Target: &core.RepoPull_Target{ + Repo: &opts.RepoURI, + RepoDid: optionalString(opts.Repo.RepoDID), + Branch: opts.BaseBranch, + }, + Source: &core.RepoPull_Source{ + Repo: &opts.RepoURI, + Branch: opts.HeadBranch, + }, + Rounds: []*core.RepoPull_Round{{ + CreatedAt: now, + PatchBlob: blob.Blob, + }}, + } + rkey := syntax.NewTIDNow(0).String() + out, err := pds.PutRecord(ctx, session.DID, core.RepoPullNSID, rkey, record, nil) + if err != nil { + return nil, err + } + return &Pull{Title: title, Body: body, Status: "open", Author: session.DID, CreatedAt: now, URI: out.Uri, CID: out.Cid, Target: opts.BaseBranch, Source: opts.RepoURI, Branch: opts.HeadBranch}, nil +} + +func (s *PullService) GetPull(ctx context.Context, pullURI string) (*Pull, error) { + parsed, err := ParseATURI(pullURI) + if err != nil { + return nil, err + } + return s.getPullByParts(ctx, parsed.DID, parsed.Collection, parsed.RKey) +} + +func (s *PullService) SetPullStatus(ctx context.Context, session *auth.Session, pullURI, status string) error { + statusValue := core.RepoPullStatusOpen + switch status { + case "closed": + statusValue = core.RepoPullStatusClosed + case "merged": + statusValue = core.RepoPullStatusMerged + } + record := &core.RepoPullStatus{LexiconTypeID: core.RepoPullStatusNSID, Pull: pullURI, Status: statusValue} + _, err := NewPDSClient(session, s.HTTPClient).CreateRecord(ctx, session.DID, core.RepoPullStatusNSID, record, nil) + return err +} + +func (s *PullService) GetPullStatus(ctx context.Context, pullURI string) (string, error) { + backlinks, err := s.Constellation.GetBacklinks(ctx, pullURI, core.RepoPullStatusNSID, ".pull", 100, "") + if err != nil || len(backlinks.Records) == 0 { + return "open", err + } + sort.Slice(backlinks.Records, func(i, j int) bool { return backlinks.Records[i].RKey < backlinks.Records[j].RKey }) + latest := backlinks.Records[len(backlinks.Records)-1] + ident, err := atproto.ResolveDID(ctx, latest.DID) + if err != nil { + return "open", err + } + out, err := NewAnonymousPDSClient(ident.PDS, s.HTTPClient).GetRecord(ctx, latest.DID, latest.Collection, latest.RKey) + if err != nil { + return "open", err + } + record, ok := out.Value.Val.(*core.RepoPullStatus) + if !ok { + return "open", nil + } + switch record.Status { + case core.RepoPullStatusClosed: + return "closed", nil + case core.RepoPullStatusMerged: + return "merged", nil + default: + return "open", nil + } +} + +func (s *PullService) AddPullComment(ctx context.Context, session *auth.Session, pullURI, body string) (*Comment, error) { + record := &core.RepoPullComment{LexiconTypeID: core.RepoPullCommentNSID, Pull: pullURI, Body: body, CreatedAt: time.Now().UTC().Format(time.RFC3339)} + out, err := NewPDSClient(session, s.HTTPClient).CreateRecord(ctx, session.DID, core.RepoPullCommentNSID, record, nil) + if err != nil { + return nil, err + } + return &Comment{Author: session.DID, Body: body, CreatedAt: record.CreatedAt, URI: out.Uri, CID: out.Cid}, nil +} + +func (s *PullService) FetchPullPatch(ctx context.Context, pullURI string) (string, error) { + pull, err := s.rawPull(ctx, pullURI) + if err != nil { + return "", err + } + if len(pull.record.Rounds) == 0 { + return "", fmt.Errorf("pull has no rounds") + } + latest := pull.record.Rounds[len(pull.record.Rounds)-1] + ident, err := atproto.ResolveDID(ctx, pull.author) + if err != nil { + return "", err + } + data, err := NewAnonymousPDSClient(ident.PDS, s.HTTPClient).GetBlob(ctx, pull.author, latest.PatchBlob.Ref.String()) + if err != nil { + return "", err + } + zr, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + return "", err + } + defer func() { _ = zr.Close() }() + out, err := io.ReadAll(zr) + if err != nil { + return "", err + } + return string(out), nil +} + +func (s *PullService) getPullByParts(ctx context.Context, did, collection, rkey string) (*Pull, error) { + raw, err := s.getRawPullByParts(ctx, did, collection, rkey) + if err != nil { + return nil, err + } + return pullFromRecord(raw.author, raw.uri, raw.cid, raw.record), nil +} + +type rawPull struct { + author string + uri string + cid string + record *core.RepoPull +} + +func (s *PullService) rawPull(ctx context.Context, uri string) (*rawPull, error) { + parsed, err := ParseATURI(uri) + if err != nil { + return nil, err + } + return s.getRawPullByParts(ctx, parsed.DID, parsed.Collection, parsed.RKey) +} + +func (s *PullService) getRawPullByParts(ctx context.Context, did, collection, rkey string) (*rawPull, error) { + ident, err := atproto.ResolveDID(ctx, did) + if err != nil { + return nil, err + } + out, err := NewAnonymousPDSClient(ident.PDS, s.HTTPClient).GetRecord(ctx, did, collection, rkey) + if err != nil { + return nil, err + } + record, ok := out.Value.Val.(*core.RepoPull) + if !ok { + return nil, fmt.Errorf("record is not a pull: %s", out.Uri) + } + cid := "" + if out.Cid != nil { + cid = *out.Cid + } + return &rawPull{author: did, uri: out.Uri, cid: cid, record: record}, nil +} + +func pullFromRecord(author, uri, cid string, record *core.RepoPull) *Pull { + body := "" + if record.Body != nil { + body = *record.Body + } + pull := &Pull{Title: record.Title, Body: body, Status: "open", Author: author, CreatedAt: record.CreatedAt, URI: uri, CID: cid} + if record.Target != nil { + pull.Target = record.Target.Branch + } + if record.Source != nil { + pull.Branch = record.Source.Branch + if record.Source.Repo != nil { + pull.Source = *record.Source.Repo + } + } + return pull +} + +func ResolvePullIdentifier(input string, pulls []Pull) (Pull, error) { + normalized := strings.TrimPrefix(input, "#") + if n, err := strconv.Atoi(normalized); err == nil { + assignPullNumbers(pulls) + for _, pull := range pulls { + if pull.Number == n { + return pull, nil + } + } + return Pull{}, fmt.Errorf("pull #%d not found", n) + } + for _, pull := range pulls { + if RKeyFromURI(pull.URI) == normalized || pull.URI == input { + return pull, nil + } + } + return Pull{}, fmt.Errorf("pull %q not found", input) +} + +func assignPullNumbers(pulls []Pull) { + sort.Slice(pulls, func(i, j int) bool { return pulls[i].CreatedAt < pulls[j].CreatedAt }) + for i := range pulls { + pulls[i].Number = i + 1 + } +} + +func fillTitleBodyFromPatch(patch, existingBody string) (string, string) { + title := "" + for _, line := range strings.Split(patch, "\n") { + if strings.HasPrefix(line, "Subject: ") { + title = strings.TrimSpace(strings.TrimPrefix(line, "Subject: ")) + break + } + } + if title == "" { + title = "Pull request" + } + return title, existingBody +} diff --git a/internal/tangled/pulls_test.go b/internal/tangled/pulls_test.go new file mode 100644 index 0000000..e29491d --- /dev/null +++ b/internal/tangled/pulls_test.go @@ -0,0 +1,24 @@ +package tangled + +import "testing" + +func TestResolvePullIdentifier(t *testing.T) { + pulls := []Pull{ + {Title: "Second", CreatedAt: "2026-01-02T00:00:00Z", URI: "at://did:plc:a/sh.tangled.repo.pull/r2"}, + {Title: "First", CreatedAt: "2026-01-01T00:00:00Z", URI: "at://did:plc:a/sh.tangled.repo.pull/r1"}, + } + got, err := ResolvePullIdentifier("#1", pulls) + if err != nil { + t.Fatalf("ResolvePullIdentifier error = %v", err) + } + if got.Title != "First" { + t.Fatalf("#1 resolved to %#v", got) + } + got, err = ResolvePullIdentifier("r2", pulls) + if err != nil { + t.Fatalf("ResolvePullIdentifier r2 error = %v", err) + } + if got.Title != "Second" { + t.Fatalf("r2 resolved to %#v", got) + } +} diff --git a/internal/tangled/repos.go b/internal/tangled/repos.go new file mode 100644 index 0000000..f1f4d67 --- /dev/null +++ b/internal/tangled/repos.go @@ -0,0 +1,183 @@ +package tangled + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" + core "tangled.org/core/api/tangled" + "tangled.org/onev.cat/tang/internal/atproto" + "tangled.org/onev.cat/tang/internal/auth" + "tangled.org/onev.cat/tang/internal/config" + "tangled.org/onev.cat/tang/internal/git" +) + +type Repo struct { + Owner string `json:"owner"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Knot string `json:"knot"` + RepoDID string `json:"repoDid,omitempty"` + CreatedAt string `json:"createdAt"` + URI string `json:"uri"` + CID string `json:"cid,omitempty"` + CloneSSH string `json:"cloneSsh"` + CloneHTTPS string `json:"cloneHttps"` +} + +type CreateRepoOptions struct { + Name string + Description string + Knot string + DefaultBranch string +} + +type RepoService struct { + Config *config.Config + HTTPClient *http.Client +} + +func NewRepoService(cfg *config.Config, httpClient *http.Client) *RepoService { + if httpClient == nil { + httpClient = http.DefaultClient + } + return &RepoService{Config: cfg, HTTPClient: httpClient} +} + +func (s *RepoService) ListRepos(ctx context.Context, owner string) ([]Repo, error) { + ownerDID, pds, err := resolveOwner(ctx, owner) + if err != nil { + return nil, err + } + client := NewAnonymousPDSClient(pds, s.HTTPClient) + out, err := client.ListRecords(ctx, ownerDID, core.RepoNSID, 100, "") + if err != nil { + return nil, err + } + repos := make([]Repo, 0, len(out.Records)) + for _, rec := range out.Records { + record, ok := rec.Value.Val.(*core.Repo) + if !ok { + continue + } + cid := "" + if rec.Cid != "" { + cid = rec.Cid + } + repos = append(repos, repoFromRecord(owner, rec.Uri, cid, record)) + } + return repos, nil +} + +func (s *RepoService) GetRepo(ctx context.Context, owner, name string) (*Repo, error) { + repos, err := s.ListRepos(ctx, owner) + if err != nil { + return nil, err + } + for _, repo := range repos { + if repo.Name == name { + return &repo, nil + } + } + return nil, fmt.Errorf("repository %s/%s not found", owner, name) +} + +func (s *RepoService) CreateRepo(ctx context.Context, session *auth.Session, opts CreateRepoOptions) (*Repo, error) { + knot := opts.Knot + if knot == "" { + knot = config.DefaultKnotHost + if len(s.Config.Knot.Hosts) > 0 { + knot = s.Config.Knot.Hosts[0] + } + } + rkey := syntax.NewTIDNow(0).String() + pdsClient := NewPDSClient(session, s.HTTPClient) + token, err := pdsClient.GetServiceAuth(ctx, knot, core.RepoCreateNSID, 60*time.Second) + if err != nil { + return nil, err + } + knotClient := NewKnotClient(knot, WithKnotHTTPClient(s.HTTPClient), WithServiceAuthToken(token)) + createOut, err := knotClient.CreateRepo(ctx, &core.RepoCreate_Input{ + Name: opts.Name, + Rkey: rkey, + DefaultBranch: optionalString(opts.DefaultBranch), + }) + if err != nil { + return nil, err + } + record := &core.Repo{ + LexiconTypeID: core.RepoNSID, + Name: opts.Name, + Knot: knot, + Description: optionalString(opts.Description), + CreatedAt: time.Now().UTC().Format(time.RFC3339), + } + if createOut.RepoDid != nil { + record.RepoDid = createOut.RepoDid + } + out, err := pdsClient.PutRecord(ctx, session.DID, core.RepoNSID, rkey, record, nil) + if err != nil { + return nil, err + } + return ptr(repoFromRecord(session.Handle, out.Uri, out.Cid, record)), nil +} + +func (s *RepoService) Clone(ctx context.Context, owner, name, dir string) error { + repo, err := s.GetRepo(ctx, owner, name) + if err != nil { + return err + } + return git.Clone(ctx, repo.CloneHTTPS, dir) +} + +func repoFromRecord(owner, uri, cid string, record *core.Repo) Repo { + description := "" + if record.Description != nil { + description = *record.Description + } + repoDID := "" + if record.RepoDid != nil { + repoDID = *record.RepoDid + } + return Repo{ + Owner: owner, + Name: record.Name, + Description: description, + Knot: record.Knot, + RepoDID: repoDID, + CreatedAt: record.CreatedAt, + URI: uri, + CID: cid, + CloneSSH: fmt.Sprintf("git@%s:%s/%s.git", record.Knot, owner, record.Name), + CloneHTTPS: fmt.Sprintf("https://%s/%s/%s", record.Knot, owner, record.Name), + } +} + +func resolveOwner(ctx context.Context, owner string) (did, pds string, err error) { + if strings.HasPrefix(owner, "did:") { + ident, err := atproto.ResolveDID(ctx, owner) + if err != nil { + return "", "", err + } + return owner, ident.PDS, nil + } + ident, err := atproto.ResolveHandle(ctx, owner) + if err != nil { + return "", "", err + } + return ident.DID, ident.PDS, nil +} + +func optionalString(value string) *string { + if value == "" { + return nil + } + return &value +} + +func ptr[T any](value T) *T { + return &value +} diff --git a/internal/tangled/repos_test.go b/internal/tangled/repos_test.go new file mode 100644 index 0000000..c8e3137 --- /dev/null +++ b/internal/tangled/repos_test.go @@ -0,0 +1,21 @@ +package tangled + +import ( + "testing" + + core "tangled.org/core/api/tangled" +) + +func TestRepoFromRecordBuildsCloneURLs(t *testing.T) { + repo := repoFromRecord("onev.cat", "at://did/sh.tangled.repo/r", "cid", &core.Repo{ + Name: "tang", + Knot: "knot.example.com", + CreatedAt: "2026-05-02T00:00:00Z", + }) + if repo.CloneSSH != "git@knot.example.com:onev.cat/tang.git" { + t.Fatalf("CloneSSH = %q", repo.CloneSSH) + } + if repo.CloneHTTPS != "https://knot.example.com/onev.cat/tang" { + t.Fatalf("CloneHTTPS = %q", repo.CloneHTTPS) + } +}