diff --git a/AGENTS.md b/AGENTS.md
index 2930a5f..37d0da9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -19,17 +19,18 @@ Tangled pipelines in `.tangled/workflows/` (nixery engine), not GitHub Actions:
## Architecture
-- `internal/cli/` — Cobra command tree, one file per command, all wired in `root.go`'s `init()`.
+- `internal/app/` — frontend-independent application layer. `app.Service` bundles the resolver, appview, and auth dependencies and exposes every operation (target resolution, issue/PR/repo/string/SSH-key CRUD, auth flows) as methods returning typed domain structs. All application logic lives here.
+- `internal/cli/` — thin Cobra frontend over `internal/app`: one file per command, all built in `NewRoot`. Each `RunE` parses flags/args into a service call and renders the returned struct.
- `tangled/` — read-only client for the Bobbin appview XRPC API (default `https://bobbin.klbr.net`; override via `--appview`/`TG_APPVIEW`).
-- Writes go two ways: PDS record mutations with the user's session (`atproto/`, `internal/cli/record_mutations.go`), and knot server RPCs (`knot/`) authed with a PDS-minted service-auth JWT.
+- Writes go two ways: PDS record mutations with the user's session (`atproto/`), and knot server RPCs (`knot/`) authed with a PDS-minted service-auth JWT. Both are orchestrated by `internal/app`.
- `atproto/` — handle↔DID resolution, PDS discovery, OAuth + app-password sessions stored in the OS keyring.
- `internal/gitutil/` — git subprocesses (clone, fetch, patch apply); its tests need `git` in PATH.
## Conventions
-- New command: new file in `internal/cli/`, register with `AddCommand` in `root.go`.
-- Commands returning data must support `--json` via the generic `output(data, humanFunc)` helper in `internal/cli/output.go` (human renderer + JSON struct with tags).
-- Repo-targeting commands accept `handle/repo` or auto-detect from the `origin` remote (see `internal/cli/target.go`).
+- New command: add the service method to `internal/app/`, then a thin file in `internal/cli/` that parses flags and calls it, registered with `AddCommand` in `NewRoot`.
+- Commands returning data must support `--json` via `output(cmd, data, human)` in `internal/cli/output.go`. The JSON structs are the canonical domain types in `internal/app/types.go`.
+- Repo-targeting commands accept `handle/repo` or auto-detect from the `origin` remote (see `internal/app/target.go` and the `resolveTarget`/`resolveTargetFlag` shims in `internal/cli/target.go`).
- Config is Viper: flag > `TG_` env var > `$XDG_CONFIG_HOME/tg/config.toml` > default.
-- Tests are plain unit tests with table-driven style; keyring tests use an in-memory fake, no real keyring needed.
-- Commit messages: lowercase `: `, area = package/command path (e.g. `cli:`, `atproto/auth:`, `nix/tg:`, `gitutil:`).
+- Tests are plain unit tests with table-driven style; keyring tests use an in-memory fake, no real keyring needed. Tests for application logic live in `internal/app/`.
+- Commit messages: lowercase `: `, area = package/command path (e.g. `app:`, `cli:`, `atproto/auth:`, `nix/tg:`, `gitutil:`).
diff --git a/internal/app/api.go b/internal/app/api.go
new file mode 100644
index 0000000..c204083
--- /dev/null
+++ b/internal/app/api.go
@@ -0,0 +1,65 @@
+package app
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+
+ "github.com/bluesky-social/indigo/atproto/atclient"
+ "github.com/bluesky-social/indigo/atproto/syntax"
+)
+
+// APIRequestInput describes an authenticated XRPC request.
+type APIRequestInput struct {
+ Endpoint syntax.NSID
+ Method string
+ Fields map[string]any
+}
+
+// APIResponse contains the response returned by an authenticated XRPC call.
+type APIResponse struct {
+ StatusCode int
+ Body []byte
+}
+
+// CallAPI performs an authenticated XRPC request for frontend-specific API
+// commands.
+func (s *Service) CallAPI(ctx context.Context, in APIRequestInput) (*APIResponse, error) {
+ client, err := s.AuthenticatedAPIClient(ctx)
+ if err != nil {
+ return nil, err
+ }
+ request := atclient.NewAPIRequest(in.Method, in.Endpoint, nil)
+ request.Headers.Set("Accept", "application/json")
+ if in.Method == http.MethodGet {
+ request.QueryParams = apiQuery(in.Fields)
+ } else {
+ body, err := json.Marshal(in.Fields)
+ if err != nil {
+ return nil, fmt.Errorf("encode request body: %w", err)
+ }
+ request.Body = bytes.NewReader(body)
+ request.Headers.Set("Content-Type", "application/json")
+ }
+ response, err := client.Do(ctx, request)
+ if err != nil {
+ return nil, fmt.Errorf("call %s: %w", in.Endpoint, err)
+ }
+ defer response.Body.Close()
+ body, err := io.ReadAll(response.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read API response: %w", err)
+ }
+ return &APIResponse{StatusCode: response.StatusCode, Body: body}, nil
+}
+
+func apiQuery(fields map[string]any) map[string][]string {
+ query := make(map[string][]string, len(fields))
+ for key, value := range fields {
+ query[key] = []string{fmt.Sprint(value)}
+ }
+ return query
+}
diff --git a/internal/app/app.go b/internal/app/app.go
new file mode 100644
index 0000000..8ce03f7
--- /dev/null
+++ b/internal/app/app.go
@@ -0,0 +1,84 @@
+// Package app is tg's frontend-independent application layer. A Service
+// bundles the atproto, appview, and auth dependencies and exposes every
+// operation tg supports — resolving repositories, listing and mutating
+// issues and pull requests, managing repos, strings, and SSH keys — as
+// methods that take plain inputs and return typed domain structs.
+//
+// Frontends (the Cobra CLI in internal/cli, a future TUI or GUI) construct
+// a Service, translate user intent into method calls, and render the
+// returned structs however they see fit.
+package app
+
+import (
+ "io"
+ "log/slog"
+ "net/http"
+ "os"
+
+ "github.com/alyraffauf/tg/atproto"
+ "github.com/alyraffauf/tg/internal/gitutil"
+ "github.com/alyraffauf/tg/tangled"
+ "github.com/bluesky-social/indigo/atproto/atclient"
+ "github.com/bluesky-social/indigo/atproto/identity"
+)
+
+// Dependencies configures the external clients used by Service.
+type Dependencies struct {
+ Resolver *atproto.Resolver
+ Appview *tangled.Tangled
+ Auth *atproto.AuthManager
+ Git *gitutil.Client
+ HTTPClient *http.Client
+}
+
+// Service bundles the dependencies every tg operation needs.
+type Service struct {
+ // Resolver resolves handles and DIDs via the atproto identity directory.
+ Resolver *atproto.Resolver
+ // Appview is the read-only Tangled appview (bobbin) XRPC client.
+ Appview *tangled.Tangled
+ // Auth manages atproto sessions (OAuth and app-password) in the keyring.
+ Auth *atproto.AuthManager
+ // Git runs local git operations.
+ Git *gitutil.Client
+ // HTTPClient downloads pull request patches.
+ HTTPClient *http.Client
+}
+
+// New returns a Service with production defaults: the default atproto
+// identity directory, the given appview host, and an AuthManager using
+// oauthCallbackURL for localhost OAuth redirects.
+func New(appviewHost, oauthCallbackURL string) *Service {
+ return NewWithStreams(appviewHost, oauthCallbackURL, os.Stdout, os.Stderr)
+}
+
+// NewWithStreams creates production dependencies with configurable command
+// output streams.
+func NewWithStreams(appviewHost, oauthCallbackURL string, stdout, stderr io.Writer) *Service {
+ return NewWithDependencies(Dependencies{
+ Resolver: &atproto.Resolver{Directory: identity.DefaultDirectory()},
+ Appview: &tangled.Tangled{
+ Client: &atclient.APIClient{Host: appviewHost},
+ Logger: slog.Default(),
+ },
+ Auth: atproto.NewAuthManager(oauthCallbackURL),
+ Git: gitutil.NewClient(stdout, stderr),
+ HTTPClient: http.DefaultClient,
+ })
+}
+
+// NewWithDependencies constructs a Service from explicit dependencies.
+func NewWithDependencies(dependencies Dependencies) *Service {
+ return &Service{
+ Resolver: dependencies.Resolver,
+ Appview: dependencies.Appview,
+ Auth: dependencies.Auth,
+ Git: dependencies.Git,
+ HTTPClient: dependencies.HTTPClient,
+ }
+}
+
+// SetAccount selects the account used by subsequent service operations.
+func (s *Service) SetAccount(selector string) {
+ s.Auth.SetAccount(selector)
+}
\ No newline at end of file
diff --git a/internal/app/auth.go b/internal/app/auth.go
new file mode 100644
index 0000000..3417cc2
--- /dev/null
+++ b/internal/app/auth.go
@@ -0,0 +1,214 @@
+package app
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/url"
+
+ "github.com/alyraffauf/tg/atproto"
+ "github.com/bluesky-social/indigo/atproto/atclient"
+)
+var ErrNotAuthenticated = atproto.ErrNotAuthenticated
+
+const (
+ SessionStatusActive = atproto.SessionStatusActive
+ SessionStatusExpired = atproto.SessionStatusExpired
+ SessionStatusUnknown = atproto.SessionStatusUnknown
+)
+
+
+// LoginWithPassword authenticates an account with an app password.
+func (s *Service) LoginWithPassword(ctx context.Context, identifier, password string) error {
+ return s.Auth.LoginWithPassword(ctx, identifier, password)
+}
+
+// CurrentDID returns the DID for the active account.
+func (s *Service) CurrentDID(ctx context.Context) (string, error) {
+ did, err := s.Auth.CurrentDID(ctx)
+ if err != nil {
+ return "", err
+ }
+ return did.String(), nil
+}
+
+// StartLogin starts an OAuth login flow.
+func (s *Service) StartLogin(ctx context.Context, identifier string) (string, error) {
+ return s.Auth.StartLogin(ctx, identifier)
+}
+
+// FinishLogin completes an OAuth login flow from callback query parameters.
+func (s *Service) FinishLogin(ctx context.Context, query url.Values) error {
+ return s.Auth.FinishLogin(ctx, query)
+}
+
+// CancelLogin discards a pending OAuth login flow.
+func (s *Service) CancelLogin() {
+ s.Auth.CancelLogin()
+}
+
+// AuthenticatedClient returns an atproto PDS client and the active account's
+// DID, for write operations. Token refreshes are persisted back to the
+// keyring. Returns atproto.ErrNotAuthenticated when there is no active
+// account.
+func (s *Service) AuthenticatedClient(ctx context.Context) (*atproto.ATProto, string, error) {
+ client, did, err := s.Auth.APIClient(ctx)
+ if err != nil {
+ if errors.Is(err, atproto.ErrNotAuthenticated) {
+ return nil, "", atproto.ErrNotAuthenticated
+ }
+ return nil, "", fmt.Errorf("resume auth session: %w", err)
+ }
+ return &atproto.ATProto{Client: client}, did.String(), nil
+}
+
+// AuthenticatedAPIClient returns the active account's authenticated XRPC
+// client for frontend-specific endpoints.
+func (s *Service) AuthenticatedAPIClient(ctx context.Context) (*atclient.APIClient, error) {
+ client, _, err := s.Auth.APIClient(ctx)
+ if err != nil {
+ if errors.Is(err, atproto.ErrNotAuthenticated) {
+ return nil, atproto.ErrNotAuthenticated
+ }
+ return nil, fmt.Errorf("resume auth session: %w", err)
+ }
+ return client, nil
+}
+
+// PublicAccountReader resolves handle to an unauthenticated PDS client for
+// read-only queries of account-owned records (strings, public keys).
+func (s *Service) PublicAccountReader(ctx context.Context, handle string) (*atproto.ATProto, string, error) {
+ ident, err := s.Resolver.ResolveHandle(ctx, handle)
+ if err != nil {
+ return nil, "", fmt.Errorf("resolve handle %q: %w", handle, err)
+ }
+
+ pdsURL, err := s.Resolver.ResolvePDS(ctx, ident.DID.String())
+ if err != nil {
+ return nil, "", fmt.Errorf("resolve PDS for %q: %w", handle, err)
+ }
+
+ return &atproto.ATProto{Client: &atclient.APIClient{Host: pdsURL}}, ident.DID.String(), nil
+}
+
+// HandleOrSelf returns handle when non-empty, otherwise the authenticated
+// user's handle.
+func (s *Service) HandleOrSelf(ctx context.Context, handle string) (string, error) {
+ if handle != "" {
+ return handle, nil
+ }
+ did, err := s.Auth.CurrentDID(ctx)
+ if err != nil {
+ if errors.Is(err, atproto.ErrNotAuthenticated) {
+ return "", fmt.Errorf("not logged in; provide a handle or run \"tg auth login\"")
+ }
+ return "", fmt.Errorf("resume OAuth session: %w", err)
+ }
+ ident, err := s.Resolver.ResolveDID(ctx, did.String())
+ if err != nil {
+ return "", fmt.Errorf("resolve your DID: %w", err)
+ }
+ return ident.Handle.String(), nil
+}
+
+// AuthStatus probes the active session. A missing session is reported as a
+// zero AuthStatusResult (Authenticated=false), not an error.
+func (s *Service) AuthStatus(ctx context.Context) (*AuthStatusResult, error) {
+ status, did, err := s.Auth.SessionStatus(ctx)
+ if err != nil {
+ if errors.Is(err, atproto.ErrNotAuthenticated) {
+ return &AuthStatusResult{}, nil
+ }
+ return nil, fmt.Errorf("check session: %w", err)
+ }
+ author := s.resolveAuthor(ctx, did.String())
+ return &AuthStatusResult{
+ Authenticated: true,
+ Status: status,
+ DID: author.DID,
+ Handle: author.Handle,
+ }, nil
+}
+
+// AuthAccounts lists all stored accounts, marking the active one.
+func (s *Service) AuthAccounts(ctx context.Context) ([]AuthAccountResult, error) {
+ accounts, activeDID, err := s.Auth.Accounts()
+ if err != nil {
+ return nil, fmt.Errorf("list accounts: %w", err)
+ }
+ results := make([]AuthAccountResult, 0, len(accounts))
+ for _, account := range accounts {
+ handle := account.Handle
+ resolved := s.resolveAuthor(ctx, account.DID)
+ if resolved.Handle != account.DID {
+ handle = resolved.Handle
+ }
+ results = append(results, AuthAccountResult{
+ Active: account.DID == activeDID,
+ DID: account.DID, Handle: handle, Method: account.Method,
+ })
+ }
+ return results, nil
+}
+
+// SwitchAccount selects the active account by handle or DID.
+func (s *Service) SwitchAccount(ctx context.Context, selector string) (*AuthAccountResult, error) {
+ account, err := s.Auth.SelectAccount(selector)
+ if err != nil {
+ return nil, fmt.Errorf("select account %q: %w", selector, err)
+ }
+ resolved := s.resolveAuthor(ctx, account.DID)
+ return &AuthAccountResult{
+ Active: true, DID: account.DID, Handle: resolved.Handle, Method: account.Method,
+ }, nil
+}
+
+// Logout removes the active account's credentials (or all accounts when all
+// is true). A missing session is reported as WasLoggedIn=false, not an error.
+func (s *Service) Logout(ctx context.Context, all bool) (*AuthLogoutResult, error) {
+ var err error
+ if all {
+ err = s.Auth.LogoutAll(ctx)
+ } else {
+ err = s.Auth.Logout(ctx)
+ }
+ if err != nil {
+ if errors.Is(err, atproto.ErrNotAuthenticated) {
+ return &AuthLogoutResult{WasLoggedIn: false}, nil
+ }
+ return nil, err
+ }
+ return &AuthLogoutResult{WasLoggedIn: true}, nil
+}
+
+// AccessToken returns the current session's access token, whether OAuth or
+// app-password.
+func (s *Service) AccessToken(ctx context.Context) (string, error) {
+ session, err := s.Auth.CurrentSession(ctx)
+ if err == nil {
+ token, _ := session.GetHostAccessData()
+ if token == "" {
+ return "", fmt.Errorf("current session has no access token")
+ }
+ return token, nil
+ }
+ if !errors.Is(err, atproto.ErrNotAuthenticated) {
+ return "", fmt.Errorf("resume OAuth session: %w", err)
+ }
+ client, _, err := s.Auth.APIClient(ctx)
+ if err != nil {
+ if errors.Is(err, atproto.ErrNotAuthenticated) {
+ return "", fmt.Errorf("not logged in; run \"tg auth login\" first")
+ }
+ return "", fmt.Errorf("resume auth session: %w", err)
+ }
+ passwordAuth, ok := client.Auth.(*atclient.PasswordAuth)
+ if !ok {
+ return "", fmt.Errorf("not logged in; run \"tg auth login\" first")
+ }
+ token, _ := passwordAuth.GetTokens()
+ if token == "" {
+ return "", fmt.Errorf("current session has no access token")
+ }
+ return token, nil
+}
\ No newline at end of file
diff --git a/internal/app/git.go b/internal/app/git.go
new file mode 100644
index 0000000..b757e38
--- /dev/null
+++ b/internal/app/git.go
@@ -0,0 +1,93 @@
+package app
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/alyraffauf/tg/internal/gitutil"
+)
+
+// CloneRepoInput configures a repository clone.
+type CloneRepoInput struct {
+ Handle string
+ Repo string
+ Destination string
+}
+
+// CloneRepo clones a Tangled repository into Destination.
+func (s *Service) CloneRepo(ctx context.Context, in CloneRepoInput) (*RepoCloneResult, error) {
+ if err := s.Git.CloneRepo(ctx, gitutil.CloneRepoParams{
+ Handle: in.Handle,
+ Repo: in.Repo,
+ RepoDir: in.Destination,
+ }); err != nil {
+ return nil, err
+ }
+ return &RepoCloneResult{
+ Handle: in.Handle,
+ Repo: in.Repo,
+ Destination: in.Destination,
+ }, nil
+}
+
+// CheckoutPullInput configures reconstructing a pull request in a local
+// repository. Target may be empty, in which case the current repository is
+// used.
+type CheckoutPullInput struct {
+ RepoDir string
+ Rkey string
+ Target *Target
+ Branch string
+ Force bool
+}
+
+// CheckoutPull downloads and applies the latest pull request patch.
+func (s *Service) CheckoutPull(ctx context.Context, in CheckoutPullInput) (*PRCheckoutResult, error) {
+ localRepo, err := s.Git.DetectRepoFromCWD(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("detect local repository: %w", err)
+ }
+ localTarget := Target{Handle: localRepo.Handle, Repo: localRepo.Repo}
+ localRecord, err := s.ResolveRepo(ctx, localTarget)
+ if err != nil {
+ return nil, err
+ }
+
+ target := localTarget
+ if in.Target != nil {
+ target = *in.Target
+ }
+ targetRecord := localRecord
+ if target != localTarget {
+ targetRecord, err = s.ResolveRepo(ctx, target)
+ if err != nil {
+ return nil, err
+ }
+ }
+ if targetRecord.Value.RepoDid != localRecord.Value.RepoDid {
+ return nil, fmt.Errorf("pull request target %s does not match the current repository", target)
+ }
+
+ patch, err := s.PullPatch(ctx, target, in.Rkey)
+ if err != nil {
+ return nil, err
+ }
+ if patch.Record.Target.Branch == "" {
+ return nil, fmt.Errorf("pull request %q has no target branch", in.Rkey)
+ }
+
+ branch := in.Branch
+ if branch == "" {
+ branch = "pr-" + in.Rkey
+ }
+ if err := s.Git.CheckoutPatch(ctx, gitutil.CheckoutPatchParams{
+ RepoDir: in.RepoDir,
+ Branch: branch,
+ TargetBranch: patch.Record.Target.Branch,
+ Patch: patch.Patch,
+ Force: in.Force,
+ }); err != nil {
+ return nil, err
+ }
+ return &PRCheckoutResult{Rkey: in.Rkey, Branch: branch}, nil
+}
diff --git a/internal/app/issues.go b/internal/app/issues.go
new file mode 100644
index 0000000..40a6c9a
--- /dev/null
+++ b/internal/app/issues.go
@@ -0,0 +1,186 @@
+package app
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/alyraffauf/tg/atproto"
+ "github.com/alyraffauf/tg/tangled"
+ "github.com/bluesky-social/indigo/atproto/syntax"
+)
+
+// ListIssues lists every issue in the target repository.
+func (s *Service) ListIssues(ctx context.Context, t Target) ([]Item, error) {
+ repoDid, err := s.RepoDID(ctx, t)
+ if err != nil {
+ return nil, err
+ }
+ issues, err := s.Appview.ListIssues(ctx, repoDid, tangled.ListOpts{
+ Limit: defaultListLimit,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("list issues for %q: %w", t.Repo, err)
+ }
+ return s.buildItems(ctx, issues.Items, decodeIssue), nil
+}
+
+// ViewIssue finds a single issue by rkey within the target repository.
+func (s *Service) ViewIssue(ctx context.Context, t Target, rkey string) (*ViewResult, error) {
+ repoDid, err := s.RepoDID(ctx, t)
+ if err != nil {
+ return nil, err
+ }
+ issues, err := s.Appview.ListIssues(ctx, repoDid, tangled.ListOpts{
+ Limit: defaultListLimit,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("list issues for %s: %w", t, err)
+ }
+ found, err := findByRKey(issues.Items, rkey, "issue")
+ if err != nil {
+ return nil, err
+ }
+ decoded, err := decodeIssue(found.Value)
+ if err != nil {
+ return nil, fmt.Errorf("decode issue %q: %w", rkey, err)
+ }
+ return &ViewResult{
+ Rkey: rkey,
+ Title: decoded.Title,
+ Body: decoded.Body,
+ Author: s.resolveAuthor(ctx, extractDID(found.URI)),
+ CreatedAt: decoded.CreatedAt,
+ }, nil
+}
+
+// CreateIssue writes a new issue record in the target repository.
+func (s *Service) CreateIssue(ctx context.Context, t Target, title, body string) (*CreatedRecordResult, error) {
+ atClient, did, err := s.AuthenticatedClient(ctx)
+ if err != nil {
+ return nil, err
+ }
+ repoDid, err := s.RepoDID(ctx, t)
+ if err != nil {
+ return nil, err
+ }
+ rkey := string(syntax.NewTIDNow(0))
+ uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{
+ Repo: did,
+ Collection: issueCollection,
+ Rkey: rkey,
+ Record: tangled.IssueRecord{
+ Type: issueCollection,
+ Repo: repoDid,
+ Title: title,
+ Body: body,
+ CreatedAt: time.Now().UTC().Format(time.RFC3339),
+ },
+ })
+ if err != nil {
+ return nil, fmt.Errorf("create issue: %w", err)
+ }
+ return &CreatedRecordResult{Rkey: rkey, URI: uri}, nil
+}
+
+// CommentIssue adds a comment to the issue identified by rkey.
+func (s *Service) CommentIssue(ctx context.Context, t Target, rkey, body string) (*CreatedRecordResult, error) {
+ repoDid, err := s.RepoDID(ctx, t)
+ if err != nil {
+ return nil, err
+ }
+ issues, err := s.Appview.ListIssues(ctx, repoDid, tangled.ListOpts{
+ Limit: defaultListLimit,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("list issues for %s: %w", t, err)
+ }
+ issue, err := findByRKey(issues.Items, rkey, "issue")
+ if err != nil {
+ return nil, err
+ }
+ return s.createIssueComment(ctx, issue.URI, body)
+}
+
+func (s *Service) createIssueComment(ctx context.Context, issueURI, body string) (*CreatedRecordResult, error) {
+ atClient, did, err := s.AuthenticatedClient(ctx)
+ if err != nil {
+ return nil, err
+ }
+ rkey := string(syntax.NewTIDNow(0))
+ uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{
+ Repo: did,
+ Collection: issueCollection + ".comment",
+ Rkey: rkey,
+ Record: tangled.IssueCommentRecord{
+ Type: issueCollection + ".comment",
+ Issue: issueURI,
+ Body: body,
+ CreatedAt: time.Now().UTC().Format(time.RFC3339),
+ },
+ })
+ if err != nil {
+ return nil, fmt.Errorf("create issue comment: %w", err)
+ }
+ return &CreatedRecordResult{Rkey: rkey, URI: uri}, nil
+}
+
+// SetIssueState closes or reopens an issue. state is the bare verb
+// ("open" or "closed").
+func (s *Service) SetIssueState(ctx context.Context, t Target, rkey, state string) (*StateResult, error) {
+ atClient, did, err := s.AuthenticatedClient(ctx)
+ if err != nil {
+ return nil, err
+ }
+ target, _, err := s.targetRecord(ctx, t, issueCollection, rkey)
+ if err != nil {
+ return nil, err
+ }
+ if err := putState(ctx, atClient, did, rkey, issueCollection, target, state); err != nil {
+ return nil, err
+ }
+ return &StateResult{Rkey: rkey, State: state}, nil
+}
+
+// EditIssue patches an issue's title and/or body. A nil pointer leaves the
+// field untouched.
+func (s *Service) EditIssue(ctx context.Context, rkey string, title, body *string) error {
+ atClient, did, err := s.AuthenticatedClient(ctx)
+ if err != nil {
+ return err
+ }
+ return editRecord(ctx, atClient, did, issueCollection, rkey, title, body)
+}
+
+// targetRecord resolves t, finds the issue/pull record rkey, and returns the
+// record URI and the repo record URI. collection selects issues or pulls.
+func (s *Service) targetRecord(ctx context.Context, t Target, collection, rkey string) (string, string, error) {
+ repoRecord, err := s.ResolveRepo(ctx, t)
+ if err != nil {
+ return "", "", err
+ }
+
+ var items []tangled.ListItem
+ var recordType string
+ if collection == issueCollection {
+ issues, err := s.Appview.ListIssues(ctx, repoRecord.Value.RepoDid, tangled.ListOpts{Limit: defaultListLimit})
+ if err != nil {
+ return "", "", fmt.Errorf("list issues for %s: %w", t, err)
+ }
+ items = issues.Items
+ recordType = "issue"
+ } else {
+ pulls, err := s.Appview.ListPulls(ctx, repoRecord.Value.RepoDid, tangled.ListOpts{Limit: defaultListLimit})
+ if err != nil {
+ return "", "", fmt.Errorf("list pull requests for %s: %w", t, err)
+ }
+ items = pulls.Items
+ recordType = "pull request"
+ }
+
+ record, err := findByRKey(items, rkey, recordType)
+ if err != nil {
+ return "", "", err
+ }
+ return record.URI, repoRecord.URI, nil
+}
diff --git a/internal/app/items.go b/internal/app/items.go
new file mode 100644
index 0000000..2d1ccbb
--- /dev/null
+++ b/internal/app/items.go
@@ -0,0 +1,121 @@
+package app
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/alyraffauf/tg/tangled"
+)
+
+// defaultListLimit is the page size used when listing issues, pulls, and
+// records via the appview or PDS.
+const defaultListLimit int64 = 100
+
+// recordView holds the fields common to an issue or pull-request record,
+// decoded from a tangled.ListItem's raw Value.
+type recordView struct {
+ Title string
+ Body string
+ CreatedAt string
+ SourceBranch string
+ TargetBranch string
+}
+
+func decodeIssue(raw json.RawMessage) (recordView, error) {
+ var r tangled.IssueRecord
+ if err := json.Unmarshal(raw, &r); err != nil {
+ return recordView{}, err
+ }
+ return recordView{Title: r.Title, Body: r.Body, CreatedAt: r.CreatedAt}, nil
+}
+
+func decodePull(raw json.RawMessage) (recordView, error) {
+ var r tangled.PullRecord
+ if err := json.Unmarshal(raw, &r); err != nil {
+ return recordView{}, err
+ }
+ return recordView{
+ Title: r.Title,
+ Body: r.Body,
+ CreatedAt: r.CreatedAt,
+ SourceBranch: r.Source.Branch,
+ TargetBranch: r.Target.Branch,
+ }, nil
+}
+
+// resolveAuthor resolves a DID to an author, falling back to the raw DID
+// string for Handle if resolution fails.
+func (s *Service) resolveAuthor(ctx context.Context, did string) Author {
+ result := Author{DID: did}
+ if ident, err := s.Resolver.ResolveDID(ctx, did); err == nil {
+ result.Handle = ident.Handle.String()
+ } else {
+ result.Handle = did
+ }
+ return result
+}
+
+// buildItems decodes a listing's items into display/JSON-ready items,
+// silently skipping any whose Value fails to decode. decode is decodeIssue
+// or decodePull depending on the resource being listed.
+func (s *Service) buildItems(ctx context.Context, items []tangled.ListItem, decode func(json.RawMessage) (recordView, error)) []Item {
+ result := make([]Item, 0, len(items))
+
+ for _, listItem := range items {
+ decoded, err := decode(listItem.Value)
+ if err != nil {
+ continue
+ }
+
+ updated := listItem.StateUpdatedAt
+ if updated == "" {
+ updated = decoded.CreatedAt
+ }
+
+ title := decoded.Title
+ if title == "" {
+ title = "(no title)"
+ }
+
+ result = append(result, Item{
+ Rkey: extractRKey(listItem.URI),
+ URI: listItem.URI,
+ Title: title,
+ State: listItem.State,
+ Author: s.resolveAuthor(ctx, extractDID(listItem.URI)),
+ CreatedAt: decoded.CreatedAt,
+ UpdatedAt: updated,
+ CommentCount: listItem.CommentCount,
+ SourceBranch: decoded.SourceBranch,
+ TargetBranch: decoded.TargetBranch,
+ })
+ }
+
+ return result
+}
+
+func extractDID(uri string) string {
+ uri = strings.TrimPrefix(uri, "at://")
+ did, _, _ := strings.Cut(uri, "/")
+ return did
+}
+
+func extractRKey(uri string) string {
+ if idx := strings.LastIndex(uri, "/"); idx != -1 {
+ return uri[idx+1:]
+ }
+ return uri
+}
+
+// findByRKey finds the listing item whose URI ends in "/"+rkey. what names
+// the resource kind (e.g. "issue", "pull request") for the error message.
+func findByRKey(items []tangled.ListItem, rkey, what string) (*tangled.ListItem, error) {
+ for i := range items {
+ if strings.HasSuffix(items[i].URI, "/"+rkey) {
+ return &items[i], nil
+ }
+ }
+ return nil, fmt.Errorf("%s %q not found", what, rkey)
+}
diff --git a/internal/app/items_test.go b/internal/app/items_test.go
new file mode 100644
index 0000000..312f478
--- /dev/null
+++ b/internal/app/items_test.go
@@ -0,0 +1,152 @@
+package app
+
+import (
+ "testing"
+
+ "github.com/alyraffauf/tg/tangled"
+)
+
+func TestExtractDID(t *testing.T) {
+ tests := []struct {
+ name string
+ uri string
+ want string
+ }{
+ {name: "record uri", uri: "at://did:plc:abc123/sh.tangled.repo.issue/3kdui", want: "did:plc:abc123"},
+ {name: "bare did", uri: "did:plc:abc123", want: "did:plc:abc123"},
+ {name: "trailing slash", uri: "at://did:plc:abc123/", want: "did:plc:abc123"},
+ {name: "empty", uri: "", want: ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := extractDID(tt.uri); got != tt.want {
+ t.Fatalf("got %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestExtractRKey(t *testing.T) {
+ tests := []struct {
+ name string
+ uri string
+ want string
+ }{
+ {name: "record uri", uri: "at://did:plc:abc123/sh.tangled.repo.issue/3kdui", want: "3kdui"},
+ {name: "bare rkey", uri: "3kdui", want: "3kdui"},
+ {name: "trailing slash", uri: "at://did:plc:abc123/", want: ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := extractRKey(tt.uri); got != tt.want {
+ t.Fatalf("got %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestDecodeIssue(t *testing.T) {
+ tests := []struct {
+ name string
+ raw string
+ want recordView
+ wantErr bool
+ }{
+ {
+ name: "full record",
+ raw: `{"title":"Bug report","body":"details","createdAt":"2026-07-18T12:00:00Z"}`,
+ want: recordView{Title: "Bug report", Body: "details", CreatedAt: "2026-07-18T12:00:00Z"},
+ },
+ {
+ name: "title only",
+ raw: `{"title":"Bug report"}`,
+ want: recordView{Title: "Bug report"},
+ },
+ {name: "invalid json", raw: `{`, wantErr: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := decodeIssue([]byte(tt.raw))
+ if (err != nil) != tt.wantErr {
+ t.Fatalf("error = %v, wantErr %v", err, tt.wantErr)
+ }
+ if got != tt.want {
+ t.Fatalf("got %+v, want %+v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestDecodePull(t *testing.T) {
+ tests := []struct {
+ name string
+ raw string
+ want recordView
+ wantErr bool
+ }{
+ {
+ name: "full record",
+ raw: `{"title":"Add feature","body":"details","createdAt":"2026-07-18T12:00:00Z",` +
+ `"source":{"branch":"feature"},"target":{"branch":"main"}}`,
+ want: recordView{
+ Title: "Add feature",
+ Body: "details",
+ CreatedAt: "2026-07-18T12:00:00Z",
+ SourceBranch: "feature",
+ TargetBranch: "main",
+ },
+ },
+ {
+ name: "title only",
+ raw: `{"title":"Add feature"}`,
+ want: recordView{Title: "Add feature"},
+ },
+ {name: "invalid json", raw: `{`, wantErr: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := decodePull([]byte(tt.raw))
+ if (err != nil) != tt.wantErr {
+ t.Fatalf("error = %v, wantErr %v", err, tt.wantErr)
+ }
+ if got != tt.want {
+ t.Fatalf("got %+v, want %+v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestFindByRKey(t *testing.T) {
+ items := []tangled.ListItem{
+ {URI: "at://did:plc:abc123/sh.tangled.repo.issue/3kdui"},
+ {URI: "at://did:plc:abc123/sh.tangled.repo.issue/9xyz"},
+ }
+
+ tests := []struct {
+ name string
+ rkey string
+ wantURI string
+ wantErr bool
+ }{
+ {name: "found", rkey: "3kdui", wantURI: items[0].URI},
+ {name: "found second", rkey: "9xyz", wantURI: items[1].URI},
+ {name: "not found", rkey: "missing", wantErr: true},
+ {name: "no partial match", rkey: "xyz", wantErr: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := findByRKey(items, tt.rkey, "issue")
+ if (err != nil) != tt.wantErr {
+ t.Fatalf("error = %v, wantErr %v", err, tt.wantErr)
+ }
+ if err == nil && got.URI != tt.wantURI {
+ t.Fatalf("got %q, want %q", got.URI, tt.wantURI)
+ }
+ })
+ }
+}
diff --git a/internal/app/pulls.go b/internal/app/pulls.go
new file mode 100644
index 0000000..fed5a3b
--- /dev/null
+++ b/internal/app/pulls.go
@@ -0,0 +1,418 @@
+package app
+
+import (
+ "bytes"
+ "compress/gzip"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "github.com/alyraffauf/tg/atproto"
+ "github.com/alyraffauf/tg/knot"
+ "github.com/alyraffauf/tg/tangled"
+ "github.com/bluesky-social/indigo/atproto/syntax"
+)
+
+// maxPullPatchSize caps a downloaded pull-request patch.
+const maxPullPatchSize = 100 << 20
+
+// PullPatch is a pull request's decoded record and its latest decompressed
+// patch, returned by PullPatch for use by diff and checkout.
+type PullPatch struct {
+ URI string
+ Record tangled.PullRecord
+ Patch []byte
+}
+
+// CreatePullInput configures pull request creation.
+type CreatePullInput struct {
+ RepoDir string // local git repository (for branch detection + patch)
+ Title string
+ Body string
+ Base string // empty: detect origin's default branch
+ Head string // empty: current branch
+ Target Target
+ Source *Target // nil: same as Target
+}
+
+// pullRecordInput is the write-side input to newPullRecord.
+type pullRecordInput struct {
+ Title string
+ Body string
+ TargetRepoDid string
+ SourceRepoDid string
+ Base string
+ Head string
+ Patch *atproto.Blob
+}
+
+// ListPulls lists every pull request in the target repository.
+func (s *Service) ListPulls(ctx context.Context, t Target) ([]Item, error) {
+ repoDid, err := s.RepoDID(ctx, t)
+ if err != nil {
+ return nil, err
+ }
+ pulls, err := s.Appview.ListPulls(ctx, repoDid, tangled.ListOpts{
+ Limit: defaultListLimit,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("list PRs for %q: %w", t.Repo, err)
+ }
+ return s.buildItems(ctx, pulls.Items, decodePull), nil
+}
+
+// ViewPull finds a single pull request by rkey within the target repository.
+func (s *Service) ViewPull(ctx context.Context, t Target, rkey string) (*ViewResult, error) {
+ repoDid, err := s.RepoDID(ctx, t)
+ if err != nil {
+ return nil, err
+ }
+ pulls, err := s.Appview.ListPulls(ctx, repoDid, tangled.ListOpts{
+ Limit: defaultListLimit,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("list PRs for %s: %w", t, err)
+ }
+ found, err := findByRKey(pulls.Items, rkey, "pull request")
+ if err != nil {
+ return nil, err
+ }
+ decoded, err := decodePull(found.Value)
+ if err != nil {
+ return nil, fmt.Errorf("decode pull request %q: %w", rkey, err)
+ }
+ return &ViewResult{
+ Rkey: rkey,
+ Title: decoded.Title,
+ Body: decoded.Body,
+ Author: s.resolveAuthor(ctx, extractDID(found.URI)),
+ CreatedAt: decoded.CreatedAt,
+ SourceBranch: decoded.SourceBranch,
+ TargetBranch: decoded.TargetBranch,
+ }, nil
+}
+
+// CreatePull generates a patch from the local repository, uploads it, and
+// writes a pull record.
+func (s *Service) CreatePull(ctx context.Context, in CreatePullInput) (*PRCreateResult, error) {
+ atClient, did, err := s.AuthenticatedClient(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ head := in.Head
+ if head == "" {
+ head, err = s.Git.CurrentBranch(ctx, in.RepoDir)
+ if err != nil {
+ return nil, fmt.Errorf("determine source branch: %w", err)
+ }
+ }
+ base := in.Base
+ if base == "" {
+ base, err = s.Git.DefaultBranch(ctx, in.RepoDir)
+ if err != nil {
+ return nil, fmt.Errorf("determine target branch; set --base explicitly: %w", err)
+ }
+ }
+
+ target, err := s.ResolveRepo(ctx, in.Target)
+ if err != nil {
+ return nil, err
+ }
+ if !atURIPrefix(target.URI) {
+ return nil, fmt.Errorf("target repository %q has no strong at:// URI", in.Target.Repo)
+ }
+ source := target
+ if in.Source != nil {
+ source, err = s.ResolveRepo(ctx, *in.Source)
+ if err != nil {
+ return nil, fmt.Errorf("resolve source repository: %w", err)
+ }
+ }
+ if source.Value.RepoDid == "" {
+ return nil, fmt.Errorf("source repository has no repo DID")
+ }
+
+ patch, err := s.Git.GeneratePatch(ctx, in.RepoDir, base, head)
+ if err != nil {
+ return nil, fmt.Errorf("generate pull request patch: %w", err)
+ }
+ blob, err := atClient.UploadBlob(ctx, patch, patchMimeType)
+ if err != nil {
+ return nil, err
+ }
+
+ uri, err := createPullRecord(ctx, atClient, did, pullRecordInput{
+ Title: in.Title,
+ Body: in.Body,
+ TargetRepoDid: target.Value.RepoDid,
+ SourceRepoDid: source.Value.RepoDid,
+ Base: base,
+ Head: head,
+ Patch: blob,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &PRCreateResult{URI: uri, Title: in.Title, Base: base, Head: head}, nil
+}
+
+func atURIPrefix(uri string) bool { return len(uri) >= 5 && uri[:5] == "at://" }
+
+func createPullRecord(ctx context.Context, atClient *atproto.ATProto, did string, input pullRecordInput) (string, error) {
+ record, err := newPullRecord(input, time.Now().UTC())
+ if err != nil {
+ return "", err
+ }
+ uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{
+ Repo: did,
+ Collection: pullCollection,
+ Rkey: string(syntax.NewTIDNow(0)),
+ Record: record,
+ })
+ if err != nil {
+ return "", fmt.Errorf("create pull request record: %w", err)
+ }
+ return uri, nil
+}
+
+func newPullRecord(input pullRecordInput, createdAt time.Time) (tangled.PullRecord, error) {
+ now := createdAt.Format(time.RFC3339)
+ patchBlob, err := patchBlob(input.Patch)
+ if err != nil {
+ return tangled.PullRecord{}, err
+ }
+ return tangled.PullRecord{
+ Type: pullCollection,
+ Title: input.Title,
+ Body: input.Body,
+ CreatedAt: now,
+ Target: tangled.PullTarget{
+ Repo: input.TargetRepoDid,
+ Branch: input.Base,
+ },
+ Source: tangled.PullSource{
+ Repo: input.SourceRepoDid,
+ Branch: input.Head,
+ },
+ Rounds: []tangled.PullRound{{
+ CreatedAt: now,
+ PatchBlob: patchBlob,
+ }},
+ }, nil
+}
+
+func patchBlob(blob *atproto.Blob) (tangled.PatchBlob, error) {
+ if blob == nil || blob.Ref == nil {
+ return tangled.PatchBlob{}, nil
+ }
+ data, err := json.Marshal(blob)
+ if err != nil {
+ return tangled.PatchBlob{}, fmt.Errorf("encode pull patch blob: %w", err)
+ }
+ var result tangled.PatchBlob
+ if err := json.Unmarshal(data, &result); err != nil {
+ return tangled.PatchBlob{}, fmt.Errorf("decode pull patch blob: %w", err)
+ }
+ return result, nil
+}
+
+// CommentPull adds a comment to the pull request identified by rkey.
+func (s *Service) CommentPull(ctx context.Context, t Target, rkey, body string) (*CreatedRecordResult, error) {
+ repoDid, err := s.RepoDID(ctx, t)
+ if err != nil {
+ return nil, err
+ }
+ pulls, err := s.Appview.ListPulls(ctx, repoDid, tangled.ListOpts{
+ Limit: defaultListLimit,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("list PRs for %s: %w", t, err)
+ }
+ pull, err := findByRKey(pulls.Items, rkey, "pull request")
+ if err != nil {
+ return nil, err
+ }
+ return s.createPullComment(ctx, pull.URI, body)
+}
+
+func (s *Service) createPullComment(ctx context.Context, pullURI, body string) (*CreatedRecordResult, error) {
+ atClient, did, err := s.AuthenticatedClient(ctx)
+ if err != nil {
+ return nil, err
+ }
+ rkey := string(syntax.NewTIDNow(0))
+ uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{
+ Repo: did,
+ Collection: pullCollection + ".comment",
+ Rkey: rkey,
+ Record: tangled.PullCommentRecord{
+ Type: pullCollection + ".comment",
+ Pull: pullURI,
+ Body: body,
+ CreatedAt: time.Now().UTC().Format(time.RFC3339),
+ },
+ })
+ if err != nil {
+ return nil, fmt.Errorf("create pull request comment: %w", err)
+ }
+ return &CreatedRecordResult{Rkey: rkey, URI: uri}, nil
+}
+
+// PullPatch fetches a pull request's latest patch, decompressed and ready to
+// apply or stream.
+func (s *Service) PullPatch(ctx context.Context, t Target, rkey string) (*PullPatch, error) {
+ repoDid, err := s.RepoDID(ctx, t)
+ if err != nil {
+ return nil, err
+ }
+ pulls, err := s.Appview.ListPulls(ctx, repoDid, tangled.ListOpts{
+ Limit: defaultListLimit,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("list PRs for %s: %w", t, err)
+ }
+ pull, err := findByRKey(pulls.Items, rkey, "pull request")
+ if err != nil {
+ return nil, err
+ }
+ record, patchCID, err := latestPullPatch(pull, rkey)
+ if err != nil {
+ return nil, err
+ }
+ patch, err := s.downloadPullPatch(ctx, extractDID(pull.URI), patchCID)
+ if err != nil {
+ return nil, err
+ }
+ return &PullPatch{URI: pull.URI, Record: record, Patch: patch}, nil
+}
+
+func latestPullPatch(pull *tangled.ListItem, rkey string) (tangled.PullRecord, string, error) {
+ var record tangled.PullRecord
+ if err := json.Unmarshal(pull.Value, &record); err != nil {
+ return record, "", fmt.Errorf("decode pull request %q: %w", rkey, err)
+ }
+ if len(record.Rounds) == 0 {
+ return record, "", fmt.Errorf("pull request %q has no rounds", rkey)
+ }
+ patchCID := record.Rounds[len(record.Rounds)-1].PatchBlob.Ref.String()
+ if patchCID == "" {
+ return record, "", fmt.Errorf("pull request %q has no patch blob", rkey)
+ }
+ return record, patchCID, nil
+}
+
+func (s *Service) downloadPullPatch(ctx context.Context, authorDID, cid string) ([]byte, error) {
+ pdsHost, err := s.Resolver.ResolvePDS(ctx, authorDID)
+ if err != nil {
+ return nil, fmt.Errorf("resolve PDS for author %q: %w", authorDID, err)
+ }
+ url := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", pdsHost, authorDID, cid)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("build patch download request: %w", err)
+ }
+ resp, err := s.HTTPClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("download patch: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
+ return nil, fmt.Errorf("download patch: PDS returned HTTP %d", resp.StatusCode)
+ }
+
+ compressed, err := readLimited(resp.Body, maxPullPatchSize)
+ if err != nil {
+ return nil, fmt.Errorf("download patch: %w", err)
+ }
+ patch, err := gzip.NewReader(bytes.NewReader(compressed))
+ if err != nil {
+ return nil, fmt.Errorf("decompress patch: %w", err)
+ }
+ defer patch.Close()
+ contents, err := readLimited(patch, maxPullPatchSize)
+ if err != nil {
+ return nil, fmt.Errorf("decompress patch: %w", err)
+ }
+ return contents, nil
+}
+
+func readLimited(reader io.Reader, limit int64) ([]byte, error) {
+ contents, err := io.ReadAll(io.LimitReader(reader, limit+1))
+ if err != nil {
+ return nil, err
+ }
+ if int64(len(contents)) > limit {
+ return nil, fmt.Errorf("patch exceeds %d bytes", limit)
+ }
+ return contents, nil
+}
+
+// SetPullState closes or reopens a pull request. status is the bare verb
+// ("open" or "closed").
+func (s *Service) SetPullState(ctx context.Context, t Target, rkey, status string) (*StateResult, error) {
+ atClient, did, err := s.AuthenticatedClient(ctx)
+ if err != nil {
+ return nil, err
+ }
+ target, _, err := s.targetRecord(ctx, t, pullCollection, rkey)
+ if err != nil {
+ return nil, err
+ }
+ if err := putState(ctx, atClient, did, rkey, pullCollection, target, status); err != nil {
+ return nil, err
+ }
+ return &StateResult{Rkey: rkey, State: status}, nil
+}
+
+// MergePull applies a pull request on its knot and records the merged status.
+func (s *Service) MergePull(ctx context.Context, t Target, rkey string) (*StateResult, error) {
+ atClient, did, err := s.AuthenticatedClient(ctx)
+ if err != nil {
+ return nil, err
+ }
+ pullURI, repoURI, err := s.targetRecord(ctx, t, pullCollection, rkey)
+ if err != nil {
+ return nil, err
+ }
+ knotHost, err := s.repoKnot(ctx, repoURI)
+ if err != nil {
+ return nil, err
+ }
+ token, err := atClient.GetServiceAuth(ctx, "did:web:"+knotHost, "sh.tangled.repo.merge")
+ if err != nil {
+ return nil, err
+ }
+ if err := knot.New(knotHost, token).Merge(ctx, knot.MergeInput{Repo: repoURI, Pull: pullURI}); err != nil {
+ return nil, err
+ }
+ if err := putState(ctx, atClient, did, rkey, pullCollection, pullURI, "merged"); err != nil {
+ return nil, fmt.Errorf("record merged pull request status: %w", err)
+ }
+ return &StateResult{Rkey: rkey, State: "merged"}, nil
+}
+
+// EditPull patches a pull request's title and/or body. A nil pointer leaves
+// the field untouched.
+func (s *Service) EditPull(ctx context.Context, rkey string, title, body *string) error {
+ atClient, did, err := s.AuthenticatedClient(ctx)
+ if err != nil {
+ return err
+ }
+ return editRecord(ctx, atClient, did, pullCollection, rkey, title, body)
+}
+
+// repoKnot resolves the knot host for a repository record URI.
+func (s *Service) repoKnot(ctx context.Context, repoURI string) (string, error) {
+ repo, err := s.Appview.GetRepo(ctx, repoURI)
+ if err != nil {
+ return "", fmt.Errorf("get repository: %w", err)
+ }
+ if repo.Value.Knot == "" {
+ return "", fmt.Errorf("repository record has no knot")
+ }
+ return repo.Value.Knot, nil
+}
diff --git a/internal/cli/pr_create_test.go b/internal/app/pulls_test.go
similarity index 82%
rename from internal/cli/pr_create_test.go
rename to internal/app/pulls_test.go
index 85389ec..ee2086e 100644
--- a/internal/cli/pr_create_test.go
+++ b/internal/app/pulls_test.go
@@ -1,4 +1,4 @@
-package cli
+package app
import (
"testing"
@@ -8,7 +8,7 @@ import (
)
func TestNewPullRecordUsesDistinctSourceAndTarget(t *testing.T) {
- record := newPullRecord(prCreateRecord{
+ record, err := newPullRecord(pullRecordInput{
Title: "Cross-repo change",
TargetRepoDid: "did:plc:upstream",
SourceRepoDid: "did:plc:fork",
@@ -16,6 +16,9 @@ func TestNewPullRecordUsesDistinctSourceAndTarget(t *testing.T) {
Head: "feature",
Patch: &atproto.Blob{},
}, time.Date(2026, 7, 17, 0, 0, 0, 0, time.UTC))
+ if err != nil {
+ t.Fatalf("newPullRecord() error = %v", err)
+ }
if record.Target.Repo != "did:plc:upstream" {
t.Fatalf("unexpected target: %+v", record.Target)
diff --git a/internal/app/records.go b/internal/app/records.go
new file mode 100644
index 0000000..76032d9
--- /dev/null
+++ b/internal/app/records.go
@@ -0,0 +1,91 @@
+package app
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/alyraffauf/tg/atproto"
+ "github.com/alyraffauf/tg/tangled"
+)
+
+const (
+ issueCollection = tangled.IssueCollection
+ pullCollection = tangled.PullCollection
+ stringCollection = tangled.StringCollection
+ sshKeyCollection = tangled.SSHKeyCollection
+ repoCollection = tangled.RepoCollection
+)
+
+const patchMimeType = "application/gzip"
+
+// putRecord writes a record to the PDS.
+func putRecord(ctx context.Context, atClient *atproto.ATProto, did, collection, rkey string, record any) error {
+ if _, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{
+ Repo: did, Collection: collection, Rkey: rkey, Record: record,
+ }); err != nil {
+ return err
+ }
+ return nil
+}
+
+// editRecord fetches an existing record, applies the provided title and/or
+// body patches (nil leaves the field untouched), and writes it back.
+func editRecord(ctx context.Context, atClient *atproto.ATProto, did, collection, rkey string, title, body *string) error {
+ found, err := atClient.GetRecord(ctx, did, collection, rkey)
+ if err != nil {
+ return fmt.Errorf("get existing record: %w", err)
+ }
+
+ record, err := preserveRecord(found.Value)
+ if err != nil {
+ return err
+ }
+ if title != nil {
+ record["title"] = *title
+ }
+ if body != nil {
+ record["body"] = *body
+ }
+ _, _, err = atClient.PutRecord(ctx, atproto.PutRecordInput{
+ Repo: did, Collection: collection, Rkey: rkey, Record: record,
+ })
+ return err
+}
+
+// preserveRecord marshals a record value into a map so individual fields can
+// be patched without losing fields this client does not model.
+func preserveRecord(value any) (map[string]any, error) {
+ data, err := json.Marshal(value)
+ if err != nil {
+ return nil, fmt.Errorf("encode existing record: %w", err)
+ }
+ var record map[string]any
+ if err := json.Unmarshal(data, &record); err != nil {
+ return nil, fmt.Errorf("decode existing record: %w", err)
+ }
+ if record == nil {
+ return nil, fmt.Errorf("existing record is not an object")
+ }
+ return record, nil
+}
+
+// putState writes an issue.state or pull.status record keyed by rkey. state
+// is the bare verb ("open"/"closed"/"merged"); the collection-specific suffix
+// is applied here.
+func putState(ctx context.Context, atClient *atproto.ATProto, did, rkey, collection, target, state string) error {
+ if collection == tangled.IssueCollection {
+ state = tangled.IssueCollection + tangled.IssueStateSuffix + "." + state
+ return putRecord(ctx, atClient, did, tangled.IssueCollection+tangled.IssueStateSuffix, rkey, tangled.IssueStateRecord{
+ Type: tangled.IssueCollection + tangled.IssueStateSuffix,
+ Issue: target,
+ State: state,
+ })
+ }
+ state = tangled.PullCollection + tangled.PullStatusSuffix + "." + state
+ return putRecord(ctx, atClient, did, tangled.PullCollection+tangled.PullStatusSuffix, rkey, tangled.PullStatusRecord{
+ Type: tangled.PullCollection + tangled.PullStatusSuffix,
+ Pull: target,
+ Status: state,
+ })
+}
diff --git a/internal/app/repos.go b/internal/app/repos.go
new file mode 100644
index 0000000..9c5dd60
--- /dev/null
+++ b/internal/app/repos.go
@@ -0,0 +1,447 @@
+package app
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/alyraffauf/tg/atproto"
+ "github.com/alyraffauf/tg/internal/gitutil"
+ "github.com/alyraffauf/tg/knot"
+ "github.com/alyraffauf/tg/tangled"
+)
+
+// ViewRepo fetches a single repository record.
+func (s *Service) ViewRepo(ctx context.Context, t Target) (*RepoItem, error) {
+ ident, err := s.Resolver.ResolveHandle(ctx, t.Handle)
+ if err != nil {
+ return nil, fmt.Errorf("resolve handle %q: %w", t.Handle, err)
+ }
+ repoURI := fmt.Sprintf("at://%s/sh.tangled.repo/%s", ident.DID, t.Repo)
+ tangledRepo, err := s.Appview.GetRepo(ctx, repoURI)
+ if err != nil {
+ return nil, fmt.Errorf("get repo %s: %w", t, err)
+ }
+ name := tangledRepo.Value.Name
+ if name == "" {
+ name = t.Repo
+ }
+ return &RepoItem{
+ Name: name,
+ Author: t.Handle,
+ URI: repoURI,
+ Knot: tangledRepo.Value.Knot,
+ Description: tangledRepo.Value.Description,
+ CreatedAt: tangledRepo.Value.CreatedAt,
+ RepoDid: tangledRepo.Value.RepoDid,
+ }, nil
+}
+
+// ListRepos lists every repository owned by handle.
+func (s *Service) ListRepos(ctx context.Context, handle string) ([]RepoItem, error) {
+ ident, err := s.Resolver.ResolveHandle(ctx, handle)
+ if err != nil {
+ return nil, fmt.Errorf("resolve handle %q: %w", handle, err)
+ }
+ repos, err := s.Appview.ListRepos(ctx, ident.DID.String())
+ if err != nil {
+ return nil, fmt.Errorf("list repos for %q: %w", handle, err)
+ }
+ return buildRepoItems(repos.Items, handle), nil
+}
+
+func buildRepoItems(items []tangled.Repo, author string) []RepoItem {
+ result := make([]RepoItem, 0, len(items))
+ for _, tangledRepo := range items {
+ name := tangledRepo.Value.Name
+ if name == "" {
+ // Fall back to the rkey segment of the at:// URI.
+ if idx := strings.LastIndex(tangledRepo.URI, "/"); idx != -1 {
+ name = tangledRepo.URI[idx+1:]
+ }
+ }
+ result = append(result, RepoItem{
+ Name: name,
+ URI: tangledRepo.URI,
+ Author: author,
+ Knot: tangledRepo.Value.Knot,
+ Description: tangledRepo.Value.Description,
+ CreatedAt: tangledRepo.Value.CreatedAt,
+ RepoDid: tangledRepo.Value.RepoDid,
+ })
+ }
+ return result
+}
+
+// ProvisionRepoInput configures repository provisioning.
+type ProvisionRepoInput struct {
+ KnotHost string
+ Name string
+ Description string
+}
+
+// ProvisionRepo creates the repo on the knot and writes the sh.tangled.repo
+// record to the user's PDS. Returns the new record URI and the owner's handle.
+func (s *Service) ProvisionRepo(ctx context.Context, in ProvisionRepoInput) (uri, handle string, err error) {
+ atClient, did, err := s.AuthenticatedClient(ctx)
+ if err != nil {
+ return "", "", err
+ }
+ token, err := atClient.GetServiceAuth(ctx, "did:web:"+in.KnotHost, "sh.tangled.repo.create")
+ if err != nil {
+ return "", "", err
+ }
+ repoDid, err := knot.New(in.KnotHost, token).CreateRepo(ctx, knot.CreateRepoInput{
+ Name: in.Name,
+ Rkey: in.Name,
+ })
+ if err != nil {
+ return "", "", err
+ }
+ record := tangled.RepoRecord{
+ Type: repoCollection,
+ Knot: in.KnotHost,
+ CreatedAt: time.Now().UTC().Format(time.RFC3339),
+ RepoDid: repoDid,
+ }
+ if in.Description != "" {
+ record.Description = in.Description
+ }
+ uri, _, err = atClient.PutRecord(ctx, atproto.PutRecordInput{
+ Repo: did,
+ Collection: repoCollection,
+ Rkey: in.Name,
+ Record: record,
+ })
+ if err != nil {
+ return "", "", err
+ }
+ return uri, s.OwnerHandle(ctx, did), nil
+}
+
+// OwnerHandle resolves did to a handle, falling back to the DID string.
+func (s *Service) OwnerHandle(ctx context.Context, did string) string {
+ if ident, err := s.Resolver.ResolveDID(ctx, did); err == nil {
+ return ident.Handle.String()
+ }
+ return did
+}
+
+// SetRepoDefaultBranch sets the default branch of the authenticated user's
+// repo t.
+func (s *Service) SetRepoDefaultBranch(ctx context.Context, t Target, branch string) (*RepoDefaultBranchResult, error) {
+ atClient, did, err := s.AuthenticatedClient(ctx)
+ if err != nil {
+ return nil, err
+ }
+ repo, err := s.RequireOwnedRepo(ctx, t, did)
+ if err != nil {
+ return nil, err
+ }
+ if repo.Value.Knot == "" {
+ return nil, fmt.Errorf("repo %q has no knot", t.String())
+ }
+ if err := s.setKnotDefaultBranch(ctx, atClient, repo.Value.Knot, repo.URI, branch); err != nil {
+ return nil, err
+ }
+ return &RepoDefaultBranchResult{URI: repo.URI, Branch: branch}, nil
+}
+
+// SetDefaultBranchFromDir repoints the default branch of repoURI on knotHost
+// to the current branch of the local git repository at dir. It is best-effort
+// during repo creation; callers may warn rather than fail on error.
+func (s *Service) SetDefaultBranchFromDir(ctx context.Context, knotHost, repoURI, dir string) (string, error) {
+ atClient, _, err := s.AuthenticatedClient(ctx)
+ if err != nil {
+ return "", err
+ }
+ branch, err := s.Git.CurrentBranch(ctx, dir)
+ if err != nil {
+ return "", err
+ }
+ if err := s.setKnotDefaultBranch(ctx, atClient, knotHost, repoURI, branch); err != nil {
+ return branch, err
+ }
+ return branch, nil
+}
+
+// PushNewRepo sets the knot default branch when possible, then pushes the
+// local repository to its new Tangled remote. A default-branch error is
+// returned separately because repository creation treats it as a warning.
+func (s *Service) PushNewRepo(ctx context.Context, in PushNewRepoInput) (string, error, error) {
+ branch, defaultBranchErr := s.SetDefaultBranchFromDir(ctx, in.KnotHost, in.RepoURI, in.Dir)
+ if err := s.Git.PushNewRepo(ctx, gitutil.PushNewRepoParams{
+ Dir: in.Dir, Handle: in.Handle, Repo: in.Repo, RemoteName: in.RemoteName,
+ }); err != nil {
+ return branch, defaultBranchErr, fmt.Errorf("push to new repository: %w", err)
+ }
+ return branch, defaultBranchErr, nil
+}
+
+// PushNewRepoInput configures pushing a newly created repository.
+type PushNewRepoInput struct {
+ KnotHost string
+ RepoURI string
+ Dir string
+ Handle string
+ Repo string
+ RemoteName string
+}
+
+func (s *Service) setKnotDefaultBranch(ctx context.Context, atClient *atproto.ATProto, knotHost, repoURI, branch string) error {
+ token, err := atClient.GetServiceAuth(ctx, "did:web:"+knotHost, "sh.tangled.repo.setDefaultBranch")
+ if err != nil {
+ return fmt.Errorf("get knot authorization: %w", err)
+ }
+ return knot.New(knotHost, token).SetDefaultBranch(ctx, knot.SetDefaultBranchInput{
+ Repo: repoURI,
+ DefaultBranch: branch,
+ })
+}
+
+// EditRepoInput configures repository edits. Pointer fields are nil when the
+// corresponding flag was not set.
+type EditRepoInput struct {
+ Description *string
+ Website *string
+ Spindle *string
+ AddLabels []string
+ RemoveLabels []string
+}
+
+// EditRepo patches repository fields on the authenticated user's repo t.
+func (s *Service) EditRepo(ctx context.Context, t Target, in EditRepoInput) (*RepoEditResult, error) {
+ atClient, did, err := s.AuthenticatedClient(ctx)
+ if err != nil {
+ return nil, err
+ }
+ repo, err := s.RequireOwnedRepo(ctx, t, did)
+ if err != nil {
+ return nil, err
+ }
+ rkey := extractRKey(repo.URI)
+ existing, err := atClient.GetRecord(ctx, did, repoCollection, rkey)
+ if err != nil {
+ return nil, fmt.Errorf("get repository record: %w", err)
+ }
+ record, err := repoRecordMap(existing.Value)
+ if err != nil {
+ return nil, err
+ }
+ if in.Description != nil {
+ record["description"] = *in.Description
+ }
+ if in.Website != nil {
+ record["website"] = *in.Website
+ }
+ if in.Spindle != nil {
+ record["spindle"] = *in.Spindle
+ }
+ if len(in.AddLabels) > 0 || len(in.RemoveLabels) > 0 {
+ labels := labelsFromRecord(record["labels"])
+ for _, label := range in.AddLabels {
+ labels[label] = true
+ }
+ for _, label := range in.RemoveLabels {
+ delete(labels, label)
+ }
+ record["labels"] = labelNames(labels)
+ }
+ if _, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{
+ Repo: did,
+ Collection: repoCollection,
+ Rkey: rkey,
+ Record: record,
+ }); err != nil {
+ return nil, fmt.Errorf("edit repository: %w", err)
+ }
+ result := &RepoEditResult{URI: repo.URI}
+ if in.Description != nil {
+ result.Description = *in.Description
+ }
+ return result, nil
+}
+
+func repoRecordMap(value any) (map[string]any, error) {
+ data, err := json.Marshal(value)
+ if err != nil {
+ return nil, fmt.Errorf("encode repository record: %w", err)
+ }
+ var record map[string]any
+ if err := json.Unmarshal(data, &record); err != nil {
+ return nil, fmt.Errorf("decode repository record: %w", err)
+ }
+ if record == nil {
+ return nil, fmt.Errorf("repository record is not an object")
+ }
+ return record, nil
+}
+
+func labelsFromRecord(value any) map[string]bool {
+ labels := make(map[string]bool)
+ values, ok := value.([]any)
+ if !ok {
+ return labels
+ }
+ for _, value := range values {
+ if label, ok := value.(string); ok {
+ labels[label] = true
+ }
+ }
+ return labels
+}
+
+func labelNames(labels map[string]bool) []string {
+ names := make([]string, 0, len(labels))
+ for label := range labels {
+ names = append(names, label)
+ }
+ sort.Strings(names)
+ return names
+}
+
+// DeleteRepo deletes the repository record and the knot-side repo. If the
+// knot deletion fails after the record is deleted, the record is restored.
+func (s *Service) DeleteRepo(ctx context.Context, t Target) (*RepoDeleteResult, error) {
+ atClient, did, err := s.AuthenticatedClient(ctx)
+ if err != nil {
+ return nil, err
+ }
+ repo, err := s.RequireOwnedRepo(ctx, t, did)
+ if err != nil {
+ return nil, err
+ }
+ if repo.Value.Knot == "" {
+ return nil, fmt.Errorf("repo %q has no knot", t.String())
+ }
+ rkey := extractRKey(repo.URI)
+ existingRecord, getErr := atClient.GetRecord(ctx, did, repoCollection, rkey)
+ // getErr is non-fatal: the record may already be deleted. Only call
+ // DeleteRecord if it still exists.
+
+ token, err := atClient.GetServiceAuth(ctx, "did:web:"+repo.Value.Knot, "sh.tangled.repo.delete")
+ if err != nil {
+ return nil, fmt.Errorf("get knot authorization: %w", err)
+ }
+ if getErr == nil {
+ if err := atClient.DeleteRecord(ctx, atproto.DeleteRecordInput{
+ Repo: did,
+ Collection: repoCollection,
+ Rkey: rkey,
+ }); err != nil {
+ return nil, fmt.Errorf("delete repository record: %w", err)
+ }
+ }
+ if err := knot.New(repo.Value.Knot, token).DeleteRepo(ctx, knot.DeleteRepoInput{
+ DID: did,
+ Name: t.Repo,
+ Rkey: rkey,
+ }); err != nil {
+ if getErr == nil {
+ if _, _, restoreErr := atClient.PutRecord(ctx, atproto.PutRecordInput{
+ Repo: did, Collection: repoCollection, Rkey: rkey, Record: existingRecord.Value,
+ }); restoreErr != nil {
+ return nil, fmt.Errorf("delete knot repository: %w; restore repository record: %v", err, restoreErr)
+ }
+ }
+ return nil, err
+ }
+ return &RepoDeleteResult{URI: repo.URI}, nil
+}
+
+// ForkRepo creates a fork of source on the authenticated user's account,
+// named name (defaults to the source repo's name).
+func (s *Service) ForkRepo(ctx context.Context, source Target, name string) (*RepoForkResult, error) {
+ atClient, ownerDID, err := s.AuthenticatedClient(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ src, err := s.getForkSource(ctx, source)
+ if err != nil {
+ return nil, err
+ }
+ token, err := atClient.GetServiceAuth(ctx, "did:web:"+src.Knot, "sh.tangled.repo.create")
+ if err != nil {
+ return nil, fmt.Errorf("get knot service auth: %w", err)
+ }
+ repoDID, err := knot.New(src.Knot, token).CreateRepo(ctx, knot.CreateRepoInput{
+ Name: name,
+ Rkey: name,
+ Source: forkSourceURL(src.Knot, src.RepoDID),
+ })
+ if err != nil {
+ return nil, err
+ }
+ uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{
+ Repo: ownerDID,
+ Collection: repoCollection,
+ Rkey: name,
+ Record: tangled.RepoRecord{
+ Type: repoCollection,
+ Name: name,
+ Knot: src.Knot,
+ CreatedAt: time.Now().UTC().Format(time.RFC3339),
+ RepoDid: repoDID,
+ Source: src.URI,
+ },
+ })
+ if err != nil {
+ cleanupErr := s.deleteFork(ctx, atClient, src.Knot, ownerDID, name)
+ if cleanupErr != nil {
+ return nil, fmt.Errorf("write fork record: %w; delete orphaned fork: %v", err, cleanupErr)
+ }
+ return nil, fmt.Errorf("write fork record: %w", err)
+ }
+ return &RepoForkResult{Handle: s.OwnerHandle(ctx, ownerDID), Name: name, URI: uri, Knot: src.Knot}, nil
+}
+
+type forkSource struct {
+ URI string
+ Knot string
+ RepoDID string
+}
+
+func forkSourceURL(knotHost, repoDID string) string {
+ base := strings.TrimRight(knotHost, "/")
+ if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
+ base = "https://" + base
+ }
+ return base + "/" + repoDID
+}
+
+func (s *Service) getForkSource(ctx context.Context, t Target) (forkSource, error) {
+ ident, err := s.Resolver.ResolveHandle(ctx, t.Handle)
+ if err != nil {
+ return forkSource{}, fmt.Errorf("resolve handle %q: %w", t.Handle, err)
+ }
+ uri := fmt.Sprintf("at://%s/sh.tangled.repo/%s", ident.DID, t.Repo)
+ repo, err := s.Appview.GetRepo(ctx, uri)
+ if err != nil {
+ return forkSource{}, fmt.Errorf("get source repository %s: %w", t, err)
+ }
+ if repo.Value.Knot == "" {
+ return forkSource{}, fmt.Errorf("source repository %s has no knot", t)
+ }
+ if repo.Value.RepoDid == "" {
+ return forkSource{}, fmt.Errorf("source repository %s has no repo DID", t)
+ }
+ if repo.URI != "" {
+ uri = repo.URI
+ }
+ return forkSource{URI: uri, Knot: repo.Value.Knot, RepoDID: repo.Value.RepoDid}, nil
+}
+
+func (s *Service) deleteFork(ctx context.Context, atClient *atproto.ATProto, knotHost, did, name string) error {
+ token, err := atClient.GetServiceAuth(ctx, "did:web:"+knotHost, "sh.tangled.repo.delete")
+ if err != nil {
+ return fmt.Errorf("get knot authorization: %w", err)
+ }
+ if err := knot.New(knotHost, token).DeleteRepo(ctx, knot.DeleteRepoInput{DID: did, Name: name, Rkey: name}); err != nil {
+ return err
+ }
+ return nil
+}
diff --git a/internal/cli/repo_fork_test.go b/internal/app/repos_test.go
similarity index 98%
rename from internal/cli/repo_fork_test.go
rename to internal/app/repos_test.go
index 14c6a68..56878db 100644
--- a/internal/cli/repo_fork_test.go
+++ b/internal/app/repos_test.go
@@ -1,4 +1,4 @@
-package cli
+package app
import "testing"
diff --git a/internal/app/sshkeys.go b/internal/app/sshkeys.go
new file mode 100644
index 0000000..ddeafb7
--- /dev/null
+++ b/internal/app/sshkeys.go
@@ -0,0 +1,85 @@
+package app
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "github.com/alyraffauf/tg/atproto"
+ "github.com/alyraffauf/tg/tangled"
+ "github.com/bluesky-social/indigo/atproto/syntax"
+)
+
+// AddSSHKey writes a new sh.tangled.publicKey record.
+func (s *Service) AddSSHKey(ctx context.Context, name, key string) (*SSHKeyAddResult, error) {
+ atClient, did, err := s.AuthenticatedClient(ctx)
+ if err != nil {
+ return nil, err
+ }
+ uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{
+ Repo: did,
+ Collection: sshKeyCollection,
+ Rkey: string(syntax.NewTIDNow(0)),
+ Record: tangled.SSHKeyRecord{
+ Type: sshKeyCollection,
+ Key: key,
+ Name: name,
+ CreatedAt: time.Now().UTC().Format(time.RFC3339),
+ },
+ })
+ if err != nil {
+ return nil, fmt.Errorf("add SSH key: %w", err)
+ }
+ return &SSHKeyAddResult{Name: name, URI: uri}, nil
+}
+
+// ListSSHKeys lists every public key owned by handle.
+func (s *Service) ListSSHKeys(ctx context.Context, handle string) ([]SSHKeyItem, error) {
+ atClient, did, err := s.PublicAccountReader(ctx, handle)
+ if err != nil {
+ return nil, err
+ }
+ records, err := atClient.ListAllRecords(ctx, did, sshKeyCollection, atproto.ListRecordsOpts{Limit: defaultListLimit})
+ if err != nil {
+ return nil, fmt.Errorf("list SSH keys for %q: %w", handle, err)
+ }
+ return buildSSHKeyItems(records), nil
+}
+
+// DeleteSSHKey removes a public key record from the authenticated user's account.
+func (s *Service) DeleteSSHKey(ctx context.Context, rkey string) (*DeletedRecordResult, error) {
+ atClient, did, err := s.AuthenticatedClient(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if err := atClient.DeleteRecord(ctx, atproto.DeleteRecordInput{
+ Repo: did,
+ Collection: sshKeyCollection,
+ Rkey: rkey,
+ }); err != nil {
+ return nil, fmt.Errorf("delete SSH key: %w", err)
+ }
+ return &DeletedRecordResult{Rkey: rkey}, nil
+}
+
+func buildSSHKeyItems(records []atproto.RecordItem) []SSHKeyItem {
+ items := make([]SSHKeyItem, 0, len(records))
+ for _, rec := range records {
+ var key tangled.SSHKeyRecord
+ data, err := json.Marshal(rec.Value)
+ if err != nil {
+ continue
+ }
+ if err := json.Unmarshal(data, &key); err != nil {
+ continue
+ }
+ items = append(items, SSHKeyItem{
+ Name: key.Name,
+ Key: key.Key,
+ CreatedAt: key.CreatedAt,
+ URI: rec.URI,
+ })
+ }
+ return items
+}
diff --git a/internal/app/strings.go b/internal/app/strings.go
new file mode 100644
index 0000000..36c8e30
--- /dev/null
+++ b/internal/app/strings.go
@@ -0,0 +1,137 @@
+package app
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "github.com/alyraffauf/tg/atproto"
+ "github.com/alyraffauf/tg/tangled"
+ "github.com/bluesky-social/indigo/atproto/syntax"
+)
+
+// CreateStringInput configures string creation.
+type CreateStringInput struct {
+ Filename string
+ Description string
+ Contents string
+}
+
+// CreateString writes a new sh.tangled.string record.
+func (s *Service) CreateString(ctx context.Context, in CreateStringInput) (*CreatedRecordResult, error) {
+ atClient, did, err := s.AuthenticatedClient(ctx)
+ if err != nil {
+ return nil, err
+ }
+ rkey := string(syntax.NewTIDNow(0))
+ uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{
+ Repo: did,
+ Collection: stringCollection,
+ Rkey: rkey,
+ Record: tangled.StringRecord{
+ Type: stringCollection,
+ Filename: in.Filename,
+ Description: in.Description,
+ Contents: in.Contents,
+ CreatedAt: time.Now().UTC().Format(time.RFC3339),
+ },
+ })
+ if err != nil {
+ return nil, fmt.Errorf("create string: %w", err)
+ }
+ return &CreatedRecordResult{Rkey: rkey, URI: uri}, nil
+}
+
+// ListStrings lists every string owned by handle.
+func (s *Service) ListStrings(ctx context.Context, handle string) ([]StringItem, error) {
+ atClient, did, err := s.PublicAccountReader(ctx, handle)
+ if err != nil {
+ return nil, err
+ }
+ records, err := atClient.ListAllRecords(ctx, did, stringCollection, atproto.ListRecordsOpts{Limit: defaultListLimit})
+ if err != nil {
+ return nil, fmt.Errorf("list strings for %q: %w", handle, err)
+ }
+ return buildStringItems(records), nil
+}
+
+// ViewString fetches a single string by rkey from handle's account.
+func (s *Service) ViewString(ctx context.Context, handle, rkey string) (*StringViewResult, error) {
+ atClient, did, err := s.PublicAccountReader(ctx, handle)
+ if err != nil {
+ return nil, err
+ }
+ found, err := atClient.GetRecord(ctx, did, stringCollection, rkey)
+ if err != nil {
+ return nil, fmt.Errorf("get string %q for %q: %w", rkey, handle, err)
+ }
+ record, err := decodeStringRecord(found.Value)
+ if err != nil {
+ return nil, fmt.Errorf("decode string %q: %w", rkey, err)
+ }
+ return &StringViewResult{
+ Rkey: rkey,
+ URI: found.URI,
+ Filename: record.Filename,
+ Author: Author{DID: did, Handle: handle},
+ Description: record.Description,
+ Contents: record.Contents,
+ CreatedAt: record.CreatedAt,
+ }, nil
+}
+
+// DeleteString removes a string record from the authenticated user's account.
+func (s *Service) DeleteString(ctx context.Context, rkey string) (*DeletedRecordResult, error) {
+ atClient, did, err := s.AuthenticatedClient(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if err := atClient.DeleteRecord(ctx, atproto.DeleteRecordInput{
+ Repo: did,
+ Collection: stringCollection,
+ Rkey: rkey,
+ }); err != nil {
+ return nil, fmt.Errorf("delete string: %w", err)
+ }
+ return &DeletedRecordResult{Rkey: rkey}, nil
+}
+
+func buildStringItems(records []atproto.RecordItem) []StringItem {
+ items := make([]StringItem, 0, len(records))
+ for _, rec := range records {
+ var str tangled.StringRecord
+ data, err := json.Marshal(rec.Value)
+ if err != nil {
+ continue
+ }
+ if err := json.Unmarshal(data, &str); err != nil {
+ continue
+ }
+ // Records without a filename are not strings; skip them rather
+ // than rendering a blank row.
+ if str.Filename == "" {
+ continue
+ }
+ items = append(items, StringItem{
+ Rkey: extractRKey(rec.URI),
+ URI: rec.URI,
+ Filename: str.Filename,
+ Description: str.Description,
+ CreatedAt: str.CreatedAt,
+ })
+ }
+ return items
+}
+
+func decodeStringRecord(value any) (tangled.StringRecord, error) {
+ data, err := json.Marshal(value)
+ if err != nil {
+ return tangled.StringRecord{}, fmt.Errorf("encode record: %w", err)
+ }
+ var record tangled.StringRecord
+ if err := json.Unmarshal(data, &record); err != nil {
+ return tangled.StringRecord{}, fmt.Errorf("decode record: %w", err)
+ }
+ return record, nil
+}
diff --git a/internal/cli/string_view_test.go b/internal/app/strings_test.go
similarity index 61%
rename from internal/cli/string_view_test.go
rename to internal/app/strings_test.go
index d05c987..53c71dd 100644
--- a/internal/cli/string_view_test.go
+++ b/internal/app/strings_test.go
@@ -1,14 +1,18 @@
-package cli
+package app
import (
"testing"
+
+ "github.com/alyraffauf/tg/tangled"
+
+ "github.com/alyraffauf/tg/atproto"
)
func TestDecodeStringRecord(t *testing.T) {
tests := []struct {
name string
value any
- want stringRecord
+ want tangled.StringRecord
wantErr bool
}{
{
@@ -20,7 +24,7 @@ func TestDecodeStringRecord(t *testing.T) {
"contents": "# hello",
"createdAt": "2026-07-18T23:15:54+03:00",
},
- want: stringRecord{
+ want: tangled.StringRecord{
Type: "sh.tangled.string",
Filename: "hello.md",
Description: "a greeting",
@@ -36,7 +40,7 @@ func TestDecodeStringRecord(t *testing.T) {
"contents": "no description",
"createdAt": "2026-07-18T12:00:00Z",
},
- want: stringRecord{
+ want: tangled.StringRecord{
Type: "sh.tangled.string",
Filename: "bare.md",
Contents: "no description",
@@ -51,7 +55,7 @@ func TestDecodeStringRecord(t *testing.T) {
"contents": "empty filename",
"createdAt": "2026-07-18T12:00:00Z",
},
- want: stringRecord{
+ want: tangled.StringRecord{
Type: "sh.tangled.string",
Filename: "",
Contents: "empty filename",
@@ -89,3 +93,43 @@ func TestDecodeStringRecord(t *testing.T) {
})
}
}
+
+func TestBuildStringItems(t *testing.T) {
+ records := []atproto.RecordItem{
+ {
+ URI: "at://did:plc:abc/sh.tangled.string/3k2abc",
+ Value: map[string]any{
+ "$type": "sh.tangled.string",
+ "filename": "test.d",
+ "description": "my test string",
+ "contents": "# hello",
+ "createdAt": "2026-07-18T23:15:54+03:00",
+ },
+ },
+ {
+ URI: "at://did:plc:abc/sh.tangled.string/3k2def",
+ Value: map[string]any{"not": "a string record"},
+ },
+ }
+
+ items := buildStringItems(records)
+
+ // Records without a filename are not strings and are skipped.
+ if len(items) != 1 {
+ t.Fatalf("buildStringItems() returned %d items, want 1", len(items))
+ }
+
+ first := items[0]
+ if first.Rkey != "3k2abc" {
+ t.Errorf("Rkey = %q, want %q", first.Rkey, "3k2abc")
+ }
+ if first.Filename != "test.d" {
+ t.Errorf("Filename = %q, want %q", first.Filename, "test.d")
+ }
+ if first.Description != "my test string" {
+ t.Errorf("Description = %q, want %q", first.Description, "my test string")
+ }
+ if first.CreatedAt != "2026-07-18T23:15:54+03:00" {
+ t.Errorf("CreatedAt = %q, want %q", first.CreatedAt, "2026-07-18T23:15:54+03:00")
+ }
+}
diff --git a/internal/app/target.go b/internal/app/target.go
new file mode 100644
index 0000000..a057cd0
--- /dev/null
+++ b/internal/app/target.go
@@ -0,0 +1,113 @@
+package app
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/alyraffauf/tg/internal/gitutil"
+ "github.com/alyraffauf/tg/tangled"
+ "github.com/bluesky-social/indigo/atproto/atclient"
+)
+
+// Target identifies a repository by owner handle (or DID) and repo name.
+type Target struct {
+ Handle string
+ Repo string
+}
+
+func (t Target) String() string { return t.Handle + "/" + t.Repo }
+
+// ParseTarget parses a "handle/repo" (or "did:plc:.../repo") argument.
+func ParseTarget(arg string) (Target, error) {
+ parts := strings.SplitN(arg, "/", 2)
+ if len(parts) != 2 || parts[0] == "" || parts[1] == "" || strings.Contains(parts[1], "/") {
+ return Target{}, fmt.Errorf("expected handle/repo, got %q", arg)
+ }
+ return Target{Handle: parts[0], Repo: parts[1]}, nil
+}
+
+// TargetFromCWD detects the target using the service's Git client.
+func (s *Service) TargetFromCWD(ctx context.Context) (Target, error) {
+ return targetFromCWD(ctx, s.Git)
+}
+
+func targetFromCWD(ctx context.Context, git *gitutil.Client) (Target, error) {
+ rc, err := git.DetectRepoFromCWD(ctx)
+ if err != nil {
+ return Target{}, fmt.Errorf("detect repo from current directory: %w", err)
+ }
+ return Target{Handle: rc.Handle, Repo: rc.Repo}, nil
+}
+
+// ResolveRepo finds a repository record even when its rkey does not match
+// the repository name.
+func (s *Service) ResolveRepo(ctx context.Context, t Target) (*tangled.Repo, error) {
+ ident, err := s.Resolver.ResolveHandle(ctx, t.Handle)
+ if err != nil {
+ return nil, fmt.Errorf("resolve handle %q: %w", t.Handle, err)
+ }
+
+ recordURI := fmt.Sprintf("at://%s/sh.tangled.repo/%s", ident.DID, t.Repo)
+ if repo, err := s.Appview.GetRepo(ctx, recordURI); err == nil {
+ if repo.URI == "" {
+ repo.URI = recordURI
+ }
+ return repo, nil
+ } else if !shouldListRepoRecords(err) {
+ return nil, fmt.Errorf("get repository %q: %w", t.Repo, err)
+ }
+
+ repos, err := s.Appview.ListRepos(ctx, ident.DID.String())
+ if err != nil {
+ return nil, fmt.Errorf("list repos for %q: %w", t.Handle, err)
+ }
+ for index := range repos.Items {
+ repo := &repos.Items[index]
+ if repo.Value.Name == t.Repo || extractRKey(repo.URI) == t.Repo {
+ return repo, nil
+ }
+ }
+ return nil, fmt.Errorf("repo %q not found for handle %q", t.Repo, t.Handle)
+}
+
+// RepoDID resolves a target to its repoDid, the key the issue/PR listings use.
+func (s *Service) RepoDID(ctx context.Context, t Target) (string, error) {
+ record, err := s.ResolveRepo(ctx, t)
+ if err != nil {
+ return "", err
+ }
+ return record.Value.RepoDid, nil
+}
+
+// RequireOwnedRepo resolves a target and verifies it is owned by did.
+func (s *Service) RequireOwnedRepo(ctx context.Context, t Target, did string) (*tangled.Repo, error) {
+ repo, err := s.ResolveRepo(ctx, t)
+ if err != nil {
+ return nil, err
+ }
+ if extractDID(repo.URI) != did {
+ return nil, fmt.Errorf("repo %q is not owned by the authenticated user", t.String())
+ }
+ return repo, nil
+}
+
+// shouldListRepoRecords reports whether err indicates the record is absent at
+// the name-derived rkey and so a full listing is required to find it.
+func shouldListRepoRecords(err error) bool {
+ var apiError *atclient.APIError
+ if !errors.As(err, &apiError) {
+ return false
+ }
+ if apiError.StatusCode == http.StatusNotFound {
+ return true
+ }
+
+ // Bobbin wraps an upstream PDS 400 as a 502 when no record exists at the
+ // name-derived rkey. Listing is required to find the record's actual rkey.
+ return apiError.StatusCode == http.StatusBadGateway &&
+ apiError.Name == "UpstreamFailed" &&
+ strings.Contains(apiError.Message, "upstream returned status 400 Bad Request")
+}
diff --git a/internal/cli/target_test.go b/internal/app/target_test.go
similarity index 64%
rename from internal/cli/target_test.go
rename to internal/app/target_test.go
index 66d6dc6..132f3c0 100644
--- a/internal/cli/target_test.go
+++ b/internal/app/target_test.go
@@ -1,8 +1,8 @@
-package cli
+package app
import "testing"
-func TestParseHandleRepo(t *testing.T) {
+func TestParseTarget(t *testing.T) {
tests := []struct {
name string
arg string
@@ -22,13 +22,20 @@ func TestParseHandleRepo(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- handle, repo, err := parseHandleRepo(tt.arg)
+ target, err := ParseTarget(tt.arg)
if (err != nil) != tt.wantErr {
t.Fatalf("error = %v, wantErr %v", err, tt.wantErr)
}
- if handle != tt.wantHandle || repo != tt.wantRepo {
- t.Fatalf("got (%q, %q), want (%q, %q)", handle, repo, tt.wantHandle, tt.wantRepo)
+ if target.Handle != tt.wantHandle || target.Repo != tt.wantRepo {
+ t.Fatalf("got (%q, %q), want (%q, %q)", target.Handle, target.Repo, tt.wantHandle, tt.wantRepo)
}
})
}
}
+
+func TestTargetString(t *testing.T) {
+ target := Target{Handle: "aly.codes", Repo: "tg"}
+ if got := target.String(); got != "aly.codes/tg" {
+ t.Fatalf("String() = %q, want %q", got, "aly.codes/tg")
+ }
+}
diff --git a/internal/app/types.go b/internal/app/types.go
new file mode 100644
index 0000000..d5787fd
--- /dev/null
+++ b/internal/app/types.go
@@ -0,0 +1,180 @@
+package app
+
+// This file holds the result/domain types returned by Service methods. They
+// are the canonical structured-data contract between the application layer
+// and any frontend: every operation produces one of these (or a slice),
+// and the CLI renders them as JSON (--json) or human-readable text.
+
+// Author is the owner or creator of a record, resolved from a DID.
+type Author struct {
+ DID string `json:"did"`
+ Handle string `json:"handle"`
+}
+
+// Item is a listing entry for an issue or a pull request. SourceBranch and
+// TargetBranch are only populated (and only emitted as JSON) for pulls.
+type Item struct {
+ Rkey string `json:"rkey"`
+ URI string `json:"uri"`
+ Title string `json:"title"`
+ State string `json:"state"`
+ Author Author `json:"author"`
+ CreatedAt string `json:"createdAt"`
+ UpdatedAt string `json:"updatedAt,omitempty"`
+ CommentCount int64 `json:"commentCount"`
+ SourceBranch string `json:"sourceBranch,omitempty"`
+ TargetBranch string `json:"targetBranch,omitempty"`
+}
+
+// RepoItem is a single repository in a listing or view.
+type RepoItem struct {
+ Name string `json:"name"`
+ URI string `json:"uri"`
+ Author string `json:"author"`
+ Knot string `json:"knot"`
+ Description string `json:"description,omitempty"`
+ CreatedAt string `json:"createdAt"`
+ RepoDid string `json:"repoDid,omitempty"`
+}
+
+// SSHKeyItem is one SSH public key in a listing.
+type SSHKeyItem struct {
+ Name string `json:"name"`
+ Key string `json:"key"`
+ CreatedAt string `json:"createdAt"`
+ URI string `json:"uri"`
+}
+
+// StringItem is one tangled string in a listing.
+type StringItem struct {
+ Rkey string `json:"rkey"`
+ URI string `json:"uri"`
+ Filename string `json:"filename"`
+ Description string `json:"description,omitempty"`
+ CreatedAt string `json:"createdAt"`
+}
+
+// StringViewResult is the full view of a single string.
+type StringViewResult struct {
+ Rkey string `json:"rkey"`
+ URI string `json:"uri"`
+ Filename string `json:"filename"`
+ Author Author `json:"author"`
+ Description string `json:"description,omitempty"`
+ Contents string `json:"contents"`
+ CreatedAt string `json:"createdAt"`
+}
+
+// ViewResult is a single issue or pull request. SourceBranch and
+// TargetBranch are only populated (and only emitted as JSON) for pulls.
+type ViewResult struct {
+ Rkey string `json:"rkey"`
+ Title string `json:"title"`
+ Body string `json:"body,omitempty"`
+ Author Author `json:"author"`
+ CreatedAt string `json:"createdAt"`
+ SourceBranch string `json:"sourceBranch,omitempty"`
+ TargetBranch string `json:"targetBranch,omitempty"`
+}
+
+// CreatedRecordResult is returned by any operation that creates a record.
+type CreatedRecordResult struct {
+ Rkey string `json:"rkey"`
+ URI string `json:"uri"`
+}
+
+// DeletedRecordResult is returned by any operation that deletes a record.
+type DeletedRecordResult struct {
+ Rkey string `json:"rkey"`
+}
+
+// StateResult is returned by issue/PR state changes (close, reopen, merge).
+type StateResult struct {
+ Rkey string `json:"rkey"`
+ State string `json:"state"`
+}
+
+// RepoCreateResult is returned by repository creation.
+type RepoCreateResult struct {
+ Handle string `json:"handle"`
+ Name string `json:"name"`
+ URI string `json:"uri"`
+ Knot string `json:"knot"`
+ Cloned bool `json:"cloned"`
+ Pushed bool `json:"pushed"`
+}
+
+// RepoCloneResult is returned by repository cloning.
+type RepoCloneResult struct {
+ Handle string `json:"handle"`
+ Repo string `json:"repo"`
+ Destination string `json:"destination"`
+}
+
+// RepoEditResult is returned by repository edits.
+type RepoEditResult struct {
+ URI string `json:"uri"`
+ Description string `json:"description"`
+}
+
+// RepoDeleteResult is returned by repository deletion.
+type RepoDeleteResult struct {
+ URI string `json:"uri"`
+}
+
+// RepoDefaultBranchResult is returned by setting a repo's default branch.
+type RepoDefaultBranchResult struct {
+ URI string `json:"uri"`
+ Branch string `json:"branch"`
+}
+
+// RepoForkResult is returned by repository forking.
+type RepoForkResult struct {
+ Handle string `json:"handle"`
+ Name string `json:"name"`
+ URI string `json:"uri"`
+ Knot string `json:"knot"`
+}
+
+// PRCreateResult is returned by pull request creation.
+type PRCreateResult struct {
+ URI string `json:"uri"`
+ Title string `json:"title"`
+ Base string `json:"base"`
+ Head string `json:"head"`
+}
+
+// PRCheckoutResult is returned by pull request checkout.
+type PRCheckoutResult struct {
+ Rkey string `json:"rkey"`
+ Branch string `json:"branch"`
+}
+
+// SSHKeyAddResult is returned by SSH key addition.
+type SSHKeyAddResult struct {
+ Name string `json:"name"`
+ URI string `json:"uri"`
+}
+
+// AuthStatusResult is returned by an auth status probe.
+type AuthStatusResult struct {
+ Authenticated bool `json:"authenticated"`
+ Status string `json:"status,omitempty"`
+ DID string `json:"did,omitempty"`
+ Handle string `json:"handle,omitempty"`
+}
+
+// AuthLogoutResult is returned by logout. WasLoggedIn reports whether a
+// session existed and was cleared; it is false when there was nothing to
+// log out (not a failure — the command still exits 0).
+type AuthLogoutResult struct {
+ WasLoggedIn bool `json:"wasLoggedIn"`
+}
+
+// AuthAccountResult is one account in an account listing or switch.
+type AuthAccountResult struct {
+ Active bool `json:"active"`
+ DID string `json:"did"`
+ Handle string `json:"handle"`
+ Method string `json:"method"`
+}
diff --git a/internal/cli/api.go b/internal/cli/api.go
index b5623af..43b5b25 100644
--- a/internal/cli/api.go
+++ b/internal/cli/api.go
@@ -3,63 +3,54 @@ package cli
import (
"bytes"
"encoding/json"
- "errors"
"fmt"
- "io"
"net/http"
"strings"
- "github.com/alyraffauf/tg/atproto"
- "github.com/bluesky-social/indigo/atproto/atclient"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/spf13/cobra"
)
-var (
- apiMethod string
- apiFields []string
-)
-
-var apiCmd = &cobra.Command{
- Use: "api ",
- Short: "Call an authenticated XRPC endpoint",
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- endpoint, err := syntax.ParseNSID(args[0])
- if err != nil {
- return fmt.Errorf("parse NSID: %w", err)
- }
- fields, err := parseAPIFields(apiFields)
- if err != nil {
- return err
- }
- method := strings.ToUpper(apiMethod)
- if method == "GET" && len(apiFields) > 0 && !cmd.Flags().Changed("method") {
- method = "POST"
- }
- if method != http.MethodGet && method != http.MethodPost {
- return fmt.Errorf("method must be GET or POST, got %q", apiMethod)
- }
+func newAPICommand(service *app.Service) *cobra.Command {
+ var methodFlag string
+ var fieldsFlag []string
- client, _, err := auth.APIClient(cmd.Context())
- if err != nil {
- if errors.Is(err, atproto.ErrNotAuthenticated) {
- return fmt.Errorf("not logged in; run \"tg auth login\" first")
+ command := &cobra.Command{
+ Use: "api ",
+ Short: "Call an authenticated XRPC endpoint",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ endpoint, err := syntax.ParseNSID(args[0])
+ if err != nil {
+ return fmt.Errorf("parse NSID: %w", err)
+ }
+ fields, err := parseAPIFields(fieldsFlag)
+ if err != nil {
+ return err
+ }
+ method := strings.ToUpper(methodFlag)
+ if method == "GET" && len(fieldsFlag) > 0 && !cmd.Flags().Changed("method") {
+ method = "POST"
+ }
+ if method != http.MethodGet && method != http.MethodPost {
+ return fmt.Errorf("method must be GET or POST, got %q", methodFlag)
}
- return fmt.Errorf("resume auth session: %w", err)
- }
- response, err := doAPIRequest(cmd, client, endpoint, method, fields)
- if err != nil {
- return err
- }
- defer response.Body.Close()
- return writeAPIResponse(cmd, response)
- },
-}
-func init() {
- apiCmd.Flags().StringVarP(&apiMethod, "method", "X", "GET", "HTTP method (GET or POST)")
- apiCmd.Flags().StringArrayVarP(&apiFields, "field", "f", nil, "Add a key=value field")
+ response, err := service.CallAPI(cmd.Context(), app.APIRequestInput{
+ Endpoint: endpoint,
+ Method: method,
+ Fields: fields,
+ })
+ if err != nil {
+ return err
+ }
+ return writeAPIResponse(cmd, response)
+ },
+ }
+ command.Flags().StringVarP(&methodFlag, "method", "X", "GET", "HTTP method (GET or POST)")
+ command.Flags().StringArrayVarP(&fieldsFlag, "field", "f", nil, "Add a key=value field")
+ return command
}
func parseAPIFields(rawFields []string) (map[string]any, error) {
@@ -80,56 +71,19 @@ func parseAPIFields(rawFields []string) (map[string]any, error) {
return fields, nil
}
-func doAPIRequest(cmd *cobra.Command, client *atclient.APIClient, endpoint syntax.NSID, method string, fields map[string]any) (*http.Response, error) {
- request := atclient.NewAPIRequest(method, endpoint, nil)
- if method == http.MethodGet {
- query := make(map[string]string, len(fields))
- for key, value := range fields {
- query[key] = fmt.Sprint(value)
- }
- request.QueryParams = makeURLValues(query)
- } else {
- encoded, err := json.Marshal(fields)
- if err != nil {
- return nil, fmt.Errorf("encode request body: %w", err)
- }
- request.Body = bytes.NewReader(encoded)
- request.Headers.Set("Content-Type", "application/json")
- }
- request.Headers.Set("Accept", "application/json")
-
- response, err := client.Do(cmd.Context(), request)
- if err != nil {
- return nil, fmt.Errorf("call %s: %w", endpoint, err)
- }
- return response, nil
-}
-
-func makeURLValues(fields map[string]string) map[string][]string {
- values := make(map[string][]string, len(fields))
- for key, value := range fields {
- values[key] = []string{value}
- }
- return values
-}
-
-func writeAPIResponse(cmd *cobra.Command, response *http.Response) error {
- body, err := io.ReadAll(response.Body)
- if err != nil {
- return fmt.Errorf("read API response: %w", err)
- }
+func writeAPIResponse(cmd *cobra.Command, response *app.APIResponse) error {
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
- return fmt.Errorf("API returned HTTP %d: %s", response.StatusCode, strings.TrimSpace(string(body)))
+ return fmt.Errorf("API returned HTTP %d: %s", response.StatusCode, strings.TrimSpace(string(response.Body)))
}
- if json.Valid(body) {
+ if json.Valid(response.Body) {
var formatted bytes.Buffer
- if err := json.Indent(&formatted, body, "", " "); err == nil {
+ if err := json.Indent(&formatted, response.Body, "", " "); err == nil {
formatted.WriteByte('\n')
_, err = cmd.OutOrStdout().Write(formatted.Bytes())
return err
}
}
- _, err = cmd.OutOrStdout().Write(body)
+ _, err := cmd.OutOrStdout().Write(response.Body)
return err
}
diff --git a/internal/cli/atproto_auth.go b/internal/cli/atproto_auth.go
deleted file mode 100644
index c9c0cc4..0000000
--- a/internal/cli/atproto_auth.go
+++ /dev/null
@@ -1,38 +0,0 @@
-package cli
-
-import (
- "context"
- "errors"
- "fmt"
-
- "github.com/alyraffauf/tg/atproto"
- "github.com/bluesky-social/indigo/atproto/atclient"
-)
-
-func authenticatedATProto(ctx context.Context) (*atproto.ATProto, string, error) {
- client, did, err := auth.APIClient(ctx)
- if err != nil {
- if errors.Is(err, atproto.ErrNotAuthenticated) {
- return nil, "", fmt.Errorf("not logged in; run \"tg auth login\" first")
- }
- return nil, "", fmt.Errorf("resume auth session: %w", err)
- }
- return &atproto.ATProto{Client: client}, did.String(), nil
-}
-
-// publicAccountReader resolves handle to an unauthenticated client on its
-// PDS for read-only queries of account-owned records (strings, public
-// keys), returning the client and the owner's DID.
-func publicAccountReader(ctx context.Context, handle string) (*atproto.ATProto, string, error) {
- ident, err := resolver.ResolveHandle(ctx, handle)
- if err != nil {
- return nil, "", fmt.Errorf("resolve handle %q: %w", handle, err)
- }
-
- pdsURL, err := resolver.ResolvePDS(ctx, ident.DID.String())
- if err != nil {
- return nil, "", fmt.Errorf("resolve PDS for %q: %w", handle, err)
- }
-
- return &atproto.ATProto{Client: &atclient.APIClient{Host: pdsURL}}, ident.DID.String(), nil
-}
diff --git a/internal/cli/auth.go b/internal/cli/auth.go
index b9e0ca4..bea57e8 100644
--- a/internal/cli/auth.go
+++ b/internal/cli/auth.go
@@ -1,8 +1,13 @@
package cli
-import "github.com/spf13/cobra"
+import (
+ "github.com/alyraffauf/tg/internal/app"
+ "github.com/spf13/cobra"
+)
-var authCmd = &cobra.Command{
- Use: "auth",
- Short: "Manage authentication",
+func newAuthCommand(_ *app.Service) *cobra.Command {
+ return &cobra.Command{
+ Use: "auth",
+ Short: "Manage authentication",
+ }
}
diff --git a/internal/cli/auth_list.go b/internal/cli/auth_list.go
index 2791cfb..c55c08f 100644
--- a/internal/cli/auth_list.go
+++ b/internal/cli/auth_list.go
@@ -3,42 +3,33 @@ package cli
import (
"fmt"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
-var authListCmd = &cobra.Command{
- Use: "list",
- Short: "List authenticated accounts",
- Args: cobra.NoArgs,
- RunE: func(cmd *cobra.Command, _ []string) error {
- accounts, activeDID, err := auth.Accounts()
- if err != nil {
- return fmt.Errorf("list accounts: %w", err)
- }
- results := make([]authAccountResult, 0, len(accounts))
- for _, account := range accounts {
- handle := account.Handle
- resolved := resolveAuthor(cmd.Context(), account.DID)
- if resolved.Handle != account.DID {
- handle = resolved.Handle
+func newAuthListCommand(service *app.Service) *cobra.Command {
+ return &cobra.Command{
+ Use: "list",
+ Short: "List authenticated accounts",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ results, err := service.AuthAccounts(cmd.Context())
+ if err != nil {
+ return err
}
- results = append(results, authAccountResult{
- Active: account.DID == activeDID,
- DID: account.DID, Handle: handle, Method: account.Method,
- })
- }
- return output(results, func(items []authAccountResult) {
- if len(items) == 0 {
- fmt.Println("No accounts.")
- return
- }
- for _, item := range items {
- marker := " "
- if item.Active {
- marker = "*"
+ return output(cmd, results, func(items []app.AuthAccountResult) {
+ if len(items) == 0 {
+ fmt.Fprintln(cmd.OutOrStdout(), "No accounts.")
+ return
}
- fmt.Printf("%s %s %s %s\n", marker, item.Handle, item.DID, item.Method)
- }
- })
- },
+ for _, item := range items {
+ marker := " "
+ if item.Active {
+ marker = "*"
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "%s %s %s %s\n", marker, item.Handle, item.DID, item.Method)
+ }
+ })
+ },
+ }
}
diff --git a/internal/cli/auth_login.go b/internal/cli/auth_login.go
index c882b55..1d317c4 100644
--- a/internal/cli/auth_login.go
+++ b/internal/cli/auth_login.go
@@ -10,74 +10,75 @@ import (
"runtime"
"strings"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
-var authLoginPasswordStdin bool
-
-var authLoginCmd = &cobra.Command{
- Use: "login [app-password]",
- Short: "Log in to atproto via OAuth or an app password",
- Long: `Log in with OAuth, or use an app password as the second argument for headless login.`,
- Args: cobra.RangeArgs(1, 2),
- RunE: func(cmd *cobra.Command, args []string) error {
- identifier := args[0]
- password, usePassword, err := loginPassword(args, authLoginPasswordStdin, cmd.InOrStdin())
- if err != nil {
- return err
- }
- if usePassword {
- if err := auth.LoginWithPassword(cmd.Context(), identifier, password); err != nil {
- return err
- }
- did, err := auth.CurrentDID(cmd.Context())
+func newAuthLoginCommand(service *app.Service) *cobra.Command {
+ var passwordStdin bool
+
+ command := &cobra.Command{
+ Use: "login [app-password]",
+ Short: "Log in to atproto via OAuth or an app password",
+ Long: `Log in with OAuth, or use an app password as the second argument for headless login.`,
+ Args: cobra.RangeArgs(1, 2),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ identifier := args[0]
+ password, usePassword, err := loginPassword(args, passwordStdin, cmd.InOrStdin())
if err != nil {
- fmt.Fprintln(os.Stderr, "Login completed but session could not be confirmed.")
return err
}
- fmt.Printf("Logged in as %s\n", did)
- return nil
- }
-
- server, resultChannel, err := runCallbackServer()
- if err != nil {
- return err
- }
- defer server.Shutdown(context.Background())
-
- ctx := cmd.Context()
- loginURL, err := auth.StartLogin(ctx, identifier)
- if err != nil {
- auth.CancelLogin()
- return err
- }
- defer auth.CancelLogin()
-
- fmt.Println("Opening browser to complete login...")
- if err := openBrowser(loginURL); err != nil {
- fmt.Printf("Could not open browser. Open this URL manually:\n%s\n", loginURL)
- }
+ if usePassword {
+ if err := service.LoginWithPassword(cmd.Context(), identifier, password); err != nil {
+ return err
+ }
+ did, err := service.CurrentDID(cmd.Context())
+ if err != nil {
+ fmt.Fprintln(cmd.ErrOrStderr(), "Login completed but session could not be confirmed.")
+ return err
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "Logged in as %s\n", did)
+ return nil
+ }
- select {
- case err := <-resultChannel:
+ server, resultChannel, err := runCallbackServer(service)
if err != nil {
return err
}
- did, err := auth.CurrentDID(ctx)
+ defer server.Shutdown(context.Background())
+
+ ctx := cmd.Context()
+ loginURL, err := service.StartLogin(ctx, identifier)
if err != nil {
- fmt.Fprintln(os.Stderr, "Login completed but session could not be confirmed.")
+ service.CancelLogin()
return err
}
- fmt.Printf("Logged in as %s\n", did)
- return nil
- case <-ctx.Done():
- return ctx.Err()
- }
- },
-}
+ defer service.CancelLogin()
-func init() {
- authLoginCmd.Flags().BoolVar(&authLoginPasswordStdin, "password-stdin", false, "Read the app password from standard input")
+ fmt.Fprintln(cmd.OutOrStdout(), "Opening browser to complete login...")
+ if err := openBrowser(loginURL); err != nil {
+ fmt.Fprintf(cmd.ErrOrStderr(), "Could not open browser. Open this URL manually:\n%s\n", loginURL)
+ }
+
+ select {
+ case err := <-resultChannel:
+ if err != nil {
+ return err
+ }
+ did, err := service.CurrentDID(ctx)
+ if err != nil {
+ fmt.Fprintln(cmd.ErrOrStderr(), "Login completed but session could not be confirmed.")
+ return err
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "Logged in as %s\n", did)
+ return nil
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ },
+ }
+ command.Flags().BoolVar(&passwordStdin, "password-stdin", false, "Read the app password from standard input")
+ return command
}
func loginPassword(args []string, fromStdin bool, stdin io.Reader) (string, bool, error) {
@@ -101,12 +102,12 @@ func loginPassword(args []string, fromStdin bool, stdin io.Reader) (string, bool
return password, true, nil
}
-func runCallbackServer() (*http.Server, <-chan error, error) {
+func runCallbackServer(service *app.Service) (*http.Server, <-chan error, error) {
resultChannel := make(chan error, 1)
serveMux := http.NewServeMux()
serveMux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
- if err := auth.FinishLogin(r.Context(), r.URL.Query()); err != nil {
+ if err := service.FinishLogin(r.Context(), r.URL.Query()); err != nil {
resultChannel <- err
http.Error(w, err.Error(), http.StatusBadRequest)
return
diff --git a/internal/cli/auth_logout.go b/internal/cli/auth_logout.go
index 1a3db80..58e2192 100644
--- a/internal/cli/auth_logout.go
+++ b/internal/cli/auth_logout.go
@@ -1,44 +1,33 @@
package cli
import (
- "errors"
"fmt"
- "github.com/alyraffauf/tg/atproto"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
-var authLogoutAll bool
+func newAuthLogoutCommand(service *app.Service) *cobra.Command {
+ var logoutAll bool
-var authLogoutCmd = &cobra.Command{
- Use: "logout",
- Short: "Log out of your AT Protocol account",
- Args: cobra.NoArgs,
- RunE: func(cmd *cobra.Command, args []string) error {
- var err error
- if authLogoutAll {
- err = auth.LogoutAll(cmd.Context())
- } else {
- err = auth.Logout(cmd.Context())
- }
- wasLoggedIn := true
- if err != nil {
- if errors.Is(err, atproto.ErrNotAuthenticated) {
- wasLoggedIn = false
- } else {
+ command := &cobra.Command{
+ Use: "logout",
+ Short: "Log out of your AT Protocol account",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ result, err := service.Logout(cmd.Context(), logoutAll)
+ if err != nil {
return err
}
- }
- return output(authLogoutResult{WasLoggedIn: wasLoggedIn}, func(r authLogoutResult) {
- if r.WasLoggedIn {
- fmt.Println("Logged out.")
- } else {
- fmt.Println("Not logged in.")
- }
- })
- },
-}
-
-func init() {
- authLogoutCmd.Flags().BoolVar(&authLogoutAll, "all", false, "Log out all accounts")
+ return output(cmd, result, func(r *app.AuthLogoutResult) {
+ if r.WasLoggedIn {
+ fmt.Fprintln(cmd.OutOrStdout(), "Logged out.")
+ } else {
+ fmt.Fprintln(cmd.OutOrStdout(), "Not logged in.")
+ }
+ })
+ },
+ }
+ command.Flags().BoolVar(&logoutAll, "all", false, "Log out all accounts")
+ return command
}
diff --git a/internal/cli/auth_status.go b/internal/cli/auth_status.go
index 2398b5b..54d00b5 100644
--- a/internal/cli/auth_status.go
+++ b/internal/cli/auth_status.go
@@ -1,45 +1,35 @@
package cli
import (
- "errors"
"fmt"
- "github.com/alyraffauf/tg/atproto"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
-var authStatusCmd = &cobra.Command{
- Use: "status",
- Short: "Show authentication status",
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx := cmd.Context()
-
- status, did, err := auth.SessionStatus(ctx)
- if err != nil {
- if !errors.Is(err, atproto.ErrNotAuthenticated) {
- return fmt.Errorf("check session: %w", err)
+func newAuthStatusCommand(service *app.Service) *cobra.Command {
+ return &cobra.Command{
+ Use: "status",
+ Short: "Show authentication status",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ result, err := service.AuthStatus(cmd.Context())
+ if err != nil {
+ return err
}
- return output(authStatusResult{}, func(_ authStatusResult) {
- fmt.Println("Not logged in.")
+ return output(cmd, result, func(r *app.AuthStatusResult) {
+ if !r.Authenticated {
+ fmt.Fprintln(cmd.OutOrStdout(), "Not logged in.")
+ return
+ }
+ switch r.Status {
+ case app.SessionStatusActive:
+ fmt.Fprintf(cmd.OutOrStdout(), "Logged in as %s\n", r.Handle)
+ case app.SessionStatusExpired:
+ fmt.Fprintln(cmd.OutOrStdout(), "Session expired. Run \"tg auth login\" to re-authenticate.")
+ case app.SessionStatusUnknown:
+ fmt.Fprintln(cmd.OutOrStdout(), "Unable to verify session (network error).")
+ }
})
- }
-
- author := resolveAuthor(ctx, did.String())
- result := authStatusResult{
- Authenticated: true,
- Status: status,
- DID: author.DID,
- Handle: author.Handle,
- }
- return output(result, func(r authStatusResult) {
- switch r.Status {
- case atproto.SessionStatusActive:
- fmt.Printf("Logged in as %s\n", r.Handle)
- case atproto.SessionStatusExpired:
- fmt.Println("Session expired. Run \"tg auth login\" to re-authenticate.")
- case atproto.SessionStatusUnknown:
- fmt.Println("Unable to verify session (network error).")
- }
- })
- },
+ },
+ }
}
diff --git a/internal/cli/auth_switch.go b/internal/cli/auth_switch.go
index 07cab89..4067df5 100644
--- a/internal/cli/auth_switch.go
+++ b/internal/cli/auth_switch.go
@@ -3,23 +3,23 @@ package cli
import (
"fmt"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
-var authSwitchCmd = &cobra.Command{
- Use: "switch ",
- Short: "Select the active account",
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- account, err := auth.SelectAccount(args[0])
- if err != nil {
- return fmt.Errorf("select account %q: %w", args[0], err)
- }
- resolved := resolveAuthor(cmd.Context(), account.DID)
- return output(authAccountResult{
- Active: true, DID: account.DID, Handle: resolved.Handle, Method: account.Method,
- }, func(item authAccountResult) {
- fmt.Printf("Switched to %s\n", item.Handle)
- })
- },
+func newAuthSwitchCommand(service *app.Service) *cobra.Command {
+ return &cobra.Command{
+ Use: "switch ",
+ Short: "Select the active account",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ result, err := service.SwitchAccount(cmd.Context(), args[0])
+ if err != nil {
+ return err
+ }
+ return output(cmd, result, func(item *app.AuthAccountResult) {
+ fmt.Fprintf(cmd.OutOrStdout(), "Switched to %s\n", item.Handle)
+ })
+ },
+ }
}
diff --git a/internal/cli/auth_token.go b/internal/cli/auth_token.go
index 95915aa..84dac00 100644
--- a/internal/cli/auth_token.go
+++ b/internal/cli/auth_token.go
@@ -1,48 +1,24 @@
package cli
import (
- "errors"
"fmt"
- "github.com/alyraffauf/tg/atproto"
- "github.com/bluesky-social/indigo/atproto/atclient"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
-var authTokenCmd = &cobra.Command{
- Use: "token",
- Short: "Print the current access token",
- Args: cobra.NoArgs,
- RunE: func(cmd *cobra.Command, _ []string) error {
- ctx := cmd.Context()
- session, err := auth.CurrentSession(ctx)
- if err == nil {
- token, _ := session.GetHostAccessData()
- if token == "" {
- return fmt.Errorf("current session has no access token")
+func newAuthTokenCommand(service *app.Service) *cobra.Command {
+ return &cobra.Command{
+ Use: "token",
+ Short: "Print the current access token",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ token, err := service.AccessToken(cmd.Context())
+ if err != nil {
+ return err
}
fmt.Fprintln(cmd.OutOrStdout(), token)
return nil
- }
- if !errors.Is(err, atproto.ErrNotAuthenticated) {
- return fmt.Errorf("resume OAuth session: %w", err)
- }
- client, _, err := auth.APIClient(ctx)
- if err != nil {
- if errors.Is(err, atproto.ErrNotAuthenticated) {
- return fmt.Errorf("not logged in; run \"tg auth login\" first")
- }
- return fmt.Errorf("resume auth session: %w", err)
- }
- passwordAuth, ok := client.Auth.(*atclient.PasswordAuth)
- if !ok {
- return fmt.Errorf("not logged in; run \"tg auth login\" first")
- }
- token, _ := passwordAuth.GetTokens()
- if token == "" {
- return fmt.Errorf("current session has no access token")
- }
- fmt.Fprintln(cmd.OutOrStdout(), token)
- return nil
- },
+ },
+ }
}
diff --git a/internal/cli/browse.go b/internal/cli/browse.go
index 64f1317..8bcd301 100644
--- a/internal/cli/browse.go
+++ b/internal/cli/browse.go
@@ -6,25 +6,28 @@ import (
"os/exec"
"runtime"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
-var browseCmd = &cobra.Command{
- Use: "browse [handle/repo]",
- Short: "Open a Tangled repository in a browser",
- Args: cobra.MaximumNArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- handle, repo, err := resolveTarget(cmd.Context(), args)
- if err != nil {
- return err
- }
+func newBrowseCommand(service *app.Service) *cobra.Command {
+ return &cobra.Command{
+ Use: "browse [handle/repo]",
+ Short: "Open a Tangled repository in a browser",
+ Args: cobra.MaximumNArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ target, err := resolveTarget(cmd.Context(), args, service)
+ if err != nil {
+ return err
+ }
- repoURL := "https://tangled.org/" + url.PathEscape(handle) + "/" + url.PathEscape(repo)
- if err := openURL(repoURL); err != nil {
- return fmt.Errorf("open browser: %w", err)
- }
- return nil
- },
+ repoURL := "https://tangled.org/" + url.PathEscape(target.Handle) + "/" + url.PathEscape(target.Repo)
+ if err := openURL(repoURL); err != nil {
+ return fmt.Errorf("open browser: %w", err)
+ }
+ return nil
+ },
+ }
}
// openURL passes the URL as an argument instead of through a shell.
diff --git a/internal/cli/completion.go b/internal/cli/completion.go
index 2c0cc64..b8511af 100644
--- a/internal/cli/completion.go
+++ b/internal/cli/completion.go
@@ -2,28 +2,30 @@ package cli
import (
"fmt"
- "os"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
-var completionCmd = &cobra.Command{
- Use: "completion [bash|zsh|fish|powershell]",
- Short: "Generate shell completion script",
- Args: cobra.ExactArgs(1),
- ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
- RunE: func(_ *cobra.Command, args []string) error {
- switch args[0] {
- case "bash":
- return rootCmd.GenBashCompletion(os.Stdout)
- case "zsh":
- return rootCmd.GenZshCompletion(os.Stdout)
- case "fish":
- return rootCmd.GenFishCompletion(os.Stdout, true)
- case "powershell":
- return rootCmd.GenPowerShellCompletion(os.Stdout)
- default:
- return fmt.Errorf("unsupported shell %q", args[0])
- }
- },
+func newCompletionCommand(_ *app.Service) *cobra.Command {
+ return &cobra.Command{
+ Use: "completion [bash|zsh|fish|powershell]",
+ Short: "Generate shell completion script",
+ Args: cobra.ExactArgs(1),
+ ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ switch args[0] {
+ case "bash":
+ return cmd.Root().GenBashCompletion(cmd.OutOrStdout())
+ case "zsh":
+ return cmd.Root().GenZshCompletion(cmd.OutOrStdout())
+ case "fish":
+ return cmd.Root().GenFishCompletion(cmd.OutOrStdout(), true)
+ case "powershell":
+ return cmd.Root().GenPowerShellCompletion(cmd.OutOrStdout())
+ default:
+ return fmt.Errorf("unsupported shell %q", args[0])
+ }
+ },
+ }
}
diff --git a/internal/cli/config.go b/internal/cli/config.go
index 2ad05e5..50bd625 100644
--- a/internal/cli/config.go
+++ b/internal/cli/config.go
@@ -3,6 +3,7 @@ package cli
import (
"errors"
"fmt"
+ "io"
"os"
"path/filepath"
"strings"
@@ -18,11 +19,26 @@ const (
// config resolves values with the following precedence (highest to lowest):
// command-line flags, environment variables prefixed TG_, config file, defaults.
-var config = viper.NewWithOptions(viper.KeyDelimiter("."))
+type settings struct {
+ Appview string
+ Account string
+}
-var configPath string
+type flagSettings struct {
+ ConfigPath string
+ Appview string
+ Account string
+ ConfigSet bool
+ AppviewSet bool
+ AccountSet bool
+}
-func initConfig() {
+func loadConfig(flags flagSettings, errorWriter io.Writer) settings {
+ config := viper.NewWithOptions(viper.KeyDelimiter("."))
+ configPath := flags.ConfigPath
+ if !flags.ConfigSet && configPath == "" {
+ configPath = os.Getenv("TG_CONFIG")
+ }
config.SetConfigName(configName)
config.SetConfigType(configType)
@@ -43,11 +59,26 @@ func initConfig() {
if err := config.ReadInConfig(); err != nil {
if _, ok := errors.AsType[viper.ConfigFileNotFoundError](err); ok {
// A missing config file is fine; configuration is optional.
- return
+ return applyFlagSettings(settings{
+ Appview: config.GetString("appview"),
+ Account: config.GetString("account"),
+ }, flags)
}
// Surface parse/permission errors but keep running with defaults.
- fmt.Fprintln(os.Stderr, "warning: failed to read config:", err)
+ fmt.Fprintln(errorWriter, "warning: failed to read config:", err)
+ }
+ resolved := settings{Appview: config.GetString("appview"), Account: config.GetString("account")}
+ return applyFlagSettings(resolved, flags)
+}
+
+func applyFlagSettings(resolved settings, flags flagSettings) settings {
+ if flags.AppviewSet {
+ resolved.Appview = flags.Appview
+ }
+ if flags.AccountSet {
+ resolved.Account = flags.Account
}
+ return resolved
}
func configSearchDirs() []string {
diff --git a/internal/cli/issue.go b/internal/cli/issue.go
index c4def9f..97c7afb 100644
--- a/internal/cli/issue.go
+++ b/internal/cli/issue.go
@@ -1,8 +1,13 @@
package cli
-import "github.com/spf13/cobra"
+import (
+ "github.com/alyraffauf/tg/internal/app"
+ "github.com/spf13/cobra"
+)
-var issueCmd = &cobra.Command{
- Use: "issue",
- Short: "Manage issues on a Tangled repository",
+func newIssueCommand(_ *app.Service) *cobra.Command {
+ return &cobra.Command{
+ Use: "issue",
+ Short: "Manage issues on a Tangled repository",
+ }
}
diff --git a/internal/cli/issue_comment.go b/internal/cli/issue_comment.go
index 5e24bd1..b937af8 100644
--- a/internal/cli/issue_comment.go
+++ b/internal/cli/issue_comment.go
@@ -3,62 +3,41 @@ package cli
import (
"fmt"
- "github.com/alyraffauf/tg/tangled"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
-var (
- issueCommentBody string
- issueCommentBodyFile string
- issueCommentRepo string
-)
-
-var issueCommentCmd = &cobra.Command{
- Use: "comment ",
- Short: "Add a comment to an issue",
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- body, err := commandBody(issueCommentBody, issueCommentBodyFile)
- if err != nil {
- return err
- }
- if body == "" {
- return fmt.Errorf("set --body or --body-file")
- }
- ctx := cmd.Context()
- targetArgs := []string{}
- if issueCommentRepo != "" {
- targetArgs = []string{issueCommentRepo}
- }
- handle, name, err := resolveTarget(ctx, targetArgs)
- if err != nil {
- return err
- }
- repoDid, err := findRepoDid(ctx, handle, name)
- if err != nil {
- return err
- }
- issues, err := client.ListIssues(ctx, repoDid, tangled.ListOpts{Limit: defaultListLimit})
- if err != nil {
- return fmt.Errorf("list issues for %s/%s: %w", handle, name, err)
- }
- issue, err := findByRKey(issues.Items, args[0], "issue")
- if err != nil {
- return err
- }
-
- result, err := createIssueComment(ctx, issue.URI, body)
- if err != nil {
- return err
- }
- return output(result, func(result createdRecordResult) {
- fmt.Printf("Added comment %s\n", result.URI)
- })
- },
-}
+func newIssueCommentCommand(service *app.Service) *cobra.Command {
+ var bodyText, bodyFile, repository string
-func init() {
- issueCommentCmd.Flags().StringVarP(&issueCommentBody, "body", "b", "", "Comment body")
- issueCommentCmd.Flags().StringVarP(&issueCommentBodyFile, "body-file", "F", "", "Read comment body from file")
- issueCommentCmd.Flags().StringVarP(&issueCommentRepo, "repo", "R", "", "Target repository as handle/repo")
+ command := &cobra.Command{
+ Use: "comment ",
+ Short: "Add a comment to an issue",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ body, err := commandBody(bodyText, bodyFile)
+ if err != nil {
+ return err
+ }
+ if body == "" {
+ return fmt.Errorf("set --body or --body-file")
+ }
+ ctx := cmd.Context()
+ target, err := resolveTargetFlag(ctx, repository, service)
+ if err != nil {
+ return err
+ }
+ result, err := service.CommentIssue(ctx, target, args[0], body)
+ if err != nil {
+ return err
+ }
+ return output(cmd, result, func(result *app.CreatedRecordResult) {
+ fmt.Fprintf(cmd.OutOrStdout(), "Added comment %s\n", result.URI)
+ })
+ },
+ }
+ command.Flags().StringVarP(&bodyText, "body", "b", "", "Comment body")
+ command.Flags().StringVarP(&bodyFile, "body-file", "F", "", "Read comment body from file")
+ command.Flags().StringVarP(&repository, "repo", "R", "", "Target repository as handle/repo")
+ return command
}
diff --git a/internal/cli/issue_create.go b/internal/cli/issue_create.go
index 06cc1e0..a4b4002 100644
--- a/internal/cli/issue_create.go
+++ b/internal/cli/issue_create.go
@@ -2,73 +2,39 @@ package cli
import (
"fmt"
- "time"
- "github.com/alyraffauf/tg/atproto"
- "github.com/alyraffauf/tg/tangled"
- "github.com/bluesky-social/indigo/atproto/syntax"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
-var (
- issueCreateBody string
- issueCreateBodyFile string
- issueCreateRepo string
-)
-
-var issueCreateCmd = &cobra.Command{
- Use: "create ",
- Short: "Create an issue on a Tangled repository",
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx := cmd.Context()
- body, err := commandBody(issueCreateBody, issueCreateBodyFile)
- if err != nil {
- return err
- }
- atClient, did, err := authenticatedATProto(ctx)
- if err != nil {
- return err
- }
-
- targetArgs := []string{}
- if issueCreateRepo != "" {
- targetArgs = []string{issueCreateRepo}
- }
- handle, name, err := resolveTarget(ctx, targetArgs)
- if err != nil {
- return err
- }
- repoDid, err := findRepoDid(ctx, handle, name)
- if err != nil {
- return err
- }
-
- rkey := string(syntax.NewTIDNow(0))
- uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{
- Repo: did,
- Collection: "sh.tangled.repo.issue",
- Rkey: rkey,
- Record: tangled.IssueRecord{
- Type: "sh.tangled.repo.issue",
- Repo: repoDid,
- Title: args[0],
- Body: body,
- CreatedAt: time.Now().UTC().Format(time.RFC3339),
- },
- })
- if err != nil {
- return fmt.Errorf("create issue: %w", err)
- }
-
- return output(createdRecordResult{Rkey: rkey, URI: uri}, func(result createdRecordResult) {
- fmt.Printf("Created issue %s\n", result.URI)
- })
- },
-}
-
-func init() {
- issueCreateCmd.Flags().StringVarP(&issueCreateBody, "body", "b", "", "Issue body")
- issueCreateCmd.Flags().StringVarP(&issueCreateBodyFile, "body-file", "F", "", "Read issue body from file")
- issueCreateCmd.Flags().StringVarP(&issueCreateRepo, "repo", "R", "", "Target repository as handle/repo")
+func newIssueCreateCommand(service *app.Service) *cobra.Command {
+ var bodyText, bodyFile, repository string
+
+ command := &cobra.Command{
+ Use: "create ",
+ Short: "Create an issue on a Tangled repository",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := cmd.Context()
+ body, err := commandBody(bodyText, bodyFile)
+ if err != nil {
+ return err
+ }
+ target, err := resolveTargetFlag(ctx, repository, service)
+ if err != nil {
+ return err
+ }
+ result, err := service.CreateIssue(ctx, target, args[0], body)
+ if err != nil {
+ return err
+ }
+ return output(cmd, result, func(result *app.CreatedRecordResult) {
+ fmt.Fprintf(cmd.OutOrStdout(), "Created issue %s\n", result.URI)
+ })
+ },
+ }
+ command.Flags().StringVarP(&bodyText, "body", "b", "", "Issue body")
+ command.Flags().StringVarP(&bodyFile, "body-file", "F", "", "Read issue body from file")
+ command.Flags().StringVarP(&repository, "repo", "R", "", "Target repository as handle/repo")
+ return command
}
diff --git a/internal/cli/issue_list.go b/internal/cli/issue_list.go
index 93a9186..1767ef0 100644
--- a/internal/cli/issue_list.go
+++ b/internal/cli/issue_list.go
@@ -1,43 +1,32 @@
package cli
import (
- "fmt"
-
- "github.com/alyraffauf/tg/tangled"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
-var issueListCmd = &cobra.Command{
- Use: "list [handle/repo]",
- Short: "List issues for a Tangled repository",
- Long: `List issues for a Tangled repository.
+func newIssueListCommand(service *app.Service) *cobra.Command {
+ return &cobra.Command{
+ Use: "list [handle/repo]",
+ Short: "List issues for a Tangled repository",
+ Long: `List issues for a Tangled repository.
If no argument is given, the command detects the repository from the
"origin" remote URL of the git repository in the current directory.`,
- Args: cobra.MaximumNArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx := cmd.Context()
-
- handle, repo, err := resolveTarget(ctx, args)
- if err != nil {
- return err
- }
-
- repoDid, err := findRepoDid(ctx, handle, repo)
- if err != nil {
- return err
- }
-
- issues, err := client.ListIssues(ctx, repoDid, tangled.ListOpts{
- Limit: defaultListLimit,
- })
- if err != nil {
- return fmt.Errorf("list issues for %q: %w", repo, err)
- }
-
- items := buildItems(ctx, issues.Items, decodeIssue)
- return output(items, func(items []item) {
- renderList(items, "No issues found.")
- })
- },
+ Args: cobra.MaximumNArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := cmd.Context()
+ target, err := resolveTarget(ctx, args, service)
+ if err != nil {
+ return err
+ }
+ items, err := service.ListIssues(ctx, target)
+ if err != nil {
+ return err
+ }
+ return output(cmd, items, func(items []app.Item) {
+ renderList(cmd.OutOrStdout(), items, "No issues found.")
+ })
+ },
+ }
}
diff --git a/internal/cli/issue_state.go b/internal/cli/issue_state.go
index 8a04098..9929add 100644
--- a/internal/cli/issue_state.go
+++ b/internal/cli/issue_state.go
@@ -3,63 +3,66 @@ package cli
import (
"fmt"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
-var (
- issueStateRepo string
- issueEditTitle string
- issueEditBody string
-)
+func newIssueCloseCommand(service *app.Service) *cobra.Command {
+ return newIssueStateCommand(service, "close", "closed")
+}
+
+func newIssueReopenCommand(service *app.Service) *cobra.Command {
+ return newIssueStateCommand(service, "reopen", "open")
+}
-var issueCloseCmd = newIssueStateCmd("close", "closed")
-var issueReopenCmd = newIssueStateCmd("reopen", "open")
+func newIssueEditCommand(service *app.Service) *cobra.Command {
+ var titleText, bodyText string
-var issueEditCmd = &cobra.Command{
- Use: "edit ",
- Short: "Edit an issue",
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- setTitle := cmd.Flags().Changed("title")
- setBody := cmd.Flags().Changed("body")
- if !setTitle && !setBody {
- return fmt.Errorf("set --title or --body")
- }
- atClient, did, err := authenticatedATProto(cmd.Context())
- if err != nil {
- return err
- }
- return editRecord(cmd.Context(), atClient, did, issueCollection, args[0], issueEditTitle, issueEditBody, setTitle, setBody)
- },
+ command := &cobra.Command{
+ Use: "edit ",
+ Short: "Edit an issue",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ var title, body *string
+ if cmd.Flags().Changed("title") {
+ title = &titleText
+ }
+ if cmd.Flags().Changed("body") {
+ body = &bodyText
+ }
+ if title == nil && body == nil {
+ return fmt.Errorf("set --title or --body")
+ }
+ return service.EditIssue(cmd.Context(), args[0], title, body)
+ },
+ }
+ command.Flags().StringVarP(&titleText, "title", "t", "", "New title")
+ command.Flags().StringVarP(&bodyText, "body", "b", "", "New body")
+ return command
}
-func newIssueStateCmd(use, state string) *cobra.Command {
- return &cobra.Command{
+func newIssueStateCommand(service *app.Service, use, state string) *cobra.Command {
+ var repository string
+
+ command := &cobra.Command{
Use: use + " ",
Short: use + " an issue",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
- atClient, did, err := authenticatedATProto(cmd.Context())
+ ctx := cmd.Context()
+ target, err := resolveTargetFlag(ctx, repository, service)
if err != nil {
return err
}
- target, _, err := targetRecord(cmd.Context(), issueStateRepo, issueCollection, args[0])
+ result, err := service.SetIssueState(ctx, target, args[0], state)
if err != nil {
- return err
- }
- if err := putState(cmd.Context(), atClient, did, args[0], issueCollection, target, state); err != nil {
return fmt.Errorf("%s issue: %w", use, err)
}
- return output(stateResult{Rkey: args[0], State: state}, func(result stateResult) {
- fmt.Printf("Issue %s %s\n", result.Rkey, result.State)
+ return output(cmd, result, func(result *app.StateResult) {
+ fmt.Fprintf(cmd.OutOrStdout(), "Issue %s %s\n", result.Rkey, result.State)
})
},
}
-}
-
-func init() {
- issueCloseCmd.Flags().StringVarP(&issueStateRepo, "repo", "R", "", "Target repository as handle/repo")
- issueReopenCmd.Flags().StringVarP(&issueStateRepo, "repo", "R", "", "Target repository as handle/repo")
- issueEditCmd.Flags().StringVarP(&issueEditTitle, "title", "t", "", "New title")
- issueEditCmd.Flags().StringVarP(&issueEditBody, "body", "b", "", "New body")
+ command.Flags().StringVarP(&repository, "repo", "R", "", "Target repository as handle/repo")
+ return command
}
diff --git a/internal/cli/issue_view.go b/internal/cli/issue_view.go
index 66c6465..c877b72 100644
--- a/internal/cli/issue_view.go
+++ b/internal/cli/issue_view.go
@@ -3,72 +3,41 @@ package cli
import (
"fmt"
- "github.com/alyraffauf/tg/tangled"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
-var issueViewRepo string
+func newIssueViewCommand(service *app.Service) *cobra.Command {
+ var repository string
-var issueViewCmd = &cobra.Command{
- Use: "view ",
- Short: "View an issue for a Tangled repository",
- Long: `View an issue by its rkey (the last segment of its at:// URI).
+ command := &cobra.Command{
+ Use: "view ",
+ Short: "View an issue for a Tangled repository",
+ Long: `View an issue by its rkey (the last segment of its at:// URI).
If --repo is not set, the repository is detected from the current
directory's git origin remote.`,
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx := cmd.Context()
- rkey := args[0]
-
- targetArgs := []string{}
- if issueViewRepo != "" {
- targetArgs = []string{issueViewRepo}
- }
- handle, repo, err := resolveTarget(ctx, targetArgs)
- if err != nil {
- return err
- }
-
- repoDid, err := findRepoDid(ctx, handle, repo)
- if err != nil {
- return err
- }
-
- issues, err := client.ListIssues(ctx, repoDid, tangled.ListOpts{
- Limit: defaultListLimit,
- })
- if err != nil {
- return fmt.Errorf("list issues for %s/%s: %w", handle, repo, err)
- }
-
- found, err := findByRKey(issues.Items, rkey, "issue")
- if err != nil {
- return err
- }
- decoded, err := decodeIssue(found.Value)
- if err != nil {
- return fmt.Errorf("decode issue %q: %w", rkey, err)
- }
-
- result := viewResult{
- Rkey: rkey,
- Title: decoded.Title,
- Body: decoded.Body,
- Author: resolveAuthor(ctx, extractDID(found.URI)),
- CreatedAt: decoded.CreatedAt,
- }
- return output(result, func(view viewResult) {
- fmt.Printf("Title: %s\n", view.Title)
- fmt.Printf("Author: %s\n", view.Author.Handle)
- fmt.Printf("Created: %s\n", view.CreatedAt)
- if view.Body != "" {
- fmt.Printf("\n%s\n", view.Body)
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := cmd.Context()
+ target, err := resolveTargetFlag(ctx, repository, service)
+ if err != nil {
+ return err
}
- })
- },
-}
-
-func init() {
- issueViewCmd.Flags().StringVarP(&issueViewRepo, "repo", "R", "", "Target repository as handle/repo")
+ view, err := service.ViewIssue(ctx, target, args[0])
+ if err != nil {
+ return err
+ }
+ return output(cmd, view, func(view *app.ViewResult) {
+ fmt.Fprintf(cmd.OutOrStdout(), "Title: %s\n", view.Title)
+ fmt.Fprintf(cmd.OutOrStdout(), "Author: %s\n", view.Author.Handle)
+ fmt.Fprintf(cmd.OutOrStdout(), "Created: %s\n", view.CreatedAt)
+ if view.Body != "" {
+ fmt.Fprintf(cmd.OutOrStdout(), "\n%s\n", view.Body)
+ }
+ })
+ },
+ }
+ command.Flags().StringVarP(&repository, "repo", "R", "", "Target repository as handle/repo")
+ return command
}
diff --git a/internal/cli/man.go b/internal/cli/man.go
index 51e43fb..e490179 100644
--- a/internal/cli/man.go
+++ b/internal/cli/man.go
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
"github.com/spf13/cobra/doc"
)
@@ -12,21 +13,23 @@ import (
// derivation invokes to generate man pages, not a user-facing command (cf.
// `gh`, which produces man pages via its Makefile rather than a visible
// subcommand).
-var manCmd = &cobra.Command{
- Use: "man [directory]",
- Short: "Generate man pages",
- Args: cobra.ExactArgs(1),
- Hidden: true,
- RunE: func(_ *cobra.Command, args []string) error {
- dir := args[0]
- if err := os.MkdirAll(dir, 0o755); err != nil {
- return fmt.Errorf("create man page directory: %w", err)
- }
- header := &doc.GenManHeader{
- Title: "tg",
- Section: "1",
- Source: "tg",
- }
- return doc.GenManTree(rootCmd, header, dir)
- },
+func newManCommand(_ *app.Service) *cobra.Command {
+ return &cobra.Command{
+ Use: "man [directory]",
+ Short: "Generate man pages",
+ Args: cobra.ExactArgs(1),
+ Hidden: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ dir := args[0]
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return fmt.Errorf("create man page directory: %w", err)
+ }
+ header := &doc.GenManHeader{
+ Title: "tg",
+ Section: "1",
+ Source: "tg",
+ }
+ return doc.GenManTree(cmd.Root(), header, dir)
+ },
+ }
}
diff --git a/internal/cli/output.go b/internal/cli/output.go
index a4735dd..fd9a4f5 100644
--- a/internal/cli/output.go
+++ b/internal/cli/output.go
@@ -2,124 +2,18 @@ package cli
import (
"encoding/json"
- "os"
+
+ "github.com/spf13/cobra"
)
-// output dispatches structured data to JSON (when --json is set) or to
-// a human-readable renderer.
-func output[T any](data T, human func(T)) error {
- if jsonOutput {
- enc := json.NewEncoder(os.Stdout)
+// output dispatches structured data to JSON (when --json is set) or to a
+// human-readable renderer.
+func output[T any](cmd *cobra.Command, data T, human func(T)) error {
+ if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
+ enc := json.NewEncoder(cmd.OutOrStdout())
enc.SetIndent("", " ")
return enc.Encode(data)
}
human(data)
return nil
}
-
-type author struct {
- DID string `json:"did"`
- Handle string `json:"handle"`
-}
-
-// item is a listing entry for an issue or a pull request. SourceBranch and
-// TargetBranch are only populated (and only emitted as JSON) for pulls.
-type item struct {
- Rkey string `json:"rkey"`
- URI string `json:"uri"`
- Title string `json:"title"`
- State string `json:"state"`
- Author author `json:"author"`
- CreatedAt string `json:"createdAt"`
- UpdatedAt string `json:"updatedAt,omitempty"`
- CommentCount int64 `json:"commentCount"`
- SourceBranch string `json:"sourceBranch,omitempty"`
- TargetBranch string `json:"targetBranch,omitempty"`
-}
-
-type repoItem struct {
- Name string `json:"name"`
- URI string `json:"uri"`
- Author string `json:"author"`
- Knot string `json:"knot"`
- Description string `json:"description,omitempty"`
- CreatedAt string `json:"createdAt"`
- RepoDid string `json:"repoDid,omitempty"`
-}
-
-type sshKeyItem struct {
- Name string `json:"name"`
- Key string `json:"key"`
- CreatedAt string `json:"createdAt"`
- URI string `json:"uri"`
-}
-
-type stringItem struct {
- Rkey string `json:"rkey"`
- URI string `json:"uri"`
- Filename string `json:"filename"`
- Description string `json:"description,omitempty"`
- CreatedAt string `json:"createdAt"`
-}
-
-type stringViewResult struct {
- Rkey string `json:"rkey"`
- URI string `json:"uri"`
- Filename string `json:"filename"`
- Author author `json:"author"`
- Description string `json:"description,omitempty"`
- Contents string `json:"contents"`
- CreatedAt string `json:"createdAt"`
-}
-
-// viewResult is a single issue or pull request. SourceBranch and
-// TargetBranch are only populated (and only emitted as JSON) for pulls.
-type viewResult struct {
- Rkey string `json:"rkey"`
- Title string `json:"title"`
- Body string `json:"body,omitempty"`
- Author author `json:"author"`
- CreatedAt string `json:"createdAt"`
- SourceBranch string `json:"sourceBranch,omitempty"`
- TargetBranch string `json:"targetBranch,omitempty"`
-}
-
-type repoCreateResult struct {
- Handle string `json:"handle"`
- Name string `json:"name"`
- URI string `json:"uri"`
- Knot string `json:"knot"`
- Cloned bool `json:"cloned"`
- Pushed bool `json:"pushed"`
-}
-
-type repoCloneResult struct {
- Handle string `json:"handle"`
- Repo string `json:"repo"`
- Destination string `json:"destination"`
-}
-
-type sshKeyAddResult struct {
- Name string `json:"name"`
- URI string `json:"uri"`
-}
-
-type authStatusResult struct {
- Authenticated bool `json:"authenticated"`
- Status string `json:"status,omitempty"`
- DID string `json:"did,omitempty"`
- Handle string `json:"handle,omitempty"`
-}
-
-type authLogoutResult struct {
- // WasLoggedIn reports whether a session existed and was cleared. It is false
- // when there was nothing to log out (not a failure; the command still exits 0).
- WasLoggedIn bool `json:"wasLoggedIn"`
-}
-
-type authAccountResult struct {
- Active bool `json:"active"`
- DID string `json:"did"`
- Handle string `json:"handle"`
- Method string `json:"method"`
-}
diff --git a/internal/cli/pr.go b/internal/cli/pr.go
index 2508239..b17e60e 100644
--- a/internal/cli/pr.go
+++ b/internal/cli/pr.go
@@ -1,8 +1,13 @@
package cli
-import "github.com/spf13/cobra"
+import (
+ "github.com/alyraffauf/tg/internal/app"
+ "github.com/spf13/cobra"
+)
-var prCmd = &cobra.Command{
- Use: "pr",
- Short: "Manage pull requests on a Tangled repository",
+func newPRCommand(_ *app.Service) *cobra.Command {
+ return &cobra.Command{
+ Use: "pr",
+ Short: "Manage pull requests on a Tangled repository",
+ }
}
diff --git a/internal/cli/pr_checkout.go b/internal/cli/pr_checkout.go
index 9650cb8..6ccbe13 100644
--- a/internal/cli/pr_checkout.go
+++ b/internal/cli/pr_checkout.go
@@ -2,106 +2,52 @@ package cli
import (
"fmt"
- "os"
- "github.com/alyraffauf/tg/internal/gitutil"
- "github.com/alyraffauf/tg/tangled"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
-var (
- prCheckoutRepo string
- prCheckoutBranch string
- prCheckoutForce bool
-)
-
-var prCheckoutCmd = &cobra.Command{
- Use: "checkout ",
- Short: "Check out a pull request in Git",
- Long: "Check out the latest pull request round on the current remote target branch.",
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx := cmd.Context()
- rkey := args[0]
- repoDir, err := os.Getwd()
- if err != nil {
- return fmt.Errorf("get current directory: %w", err)
- }
- localRepo, err := gitutil.DetectRepoFromCWD(ctx)
- if err != nil {
- return fmt.Errorf("detect local repository: %w", err)
- }
- localRecord, err := resolveRepoRecord(ctx, localRepo.Handle, localRepo.Repo)
- if err != nil {
- return err
- }
-
- handle, repoName := localRepo.Handle, localRepo.Repo
- if prCheckoutRepo != "" {
- handle, repoName, err = parseHandleRepo(prCheckoutRepo)
+func newPRCheckoutCommand(service *app.Service) *cobra.Command {
+ var repository, branch string
+ var force bool
+
+ command := &cobra.Command{
+ Use: "checkout ",
+ Short: "Check out a pull request in Git",
+ Long: "Check out the latest pull request round on the current remote target branch.",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := cmd.Context()
+ rkey := args[0]
+ repoDir, err := getwd()
if err != nil {
return err
}
- }
- targetRecord := localRecord
- if handle != localRepo.Handle || repoName != localRepo.Repo {
- targetRecord, err = resolveRepoRecord(ctx, handle, repoName)
+ var target *app.Target
+ if repository != "" {
+ parsedTarget, err := app.ParseTarget(repository)
+ if err != nil {
+ return err
+ }
+ target = &parsedTarget
+ }
+ result, err := service.CheckoutPull(ctx, app.CheckoutPullInput{
+ RepoDir: repoDir,
+ Rkey: rkey,
+ Target: target,
+ Branch: branch,
+ Force: force,
+ })
if err != nil {
return err
}
- }
- if targetRecord.Value.RepoDid != localRecord.Value.RepoDid {
- return fmt.Errorf("pull request target %s/%s does not match the current repository", handle, repoName)
- }
-
- pulls, err := client.ListPulls(ctx, targetRecord.Value.RepoDid, tangled.ListOpts{Limit: defaultListLimit})
- if err != nil {
- return fmt.Errorf("list PRs for %s/%s: %w", handle, repoName, err)
- }
- pull, err := findByRKey(pulls.Items, rkey, "pull request")
- if err != nil {
- return err
- }
- record, patchCID, err := latestPullPatch(pull, rkey)
- if err != nil {
- return err
- }
- if record.Target.Branch == "" {
- return fmt.Errorf("pull request %q has no target branch", rkey)
- }
-
- patch, err := downloadPullPatch(ctx, extractDID(pull.URI), patchCID)
- if err != nil {
- return err
- }
- branch := prCheckoutBranch
- if branch == "" {
- branch = "pr-" + rkey
- }
-
- if err := gitutil.CheckoutPatch(ctx, gitutil.CheckoutPatchParams{
- RepoDir: repoDir,
- Branch: branch,
- TargetBranch: record.Target.Branch,
- Patch: patch,
- Force: prCheckoutForce,
- }); err != nil {
- return err
- }
- result := prCheckoutResult{Rkey: rkey, Branch: branch}
- return output(result, func(result prCheckoutResult) {
- fmt.Printf("Checked out pull request %s as branch %s\n", result.Rkey, result.Branch)
- })
- },
-}
-
-type prCheckoutResult struct {
- Rkey string `json:"rkey"`
- Branch string `json:"branch"`
-}
-
-func init() {
- prCheckoutCmd.Flags().StringVarP(&prCheckoutRepo, "repo", "R", "", "Target repository as handle/repo")
- prCheckoutCmd.Flags().StringVarP(&prCheckoutBranch, "branch", "b", "", "Local branch name (default: pr-)")
- prCheckoutCmd.Flags().BoolVarP(&prCheckoutForce, "force", "f", false, "Reset an existing checkout branch")
+ return output(cmd, result, func(result *app.PRCheckoutResult) {
+ fmt.Fprintf(cmd.OutOrStdout(), "Checked out pull request %s as branch %s\n", result.Rkey, result.Branch)
+ })
+ },
+ }
+ command.Flags().StringVarP(&repository, "repo", "R", "", "Target repository as handle/repo")
+ command.Flags().StringVarP(&branch, "branch", "b", "", "Local branch name (default: pr-)")
+ command.Flags().BoolVarP(&force, "force", "f", false, "Reset an existing checkout branch")
+ return command
}
diff --git a/internal/cli/pr_comment.go b/internal/cli/pr_comment.go
index 82ecd52..3304aba 100644
--- a/internal/cli/pr_comment.go
+++ b/internal/cli/pr_comment.go
@@ -3,62 +3,41 @@ package cli
import (
"fmt"
- "github.com/alyraffauf/tg/tangled"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
-var (
- prCommentBody string
- prCommentBodyFile string
- prCommentRepo string
-)
-
-var prCommentCmd = &cobra.Command{
- Use: "comment ",
- Short: "Add a comment to a pull request",
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- body, err := commandBody(prCommentBody, prCommentBodyFile)
- if err != nil {
- return err
- }
- if body == "" {
- return fmt.Errorf("set --body or --body-file")
- }
- ctx := cmd.Context()
- targetArgs := []string{}
- if prCommentRepo != "" {
- targetArgs = []string{prCommentRepo}
- }
- handle, name, err := resolveTarget(ctx, targetArgs)
- if err != nil {
- return err
- }
- repoDid, err := findRepoDid(ctx, handle, name)
- if err != nil {
- return err
- }
- pulls, err := client.ListPulls(ctx, repoDid, tangled.ListOpts{Limit: defaultListLimit})
- if err != nil {
- return fmt.Errorf("list PRs for %s/%s: %w", handle, name, err)
- }
- pull, err := findByRKey(pulls.Items, args[0], "pull request")
- if err != nil {
- return err
- }
-
- result, err := createPullComment(ctx, pull.URI, body)
- if err != nil {
- return err
- }
- return output(result, func(result createdRecordResult) {
- fmt.Printf("Added comment %s\n", result.URI)
- })
- },
-}
+func newPRCommentCommand(service *app.Service) *cobra.Command {
+ var bodyText, bodyFile, repository string
-func init() {
- prCommentCmd.Flags().StringVarP(&prCommentBody, "body", "b", "", "Comment body")
- prCommentCmd.Flags().StringVarP(&prCommentBodyFile, "body-file", "F", "", "Read comment body from file")
- prCommentCmd.Flags().StringVarP(&prCommentRepo, "repo", "R", "", "Target repository as handle/repo")
+ command := &cobra.Command{
+ Use: "comment ",
+ Short: "Add a comment to a pull request",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ body, err := commandBody(bodyText, bodyFile)
+ if err != nil {
+ return err
+ }
+ if body == "" {
+ return fmt.Errorf("set --body or --body-file")
+ }
+ ctx := cmd.Context()
+ target, err := resolveTargetFlag(ctx, repository, service)
+ if err != nil {
+ return err
+ }
+ result, err := service.CommentPull(ctx, target, args[0], body)
+ if err != nil {
+ return err
+ }
+ return output(cmd, result, func(result *app.CreatedRecordResult) {
+ fmt.Fprintf(cmd.OutOrStdout(), "Added comment %s\n", result.URI)
+ })
+ },
+ }
+ command.Flags().StringVarP(&bodyText, "body", "b", "", "Comment body")
+ command.Flags().StringVarP(&bodyFile, "body-file", "F", "", "Read comment body from file")
+ command.Flags().StringVarP(&repository, "repo", "R", "", "Target repository as handle/repo")
+ return command
}
diff --git a/internal/cli/pr_create.go b/internal/cli/pr_create.go
index 37ffef7..8b30b4d 100644
--- a/internal/cli/pr_create.go
+++ b/internal/cli/pr_create.go
@@ -1,227 +1,68 @@
package cli
import (
- "context"
"fmt"
- "os"
- "strings"
- "time"
- "github.com/alyraffauf/tg/atproto"
- "github.com/alyraffauf/tg/internal/gitutil"
- "github.com/bluesky-social/indigo/atproto/syntax"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
-const patchMimeType = "application/gzip"
-
-var (
- prCreateTitle string
- prCreateBody string
- prCreateBodyFile string
- prCreateBase string
- prCreateHead string
- prCreateRepo string
- prCreateSourceRepo string
-)
-
-var prCreateCmd = &cobra.Command{
- Use: "create",
- Short: "Create a pull request from the current branch",
- Long: "Create a pull request by uploading a gzipped git patch and writing a sh.tangled.repo.pull record. " +
- "By default, the current repository and branch are both the source and target repository, and origin's " +
- "default branch is the target branch. Use --repo and --source-repo for a fork-based pull request.",
- Args: cobra.NoArgs,
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx := cmd.Context()
- atClient, did, err := authenticatedATProto(ctx)
- if err != nil {
- return err
- }
-
- repoDir, err := os.Getwd()
- if err != nil {
- return fmt.Errorf("get current directory: %w", err)
- }
- head, err := prSourceBranch(ctx, repoDir)
- if err != nil {
- return err
- }
- base, err := prTargetBranch(ctx, repoDir)
- if err != nil {
- return err
- }
- body, err := commandBody(prCreateBody, prCreateBodyFile)
- if err != nil {
- return err
- }
-
- targetArgs := []string{}
- if prCreateRepo != "" {
- targetArgs = []string{prCreateRepo}
- }
- handle, repo, err := resolveTarget(ctx, targetArgs)
- if err != nil {
- return err
- }
- target, err := resolveRepoRecord(ctx, handle, repo)
- if err != nil {
- return err
- }
- if !strings.HasPrefix(target.URI, "at://") {
- return fmt.Errorf("target repository %q has no strong at:// URI", repo)
- }
- source := target
- if prCreateSourceRepo != "" {
- sourceHandle, sourceName, err := parseHandleRepo(prCreateSourceRepo)
+func newPRCreateCommand(service *app.Service) *cobra.Command {
+ var title, bodyText, bodyFile, base, head, repository, sourceRepository string
+
+ command := &cobra.Command{
+ Use: "create",
+ Short: "Create a pull request from the current branch",
+ Long: "Create a pull request by uploading a gzipped git patch and writing a sh.tangled.repo.pull record. " +
+ "By default, the current repository and branch are both the source and target repository, and origin's " +
+ "default branch is the target branch. Use --repo and --source-repo for a fork-based pull request.",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := cmd.Context()
+ body, err := commandBody(bodyText, bodyFile)
if err != nil {
return err
}
- source, err = resolveRepoRecord(ctx, sourceHandle, sourceName)
+ repoDir, err := getwd()
if err != nil {
- return fmt.Errorf("resolve source repository: %w", err)
+ return err
}
- }
- if source.Value.RepoDid == "" {
- return fmt.Errorf("source repository has no repo DID")
- }
-
- patch, err := gitutil.GeneratePatch(ctx, repoDir, base, head)
- if err != nil {
- return fmt.Errorf("generate pull request patch: %w", err)
- }
- blob, err := atClient.UploadBlob(ctx, patch, patchMimeType)
- if err != nil {
- return err
- }
-
- uri, err := createPullRecord(ctx, atClient, did, prCreateRecord{
- Title: prCreateTitle,
- Body: body,
- TargetRepoDid: target.Value.RepoDid,
- SourceRepoDid: source.Value.RepoDid,
- Base: base,
- Head: head,
- Patch: blob,
- })
- if err != nil {
- return err
- }
- result := prCreateResult{URI: uri, Title: prCreateTitle, Base: base, Head: head}
- return output(result, func(created prCreateResult) {
- fmt.Printf("Created pull request %s (%s -> %s)\n", created.URI, created.Head, created.Base)
- })
- },
-}
-
-func init() {
- prCreateCmd.Flags().StringVarP(&prCreateTitle, "title", "t", "", "Pull request title")
- prCreateCmd.Flags().StringVarP(&prCreateBody, "body", "b", "", "Pull request body")
- prCreateCmd.Flags().StringVarP(&prCreateBodyFile, "body-file", "F", "", "Read pull request body from file")
- prCreateCmd.Flags().StringVarP(&prCreateBase, "base", "B", "", "Target branch (default: origin's default branch)")
- prCreateCmd.Flags().StringVarP(&prCreateHead, "head", "H", "", "Source branch (default: current branch)")
- prCreateCmd.Flags().StringVarP(&prCreateRepo, "repo", "R", "", "Target repository as handle/repo")
- prCreateCmd.Flags().StringVar(&prCreateSourceRepo, "source-repo", "", "Source repository as handle/repo (for fork-based pull requests)")
- prCreateCmd.MarkFlagRequired("title")
-}
-
-type prCreateRecord struct {
- Title string
- Body string
- TargetRepoDid string
- SourceRepoDid string
- Base string
- Head string
- Patch *atproto.Blob
-}
-
-// pullRecord is the sh.tangled.repo.pull lexicon shape used for record writes.
-type pullRecord struct {
- Type string `json:"$type"`
- Title string `json:"title"`
- Body string `json:"body,omitempty"`
- CreatedAt string `json:"createdAt"`
- Target pullTarget `json:"target"`
- Source pullSource `json:"source"`
- Rounds []pullRound `json:"rounds"`
-}
-
-type pullTarget struct {
- Repo string `json:"repo"`
- Branch string `json:"branch"`
-}
-
-type pullSource struct {
- Repo string `json:"repo,omitempty"`
- Branch string `json:"branch"`
-}
-
-type pullRound struct {
- CreatedAt string `json:"createdAt"`
- PatchBlob *atproto.Blob `json:"patchBlob"`
-}
-
-type prCreateResult struct {
- URI string `json:"uri"`
- Title string `json:"title"`
- Base string `json:"base"`
- Head string `json:"head"`
-}
-
-func prSourceBranch(ctx context.Context, repoDir string) (string, error) {
- if prCreateHead != "" {
- return prCreateHead, nil
- }
- branch, err := gitutil.CurrentBranch(ctx, repoDir)
- if err != nil {
- return "", fmt.Errorf("determine source branch: %w", err)
- }
- return branch, nil
-}
-
-func prTargetBranch(ctx context.Context, repoDir string) (string, error) {
- if prCreateBase != "" {
- return prCreateBase, nil
- }
- branch, err := gitutil.DefaultBranch(ctx, repoDir)
- if err != nil {
- return "", fmt.Errorf("determine target branch; set --base explicitly: %w", err)
- }
- return branch, nil
-}
-
-func createPullRecord(ctx context.Context, atClient *atproto.ATProto, did string, input prCreateRecord) (string, error) {
- record := newPullRecord(input, time.Now().UTC())
- uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{
- Repo: did,
- Collection: "sh.tangled.repo.pull",
- Rkey: string(syntax.NewTIDNow(0)),
- Record: record,
- })
- if err != nil {
- return "", fmt.Errorf("create pull request record: %w", err)
- }
- return uri, nil
-}
-
-func newPullRecord(input prCreateRecord, createdAt time.Time) pullRecord {
- now := createdAt.Format(time.RFC3339)
- return pullRecord{
- Type: "sh.tangled.repo.pull",
- Title: input.Title,
- Body: input.Body,
- CreatedAt: now,
- Target: pullTarget{
- Repo: input.TargetRepoDid,
- Branch: input.Base,
- },
- Source: pullSource{
- Repo: input.SourceRepoDid,
- Branch: input.Head,
+ target, err := resolveTargetFlag(ctx, repository, service)
+ if err != nil {
+ return err
+ }
+ var source *app.Target
+ if sourceRepository != "" {
+ st, err := app.ParseTarget(sourceRepository)
+ if err != nil {
+ return err
+ }
+ source = &st
+ }
+ result, err := service.CreatePull(ctx, app.CreatePullInput{
+ RepoDir: repoDir,
+ Title: title,
+ Body: body,
+ Base: base,
+ Head: head,
+ Target: target,
+ Source: source,
+ })
+ if err != nil {
+ return err
+ }
+ return output(cmd, result, func(created *app.PRCreateResult) {
+ fmt.Fprintf(cmd.OutOrStdout(), "Created pull request %s (%s -> %s)\n", created.URI, created.Head, created.Base)
+ })
},
- Rounds: []pullRound{{
- CreatedAt: now,
- PatchBlob: input.Patch,
- }},
}
+ command.Flags().StringVarP(&title, "title", "t", "", "Pull request title")
+ command.Flags().StringVarP(&bodyText, "body", "b", "", "Pull request body")
+ command.Flags().StringVarP(&bodyFile, "body-file", "F", "", "Read pull request body from file")
+ command.Flags().StringVarP(&base, "base", "B", "", "Target branch (default: origin's default branch)")
+ command.Flags().StringVarP(&head, "head", "H", "", "Source branch (default: current branch)")
+ command.Flags().StringVarP(&repository, "repo", "R", "", "Target repository as handle/repo")
+ command.Flags().StringVar(&sourceRepository, "source-repo", "", "Source repository as handle/repo (for fork-based pull requests)")
+ _ = command.MarkFlagRequired("title")
+ return command
}
diff --git a/internal/cli/pr_diff.go b/internal/cli/pr_diff.go
index 946a7d2..97d6552 100644
--- a/internal/cli/pr_diff.go
+++ b/internal/cli/pr_diff.go
@@ -1,125 +1,35 @@
package cli
import (
- "bytes"
- "compress/gzip"
- "context"
- "encoding/json"
"fmt"
- "io"
- "net/http"
- "os"
- "github.com/alyraffauf/tg/tangled"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
-const maxPullPatchSize = 100 << 20
-
-var prDiffRepo string
-
-var prDiffCmd = &cobra.Command{
- Use: "diff ",
- Short: "Print the latest patch for a pull request",
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx := cmd.Context()
- targetArgs := []string{}
- if prDiffRepo != "" {
- targetArgs = []string{prDiffRepo}
- }
- handle, name, err := resolveTarget(ctx, targetArgs)
- if err != nil {
- return err
- }
- repoDid, err := findRepoDid(ctx, handle, name)
- if err != nil {
- return err
- }
- pulls, err := client.ListPulls(ctx, repoDid, tangled.ListOpts{Limit: defaultListLimit})
- if err != nil {
- return fmt.Errorf("list PRs for %s/%s: %w", handle, name, err)
- }
- pull, err := findByRKey(pulls.Items, args[0], "pull request")
- if err != nil {
- return err
- }
- _, patchCID, err := latestPullPatch(pull, args[0])
- if err != nil {
- return err
- }
- patch, err := downloadPullPatch(ctx, extractDID(pull.URI), patchCID)
- if err != nil {
- return err
- }
- if _, err := os.Stdout.Write(patch); err != nil {
- return fmt.Errorf("write patch: %w", err)
- }
- return nil
- },
-}
-
-func init() {
- prDiffCmd.Flags().StringVarP(&prDiffRepo, "repo", "R", "", "Target repository as handle/repo")
-}
-
-func latestPullPatch(pull *tangled.ListItem, rkey string) (tangled.PullRecord, string, error) {
- var record tangled.PullRecord
- if err := json.Unmarshal(pull.Value, &record); err != nil {
- return record, "", fmt.Errorf("decode pull request %q: %w", rkey, err)
- }
- if len(record.Rounds) == 0 {
- return record, "", fmt.Errorf("pull request %q has no rounds", rkey)
- }
- patchCID := record.Rounds[len(record.Rounds)-1].PatchBlob.Ref.String()
- if patchCID == "" {
- return record, "", fmt.Errorf("pull request %q has no patch blob", rkey)
- }
- return record, patchCID, nil
-}
-
-func downloadPullPatch(ctx context.Context, authorDID, cid string) ([]byte, error) {
- pdsHost, err := resolver.ResolvePDS(ctx, authorDID)
- if err != nil {
- return nil, fmt.Errorf("resolve PDS for author %q: %w", authorDID, err)
- }
- url := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", pdsHost, authorDID, cid)
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
- if err != nil {
- return nil, fmt.Errorf("build patch download request: %w", err)
- }
- resp, err := http.DefaultClient.Do(req)
- if err != nil {
- return nil, fmt.Errorf("download patch: %w", err)
- }
- defer resp.Body.Close()
- if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
- return nil, fmt.Errorf("download patch: PDS returned HTTP %d", resp.StatusCode)
- }
-
- compressed, err := readLimited(resp.Body, maxPullPatchSize)
- if err != nil {
- return nil, fmt.Errorf("download patch: %w", err)
- }
- patch, err := gzip.NewReader(bytes.NewReader(compressed))
- if err != nil {
- return nil, fmt.Errorf("decompress patch: %w", err)
- }
- defer patch.Close()
- contents, err := readLimited(patch, maxPullPatchSize)
- if err != nil {
- return nil, fmt.Errorf("decompress patch: %w", err)
- }
- return contents, nil
-}
-
-func readLimited(reader io.Reader, limit int64) ([]byte, error) {
- contents, err := io.ReadAll(io.LimitReader(reader, limit+1))
- if err != nil {
- return nil, err
- }
- if int64(len(contents)) > limit {
- return nil, fmt.Errorf("patch exceeds %d bytes", limit)
- }
- return contents, nil
+func newPRDiffCommand(service *app.Service) *cobra.Command {
+ var repository string
+
+ command := &cobra.Command{
+ Use: "diff ",
+ Short: "Print the latest patch for a pull request",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := cmd.Context()
+ target, err := resolveTargetFlag(ctx, repository, service)
+ if err != nil {
+ return err
+ }
+ patch, err := service.PullPatch(ctx, target, args[0])
+ if err != nil {
+ return err
+ }
+ if _, err := cmd.OutOrStdout().Write(patch.Patch); err != nil {
+ return fmt.Errorf("write patch: %w", err)
+ }
+ return nil
+ },
+ }
+ command.Flags().StringVarP(&repository, "repo", "R", "", "Target repository as handle/repo")
+ return command
}
diff --git a/internal/cli/pr_list.go b/internal/cli/pr_list.go
index 5aa5208..c4c9c77 100644
--- a/internal/cli/pr_list.go
+++ b/internal/cli/pr_list.go
@@ -1,43 +1,32 @@
package cli
import (
- "fmt"
-
- "github.com/alyraffauf/tg/tangled"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
-var prListCmd = &cobra.Command{
- Use: "list [handle/repo]",
- Short: "List pull requests for a Tangled repository",
- Long: `List pull requests for a Tangled repository.
+func newPRListCommand(service *app.Service) *cobra.Command {
+ return &cobra.Command{
+ Use: "list [handle/repo]",
+ Short: "List pull requests for a Tangled repository",
+ Long: `List pull requests for a Tangled repository.
If no argument is given, the command detects the repository from the
"origin" remote URL of the git repository in the current directory.`,
- Args: cobra.MaximumNArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx := cmd.Context()
-
- handle, repo, err := resolveTarget(ctx, args)
- if err != nil {
- return err
- }
-
- repoDid, err := findRepoDid(ctx, handle, repo)
- if err != nil {
- return err
- }
-
- pulls, err := client.ListPulls(ctx, repoDid, tangled.ListOpts{
- Limit: defaultListLimit,
- })
- if err != nil {
- return fmt.Errorf("list PRs for %q: %w", repo, err)
- }
-
- items := buildItems(ctx, pulls.Items, decodePull)
- return output(items, func(items []item) {
- renderList(items, "No pull requests found.")
- })
- },
+ Args: cobra.MaximumNArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := cmd.Context()
+ target, err := resolveTarget(ctx, args, service)
+ if err != nil {
+ return err
+ }
+ items, err := service.ListPulls(ctx, target)
+ if err != nil {
+ return err
+ }
+ return output(cmd, items, func(items []app.Item) {
+ renderList(cmd.OutOrStdout(), items, "No pull requests found.")
+ })
+ },
+ }
}
diff --git a/internal/cli/pr_state.go b/internal/cli/pr_state.go
index 9303194..70b733f 100644
--- a/internal/cli/pr_state.go
+++ b/internal/cli/pr_state.go
@@ -1,114 +1,94 @@
package cli
import (
- "context"
"fmt"
- "github.com/alyraffauf/tg/knot"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
-var (
- prStateRepo string
- prEditTitle string
- prEditBody string
- prMergeRepo string
-)
-
-var prCloseCmd = newPRStateCmd("close", "closed")
-var prReopenCmd = newPRStateCmd("reopen", "open")
+func newPRCloseCommand(service *app.Service) *cobra.Command {
+ return newPRStateCommand(service, "close", "closed")
+}
-var prEditCmd = &cobra.Command{
- Use: "edit ",
- Short: "Edit a pull request",
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- setTitle := cmd.Flags().Changed("title")
- setBody := cmd.Flags().Changed("body")
- if !setTitle && !setBody {
- return fmt.Errorf("set --title or --body")
- }
- atClient, did, err := authenticatedATProto(cmd.Context())
- if err != nil {
- return err
- }
- return editRecord(cmd.Context(), atClient, did, pullCollection, args[0], prEditTitle, prEditBody, setTitle, setBody)
- },
+func newPRReopenCommand(service *app.Service) *cobra.Command {
+ return newPRStateCommand(service, "reopen", "open")
}
-var prMergeCmd = &cobra.Command{
- Use: "merge ",
- Short: "Merge a pull request",
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx := cmd.Context()
- atClient, did, err := authenticatedATProto(ctx)
- if err != nil {
- return err
- }
- pullURI, repoURI, err := targetRecord(ctx, prMergeRepo, pullCollection, args[0])
- if err != nil {
- return err
- }
- knotHost, err := repoKnot(ctx, repoURI)
- if err != nil {
- return err
- }
- token, err := atClient.GetServiceAuth(ctx, "did:web:"+knotHost, "sh.tangled.repo.merge")
- if err != nil {
- return err
- }
- if err := knot.New(knotHost, token).Merge(ctx, knot.MergeInput{Repo: repoURI, Pull: pullURI}); err != nil {
- return err
- }
- if err := putState(ctx, atClient, did, args[0], pullCollection, pullURI, "merged"); err != nil {
- return fmt.Errorf("record merged pull request status: %w", err)
- }
- return output(stateResult{Rkey: args[0], State: "merged"}, func(result stateResult) {
- fmt.Printf("Pull request %s merged\n", result.Rkey)
- })
- },
+func newPREditCommand(service *app.Service) *cobra.Command {
+ var titleText, bodyText string
+
+ command := &cobra.Command{
+ Use: "edit ",
+ Short: "Edit a pull request",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ var title, body *string
+ if cmd.Flags().Changed("title") {
+ title = &titleText
+ }
+ if cmd.Flags().Changed("body") {
+ body = &bodyText
+ }
+ if title == nil && body == nil {
+ return fmt.Errorf("set --title or --body")
+ }
+ return service.EditPull(cmd.Context(), args[0], title, body)
+ },
+ }
+ command.Flags().StringVarP(&titleText, "title", "t", "", "New title")
+ command.Flags().StringVarP(&bodyText, "body", "b", "", "New body")
+ return command
}
-func newPRStateCmd(use, status string) *cobra.Command {
- return &cobra.Command{
- Use: use + " ",
- Short: use + " a pull request",
+func newPRMergeCommand(service *app.Service) *cobra.Command {
+ var repository string
+
+ command := &cobra.Command{
+ Use: "merge ",
+ Short: "Merge a pull request",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
- atClient, did, err := authenticatedATProto(cmd.Context())
+ ctx := cmd.Context()
+ target, err := resolveTargetFlag(ctx, repository, service)
if err != nil {
return err
}
- target, _, err := targetRecord(cmd.Context(), prStateRepo, pullCollection, args[0])
+ result, err := service.MergePull(ctx, target, args[0])
if err != nil {
return err
}
- if err := putState(cmd.Context(), atClient, did, args[0], pullCollection, target, status); err != nil {
- return fmt.Errorf("%s pull request: %w", use, err)
- }
- return output(stateResult{Rkey: args[0], State: status}, func(result stateResult) {
- fmt.Printf("Pull request %s %s\n", result.Rkey, result.State)
+ return output(cmd, result, func(result *app.StateResult) {
+ fmt.Fprintf(cmd.OutOrStdout(), "Pull request %s merged\n", result.Rkey)
})
},
}
+ command.Flags().StringVarP(&repository, "repo", "R", "", "Target repository as handle/repo")
+ return command
}
-func repoKnot(ctx context.Context, repoURI string) (string, error) {
- repo, err := client.GetRepo(ctx, repoURI)
- if err != nil {
- return "", fmt.Errorf("get repository: %w", err)
- }
- if repo.Value.Knot == "" {
- return "", fmt.Errorf("repository record has no knot")
- }
- return repo.Value.Knot, nil
-}
+func newPRStateCommand(service *app.Service, use, status string) *cobra.Command {
+ var repository string
-func init() {
- prCloseCmd.Flags().StringVarP(&prStateRepo, "repo", "R", "", "Target repository as handle/repo")
- prReopenCmd.Flags().StringVarP(&prStateRepo, "repo", "R", "", "Target repository as handle/repo")
- prEditCmd.Flags().StringVarP(&prEditTitle, "title", "t", "", "New title")
- prEditCmd.Flags().StringVarP(&prEditBody, "body", "b", "", "New body")
- prMergeCmd.Flags().StringVarP(&prMergeRepo, "repo", "R", "", "Target repository as handle/repo")
+ command := &cobra.Command{
+ Use: use + " ",
+ Short: use + " a pull request",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := cmd.Context()
+ target, err := resolveTargetFlag(ctx, repository, service)
+ if err != nil {
+ return err
+ }
+ result, err := service.SetPullState(ctx, target, args[0], status)
+ if err != nil {
+ return fmt.Errorf("%s pull request: %w", use, err)
+ }
+ return output(cmd, result, func(result *app.StateResult) {
+ fmt.Fprintf(cmd.OutOrStdout(), "Pull request %s %s\n", result.Rkey, result.State)
+ })
+ },
+ }
+ command.Flags().StringVarP(&repository, "repo", "R", "", "Target repository as handle/repo")
+ return command
}
diff --git a/internal/cli/pr_view.go b/internal/cli/pr_view.go
index a8a127b..bfb65b5 100644
--- a/internal/cli/pr_view.go
+++ b/internal/cli/pr_view.go
@@ -3,75 +3,42 @@ package cli
import (
"fmt"
- "github.com/alyraffauf/tg/tangled"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
-var prViewRepo string
+func newPRViewCommand(service *app.Service) *cobra.Command {
+ var repository string
-var prViewCmd = &cobra.Command{
- Use: "view ",
- Short: "View a pull request for a Tangled repository",
- Long: `View a pull request by its rkey (the last segment of its at:// URI).
+ command := &cobra.Command{
+ Use: "view ",
+ Short: "View a pull request for a Tangled repository",
+ Long: `View a pull request by its rkey (the last segment of its at:// URI).
If --repo is not set, the repository is detected from the current
directory's git origin remote.`,
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx := cmd.Context()
- rkey := args[0]
-
- targetArgs := []string{}
- if prViewRepo != "" {
- targetArgs = []string{prViewRepo}
- }
- handle, repo, err := resolveTarget(ctx, targetArgs)
- if err != nil {
- return err
- }
-
- repoDid, err := findRepoDid(ctx, handle, repo)
- if err != nil {
- return err
- }
-
- pulls, err := client.ListPulls(ctx, repoDid, tangled.ListOpts{
- Limit: defaultListLimit,
- })
- if err != nil {
- return fmt.Errorf("list PRs for %s/%s: %w", handle, repo, err)
- }
-
- found, err := findByRKey(pulls.Items, rkey, "pull request")
- if err != nil {
- return err
- }
- decoded, err := decodePull(found.Value)
- if err != nil {
- return fmt.Errorf("decode pull request %q: %w", rkey, err)
- }
-
- result := viewResult{
- Rkey: rkey,
- Title: decoded.Title,
- Body: decoded.Body,
- Author: resolveAuthor(ctx, extractDID(found.URI)),
- CreatedAt: decoded.CreatedAt,
- SourceBranch: decoded.SourceBranch,
- TargetBranch: decoded.TargetBranch,
- }
- return output(result, func(view viewResult) {
- fmt.Printf("Title: %s\n", view.Title)
- fmt.Printf("Author: %s\n", view.Author.Handle)
- fmt.Printf("Created: %s\n", view.CreatedAt)
- fmt.Printf("Branch: %s → %s\n", view.SourceBranch, view.TargetBranch)
- if view.Body != "" {
- fmt.Printf("\n%s\n", view.Body)
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := cmd.Context()
+ target, err := resolveTargetFlag(ctx, repository, service)
+ if err != nil {
+ return err
}
- })
- },
-}
-
-func init() {
- prViewCmd.Flags().StringVarP(&prViewRepo, "repo", "R", "", "Target repository as handle/repo")
+ view, err := service.ViewPull(ctx, target, args[0])
+ if err != nil {
+ return err
+ }
+ return output(cmd, view, func(view *app.ViewResult) {
+ fmt.Fprintf(cmd.OutOrStdout(), "Title: %s\n", view.Title)
+ fmt.Fprintf(cmd.OutOrStdout(), "Author: %s\n", view.Author.Handle)
+ fmt.Fprintf(cmd.OutOrStdout(), "Created: %s\n", view.CreatedAt)
+ fmt.Fprintf(cmd.OutOrStdout(), "Branch: %s → %s\n", view.SourceBranch, view.TargetBranch)
+ if view.Body != "" {
+ fmt.Fprintf(cmd.OutOrStdout(), "\n%s\n", view.Body)
+ }
+ })
+ },
+ }
+ command.Flags().StringVarP(&repository, "repo", "R", "", "Target repository as handle/repo")
+ return command
}
diff --git a/internal/cli/record_mutations.go b/internal/cli/record_mutations.go
deleted file mode 100644
index 305c009..0000000
--- a/internal/cli/record_mutations.go
+++ /dev/null
@@ -1,60 +0,0 @@
-package cli
-
-import (
- "context"
- "encoding/json"
- "fmt"
-
- "github.com/alyraffauf/tg/atproto"
-)
-
-type createdRecordResult struct {
- Rkey string `json:"rkey"`
- URI string `json:"uri"`
-}
-
-func putRecord(ctx context.Context, atClient *atproto.ATProto, did, collection, rkey string, record any) error {
- if _, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{
- Repo: did, Collection: collection, Rkey: rkey, Record: record,
- }); err != nil {
- return err
- }
- return nil
-}
-
-func editRecord(ctx context.Context, atClient *atproto.ATProto, did, collection, rkey, title, body string, setTitle, setBody bool) error {
- found, err := atClient.GetRecord(ctx, did, collection, rkey)
- if err != nil {
- return fmt.Errorf("get existing record: %w", err)
- }
-
- record, err := preserveRecord(found.Value)
- if err != nil {
- return err
- }
- if setTitle {
- record["title"] = title
- }
- if setBody {
- record["body"] = body
- }
- _, _, err = atClient.PutRecord(ctx, atproto.PutRecordInput{
- Repo: did, Collection: collection, Rkey: rkey, Record: record,
- })
- return err
-}
-
-func preserveRecord(value any) (map[string]any, error) {
- data, err := json.Marshal(value)
- if err != nil {
- return nil, fmt.Errorf("encode existing record: %w", err)
- }
- var record map[string]any
- if err := json.Unmarshal(data, &record); err != nil {
- return nil, fmt.Errorf("decode existing record: %w", err)
- }
- if record == nil {
- return nil, fmt.Errorf("existing record is not an object")
- }
- return record, nil
-}
diff --git a/internal/cli/repo.go b/internal/cli/repo.go
index 6b35c96..9dce47b 100644
--- a/internal/cli/repo.go
+++ b/internal/cli/repo.go
@@ -1,8 +1,13 @@
package cli
-import "github.com/spf13/cobra"
+import (
+ "github.com/alyraffauf/tg/internal/app"
+ "github.com/spf13/cobra"
+)
-var repoCmd = &cobra.Command{
- Use: "repo",
- Short: "Manage repositories on Tangled",
+func newRepoCommand(_ *app.Service) *cobra.Command {
+ return &cobra.Command{
+ Use: "repo",
+ Short: "Manage repositories on Tangled",
+ }
}
diff --git a/internal/cli/repo_clone.go b/internal/cli/repo_clone.go
index 47d6b76..ac00186 100644
--- a/internal/cli/repo_clone.go
+++ b/internal/cli/repo_clone.go
@@ -2,48 +2,42 @@ package cli
import (
"fmt"
- "os"
- "github.com/alyraffauf/tg/internal/gitutil"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
-var repoCloneCmd = &cobra.Command{
- Use: "clone [directory]",
- Short: "Clone a Tangled repository",
- Long: `Clone a Tangled repository via SSH into a local directory.
+func newRepoCloneCommand(service *app.Service) *cobra.Command {
+ return &cobra.Command{
+ Use: "clone [directory]",
+ Short: "Clone a Tangled repository",
+ Long: `Clone a Tangled repository via SSH into a local directory.
The default destination is the repository name.`,
- Args: cobra.RangeArgs(1, 2),
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx := cmd.Context()
-
- handle, repo, err := parseHandleRepo(args[0])
- if err != nil {
- return err
- }
-
- dest := repo
- if len(args) == 2 {
- dest = args[1]
- }
-
- fmt.Fprintf(os.Stderr, "Cloning %s/%s into %s...\n", handle, repo, dest)
- if err := gitutil.CloneRepo(ctx, gitutil.CloneRepoParams{
- Handle: handle,
- Repo: repo,
- RepoDir: dest,
- }); err != nil {
- return fmt.Errorf("clone %q: %w", args[0], err)
- }
-
- result := repoCloneResult{
- Handle: handle,
- Repo: repo,
- Destination: dest,
- }
- return output(result, func(clone repoCloneResult) {
- fmt.Printf("Cloned %s/%s into %s\n", clone.Handle, clone.Repo, clone.Destination)
- })
- },
+ Args: cobra.RangeArgs(1, 2),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := cmd.Context()
+ target, err := app.ParseTarget(args[0])
+ if err != nil {
+ return err
+ }
+
+ dest := target.Repo
+ if len(args) == 2 {
+ dest = args[1]
+ }
+
+ result, err := service.CloneRepo(ctx, app.CloneRepoInput{
+ Handle: target.Handle,
+ Repo: target.Repo,
+ Destination: dest,
+ })
+ if err != nil {
+ return fmt.Errorf("clone %q: %w", args[0], err)
+ }
+ return output(cmd, result, func(clone *app.RepoCloneResult) {
+ fmt.Fprintf(cmd.OutOrStdout(), "Cloned %s/%s into %s\n", clone.Handle, clone.Repo, clone.Destination)
+ })
+ },
+ }
}
diff --git a/internal/cli/repo_create.go b/internal/cli/repo_create.go
index b3bf79f..36bb6b2 100644
--- a/internal/cli/repo_create.go
+++ b/internal/cli/repo_create.go
@@ -3,28 +3,21 @@ package cli
import (
"context"
"fmt"
- "os"
- "time"
+ "io"
- "github.com/alyraffauf/tg/atproto"
- "github.com/alyraffauf/tg/internal/gitutil"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/alyraffauf/tg/knot"
- "github.com/alyraffauf/tg/tangled"
"github.com/spf13/cobra"
)
-var (
- repoCreateDescription string
- repoCreateKnot string
- repoCreateClone bool
- repoCreatePushPath string
- repoCreateRemote string
-)
+func newRepoCreateCommand(service *app.Service) *cobra.Command {
+ var description, knotHost, pushPath, remote string
+ var clone bool
-var repoCreateCmd = &cobra.Command{
- Use: "create ",
- Short: "Create a repository on Tangled",
- Long: `Create a repository on Tangled.
+ command := &cobra.Command{
+ Use: "create ",
+ Short: "Create a repository on Tangled",
+ Long: `Create a repository on Tangled.
The repository is provisioned on a knot (default ` + knot.DefaultKnot + `) and a
sh.tangled.repo record is written to your PDS. The repository name is used as
@@ -35,122 +28,72 @@ Use --clone to clone the new repository into the current directory, or
remote (and set its current branch as the default branch).
Requires authentication (run "tg auth login" first).`,
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx := cmd.Context()
-
- atClient, did, err := authenticatedATProto(ctx)
- if err != nil {
- return err
- }
-
- knotHost := repoCreateKnot
- if knotHost == "" {
- knotHost = knot.DefaultKnot
- }
-
- uri, err := provisionRepo(ctx, atClient, provisionRepoInput{
- KnotHost: knotHost,
- OwnerDID: did,
- Name: args[0],
- Description: repoCreateDescription,
- })
- if err != nil {
- return err
- }
-
- handle := ownerHandle(ctx, did)
- result := repoCreateResult{
- Handle: handle,
- Name: args[0],
- URI: uri,
- Knot: knotHost,
- }
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := cmd.Context()
- if repoCreateClone {
- if err := gitutil.CloneRepo(ctx, gitutil.CloneRepoParams{
- Handle: handle,
- Repo: args[0],
- RepoDir: args[0],
- }); err != nil {
- return fmt.Errorf("clone new repository: %w", err)
+ selectedKnot := knotHost
+ if selectedKnot == "" {
+ selectedKnot = knot.DefaultKnot
}
- result.Cloned = true
- }
- if repoCreatePushPath != "" {
- if err := pushToNewRepo(ctx, atClient, pushToNewRepoInput{
- KnotHost: knotHost,
- RepoURI: uri,
- Handle: handle,
- RepoName: args[0],
- PushPath: repoCreatePushPath,
- RemoteName: repoCreateRemote,
- }); err != nil {
+
+ uri, handle, err := service.ProvisionRepo(ctx, app.ProvisionRepoInput{
+ KnotHost: selectedKnot,
+ Name: args[0],
+ Description: description,
+ })
+ if err != nil {
return err
}
- result.Pushed = true
- }
- return output(result, func(repo repoCreateResult) {
- fmt.Printf("Created repository %s/%s\n", repo.Handle, repo.Name)
- if repo.Cloned {
- fmt.Printf("Cloned into %s\n", repo.Name)
- }
- if repo.Pushed {
- fmt.Printf("Pushed to %s\n", repo.Name)
+ result := app.RepoCreateResult{
+ Handle: handle,
+ Name: args[0],
+ URI: uri,
+ Knot: selectedKnot,
}
- })
- },
-}
-func init() {
- repoCreateCmd.Flags().StringVar(&repoCreateDescription, "description", "", "Repository description")
- repoCreateCmd.Flags().StringVar(&repoCreateKnot, "knot", "", "knot host to create on (default "+knot.DefaultKnot+")")
- repoCreateCmd.Flags().BoolVar(&repoCreateClone, "clone", false, "Clone the new repository into the current directory")
- repoCreateCmd.Flags().StringVar(&repoCreatePushPath, "push", "", "Push an existing local repository at this path to the new remote (e.g. .)")
- repoCreateCmd.Flags().StringVar(&repoCreateRemote, "remote", "origin", "Remote name to use with --push")
-}
-
-type provisionRepoInput struct {
- KnotHost string
- OwnerDID string
- Name string
- Description string
-}
+ if clone {
+ if _, err := service.CloneRepo(ctx, app.CloneRepoInput{
+ Handle: handle,
+ Repo: args[0],
+ Destination: args[0],
+ }); err != nil {
+ return fmt.Errorf("clone new repository: %w", err)
+ }
+ result.Cloned = true
+ }
+ if pushPath != "" {
+ if err := pushToNewRepo(ctx, service, cmd.ErrOrStderr(), pushToNewRepoInput{
+ KnotHost: selectedKnot,
+ RepoURI: uri,
+ Handle: handle,
+ RepoName: args[0],
+ PushPath: pushPath,
+ RemoteName: remote,
+ }); err != nil {
+ return err
+ }
+ result.Pushed = true
+ }
-// provisionRepo creates the repo on the knot and writes the sh.tangled.repo
-// record to the user's PDS.
-func provisionRepo(ctx context.Context, atClient *atproto.ATProto, in provisionRepoInput) (string, error) {
- token, err := atClient.GetServiceAuth(ctx, "did:web:"+in.KnotHost, "sh.tangled.repo.create")
- if err != nil {
- return "", err
- }
- repoDid, err := knot.New(in.KnotHost, token).CreateRepo(ctx, knot.CreateRepoInput{
- Name: in.Name,
- Rkey: in.Name,
- })
- if err != nil {
- return "", err
- }
- record := tangled.RepoRecord{
- Type: "sh.tangled.repo",
- Knot: in.KnotHost,
- CreatedAt: time.Now().UTC().Format(time.RFC3339),
- RepoDid: repoDid,
- }
- if in.Description != "" {
- record.Description = in.Description
+ return output(cmd, result, func(repo app.RepoCreateResult) {
+ fmt.Fprintf(cmd.OutOrStdout(), "Created repository %s/%s\n", repo.Handle, repo.Name)
+ if repo.Cloned {
+ fmt.Fprintf(cmd.OutOrStdout(), "Cloned into %s\n", repo.Name)
+ }
+ if repo.Pushed {
+ fmt.Fprintf(cmd.OutOrStdout(), "Pushed to %s\n", repo.Name)
+ }
+ })
+ },
}
- uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{
- Repo: in.OwnerDID,
- Collection: "sh.tangled.repo",
- Rkey: in.Name,
- Record: record,
- })
- if err != nil {
- return "", err
- }
- return uri, nil
+ command.Flags().StringVar(&description, "description", "", "Repository description")
+ command.Flags().StringVar(&knotHost, "knot", "", "knot host to create on (default "+knot.DefaultKnot+")")
+ command.Flags().BoolVar(&clone, "clone", false, "Clone the new repository into the current directory")
+ command.Flags().StringVar(&pushPath, "push", "", "Push an existing local repository at this path to the new remote (e.g. .)")
+ command.Flags().StringVar(&remote, "remote", "origin", "Remote name to use with --push")
+ return command
}
type pushToNewRepoInput struct {
@@ -164,57 +107,19 @@ type pushToNewRepoInput struct {
// pushToNewRepo sets the default branch then pushes. Default-branch failure is
// warned, not fatal. Set before push so the knot's hook skips its PR suggestion.
-func pushToNewRepo(ctx context.Context, atClient *atproto.ATProto, in pushToNewRepoInput) error {
- branch, err := setDefaultBranch(ctx, atClient, setDefaultBranchInput{
- KnotHost: in.KnotHost,
- RepoURI: in.RepoURI,
- Dir: in.PushPath,
- })
- if err != nil {
- fmt.Fprintf(os.Stderr, "warning: could not set default branch: %v\n", err)
- } else {
- fmt.Fprintf(os.Stderr, "Set default branch to %s\n", branch)
- }
- if err := gitutil.PushNewRepo(ctx, gitutil.PushNewRepoParams{
+func pushToNewRepo(ctx context.Context, service *app.Service, errorWriter io.Writer, in pushToNewRepoInput) error {
+ branch, defaultBranchErr, err := service.PushNewRepo(ctx, app.PushNewRepoInput{
+ KnotHost: in.KnotHost,
+ RepoURI: in.RepoURI,
Dir: in.PushPath,
Handle: in.Handle,
Repo: in.RepoName,
RemoteName: in.RemoteName,
- }); err != nil {
- return fmt.Errorf("push to new repository: %w", err)
- }
- return nil
-}
-
-type setDefaultBranchInput struct {
- KnotHost string
- RepoURI string
- Dir string
-}
-
-// setDefaultBranch repoints the default branch to the local repo's current
-// branch. Mints a fresh token because the create token is lexicon-scoped.
-func setDefaultBranch(ctx context.Context, atClient *atproto.ATProto, in setDefaultBranchInput) (string, error) {
- branch, err := gitutil.CurrentBranch(ctx, in.Dir)
- if err != nil {
- return "", err
- }
- token, err := atClient.GetServiceAuth(ctx, "did:web:"+in.KnotHost, "sh.tangled.repo.setDefaultBranch")
- if err != nil {
- return "", err
- }
- if err := knot.New(in.KnotHost, token).SetDefaultBranch(ctx, knot.SetDefaultBranchInput{
- Repo: in.RepoURI,
- DefaultBranch: branch,
- }); err != nil {
- return branch, err
- }
- return branch, nil
-}
-
-func ownerHandle(ctx context.Context, did string) string {
- if ident, err := resolver.ResolveDID(ctx, did); err == nil {
- return ident.Handle.String()
+ })
+ if defaultBranchErr != nil {
+ fmt.Fprintf(errorWriter, "warning: could not set default branch: %v\n", defaultBranchErr)
+ } else {
+ fmt.Fprintf(errorWriter, "Set default branch to %s\n", branch)
}
- return did
+ return err
}
diff --git a/internal/cli/repo_delete.go b/internal/cli/repo_delete.go
index da4a25b..8819744 100644
--- a/internal/cli/repo_delete.go
+++ b/internal/cli/repo_delete.go
@@ -3,79 +3,35 @@ package cli
import (
"fmt"
- "github.com/alyraffauf/tg/atproto"
- "github.com/alyraffauf/tg/knot"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
-var repoDeleteConfirm bool
+func newRepoDeleteCommand(service *app.Service) *cobra.Command {
+ var confirm bool
-var repoDeleteCmd = &cobra.Command{
- Use: "delete [handle/repo]",
- Short: "Delete a Tangled repository",
- Args: cobra.MaximumNArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- if !repoDeleteConfirm {
- return fmt.Errorf("refusing to delete without --yes")
- }
- ctx := cmd.Context()
- atClient, did, err := authenticatedATProto(ctx)
- if err != nil {
- return err
- }
- handle, name, err := resolveTarget(ctx, args)
- if err != nil {
- return err
- }
- repo, err := requireOwnedRepo(ctx, handle, name, did)
- if err != nil {
- return err
- }
- if repo.Value.Knot == "" {
- return fmt.Errorf("repo %q has no knot", handle+"/"+name)
- }
- rkey := extractRKey(repo.URI)
- existingRecord, getErr := atClient.GetRecord(ctx, did, "sh.tangled.repo", rkey)
- // getErr is non-fatal: the record may already be deleted. Only
- // call DeleteRecord if it still exists.
-
- token, err := atClient.GetServiceAuth(ctx, "did:web:"+repo.Value.Knot, "sh.tangled.repo.delete")
- if err != nil {
- return fmt.Errorf("get knot authorization: %w", err)
- }
- if getErr == nil {
- if err := atClient.DeleteRecord(ctx, atproto.DeleteRecordInput{
- Repo: did,
- Collection: "sh.tangled.repo",
- Rkey: rkey,
- }); err != nil {
- return fmt.Errorf("delete repository record: %w", err)
+ command := &cobra.Command{
+ Use: "delete [handle/repo]",
+ Short: "Delete a Tangled repository",
+ Args: cobra.MaximumNArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if !confirm {
+ return fmt.Errorf("refusing to delete without --yes")
}
- }
- if err := knot.New(repo.Value.Knot, token).DeleteRepo(ctx, knot.DeleteRepoInput{
- DID: did,
- Name: name,
- Rkey: rkey,
- }); err != nil {
- if getErr == nil {
- if _, _, restoreErr := atClient.PutRecord(ctx, atproto.PutRecordInput{
- Repo: did, Collection: "sh.tangled.repo", Rkey: rkey, Record: existingRecord.Value,
- }); restoreErr != nil {
- return fmt.Errorf("delete knot repository: %w; restore repository record: %v", err, restoreErr)
- }
+ ctx := cmd.Context()
+ target, err := resolveTarget(ctx, args, service)
+ if err != nil {
+ return err
}
- return err
- }
- return output(repoDeleteResult{URI: repo.URI}, func(result repoDeleteResult) {
- fmt.Printf("Deleted repository %s\n", result.URI)
- })
- },
-}
-
-func init() {
- repoDeleteCmd.Flags().BoolVar(&repoDeleteConfirm, "yes", false, "Confirm permanent repository deletion")
-}
-
-type repoDeleteResult struct {
- URI string `json:"uri"`
+ result, err := service.DeleteRepo(ctx, target)
+ if err != nil {
+ return err
+ }
+ return output(cmd, result, func(result *app.RepoDeleteResult) {
+ fmt.Fprintf(cmd.OutOrStdout(), "Deleted repository %s\n", result.URI)
+ })
+ },
+ }
+ command.Flags().BoolVar(&confirm, "yes", false, "Confirm permanent repository deletion")
+ return command
}
diff --git a/internal/cli/repo_edit.go b/internal/cli/repo_edit.go
index 54ec4b9..859c2a6 100644
--- a/internal/cli/repo_edit.go
+++ b/internal/cli/repo_edit.go
@@ -1,135 +1,52 @@
package cli
import (
- "encoding/json"
"fmt"
- "sort"
- "github.com/alyraffauf/tg/atproto"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
-var (
- repoEditDescription string
- repoEditWebsite string
- repoEditSpindle string
- repoEditAddLabels []string
- repoEditRemoveLabels []string
-)
-
-var repoEditCmd = &cobra.Command{
- Use: "edit [handle/repo]",
- Short: "Edit a Tangled repository",
- Args: cobra.MaximumNArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- if !cmd.Flags().Changed("description") && !cmd.Flags().Changed("website") && !cmd.Flags().Changed("spindle") && len(repoEditAddLabels) == 0 && len(repoEditRemoveLabels) == 0 {
- return fmt.Errorf("set a repository field to update")
- }
- ctx := cmd.Context()
- atClient, did, err := authenticatedATProto(ctx)
- if err != nil {
- return err
- }
- handle, name, err := resolveTarget(ctx, args)
- if err != nil {
- return err
- }
- repo, err := requireOwnedRepo(ctx, handle, name, did)
- if err != nil {
- return err
- }
-
- rkey := extractRKey(repo.URI)
- existing, err := atClient.GetRecord(ctx, did, "sh.tangled.repo", rkey)
- if err != nil {
- return fmt.Errorf("get repository record: %w", err)
- }
- record, err := repoRecordMap(existing.Value)
- if err != nil {
- return err
- }
- if cmd.Flags().Changed("description") {
- record["description"] = repoEditDescription
- }
- if cmd.Flags().Changed("website") {
- record["website"] = repoEditWebsite
- }
- if cmd.Flags().Changed("spindle") {
- record["spindle"] = repoEditSpindle
- }
- if len(repoEditAddLabels) > 0 || len(repoEditRemoveLabels) > 0 {
- labels := labelsFromRecord(record["labels"])
- for _, label := range repoEditAddLabels {
- labels[label] = true
+func newRepoEditCommand(service *app.Service) *cobra.Command {
+ var description, website, spindle string
+ var addLabels, removeLabels []string
+
+ command := &cobra.Command{
+ Use: "edit [handle/repo]",
+ Short: "Edit a Tangled repository",
+ Args: cobra.MaximumNArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if !cmd.Flags().Changed("description") && !cmd.Flags().Changed("website") && !cmd.Flags().Changed("spindle") && len(addLabels) == 0 && len(removeLabels) == 0 {
+ return fmt.Errorf("set a repository field to update")
}
- for _, label := range repoEditRemoveLabels {
- delete(labels, label)
+ ctx := cmd.Context()
+ target, err := resolveTarget(ctx, args, service)
+ if err != nil {
+ return err
}
- record["labels"] = labelNames(labels)
- }
- if _, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{
- Repo: did,
- Collection: "sh.tangled.repo",
- Rkey: rkey,
- Record: record,
- }); err != nil {
- return fmt.Errorf("edit repository: %w", err)
- }
-
- result := repoEditResult{URI: repo.URI, Description: repoEditDescription}
- return output(result, func(result repoEditResult) {
- fmt.Printf("Updated repository %s\n", result.URI)
- })
- },
-}
-
-type repoEditResult struct {
- URI string `json:"uri"`
- Description string `json:"description"`
-}
-
-func init() {
- repoEditCmd.Flags().StringVarP(&repoEditDescription, "description", "d", "", "Repository description")
- repoEditCmd.Flags().StringVar(&repoEditWebsite, "website", "", "Repository website")
- repoEditCmd.Flags().StringVar(&repoEditSpindle, "spindle", "", "Repository spindle")
- repoEditCmd.Flags().StringSliceVar(&repoEditAddLabels, "add-label", nil, "Label to add")
- repoEditCmd.Flags().StringSliceVar(&repoEditRemoveLabels, "remove-label", nil, "Label to remove")
-}
-
-func repoRecordMap(value any) (map[string]any, error) {
- data, err := json.Marshal(value)
- if err != nil {
- return nil, fmt.Errorf("encode repository record: %w", err)
- }
- var record map[string]any
- if err := json.Unmarshal(data, &record); err != nil {
- return nil, fmt.Errorf("decode repository record: %w", err)
- }
- if record == nil {
- return nil, fmt.Errorf("repository record is not an object")
- }
- return record, nil
-}
-
-func labelsFromRecord(value any) map[string]bool {
- labels := make(map[string]bool)
- values, ok := value.([]any)
- if !ok {
- return labels
- }
- for _, value := range values {
- if label, ok := value.(string); ok {
- labels[label] = true
- }
- }
- return labels
-}
-
-func labelNames(labels map[string]bool) []string {
- names := make([]string, 0, len(labels))
- for label := range labels {
- names = append(names, label)
+ in := app.EditRepoInput{AddLabels: addLabels, RemoveLabels: removeLabels}
+ if cmd.Flags().Changed("description") {
+ in.Description = &description
+ }
+ if cmd.Flags().Changed("website") {
+ in.Website = &website
+ }
+ if cmd.Flags().Changed("spindle") {
+ in.Spindle = &spindle
+ }
+ result, err := service.EditRepo(ctx, target, in)
+ if err != nil {
+ return err
+ }
+ return output(cmd, result, func(result *app.RepoEditResult) {
+ fmt.Fprintf(cmd.OutOrStdout(), "Updated repository %s\n", result.URI)
+ })
+ },
}
- sort.Strings(names)
- return names
+ command.Flags().StringVarP(&description, "description", "d", "", "Repository description")
+ command.Flags().StringVar(&website, "website", "", "Repository website")
+ command.Flags().StringVar(&spindle, "spindle", "", "Repository spindle")
+ command.Flags().StringSliceVar(&addLabels, "add-label", nil, "Label to add")
+ command.Flags().StringSliceVar(&removeLabels, "remove-label", nil, "Label to remove")
+ return command
}
diff --git a/internal/cli/repo_fork.go b/internal/cli/repo_fork.go
index c055d54..a4eac0f 100644
--- a/internal/cli/repo_fork.go
+++ b/internal/cli/repo_fork.go
@@ -1,130 +1,34 @@
package cli
import (
- "context"
"fmt"
- "strings"
- "time"
- "github.com/alyraffauf/tg/atproto"
- "github.com/alyraffauf/tg/knot"
- "github.com/alyraffauf/tg/tangled"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
-var repoForkCmd = &cobra.Command{
- Use: "fork [name]",
- Short: "Fork a Tangled repository",
- Args: cobra.RangeArgs(1, 2),
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx := cmd.Context()
- atClient, ownerDID, err := authenticatedATProto(ctx)
- if err != nil {
- return err
- }
-
- handle, sourceName, err := parseHandleRepo(args[0])
- if err != nil {
- return err
- }
- name := sourceName
- if len(args) == 2 {
- name = args[1]
- }
-
- source, err := getForkSource(ctx, handle, sourceName)
- if err != nil {
- return err
- }
- token, err := atClient.GetServiceAuth(ctx, "did:web:"+source.Knot, "sh.tangled.repo.create")
- if err != nil {
- return fmt.Errorf("get knot service auth: %w", err)
- }
- repoDID, err := knot.New(source.Knot, token).CreateRepo(ctx, knot.CreateRepoInput{
- Name: name,
- Rkey: name,
- Source: forkSourceURL(source.Knot, source.RepoDID),
- })
- if err != nil {
- return err
- }
- uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{
- Repo: ownerDID,
- Collection: "sh.tangled.repo",
- Rkey: name,
- Record: tangled.RepoRecord{
- Type: "sh.tangled.repo",
- Name: name,
- Knot: source.Knot,
- CreatedAt: time.Now().UTC().Format(time.RFC3339),
- RepoDid: repoDID,
- Source: source.URI,
- },
- })
- if err != nil {
- cleanupErr := deleteFork(ctx, atClient, source.Knot, ownerDID, name)
- if cleanupErr != nil {
- return fmt.Errorf("write fork record: %w; delete orphaned fork: %v", err, cleanupErr)
+func newRepoForkCommand(service *app.Service) *cobra.Command {
+ return &cobra.Command{
+ Use: "fork [name]",
+ Short: "Fork a Tangled repository",
+ Args: cobra.RangeArgs(1, 2),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := cmd.Context()
+ source, err := app.ParseTarget(args[0])
+ if err != nil {
+ return err
}
- return fmt.Errorf("write fork record: %w", err)
- }
-
- return output(repoForkResult{Handle: ownerHandle(ctx, ownerDID), Name: name, URI: uri, Knot: source.Knot}, func(fork repoForkResult) {
- fmt.Printf("Forked %s/%s as %s/%s\n", handle, sourceName, fork.Handle, fork.Name)
- })
- },
-}
-
-func deleteFork(ctx context.Context, atClient *atproto.ATProto, knotHost, did, name string) error {
- token, err := atClient.GetServiceAuth(ctx, "did:web:"+knotHost, "sh.tangled.repo.delete")
- if err != nil {
- return fmt.Errorf("get knot authorization: %w", err)
- }
- if err := knot.New(knotHost, token).DeleteRepo(ctx, knot.DeleteRepoInput{DID: did, Name: name, Rkey: name}); err != nil {
- return err
- }
- return nil
-}
-
-type forkSource struct {
- URI string
- Knot string
- RepoDID string
-}
-
-func forkSourceURL(knotHost, repoDID string) string {
- base := strings.TrimRight(knotHost, "/")
- if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
- base = "https://" + base
- }
- return base + "/" + repoDID
-}
-
-func getForkSource(ctx context.Context, handle, name string) (forkSource, error) {
- ident, err := resolver.ResolveHandle(ctx, handle)
- if err != nil {
- return forkSource{}, fmt.Errorf("resolve handle %q: %w", handle, err)
- }
- uri := fmt.Sprintf("at://%s/sh.tangled.repo/%s", ident.DID, name)
- repo, err := client.GetRepo(ctx, uri)
- if err != nil {
- return forkSource{}, fmt.Errorf("get source repository %s/%s: %w", handle, name, err)
- }
- if repo.Value.Knot == "" {
- return forkSource{}, fmt.Errorf("source repository %s/%s has no knot", handle, name)
- }
- if repo.Value.RepoDid == "" {
- return forkSource{}, fmt.Errorf("source repository %s/%s has no repo DID", handle, name)
- }
- if repo.URI != "" {
- uri = repo.URI
+ name := source.Repo
+ if len(args) == 2 {
+ name = args[1]
+ }
+ result, err := service.ForkRepo(ctx, source, name)
+ if err != nil {
+ return err
+ }
+ return output(cmd, result, func(fork *app.RepoForkResult) {
+ fmt.Fprintf(cmd.OutOrStdout(), "Forked %s as %s/%s\n", source, fork.Handle, fork.Name)
+ })
+ },
}
- return forkSource{URI: uri, Knot: repo.Value.Knot, RepoDID: repo.Value.RepoDid}, nil
-}
-
-type repoForkResult struct {
- Handle string `json:"handle"`
- Name string `json:"name"`
- URI string `json:"uri"`
- Knot string `json:"knot"`
}
diff --git a/internal/cli/repo_list.go b/internal/cli/repo_list.go
index bc433f2..58d3166 100644
--- a/internal/cli/repo_list.go
+++ b/internal/cli/repo_list.go
@@ -1,112 +1,42 @@
package cli
import (
- "context"
- "errors"
- "fmt"
- "strings"
+ "io"
- "github.com/alyraffauf/tg/atproto"
- "github.com/alyraffauf/tg/internal/gitutil"
- "github.com/alyraffauf/tg/tangled"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
-var repoListCmd = &cobra.Command{
- Use: "list [handle]",
- Short: "List repositories owned by a Tangled user",
- Long: `List repositories owned by a Tangled user.
+func newRepoListCommand(service *app.Service) *cobra.Command {
+ return &cobra.Command{
+ Use: "list [handle]",
+ Short: "List repositories owned by a Tangled user",
+ Long: `List repositories owned by a Tangled user.
If no argument is given, the command detects the user from the "origin"
remote URL of the git repository in the current directory.`,
- Args: cobra.MaximumNArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx := cmd.Context()
-
- handle, err := resolveHandleArg(ctx, args)
- if err != nil {
- return err
- }
-
- ident, err := resolver.ResolveHandle(ctx, handle)
- if err != nil {
- return fmt.Errorf("resolve handle %q: %w", handle, err)
- }
-
- repos, err := client.ListRepos(ctx, ident.DID.String())
- if err != nil {
- return fmt.Errorf("list repos for %q: %w", handle, err)
- }
-
- items := buildRepoItems(repos.Items, handle)
- return output(items, renderRepoList)
- },
-}
-
-// resolveHandleArg returns the handle from an explicit argument, or
-// falls back to the handle of the CWD's git origin remote.
-func resolveHandleArg(ctx context.Context, args []string) (string, error) {
- if len(args) == 1 {
- return args[0], nil
- }
-
- rc, err := gitutil.DetectRepoFromCWD(ctx)
- if err != nil {
- return "", fmt.Errorf("detect repo from current directory: %w", err)
- }
- return rc.Handle, nil
-}
-
-// resolveHandleOrSelf returns the handle from an explicit argument, or the
-// authenticated user's handle. It does not fall back to CWD git detection.
-func resolveHandleOrSelf(ctx context.Context, args []string) (string, error) {
- if len(args) == 1 {
- return args[0], nil
- }
- did, err := auth.CurrentDID(ctx)
- if err != nil {
- if errors.Is(err, atproto.ErrNotAuthenticated) {
- return "", fmt.Errorf("not logged in; provide a handle or run \"tg auth login\"")
- }
- return "", fmt.Errorf("resume OAuth session: %w", err)
- }
- ident, err := resolver.ResolveDID(ctx, did.String())
- if err != nil {
- return "", fmt.Errorf("resolve your DID: %w", err)
- }
- return ident.Handle.String(), nil
-}
-
-func buildRepoItems(items []tangled.Repo, author string) []repoItem {
- result := make([]repoItem, 0, len(items))
-
- for _, tangledRepo := range items {
- name := tangledRepo.Value.Name
- if name == "" {
- // Fall back to the rkey segment of the at:// URI.
- if idx := strings.LastIndex(tangledRepo.URI, "/"); idx != -1 {
- name = tangledRepo.URI[idx+1:]
+ Args: cobra.MaximumNArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := cmd.Context()
+ handle, err := resolveHandleArg(ctx, args, service)
+ if err != nil {
+ return err
}
- }
-
- result = append(result, repoItem{
- Name: name,
- URI: tangledRepo.URI,
- Author: author,
- Knot: tangledRepo.Value.Knot,
- Description: tangledRepo.Value.Description,
- CreatedAt: tangledRepo.Value.CreatedAt,
- RepoDid: tangledRepo.Value.RepoDid,
- })
+ items, err := service.ListRepos(ctx, handle)
+ if err != nil {
+ return err
+ }
+ return output(cmd, items, func(items []app.RepoItem) {
+ renderRepoList(cmd.OutOrStdout(), items)
+ })
+ },
}
-
- return result
}
-func renderRepoList(items []repoItem) {
+func renderRepoList(writer io.Writer, items []app.RepoItem) {
rows := make([][]string, 0, len(items))
for _, repo := range items {
rows = append(rows, []string{repo.Name, repo.Knot, repo.Description, shortDate(repo.CreatedAt)})
}
- renderTable([]string{"NAME", "KNOT", "DESCRIPTION", "CREATED"}, rows, "No repositories found.")
+ renderTable(writer, []string{"NAME", "KNOT", "DESCRIPTION", "CREATED"}, rows, "No repositories found.")
}
diff --git a/internal/cli/repo_records.go b/internal/cli/repo_records.go
deleted file mode 100644
index b1a5dec..0000000
--- a/internal/cli/repo_records.go
+++ /dev/null
@@ -1,70 +0,0 @@
-package cli
-
-import (
- "context"
- "errors"
- "fmt"
- "net/http"
- "strings"
-
- "github.com/alyraffauf/tg/tangled"
- "github.com/bluesky-social/indigo/atproto/atclient"
-)
-
-// resolveRepoRecord finds a repository record even when its rkey does not
-// match the repository name.
-func resolveRepoRecord(ctx context.Context, handle, name string) (*tangled.Repo, error) {
- ident, err := resolver.ResolveHandle(ctx, handle)
- if err != nil {
- return nil, fmt.Errorf("resolve handle %q: %w", handle, err)
- }
-
- recordURI := fmt.Sprintf("at://%s/sh.tangled.repo/%s", ident.DID, name)
- if repo, err := client.GetRepo(ctx, recordURI); err == nil {
- if repo.URI == "" {
- repo.URI = recordURI
- }
- return repo, nil
- } else if !shouldListRepoRecords(err) {
- return nil, fmt.Errorf("get repository %q: %w", name, err)
- }
-
- repos, err := client.ListRepos(ctx, ident.DID.String())
- if err != nil {
- return nil, fmt.Errorf("list repos for %q: %w", handle, err)
- }
- for index := range repos.Items {
- repo := &repos.Items[index]
- if repo.Value.Name == name || extractRKey(repo.URI) == name {
- return repo, nil
- }
- }
- return nil, fmt.Errorf("repo %q not found for handle %q", name, handle)
-}
-
-func shouldListRepoRecords(err error) bool {
- var apiError *atclient.APIError
- if !errors.As(err, &apiError) {
- return false
- }
- if apiError.StatusCode == http.StatusNotFound {
- return true
- }
-
- // Bobbin wraps an upstream PDS 400 as a 502 when no record exists at the
- // name-derived rkey. Listing is required to find the record's actual rkey.
- return apiError.StatusCode == http.StatusBadGateway &&
- apiError.Name == "UpstreamFailed" &&
- strings.Contains(apiError.Message, "upstream returned status 400 Bad Request")
-}
-
-func requireOwnedRepo(ctx context.Context, handle, name, did string) (*tangled.Repo, error) {
- repo, err := resolveRepoRecord(ctx, handle, name)
- if err != nil {
- return nil, err
- }
- if extractDID(repo.URI) != did {
- return nil, fmt.Errorf("repo %q is not owned by the authenticated user", handle+"/"+name)
- }
- return repo, nil
-}
diff --git a/internal/cli/repo_set_default_branch.go b/internal/cli/repo_set_default_branch.go
index dd46cb1..a73cc07 100644
--- a/internal/cli/repo_set_default_branch.go
+++ b/internal/cli/repo_set_default_branch.go
@@ -3,51 +3,29 @@ package cli
import (
"fmt"
- "github.com/alyraffauf/tg/knot"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
-var repoSetDefaultBranchCmd = &cobra.Command{
- Use: "set-default-branch [handle/repo]",
- Short: "Set a Tangled repository's default branch",
- Args: cobra.RangeArgs(1, 2),
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx := cmd.Context()
- atClient, did, err := authenticatedATProto(ctx)
- if err != nil {
- return err
- }
- branch := args[0]
- targetArgs := args[1:]
- handle, name, err := resolveTarget(ctx, targetArgs)
- if err != nil {
- return err
- }
- repo, err := requireOwnedRepo(ctx, handle, name, did)
- if err != nil {
- return err
- }
- if repo.Value.Knot == "" {
- return fmt.Errorf("repo %q has no knot", handle+"/"+name)
- }
-
- token, err := atClient.GetServiceAuth(ctx, "did:web:"+repo.Value.Knot, "sh.tangled.repo.setDefaultBranch")
- if err != nil {
- return fmt.Errorf("get knot authorization: %w", err)
- }
- if err := knot.New(repo.Value.Knot, token).SetDefaultBranch(ctx, knot.SetDefaultBranchInput{
- Repo: repo.URI,
- DefaultBranch: branch,
- }); err != nil {
- return err
- }
- return output(repoDefaultBranchResult{URI: repo.URI, Branch: branch}, func(result repoDefaultBranchResult) {
- fmt.Printf("Set default branch for %s to %s\n", result.URI, result.Branch)
- })
- },
-}
-
-type repoDefaultBranchResult struct {
- URI string `json:"uri"`
- Branch string `json:"branch"`
+func newRepoSetDefaultBranchCommand(service *app.Service) *cobra.Command {
+ return &cobra.Command{
+ Use: "set-default-branch [handle/repo]",
+ Short: "Set a Tangled repository's default branch",
+ Args: cobra.RangeArgs(1, 2),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := cmd.Context()
+ branch := args[0]
+ target, err := resolveTarget(ctx, args[1:], service)
+ if err != nil {
+ return err
+ }
+ result, err := service.SetRepoDefaultBranch(ctx, target, branch)
+ if err != nil {
+ return err
+ }
+ return output(cmd, result, func(result *app.RepoDefaultBranchResult) {
+ fmt.Fprintf(cmd.OutOrStdout(), "Set default branch for %s to %s\n", result.URI, result.Branch)
+ })
+ },
+ }
}
diff --git a/internal/cli/repo_view.go b/internal/cli/repo_view.go
index 05b4e29..d0865d7 100644
--- a/internal/cli/repo_view.go
+++ b/internal/cli/repo_view.go
@@ -3,60 +3,36 @@ package cli
import (
"fmt"
- "github.com/alyraffauf/tg/tangled"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
-var repoViewCmd = &cobra.Command{
- Use: "view ",
- Short: "View a Tangled repository",
- Long: `View details for a Tangled repository.`,
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx := cmd.Context()
-
- handle, repo, err := parseHandleRepo(args[0])
- if err != nil {
- return err
- }
-
- ident, err := resolver.ResolveHandle(ctx, handle)
- if err != nil {
- return fmt.Errorf("resolve handle %q: %w", handle, err)
- }
-
- repoURI := fmt.Sprintf("at://%s/sh.tangled.repo/%s", ident.DID, repo)
-
- var tangledRepo *tangled.Repo
- tangledRepo, err = client.GetRepo(ctx, repoURI)
- if err != nil {
- return fmt.Errorf("get repo %s/%s: %w", handle, repo, err)
- }
-
- name := tangledRepo.Value.Name
- if name == "" {
- name = repo
- }
-
- result := repoItem{
- Name: name,
- Author: handle,
- URI: repoURI,
- Knot: tangledRepo.Value.Knot,
- Description: tangledRepo.Value.Description,
- CreatedAt: tangledRepo.Value.CreatedAt,
- RepoDid: tangledRepo.Value.RepoDid,
- }
- return output(result, func(item repoItem) {
- fmt.Printf("Name: %s\n", item.Name)
- fmt.Printf("Description: %s\n", item.Description)
- fmt.Printf("URI: %s\n", item.URI)
- fmt.Printf("Knot: %s\n", item.Knot)
- fmt.Printf("Created: %s\n", item.CreatedAt)
- if item.RepoDid != "" {
- fmt.Printf("Repo DID: %s\n", item.RepoDid)
+func newRepoViewCommand(service *app.Service) *cobra.Command {
+ return &cobra.Command{
+ Use: "view ",
+ Short: "View a Tangled repository",
+ Long: `View details for a Tangled repository.`,
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := cmd.Context()
+ target, err := app.ParseTarget(args[0])
+ if err != nil {
+ return err
}
- })
-
- },
+ item, err := service.ViewRepo(ctx, target)
+ if err != nil {
+ return err
+ }
+ return output(cmd, item, func(item *app.RepoItem) {
+ fmt.Fprintf(cmd.OutOrStdout(), "Name: %s\n", item.Name)
+ fmt.Fprintf(cmd.OutOrStdout(), "Description: %s\n", item.Description)
+ fmt.Fprintf(cmd.OutOrStdout(), "URI: %s\n", item.URI)
+ fmt.Fprintf(cmd.OutOrStdout(), "Knot: %s\n", item.Knot)
+ fmt.Fprintf(cmd.OutOrStdout(), "Created: %s\n", item.CreatedAt)
+ if item.RepoDid != "" {
+ fmt.Fprintf(cmd.OutOrStdout(), "Repo DID: %s\n", item.RepoDid)
+ }
+ })
+ },
+ }
}
diff --git a/internal/cli/resource_comments.go b/internal/cli/resource_comments.go
deleted file mode 100644
index 287695b..0000000
--- a/internal/cli/resource_comments.go
+++ /dev/null
@@ -1,70 +0,0 @@
-package cli
-
-import (
- "context"
- "fmt"
- "time"
-
- "github.com/alyraffauf/tg/atproto"
- "github.com/bluesky-social/indigo/atproto/syntax"
-)
-
-type issueCommentRecord struct {
- Type string `json:"$type"`
- Issue string `json:"issue"`
- Body string `json:"body"`
- CreatedAt string `json:"createdAt"`
-}
-
-type pullCommentRecord struct {
- Type string `json:"$type"`
- Pull string `json:"pull"`
- Body string `json:"body"`
- CreatedAt string `json:"createdAt"`
-}
-
-func createIssueComment(ctx context.Context, issueURI, body string) (createdRecordResult, error) {
- atClient, did, err := authenticatedATProto(ctx)
- if err != nil {
- return createdRecordResult{}, err
- }
- rkey := string(syntax.NewTIDNow(0))
- uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{
- Repo: did,
- Collection: "sh.tangled.repo.issue.comment",
- Rkey: rkey,
- Record: issueCommentRecord{
- Type: "sh.tangled.repo.issue.comment",
- Issue: issueURI,
- Body: body,
- CreatedAt: time.Now().UTC().Format(time.RFC3339),
- },
- })
- if err != nil {
- return createdRecordResult{}, fmt.Errorf("create issue comment: %w", err)
- }
- return createdRecordResult{Rkey: rkey, URI: uri}, nil
-}
-
-func createPullComment(ctx context.Context, pullURI, body string) (createdRecordResult, error) {
- atClient, did, err := authenticatedATProto(ctx)
- if err != nil {
- return createdRecordResult{}, err
- }
- rkey := string(syntax.NewTIDNow(0))
- uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{
- Repo: did,
- Collection: "sh.tangled.repo.pull.comment",
- Rkey: rkey,
- Record: pullCommentRecord{
- Type: "sh.tangled.repo.pull.comment",
- Pull: pullURI,
- Body: body,
- CreatedAt: time.Now().UTC().Format(time.RFC3339),
- },
- })
- if err != nil {
- return createdRecordResult{}, fmt.Errorf("create pull request comment: %w", err)
- }
- return createdRecordResult{Rkey: rkey, URI: uri}, nil
-}
diff --git a/internal/cli/resource_states.go b/internal/cli/resource_states.go
deleted file mode 100644
index ba4195b..0000000
--- a/internal/cli/resource_states.go
+++ /dev/null
@@ -1,87 +0,0 @@
-package cli
-
-import (
- "context"
- "fmt"
-
- "github.com/alyraffauf/tg/atproto"
- "github.com/alyraffauf/tg/tangled"
-)
-
-const (
- issueCollection = "sh.tangled.repo.issue"
- pullCollection = "sh.tangled.repo.pull"
-)
-
-type issueStateRecord struct {
- Type string `json:"$type"`
- Issue string `json:"issue"`
- State string `json:"state"`
-}
-
-type pullStatusRecord struct {
- Type string `json:"$type"`
- Pull string `json:"pull"`
- Status string `json:"status"`
-}
-
-type stateResult struct {
- Rkey string `json:"rkey"`
- State string `json:"state"`
-}
-
-func targetRecord(ctx context.Context, repoArg, collection, rkey string) (string, string, error) {
- targetArgs := []string{}
- if repoArg != "" {
- targetArgs = []string{repoArg}
- }
- handle, repo, err := resolveTarget(ctx, targetArgs)
- if err != nil {
- return "", "", err
- }
- repoRecord, err := resolveRepoRecord(ctx, handle, repo)
- if err != nil {
- return "", "", err
- }
-
- var items []tangled.ListItem
- var recordType string
- if collection == issueCollection {
- issues, err := client.ListIssues(ctx, repoRecord.Value.RepoDid, tangled.ListOpts{Limit: defaultListLimit})
- if err != nil {
- return "", "", fmt.Errorf("list issues for %s/%s: %w", handle, repo, err)
- }
- items = issues.Items
- recordType = "issue"
- } else {
- pulls, err := client.ListPulls(ctx, repoRecord.Value.RepoDid, tangled.ListOpts{Limit: defaultListLimit})
- if err != nil {
- return "", "", fmt.Errorf("list pull requests for %s/%s: %w", handle, repo, err)
- }
- items = pulls.Items
- recordType = "pull request"
- }
-
- record, err := findByRKey(items, rkey, recordType)
- if err != nil {
- return "", "", err
- }
- return record.URI, repoRecord.URI, nil
-}
-
-func putState(ctx context.Context, atClient *atproto.ATProto, did, rkey, collection, target, state string) error {
- if collection == issueCollection {
- state = "sh.tangled.repo.issue.state." + state
- return putRecord(ctx, atClient, did, "sh.tangled.repo.issue.state", rkey, issueStateRecord{
- Type: "sh.tangled.repo.issue.state",
- Issue: target,
- State: state,
- })
- }
- state = "sh.tangled.repo.pull.status." + state
- return putRecord(ctx, atClient, did, "sh.tangled.repo.pull.status", rkey, pullStatusRecord{
- Type: "sh.tangled.repo.pull.status",
- Pull: target,
- Status: state,
- })
-}
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 513cea5..6057975 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -1,12 +1,13 @@
package cli
import (
- "log/slog"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "strings"
- "github.com/alyraffauf/tg/atproto"
- "github.com/alyraffauf/tg/tangled"
- "github.com/bluesky-social/indigo/atproto/atclient"
- "github.com/bluesky-social/indigo/atproto/identity"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
@@ -15,95 +16,109 @@ const (
oauthCallbackURL = "http://" + oauthCallbackAddr + "/callback"
)
-var (
- resolver = &atproto.Resolver{Directory: identity.DefaultDirectory()}
- client = &tangled.Tangled{
- Client: &atclient.APIClient{Host: defaultAppview},
- Logger: slog.Default(),
+func NewRoot(service *app.Service) *cobra.Command {
+ rootCmd := &cobra.Command{
+ Use: "tg",
+ Short: "A CLI for Tangled",
+ SilenceUsage: true,
}
- auth = atproto.NewAuthManager(oauthCallbackURL)
+ configureRoot(rootCmd)
- jsonOutput bool
-)
+ auth := newAuthCommand(service)
+ auth.AddCommand(newAuthLoginCommand(service), newAuthLogoutCommand(service), newAuthStatusCommand(service), newAuthTokenCommand(service), newAuthListCommand(service), newAuthSwitchCommand(service))
+ rootCmd.AddCommand(auth)
-var rootCmd = &cobra.Command{
- Use: "tg",
- Short: "A CLI for Tangled",
- // Errors such as "not logged in" are expected and shouldn't dump usage.
- SilenceUsage: true,
- PersistentPreRun: func(cmd *cobra.Command, args []string) {
- client.Client.Host = config.GetString("appview")
- auth.SetAccount(config.GetString("account"))
- },
-}
+ issue := newIssueCommand(service)
+ issue.AddCommand(newIssueListCommand(service), newIssueViewCommand(service), newIssueCreateCommand(service), newIssueCommentCommand(service), newIssueCloseCommand(service), newIssueReopenCommand(service), newIssueEditCommand(service))
+ rootCmd.AddCommand(issue)
-func Execute() error {
- return rootCmd.Execute()
-}
+ pull := newPRCommand(service)
+ pull.AddCommand(newPRListCommand(service), newPRViewCommand(service), newPRCreateCommand(service), newPRCommentCommand(service), newPRDiffCommand(service), newPRCheckoutCommand(service), newPRCloseCommand(service), newPRReopenCommand(service), newPREditCommand(service), newPRMergeCommand(service))
+ rootCmd.AddCommand(pull)
-func init() {
- cobra.OnInitialize(initConfig)
+ repo := newRepoCommand(service)
+ repo.AddCommand(newRepoViewCommand(service), newRepoCloneCommand(service), newRepoCreateCommand(service), newRepoListCommand(service), newRepoEditCommand(service), newRepoSetDefaultBranchCommand(service), newRepoDeleteCommand(service), newRepoForkCommand(service))
+ rootCmd.AddCommand(repo)
- rootCmd.PersistentFlags().StringVar(&configPath, "config", "", "Path to config file (default: $XDG_CONFIG_HOME/tg/config.toml)")
- rootCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Output in JSON format")
- rootCmd.PersistentFlags().String("appview", defaultAppview, "Appview host URL (overrides config file and TG_APPVIEW)")
- rootCmd.PersistentFlags().String("account", "", "Account handle or DID to use (overrides the active account and TG_ACCOUNT)")
+ keys := newSSHKeyCommand(service)
+ keys.AddCommand(newSSHKeyAddCommand(service), newSSHKeyListCommand(service), newSSHKeyDeleteCommand(service))
+ rootCmd.AddCommand(keys)
- config.BindPFlag("appview", rootCmd.PersistentFlags().Lookup("appview"))
- config.BindPFlag("account", rootCmd.PersistentFlags().Lookup("account"))
-
- rootCmd.AddCommand(authCmd)
- authCmd.AddCommand(authLoginCmd)
- authCmd.AddCommand(authLogoutCmd)
- authCmd.AddCommand(authStatusCmd)
- authCmd.AddCommand(authTokenCmd)
- authCmd.AddCommand(authListCmd)
- authCmd.AddCommand(authSwitchCmd)
-
- rootCmd.AddCommand(issueCmd)
- issueCmd.AddCommand(issueListCmd)
- issueCmd.AddCommand(issueViewCmd)
- issueCmd.AddCommand(issueCreateCmd)
- issueCmd.AddCommand(issueCommentCmd)
- issueCmd.AddCommand(issueCloseCmd)
- issueCmd.AddCommand(issueReopenCmd)
- issueCmd.AddCommand(issueEditCmd)
+ stringsCmd := newStringCommand(service)
+ stringsCmd.AddCommand(newStringCreateCommand(service), newStringListCommand(service), newStringViewCommand(service), newStringDeleteCommand(service))
+ rootCmd.AddCommand(stringsCmd, newBrowseCommand(service), newCompletionCommand(service), newManCommand(service), newAPICommand(service))
+ return rootCmd
+}
- rootCmd.AddCommand(prCmd)
- prCmd.AddCommand(prListCmd)
- prCmd.AddCommand(prViewCmd)
- prCmd.AddCommand(prCreateCmd)
- prCmd.AddCommand(prCommentCmd)
- prCmd.AddCommand(prDiffCmd)
- prCmd.AddCommand(prCheckoutCmd)
- prCmd.AddCommand(prCloseCmd)
- prCmd.AddCommand(prReopenCmd)
- prCmd.AddCommand(prEditCmd)
- prCmd.AddCommand(prMergeCmd)
+func Execute() error {
+ return ExecuteWith(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)
+}
- rootCmd.AddCommand(repoCmd)
- repoCmd.AddCommand(repoViewCmd)
- repoCmd.AddCommand(repoCloneCmd)
- repoCmd.AddCommand(repoCreateCmd)
- repoCmd.AddCommand(repoListCmd)
- repoCmd.AddCommand(repoEditCmd)
- repoCmd.AddCommand(repoSetDefaultBranchCmd)
- repoCmd.AddCommand(repoDeleteCmd)
- repoCmd.AddCommand(repoForkCmd)
+// ExecuteWith runs the CLI with explicit arguments and I/O streams.
+func ExecuteWith(arguments []string, input io.Reader, output, errorOutput io.Writer) error {
+ flags, err := parseFlagSettings(arguments)
+ if err != nil {
+ return err
+ }
+ settings := loadConfig(flags, errorOutput)
+ service := app.NewWithStreams(settings.Appview, oauthCallbackURL, output, errorOutput)
+ service.SetAccount(settings.Account)
+ root := NewRoot(service)
+ root.SetArgs(arguments)
+ root.SetIn(input)
+ root.SetOut(output)
+ root.SetErr(errorOutput)
+ err = root.Execute()
+ // A not-authenticated error from any service method is surfaced as the
+ // familiar login hint, so individual commands don't each have to.
+ if errors.Is(err, app.ErrNotAuthenticated) {
+ return fmt.Errorf("not logged in; run \"tg auth login\" first")
+ }
+ return err
+}
- rootCmd.AddCommand(sshKeyCmd)
- sshKeyCmd.AddCommand(sshKeyAddCmd)
- sshKeyCmd.AddCommand(sshKeyListCmd)
- sshKeyCmd.AddCommand(sshKeyDeleteCmd)
+func parseFlagSettings(arguments []string) (flagSettings, error) {
+ var flags flagSettings
+ for index := 0; index < len(arguments); index++ {
+ if arguments[index] == "--" {
+ break
+ }
+ argument := arguments[index]
+ name, value, hasValue := strings.Cut(argument, "=")
+ switch name {
+ case "--config":
+ flags.ConfigPath = flagValue(arguments, index, value, hasValue)
+ flags.ConfigSet = true
+ case "--appview":
+ flags.Appview = flagValue(arguments, index, value, hasValue)
+ flags.AppviewSet = true
+ case "--account":
+ flags.Account = flagValue(arguments, index, value, hasValue)
+ flags.AccountSet = true
+ }
+ if !hasValue && (name == "--config" || name == "--appview" || name == "--account") {
+ if index+1 >= len(arguments) || arguments[index+1] == "--" {
+ return flagSettings{}, fmt.Errorf("flag %s requires a value", name)
+ }
+ index++
+ }
+ }
+ return flags, nil
+}
- rootCmd.AddCommand(stringCmd)
- stringCmd.AddCommand(stringCreateCmd)
- stringCmd.AddCommand(stringListCmd)
- stringCmd.AddCommand(stringViewCmd)
- stringCmd.AddCommand(stringDeleteCmd)
+func flagValue(arguments []string, index int, value string, hasValue bool) string {
+ if hasValue {
+ return value
+ }
+ if index+1 < len(arguments) {
+ return arguments[index+1]
+ }
+ return ""
+}
- rootCmd.AddCommand(browseCmd)
- rootCmd.AddCommand(completionCmd)
- rootCmd.AddCommand(manCmd)
- rootCmd.AddCommand(apiCmd)
+func configureRoot(rootCmd *cobra.Command) {
+ rootCmd.PersistentFlags().String("config", "", "Path to config file (default: $XDG_CONFIG_HOME/tg/config.toml)")
+ rootCmd.PersistentFlags().Bool("json", false, "Output in JSON format")
+ rootCmd.PersistentFlags().String("appview", defaultAppview, "Appview host URL (overrides config file and TG_APPVIEW)")
+ rootCmd.PersistentFlags().String("account", "", "Account handle or DID to use (overrides the active account and TG_ACCOUNT)")
}
diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go
new file mode 100644
index 0000000..1f4a2f1
--- /dev/null
+++ b/internal/cli/root_test.go
@@ -0,0 +1,82 @@
+package cli
+
+import (
+ "testing"
+
+ "github.com/alyraffauf/tg/internal/app"
+)
+
+func TestNewRootCreatesIndependentCommandState(t *testing.T) {
+ firstRoot := NewRoot(&app.Service{})
+ secondRoot := NewRoot(&app.Service{})
+
+ firstCreate, _, err := firstRoot.Find([]string{"repo", "create"})
+ if err != nil {
+ t.Fatalf("find first repo create command: %v", err)
+ }
+ secondCreate, _, err := secondRoot.Find([]string{"repo", "create"})
+ if err != nil {
+ t.Fatalf("find second repo create command: %v", err)
+ }
+ if firstCreate == secondCreate {
+ t.Fatal("NewRoot reused the repo create command")
+ }
+
+ if err := firstCreate.Flags().Set("description", "first root"); err != nil {
+ t.Fatalf("set first root flag: %v", err)
+ }
+ if got := secondCreate.Flags().Lookup("description").Value.String(); got != "" {
+ t.Fatalf("second root inherited description %q", got)
+ }
+}
+
+func TestNewRootCreatesIndependentStateCommands(t *testing.T) {
+ firstRoot := NewRoot(&app.Service{})
+ secondRoot := NewRoot(&app.Service{})
+
+ firstClose, _, err := firstRoot.Find([]string{"issue", "close"})
+ if err != nil {
+ t.Fatalf("find first issue close command: %v", err)
+ }
+ secondClose, _, err := secondRoot.Find([]string{"issue", "close"})
+ if err != nil {
+ t.Fatalf("find second issue close command: %v", err)
+ }
+ if firstClose == secondClose {
+ t.Fatal("NewRoot reused the issue close command")
+ }
+
+ if err := firstClose.Flags().Set("repo", "first/repository"); err != nil {
+ t.Fatalf("set first issue close repo: %v", err)
+ }
+ if got := secondClose.Flags().Lookup("repo").Value.String(); got != "" {
+ t.Fatalf("second root inherited repo %q", got)
+ }
+}
+
+func TestParseFlagSettings(t *testing.T) {
+ flags, err := parseFlagSettings([]string{
+ "--appview", "https://flag.example",
+ "--account=flag.example",
+ "--config", "/tmp/tg.toml",
+ "--", "--account", "ignored",
+ })
+ if err != nil {
+ t.Fatalf("parseFlagSettings() error = %v", err)
+ }
+ if flags.Appview != "https://flag.example" || !flags.AppviewSet {
+ t.Fatalf("unexpected appview settings: %+v", flags)
+ }
+ if flags.Account != "flag.example" || !flags.AccountSet {
+ t.Fatalf("unexpected account settings: %+v", flags)
+ }
+ if flags.ConfigPath != "/tmp/tg.toml" || !flags.ConfigSet {
+ t.Fatalf("unexpected config settings: %+v", flags)
+ }
+}
+
+func TestParseFlagSettingsRejectsMissingValue(t *testing.T) {
+ if _, err := parseFlagSettings([]string{"--appview"}); err == nil {
+ t.Fatal("parseFlagSettings() accepted a missing value")
+ }
+}
diff --git a/internal/cli/rows.go b/internal/cli/rows.go
index fb3ae3a..4bb0053 100644
--- a/internal/cli/rows.go
+++ b/internal/cli/rows.go
@@ -1,18 +1,14 @@
package cli
import (
- "context"
- "encoding/json"
"fmt"
- "os"
+ "io"
"strings"
"text/tabwriter"
- "github.com/alyraffauf/tg/tangled"
+ "github.com/alyraffauf/tg/internal/app"
)
-const defaultListLimit int64 = 100
-
// shortDate trims an ISO 8601 timestamp to its YYYY-MM-DD prefix.
func shortDate(timestamp string) string {
if len(timestamp) > 10 {
@@ -24,13 +20,13 @@ func shortDate(timestamp string) string {
// renderTable writes a tab-aligned table of rows to stdout under header.
// emptyMessage is shown when rows has no entries. Every renderer in this
// package (issues, pulls, repos, SSH keys) goes through this.
-func renderTable(header []string, rows [][]string, emptyMessage string) {
+func renderTable(writer io.Writer, header []string, rows [][]string, emptyMessage string) {
if len(rows) == 0 {
- fmt.Println(emptyMessage)
+ fmt.Fprintln(writer, emptyMessage)
return
}
- tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', tabwriter.TabIndent)
+ tw := tabwriter.NewWriter(writer, 0, 0, 3, ' ', tabwriter.TabIndent)
fmt.Fprintln(tw, strings.Join(header, "\t"))
for _, row := range rows {
fmt.Fprintln(tw, strings.Join(row, "\t"))
@@ -38,118 +34,11 @@ func renderTable(header []string, rows [][]string, emptyMessage string) {
tw.Flush()
}
-func extractDID(uri string) string {
- uri = strings.TrimPrefix(uri, "at://")
- did, _, _ := strings.Cut(uri, "/")
- return did
-}
-
-func extractRKey(uri string) string {
- if idx := strings.LastIndex(uri, "/"); idx != -1 {
- return uri[idx+1:]
- }
- return uri
-}
-
-// resolveAuthor resolves a DID to an author, falling back to the raw
-// DID string for Handle if resolution fails.
-func resolveAuthor(ctx context.Context, did string) author {
- result := author{DID: did}
- if ident, err := resolver.ResolveDID(ctx, did); err == nil {
- result.Handle = ident.Handle.String()
- } else {
- result.Handle = did
- }
- return result
-}
-
-// recordView is the fields common to an issue or pull-request record,
-// as decoded from a tangled.ListItem's raw Value.
-type recordView struct {
- Title string
- Body string
- CreatedAt string
- SourceBranch string
- TargetBranch string
-}
-
-func decodeIssue(raw json.RawMessage) (recordView, error) {
- var r tangled.IssueRecord
- if err := json.Unmarshal(raw, &r); err != nil {
- return recordView{}, err
- }
- return recordView{Title: r.Title, Body: r.Body, CreatedAt: r.CreatedAt}, nil
-}
-
-func decodePull(raw json.RawMessage) (recordView, error) {
- var r tangled.PullRecord
- if err := json.Unmarshal(raw, &r); err != nil {
- return recordView{}, err
- }
- return recordView{
- Title: r.Title,
- Body: r.Body,
- CreatedAt: r.CreatedAt,
- SourceBranch: r.Source.Branch,
- TargetBranch: r.Target.Branch,
- }, nil
-}
-
-// buildItems decodes a listing's items into display/JSON-ready items,
-// silently skipping any whose Value fails to decode. decode is
-// decodeIssue or decodePull depending on the resource being listed.
-func buildItems(ctx context.Context, items []tangled.ListItem, decode func(json.RawMessage) (recordView, error)) []item {
- result := make([]item, 0, len(items))
-
- for _, listItem := range items {
- decoded, err := decode(listItem.Value)
- if err != nil {
- continue
- }
-
- updated := listItem.StateUpdatedAt
- if updated == "" {
- updated = decoded.CreatedAt
- }
-
- title := decoded.Title
- if title == "" {
- title = "(no title)"
- }
-
- result = append(result, item{
- Rkey: extractRKey(listItem.URI),
- URI: listItem.URI,
- Title: title,
- State: listItem.State,
- Author: resolveAuthor(ctx, extractDID(listItem.URI)),
- CreatedAt: decoded.CreatedAt,
- UpdatedAt: updated,
- CommentCount: listItem.CommentCount,
- SourceBranch: decoded.SourceBranch,
- TargetBranch: decoded.TargetBranch,
- })
- }
-
- return result
-}
-
// renderList renders issue or pull-request items as a table.
-func renderList(items []item, emptyMessage string) {
+func renderList(writer io.Writer, items []app.Item, emptyMessage string) {
rows := make([][]string, 0, len(items))
for _, it := range items {
rows = append(rows, []string{it.Rkey, it.Title, it.State, it.Author.Handle, shortDate(it.UpdatedAt)})
}
- renderTable([]string{"RKEY", "TITLE", "STATE", "AUTHOR", "UPDATED"}, rows, emptyMessage)
-}
-
-// findByRKey finds the listing item whose URI ends in "/"+rkey. what names
-// the resource kind (e.g. "issue", "pull request") for the error message.
-func findByRKey(items []tangled.ListItem, rkey, what string) (*tangled.ListItem, error) {
- for i := range items {
- if strings.HasSuffix(items[i].URI, "/"+rkey) {
- return &items[i], nil
- }
- }
- return nil, fmt.Errorf("%s %q not found", what, rkey)
+ renderTable(writer, []string{"RKEY", "TITLE", "STATE", "AUTHOR", "UPDATED"}, rows, emptyMessage)
}
diff --git a/internal/cli/rows_test.go b/internal/cli/rows_test.go
index 79f2333..9dfefcc 100644
--- a/internal/cli/rows_test.go
+++ b/internal/cli/rows_test.go
@@ -1,10 +1,6 @@
package cli
-import (
- "testing"
-
- "github.com/alyraffauf/tg/tangled"
-)
+import "testing"
func TestShortDate(t *testing.T) {
tests := []struct {
@@ -26,148 +22,3 @@ func TestShortDate(t *testing.T) {
})
}
}
-
-func TestExtractDID(t *testing.T) {
- tests := []struct {
- name string
- uri string
- want string
- }{
- {name: "record uri", uri: "at://did:plc:abc123/sh.tangled.repo.issue/3kdui", want: "did:plc:abc123"},
- {name: "bare did", uri: "did:plc:abc123", want: "did:plc:abc123"},
- {name: "trailing slash", uri: "at://did:plc:abc123/", want: "did:plc:abc123"},
- {name: "empty", uri: "", want: ""},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- if got := extractDID(tt.uri); got != tt.want {
- t.Fatalf("got %q, want %q", got, tt.want)
- }
- })
- }
-}
-
-func TestExtractRKey(t *testing.T) {
- tests := []struct {
- name string
- uri string
- want string
- }{
- {name: "record uri", uri: "at://did:plc:abc123/sh.tangled.repo.issue/3kdui", want: "3kdui"},
- {name: "bare rkey", uri: "3kdui", want: "3kdui"},
- {name: "trailing slash", uri: "at://did:plc:abc123/", want: ""},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- if got := extractRKey(tt.uri); got != tt.want {
- t.Fatalf("got %q, want %q", got, tt.want)
- }
- })
- }
-}
-
-func TestDecodeIssue(t *testing.T) {
- tests := []struct {
- name string
- raw string
- want recordView
- wantErr bool
- }{
- {
- name: "full record",
- raw: `{"title":"Bug report","body":"details","createdAt":"2026-07-18T12:00:00Z"}`,
- want: recordView{Title: "Bug report", Body: "details", CreatedAt: "2026-07-18T12:00:00Z"},
- },
- {
- name: "title only",
- raw: `{"title":"Bug report"}`,
- want: recordView{Title: "Bug report"},
- },
- {name: "invalid json", raw: `{`, wantErr: true},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got, err := decodeIssue([]byte(tt.raw))
- if (err != nil) != tt.wantErr {
- t.Fatalf("error = %v, wantErr %v", err, tt.wantErr)
- }
- if got != tt.want {
- t.Fatalf("got %+v, want %+v", got, tt.want)
- }
- })
- }
-}
-
-func TestDecodePull(t *testing.T) {
- tests := []struct {
- name string
- raw string
- want recordView
- wantErr bool
- }{
- {
- name: "full record",
- raw: `{"title":"Add feature","body":"details","createdAt":"2026-07-18T12:00:00Z",` +
- `"source":{"branch":"feature"},"target":{"branch":"main"}}`,
- want: recordView{
- Title: "Add feature",
- Body: "details",
- CreatedAt: "2026-07-18T12:00:00Z",
- SourceBranch: "feature",
- TargetBranch: "main",
- },
- },
- {
- name: "title only",
- raw: `{"title":"Add feature"}`,
- want: recordView{Title: "Add feature"},
- },
- {name: "invalid json", raw: `{`, wantErr: true},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got, err := decodePull([]byte(tt.raw))
- if (err != nil) != tt.wantErr {
- t.Fatalf("error = %v, wantErr %v", err, tt.wantErr)
- }
- if got != tt.want {
- t.Fatalf("got %+v, want %+v", got, tt.want)
- }
- })
- }
-}
-
-func TestFindByRKey(t *testing.T) {
- items := []tangled.ListItem{
- {URI: "at://did:plc:abc123/sh.tangled.repo.issue/3kdui"},
- {URI: "at://did:plc:abc123/sh.tangled.repo.issue/9xyz"},
- }
-
- tests := []struct {
- name string
- rkey string
- wantURI string
- wantErr bool
- }{
- {name: "found", rkey: "3kdui", wantURI: items[0].URI},
- {name: "found second", rkey: "9xyz", wantURI: items[1].URI},
- {name: "not found", rkey: "missing", wantErr: true},
- {name: "no partial match", rkey: "xyz", wantErr: true},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got, err := findByRKey(items, tt.rkey, "issue")
- if (err != nil) != tt.wantErr {
- t.Fatalf("error = %v, wantErr %v", err, tt.wantErr)
- }
- if err == nil && got.URI != tt.wantURI {
- t.Fatalf("got %q, want %q", got.URI, tt.wantURI)
- }
- })
- }
-}
diff --git a/internal/cli/ssh_key.go b/internal/cli/ssh_key.go
index 6450337..89b258e 100644
--- a/internal/cli/ssh_key.go
+++ b/internal/cli/ssh_key.go
@@ -1,16 +1,13 @@
package cli
-import "github.com/spf13/cobra"
+import (
+ "github.com/alyraffauf/tg/internal/app"
+ "github.com/spf13/cobra"
+)
-// sshKeyRecord is the value of a sh.tangled.publicKey record.
-type sshKeyRecord struct {
- Type string `json:"$type"`
- Key string `json:"key"`
- Name string `json:"name"`
- CreatedAt string `json:"createdAt"`
-}
-
-var sshKeyCmd = &cobra.Command{
- Use: "ssh-key",
- Short: "Manage SSH keys on Tangled",
+func newSSHKeyCommand(_ *app.Service) *cobra.Command {
+ return &cobra.Command{
+ Use: "ssh-key",
+ Short: "Manage SSH keys on Tangled",
+ }
}
diff --git a/internal/cli/ssh_key_add.go b/internal/cli/ssh_key_add.go
index 77371c4..247aea1 100644
--- a/internal/cli/ssh_key_add.go
+++ b/internal/cli/ssh_key_add.go
@@ -5,79 +5,60 @@ import (
"os"
"path/filepath"
"strings"
- "time"
- "github.com/alyraffauf/tg/atproto"
- "github.com/bluesky-social/indigo/atproto/syntax"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
-var sshKeyAddTitle string
+func newSSHKeyAddCommand(service *app.Service) *cobra.Command {
+ var title string
-var sshKeyAddCmd = &cobra.Command{
- Use: "add []",
- Short: "Add an SSH key to your Tangled account",
- Long: `Add an SSH public key to your Tangled account.
+ command := &cobra.Command{
+ Use: "add []",
+ Short: "Add an SSH key to your Tangled account",
+ Long: `Add an SSH public key to your Tangled account.
If no key file is given, defaults to ~/.ssh/id_ed25519.pub.
Requires authentication (run "tg auth login" first).`,
- Args: cobra.MaximumNArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx := cmd.Context()
+ Args: cobra.MaximumNArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := cmd.Context()
- atClient, did, err := authenticatedATProto(ctx)
- if err != nil {
- return err
- }
+ keyPath := "~/.ssh/id_ed25519.pub"
+ if len(args) == 1 {
+ keyPath = args[0]
+ }
+ if strings.HasPrefix(keyPath, "~/") {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return fmt.Errorf("resolve home directory: %w", err)
+ }
+ keyPath = filepath.Join(home, keyPath[2:])
+ }
- keyPath := "~/.ssh/id_ed25519.pub"
- if len(args) == 1 {
- keyPath = args[0]
- }
- if strings.HasPrefix(keyPath, "~/") {
- home, err := os.UserHomeDir()
+ keyBytes, err := os.ReadFile(keyPath)
if err != nil {
- return fmt.Errorf("resolve home directory: %w", err)
+ return fmt.Errorf("read key file %q: %w", keyPath, err)
+ }
+ key := strings.TrimSpace(string(keyBytes))
+ if key == "" {
+ return fmt.Errorf("key file %q is empty", keyPath)
}
- keyPath = filepath.Join(home, keyPath[2:])
- }
-
- keyBytes, err := os.ReadFile(keyPath)
- if err != nil {
- return fmt.Errorf("read key file %q: %w", keyPath, err)
- }
- key := strings.TrimSpace(string(keyBytes))
- if key == "" {
- return fmt.Errorf("key file %q is empty", keyPath)
- }
-
- title := sshKeyAddTitle
- if title == "" {
- title = filepath.Base(keyPath)
- }
-
- uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{
- Repo: did,
- Collection: "sh.tangled.publicKey",
- Rkey: string(syntax.NewTIDNow(0)),
- Record: sshKeyRecord{
- Type: "sh.tangled.publicKey",
- Key: key,
- Name: title,
- CreatedAt: time.Now().UTC().Format(time.RFC3339),
- },
- })
- if err != nil {
- return fmt.Errorf("add SSH key: %w", err)
- }
- result := sshKeyAddResult{Name: title, URI: uri}
- return output(result, func(added sshKeyAddResult) {
- fmt.Printf("Added SSH key %q (%s)\n", added.Name, added.URI)
- })
- },
-}
+ keyTitle := title
+ if keyTitle == "" {
+ keyTitle = filepath.Base(keyPath)
+ }
-func init() {
- sshKeyAddCmd.Flags().StringVarP(&sshKeyAddTitle, "title", "t", "", "Title for the new key")
+ result, err := service.AddSSHKey(ctx, keyTitle, key)
+ if err != nil {
+ return err
+ }
+ return output(cmd, result, func(added *app.SSHKeyAddResult) {
+ fmt.Fprintf(cmd.OutOrStdout(), "Added SSH key %q (%s)\n", added.Name, added.URI)
+ })
+ },
+ }
+ command.Flags().StringVarP(&title, "title", "t", "", "Title for the new key")
+ return command
}
diff --git a/internal/cli/ssh_key_delete.go b/internal/cli/ssh_key_delete.go
index 7dc836d..2725944 100644
--- a/internal/cli/ssh_key_delete.go
+++ b/internal/cli/ssh_key_delete.go
@@ -3,33 +3,24 @@ package cli
import (
"fmt"
- "github.com/alyraffauf/tg/atproto"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
-var sshKeyDeleteCmd = &cobra.Command{
- Use: "delete ",
- Short: "Delete an SSH key from your Tangled account",
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx := cmd.Context()
- atClient, did, err := authenticatedATProto(ctx)
- if err != nil {
- return err
- }
- if err := atClient.DeleteRecord(ctx, atproto.DeleteRecordInput{
- Repo: did,
- Collection: "sh.tangled.publicKey",
- Rkey: args[0],
- }); err != nil {
- return fmt.Errorf("delete SSH key: %w", err)
- }
- return output(deletedRecordResult{Rkey: args[0]}, func(result deletedRecordResult) {
- fmt.Printf("Deleted SSH key %s\n", result.Rkey)
- })
- },
-}
-
-type deletedRecordResult struct {
- Rkey string `json:"rkey"`
+func newSSHKeyDeleteCommand(service *app.Service) *cobra.Command {
+ return &cobra.Command{
+ Use: "delete ",
+ Short: "Delete an SSH key from your Tangled account",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := cmd.Context()
+ result, err := service.DeleteSSHKey(ctx, args[0])
+ if err != nil {
+ return err
+ }
+ return output(cmd, result, func(result *app.DeletedRecordResult) {
+ fmt.Fprintf(cmd.OutOrStdout(), "Deleted SSH key %s\n", result.Rkey)
+ })
+ },
+ }
}
diff --git a/internal/cli/ssh_key_list.go b/internal/cli/ssh_key_list.go
index 5d32f68..33cff08 100644
--- a/internal/cli/ssh_key_list.go
+++ b/internal/cli/ssh_key_list.go
@@ -1,69 +1,42 @@
package cli
import (
- "encoding/json"
- "fmt"
+ "io"
- "github.com/alyraffauf/tg/atproto"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
-var sshKeyListCmd = &cobra.Command{
- Use: "list [handle]",
- Short: "List SSH keys on a Tangled account",
- Long: `List SSH keys on a Tangled account.
+func newSSHKeyListCommand(service *app.Service) *cobra.Command {
+ return &cobra.Command{
+ Use: "list [handle]",
+ Short: "List SSH keys on a Tangled account",
+ Long: `List SSH keys on a Tangled account.
If no argument is given, lists the authenticated user's keys
(run "tg auth login" first).`,
- Args: cobra.MaximumNArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx := cmd.Context()
-
- handle, err := resolveHandleOrSelf(ctx, args)
- if err != nil {
- return err
- }
-
- atClient, did, err := publicAccountReader(ctx, handle)
- if err != nil {
- return err
- }
-
- records, err := atClient.ListAllRecords(ctx, did, "sh.tangled.publicKey", atproto.ListRecordsOpts{Limit: defaultListLimit})
- if err != nil {
- return fmt.Errorf("list SSH keys for %q: %w", handle, err)
- }
-
- items := buildSSHKeyItems(records)
- return output(items, renderSSHKeyList)
- },
-}
-
-func buildSSHKeyItems(records []atproto.RecordItem) []sshKeyItem {
- items := make([]sshKeyItem, 0, len(records))
- for _, rec := range records {
- var key sshKeyRecord
- data, err := json.Marshal(rec.Value)
- if err != nil {
- continue
- }
- if err := json.Unmarshal(data, &key); err != nil {
- continue
- }
- items = append(items, sshKeyItem{
- Name: key.Name,
- Key: key.Key,
- CreatedAt: key.CreatedAt,
- URI: rec.URI,
- })
+ Args: cobra.MaximumNArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := cmd.Context()
+ handle, err := resolveHandleOrSelf(ctx, args, service)
+ if err != nil {
+ return err
+ }
+ items, err := service.ListSSHKeys(ctx, handle)
+ if err != nil {
+ return err
+ }
+ return output(cmd, items, func(items []app.SSHKeyItem) {
+ renderSSHKeyList(cmd.OutOrStdout(), items)
+ })
+ },
}
- return items
}
-func renderSSHKeyList(items []sshKeyItem) {
+func renderSSHKeyList(writer io.Writer, items []app.SSHKeyItem) {
rows := make([][]string, 0, len(items))
for _, key := range items {
rows = append(rows, []string{key.Name, key.Key, shortDate(key.CreatedAt)})
}
- renderTable([]string{"NAME", "KEY", "ADDED"}, rows, "No SSH keys found.")
+ renderTable(writer, []string{"NAME", "KEY", "ADDED"}, rows, "No SSH keys found.")
}
diff --git a/internal/cli/string.go b/internal/cli/string.go
index 627dea3..a2cdda9 100644
--- a/internal/cli/string.go
+++ b/internal/cli/string.go
@@ -1,23 +1,13 @@
package cli
import (
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
-// stringCollection is the NSID of tangled string records.
-const stringCollection = "sh.tangled.string"
-
-// stringRecord is the value of a sh.tangled.string record. Description may
-// be empty.
-type stringRecord struct {
- Type string `json:"$type"`
- Filename string `json:"filename"`
- Description string `json:"description"`
- Contents string `json:"contents"`
- CreatedAt string `json:"createdAt"`
-}
-
-var stringCmd = &cobra.Command{
- Use: "string",
- Short: "Manage strings on Tangled",
+func newStringCommand(_ *app.Service) *cobra.Command {
+ return &cobra.Command{
+ Use: "string",
+ Short: "Manage strings on Tangled",
+ }
}
diff --git a/internal/cli/string_create.go b/internal/cli/string_create.go
index 084a259..c5643f8 100644
--- a/internal/cli/string_create.go
+++ b/internal/cli/string_create.go
@@ -5,11 +5,9 @@ import (
"io"
"os"
"path/filepath"
- "time"
"unicode/utf8"
- "github.com/alyraffauf/tg/atproto"
- "github.com/bluesky-social/indigo/atproto/syntax"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
@@ -19,55 +17,43 @@ const bytesPerMiB = 1 << 20
// limit for a text record.
const maxStringContents = 100 * bytesPerMiB
-var (
- stringCreateDescription string
- stringCreateFilename string
-)
+func newStringCreateCommand(service *app.Service) *cobra.Command {
+ var description, filenameFlag string
-var stringCreateCmd = &cobra.Command{
- Use: "create []",
- Short: "Create a string on your Tangled account",
- Long: `Create a string on your Tangled account.
+ command := &cobra.Command{
+ Use: "create []",
+ Short: "Create a string on your Tangled account",
+ Long: `Create a string on your Tangled account.
Contents are read from the given file, or from standard input if no file
is given (or the file is "-"). When reading from standard input,
--filename is required. Contents must be valid UTF-8, at most 100 MiB.
Requires authentication (run "tg auth login" first).`,
- Args: cobra.MaximumNArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx := cmd.Context()
-
- contents, filename, err := stringContents(os.Stdin, args, stringCreateFilename)
- if err != nil {
- return err
- }
+ Args: cobra.MaximumNArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := cmd.Context()
- atClient, did, err := authenticatedATProto(ctx)
- if err != nil {
- return err
- }
+ contents, filename, err := stringContents(cmd.InOrStdin(), args, filenameFlag)
+ if err != nil {
+ return err
+ }
- rkey := string(syntax.NewTIDNow(0))
- uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{
- Repo: did,
- Collection: stringCollection,
- Rkey: rkey,
- Record: stringRecord{
- Type: stringCollection,
+ result, err := service.CreateString(ctx, app.CreateStringInput{
Filename: filename,
- Description: stringCreateDescription,
+ Description: description,
Contents: contents,
- CreatedAt: time.Now().UTC().Format(time.RFC3339),
- },
- })
- if err != nil {
- return fmt.Errorf("create string: %w", err)
- }
-
- return output(createdRecordResult{Rkey: rkey, URI: uri}, func(result createdRecordResult) {
- fmt.Printf("Created string %s\n", result.URI)
- })
- },
+ })
+ if err != nil {
+ return err
+ }
+ return output(cmd, result, func(result *app.CreatedRecordResult) {
+ fmt.Fprintf(cmd.OutOrStdout(), "Created string %s\n", result.URI)
+ })
+ },
+ }
+ command.Flags().StringVarP(&description, "description", "d", "", "Description of the string")
+ command.Flags().StringVarP(&filenameFlag, "filename", "f", "", "Filename for the string (defaults to the file's basename)")
+ return command
}
// stringContents reads string contents from the file named in args (or stdin
@@ -106,8 +92,3 @@ func stringContents(stdin io.Reader, args []string, filenameFlag string) (conten
}
return contents, filename, nil
}
-
-func init() {
- stringCreateCmd.Flags().StringVarP(&stringCreateDescription, "description", "d", "", "Description of the string")
- stringCreateCmd.Flags().StringVarP(&stringCreateFilename, "filename", "f", "", "Filename for the string (defaults to the file's basename)")
-}
diff --git a/internal/cli/string_delete.go b/internal/cli/string_delete.go
index d91236d..99018e3 100644
--- a/internal/cli/string_delete.go
+++ b/internal/cli/string_delete.go
@@ -3,29 +3,24 @@ package cli
import (
"fmt"
- "github.com/alyraffauf/tg/atproto"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
-var stringDeleteCmd = &cobra.Command{
- Use: "delete ",
- Short: "Delete a string from your Tangled account",
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx := cmd.Context()
- atClient, did, err := authenticatedATProto(ctx)
- if err != nil {
- return err
- }
- if err := atClient.DeleteRecord(ctx, atproto.DeleteRecordInput{
- Repo: did,
- Collection: stringCollection,
- Rkey: args[0],
- }); err != nil {
- return fmt.Errorf("delete string: %w", err)
- }
- return output(deletedRecordResult{Rkey: args[0]}, func(result deletedRecordResult) {
- fmt.Printf("Deleted string %s\n", result.Rkey)
- })
- },
+func newStringDeleteCommand(service *app.Service) *cobra.Command {
+ return &cobra.Command{
+ Use: "delete ",
+ Short: "Delete a string from your Tangled account",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := cmd.Context()
+ result, err := service.DeleteString(ctx, args[0])
+ if err != nil {
+ return err
+ }
+ return output(cmd, result, func(result *app.DeletedRecordResult) {
+ fmt.Fprintf(cmd.OutOrStdout(), "Deleted string %s\n", result.Rkey)
+ })
+ },
+ }
}
diff --git a/internal/cli/string_delete_test.go b/internal/cli/string_delete_test.go
index b596c15..d53d95b 100644
--- a/internal/cli/string_delete_test.go
+++ b/internal/cli/string_delete_test.go
@@ -5,20 +5,21 @@ import (
)
func TestStringDeleteCmd(t *testing.T) {
- if stringDeleteCmd == nil {
+ command := newStringDeleteCommand(nil)
+ if command == nil {
t.Fatal("stringDeleteCmd is nil")
}
- if stringDeleteCmd.Use != "delete " {
- t.Errorf("Use = %q, want %q", stringDeleteCmd.Use, "delete ")
+ if command.Use != "delete " {
+ t.Errorf("Use = %q, want %q", command.Use, "delete ")
}
// cobra.ExactArgs(1): zero args must error, one arg must succeed.
- if err := stringDeleteCmd.Args(nil, []string{}); err == nil {
+ if err := command.Args(nil, []string{}); err == nil {
t.Error("expected error for zero args, got nil")
}
- if err := stringDeleteCmd.Args(nil, []string{"3k2abc"}); err != nil {
+ if err := command.Args(nil, []string{"3k2abc"}); err != nil {
t.Errorf("expected no error for one arg, got %v", err)
}
- if err := stringDeleteCmd.Args(nil, []string{"a", "b"}); err == nil {
+ if err := command.Args(nil, []string{"a", "b"}); err == nil {
t.Error("expected error for two args, got nil")
}
}
diff --git a/internal/cli/string_list.go b/internal/cli/string_list.go
index 58de425..0eb1e41 100644
--- a/internal/cli/string_list.go
+++ b/internal/cli/string_list.go
@@ -1,75 +1,42 @@
package cli
import (
- "encoding/json"
- "fmt"
+ "io"
- "github.com/alyraffauf/tg/atproto"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
-var stringListCmd = &cobra.Command{
- Use: "list [handle]",
- Short: "List strings on a Tangled account",
- Long: `List strings on a Tangled account.
+func newStringListCommand(service *app.Service) *cobra.Command {
+ return &cobra.Command{
+ Use: "list [handle]",
+ Short: "List strings on a Tangled account",
+ Long: `List strings on a Tangled account.
If no argument is given, lists the authenticated user's strings
(run "tg auth login" first).`,
- Args: cobra.MaximumNArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx := cmd.Context()
-
- handle, err := resolveHandleOrSelf(ctx, args)
- if err != nil {
- return err
- }
-
- atClient, did, err := publicAccountReader(ctx, handle)
- if err != nil {
- return err
- }
-
- records, err := atClient.ListAllRecords(ctx, did, stringCollection, atproto.ListRecordsOpts{Limit: defaultListLimit})
- if err != nil {
- return fmt.Errorf("list strings for %q: %w", handle, err)
- }
-
- items := buildStringItems(records)
- return output(items, renderStringList)
- },
-}
-
-func buildStringItems(records []atproto.RecordItem) []stringItem {
- items := make([]stringItem, 0, len(records))
- for _, rec := range records {
- var str stringRecord
- data, err := json.Marshal(rec.Value)
- if err != nil {
- continue
- }
- if err := json.Unmarshal(data, &str); err != nil {
- continue
- }
- // Records without a filename are not strings; skip them rather
- // than rendering a blank row.
- if str.Filename == "" {
- continue
- }
- items = append(items, stringItem{
- Rkey: extractRKey(rec.URI),
- URI: rec.URI,
- Filename: str.Filename,
- Description: str.Description,
- CreatedAt: str.CreatedAt,
- })
+ Args: cobra.MaximumNArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := cmd.Context()
+ handle, err := resolveHandleOrSelf(ctx, args, service)
+ if err != nil {
+ return err
+ }
+ items, err := service.ListStrings(ctx, handle)
+ if err != nil {
+ return err
+ }
+ return output(cmd, items, func(items []app.StringItem) {
+ renderStringList(cmd.OutOrStdout(), items)
+ })
+ },
}
- return items
}
-func renderStringList(items []stringItem) {
+func renderStringList(writer io.Writer, items []app.StringItem) {
rows := make([][]string, 0, len(items))
for _, str := range items {
rows = append(rows, []string{str.Rkey, str.Filename, str.Description, shortDate(str.CreatedAt)})
}
- renderTable([]string{"RKEY", "FILENAME", "DESCRIPTION", "CREATED"}, rows, "No strings found.")
+ renderTable(writer, []string{"RKEY", "FILENAME", "DESCRIPTION", "CREATED"}, rows, "No strings found.")
}
diff --git a/internal/cli/string_list_test.go b/internal/cli/string_list_test.go
deleted file mode 100644
index eb4f093..0000000
--- a/internal/cli/string_list_test.go
+++ /dev/null
@@ -1,47 +0,0 @@
-package cli
-
-import (
- "testing"
-
- "github.com/alyraffauf/tg/atproto"
-)
-
-func TestBuildStringItems(t *testing.T) {
- records := []atproto.RecordItem{
- {
- URI: "at://did:plc:abc/sh.tangled.string/3k2abc",
- Value: map[string]any{
- "$type": "sh.tangled.string",
- "filename": "test.d",
- "description": "my test string",
- "contents": "# hello",
- "createdAt": "2026-07-18T23:15:54+03:00",
- },
- },
- {
- URI: "at://did:plc:abc/sh.tangled.string/3k2def",
- Value: map[string]any{"not": "a string record"},
- },
- }
-
- items := buildStringItems(records)
-
- // Records without a filename are not strings and are skipped.
- if len(items) != 1 {
- t.Fatalf("buildStringItems() returned %d items, want 1", len(items))
- }
-
- first := items[0]
- if first.Rkey != "3k2abc" {
- t.Errorf("Rkey = %q, want %q", first.Rkey, "3k2abc")
- }
- if first.Filename != "test.d" {
- t.Errorf("Filename = %q, want %q", first.Filename, "test.d")
- }
- if first.Description != "my test string" {
- t.Errorf("Description = %q, want %q", first.Description, "my test string")
- }
- if first.CreatedAt != "2026-07-18T23:15:54+03:00" {
- t.Errorf("CreatedAt = %q, want %q", first.CreatedAt, "2026-07-18T23:15:54+03:00")
- }
-}
diff --git a/internal/cli/string_view.go b/internal/cli/string_view.go
index 53742d0..43410c2 100644
--- a/internal/cli/string_view.go
+++ b/internal/cli/string_view.go
@@ -1,74 +1,42 @@
package cli
import (
- "encoding/json"
"fmt"
+ "github.com/alyraffauf/tg/internal/app"
"github.com/spf13/cobra"
)
-var stringViewCmd = &cobra.Command{
- Use: "view [handle]",
- Short: "View a string on a Tangled account",
- Long: `View a string by its rkey (the last segment of its at:// URI).
+func newStringViewCommand(service *app.Service) *cobra.Command {
+ return &cobra.Command{
+ Use: "view [handle]",
+ Short: "View a string on a Tangled account",
+ Long: `View a string by its rkey (the last segment of its at:// URI).
If no handle is given, views the authenticated user's string
(run "tg auth login" first).`,
- Args: cobra.RangeArgs(1, 2),
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx := cmd.Context()
- rkey := args[0]
-
- handle, err := resolveHandleOrSelf(ctx, args[1:])
- if err != nil {
- return err
- }
-
- atClient, did, err := publicAccountReader(ctx, handle)
- if err != nil {
- return err
- }
-
- found, err := atClient.GetRecord(ctx, did, stringCollection, rkey)
- if err != nil {
- return fmt.Errorf("get string %q for %q: %w", rkey, handle, err)
- }
-
- record, err := decodeStringRecord(found.Value)
- if err != nil {
- return fmt.Errorf("decode string %q: %w", rkey, err)
- }
-
- result := stringViewResult{
- Rkey: rkey,
- URI: found.URI,
- Filename: record.Filename,
- Author: author{DID: did, Handle: handle},
- Description: record.Description,
- Contents: record.Contents,
- CreatedAt: record.CreatedAt,
- }
- return output(result, func(view stringViewResult) {
- fmt.Printf("Filename: %s\n", view.Filename)
- fmt.Printf("Author: %s\n", view.Author.Handle)
- fmt.Printf("Created: %s\n", view.CreatedAt)
- if view.Description != "" {
- fmt.Printf("Description: %s\n", view.Description)
+ Args: cobra.RangeArgs(1, 2),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := cmd.Context()
+ rkey := args[0]
+
+ handle, err := resolveHandleOrSelf(ctx, args[1:], service)
+ if err != nil {
+ return err
}
- fmt.Printf("\n%s\n", view.Contents)
- })
- },
-}
-
-// decodeStringRecord decodes a record value into a stringRecord.
-func decodeStringRecord(value any) (stringRecord, error) {
- data, err := json.Marshal(value)
- if err != nil {
- return stringRecord{}, fmt.Errorf("encode record: %w", err)
- }
- var record stringRecord
- if err := json.Unmarshal(data, &record); err != nil {
- return stringRecord{}, fmt.Errorf("decode record: %w", err)
+ result, err := service.ViewString(ctx, handle, rkey)
+ if err != nil {
+ return err
+ }
+ return output(cmd, result, func(view *app.StringViewResult) {
+ fmt.Fprintf(cmd.OutOrStdout(), "Filename: %s\n", view.Filename)
+ fmt.Fprintf(cmd.OutOrStdout(), "Author: %s\n", view.Author.Handle)
+ fmt.Fprintf(cmd.OutOrStdout(), "Created: %s\n", view.CreatedAt)
+ if view.Description != "" {
+ fmt.Fprintf(cmd.OutOrStdout(), "Description: %s\n", view.Description)
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "\n%s\n", view.Contents)
+ })
+ },
}
- return record, nil
}
diff --git a/internal/cli/target.go b/internal/cli/target.go
index 45a2089..ac404a4 100644
--- a/internal/cli/target.go
+++ b/internal/cli/target.go
@@ -3,39 +3,56 @@ package cli
import (
"context"
"fmt"
- "strings"
+ "os"
- "github.com/alyraffauf/tg/internal/gitutil"
+ "github.com/alyraffauf/tg/internal/app"
)
-func parseHandleRepo(arg string) (string, string, error) {
- parts := strings.SplitN(arg, "/", 2)
- if len(parts) != 2 || parts[0] == "" || parts[1] == "" || strings.Contains(parts[1], "/") {
- return "", "", fmt.Errorf("expected handle/repo, got %q", arg)
+// getwd returns the current working directory, wrapping the common error.
+func getwd() (string, error) {
+ dir, err := os.Getwd()
+ if err != nil {
+ return "", fmt.Errorf("get current directory: %w", err)
}
- return parts[0], parts[1], nil
+ return dir, nil
}
-// resolveTarget returns the handle and repo name from an explicit
-// "handle/repo" argument or by detecting the git remote in the CWD.
-func resolveTarget(ctx context.Context, args []string) (string, string, error) {
+// resolveTarget returns the target from an explicit "handle/repo" argument
+// (when args has one element) or by detecting the git remote in the CWD.
+func resolveTarget(ctx context.Context, args []string, service *app.Service) (app.Target, error) {
if len(args) == 1 {
- return parseHandleRepo(args[0])
+ return app.ParseTarget(args[0])
}
+ return service.TargetFromCWD(ctx)
+}
- rc, err := gitutil.DetectRepoFromCWD(ctx)
- if err != nil {
- return "", "", fmt.Errorf("detect repo from current directory: %w", err)
+// resolveTargetFlag returns the target from a --repo flag value, or by
+// detecting the git remote in the CWD when the flag is unset.
+func resolveTargetFlag(ctx context.Context, repoFlag string, service *app.Service) (app.Target, error) {
+ if repoFlag != "" {
+ return app.ParseTarget(repoFlag)
}
- return rc.Handle, rc.Repo, nil
+ return service.TargetFromCWD(ctx)
}
-// findRepoDid resolves handle/repo to the repo's repoDid, which listIssues is
-// keyed by.
-func findRepoDid(ctx context.Context, handle, repo string) (string, error) {
- record, err := resolveRepoRecord(ctx, handle, repo)
+// resolveHandleArg returns the handle from an explicit argument, or falls
+// back to the handle of the CWD's git origin remote.
+func resolveHandleArg(ctx context.Context, args []string, service *app.Service) (string, error) {
+ if len(args) == 1 {
+ return args[0], nil
+ }
+ target, err := service.TargetFromCWD(ctx)
if err != nil {
return "", err
}
- return record.Value.RepoDid, nil
+ return target.Handle, nil
+}
+
+// resolveHandleOrSelf returns the handle from an explicit argument, or the
+// authenticated user's handle. It does not fall back to CWD git detection.
+func resolveHandleOrSelf(ctx context.Context, args []string, service *app.Service) (string, error) {
+ if len(args) == 1 {
+ return args[0], nil
+ }
+ return service.HandleOrSelf(ctx, "")
}
diff --git a/internal/gitutil/branch.go b/internal/gitutil/branch.go
index 3e85818..df4ede7 100644
--- a/internal/gitutil/branch.go
+++ b/internal/gitutil/branch.go
@@ -1,6 +1,7 @@
package gitutil
import (
+ "bytes"
"context"
"fmt"
"os/exec"
@@ -9,16 +10,25 @@ import (
// CurrentBranch returns the checked-out branch name at dir; errors if HEAD is
// detached.
-func CurrentBranch(ctx context.Context, dir string) (string, error) {
+
+func (c *Client) CurrentBranch(ctx context.Context, dir string) (string, error) {
cmd := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD")
cmd.Dir = dir
- out, err := cmd.Output()
+ var out bytes.Buffer
+ cmd.Stdout = &out
+ _, stderr := c.writers()
+ cmd.Stderr = stderr
+ err := cmd.Run()
if err != nil {
return "", fmt.Errorf("get current branch in %q: %w", dir, err)
}
- branch := strings.TrimSpace(string(out))
+ branch := strings.TrimSpace(out.String())
if branch == "" || branch == "HEAD" {
return "", fmt.Errorf("no current branch (detached HEAD) in %q", dir)
}
return branch, nil
}
+
+func CurrentBranch(ctx context.Context, dir string) (string, error) {
+ return defaultClient.CurrentBranch(ctx, dir)
+}
diff --git a/internal/gitutil/checkout.go b/internal/gitutil/checkout.go
index 4b0a081..58cd5e8 100644
--- a/internal/gitutil/checkout.go
+++ b/internal/gitutil/checkout.go
@@ -5,7 +5,6 @@ import (
"context"
"fmt"
"os/exec"
- "strings"
)
// CheckoutPatchParams configures a local branch reconstructed from a patch.
@@ -19,20 +18,20 @@ type CheckoutPatchParams struct {
// CheckoutPatch creates a branch at the current target branch and applies a
// pull request patch series to it.
-func CheckoutPatch(ctx context.Context, params CheckoutPatchParams) error {
- if err := requireCleanWorktree(ctx, params.RepoDir); err != nil {
+func (c *Client) CheckoutPatch(ctx context.Context, params CheckoutPatchParams) error {
+ if err := c.requireCleanWorktree(ctx, params.RepoDir); err != nil {
return err
}
- if err := validateBranch(ctx, params.RepoDir, params.Branch); err != nil {
+ if err := c.validateBranch(ctx, params.RepoDir, params.Branch); err != nil {
return fmt.Errorf("invalid checkout branch %q: %w", params.Branch, err)
}
- if err := validateBranch(ctx, params.RepoDir, params.TargetBranch); err != nil {
+ if err := c.validateBranch(ctx, params.RepoDir, params.TargetBranch); err != nil {
return fmt.Errorf("invalid target branch %q: %w", params.TargetBranch, err)
}
targetRef := "refs/remotes/origin/" + params.TargetBranch
refspec := "+refs/heads/" + params.TargetBranch + ":" + targetRef
- if err := gitCommand(ctx, params.RepoDir, "fetch", "origin", refspec); err != nil {
+ if err := c.gitCommand(ctx, params.RepoDir, "fetch", "origin", refspec); err != nil {
return fmt.Errorf("fetch target branch %q: %w", params.TargetBranch, err)
}
@@ -40,21 +39,26 @@ func CheckoutPatch(ctx context.Context, params CheckoutPatchParams) error {
if params.Force {
switchFlag = "-C"
}
- if err := gitCommand(ctx, params.RepoDir, "switch", switchFlag, params.Branch, targetRef); err != nil {
+ if err := c.gitCommand(ctx, params.RepoDir, "switch", switchFlag, params.Branch, targetRef); err != nil {
return fmt.Errorf("check out branch %q: %w", params.Branch, err)
}
- if err := applyPatch(ctx, params.RepoDir, params.Patch); err != nil {
+ if err := c.applyPatch(ctx, params.RepoDir, params.Patch); err != nil {
return fmt.Errorf("apply pull request patch; resolve conflicts with git am --continue or undo with git am --abort: %w", err)
}
return nil
}
-func validateBranch(ctx context.Context, repoDir, branch string) error {
- return gitCommand(ctx, repoDir, "check-ref-format", "--branch", branch)
+func CheckoutPatch(ctx context.Context, params CheckoutPatchParams) error {
+ return defaultClient.CheckoutPatch(ctx, params)
+}
+
+func (c *Client) validateBranch(ctx context.Context, repoDir, branch string) error {
+ _, err := c.gitOutput(ctx, repoDir, "check-ref-format", "--branch", branch)
+ return err
}
-func requireCleanWorktree(ctx context.Context, repoDir string) error {
- status, err := gitOutput(ctx, repoDir, "status", "--porcelain")
+func (c *Client) requireCleanWorktree(ctx context.Context, repoDir string) error {
+ status, err := c.gitOutput(ctx, repoDir, "status", "--porcelain")
if err != nil {
return fmt.Errorf("inspect worktree: %w", err)
}
@@ -64,13 +68,16 @@ func requireCleanWorktree(ctx context.Context, repoDir string) error {
return nil
}
-func applyPatch(ctx context.Context, repoDir string, patch []byte) error {
+func (c *Client) applyPatch(ctx context.Context, repoDir string, patch []byte) error {
cmd := exec.CommandContext(ctx, "git", "am", "--3way")
cmd.Dir = repoDir
cmd.Stdin = bytes.NewReader(patch)
- output, err := cmd.CombinedOutput()
+ stdout, stderr := c.writers()
+ cmd.Stdout = stdout
+ cmd.Stderr = stderr
+ err := cmd.Run()
if err != nil {
- return fmt.Errorf("git am --3way: %w: %s", err, strings.TrimSpace(string(output)))
+ return fmt.Errorf("git am --3way: %w", err)
}
return nil
}
diff --git a/internal/gitutil/clone_repo.go b/internal/gitutil/clone_repo.go
index f336dbd..63540c5 100644
--- a/internal/gitutil/clone_repo.go
+++ b/internal/gitutil/clone_repo.go
@@ -12,7 +12,11 @@ type CloneRepoParams struct {
}
// CloneRepo clones handle/repo from Tangled into params.RepoDir.
-func CloneRepo(ctx context.Context, params CloneRepoParams) error {
+func (c *Client) CloneRepo(ctx context.Context, params CloneRepoParams) error {
url := tangledRemoteURL(params.Handle, params.Repo)
- return run(ctx, "git", "clone", url, params.RepoDir)
+ return c.run(ctx, "git", "clone", url, params.RepoDir)
+}
+
+func CloneRepo(ctx context.Context, params CloneRepoParams) error {
+ return defaultClient.CloneRepo(ctx, params)
}
diff --git a/internal/gitutil/patch.go b/internal/gitutil/patch.go
index 686fea1..369bc41 100644
--- a/internal/gitutil/patch.go
+++ b/internal/gitutil/patch.go
@@ -5,6 +5,7 @@ import (
"compress/gzip"
"context"
"fmt"
+ "io"
"os/exec"
"strconv"
"strings"
@@ -13,20 +14,20 @@ import (
// GeneratePatch returns a gzipped git format-patch series for commits in head
// that are not in base. The base commit must be an ancestor of head so the
// result can be applied onto the target branch with git am.
-func GeneratePatch(ctx context.Context, repoDir, base, head string) ([]byte, error) {
- baseRevision, err := resolveBaseRevision(ctx, repoDir, base)
+func (c *Client) GeneratePatch(ctx context.Context, repoDir, base, head string) ([]byte, error) {
+ baseRevision, err := c.resolveBaseRevision(ctx, repoDir, base)
if err != nil {
return nil, fmt.Errorf("resolve base %q: %w", base, err)
}
- headRevision, err := resolveRevision(ctx, repoDir, head)
+ headRevision, err := c.resolveRevision(ctx, repoDir, head)
if err != nil {
return nil, fmt.Errorf("resolve head %q: %w", head, err)
}
- if err := gitCommand(ctx, repoDir, "merge-base", "--is-ancestor", baseRevision, headRevision); err != nil {
+ if err := c.gitCommand(ctx, repoDir, "merge-base", "--is-ancestor", baseRevision, headRevision); err != nil {
return nil, fmt.Errorf("base %q is not an ancestor of head %q", base, head)
}
- commitCount, err := gitOutput(ctx, repoDir, "rev-list", "--count", baseRevision+".."+headRevision)
+ commitCount, err := c.gitOutput(ctx, repoDir, "rev-list", "--count", baseRevision+".."+headRevision)
if err != nil {
return nil, fmt.Errorf("count commits from %q to %q: %w", base, head, err)
}
@@ -38,7 +39,7 @@ func GeneratePatch(ctx context.Context, repoDir, base, head string) ([]byte, err
return nil, fmt.Errorf("no commits between base %q and head %q", base, head)
}
- patch, err := gitOutput(ctx, repoDir, "format-patch", "--stdout", "--binary", "--full-index", baseRevision+".."+headRevision)
+ patch, err := c.gitOutput(ctx, repoDir, "format-patch", "--stdout", "--binary", "--full-index", baseRevision+".."+headRevision)
if err != nil {
return nil, fmt.Errorf("create patch from %q to %q: %w", base, head, err)
}
@@ -53,9 +54,13 @@ func GeneratePatch(ctx context.Context, repoDir, base, head string) ([]byte, err
return compressed.Bytes(), nil
}
+func GeneratePatch(ctx context.Context, repoDir, base, head string) ([]byte, error) {
+ return defaultClient.GeneratePatch(ctx, repoDir, base, head)
+}
+
// DefaultBranch returns the branch named by origin's local HEAD reference.
-func DefaultBranch(ctx context.Context, repoDir string) (string, error) {
- ref, err := gitOutput(ctx, repoDir, "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD")
+func (c *Client) DefaultBranch(ctx context.Context, repoDir string) (string, error) {
+ ref, err := c.gitOutput(ctx, repoDir, "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD")
if err != nil {
return "", fmt.Errorf("read origin default branch: %w", err)
}
@@ -66,41 +71,53 @@ func DefaultBranch(ctx context.Context, repoDir string) (string, error) {
return branch, nil
}
-func resolveRevision(ctx context.Context, repoDir, revision string) (string, error) {
- if _, err := gitOutput(ctx, repoDir, "rev-parse", "--verify", revision+"^{commit}"); err == nil {
+func DefaultBranch(ctx context.Context, repoDir string) (string, error) {
+ return defaultClient.DefaultBranch(ctx, repoDir)
+}
+
+func (c *Client) resolveRevision(ctx context.Context, repoDir, revision string) (string, error) {
+ if _, err := c.gitOutput(ctx, repoDir, "rev-parse", "--verify", revision+"^{commit}"); err == nil {
return revision, nil
}
remoteRevision := "origin/" + revision
- if _, err := gitOutput(ctx, repoDir, "rev-parse", "--verify", remoteRevision+"^{commit}"); err == nil {
+ if _, err := c.gitOutput(ctx, repoDir, "rev-parse", "--verify", remoteRevision+"^{commit}"); err == nil {
return remoteRevision, nil
}
return "", fmt.Errorf("commit does not exist locally or at origin")
}
-func resolveBaseRevision(ctx context.Context, repoDir, revision string) (string, error) {
+func (c *Client) resolveBaseRevision(ctx context.Context, repoDir, revision string) (string, error) {
remoteRevision := "origin/" + revision
- if _, err := gitOutput(ctx, repoDir, "rev-parse", "--verify", remoteRevision+"^{commit}"); err == nil {
+ if _, err := c.gitOutput(ctx, repoDir, "rev-parse", "--verify", remoteRevision+"^{commit}"); err == nil {
return remoteRevision, nil
}
- return resolveRevision(ctx, repoDir, revision)
+ return c.resolveRevision(ctx, repoDir, revision)
}
-func gitCommand(ctx context.Context, repoDir string, args ...string) error {
+func (c *Client) gitCommand(ctx context.Context, repoDir string, args ...string) error {
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = repoDir
- if output, err := cmd.CombinedOutput(); err != nil {
- return fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(output)))
+ var output bytes.Buffer
+ stdout, stderr := c.writers()
+ cmd.Stdout = io.MultiWriter(stdout, &output)
+ cmd.Stderr = io.MultiWriter(stderr, &output)
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(output.String()))
}
return nil
}
-func gitOutput(ctx context.Context, repoDir string, args ...string) ([]byte, error) {
+func (c *Client) gitOutput(ctx context.Context, repoDir string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = repoDir
- output, err := cmd.Output()
+ var output bytes.Buffer
+ cmd.Stdout = &output
+ _, stderr := c.writers()
+ cmd.Stderr = stderr
+ err := cmd.Run()
if err != nil {
return nil, fmt.Errorf("git %s: %w", strings.Join(args, " "), err)
}
- return output, nil
+ return output.Bytes(), nil
}
diff --git a/internal/gitutil/push_repo.go b/internal/gitutil/push_repo.go
index a039f47..0e07fff 100644
--- a/internal/gitutil/push_repo.go
+++ b/internal/gitutil/push_repo.go
@@ -14,13 +14,17 @@ type PushNewRepoParams struct {
// PushNewRepo adds a remote at Dir and pushes the current branch.
// Fails if RemoteName already exists.
-func PushNewRepo(ctx context.Context, params PushNewRepoParams) error {
+func (c *Client) PushNewRepo(ctx context.Context, params PushNewRepoParams) error {
remoteURL := tangledRemoteURL(params.Handle, params.Repo)
- if err := runIn(params.Dir, ctx, "git", "remote", "add", params.RemoteName, remoteURL); err != nil {
+ if err := c.runIn(params.Dir, ctx, "git", "remote", "add", params.RemoteName, remoteURL); err != nil {
return fmt.Errorf("add remote %q (already exists? use --remote to pick another name): %w", params.RemoteName, err)
}
- if err := runIn(params.Dir, ctx, "git", "push", "-u", params.RemoteName, "HEAD"); err != nil {
+ if err := c.runIn(params.Dir, ctx, "git", "push", "-u", params.RemoteName, "HEAD"); err != nil {
return fmt.Errorf("push to %q: %w", params.RemoteName, err)
}
return nil
}
+
+func PushNewRepo(ctx context.Context, params PushNewRepoParams) error {
+ return defaultClient.PushNewRepo(ctx, params)
+}
diff --git a/internal/gitutil/repo_context.go b/internal/gitutil/repo_context.go
index 6032b68..89a7173 100644
--- a/internal/gitutil/repo_context.go
+++ b/internal/gitutil/repo_context.go
@@ -30,14 +30,14 @@ type RepoContext struct {
// DetectRepoFromCWD scans the git remotes in the current directory for one
// pointing at Tangled, checking the default remote first. Returns the first
// match.
-func DetectRepoFromCWD(ctx context.Context) (*RepoContext, error) {
- remotes, err := gitLines(ctx, "remote")
+func (c *Client) DetectRepoFromCWD(ctx context.Context) (*RepoContext, error) {
+ remotes, err := c.gitLines(ctx, "remote")
if err != nil {
return nil, fmt.Errorf("list git remotes: %w", err)
}
for _, name := range originFirst(remotes) {
- urls, err := gitLines(ctx, "remote", "get-url", "--all", name)
+ urls, err := c.gitLines(ctx, "remote", "get-url", "--all", name)
if err != nil {
return nil, fmt.Errorf("get URLs for remote %q: %w", name, err)
}
@@ -51,6 +51,10 @@ func DetectRepoFromCWD(ctx context.Context) (*RepoContext, error) {
return nil, fmt.Errorf("no Tangled remote found among %d remote(s) %q; pass the repository as handle/repo", len(remotes), remotes)
}
+func DetectRepoFromCWD(ctx context.Context) (*RepoContext, error) {
+ return defaultClient.DetectRepoFromCWD(ctx)
+}
+
// originFirst returns remotes with the default remote first (if present),
// followed by the rest in their original order.
func originFirst(remotes []string) []string {
@@ -133,16 +137,26 @@ func splitHandleRepo(path string) (*RepoContext, bool) {
}
// gitLines runs git with the given args and returns non-empty output lines.
-func gitLines(ctx context.Context, args ...string) ([]string, error) {
- out, err := exec.CommandContext(ctx, "git", args...).Output()
+func (c *Client) gitLines(ctx context.Context, args ...string) ([]string, error) {
+ // Output is intentionally captured; diagnostics still go to the client's sink.
+ cmd := exec.CommandContext(ctx, "git", args...)
+ var captured strings.Builder
+ cmd.Stdout = &captured
+ _, stderr := c.writers()
+ cmd.Stderr = stderr
+ err := cmd.Run()
if err != nil {
return nil, fmt.Errorf("git %s: %w", strings.Join(args, " "), err)
}
var lines []string
- for _, line := range strings.Split(string(out), "\n") {
+ for _, line := range strings.Split(captured.String(), "\n") {
if line = strings.TrimSpace(line); line != "" {
lines = append(lines, line)
}
}
return lines, nil
}
+
+func gitLines(ctx context.Context, args ...string) ([]string, error) {
+ return defaultClient.gitLines(ctx, args...)
+}
diff --git a/internal/gitutil/runner.go b/internal/gitutil/runner.go
index 48e9fd9..66bf7fb 100644
--- a/internal/gitutil/runner.go
+++ b/internal/gitutil/runner.go
@@ -2,23 +2,57 @@ package gitutil
import (
"context"
- "os"
+ "io"
"os/exec"
)
-// run executes a command in the foreground, connected to the terminal.
-func run(ctx context.Context, name string, args ...string) error {
+// Client runs git commands with configurable output sinks.
+type Client struct {
+ Stdout io.Writer
+ Stderr io.Writer
+}
+
+// NewClient returns a git client connected to the supplied output sinks.
+// Nil sinks discard command output.
+func NewClient(stdout, stderr io.Writer) *Client {
+ return &Client{Stdout: stdout, Stderr: stderr}
+}
+
+func (c *Client) writers() (io.Writer, io.Writer) {
+ if c == nil {
+ return io.Discard, io.Discard
+ }
+ stdout, stderr := c.Stdout, c.Stderr
+ if stdout == nil {
+ stdout = io.Discard
+ }
+ if stderr == nil {
+ stderr = io.Discard
+ }
+ return stdout, stderr
+}
+
+// run executes a command in the foreground, connected to the client's sinks.
+func (c *Client) run(ctx context.Context, name string, args ...string) error {
cmd := exec.CommandContext(ctx, name, args...)
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
+ cmd.Stdout, cmd.Stderr = c.writers()
return cmd.Run()
}
// runIn is like run but sets the working directory to dir.
-func runIn(dir string, ctx context.Context, name string, args ...string) error {
+func (c *Client) runIn(dir string, ctx context.Context, name string, args ...string) error {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Dir = dir
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
+ cmd.Stdout, cmd.Stderr = c.writers()
return cmd.Run()
}
+
+var defaultClient = NewClient(nil, nil)
+
+func run(ctx context.Context, name string, args ...string) error {
+ return defaultClient.run(ctx, name, args...)
+}
+
+func runIn(dir string, ctx context.Context, name string, args ...string) error {
+ return defaultClient.runIn(dir, ctx, name, args...)
+}
diff --git a/tangled/records.go b/tangled/records.go
new file mode 100644
index 0000000..79c7adb
--- /dev/null
+++ b/tangled/records.go
@@ -0,0 +1,58 @@
+package tangled
+
+const (
+ IssueCollection = "sh.tangled.repo.issue"
+ PullCollection = "sh.tangled.repo.pull"
+ StringCollection = "sh.tangled.string"
+ SSHKeyCollection = "sh.tangled.publicKey"
+ RepoCollection = "sh.tangled.repo"
+ IssueStateSuffix = ".state"
+ PullStatusSuffix = ".status"
+)
+
+// IssueCommentRecord is the value of a sh.tangled.repo.issue.comment record.
+type IssueCommentRecord struct {
+ Type string `json:"$type"`
+ Issue string `json:"issue"`
+ Body string `json:"body"`
+ CreatedAt string `json:"createdAt"`
+}
+
+// PullCommentRecord is the value of a sh.tangled.repo.pull.comment record.
+type PullCommentRecord struct {
+ Type string `json:"$type"`
+ Pull string `json:"pull"`
+ Body string `json:"body"`
+ CreatedAt string `json:"createdAt"`
+}
+
+// IssueStateRecord is the value of a sh.tangled.repo.issue.state record.
+type IssueStateRecord struct {
+ Type string `json:"$type"`
+ Issue string `json:"issue"`
+ State string `json:"state"`
+}
+
+// PullStatusRecord is the value of a sh.tangled.repo.pull.status record.
+type PullStatusRecord struct {
+ Type string `json:"$type"`
+ Pull string `json:"pull"`
+ Status string `json:"status"`
+}
+
+// SSHKeyRecord is the value of a sh.tangled.publicKey record.
+type SSHKeyRecord struct {
+ Type string `json:"$type"`
+ Key string `json:"key"`
+ Name string `json:"name"`
+ CreatedAt string `json:"createdAt"`
+}
+
+// StringRecord is the value of a sh.tangled.string record.
+type StringRecord struct {
+ Type string `json:"$type"`
+ Filename string `json:"filename"`
+ Description string `json:"description"`
+ Contents string `json:"contents"`
+ CreatedAt string `json:"createdAt"`
+}