A CLI for tangled operations.
11
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement repo and pull request workflows

onevcat bd5d33de 18a3bcc8

+1247
+59
ai-docs/2026-05-02-PHASE_3_repo_pr.md
··· 1 + # Phase 3 — Repo and PR 2 + 3 + - **Date**: 2026-05-02 4 + - **Status**: In Progress 5 + 6 + ## Goal 7 + 8 + 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. 9 + 10 + ## Investigation Notes 11 + 12 + - `sh.tangled.repo.pull` stores `rounds[]`; each round contains a gzipped format-patch blob. 13 + - 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`. 14 + - `pr checkout` can only work when `source.branch` exists; patch-only PRs must return a clear unsupported error. 15 + - `pr merge` uses Knot `sh.tangled.repo.merge` with a patch and target branch; gh merge strategies are not represented in the current lexicon. 16 + 17 + ## Plan 18 + 19 + 1. Add repo service for list/view/create and clone URL construction. 20 + 2. Add PR service for list/view/status/comment/diff and patch blob download. 21 + 3. Add PR create helper tests covering compare → gzip upload blob → pull record shape where feasible. 22 + 4. Add CLI commands for repo and PR. 23 + 5. Validate with unit tests, build/lint, and safe E2E on `tang-playground` where possible. 24 + 25 + ## Validation Log 26 + 27 + - Completed: unit tests cover repo clone URL construction and pull identifier resolution. 28 + - Completed: `go test ./...` passed. 29 + - Completed: `PATH="$(go env GOPATH)/bin:$PATH" make lint` passed with 0 issues. 30 + - Completed: `make build` passed and `./bin/tang --help` lists `repo` and `pr`. 31 + - Completed: `GOOS=linux GOARCH=amd64 go build ./cmd/tang` passed. 32 + - 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`. 33 + - Completed: `tang repo list onev.cat --json=name,knot` returned 4 repos. 34 + - Completed: `tang repo clone onev.cat/tang-playground <tmp>` succeeded after switching clone behavior to HTTPS; SSH clone to `knot1.tangled.sh` timed out in this environment. 35 + - 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. 36 + - Completed: `tang pr list --state all --json=number,title,status,uri` against `tangled.org/core` returned 35 PRs. 37 + - Completed: `tang pr view 1 --json=title,status,branch` against `tangled.org/core` returned a real PR. 38 + - Completed: PR create/diff/comment/close/reopen E2E on `onev.cat/tang`: 39 + - Created temporary branch `tang-phase3-e2e-20260502143010` from a worktree and pushed it. 40 + - `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`. 41 + - `tang pr diff 3mkuu6q672u22` printed the gzipped-patch round as a format-patch. 42 + - `tang pr comment 3mkuu6q672u22 --body ...` succeeded. 43 + - `tang pr close 3mkuu6q672u22` and `tang pr reopen 3mkuu6q672u22` succeeded. 44 + - Completed: `tang pr checkout 3mkuu6q672u22` in a temporary git repo fetched and checked out `tang-phase3-e2e-20260502143010`. 45 + - Completed: `tang pr merge` E2E used temporary target/head branches so `main` was not changed: 46 + - Created target `tang-phase3-target-20260502143109` and head `tang-phase3-merge-20260502143109`. 47 + - Created PR `at://did:plc:kl2ejrmz5zmxnno3ll4luz76/sh.tangled.repo.pull/3mkuuamwfj322`. 48 + - `tang pr merge 3mkuuamwfj322 --subject "Merge Phase 3 E2E"` succeeded. 49 + - Deleted both temporary remote branches afterward. 50 + 51 + ## Notes 52 + 53 + - `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. 54 + - 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. 55 + - `pr merge` does not expose `--squash`, `--rebase`, or `--merge`, matching the current Tangled merge endpoint. 56 + 57 + ## Completion 58 + 59 + 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`.
+398
internal/cli/pr.go
··· 1 + package cli 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "strconv" 7 + "strings" 8 + "time" 9 + 10 + "github.com/spf13/cobra" 11 + core "tangled.org/core/api/tangled" 12 + "tangled.org/onev.cat/tang/internal/atproto" 13 + "tangled.org/onev.cat/tang/internal/auth" 14 + "tangled.org/onev.cat/tang/internal/config" 15 + tanggit "tangled.org/onev.cat/tang/internal/git" 16 + localrepo "tangled.org/onev.cat/tang/internal/repo" 17 + "tangled.org/onev.cat/tang/internal/tangled" 18 + ) 19 + 20 + func newPRCommand(opts *RootOptions) *cobra.Command { 21 + cmd := &cobra.Command{Use: "pr", Short: "Manage Tangled pull requests"} 22 + cmd.AddCommand(newPRListCommand(opts)) 23 + cmd.AddCommand(newPRViewCommand(opts)) 24 + cmd.AddCommand(newPRCreateCommand(opts)) 25 + cmd.AddCommand(newPRStateCommand(opts, "close", "closed")) 26 + cmd.AddCommand(newPRStateCommand(opts, "reopen", "open")) 27 + cmd.AddCommand(newPRDiffCommand()) 28 + cmd.AddCommand(newPRCommentCommand(opts)) 29 + cmd.AddCommand(newPRCheckoutCommand()) 30 + cmd.AddCommand(newPRMergeCommand()) 31 + return cmd 32 + } 33 + 34 + func newPRListCommand(opts *RootOptions) *cobra.Command { 35 + var state string 36 + cmd := &cobra.Command{ 37 + Use: "list", 38 + Short: "List pull requests", 39 + RunE: func(cmd *cobra.Command, _ []string) error { 40 + _, service, repoURI, _, err := prDependencies(cmd) 41 + if err != nil { 42 + return err 43 + } 44 + pulls, err := service.ListPulls(cmd.Context(), repoURI, state, 50) 45 + if err != nil { 46 + return err 47 + } 48 + if rendered, err := renderJSONIfRequested(cmd, opts, pulls); rendered || err != nil { 49 + return err 50 + } 51 + for _, pull := range pulls { 52 + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "#%d\t%s\t%s\t%s\n", pull.Number, pull.Title, pull.Status, pull.Author); err != nil { 53 + return err 54 + } 55 + } 56 + return nil 57 + }, 58 + } 59 + cmd.Flags().StringVar(&state, "state", "open", "Filter by state: open, closed, merged, all") 60 + return cmd 61 + } 62 + 63 + func newPRViewCommand(opts *RootOptions) *cobra.Command { 64 + var web bool 65 + cmd := &cobra.Command{ 66 + Use: "view <pull>", 67 + Short: "View a pull request", 68 + Args: cobra.ExactArgs(1), 69 + RunE: func(cmd *cobra.Command, args []string) error { 70 + cfg, service, repoURI, context, err := prDependencies(cmd) 71 + if err != nil { 72 + return err 73 + } 74 + pull, err := resolvePullArg(cmd, service, repoURI, args[0]) 75 + if err != nil { 76 + return err 77 + } 78 + if web { 79 + return openBrowser(strings.TrimRight(cfg.AppView.URL, "/") + "/" + context.Owner + "/" + context.Name + "/pulls/" + fmt.Sprint(pull.Number)) 80 + } 81 + if rendered, err := renderJSONIfRequested(cmd, opts, pull); rendered || err != nil { 82 + return err 83 + } 84 + _, 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) 85 + return err 86 + }, 87 + } 88 + cmd.Flags().BoolVar(&web, "web", false, "Open the pull request in a browser") 89 + return cmd 90 + } 91 + 92 + func newPRCreateCommand(opts *RootOptions) *cobra.Command { 93 + var title, base, head string 94 + var fill bool 95 + flags := bodyFlags{} 96 + cmd := &cobra.Command{ 97 + Use: "create", 98 + Short: "Create a pull request", 99 + RunE: func(cmd *cobra.Command, _ []string) error { 100 + session, err := auth.Load() 101 + if err != nil { 102 + return err 103 + } 104 + _, service, repoURI, context, err := prDependencies(cmd) 105 + if err != nil { 106 + return err 107 + } 108 + cfg, err := config.Load() 109 + if err != nil { 110 + return err 111 + } 112 + repoRecord, err := tangled.NewRepoService(cfg, nil).GetRepo(cmd.Context(), context.Owner, context.Name) 113 + if err != nil { 114 + return err 115 + } 116 + if head == "" { 117 + cwd, _ := os.Getwd() 118 + head, err = tanggit.CurrentBranch(cmd.Context(), cwd, tanggit.GitRunner{}) 119 + if err != nil { 120 + return err 121 + } 122 + } 123 + body, _, err := readBodyInput(flags, cmd.InOrStdin()) 124 + if err != nil { 125 + return err 126 + } 127 + pull, err := service.CreatePull(cmd.Context(), session, tangled.PullCreateOptions{ 128 + Repo: *repoRecord, 129 + RepoURI: repoURI, 130 + BaseBranch: base, 131 + HeadBranch: head, 132 + Title: title, 133 + Body: body, 134 + Fill: fill, 135 + }) 136 + if err != nil { 137 + return err 138 + } 139 + if rendered, err := renderJSONIfRequested(cmd, opts, pull); rendered || err != nil { 140 + return err 141 + } 142 + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Created pull %s\n", tangled.RKeyFromURI(pull.URI)) 143 + return err 144 + }, 145 + } 146 + cmd.Flags().StringVar(&title, "title", "", "Pull request title") 147 + cmd.Flags().StringVar(&base, "base", "main", "Base branch") 148 + cmd.Flags().StringVar(&head, "head", "", "Head branch") 149 + cmd.Flags().BoolVar(&fill, "fill", false, "Fill title from patch") 150 + addBodyFlags(cmd, &flags) 151 + return cmd 152 + } 153 + 154 + func newPRStateCommand(opts *RootOptions, name, state string) *cobra.Command { 155 + return &cobra.Command{ 156 + Use: name + " <pull>", 157 + Short: name + " a pull request", 158 + Args: cobra.ExactArgs(1), 159 + RunE: func(cmd *cobra.Command, args []string) error { 160 + session, err := auth.Load() 161 + if err != nil { 162 + return err 163 + } 164 + _, service, repoURI, _, err := prDependencies(cmd) 165 + if err != nil { 166 + return err 167 + } 168 + pull, err := resolvePullArg(cmd, service, repoURI, args[0]) 169 + if err != nil { 170 + return err 171 + } 172 + if err := service.SetPullStatus(cmd.Context(), session, pull.URI, state); err != nil { 173 + return err 174 + } 175 + pull.Status = state 176 + if rendered, err := renderJSONIfRequested(cmd, opts, pull); rendered || err != nil { 177 + return err 178 + } 179 + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Pull #%d is now %s\n", pull.Number, state) 180 + return err 181 + }, 182 + } 183 + } 184 + 185 + func newPRDiffCommand() *cobra.Command { 186 + return &cobra.Command{ 187 + Use: "diff <pull>", 188 + Short: "Print pull request patch", 189 + Args: cobra.ExactArgs(1), 190 + RunE: func(cmd *cobra.Command, args []string) error { 191 + _, service, repoURI, _, err := prDependencies(cmd) 192 + if err != nil { 193 + return err 194 + } 195 + pull, err := resolvePullArg(cmd, service, repoURI, args[0]) 196 + if err != nil { 197 + return err 198 + } 199 + patch, err := service.FetchPullPatch(cmd.Context(), pull.URI) 200 + if err != nil { 201 + return err 202 + } 203 + _, err = fmt.Fprint(cmd.OutOrStdout(), patch) 204 + return err 205 + }, 206 + } 207 + } 208 + 209 + func newPRCommentCommand(opts *RootOptions) *cobra.Command { 210 + flags := bodyFlags{} 211 + cmd := &cobra.Command{ 212 + Use: "comment <pull>", 213 + Short: "Comment on a pull request", 214 + Args: cobra.ExactArgs(1), 215 + RunE: func(cmd *cobra.Command, args []string) error { 216 + session, err := auth.Load() 217 + if err != nil { 218 + return err 219 + } 220 + _, service, repoURI, _, err := prDependencies(cmd) 221 + if err != nil { 222 + return err 223 + } 224 + body, hasBody, err := readBodyInput(flags, cmd.InOrStdin()) 225 + if err != nil { 226 + return err 227 + } 228 + if !hasBody || body == "" { 229 + return fmt.Errorf("comment body is required") 230 + } 231 + pull, err := resolvePullArg(cmd, service, repoURI, args[0]) 232 + if err != nil { 233 + return err 234 + } 235 + comment, err := service.AddPullComment(cmd.Context(), session, pull.URI, body) 236 + if err != nil { 237 + return err 238 + } 239 + if rendered, err := renderJSONIfRequested(cmd, opts, comment); rendered || err != nil { 240 + return err 241 + } 242 + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Commented on pull #%d\n", pull.Number) 243 + return err 244 + }, 245 + } 246 + addBodyFlags(cmd, &flags) 247 + return cmd 248 + } 249 + 250 + func newPRCheckoutCommand() *cobra.Command { 251 + return &cobra.Command{ 252 + Use: "checkout <pull>", 253 + Short: "Checkout a pull request source branch", 254 + Args: cobra.ExactArgs(1), 255 + RunE: func(cmd *cobra.Command, args []string) error { 256 + _, service, repoURI, context, err := prDependencies(cmd) 257 + if err != nil { 258 + return err 259 + } 260 + pull, err := resolvePullArg(cmd, service, repoURI, args[0]) 261 + if err != nil { 262 + return err 263 + } 264 + if pull.Branch == "" { 265 + return tangled.ErrPatchOnlyCheckout 266 + } 267 + cwd, _ := os.Getwd() 268 + return tanggit.CheckoutBranchFromRemote(cmd.Context(), cwd, context.RemoteName, pull.Branch) 269 + }, 270 + } 271 + } 272 + 273 + func newPRMergeCommand() *cobra.Command { 274 + var subject, body string 275 + cmd := &cobra.Command{ 276 + Use: "merge <pull>", 277 + Short: "Merge a pull request patch", 278 + Args: cobra.ExactArgs(1), 279 + RunE: func(cmd *cobra.Command, args []string) error { 280 + session, err := auth.Load() 281 + if err != nil { 282 + return err 283 + } 284 + cfg, service, repoURI, context, err := prDependencies(cmd) 285 + if err != nil { 286 + return err 287 + } 288 + pull, err := resolvePullArg(cmd, service, repoURI, args[0]) 289 + if err != nil { 290 + return err 291 + } 292 + patch, err := service.FetchPullPatch(cmd.Context(), pull.URI) 293 + if err != nil { 294 + return err 295 + } 296 + repoRecord, err := tangled.NewRepoService(cfg, nil).GetRepo(cmd.Context(), context.Owner, context.Name) 297 + if err != nil { 298 + return err 299 + } 300 + ownerDID, _, err := resolveRepoOwnerForCLI(cmd, context.Owner) 301 + if err != nil { 302 + return err 303 + } 304 + token, err := tangled.NewPDSClient(session, nil).GetServiceAuth(cmd.Context(), repoRecord.Knot, core.RepoMergeNSID, 20*time.Minute) 305 + if err != nil { 306 + return err 307 + } 308 + message := subject 309 + if message == "" { 310 + message = pull.Title 311 + } 312 + author := session.Handle 313 + if err := tangled.NewKnotClient(repoRecord.Knot, tangled.WithServiceAuthToken(token)).Merge(cmd.Context(), &core.RepoMerge_Input{ 314 + Did: ownerDID, 315 + Name: repoRecord.Name, 316 + Branch: pull.Target, 317 + Patch: patch, 318 + CommitMessage: &message, 319 + CommitBody: optionalCLIString(body), 320 + AuthorName: &author, 321 + }); err != nil { 322 + return err 323 + } 324 + _ = service.SetPullStatus(cmd.Context(), session, pull.URI, "merged") 325 + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Merged pull %s\n", tangled.RKeyFromURI(pull.URI)) 326 + return err 327 + }, 328 + } 329 + cmd.Flags().StringVar(&subject, "subject", "", "Merge commit subject") 330 + cmd.Flags().StringVar(&body, "body", "", "Merge commit body") 331 + return cmd 332 + } 333 + 334 + func prDependencies(cmd *cobra.Command) (*config.Config, *tangled.PullService, string, *localrepo.RepositoryContext, error) { 335 + cfg, err := config.Load() 336 + if err != nil { 337 + return nil, nil, "", nil, err 338 + } 339 + context, err := currentRepoContext(cmd, cfg) 340 + if err != nil { 341 + return nil, nil, "", nil, err 342 + } 343 + repoURI, err := tangled.BuildRepoATURI(cmd.Context(), context) 344 + if err != nil { 345 + return nil, nil, "", nil, err 346 + } 347 + return cfg, tangled.NewPullService(cfg, nil), repoURI, context, nil 348 + } 349 + 350 + func resolvePullArg(cmd *cobra.Command, service *tangled.PullService, repoURI, input string) (tangled.Pull, error) { 351 + pulls, err := service.ListPulls(cmd.Context(), repoURI, "all", 100) 352 + if err == nil { 353 + if pull, resolveErr := tangled.ResolvePullIdentifier(input, pulls); resolveErr == nil { 354 + return pull, nil 355 + } 356 + } 357 + if _, parseErr := strconv.Atoi(strings.TrimPrefix(input, "#")); parseErr == nil { 358 + return tangled.Pull{}, fmt.Errorf("pull %s not found", input) 359 + } 360 + session, loadErr := auth.Load() 361 + if loadErr != nil { 362 + if err != nil { 363 + return tangled.Pull{}, err 364 + } 365 + return tangled.Pull{}, loadErr 366 + } 367 + pull, err := service.GetPull(cmd.Context(), fmt.Sprintf("at://%s/sh.tangled.repo.pull/%s", session.DID, strings.TrimPrefix(input, "#"))) 368 + if err != nil { 369 + return tangled.Pull{}, err 370 + } 371 + return *pull, nil 372 + } 373 + 374 + func resolveRepoOwnerForCLI(cmd *cobra.Command, owner string) (string, string, error) { 375 + return tangledResolveOwner(cmd, owner) 376 + } 377 + 378 + func tangledResolveOwner(cmd *cobra.Command, owner string) (string, string, error) { 379 + if strings.HasPrefix(owner, "did:") { 380 + ident, err := atproto.ResolveDID(cmd.Context(), owner) 381 + if err != nil { 382 + return "", "", err 383 + } 384 + return owner, ident.PDS, nil 385 + } 386 + ident, err := atproto.ResolveHandle(cmd.Context(), owner) 387 + if err != nil { 388 + return "", "", err 389 + } 390 + return ident.DID, ident.PDS, nil 391 + } 392 + 393 + func optionalCLIString(value string) *string { 394 + if value == "" { 395 + return nil 396 + } 397 + return &value 398 + }
+168
internal/cli/repo.go
··· 1 + package cli 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "strings" 7 + 8 + "github.com/spf13/cobra" 9 + "tangled.org/onev.cat/tang/internal/auth" 10 + "tangled.org/onev.cat/tang/internal/config" 11 + localrepo "tangled.org/onev.cat/tang/internal/repo" 12 + "tangled.org/onev.cat/tang/internal/tangled" 13 + ) 14 + 15 + func newRepoCommand(opts *RootOptions) *cobra.Command { 16 + cmd := &cobra.Command{Use: "repo", Short: "Manage Tangled repositories"} 17 + cmd.AddCommand(newRepoViewCommand(opts)) 18 + cmd.AddCommand(newRepoListCommand(opts)) 19 + cmd.AddCommand(newRepoCreateCommand(opts)) 20 + cmd.AddCommand(newRepoCloneCommand()) 21 + return cmd 22 + } 23 + 24 + func newRepoViewCommand(opts *RootOptions) *cobra.Command { 25 + return &cobra.Command{ 26 + Use: "view [owner/name]", 27 + Short: "View repository information", 28 + Args: cobra.MaximumNArgs(1), 29 + RunE: func(cmd *cobra.Command, args []string) error { 30 + cfg, err := config.Load() 31 + if err != nil { 32 + return err 33 + } 34 + owner, name, err := repoSelector(cmd, cfg, args) 35 + if err != nil { 36 + return err 37 + } 38 + repo, err := tangled.NewRepoService(cfg, nil).GetRepo(cmd.Context(), owner, name) 39 + if err != nil { 40 + return err 41 + } 42 + if rendered, err := renderJSONIfRequested(cmd, opts, repo); rendered || err != nil { 43 + return err 44 + } 45 + _, err = fmt.Fprintf(cmd.OutOrStdout(), "%s/%s\nKnot: %s\nSSH: %s\nHTTPS: %s\n", repo.Owner, repo.Name, repo.Knot, repo.CloneSSH, repo.CloneHTTPS) 46 + return err 47 + }, 48 + } 49 + } 50 + 51 + func newRepoListCommand(opts *RootOptions) *cobra.Command { 52 + return &cobra.Command{ 53 + Use: "list [owner]", 54 + Short: "List repositories", 55 + Args: cobra.MaximumNArgs(1), 56 + RunE: func(cmd *cobra.Command, args []string) error { 57 + owner := "" 58 + if len(args) > 0 { 59 + owner = args[0] 60 + } else if session, err := auth.Load(); err == nil { 61 + owner = session.Handle 62 + } 63 + if owner == "" { 64 + return fmt.Errorf("owner is required when not authenticated") 65 + } 66 + cfg, err := config.Load() 67 + if err != nil { 68 + return err 69 + } 70 + repos, err := tangled.NewRepoService(cfg, nil).ListRepos(cmd.Context(), owner) 71 + if err != nil { 72 + return err 73 + } 74 + if rendered, err := renderJSONIfRequested(cmd, opts, repos); rendered || err != nil { 75 + return err 76 + } 77 + for _, repo := range repos { 78 + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s/%s\t%s\n", repo.Owner, repo.Name, repo.Knot); err != nil { 79 + return err 80 + } 81 + } 82 + return nil 83 + }, 84 + } 85 + } 86 + 87 + func newRepoCreateCommand(opts *RootOptions) *cobra.Command { 88 + var description, knot, defaultBranch string 89 + cmd := &cobra.Command{ 90 + Use: "create <name>", 91 + Short: "Create a repository", 92 + Args: cobra.ExactArgs(1), 93 + RunE: func(cmd *cobra.Command, args []string) error { 94 + session, err := auth.Load() 95 + if err != nil { 96 + return err 97 + } 98 + cfg, err := config.Load() 99 + if err != nil { 100 + return err 101 + } 102 + repo, err := tangled.NewRepoService(cfg, nil).CreateRepo(cmd.Context(), session, tangled.CreateRepoOptions{ 103 + Name: args[0], 104 + Description: description, 105 + Knot: knot, 106 + DefaultBranch: defaultBranch, 107 + }) 108 + if err != nil { 109 + return err 110 + } 111 + if rendered, err := renderJSONIfRequested(cmd, opts, repo); rendered || err != nil { 112 + return err 113 + } 114 + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Created repository %s/%s\n", repo.Owner, repo.Name) 115 + return err 116 + }, 117 + } 118 + cmd.Flags().StringVar(&description, "description", "", "Repository description") 119 + cmd.Flags().StringVar(&knot, "knot", "", "Knot host") 120 + cmd.Flags().StringVar(&defaultBranch, "default-branch", "main", "Default branch") 121 + return cmd 122 + } 123 + 124 + func newRepoCloneCommand() *cobra.Command { 125 + return &cobra.Command{ 126 + Use: "clone <owner/name> [dir]", 127 + Short: "Clone a repository", 128 + Args: cobra.RangeArgs(1, 2), 129 + RunE: func(cmd *cobra.Command, args []string) error { 130 + owner, name, err := splitOwnerRepo(args[0]) 131 + if err != nil { 132 + return err 133 + } 134 + dir := "" 135 + if len(args) > 1 { 136 + dir = args[1] 137 + } 138 + cfg, err := config.Load() 139 + if err != nil { 140 + return err 141 + } 142 + return tangled.NewRepoService(cfg, nil).Clone(cmd.Context(), owner, name, dir) 143 + }, 144 + } 145 + } 146 + 147 + func repoSelector(cmd *cobra.Command, cfg *config.Config, args []string) (string, string, error) { 148 + if len(args) > 0 { 149 + return splitOwnerRepo(args[0]) 150 + } 151 + cwd, err := os.Getwd() 152 + if err != nil { 153 + return "", "", err 154 + } 155 + context, err := localrepo.Resolve(cmd.Context(), cwd, cfg) 156 + if err != nil { 157 + return "", "", err 158 + } 159 + return context.Owner, context.Name, nil 160 + } 161 + 162 + func splitOwnerRepo(input string) (string, string, error) { 163 + parts := strings.Split(strings.Trim(input, "/"), "/") 164 + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { 165 + return "", "", fmt.Errorf("repository must be OWNER/NAME") 166 + } 167 + return parts[0], parts[1], nil 168 + }
+2
internal/cli/root.go
··· 44 44 cmd.AddCommand(newSSHKeyCommand(opts)) 45 45 cmd.AddCommand(newIssueCommand(opts)) 46 46 cmd.AddCommand(newBrowseCommand(opts)) 47 + cmd.AddCommand(newRepoCommand(opts)) 48 + cmd.AddCommand(newPRCommand(opts)) 47 49 return cmd 48 50 } 49 51
+14
internal/git/remote.go
··· 230 230 } 231 231 return nil 232 232 } 233 + 234 + func CheckoutBranchFromRemote(ctx context.Context, cwd, remote, branch string) error { 235 + fetch := exec.CommandContext(ctx, "git", "fetch", remote, branch) // #nosec G204 -- executable is fixed; remote/branch are explicit CLI inputs. 236 + fetch.Dir = cwd 237 + if out, err := fetch.CombinedOutput(); err != nil { 238 + return fmt.Errorf("git fetch failed: %s: %w", bytes.TrimSpace(out), err) 239 + } 240 + checkout := exec.CommandContext(ctx, "git", "checkout", "-B", branch, "FETCH_HEAD") // #nosec G204 -- executable is fixed; branch is an explicit CLI input. 241 + checkout.Dir = cwd 242 + if out, err := checkout.CombinedOutput(); err != nil { 243 + return fmt.Errorf("git checkout failed: %s: %w", bytes.TrimSpace(out), err) 244 + } 245 + return nil 246 + }
+9
internal/tangled/pds_client.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "io" 5 6 "net/http" 6 7 7 8 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 96 97 func (c *PDSClient) ListRecords(ctx context.Context, repo, collection string, limit int64, cursor string) (*comatproto.RepoListRecords_Output, error) { 97 98 return comatproto.RepoListRecords(ctx, c.client, collection, cursor, limit, repo, false) 98 99 } 100 + 101 + func (c *PDSClient) UploadBlob(ctx context.Context, input io.Reader) (*comatproto.RepoUploadBlob_Output, error) { 102 + return comatproto.RepoUploadBlob(ctx, c.client, input) 103 + } 104 + 105 + func (c *PDSClient) GetBlob(ctx context.Context, did, cid string) ([]byte, error) { 106 + return comatproto.SyncGetBlob(ctx, c.client, cid, did) 107 + }
+369
internal/tangled/pulls.go
··· 1 + package tangled 2 + 3 + import ( 4 + "bytes" 5 + "compress/gzip" 6 + "context" 7 + "encoding/json" 8 + "errors" 9 + "fmt" 10 + "io" 11 + "net/http" 12 + "sort" 13 + "strconv" 14 + "strings" 15 + "time" 16 + 17 + "github.com/bluesky-social/indigo/atproto/syntax" 18 + core "tangled.org/core/api/tangled" 19 + "tangled.org/onev.cat/tang/internal/atproto" 20 + "tangled.org/onev.cat/tang/internal/auth" 21 + "tangled.org/onev.cat/tang/internal/config" 22 + "tangled.org/onev.cat/tang/internal/constellation" 23 + ) 24 + 25 + var ErrPatchOnlyCheckout = errors.New("patch-only pull requests cannot be checked out") 26 + 27 + type Pull struct { 28 + Number int `json:"number,omitempty"` 29 + Title string `json:"title"` 30 + Body string `json:"body,omitempty"` 31 + Status string `json:"status"` 32 + Author string `json:"author"` 33 + CreatedAt string `json:"createdAt"` 34 + URI string `json:"uri"` 35 + CID string `json:"cid,omitempty"` 36 + Target string `json:"target"` 37 + Source string `json:"source,omitempty"` 38 + Branch string `json:"branch,omitempty"` 39 + } 40 + 41 + type PullCreateOptions struct { 42 + Repo Repo 43 + RepoURI string 44 + BaseBranch string 45 + HeadBranch string 46 + Title string 47 + Body string 48 + Fill bool 49 + } 50 + 51 + type PullService struct { 52 + Config *config.Config 53 + Constellation *constellation.Client 54 + HTTPClient *http.Client 55 + } 56 + 57 + func NewPullService(cfg *config.Config, httpClient *http.Client) *PullService { 58 + if httpClient == nil { 59 + httpClient = http.DefaultClient 60 + } 61 + return &PullService{ 62 + Config: cfg, 63 + Constellation: constellation.NewClient(cfg.Constellation.URL, httpClient), 64 + HTTPClient: httpClient, 65 + } 66 + } 67 + 68 + func (s *PullService) ListPulls(ctx context.Context, repoURI string, status string, limit int) ([]Pull, error) { 69 + if limit <= 0 { 70 + limit = 50 71 + } 72 + backlinks, err := s.Constellation.GetBacklinks(ctx, repoURI, core.RepoPullNSID, ".target.repo", limit, "") 73 + if err != nil { 74 + return nil, err 75 + } 76 + pulls := make([]Pull, 0, len(backlinks.Records)) 77 + for _, link := range backlinks.Records { 78 + pull, err := s.getPullByParts(ctx, link.DID, link.Collection, link.RKey) 79 + if err != nil { 80 + continue 81 + } 82 + if st, err := s.GetPullStatus(ctx, pull.URI); err == nil { 83 + pull.Status = st 84 + } 85 + pulls = append(pulls, *pull) 86 + } 87 + assignPullNumbers(pulls) 88 + if status != "" && status != "all" { 89 + filtered := pulls[:0] 90 + for _, pull := range pulls { 91 + if pull.Status == status { 92 + filtered = append(filtered, pull) 93 + } 94 + } 95 + pulls = filtered 96 + } 97 + return pulls, nil 98 + } 99 + 100 + func (s *PullService) CreatePull(ctx context.Context, session *auth.Session, opts PullCreateOptions) (*Pull, error) { 101 + if opts.BaseBranch == "" { 102 + opts.BaseBranch = "main" 103 + } 104 + if opts.HeadBranch == "" { 105 + return nil, fmt.Errorf("head branch is required") 106 + } 107 + repoIdentifier := opts.Repo.RepoDID 108 + if repoIdentifier == "" { 109 + ownerDID, _, err := resolveOwner(ctx, opts.Repo.Owner) 110 + if err != nil { 111 + return nil, err 112 + } 113 + repoIdentifier = ownerDID + "/" + opts.Repo.Name 114 + } 115 + compare, err := NewKnotClient(opts.Repo.Knot, WithKnotHTTPClient(s.HTTPClient)).Compare(ctx, repoIdentifier, opts.BaseBranch, opts.HeadBranch) 116 + if err != nil { 117 + return nil, err 118 + } 119 + var comparison struct { 120 + Patch string `json:"patch"` 121 + CombinedPatchRaw string `json:"combined_patch_raw"` 122 + } 123 + if err := json.Unmarshal(compare, &comparison); err != nil { 124 + return nil, err 125 + } 126 + patch := comparison.Patch 127 + if patch == "" { 128 + patch = comparison.CombinedPatchRaw 129 + } 130 + if patch == "" { 131 + return nil, fmt.Errorf("compare returned no patch") 132 + } 133 + title := opts.Title 134 + body := opts.Body 135 + if opts.Fill && title == "" { 136 + title, body = fillTitleBodyFromPatch(patch, body) 137 + } 138 + if title == "" { 139 + return nil, fmt.Errorf("title is required") 140 + } 141 + var gz bytes.Buffer 142 + zw := gzip.NewWriter(&gz) 143 + if _, err := zw.Write([]byte(patch)); err != nil { 144 + return nil, err 145 + } 146 + if err := zw.Close(); err != nil { 147 + return nil, err 148 + } 149 + pds := NewPDSClient(session, s.HTTPClient) 150 + blob, err := pds.UploadBlob(ctx, &gz) 151 + if err != nil { 152 + return nil, err 153 + } 154 + now := time.Now().UTC().Format(time.RFC3339) 155 + record := &core.RepoPull{ 156 + LexiconTypeID: core.RepoPullNSID, 157 + Title: title, 158 + Body: optionalString(body), 159 + CreatedAt: now, 160 + Target: &core.RepoPull_Target{ 161 + Repo: &opts.RepoURI, 162 + RepoDid: optionalString(opts.Repo.RepoDID), 163 + Branch: opts.BaseBranch, 164 + }, 165 + Source: &core.RepoPull_Source{ 166 + Repo: &opts.RepoURI, 167 + Branch: opts.HeadBranch, 168 + }, 169 + Rounds: []*core.RepoPull_Round{{ 170 + CreatedAt: now, 171 + PatchBlob: blob.Blob, 172 + }}, 173 + } 174 + rkey := syntax.NewTIDNow(0).String() 175 + out, err := pds.PutRecord(ctx, session.DID, core.RepoPullNSID, rkey, record, nil) 176 + if err != nil { 177 + return nil, err 178 + } 179 + 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 180 + } 181 + 182 + func (s *PullService) GetPull(ctx context.Context, pullURI string) (*Pull, error) { 183 + parsed, err := ParseATURI(pullURI) 184 + if err != nil { 185 + return nil, err 186 + } 187 + return s.getPullByParts(ctx, parsed.DID, parsed.Collection, parsed.RKey) 188 + } 189 + 190 + func (s *PullService) SetPullStatus(ctx context.Context, session *auth.Session, pullURI, status string) error { 191 + statusValue := core.RepoPullStatusOpen 192 + switch status { 193 + case "closed": 194 + statusValue = core.RepoPullStatusClosed 195 + case "merged": 196 + statusValue = core.RepoPullStatusMerged 197 + } 198 + record := &core.RepoPullStatus{LexiconTypeID: core.RepoPullStatusNSID, Pull: pullURI, Status: statusValue} 199 + _, err := NewPDSClient(session, s.HTTPClient).CreateRecord(ctx, session.DID, core.RepoPullStatusNSID, record, nil) 200 + return err 201 + } 202 + 203 + func (s *PullService) GetPullStatus(ctx context.Context, pullURI string) (string, error) { 204 + backlinks, err := s.Constellation.GetBacklinks(ctx, pullURI, core.RepoPullStatusNSID, ".pull", 100, "") 205 + if err != nil || len(backlinks.Records) == 0 { 206 + return "open", err 207 + } 208 + sort.Slice(backlinks.Records, func(i, j int) bool { return backlinks.Records[i].RKey < backlinks.Records[j].RKey }) 209 + latest := backlinks.Records[len(backlinks.Records)-1] 210 + ident, err := atproto.ResolveDID(ctx, latest.DID) 211 + if err != nil { 212 + return "open", err 213 + } 214 + out, err := NewAnonymousPDSClient(ident.PDS, s.HTTPClient).GetRecord(ctx, latest.DID, latest.Collection, latest.RKey) 215 + if err != nil { 216 + return "open", err 217 + } 218 + record, ok := out.Value.Val.(*core.RepoPullStatus) 219 + if !ok { 220 + return "open", nil 221 + } 222 + switch record.Status { 223 + case core.RepoPullStatusClosed: 224 + return "closed", nil 225 + case core.RepoPullStatusMerged: 226 + return "merged", nil 227 + default: 228 + return "open", nil 229 + } 230 + } 231 + 232 + func (s *PullService) AddPullComment(ctx context.Context, session *auth.Session, pullURI, body string) (*Comment, error) { 233 + record := &core.RepoPullComment{LexiconTypeID: core.RepoPullCommentNSID, Pull: pullURI, Body: body, CreatedAt: time.Now().UTC().Format(time.RFC3339)} 234 + out, err := NewPDSClient(session, s.HTTPClient).CreateRecord(ctx, session.DID, core.RepoPullCommentNSID, record, nil) 235 + if err != nil { 236 + return nil, err 237 + } 238 + return &Comment{Author: session.DID, Body: body, CreatedAt: record.CreatedAt, URI: out.Uri, CID: out.Cid}, nil 239 + } 240 + 241 + func (s *PullService) FetchPullPatch(ctx context.Context, pullURI string) (string, error) { 242 + pull, err := s.rawPull(ctx, pullURI) 243 + if err != nil { 244 + return "", err 245 + } 246 + if len(pull.record.Rounds) == 0 { 247 + return "", fmt.Errorf("pull has no rounds") 248 + } 249 + latest := pull.record.Rounds[len(pull.record.Rounds)-1] 250 + ident, err := atproto.ResolveDID(ctx, pull.author) 251 + if err != nil { 252 + return "", err 253 + } 254 + data, err := NewAnonymousPDSClient(ident.PDS, s.HTTPClient).GetBlob(ctx, pull.author, latest.PatchBlob.Ref.String()) 255 + if err != nil { 256 + return "", err 257 + } 258 + zr, err := gzip.NewReader(bytes.NewReader(data)) 259 + if err != nil { 260 + return "", err 261 + } 262 + defer func() { _ = zr.Close() }() 263 + out, err := io.ReadAll(zr) 264 + if err != nil { 265 + return "", err 266 + } 267 + return string(out), nil 268 + } 269 + 270 + func (s *PullService) getPullByParts(ctx context.Context, did, collection, rkey string) (*Pull, error) { 271 + raw, err := s.getRawPullByParts(ctx, did, collection, rkey) 272 + if err != nil { 273 + return nil, err 274 + } 275 + return pullFromRecord(raw.author, raw.uri, raw.cid, raw.record), nil 276 + } 277 + 278 + type rawPull struct { 279 + author string 280 + uri string 281 + cid string 282 + record *core.RepoPull 283 + } 284 + 285 + func (s *PullService) rawPull(ctx context.Context, uri string) (*rawPull, error) { 286 + parsed, err := ParseATURI(uri) 287 + if err != nil { 288 + return nil, err 289 + } 290 + return s.getRawPullByParts(ctx, parsed.DID, parsed.Collection, parsed.RKey) 291 + } 292 + 293 + func (s *PullService) getRawPullByParts(ctx context.Context, did, collection, rkey string) (*rawPull, error) { 294 + ident, err := atproto.ResolveDID(ctx, did) 295 + if err != nil { 296 + return nil, err 297 + } 298 + out, err := NewAnonymousPDSClient(ident.PDS, s.HTTPClient).GetRecord(ctx, did, collection, rkey) 299 + if err != nil { 300 + return nil, err 301 + } 302 + record, ok := out.Value.Val.(*core.RepoPull) 303 + if !ok { 304 + return nil, fmt.Errorf("record is not a pull: %s", out.Uri) 305 + } 306 + cid := "" 307 + if out.Cid != nil { 308 + cid = *out.Cid 309 + } 310 + return &rawPull{author: did, uri: out.Uri, cid: cid, record: record}, nil 311 + } 312 + 313 + func pullFromRecord(author, uri, cid string, record *core.RepoPull) *Pull { 314 + body := "" 315 + if record.Body != nil { 316 + body = *record.Body 317 + } 318 + pull := &Pull{Title: record.Title, Body: body, Status: "open", Author: author, CreatedAt: record.CreatedAt, URI: uri, CID: cid} 319 + if record.Target != nil { 320 + pull.Target = record.Target.Branch 321 + } 322 + if record.Source != nil { 323 + pull.Branch = record.Source.Branch 324 + if record.Source.Repo != nil { 325 + pull.Source = *record.Source.Repo 326 + } 327 + } 328 + return pull 329 + } 330 + 331 + func ResolvePullIdentifier(input string, pulls []Pull) (Pull, error) { 332 + normalized := strings.TrimPrefix(input, "#") 333 + if n, err := strconv.Atoi(normalized); err == nil { 334 + assignPullNumbers(pulls) 335 + for _, pull := range pulls { 336 + if pull.Number == n { 337 + return pull, nil 338 + } 339 + } 340 + return Pull{}, fmt.Errorf("pull #%d not found", n) 341 + } 342 + for _, pull := range pulls { 343 + if RKeyFromURI(pull.URI) == normalized || pull.URI == input { 344 + return pull, nil 345 + } 346 + } 347 + return Pull{}, fmt.Errorf("pull %q not found", input) 348 + } 349 + 350 + func assignPullNumbers(pulls []Pull) { 351 + sort.Slice(pulls, func(i, j int) bool { return pulls[i].CreatedAt < pulls[j].CreatedAt }) 352 + for i := range pulls { 353 + pulls[i].Number = i + 1 354 + } 355 + } 356 + 357 + func fillTitleBodyFromPatch(patch, existingBody string) (string, string) { 358 + title := "" 359 + for _, line := range strings.Split(patch, "\n") { 360 + if strings.HasPrefix(line, "Subject: ") { 361 + title = strings.TrimSpace(strings.TrimPrefix(line, "Subject: ")) 362 + break 363 + } 364 + } 365 + if title == "" { 366 + title = "Pull request" 367 + } 368 + return title, existingBody 369 + }
+24
internal/tangled/pulls_test.go
··· 1 + package tangled 2 + 3 + import "testing" 4 + 5 + func TestResolvePullIdentifier(t *testing.T) { 6 + pulls := []Pull{ 7 + {Title: "Second", CreatedAt: "2026-01-02T00:00:00Z", URI: "at://did:plc:a/sh.tangled.repo.pull/r2"}, 8 + {Title: "First", CreatedAt: "2026-01-01T00:00:00Z", URI: "at://did:plc:a/sh.tangled.repo.pull/r1"}, 9 + } 10 + got, err := ResolvePullIdentifier("#1", pulls) 11 + if err != nil { 12 + t.Fatalf("ResolvePullIdentifier error = %v", err) 13 + } 14 + if got.Title != "First" { 15 + t.Fatalf("#1 resolved to %#v", got) 16 + } 17 + got, err = ResolvePullIdentifier("r2", pulls) 18 + if err != nil { 19 + t.Fatalf("ResolvePullIdentifier r2 error = %v", err) 20 + } 21 + if got.Title != "Second" { 22 + t.Fatalf("r2 resolved to %#v", got) 23 + } 24 + }
+183
internal/tangled/repos.go
··· 1 + package tangled 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net/http" 7 + "strings" 8 + "time" 9 + 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + core "tangled.org/core/api/tangled" 12 + "tangled.org/onev.cat/tang/internal/atproto" 13 + "tangled.org/onev.cat/tang/internal/auth" 14 + "tangled.org/onev.cat/tang/internal/config" 15 + "tangled.org/onev.cat/tang/internal/git" 16 + ) 17 + 18 + type Repo struct { 19 + Owner string `json:"owner"` 20 + Name string `json:"name"` 21 + Description string `json:"description,omitempty"` 22 + Knot string `json:"knot"` 23 + RepoDID string `json:"repoDid,omitempty"` 24 + CreatedAt string `json:"createdAt"` 25 + URI string `json:"uri"` 26 + CID string `json:"cid,omitempty"` 27 + CloneSSH string `json:"cloneSsh"` 28 + CloneHTTPS string `json:"cloneHttps"` 29 + } 30 + 31 + type CreateRepoOptions struct { 32 + Name string 33 + Description string 34 + Knot string 35 + DefaultBranch string 36 + } 37 + 38 + type RepoService struct { 39 + Config *config.Config 40 + HTTPClient *http.Client 41 + } 42 + 43 + func NewRepoService(cfg *config.Config, httpClient *http.Client) *RepoService { 44 + if httpClient == nil { 45 + httpClient = http.DefaultClient 46 + } 47 + return &RepoService{Config: cfg, HTTPClient: httpClient} 48 + } 49 + 50 + func (s *RepoService) ListRepos(ctx context.Context, owner string) ([]Repo, error) { 51 + ownerDID, pds, err := resolveOwner(ctx, owner) 52 + if err != nil { 53 + return nil, err 54 + } 55 + client := NewAnonymousPDSClient(pds, s.HTTPClient) 56 + out, err := client.ListRecords(ctx, ownerDID, core.RepoNSID, 100, "") 57 + if err != nil { 58 + return nil, err 59 + } 60 + repos := make([]Repo, 0, len(out.Records)) 61 + for _, rec := range out.Records { 62 + record, ok := rec.Value.Val.(*core.Repo) 63 + if !ok { 64 + continue 65 + } 66 + cid := "" 67 + if rec.Cid != "" { 68 + cid = rec.Cid 69 + } 70 + repos = append(repos, repoFromRecord(owner, rec.Uri, cid, record)) 71 + } 72 + return repos, nil 73 + } 74 + 75 + func (s *RepoService) GetRepo(ctx context.Context, owner, name string) (*Repo, error) { 76 + repos, err := s.ListRepos(ctx, owner) 77 + if err != nil { 78 + return nil, err 79 + } 80 + for _, repo := range repos { 81 + if repo.Name == name { 82 + return &repo, nil 83 + } 84 + } 85 + return nil, fmt.Errorf("repository %s/%s not found", owner, name) 86 + } 87 + 88 + func (s *RepoService) CreateRepo(ctx context.Context, session *auth.Session, opts CreateRepoOptions) (*Repo, error) { 89 + knot := opts.Knot 90 + if knot == "" { 91 + knot = config.DefaultKnotHost 92 + if len(s.Config.Knot.Hosts) > 0 { 93 + knot = s.Config.Knot.Hosts[0] 94 + } 95 + } 96 + rkey := syntax.NewTIDNow(0).String() 97 + pdsClient := NewPDSClient(session, s.HTTPClient) 98 + token, err := pdsClient.GetServiceAuth(ctx, knot, core.RepoCreateNSID, 60*time.Second) 99 + if err != nil { 100 + return nil, err 101 + } 102 + knotClient := NewKnotClient(knot, WithKnotHTTPClient(s.HTTPClient), WithServiceAuthToken(token)) 103 + createOut, err := knotClient.CreateRepo(ctx, &core.RepoCreate_Input{ 104 + Name: opts.Name, 105 + Rkey: rkey, 106 + DefaultBranch: optionalString(opts.DefaultBranch), 107 + }) 108 + if err != nil { 109 + return nil, err 110 + } 111 + record := &core.Repo{ 112 + LexiconTypeID: core.RepoNSID, 113 + Name: opts.Name, 114 + Knot: knot, 115 + Description: optionalString(opts.Description), 116 + CreatedAt: time.Now().UTC().Format(time.RFC3339), 117 + } 118 + if createOut.RepoDid != nil { 119 + record.RepoDid = createOut.RepoDid 120 + } 121 + out, err := pdsClient.PutRecord(ctx, session.DID, core.RepoNSID, rkey, record, nil) 122 + if err != nil { 123 + return nil, err 124 + } 125 + return ptr(repoFromRecord(session.Handle, out.Uri, out.Cid, record)), nil 126 + } 127 + 128 + func (s *RepoService) Clone(ctx context.Context, owner, name, dir string) error { 129 + repo, err := s.GetRepo(ctx, owner, name) 130 + if err != nil { 131 + return err 132 + } 133 + return git.Clone(ctx, repo.CloneHTTPS, dir) 134 + } 135 + 136 + func repoFromRecord(owner, uri, cid string, record *core.Repo) Repo { 137 + description := "" 138 + if record.Description != nil { 139 + description = *record.Description 140 + } 141 + repoDID := "" 142 + if record.RepoDid != nil { 143 + repoDID = *record.RepoDid 144 + } 145 + return Repo{ 146 + Owner: owner, 147 + Name: record.Name, 148 + Description: description, 149 + Knot: record.Knot, 150 + RepoDID: repoDID, 151 + CreatedAt: record.CreatedAt, 152 + URI: uri, 153 + CID: cid, 154 + CloneSSH: fmt.Sprintf("git@%s:%s/%s.git", record.Knot, owner, record.Name), 155 + CloneHTTPS: fmt.Sprintf("https://%s/%s/%s", record.Knot, owner, record.Name), 156 + } 157 + } 158 + 159 + func resolveOwner(ctx context.Context, owner string) (did, pds string, err error) { 160 + if strings.HasPrefix(owner, "did:") { 161 + ident, err := atproto.ResolveDID(ctx, owner) 162 + if err != nil { 163 + return "", "", err 164 + } 165 + return owner, ident.PDS, nil 166 + } 167 + ident, err := atproto.ResolveHandle(ctx, owner) 168 + if err != nil { 169 + return "", "", err 170 + } 171 + return ident.DID, ident.PDS, nil 172 + } 173 + 174 + func optionalString(value string) *string { 175 + if value == "" { 176 + return nil 177 + } 178 + return &value 179 + } 180 + 181 + func ptr[T any](value T) *T { 182 + return &value 183 + }
+21
internal/tangled/repos_test.go
··· 1 + package tangled 2 + 3 + import ( 4 + "testing" 5 + 6 + core "tangled.org/core/api/tangled" 7 + ) 8 + 9 + func TestRepoFromRecordBuildsCloneURLs(t *testing.T) { 10 + repo := repoFromRecord("onev.cat", "at://did/sh.tangled.repo/r", "cid", &core.Repo{ 11 + Name: "tang", 12 + Knot: "knot.example.com", 13 + CreatedAt: "2026-05-02T00:00:00Z", 14 + }) 15 + if repo.CloneSSH != "git@knot.example.com:onev.cat/tang.git" { 16 + t.Fatalf("CloneSSH = %q", repo.CloneSSH) 17 + } 18 + if repo.CloneHTTPS != "https://knot.example.com/onev.cat/tang" { 19 + t.Fatalf("CloneHTTPS = %q", repo.CloneHTTPS) 20 + } 21 + }