A terminal client for Tangled.
tui go tangled cli
8

Configure Feed

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

cli,tangled: unify issue and pull request list/view using shared helpers

Aly Raffauf (Jul 10, 2026, 4:03 PM EDT) a85428fb 53be7bf9

+280 -372
+5 -103
internal/cli/issue_list.go
··· 1 1 package cli 2 2 3 3 import ( 4 - "context" 5 - "encoding/json" 6 4 "fmt" 7 - "strings" 8 5 9 - "github.com/alyraffauf/tg/internal/gitutil" 10 6 "github.com/alyraffauf/tg/tangled" 11 7 "github.com/spf13/cobra" 12 8 ) ··· 32 28 return err 33 29 } 34 30 35 - issues, err := client.ListIssues(ctx, repoDid, tangled.IssueListOpts{ 31 + issues, err := client.ListIssues(ctx, repoDid, tangled.ListOpts{ 36 32 Limit: defaultListLimit, 37 33 }) 38 34 if err != nil { 39 35 return fmt.Errorf("list issues for %q: %w", repo, err) 40 36 } 41 37 42 - items := buildIssueItems(ctx, issues.Items) 43 - return output(items, renderIssueList) 44 - }, 45 - } 46 - 47 - func parseHandleRepo(arg string) (string, string, error) { 48 - parts := strings.SplitN(arg, "/", 2) 49 - if len(parts) != 2 || parts[0] == "" || parts[1] == "" { 50 - return "", "", fmt.Errorf("expected handle/repo, got %q", arg) 51 - } 52 - return parts[0], parts[1], nil 53 - } 54 - 55 - // resolveTarget returns the handle and repo name from an explicit 56 - // "handle/repo" argument or by detecting the git remote in the CWD. 57 - func resolveTarget(ctx context.Context, args []string) (string, string, error) { 58 - if len(args) == 1 { 59 - return parseHandleRepo(args[0]) 60 - } 61 - 62 - rc, err := gitutil.DetectRepoFromCWD(ctx) 63 - if err != nil { 64 - return "", "", fmt.Errorf("detect repo from current directory: %w", err) 65 - } 66 - return rc.Handle, rc.Repo, nil 67 - } 68 - 69 - // findRepoDid resolves handle/repo to the repo's repoDid, which listIssues is 70 - // keyed by. It looks the record up directly by name (current schema uses the 71 - // name as the rkey), falling back to a listing for legacy repos whose rkey is a 72 - // TID with the name in the body. 73 - func findRepoDid(ctx context.Context, handle, repo string) (string, error) { 74 - ident, err := resolver.ResolveHandle(ctx, handle) 75 - if err != nil { 76 - return "", fmt.Errorf("resolve handle %q: %w", handle, err) 77 - } 78 - 79 - repoURI := fmt.Sprintf("at://%s/sh.tangled.repo/%s", ident.DID, repo) 80 - if got, err := client.GetRepo(ctx, repoURI); err == nil { 81 - return got.Value.RepoDid, nil 82 - } 83 - 84 - if repos, err := client.ListRepos(ctx, ident.DID.String()); err == nil { 85 - for _, item := range repos.Items { 86 - if item.Value.Name == repo || strings.HasSuffix(item.URI, "/"+repo) { 87 - return item.Value.RepoDid, nil 88 - } 89 - } 90 - } 91 - 92 - return "", fmt.Errorf("repo %q not found for handle %q", repo, handle) 93 - } 94 - 95 - func buildIssueItems(ctx context.Context, items []tangled.IssueListItem) []issueItem { 96 - result := make([]issueItem, 0, len(items)) 97 - 98 - for _, item := range items { 99 - var record tangled.IssueRecord 100 - if err := json.Unmarshal(item.Value, &record); err != nil { 101 - continue 102 - } 103 - 104 - updated := item.StateUpdatedAt 105 - if updated == "" { 106 - updated = record.CreatedAt 107 - } 108 - 109 - title := record.Title 110 - if title == "" { 111 - title = "(no title)" 112 - } 113 - 114 - result = append(result, issueItem{ 115 - Rkey: extractRKey(item.URI), 116 - URI: item.URI, 117 - Title: title, 118 - State: item.State, 119 - Author: resolveAuthor(ctx, extractDID(item.URI)), 120 - CreatedAt: record.CreatedAt, 121 - UpdatedAt: updated, 122 - CommentCount: item.CommentCount, 38 + items := buildItems(ctx, issues.Items, decodeIssue) 39 + return output(items, func(items []item) { 40 + renderList(items, "No issues found.") 123 41 }) 124 - } 125 - 126 - return result 127 - } 128 - 129 - func renderIssueList(items []issueItem) { 130 - rows := make([]listRow, 0, len(items)) 131 - for _, item := range items { 132 - rows = append(rows, listRow{ 133 - rkey: item.Rkey, 134 - title: item.Title, 135 - state: item.State, 136 - author: item.Author.Handle, 137 - updated: shortDate(item.UpdatedAt), 138 - }) 139 - } 140 - renderRows(rows, "No issues found.") 42 + }, 141 43 }
+12 -24
internal/cli/issue_view.go
··· 1 1 package cli 2 2 3 3 import ( 4 - "encoding/json" 5 4 "fmt" 6 - "strings" 7 5 8 6 "github.com/alyraffauf/tg/tangled" 9 7 "github.com/spf13/cobra" ··· 37 35 return err 38 36 } 39 37 40 - issues, err := client.ListIssues(ctx, repoDid, tangled.IssueListOpts{ 38 + issues, err := client.ListIssues(ctx, repoDid, tangled.ListOpts{ 41 39 Limit: defaultListLimit, 42 40 }) 43 41 if err != nil { 44 42 return fmt.Errorf("list issues for %s/%s: %w", handle, repo, err) 45 43 } 46 44 47 - issue, authorDID, err := findIssueByRKey(issues.Items, rkey) 45 + found, err := findByRKey(issues.Items, rkey, "issue") 48 46 if err != nil { 49 47 return err 48 + } 49 + decoded, err := decodeIssue(found.Value) 50 + if err != nil { 51 + return fmt.Errorf("decode issue %q: %w", rkey, err) 50 52 } 51 53 52 - result := issueViewResult{ 54 + result := viewResult{ 53 55 Rkey: rkey, 54 - Title: issue.Title, 55 - Body: issue.Body, 56 - Author: resolveAuthor(ctx, authorDID), 57 - CreatedAt: issue.CreatedAt, 56 + Title: decoded.Title, 57 + Body: decoded.Body, 58 + Author: resolveAuthor(ctx, extractDID(found.URI)), 59 + CreatedAt: decoded.CreatedAt, 58 60 } 59 - return output(result, func(view issueViewResult) { 61 + return output(result, func(view viewResult) { 60 62 fmt.Printf("Title: %s\n", view.Title) 61 63 fmt.Printf("Author: %s\n", view.Author.Handle) 62 64 fmt.Printf("Created: %s\n", view.CreatedAt) ··· 70 72 func init() { 71 73 issueViewCmd.Flags().StringVarP(&issueViewRepo, "repo", "R", "", "Target repository as handle/repo") 72 74 } 73 - 74 - func findIssueByRKey(items []tangled.IssueListItem, rkey string) (*tangled.IssueRecord, string, error) { 75 - for _, item := range items { 76 - if !strings.HasSuffix(item.URI, "/"+rkey) { 77 - continue 78 - } 79 - var issue tangled.IssueRecord 80 - if err := json.Unmarshal(item.Value, &issue); err != nil { 81 - return nil, "", fmt.Errorf("decode issue %q: %w", rkey, err) 82 - } 83 - return &issue, extractDID(item.URI), nil 84 - } 85 - return nil, "", fmt.Errorf("issue %q not found", rkey) 86 - }
+8 -23
internal/cli/output.go
··· 22 22 Handle string `json:"handle"` 23 23 } 24 24 25 - type issueItem struct { 26 - Rkey string `json:"rkey"` 27 - URI string `json:"uri"` 28 - Title string `json:"title"` 29 - State string `json:"state"` 30 - Author author `json:"author"` 31 - CreatedAt string `json:"createdAt"` 32 - UpdatedAt string `json:"updatedAt,omitempty"` 33 - CommentCount int64 `json:"commentCount"` 34 - } 35 - 36 - type pullItem struct { 25 + // item is a listing entry for an issue or a pull request. SourceBranch and 26 + // TargetBranch are only populated (and only emitted as JSON) for pulls. 27 + type item struct { 37 28 Rkey string `json:"rkey"` 38 29 URI string `json:"uri"` 39 30 Title string `json:"title"` ··· 43 34 UpdatedAt string `json:"updatedAt,omitempty"` 44 35 CommentCount int64 `json:"commentCount"` 45 36 SourceBranch string `json:"sourceBranch,omitempty"` 46 - TargetBranch string `json:"targetBranch"` 37 + TargetBranch string `json:"targetBranch,omitempty"` 47 38 } 48 39 49 40 type repoItem struct { ··· 63 54 URI string `json:"uri"` 64 55 } 65 56 66 - type issueViewResult struct { 67 - Rkey string `json:"rkey"` 68 - Title string `json:"title"` 69 - Body string `json:"body,omitempty"` 70 - Author author `json:"author"` 71 - CreatedAt string `json:"createdAt"` 72 - } 73 - 74 - type prViewResult struct { 57 + // viewResult is a single issue or pull request. SourceBranch and 58 + // TargetBranch are only populated (and only emitted as JSON) for pulls. 59 + type viewResult struct { 75 60 Rkey string `json:"rkey"` 76 61 Title string `json:"title"` 77 62 Body string `json:"body,omitempty"` 78 63 Author author `json:"author"` 79 64 CreatedAt string `json:"createdAt"` 80 65 SourceBranch string `json:"sourceBranch,omitempty"` 81 - TargetBranch string `json:"targetBranch"` 66 + TargetBranch string `json:"targetBranch,omitempty"` 82 67 } 83 68 84 69 type repoCreateResult struct {
+8 -18
internal/cli/pr_checkout.go
··· 4 4 "encoding/json" 5 5 "fmt" 6 6 "os" 7 - "strings" 8 7 9 8 "github.com/alyraffauf/tg/internal/gitutil" 10 9 "github.com/alyraffauf/tg/tangled" ··· 34 33 return err 35 34 } 36 35 37 - pulls, err := client.ListPulls(ctx, repoDid, tangled.PullListOpts{ 36 + pulls, err := client.ListPulls(ctx, repoDid, tangled.ListOpts{ 38 37 Limit: defaultListLimit, 39 38 }) 40 39 if err != nil { 41 40 return fmt.Errorf("list pulls for %q: %w", repo, err) 42 41 } 43 42 44 - pr, authorDID, err := findPullByRKey(pulls.Items, prRKey) 43 + found, err := findByRKey(pulls.Items, prRKey, "pull request") 45 44 if err != nil { 46 45 return err 47 46 } 47 + var pr tangled.PullRecord 48 + if err := json.Unmarshal(found.Value, &pr); err != nil { 49 + return fmt.Errorf("decode pull request %q: %w", prRKey, err) 50 + } 51 + authorDID := extractDID(found.URI) 52 + 48 53 if len(pr.Rounds) == 0 { 49 54 return fmt.Errorf("pull request %q has no rounds", prRKey) 50 55 } ··· 87 92 }) 88 93 }, 89 94 } 90 - 91 - func findPullByRKey(items []tangled.PullListItem, rkey string) (*tangled.PullRecord, string, error) { 92 - for _, item := range items { 93 - if !strings.HasSuffix(item.URI, "/"+rkey) { 94 - continue 95 - } 96 - 97 - var pr tangled.PullRecord 98 - if err := json.Unmarshal(item.Value, &pr); err != nil { 99 - return nil, "", fmt.Errorf("decode pull record %q: %w", rkey, err) 100 - } 101 - return &pr, extractDID(item.URI), nil 102 - } 103 - return nil, "", fmt.Errorf("pull request %q not found", rkey) 104 - }
+5 -55
internal/cli/pr_list.go
··· 1 1 package cli 2 2 3 3 import ( 4 - "context" 5 - "encoding/json" 6 4 "fmt" 7 5 8 6 "github.com/alyraffauf/tg/tangled" ··· 30 28 return err 31 29 } 32 30 33 - pulls, err := client.ListPulls(ctx, repoDid, tangled.PullListOpts{ 31 + pulls, err := client.ListPulls(ctx, repoDid, tangled.ListOpts{ 34 32 Limit: defaultListLimit, 35 33 }) 36 34 if err != nil { 37 35 return fmt.Errorf("list PRs for %q: %w", repo, err) 38 36 } 39 37 40 - items := buildPullItems(ctx, pulls.Items) 41 - return output(items, renderPullList) 42 - }, 43 - } 44 - 45 - func buildPullItems(ctx context.Context, items []tangled.PullListItem) []pullItem { 46 - result := make([]pullItem, 0, len(items)) 47 - 48 - for _, item := range items { 49 - var record tangled.PullRecord 50 - if err := json.Unmarshal(item.Value, &record); err != nil { 51 - continue 52 - } 53 - 54 - updated := item.StateUpdatedAt 55 - if updated == "" { 56 - updated = record.CreatedAt 57 - } 58 - 59 - title := record.Title 60 - if title == "" { 61 - title = "(no title)" 62 - } 63 - 64 - result = append(result, pullItem{ 65 - Rkey: extractRKey(item.URI), 66 - URI: item.URI, 67 - Title: title, 68 - State: item.State, 69 - Author: resolveAuthor(ctx, extractDID(item.URI)), 70 - CreatedAt: record.CreatedAt, 71 - UpdatedAt: updated, 72 - CommentCount: item.CommentCount, 73 - SourceBranch: record.Source.Branch, 74 - TargetBranch: record.Target.Branch, 75 - }) 76 - } 77 - 78 - return result 79 - } 80 - 81 - func renderPullList(items []pullItem) { 82 - rows := make([]listRow, 0, len(items)) 83 - for _, item := range items { 84 - rows = append(rows, listRow{ 85 - rkey: item.Rkey, 86 - title: item.Title, 87 - state: item.State, 88 - author: item.Author.Handle, 89 - updated: shortDate(item.UpdatedAt), 38 + items := buildItems(ctx, pulls.Items, decodePull) 39 + return output(items, func(items []item) { 40 + renderList(items, "No pull requests found.") 90 41 }) 91 - } 92 - renderRows(rows, "No pull requests found.") 42 + }, 93 43 }
+14 -10
internal/cli/pr_view.go
··· 35 35 return err 36 36 } 37 37 38 - pulls, err := client.ListPulls(ctx, repoDid, tangled.PullListOpts{ 38 + pulls, err := client.ListPulls(ctx, repoDid, tangled.ListOpts{ 39 39 Limit: defaultListLimit, 40 40 }) 41 41 if err != nil { 42 42 return fmt.Errorf("list PRs for %s/%s: %w", handle, repo, err) 43 43 } 44 44 45 - pr, authorDID, err := findPullByRKey(pulls.Items, rkey) 45 + found, err := findByRKey(pulls.Items, rkey, "pull request") 46 46 if err != nil { 47 47 return err 48 48 } 49 + decoded, err := decodePull(found.Value) 50 + if err != nil { 51 + return fmt.Errorf("decode pull request %q: %w", rkey, err) 52 + } 49 53 50 - result := prViewResult{ 54 + result := viewResult{ 51 55 Rkey: rkey, 52 - Title: pr.Title, 53 - Body: pr.Body, 54 - Author: resolveAuthor(ctx, authorDID), 55 - CreatedAt: pr.CreatedAt, 56 - SourceBranch: pr.Source.Branch, 57 - TargetBranch: pr.Target.Branch, 56 + Title: decoded.Title, 57 + Body: decoded.Body, 58 + Author: resolveAuthor(ctx, extractDID(found.URI)), 59 + CreatedAt: decoded.CreatedAt, 60 + SourceBranch: decoded.SourceBranch, 61 + TargetBranch: decoded.TargetBranch, 58 62 } 59 - return output(result, func(view prViewResult) { 63 + return output(result, func(view viewResult) { 60 64 fmt.Printf("Title: %s\n", view.Title) 61 65 fmt.Printf("Author: %s\n", view.Author.Handle) 62 66 fmt.Printf("Created: %s\n", view.CreatedAt)
+13 -22
internal/cli/repo_list.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 - "os" 7 6 "strings" 8 - "text/tabwriter" 9 7 10 8 "github.com/alyraffauf/tg/internal/gitutil" 11 9 "github.com/alyraffauf/tg/tangled" ··· 76 74 func buildRepoItems(items []tangled.Repo, author string) []repoItem { 77 75 result := make([]repoItem, 0, len(items)) 78 76 79 - for _, item := range items { 80 - name := item.Value.Name 77 + for _, tangledRepo := range items { 78 + name := tangledRepo.Value.Name 81 79 if name == "" { 82 80 // Fall back to the rkey segment of the at:// URI. 83 - if idx := strings.LastIndex(item.URI, "/"); idx != -1 { 84 - name = item.URI[idx+1:] 81 + if idx := strings.LastIndex(tangledRepo.URI, "/"); idx != -1 { 82 + name = tangledRepo.URI[idx+1:] 85 83 } 86 84 } 87 85 88 86 result = append(result, repoItem{ 89 87 Name: name, 90 - URI: item.URI, 88 + URI: tangledRepo.URI, 91 89 Author: author, 92 - Knot: item.Value.Knot, 93 - Description: item.Value.Description, 94 - CreatedAt: item.Value.CreatedAt, 95 - RepoDid: item.Value.RepoDid, 90 + Knot: tangledRepo.Value.Knot, 91 + Description: tangledRepo.Value.Description, 92 + CreatedAt: tangledRepo.Value.CreatedAt, 93 + RepoDid: tangledRepo.Value.RepoDid, 96 94 }) 97 95 } 98 96 ··· 100 98 } 101 99 102 100 func renderRepoList(items []repoItem) { 103 - if len(items) == 0 { 104 - fmt.Println("No repositories found.") 105 - return 106 - } 107 - 108 - tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', tabwriter.TabIndent) 109 - fmt.Fprintln(tw, "NAME\tKNOT\tDESCRIPTION\tCREATED") 110 - 111 - for _, item := range items { 112 - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", item.Name, item.Knot, item.Description, shortDate(item.CreatedAt)) 101 + rows := make([][]string, 0, len(items)) 102 + for _, repo := range items { 103 + rows = append(rows, []string{repo.Name, repo.Knot, repo.Description, shortDate(repo.CreatedAt)}) 113 104 } 114 - tw.Flush() 105 + renderTable([]string{"NAME", "KNOT", "DESCRIPTION", "CREATED"}, rows, "No repositories found.") 115 106 }
+1 -1
internal/cli/repo_view.go
··· 11 11 Use: "view <handle/repo>", 12 12 Short: "View a Tangled repository", 13 13 Long: `View details for a Tangled repository.`, 14 - Args: cobra.ExactArgs(1), 14 + Args: cobra.ExactArgs(1), 15 15 RunE: func(cmd *cobra.Command, args []string) error { 16 16 ctx := cmd.Context() 17 17
+100 -15
internal/cli/rows.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "encoding/json" 5 6 "fmt" 6 7 "os" 7 8 "strings" 8 9 "text/tabwriter" 10 + 11 + "github.com/alyraffauf/tg/tangled" 9 12 ) 10 13 11 14 const defaultListLimit int64 = 100 12 15 13 - // listRow is display-ready data for one issue or PR. 14 - type listRow struct { 15 - rkey string 16 - title string 17 - state string 18 - author string 19 - updated string 20 - } 21 - 22 16 // shortDate trims an ISO 8601 timestamp to its YYYY-MM-DD prefix. 23 17 func shortDate(timestamp string) string { 24 18 if len(timestamp) > 10 { ··· 27 21 return timestamp 28 22 } 29 23 30 - // renderRows writes a table of rows to stdout. emptyMessage is shown 31 - // when rows has no entries. 32 - func renderRows(rows []listRow, emptyMessage string) { 24 + // renderTable writes a tab-aligned table of rows to stdout under header. 25 + // emptyMessage is shown when rows has no entries. Every renderer in this 26 + // package (issues, pulls, repos, SSH keys) goes through this. 27 + func renderTable(header []string, rows [][]string, emptyMessage string) { 33 28 if len(rows) == 0 { 34 29 fmt.Println(emptyMessage) 35 30 return 36 31 } 37 32 38 33 tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', tabwriter.TabIndent) 39 - fmt.Fprintln(tw, "RKEY\tTITLE\tSTATE\tAUTHOR\tUPDATED") 40 - 34 + fmt.Fprintln(tw, strings.Join(header, "\t")) 41 35 for _, row := range rows { 42 - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", row.rkey, row.title, row.state, row.author, row.updated) 36 + fmt.Fprintln(tw, strings.Join(row, "\t")) 43 37 } 44 38 tw.Flush() 45 39 } ··· 68 62 } 69 63 return result 70 64 } 65 + 66 + // recordView is the fields common to an issue or pull-request record, 67 + // as decoded from a tangled.ListItem's raw Value. 68 + type recordView struct { 69 + Title string 70 + Body string 71 + CreatedAt string 72 + SourceBranch string 73 + TargetBranch string 74 + } 75 + 76 + func decodeIssue(raw json.RawMessage) (recordView, error) { 77 + var r tangled.IssueRecord 78 + if err := json.Unmarshal(raw, &r); err != nil { 79 + return recordView{}, err 80 + } 81 + return recordView{Title: r.Title, Body: r.Body, CreatedAt: r.CreatedAt}, nil 82 + } 83 + 84 + func decodePull(raw json.RawMessage) (recordView, error) { 85 + var r tangled.PullRecord 86 + if err := json.Unmarshal(raw, &r); err != nil { 87 + return recordView{}, err 88 + } 89 + return recordView{ 90 + Title: r.Title, 91 + Body: r.Body, 92 + CreatedAt: r.CreatedAt, 93 + SourceBranch: r.Source.Branch, 94 + TargetBranch: r.Target.Branch, 95 + }, nil 96 + } 97 + 98 + // buildItems decodes a listing's items into display/JSON-ready items, 99 + // silently skipping any whose Value fails to decode. decode is 100 + // decodeIssue or decodePull depending on the resource being listed. 101 + func buildItems(ctx context.Context, items []tangled.ListItem, decode func(json.RawMessage) (recordView, error)) []item { 102 + result := make([]item, 0, len(items)) 103 + 104 + for _, listItem := range items { 105 + decoded, err := decode(listItem.Value) 106 + if err != nil { 107 + continue 108 + } 109 + 110 + updated := listItem.StateUpdatedAt 111 + if updated == "" { 112 + updated = decoded.CreatedAt 113 + } 114 + 115 + title := decoded.Title 116 + if title == "" { 117 + title = "(no title)" 118 + } 119 + 120 + result = append(result, item{ 121 + Rkey: extractRKey(listItem.URI), 122 + URI: listItem.URI, 123 + Title: title, 124 + State: listItem.State, 125 + Author: resolveAuthor(ctx, extractDID(listItem.URI)), 126 + CreatedAt: decoded.CreatedAt, 127 + UpdatedAt: updated, 128 + CommentCount: listItem.CommentCount, 129 + SourceBranch: decoded.SourceBranch, 130 + TargetBranch: decoded.TargetBranch, 131 + }) 132 + } 133 + 134 + return result 135 + } 136 + 137 + // renderList renders issue or pull-request items as a table. 138 + func renderList(items []item, emptyMessage string) { 139 + rows := make([][]string, 0, len(items)) 140 + for _, it := range items { 141 + rows = append(rows, []string{it.Rkey, it.Title, it.State, it.Author.Handle, shortDate(it.UpdatedAt)}) 142 + } 143 + renderTable([]string{"RKEY", "TITLE", "STATE", "AUTHOR", "UPDATED"}, rows, emptyMessage) 144 + } 145 + 146 + // findByRKey finds the listing item whose URI ends in "/"+rkey. what names 147 + // the resource kind (e.g. "issue", "pull request") for the error message. 148 + func findByRKey(items []tangled.ListItem, rkey, what string) (*tangled.ListItem, error) { 149 + for i := range items { 150 + if strings.HasSuffix(items[i].URI, "/"+rkey) { 151 + return &items[i], nil 152 + } 153 + } 154 + return nil, fmt.Errorf("%s %q not found", what, rkey) 155 + }
+4 -13
internal/cli/ssh_key_list.go
··· 3 3 import ( 4 4 "encoding/json" 5 5 "fmt" 6 - "os" 7 - "text/tabwriter" 8 6 9 7 "github.com/alyraffauf/tg/atproto" 10 8 "github.com/bluesky-social/indigo/atproto/atclient" ··· 70 68 } 71 69 72 70 func renderSSHKeyList(items []sshKeyItem) { 73 - if len(items) == 0 { 74 - fmt.Println("No SSH keys found.") 75 - return 71 + rows := make([][]string, 0, len(items)) 72 + for _, key := range items { 73 + rows = append(rows, []string{key.Name, key.Key, shortDate(key.CreatedAt)}) 76 74 } 77 - 78 - tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', tabwriter.TabIndent) 79 - fmt.Fprintln(tw, "NAME\tKEY\tADDED") 80 - 81 - for _, item := range items { 82 - fmt.Fprintf(tw, "%s\t%s\t%s\n", item.Name, item.Key, shortDate(item.CreatedAt)) 83 - } 84 - tw.Flush() 75 + renderTable([]string{"NAME", "KEY", "ADDED"}, rows, "No SSH keys found.") 85 76 }
+57
internal/cli/target.go
··· 1 + package cli 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "strings" 7 + 8 + "github.com/alyraffauf/tg/internal/gitutil" 9 + ) 10 + 11 + func parseHandleRepo(arg string) (string, string, error) { 12 + parts := strings.SplitN(arg, "/", 2) 13 + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { 14 + return "", "", fmt.Errorf("expected handle/repo, got %q", arg) 15 + } 16 + return parts[0], parts[1], nil 17 + } 18 + 19 + // resolveTarget returns the handle and repo name from an explicit 20 + // "handle/repo" argument or by detecting the git remote in the CWD. 21 + func resolveTarget(ctx context.Context, args []string) (string, string, error) { 22 + if len(args) == 1 { 23 + return parseHandleRepo(args[0]) 24 + } 25 + 26 + rc, err := gitutil.DetectRepoFromCWD(ctx) 27 + if err != nil { 28 + return "", "", fmt.Errorf("detect repo from current directory: %w", err) 29 + } 30 + return rc.Handle, rc.Repo, nil 31 + } 32 + 33 + // findRepoDid resolves handle/repo to the repo's repoDid, which listIssues is 34 + // keyed by. It looks the record up directly by name (current schema uses the 35 + // name as the rkey), falling back to a listing for legacy repos whose rkey is a 36 + // TID with the name in the body. 37 + func findRepoDid(ctx context.Context, handle, repo string) (string, error) { 38 + ident, err := resolver.ResolveHandle(ctx, handle) 39 + if err != nil { 40 + return "", fmt.Errorf("resolve handle %q: %w", handle, err) 41 + } 42 + 43 + repoURI := fmt.Sprintf("at://%s/sh.tangled.repo/%s", ident.DID, repo) 44 + if got, err := client.GetRepo(ctx, repoURI); err == nil { 45 + return got.Value.RepoDid, nil 46 + } 47 + 48 + if repos, err := client.ListRepos(ctx, ident.DID.String()); err == nil { 49 + for _, candidate := range repos.Items { 50 + if candidate.Value.Name == repo || strings.HasSuffix(candidate.URI, "/"+repo) { 51 + return candidate.Value.RepoDid, nil 52 + } 53 + } 54 + } 55 + 56 + return "", fmt.Errorf("repo %q not found for handle %q", repo, handle) 57 + }
+47
tangled/list.go
··· 1 + package tangled 2 + 3 + import "encoding/json" 4 + 5 + // ListItem is one item in an issue or pull-request listing. 6 + type ListItem struct { 7 + URI string `json:"uri"` 8 + CID string `json:"cid,omitempty"` 9 + Value json.RawMessage `json:"value"` 10 + State string `json:"state"` 11 + StateUpdatedAt string `json:"stateUpdatedAt,omitempty"` 12 + CommentCount int64 `json:"commentCount"` 13 + } 14 + 15 + // List is a page of issues or pull requests. 16 + type List struct { 17 + Items []ListItem `json:"items"` 18 + Cursor *string `json:"cursor"` 19 + } 20 + 21 + // ListOpts are the query parameters shared by ListIssues and ListPulls. 22 + type ListOpts struct { 23 + Author string // only items by this DID 24 + State string // "open" or "closed" 25 + Limit int64 // 1-1000, default 50 26 + Order string // "asc" or "desc" 27 + } 28 + 29 + // params builds the XRPC query parameters for subject. 30 + func (o ListOpts) params(subject string) map[string]any { 31 + params := map[string]any{"subject": subject} 32 + if o.Author != "" { 33 + params["author"] = o.Author 34 + } 35 + if o.State != "" { 36 + params["state"] = o.State 37 + } 38 + if o.Limit > 0 { 39 + params["limit"] = o.Limit 40 + } else { 41 + params["limit"] = 50 42 + } 43 + if o.Order != "" { 44 + params["order"] = o.Order 45 + } 46 + return params 47 + }
+3 -44
tangled/list_issues.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "encoding/json" 6 5 "fmt" 7 6 8 7 "github.com/bluesky-social/indigo/atproto/syntax" ··· 18 17 References []string `json:"references,omitempty"` 19 18 } 20 19 21 - type IssueListItem struct { 22 - URI string `json:"uri"` 23 - CID string `json:"cid,omitempty"` 24 - Value json.RawMessage `json:"value"` 25 - State string `json:"state"` 26 - StateUpdatedAt string `json:"stateUpdatedAt,omitempty"` 27 - CommentCount int64 `json:"commentCount"` 28 - } 29 - 30 - type IssueList struct { 31 - Items []IssueListItem `json:"items"` 32 - Cursor *string `json:"cursor"` 33 - } 34 - 35 - type IssueListOpts struct { 36 - Author string // only issues by this DID 37 - State string // "open" or "closed" 38 - Limit int64 // 1-1000, default 50 39 - Order string // "asc" or "desc" 40 - } 41 - 42 - func (t *Tangled) ListIssues(ctx context.Context, repoDid string, opts IssueListOpts) (*IssueList, error) { 43 - params := map[string]any{ 44 - "subject": repoDid, 45 - } 46 - if opts.Author != "" { 47 - params["author"] = opts.Author 48 - } 49 - if opts.State != "" { 50 - params["state"] = opts.State 51 - } 52 - if opts.Limit > 0 { 53 - params["limit"] = opts.Limit 54 - } else { 55 - params["limit"] = 50 56 - } 57 - if opts.Order != "" { 58 - params["order"] = opts.Order 59 - } 60 - 61 - var out IssueList 62 - err := t.Client.Get(ctx, syntax.NSID("sh.tangled.repo.listIssues"), params, &out) 63 - if err != nil { 20 + func (t *Tangled) ListIssues(ctx context.Context, repoDid string, opts ListOpts) (*List, error) { 21 + var out List 22 + if err := t.Client.Get(ctx, syntax.NSID("sh.tangled.repo.listIssues"), opts.params(repoDid), &out); err != nil { 64 23 return nil, fmt.Errorf("list issues for %q: %w", repoDid, err) 65 24 } 66 25 return &out, nil
+3 -44
tangled/list_pulls.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "encoding/json" 6 5 "fmt" 7 6 8 7 "github.com/bluesky-social/indigo/atproto/atdata" ··· 45 44 Size int64 `json:"size"` 46 45 } 47 46 48 - type PullListItem struct { 49 - URI string `json:"uri"` 50 - CID string `json:"cid,omitempty"` 51 - Value json.RawMessage `json:"value"` 52 - State string `json:"state"` 53 - StateUpdatedAt string `json:"stateUpdatedAt,omitempty"` 54 - CommentCount int64 `json:"commentCount"` 55 - } 56 - 57 - type PullList struct { 58 - Items []PullListItem `json:"items"` 59 - Cursor *string `json:"cursor"` 60 - } 61 - 62 - type PullListOpts struct { 63 - Author string // only pulls by this DID 64 - State string // "open" or "closed" 65 - Limit int64 // 1-1000, default 50 66 - Order string // "asc" or "desc" 67 - } 68 - 69 - func (t *Tangled) ListPulls(ctx context.Context, repoDid string, opts PullListOpts) (*PullList, error) { 70 - params := map[string]any{ 71 - "subject": repoDid, 72 - } 73 - if opts.Author != "" { 74 - params["author"] = opts.Author 75 - } 76 - if opts.State != "" { 77 - params["state"] = opts.State 78 - } 79 - if opts.Limit > 0 { 80 - params["limit"] = opts.Limit 81 - } else { 82 - params["limit"] = 50 83 - } 84 - if opts.Order != "" { 85 - params["order"] = opts.Order 86 - } 87 - 88 - var out PullList 89 - err := t.Client.Get(ctx, syntax.NSID("sh.tangled.repo.listPulls"), params, &out) 90 - if err != nil { 47 + func (t *Tangled) ListPulls(ctx context.Context, repoDid string, opts ListOpts) (*List, error) { 48 + var out List 49 + if err := t.Client.Get(ctx, syntax.NSID("sh.tangled.repo.listPulls"), opts.params(repoDid), &out); err != nil { 91 50 return nil, fmt.Errorf("list PRs for %q: %w", repoDid, err) 92 51 } 93 52 return &out, nil