A CLI for tangled operations.
11
fork

Configure Feed

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

Implement issue workflows and browse

onevcat 79cb75c0 6206ddaf

+1170
+52
ai-docs/2026-05-02-PHASE_2_issues_browse.md
··· 1 + # Phase 2 — Issues and Browse 2 + 3 + - **Date**: 2026-05-02 4 + - **Status**: In Progress 5 + 6 + ## Goal 7 + 8 + Implement issue list/create/view/close/reopen/edit/comment and browse commands, using Constellation backlinks for cross-PDS queries and Tangled issue/state/comment records for writes. 9 + 10 + ## Investigation Notes 11 + 12 + - TS CLI uses Constellation `/links` and reads the `linking_records` response field. 13 + - `sh.tangled.repo.issue` links to a repo through `.repo`; comments and state records link to an issue through `.issue`. 14 + - Issue state uses `sh.tangled.repo.issue.state.open` and `.closed`. 15 + - Repository AT-URI cannot be guessed from owner/name; it must be found by listing the owner DID's `sh.tangled.repo` records and matching `name`. 16 + 17 + ## Plan 18 + 19 + 1. Add Constellation client tests for request shape and response mapping. 20 + 2. Add issue identifier tests for `1`, `#1`, and rkey resolution. 21 + 3. Implement AT-URI parsing and repository AT-URI resolution. 22 + 4. Implement issue service operations against PDS plus Constellation. 23 + 5. Implement body input helper and `issue` CLI commands. 24 + 6. Implement `browse` URL construction and opener command. 25 + 7. Validate with unit tests, lint/build, and a real create/comment/close/reopen flow on `onev.cat/tang-playground` or the current repo if the playground context is not locally available. 26 + 27 + ## Validation Log 28 + 29 + - Completed: unit tests cover Constellation `/links` request/response mapping, AT-URI parsing, issue identifier resolution for `1` / `#1` / rkey, body input, and browse URL construction. 30 + - Completed: `go test ./...` passed. 31 + - Completed: `PATH="$(go env GOPATH)/bin:$PATH" make lint` passed with 0 issues. 32 + - Completed: `make build` passed and `./bin/tang --help` lists `issue` and `browse`. 33 + - Completed: `GOOS=linux GOARCH=amd64 go build ./cmd/tang` passed. 34 + - Completed: real login using `~/Desktop/tang` succeeded for `onev.cat`. 35 + - Completed: real E2E in a temporary git context with remote `git@tangled.org:onev.cat/tang-playground.git`: 36 + - `tang issue create --body ... --json=uri,title,state` created `at://did:plc:kl2ejrmz5zmxnno3ll4luz76/sh.tangled.repo.issue/3mkuteffbxa2b`. 37 + - `tang issue comment 3mkuteffbxa2b --body ...` succeeded. 38 + - `tang issue close 3mkuteffbxa2b` succeeded. 39 + - `tang issue reopen 3mkuteffbxa2b` succeeded. 40 + - `tang issue view 3mkuteffbxa2b --json=issue` returned the created title and `open` state. 41 + - Completed: `tang issue list --state all --limit 5 --json=number,title,state` in `tang-playground` returned 1 indexed issue. 42 + - Completed: `tang browse` in the temporary `tang-playground` git context exited successfully and opened the repository page through the system browser. 43 + - Completed: AppView check with `curl https://tangled.org/onev.cat/tang-playground/issues/1` found the created issue title and open state. 44 + 45 + ## Notes 46 + 47 + - After `tang issue close`, an immediate AppView HTML check still showed `open`. The CLI writes `sh.tangled.repo.issue.state` records successfully, but AppView state consumption or indexing appears delayed or incomplete. The issue was reopened after this check, and the final AppView state is open. 48 + - New issue records may take a short time to appear through Constellation. CLI commands that receive a direct rkey use a session-DID fallback so create/comment/close/reopen flows can continue before indexing catches up. 49 + 50 + ## Completion 51 + 52 + Phase 2 is complete. The only uncertainty is AppView's immediate consumption of issue state records, recorded above as a protocol/AppView limitation rather than a CLI blocker.
+43
internal/cli/bodyinput.go
··· 1 + package cli 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "io" 7 + "os" 8 + ) 9 + 10 + type bodyFlags struct { 11 + body string 12 + bodyFile string 13 + } 14 + 15 + func readBodyInput(flags bodyFlags, stdin io.Reader) (string, bool, error) { 16 + count := 0 17 + if flags.body != "" { 18 + count++ 19 + } 20 + if flags.bodyFile != "" { 21 + count++ 22 + } 23 + if count > 1 { 24 + return "", false, errors.New("--body and --body-file are mutually exclusive") 25 + } 26 + if flags.body != "" { 27 + return flags.body, true, nil 28 + } 29 + if flags.bodyFile == "" { 30 + return "", false, nil 31 + } 32 + var data []byte 33 + var err error 34 + if flags.bodyFile == "-" { 35 + data, err = io.ReadAll(stdin) 36 + } else { 37 + data, err = os.ReadFile(flags.bodyFile) // #nosec G304 -- path is explicit CLI input. 38 + } 39 + if err != nil { 40 + return "", false, fmt.Errorf("read body: %w", err) 41 + } 42 + return string(data), true, nil 43 + }
+26
internal/cli/bodyinput_test.go
··· 1 + package cli 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + ) 7 + 8 + func TestReadBodyInputFromString(t *testing.T) { 9 + body, ok, err := readBodyInput(bodyFlags{body: "hello"}, strings.NewReader("")) 10 + if err != nil { 11 + t.Fatalf("readBodyInput error = %v", err) 12 + } 13 + if !ok || body != "hello" { 14 + t.Fatalf("body=%q ok=%v", body, ok) 15 + } 16 + } 17 + 18 + func TestReadBodyInputMutuallyExclusive(t *testing.T) { 19 + _, _, err := readBodyInput(bodyFlags{body: "hello", bodyFile: "-"}, strings.NewReader("stdin")) 20 + if err == nil { 21 + t.Fatal("expected error") 22 + } 23 + if err.Error() != "--body and --body-file are mutually exclusive" { 24 + t.Fatalf("error = %v", err) 25 + } 26 + }
+97
internal/cli/browse.go
··· 1 + package cli 2 + 3 + import ( 4 + "fmt" 5 + "net/url" 6 + "os/exec" 7 + "runtime" 8 + "strconv" 9 + "strings" 10 + 11 + "github.com/spf13/cobra" 12 + "tangled.org/onev.cat/tang/internal/config" 13 + "tangled.org/onev.cat/tang/internal/repo" 14 + "tangled.org/onev.cat/tang/internal/tangled" 15 + ) 16 + 17 + func newBrowseCommand(opts *RootOptions) *cobra.Command { 18 + cmd := &cobra.Command{ 19 + Use: "browse", 20 + Short: "Open Tangled pages in a browser", 21 + RunE: func(cmd *cobra.Command, _ []string) error { 22 + cfg, context, err := browseContext(cmd) 23 + if err != nil { 24 + return err 25 + } 26 + return openBrowser(repoURL(cfg, context)) 27 + }, 28 + } 29 + cmd.AddCommand(&cobra.Command{ 30 + Use: "issue <issue>", 31 + Short: "Open an issue in a browser", 32 + Args: cobra.ExactArgs(1), 33 + RunE: func(cmd *cobra.Command, args []string) error { 34 + cfg, context, err := browseContext(cmd) 35 + if err != nil { 36 + return err 37 + } 38 + service := tangled.NewIssueService(cfg, nil) 39 + repoURI, err := tangled.BuildRepoATURI(cmd.Context(), context) 40 + if err != nil { 41 + return err 42 + } 43 + issues, err := service.ListIssues(cmd.Context(), repoURI, tangled.IssueListOptions{State: "all", Limit: 100}) 44 + if err != nil { 45 + return err 46 + } 47 + issue, err := tangled.ResolveIssueIdentifier(args[0], issues) 48 + if err != nil { 49 + return err 50 + } 51 + return openBrowser(issueURL(cfg, context, issue)) 52 + }, 53 + }) 54 + _ = opts 55 + return cmd 56 + } 57 + 58 + func browseContext(cmd *cobra.Command) (*config.Config, *repo.RepositoryContext, error) { 59 + cfg, err := config.Load() 60 + if err != nil { 61 + return nil, nil, err 62 + } 63 + context, err := repo.Resolve(cmd.Context(), ".", cfg) 64 + if err != nil { 65 + return nil, nil, err 66 + } 67 + return cfg, context, nil 68 + } 69 + 70 + func repoURL(cfg *config.Config, context *repo.RepositoryContext) string { 71 + return strings.TrimRight(cfg.AppView.URL, "/") + "/" + url.PathEscape(context.Owner) + "/" + url.PathEscape(context.Name) 72 + } 73 + 74 + func issueURL(cfg *config.Config, context *repo.RepositoryContext, issue tangled.Issue) string { 75 + base := strings.TrimRight(cfg.AppView.URL, "/") 76 + number := issue.Number 77 + if number <= 0 { 78 + number = 1 79 + } 80 + return base + "/" + url.PathEscape(context.Owner) + "/" + url.PathEscape(context.Name) + "/issues/" + strconv.Itoa(number) 81 + } 82 + 83 + func openBrowser(target string) error { 84 + var cmd *exec.Cmd 85 + switch runtime.GOOS { 86 + case "darwin": 87 + cmd = exec.Command("open", target) // #nosec G204 -- browser opener is fixed; URL is a Tangled page. 88 + case "windows": 89 + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", target) // #nosec G204 -- browser opener is fixed; URL is a Tangled page. 90 + default: 91 + cmd = exec.Command("xdg-open", target) // #nosec G204 -- browser opener is fixed; URL is a Tangled page. 92 + } 93 + if err := cmd.Start(); err != nil { 94 + return fmt.Errorf("open browser: %w", err) 95 + } 96 + return nil 97 + }
+21
internal/cli/browse_test.go
··· 1 + package cli 2 + 3 + import ( 4 + "testing" 5 + 6 + "tangled.org/onev.cat/tang/internal/config" 7 + tangrepo "tangled.org/onev.cat/tang/internal/repo" 8 + "tangled.org/onev.cat/tang/internal/tangled" 9 + ) 10 + 11 + func TestBrowseURLs(t *testing.T) { 12 + cfg := config.Defaults() 13 + context := &tangrepo.RepositoryContext{Owner: "onev.cat", Name: "tang-playground"} 14 + if got := repoURL(cfg, context); got != "https://tangled.org/onev.cat/tang-playground" { 15 + t.Fatalf("repoURL = %q", got) 16 + } 17 + issue := tangled.Issue{Number: 12} 18 + if got := issueURL(cfg, context, issue); got != "https://tangled.org/onev.cat/tang-playground/issues/12" { 19 + t.Fatalf("issueURL = %q", got) 20 + } 21 + }
+312
internal/cli/issue.go
··· 1 + package cli 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "strconv" 7 + "strings" 8 + 9 + "github.com/spf13/cobra" 10 + "tangled.org/onev.cat/tang/internal/auth" 11 + "tangled.org/onev.cat/tang/internal/config" 12 + "tangled.org/onev.cat/tang/internal/repo" 13 + "tangled.org/onev.cat/tang/internal/tangled" 14 + ) 15 + 16 + func newIssueCommand(opts *RootOptions) *cobra.Command { 17 + cmd := &cobra.Command{ 18 + Use: "issue", 19 + Short: "Manage Tangled issues", 20 + } 21 + cmd.AddCommand(newIssueListCommand(opts)) 22 + cmd.AddCommand(newIssueCreateCommand(opts)) 23 + cmd.AddCommand(newIssueViewCommand(opts)) 24 + cmd.AddCommand(newIssueStateCommand(opts, "close", "closed")) 25 + cmd.AddCommand(newIssueStateCommand(opts, "reopen", "open")) 26 + cmd.AddCommand(newIssueEditCommand(opts)) 27 + cmd.AddCommand(newIssueCommentCommand(opts)) 28 + return cmd 29 + } 30 + 31 + func newIssueListCommand(opts *RootOptions) *cobra.Command { 32 + var state string 33 + var limit int 34 + cmd := &cobra.Command{ 35 + Use: "list", 36 + Short: "List issues", 37 + RunE: func(cmd *cobra.Command, _ []string) error { 38 + cfg, service, repoURI, err := issueDependencies(cmd) 39 + _ = cfg 40 + if err != nil { 41 + return err 42 + } 43 + issues, err := service.ListIssues(cmd.Context(), repoURI, tangled.IssueListOptions{State: state, Limit: limit}) 44 + if err != nil { 45 + return err 46 + } 47 + if rendered, err := renderJSONIfRequested(cmd, opts, issues); rendered || err != nil { 48 + return err 49 + } 50 + for _, issue := range issues { 51 + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "#%d\t%s\t%s\t%s\n", issue.Number, issue.Title, issue.State, issue.Author); err != nil { 52 + return err 53 + } 54 + } 55 + return nil 56 + }, 57 + } 58 + cmd.Flags().StringVar(&state, "state", "open", "Filter by state: open, closed, all") 59 + cmd.Flags().IntVar(&limit, "limit", 50, "Maximum issues to list") 60 + return cmd 61 + } 62 + 63 + func newIssueCreateCommand(opts *RootOptions) *cobra.Command { 64 + flags := bodyFlags{} 65 + cmd := &cobra.Command{ 66 + Use: "create <title>", 67 + Short: "Create an issue", 68 + Args: cobra.ExactArgs(1), 69 + RunE: func(cmd *cobra.Command, args []string) error { 70 + session, err := auth.Load() 71 + if err != nil { 72 + return err 73 + } 74 + _, service, repoURI, err := issueDependencies(cmd) 75 + if err != nil { 76 + return err 77 + } 78 + body, _, err := readBodyInput(flags, cmd.InOrStdin()) 79 + if err != nil { 80 + return err 81 + } 82 + issue, err := service.CreateIssue(cmd.Context(), session, repoURI, args[0], body) 83 + if err != nil { 84 + return err 85 + } 86 + if rendered, err := renderJSONIfRequested(cmd, opts, issue); rendered || err != nil { 87 + return err 88 + } 89 + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Created issue %s\n", tangled.RKeyFromURI(issue.URI)) 90 + return err 91 + }, 92 + } 93 + addBodyFlags(cmd, &flags) 94 + return cmd 95 + } 96 + 97 + func newIssueViewCommand(opts *RootOptions) *cobra.Command { 98 + var web bool 99 + cmd := &cobra.Command{ 100 + Use: "view <issue>", 101 + Short: "View an issue", 102 + Args: cobra.ExactArgs(1), 103 + RunE: func(cmd *cobra.Command, args []string) error { 104 + cfg, service, repoURI, err := issueDependencies(cmd) 105 + if err != nil { 106 + return err 107 + } 108 + issue, err := resolveIssueArg(cmd, service, repoURI, args[0]) 109 + if err != nil { 110 + return err 111 + } 112 + if web { 113 + context, err := currentRepoContext(cmd, cfg) 114 + if err != nil { 115 + return err 116 + } 117 + return openBrowser(issueURL(cfg, context, issue)) 118 + } 119 + comments, _ := service.ListComments(cmd.Context(), issue.URI) 120 + if rendered, err := renderJSONIfRequested(cmd, opts, map[string]any{"issue": issue, "comments": comments}); rendered || err != nil { 121 + return err 122 + } 123 + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Issue #%d %s\nTitle: %s\nAuthor: %s\nCreated: %s\nURI: %s\n\n", issue.Number, issue.State, issue.Title, issue.Author, issue.CreatedAt, issue.URI) 124 + if issue.Body != "" { 125 + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s\n\n", issue.Body) 126 + } 127 + for _, comment := range comments { 128 + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Comment by %s at %s\n%s\n\n", comment.Author, comment.CreatedAt, comment.Body); err != nil { 129 + return err 130 + } 131 + } 132 + return nil 133 + }, 134 + } 135 + cmd.Flags().BoolVar(&web, "web", false, "Open the issue in a browser") 136 + return cmd 137 + } 138 + 139 + func newIssueStateCommand(opts *RootOptions, name, state string) *cobra.Command { 140 + return &cobra.Command{ 141 + Use: name + " <issue>", 142 + Short: name + " an issue", 143 + Args: cobra.ExactArgs(1), 144 + RunE: func(cmd *cobra.Command, args []string) error { 145 + session, err := auth.Load() 146 + if err != nil { 147 + return err 148 + } 149 + _, service, repoURI, err := issueDependencies(cmd) 150 + if err != nil { 151 + return err 152 + } 153 + issue, err := resolveIssueArg(cmd, service, repoURI, args[0]) 154 + if err != nil { 155 + return err 156 + } 157 + if err := service.SetIssueState(cmd.Context(), session, issue.URI, state); err != nil { 158 + return err 159 + } 160 + issue.State = state 161 + if rendered, err := renderJSONIfRequested(cmd, opts, issue); rendered || err != nil { 162 + return err 163 + } 164 + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Issue #%d is now %s\n", issue.Number, state) 165 + return err 166 + }, 167 + } 168 + } 169 + 170 + func newIssueEditCommand(opts *RootOptions) *cobra.Command { 171 + var title string 172 + flags := bodyFlags{} 173 + cmd := &cobra.Command{ 174 + Use: "edit <issue>", 175 + Short: "Edit an issue", 176 + Args: cobra.ExactArgs(1), 177 + RunE: func(cmd *cobra.Command, args []string) error { 178 + session, err := auth.Load() 179 + if err != nil { 180 + return err 181 + } 182 + _, service, repoURI, err := issueDependencies(cmd) 183 + if err != nil { 184 + return err 185 + } 186 + body, hasBody, err := readBodyInput(flags, cmd.InOrStdin()) 187 + if err != nil { 188 + return err 189 + } 190 + if title == "" && !hasBody { 191 + return fmt.Errorf("at least one of --title, --body, or --body-file is required") 192 + } 193 + issue, err := resolveIssueArg(cmd, service, repoURI, args[0]) 194 + if err != nil { 195 + return err 196 + } 197 + updated, err := service.UpdateIssue(cmd.Context(), session, issue.URI, title, body, title != "", hasBody) 198 + if err != nil { 199 + return err 200 + } 201 + if rendered, err := renderJSONIfRequested(cmd, opts, updated); rendered || err != nil { 202 + return err 203 + } 204 + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Updated issue %s\n", tangled.RKeyFromURI(updated.URI)) 205 + return err 206 + }, 207 + } 208 + cmd.Flags().StringVar(&title, "title", "", "New title") 209 + addBodyFlags(cmd, &flags) 210 + return cmd 211 + } 212 + 213 + func newIssueCommentCommand(opts *RootOptions) *cobra.Command { 214 + flags := bodyFlags{} 215 + cmd := &cobra.Command{ 216 + Use: "comment <issue>", 217 + Short: "Add a comment to an issue", 218 + Args: cobra.ExactArgs(1), 219 + RunE: func(cmd *cobra.Command, args []string) error { 220 + session, err := auth.Load() 221 + if err != nil { 222 + return err 223 + } 224 + _, service, repoURI, err := issueDependencies(cmd) 225 + if err != nil { 226 + return err 227 + } 228 + body, hasBody, err := readBodyInput(flags, cmd.InOrStdin()) 229 + if err != nil { 230 + return err 231 + } 232 + if !hasBody || body == "" { 233 + return fmt.Errorf("comment body is required") 234 + } 235 + issue, err := resolveIssueArg(cmd, service, repoURI, args[0]) 236 + if err != nil { 237 + return err 238 + } 239 + comment, err := service.AddComment(cmd.Context(), session, issue.URI, body) 240 + if err != nil { 241 + return err 242 + } 243 + if rendered, err := renderJSONIfRequested(cmd, opts, comment); rendered || err != nil { 244 + return err 245 + } 246 + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Commented on issue #%d\n", issue.Number) 247 + return err 248 + }, 249 + } 250 + addBodyFlags(cmd, &flags) 251 + return cmd 252 + } 253 + 254 + func issueDependencies(cmd *cobra.Command) (*config.Config, *tangled.IssueService, string, error) { 255 + cfg, err := config.Load() 256 + if err != nil { 257 + return nil, nil, "", err 258 + } 259 + context, err := currentRepoContext(cmd, cfg) 260 + if err != nil { 261 + return nil, nil, "", err 262 + } 263 + repoURI, err := tangled.BuildRepoATURI(cmd.Context(), context) 264 + if err != nil { 265 + return nil, nil, "", err 266 + } 267 + return cfg, tangled.NewIssueService(cfg, nil), repoURI, nil 268 + } 269 + 270 + func currentRepoContext(cmd *cobra.Command, cfg *config.Config) (*repo.RepositoryContext, error) { 271 + cwd, err := os.Getwd() 272 + if err != nil { 273 + return nil, err 274 + } 275 + context, err := repo.Resolve(cmd.Context(), cwd, cfg) 276 + if err != nil { 277 + return nil, err 278 + } 279 + return context, nil 280 + } 281 + 282 + func addBodyFlags(cmd *cobra.Command, flags *bodyFlags) { 283 + cmd.Flags().StringVar(&flags.body, "body", "", "Body text") 284 + cmd.Flags().StringVarP(&flags.bodyFile, "body-file", "F", "", "Read body from file, or '-' for stdin") 285 + } 286 + 287 + func resolveIssueArg(cmd *cobra.Command, service *tangled.IssueService, repoURI, input string) (tangled.Issue, error) { 288 + issues, err := service.ListIssues(cmd.Context(), repoURI, tangled.IssueListOptions{State: "all", Limit: 100}) 289 + if err == nil { 290 + if issue, resolveErr := tangled.ResolveIssueIdentifier(input, issues); resolveErr == nil { 291 + return issue, nil 292 + } 293 + } 294 + if _, parseErr := strconv.Atoi(strings.TrimPrefix(input, "#")); parseErr == nil { 295 + if err != nil { 296 + return tangled.Issue{}, err 297 + } 298 + return tangled.Issue{}, fmt.Errorf("issue %s not found", input) 299 + } 300 + session, loadErr := auth.Load() 301 + if loadErr != nil { 302 + if err != nil { 303 + return tangled.Issue{}, err 304 + } 305 + return tangled.Issue{}, loadErr 306 + } 307 + issue, err := service.GetIssue(cmd.Context(), fmt.Sprintf("at://%s/sh.tangled.repo.issue/%s", session.DID, strings.TrimPrefix(input, "#"))) 308 + if err != nil { 309 + return tangled.Issue{}, err 310 + } 311 + return *issue, nil 312 + }
+2
internal/cli/root.go
··· 42 42 cmd.AddCommand(newConfigCommand(opts)) 43 43 cmd.AddCommand(newStatusCommand(opts)) 44 44 cmd.AddCommand(newSSHKeyCommand(opts)) 45 + cmd.AddCommand(newIssueCommand(opts)) 46 + cmd.AddCommand(newBrowseCommand(opts)) 45 47 return cmd 46 48 } 47 49
+89
internal/constellation/client.go
··· 1 + package constellation 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "net/http" 8 + "net/url" 9 + "os" 10 + "strconv" 11 + "strings" 12 + 13 + "tangled.org/onev.cat/tang/internal/config" 14 + ) 15 + 16 + type Record struct { 17 + DID string `json:"did"` 18 + Collection string `json:"collection"` 19 + RKey string `json:"rkey"` 20 + } 21 + 22 + type Result struct { 23 + Total int `json:"total"` 24 + Records []Record `json:"records"` 25 + Cursor string `json:"cursor,omitempty"` 26 + } 27 + 28 + type Client struct { 29 + BaseURL string 30 + HTTPClient *http.Client 31 + } 32 + 33 + func NewClient(baseURL string, httpClient *http.Client) *Client { 34 + if env := os.Getenv("TANG_CONSTELLATION_URL"); env != "" { 35 + baseURL = env 36 + } 37 + if baseURL == "" { 38 + baseURL = config.DefaultConstellationURL 39 + } 40 + if httpClient == nil { 41 + httpClient = http.DefaultClient 42 + } 43 + return &Client{BaseURL: strings.TrimRight(baseURL, "/"), HTTPClient: httpClient} 44 + } 45 + 46 + func (c *Client) GetBacklinks(ctx context.Context, target, collection, path string, limit int, cursor string) (*Result, error) { 47 + u, err := url.Parse(c.BaseURL + "/links") 48 + if err != nil { 49 + return nil, err 50 + } 51 + query := u.Query() 52 + query.Set("target", target) 53 + query.Set("collection", collection) 54 + query.Set("path", path) 55 + query.Set("limit", strconv.Itoa(limit)) 56 + if cursor != "" { 57 + query.Set("cursor", cursor) 58 + } 59 + u.RawQuery = query.Encode() 60 + 61 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) 62 + if err != nil { 63 + return nil, err 64 + } 65 + resp, err := c.HTTPClient.Do(req) 66 + if err != nil { 67 + return nil, err 68 + } 69 + defer func() { 70 + _ = resp.Body.Close() 71 + }() 72 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { 73 + return nil, fmt.Errorf("constellation API error: %s", resp.Status) 74 + } 75 + 76 + var wire struct { 77 + Total int `json:"total"` 78 + LinkingRecords []Record `json:"linking_records"` 79 + Cursor *string `json:"cursor"` 80 + } 81 + if err := json.NewDecoder(resp.Body).Decode(&wire); err != nil { 82 + return nil, err 83 + } 84 + result := &Result{Total: wire.Total, Records: wire.LinkingRecords} 85 + if wire.Cursor != nil { 86 + result.Cursor = *wire.Cursor 87 + } 88 + return result, nil 89 + }
+64
internal/constellation/client_test.go
··· 1 + package constellation 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "net/http" 7 + "net/http/httptest" 8 + "testing" 9 + ) 10 + 11 + func TestGetBacklinksMapsLinkingRecords(t *testing.T) { 12 + var gotQuery string 13 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 14 + gotQuery = r.URL.RawQuery 15 + _ = json.NewEncoder(w).Encode(map[string]any{ 16 + "total": 1, 17 + "linking_records": []map[string]string{{ 18 + "did": "did:plc:test", 19 + "collection": "sh.tangled.repo.issue", 20 + "rkey": "abc", 21 + }}, 22 + "cursor": "next", 23 + }) 24 + })) 25 + defer server.Close() 26 + 27 + client := NewClient(server.URL, server.Client()) 28 + result, err := client.GetBacklinks(context.Background(), "at://did/repo/rkey", "sh.tangled.repo.issue", ".repo", 10, "cur") 29 + if err != nil { 30 + t.Fatalf("GetBacklinks error = %v", err) 31 + } 32 + if result.Total != 1 || result.Cursor != "next" || result.Records[0].RKey != "abc" { 33 + t.Fatalf("result = %#v", result) 34 + } 35 + for _, want := range []string{"target=at%3A%2F%2Fdid%2Frepo%2Frkey", "collection=sh.tangled.repo.issue", "path=.repo", "limit=10", "cursor=cur"} { 36 + if !containsQuery(gotQuery, want) { 37 + t.Fatalf("query %q missing %q", gotQuery, want) 38 + } 39 + } 40 + } 41 + 42 + func containsQuery(query, part string) bool { 43 + for _, item := range splitQuery(query) { 44 + if item == part { 45 + return true 46 + } 47 + } 48 + return false 49 + } 50 + 51 + func splitQuery(query string) []string { 52 + if query == "" { 53 + return nil 54 + } 55 + var parts []string 56 + start := 0 57 + for i, ch := range query { 58 + if ch == '&' { 59 + parts = append(parts, query[start:i]) 60 + start = i + 1 61 + } 62 + } 63 + return append(parts, query[start:]) 64 + }
+71
internal/tangled/aturi.go
··· 1 + package tangled 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "net/http" 8 + "regexp" 9 + "strings" 10 + 11 + core "tangled.org/core/api/tangled" 12 + "tangled.org/onev.cat/tang/internal/atproto" 13 + "tangled.org/onev.cat/tang/internal/repo" 14 + ) 15 + 16 + var ErrInvalidATURI = errors.New("invalid AT-URI") 17 + 18 + type ATURI struct { 19 + DID string 20 + Collection string 21 + RKey string 22 + } 23 + 24 + func ParseATURI(uri string) (ATURI, error) { 25 + match := aturiPattern.FindStringSubmatch(uri) 26 + if match == nil { 27 + return ATURI{}, fmt.Errorf("%w: %s", ErrInvalidATURI, uri) 28 + } 29 + return ATURI{DID: match[1], Collection: match[2], RKey: match[3]}, nil 30 + } 31 + 32 + func BuildRepoATURI(ctx context.Context, context *repo.RepositoryContext) (string, error) { 33 + ownerDID := context.Owner 34 + pds := "" 35 + if !strings.HasPrefix(ownerDID, "did:") { 36 + ident, err := atproto.ResolveHandle(ctx, ownerDID) 37 + if err != nil { 38 + return "", err 39 + } 40 + ownerDID = ident.DID 41 + pds = ident.PDS 42 + } else { 43 + ident, err := atproto.ResolveDID(ctx, ownerDID) 44 + if err != nil { 45 + return "", err 46 + } 47 + pds = ident.PDS 48 + } 49 + client := NewAnonymousPDSClient(pds, http.DefaultClient) 50 + records, err := client.ListRecords(ctx, ownerDID, core.RepoNSID, 100, "") 51 + if err != nil { 52 + return "", err 53 + } 54 + for _, record := range records.Records { 55 + value, ok := record.Value.Val.(*core.Repo) 56 + if ok && value.Name == context.Name { 57 + return record.Uri, nil 58 + } 59 + } 60 + return "", fmt.Errorf("repository %s not found for %s", context.Name, context.Owner) 61 + } 62 + 63 + func RKeyFromURI(uri string) string { 64 + parts := strings.Split(uri, "/") 65 + if len(parts) == 0 { 66 + return uri 67 + } 68 + return parts[len(parts)-1] 69 + } 70 + 71 + var aturiPattern = regexp.MustCompile(`^at://(did:[a-z]+:[a-zA-Z0-9._:%-]+)/([a-zA-Z0-9._-]+(?:\.[a-zA-Z0-9._-]+)*)(?:/([a-zA-Z0-9._-]+))?$`)
+23
internal/tangled/aturi_test.go
··· 1 + package tangled 2 + 3 + import ( 4 + "errors" 5 + "testing" 6 + ) 7 + 8 + func TestParseATURI(t *testing.T) { 9 + got, err := ParseATURI("at://did:plc:abc/sh.tangled.repo.issue/3abc") 10 + if err != nil { 11 + t.Fatalf("ParseATURI error = %v", err) 12 + } 13 + if got.DID != "did:plc:abc" || got.Collection != "sh.tangled.repo.issue" || got.RKey != "3abc" { 14 + t.Fatalf("ATURI = %#v", got) 15 + } 16 + } 17 + 18 + func TestParseATURIRejectsInvalid(t *testing.T) { 19 + _, err := ParseATURI("https://example.com") 20 + if !errors.Is(err, ErrInvalidATURI) { 21 + t.Fatalf("ParseATURI error = %v", err) 22 + } 23 + }
+323
internal/tangled/issues.go
··· 1 + package tangled 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net/http" 7 + "sort" 8 + "strconv" 9 + "strings" 10 + "time" 11 + 12 + core "tangled.org/core/api/tangled" 13 + "tangled.org/onev.cat/tang/internal/atproto" 14 + "tangled.org/onev.cat/tang/internal/auth" 15 + "tangled.org/onev.cat/tang/internal/config" 16 + "tangled.org/onev.cat/tang/internal/constellation" 17 + ) 18 + 19 + type Issue struct { 20 + Number int `json:"number,omitempty"` 21 + Title string `json:"title"` 22 + Body string `json:"body,omitempty"` 23 + Repo string `json:"repo,omitempty"` 24 + State string `json:"state"` 25 + Author string `json:"author"` 26 + CreatedAt string `json:"createdAt"` 27 + URI string `json:"uri"` 28 + CID string `json:"cid"` 29 + } 30 + 31 + type Comment struct { 32 + Author string `json:"author"` 33 + Body string `json:"body"` 34 + CreatedAt string `json:"createdAt"` 35 + URI string `json:"uri"` 36 + CID string `json:"cid"` 37 + } 38 + 39 + type IssueListOptions struct { 40 + State string 41 + Limit int 42 + Cursor string 43 + } 44 + 45 + type IssueService struct { 46 + Constellation *constellation.Client 47 + HTTPClient *http.Client 48 + } 49 + 50 + func NewIssueService(cfg *config.Config, httpClient *http.Client) *IssueService { 51 + if httpClient == nil { 52 + httpClient = http.DefaultClient 53 + } 54 + return &IssueService{ 55 + Constellation: constellation.NewClient(cfg.Constellation.URL, httpClient), 56 + HTTPClient: httpClient, 57 + } 58 + } 59 + 60 + func (s *IssueService) ListIssues(ctx context.Context, repoURI string, opts IssueListOptions) ([]Issue, error) { 61 + limit := opts.Limit 62 + if limit <= 0 { 63 + limit = 50 64 + } 65 + backlinks, err := s.Constellation.GetBacklinks(ctx, repoURI, core.RepoIssueNSID, ".repo", limit, opts.Cursor) 66 + if err != nil { 67 + return nil, err 68 + } 69 + issues := make([]Issue, 0, len(backlinks.Records)) 70 + for _, link := range backlinks.Records { 71 + issue, err := s.getIssueByParts(ctx, link.DID, link.Collection, link.RKey) 72 + if err != nil { 73 + continue 74 + } 75 + state, err := s.GetIssueState(ctx, issue.URI) 76 + if err == nil { 77 + issue.State = state 78 + } 79 + issues = append(issues, *issue) 80 + } 81 + assignIssueNumbers(issues) 82 + if opts.State != "" && opts.State != "all" { 83 + filtered := issues[:0] 84 + for _, issue := range issues { 85 + if issue.State == opts.State { 86 + filtered = append(filtered, issue) 87 + } 88 + } 89 + issues = filtered 90 + } 91 + return issues, nil 92 + } 93 + 94 + func (s *IssueService) CreateIssue(ctx context.Context, session *auth.Session, repoURI, title, body string) (*Issue, error) { 95 + var bodyPtr *string 96 + if body != "" { 97 + bodyPtr = &body 98 + } 99 + record := &core.RepoIssue{ 100 + LexiconTypeID: core.RepoIssueNSID, 101 + Repo: &repoURI, 102 + Title: title, 103 + Body: bodyPtr, 104 + CreatedAt: time.Now().UTC().Format(time.RFC3339), 105 + } 106 + client := NewPDSClient(session, s.HTTPClient) 107 + out, err := client.CreateRecord(ctx, session.DID, core.RepoIssueNSID, record, nil) 108 + if err != nil { 109 + return nil, err 110 + } 111 + cid := "" 112 + if out.Cid != "" { 113 + cid = out.Cid 114 + } 115 + return &Issue{Title: title, Body: body, Repo: repoURI, State: "open", Author: session.DID, CreatedAt: record.CreatedAt, URI: out.Uri, CID: cid}, nil 116 + } 117 + 118 + func (s *IssueService) GetIssue(ctx context.Context, issueURI string) (*Issue, error) { 119 + parsed, err := ParseATURI(issueURI) 120 + if err != nil { 121 + return nil, err 122 + } 123 + return s.getIssueByParts(ctx, parsed.DID, parsed.Collection, parsed.RKey) 124 + } 125 + 126 + func (s *IssueService) UpdateIssue(ctx context.Context, session *auth.Session, issueURI, title, body string, updateTitle, updateBody bool) (*Issue, error) { 127 + current, err := s.GetIssue(ctx, issueURI) 128 + if err != nil { 129 + return nil, err 130 + } 131 + parsed, err := ParseATURI(issueURI) 132 + if err != nil { 133 + return nil, err 134 + } 135 + if parsed.DID != session.DID { 136 + return nil, fmt.Errorf("cannot edit issue authored by %s", parsed.DID) 137 + } 138 + if !updateTitle { 139 + title = current.Title 140 + } 141 + if !updateBody { 142 + body = current.Body 143 + } 144 + var bodyPtr *string 145 + if body != "" { 146 + bodyPtr = &body 147 + } 148 + record := &core.RepoIssue{ 149 + LexiconTypeID: core.RepoIssueNSID, 150 + Title: title, 151 + Body: bodyPtr, 152 + CreatedAt: current.CreatedAt, 153 + } 154 + if current.Repo != "" { 155 + record.Repo = &current.Repo 156 + } 157 + client := NewPDSClient(session, s.HTTPClient) 158 + var swap *string 159 + if current.CID != "" { 160 + swap = &current.CID 161 + } 162 + out, err := client.PutRecord(ctx, session.DID, core.RepoIssueNSID, parsed.RKey, record, swap) 163 + if err != nil { 164 + return nil, err 165 + } 166 + current.Title = title 167 + current.Body = body 168 + current.CID = out.Cid 169 + return current, nil 170 + } 171 + 172 + func (s *IssueService) SetIssueState(ctx context.Context, session *auth.Session, issueURI, state string) error { 173 + recordState := core.RepoIssueStateOpen 174 + if state == "closed" { 175 + recordState = core.RepoIssueStateClosed 176 + } 177 + record := &core.RepoIssueState{ 178 + LexiconTypeID: core.RepoIssueStateNSID, 179 + Issue: issueURI, 180 + State: recordState, 181 + } 182 + client := NewPDSClient(session, s.HTTPClient) 183 + _, err := client.CreateRecord(ctx, session.DID, core.RepoIssueStateNSID, record, nil) 184 + return err 185 + } 186 + 187 + func (s *IssueService) GetIssueState(ctx context.Context, issueURI string) (string, error) { 188 + backlinks, err := s.Constellation.GetBacklinks(ctx, issueURI, core.RepoIssueStateNSID, ".issue", 100, "") 189 + if err != nil { 190 + return "open", err 191 + } 192 + if len(backlinks.Records) == 0 { 193 + return "open", nil 194 + } 195 + sort.Slice(backlinks.Records, func(i, j int) bool { 196 + return backlinks.Records[i].RKey < backlinks.Records[j].RKey 197 + }) 198 + latest := backlinks.Records[len(backlinks.Records)-1] 199 + ident, err := atproto.ResolveDID(ctx, latest.DID) 200 + if err != nil { 201 + return "open", err 202 + } 203 + client := NewAnonymousPDSClient(ident.PDS, s.HTTPClient) 204 + out, err := client.GetRecord(ctx, latest.DID, latest.Collection, latest.RKey) 205 + if err != nil { 206 + return "open", err 207 + } 208 + record, ok := out.Value.Val.(*core.RepoIssueState) 209 + if !ok { 210 + return "open", nil 211 + } 212 + if record.State == core.RepoIssueStateClosed { 213 + return "closed", nil 214 + } 215 + return "open", nil 216 + } 217 + 218 + func (s *IssueService) ListComments(ctx context.Context, issueURI string) ([]Comment, error) { 219 + backlinks, err := s.Constellation.GetBacklinks(ctx, issueURI, core.RepoIssueCommentNSID, ".issue", 100, "") 220 + if err != nil { 221 + return nil, err 222 + } 223 + comments := make([]Comment, 0, len(backlinks.Records)) 224 + for _, link := range backlinks.Records { 225 + ident, err := atproto.ResolveDID(ctx, link.DID) 226 + if err != nil { 227 + continue 228 + } 229 + client := NewAnonymousPDSClient(ident.PDS, s.HTTPClient) 230 + out, err := client.GetRecord(ctx, link.DID, link.Collection, link.RKey) 231 + if err != nil { 232 + continue 233 + } 234 + record, ok := out.Value.Val.(*core.RepoIssueComment) 235 + if !ok { 236 + continue 237 + } 238 + cid := "" 239 + if out.Cid != nil { 240 + cid = *out.Cid 241 + } 242 + comments = append(comments, Comment{Author: link.DID, Body: record.Body, CreatedAt: record.CreatedAt, URI: out.Uri, CID: cid}) 243 + } 244 + sort.Slice(comments, func(i, j int) bool { 245 + return comments[i].CreatedAt < comments[j].CreatedAt 246 + }) 247 + return comments, nil 248 + } 249 + 250 + func (s *IssueService) AddComment(ctx context.Context, session *auth.Session, issueURI, body string) (*Comment, error) { 251 + record := &core.RepoIssueComment{ 252 + LexiconTypeID: core.RepoIssueCommentNSID, 253 + Issue: issueURI, 254 + Body: body, 255 + CreatedAt: time.Now().UTC().Format(time.RFC3339), 256 + } 257 + client := NewPDSClient(session, s.HTTPClient) 258 + out, err := client.CreateRecord(ctx, session.DID, core.RepoIssueCommentNSID, record, nil) 259 + if err != nil { 260 + return nil, err 261 + } 262 + return &Comment{Author: session.DID, Body: body, CreatedAt: record.CreatedAt, URI: out.Uri, CID: out.Cid}, nil 263 + } 264 + 265 + func ResolveIssueIdentifier(input string, issues []Issue) (Issue, error) { 266 + normalized := strings.TrimPrefix(input, "#") 267 + if n, err := strconv.Atoi(normalized); err == nil { 268 + if n < 1 { 269 + return Issue{}, fmt.Errorf("issue number must be greater than 0") 270 + } 271 + assignIssueNumbers(issues) 272 + for _, issue := range issues { 273 + if issue.Number == n { 274 + return issue, nil 275 + } 276 + } 277 + return Issue{}, fmt.Errorf("issue #%d not found", n) 278 + } 279 + for _, issue := range issues { 280 + if RKeyFromURI(issue.URI) == normalized || issue.URI == input { 281 + return issue, nil 282 + } 283 + } 284 + return Issue{}, fmt.Errorf("issue %q not found", input) 285 + } 286 + 287 + func (s *IssueService) getIssueByParts(ctx context.Context, did, collection, rkey string) (*Issue, error) { 288 + ident, err := atproto.ResolveDID(ctx, did) 289 + if err != nil { 290 + return nil, err 291 + } 292 + client := NewAnonymousPDSClient(ident.PDS, s.HTTPClient) 293 + out, err := client.GetRecord(ctx, did, collection, rkey) 294 + if err != nil { 295 + return nil, err 296 + } 297 + record, ok := out.Value.Val.(*core.RepoIssue) 298 + if !ok { 299 + return nil, fmt.Errorf("record is not an issue: %s", out.Uri) 300 + } 301 + body := "" 302 + if record.Body != nil { 303 + body = *record.Body 304 + } 305 + cid := "" 306 + if out.Cid != nil { 307 + cid = *out.Cid 308 + } 309 + repoURI := "" 310 + if record.Repo != nil { 311 + repoURI = *record.Repo 312 + } 313 + return &Issue{Title: record.Title, Body: body, Repo: repoURI, State: "open", Author: did, CreatedAt: record.CreatedAt, URI: out.Uri, CID: cid}, nil 314 + } 315 + 316 + func assignIssueNumbers(issues []Issue) { 317 + sort.Slice(issues, func(i, j int) bool { 318 + return issues[i].CreatedAt < issues[j].CreatedAt 319 + }) 320 + for i := range issues { 321 + issues[i].Number = i + 1 322 + } 323 + }
+31
internal/tangled/issues_test.go
··· 1 + package tangled 2 + 3 + import "testing" 4 + 5 + func TestResolveIssueIdentifierNumberHashAndRKey(t *testing.T) { 6 + issues := []Issue{ 7 + {Title: "Second", CreatedAt: "2026-01-02T00:00:00Z", URI: "at://did:plc:a/sh.tangled.repo.issue/r2"}, 8 + {Title: "First", CreatedAt: "2026-01-01T00:00:00Z", URI: "at://did:plc:a/sh.tangled.repo.issue/r1"}, 9 + } 10 + got, err := ResolveIssueIdentifier("#1", issues) 11 + if err != nil { 12 + t.Fatalf("ResolveIssueIdentifier #1 error = %v", err) 13 + } 14 + if got.Title != "First" { 15 + t.Fatalf("#1 resolved to %#v", got) 16 + } 17 + got, err = ResolveIssueIdentifier("2", issues) 18 + if err != nil { 19 + t.Fatalf("ResolveIssueIdentifier 2 error = %v", err) 20 + } 21 + if got.Title != "Second" { 22 + t.Fatalf("2 resolved to %#v", got) 23 + } 24 + got, err = ResolveIssueIdentifier("r1", issues) 25 + if err != nil { 26 + t.Fatalf("ResolveIssueIdentifier r1 error = %v", err) 27 + } 28 + if got.Title != "First" { 29 + t.Fatalf("r1 resolved to %#v", got) 30 + } 31 + }
+16
internal/tangled/pds_client.go
··· 68 68 }) 69 69 } 70 70 71 + func (c *PDSClient) PutRecord(ctx context.Context, repo, collection, rkey string, record cbg.CBORMarshaler, swapRecord *string) (*comatproto.RepoPutRecord_Output, error) { 72 + validate := false 73 + return comatproto.RepoPutRecord(ctx, c.client, &comatproto.RepoPutRecord_Input{ 74 + Repo: repo, 75 + Collection: collection, 76 + Rkey: rkey, 77 + Record: &util.LexiconTypeDecoder{Val: record}, 78 + SwapRecord: swapRecord, 79 + Validate: &validate, 80 + }) 81 + } 82 + 83 + func (c *PDSClient) GetRecord(ctx context.Context, repo, collection, rkey string) (*comatproto.RepoGetRecord_Output, error) { 84 + return comatproto.RepoGetRecord(ctx, c.client, "", collection, repo, rkey) 85 + } 86 + 71 87 func (c *PDSClient) DeleteRecord(ctx context.Context, repo, collection, rkey string) error { 72 88 _, err := comatproto.RepoDeleteRecord(ctx, c.client, &comatproto.RepoDeleteRecord_Input{ 73 89 Repo: repo,