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 <title>", + 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 <rkey>", - 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 <rkey>", + 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 + " <rkey>", 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 <rkey>", - 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 <rkey>", + 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 <rkey>", - 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 <rkey>", + 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-<rkey>)") - 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-<rkey>)") + 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 <rkey>", - 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 <rkey>", + 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 <rkey>", - 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 <rkey>", + 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 <rkey>", - 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 <rkey>", - 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 <rkey>", + 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 + " <rkey>", - Short: use + " a pull request", +func newPRMergeCommand(service *app.Service) *cobra.Command { + var repository string + + command := &cobra.Command{ + Use: "merge <rkey>", + 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 + " <rkey>", + 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 <rkey>", - 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 <rkey>", + 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 <handle/repo> [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 <handle/repo> [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 <name>", - Short: "Create a repository on Tangled", - Long: `Create a repository on Tangled. + command := &cobra.Command{ + Use: "create <name>", + 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 <handle/repo> [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 <handle/repo> [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 <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 <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 <handle/repo>", - 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 <handle/repo>", + 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 [<key-file>]", - Short: "Add an SSH key to your Tangled account", - Long: `Add an SSH public key to your Tangled account. + command := &cobra.Command{ + Use: "add [<key-file>]", + 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 <rkey>", - 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 <rkey>", + 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 [<file>]", - Short: "Create a string on your Tangled account", - Long: `Create a string on your Tangled account. + command := &cobra.Command{ + Use: "create [<file>]", + 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 <rkey>", - 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 <rkey>", + 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 <rkey>" { - t.Errorf("Use = %q, want %q", stringDeleteCmd.Use, "delete <rkey>") + if command.Use != "delete <rkey>" { + t.Errorf("Use = %q, want %q", command.Use, "delete <rkey>") } // 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 <rkey> [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 <rkey> [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"` +}