diff --git a/README.md b/README.md index 3b2f906..5e10b0d 100644 --- a/README.md +++ b/README.md @@ -37,20 +37,23 @@ make clean # Remove build artifacts # 1. Authenticate with your AT Protocol handle tng auth login --handle alice.bsky.social -# 2. Clone a repository +# 2. Create a repository on a knot +tng repo create my-project --knot knot.example.com + +# 3. Clone it tng repo clone alice.bsky.social/my-project -# 3. Work on code, then create a pull request +# 4. Work on code, then create a pull request cd my-project git checkout -b feature-branch -# ... make changes, commit, push ... -tng pr create --title "Add new feature" +# ... make changes, commit ... +tng pr create --title "Add new feature" -b main -# 4. View pull requests +# 5. List and view your pull requests tng pr list -tng pr view 1 +tng pr view -# 5. Open in browser +# 6. Open in browser tng browse ``` @@ -78,11 +81,9 @@ Sessions are stored in `~/.config/tng/`. Tokens are persisted locally and refres | Variable | Description | |---|---| -| `TNG_TOKEN` | Override auth token (for CI/scripts) | | `TNG_HOST` | Override API host (default: `https://tangled.sh`) | | `TNG_CONFIG_DIR` | Override config directory | | `TNG_REPO` | Override repository context (`owner/name`) | -| `TNG_PAGER` | Override pager program | | `TNG_FORCE_TTY` | Force TTY output in non-TTY contexts | | `NO_COLOR` | Disable color output | @@ -101,6 +102,7 @@ tng auth status # Show authenticated accounts ```sh tng repo create [flags] # Create a new repository tng repo clone [dir] # Clone via SSH +tng repo delete # Delete a repository tng repo view [owner/repo] # View repository details tng repo list [owner] # List repositories ``` @@ -108,9 +110,15 @@ tng repo list [owner] # List repositories Flags for `repo create`: - `-d, --description` -- Repository description - `--default-branch` -- Default branch name (default: `main`) -- `-k, --knot` -- Knot URL to create the repo on (e.g., `https://knot.eric.wien`). Falls back to `default_knot` in config, then the Tangled-managed knot. +- `-k, --knot` -- Knot URL to create the repo on (required unless `default_knot` is set in config) - `-c, --clone` -- Clone after creation +Flags for `repo clone`: +- `-k, --knot` -- Knot hostname to clone from (skips AT Protocol record lookup) + +Flags for `repo delete`: +- `-y, --yes` -- Skip confirmation prompt + Flags for `repo view`: - `-w, --web` -- Open in browser instead @@ -119,14 +127,16 @@ Flags for `repo list`: ### `tng pr` -- Pull Requests +Pull requests are AT Protocol records identified by a TID rkey (e.g. `3jxn7q7bklr2r`), not sequential numbers. The rkey is shown in `tng pr list` output. Patches are stored as gzip-compressed blobs on the user's PDS. + ```sh tng pr create [flags] # Create a pull request -tng pr list [flags] # List pull requests -tng pr view [flags] # View PR details and rounds -tng pr diff # View PR diff -tng pr merge # Merge a pull request -tng pr close # Close a pull request -tng pr comment --body "..." # Comment on a PR +tng pr list [flags] # List your pull requests +tng pr view [flags] # View PR details +tng pr diff # View PR patch +tng pr merge # Merge a pull request +tng pr close # Close a pull request +tng pr comment --body "..." # Comment on a PR ``` Flags for `pr create`: @@ -136,36 +146,35 @@ Flags for `pr create`: - `-H, --head` -- Source branch (default: current branch) Flags for `pr list`: -- `-s, --status` -- Filter: `open`, `closed`, `merged`, `all` (default: `open`) -- `-R, --repo` -- Repository override -- `-L, --limit` -- Maximum results +- `-R, --repo` -- Filter by repository (owner/name) +- `-w, --web` -- Open pull requests page on tangled.sh -Flags for `pr view`: -- `-c, --comments` -- Show comments -- `-w, --web` -- Open in browser +Flags for `pr view`, `pr diff`, `pr merge`, `pr close`, `pr comment`: +- `--author` -- DID of the PR author (defaults to the authenticated user) ### `tng issue` -- Issues +Issues are AT Protocol records identified by a TID rkey, similar to pull requests. + ```sh -tng issue create [flags] # Create an issue -tng issue list [flags] # List issues -tng issue view [flags] # View issue details -tng issue close # Close an issue -tng issue comment --body "..." # Comment on an issue +tng issue create [flags] # Create an issue +tng issue list [flags] # List your issues +tng issue view [flags] # View issue details +tng issue close # Close an issue +tng issue comment --body "..." # Comment on an issue ``` Flags for `issue create`: - `-t, --title` -- Issue title (required) - `-b, --body` -- Issue body -- `-R, --repo` -- Repository override +- `-R, --repo` -- Repository override (owner/name) Flags for `issue list`: -- `-s, --status` -- Filter: `open`, `closed`, `all` (default: `open`) -- `-L, --limit` -- Maximum results +- `-R, --repo` -- Filter by repository (owner/name) +- `-w, --web` -- Open issues page on tangled.sh -Flags for `issue view`: -- `-c, --comments` -- Show comments -- `-w, --web` -- Open in browser +Flags for `issue view`, `issue close`, `issue comment`: +- `--author` -- DID of the issue author (defaults to the authenticated user) ### `tng browse` -- Open in Browser @@ -185,7 +194,7 @@ tng completion # Generate shell completions ## Repository Context -When you run commands inside a cloned Tangled repository, `tng` automatically detects the repository from git remotes. Remote priority: +When you run commands inside a cloned Tangled repository, `tng` automatically detects the repository from git remotes. Both `tangled.sh` and knot hostnames (e.g. `knot.example.com`) are recognized. Remote priority: 1. `upstream` remote 2. `tangled` remote @@ -222,6 +231,13 @@ default_knot: "" # Default knot for repo creation api_host: "" # Override API host ``` +## Known Issues + +- **Appview visibility**: Repos created via the CLI (or any non-web client) do not appear on tangled.sh/tangled.org. The appview's firehose ingester does not yet process `sh.tangled.repo` records. Repos are fully functional (push, pull, PRs, issues) but invisible on the web until this is resolved server-side. +- **PR/issue list scope**: `tng pr list` and `tng issue list` show only records authored by the authenticated user. The appview aggregates records from all authors but does not expose a public JSON API, so cross-user listing is not yet possible from the CLI. +- **OAuth session expiry**: The public client OAuth flow produces tokens that cannot always be refreshed silently. If you encounter `invalid_grant` errors, run `tng auth login` to re-authenticate. +- **Repo view on empty repos**: `tng repo view` returns a 404 for repos with no commits, since the knot has no ref to resolve. Push at least one commit first. + ## Architecture `tng` follows the same architectural patterns as the [GitHub CLI](https://github.com/cli/cli): @@ -235,9 +251,9 @@ api_host: "" # Override API host ### Key differences from `gh` - **Authentication**: AT Protocol OAuth 2.0 (not GitHub's device flow). Requires identity resolution through the AT Protocol stack (handle -> DID -> PDS -> auth server). -- **Pull requests**: Tangled uses a round-based review model. PRs are immutable per round -- pushing new changes creates a new round with interdiff support. -- **Data model**: Issues, PRs, stars, and follows are AT Protocol records stored on the user's PDS, not on a central server. -- **Infrastructure**: Repositories live on knots (lightweight git servers), not a single monolithic service. +- **Data model**: Issues, PRs, and other collaboration data are AT Protocol records stored on the user's PDS, not on a central server. They are identified by TID record keys, not sequential numbers. +- **Pull requests**: Patches are generated via `git format-patch`, gzip-compressed, and uploaded as blobs to the PDS. Merging sends the patch to the knot for server-side application. +- **Infrastructure**: Repositories live on knots (lightweight git servers), not a single monolithic service. The CLI resolves which knot hosts a repo via AT Protocol records. ## Project Structure @@ -251,7 +267,7 @@ internal/ pkg/ cmd/ Command implementations auth/ login, logout, status - repo/ create, clone, view, list + repo/ create, clone, delete, view, list pr/ create, list, view, diff, merge, close, comment issue/ create, list, view, close, comment browse/ Open in browser diff --git a/api/client.go b/api/client.go index 2074069..ad210e6 100644 --- a/api/client.go +++ b/api/client.go @@ -259,33 +259,104 @@ func (c *Client) doPlainRequest(req *http.Request, nsid string, out interface{}) return nil } -// Get performs an XRPC query against the user's PDS. -// TODO: most read operations should go to knots, not the PDS. Rework callers. -func (c *Client) Get(ctx context.Context, nsid string, params url.Values, out interface{}) error { - parsedNSID, err := syntax.ParseNSID(nsid) +// ResolvedRepo holds metadata about a Tangled repository resolved from AT Protocol records. +type ResolvedRepo struct { + URI string // at://did/sh.tangled.repo/rkey + OwnerDID string + Name string + Knot string // knot hostname (e.g. "knot.example.com") + KnotURL string // https://knot-host + Rkey string +} + +// ResolveRepo looks up a repository by owner and name from AT Protocol records. +func (c *Client) ResolveRepo(ctx context.Context, owner, name string) (*ResolvedRepo, error) { + records, err := c.ListRecords(ctx, owner, RepoRecordNSID, "", 100) if err != nil { - return err + return nil, fmt.Errorf("failed to look up repos for %s: %w", owner, err) } - p := map[string]any{} - for k, v := range params { - if len(v) > 0 { - p[k] = v[0] + for _, rec := range records.Records { + var r struct { + Name string `json:"name"` + Knot string `json:"knot"` + } + if err := json.Unmarshal(rec.Value, &r); err != nil { + continue + } + if r.Name == name { + if r.Knot == "" { + return nil, fmt.Errorf("repo %s/%s has no knot assigned", owner, name) + } + rkey := RkeyFromURI(rec.URI) + ownerDID := DIDFromURI(rec.URI) + return &ResolvedRepo{ + URI: rec.URI, + OwnerDID: ownerDID, + Name: r.Name, + Knot: r.Knot, + KnotURL: "https://" + r.Knot, + Rkey: rkey, + }, nil } } - return c.pdsClient.Get(ctx, parsedNSID, p, out) + return nil, fmt.Errorf("repo %s/%s not found in AT Protocol records", owner, name) } -// Post performs an XRPC procedure against the user's PDS. -// TODO: most write operations should use service auth to knots. Rework callers. -func (c *Client) Post(ctx context.Context, nsid string, input interface{}, out interface{}) error { - parsedNSID, err := syntax.ParseNSID(nsid) +// GetRecord fetches a single AT Protocol record from the PDS. +func (c *Client) GetRecord(ctx context.Context, did, collection, rkey string) (*RecordEntry, error) { + nsid, err := syntax.ParseNSID("com.atproto.repo.getRecord") if err != nil { - return err + return nil, err } - return c.pdsClient.Post(ctx, parsedNSID, input, out) + params := map[string]any{ + "repo": did, + "collection": collection, + "rkey": rkey, + } + + var out RecordEntry + if err := c.pdsClient.Get(ctx, nsid, params, &out); err != nil { + return nil, err + } + return &out, nil +} + +// UploadBlob uploads a blob to the user's PDS and returns the blob reference. +// The returned json.RawMessage can be embedded directly in record fields. +func (c *Client) UploadBlob(ctx context.Context, data io.Reader, contentType string) (json.RawMessage, error) { + nsid, err := syntax.ParseNSID("com.atproto.repo.uploadBlob") + if err != nil { + return nil, err + } + + var out struct { + Blob json.RawMessage `json:"blob"` + } + if err := c.pdsClient.LexDo(ctx, "POST", contentType, nsid.String(), nil, data, &out); err != nil { + return nil, fmt.Errorf("failed to upload blob: %w", err) + } + return out.Blob, nil +} + +// RkeyFromURI extracts the rkey (last path segment) from an AT URI. +func RkeyFromURI(uri string) string { + parts := strings.Split(uri, "/") + if len(parts) > 0 { + return parts[len(parts)-1] + } + return "" +} + +// DIDFromURI extracts the DID from an AT URI (at://did/collection/rkey). +func DIDFromURI(uri string) string { + trimmed := strings.TrimPrefix(uri, "at://") + if idx := strings.Index(trimmed, "/"); idx != -1 { + return trimmed[:idx] + } + return trimmed } // Paginate iterates through all pages of a paginated XRPC query. diff --git a/api/client_test.go b/api/client_test.go new file mode 100644 index 0000000..067a19c --- /dev/null +++ b/api/client_test.go @@ -0,0 +1,354 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestExtractHost(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"https://knot.example.com", "knot.example.com"}, + {"http://knot.example.com", "knot.example.com"}, + {"knot.example.com", "knot.example.com"}, + {"https://knot.example.com/", "knot.example.com"}, + {"https://knot.example.com:8080", "knot.example.com"}, + {"https://knot.example.com/path/to/thing", "knot.example.com"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := extractHost(tt.input) + if got != tt.want { + t.Errorf("extractHost(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestRkeyFromURI(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"at://did:plc:abc123/sh.tangled.repo/3jxn7q7bklr2r", "3jxn7q7bklr2r"}, + {"at://did:plc:abc123/sh.tangled.repo.issue/tid123", "tid123"}, + {"", ""}, + {"no-slashes", "no-slashes"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := RkeyFromURI(tt.input) + if got != tt.want { + t.Errorf("RkeyFromURI(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestDIDFromURI(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"at://did:plc:abc123/sh.tangled.repo/3jxn7q7bklr2r", "did:plc:abc123"}, + {"at://did:web:knot.example.com/collection/rkey", "did:web:knot.example.com"}, + {"at://did:plc:abc123", "did:plc:abc123"}, + {"did:plc:abc123/collection/rkey", "did:plc:abc123"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := DIDFromURI(tt.input) + if got != tt.want { + t.Errorf("DIDFromURI(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestDoPlainRequest_Success(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"hello": "world"}) + })) + defer ts.Close() + + c := &Client{} + req, _ := http.NewRequest("GET", ts.URL, nil) + var out map[string]string + err := c.doPlainRequest(req, "test.nsid", &out) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out["hello"] != "world" { + t.Errorf("got %v, want hello=world", out) + } +} + +func TestDoPlainRequest_NilOut(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer ts.Close() + + c := &Client{} + req, _ := http.NewRequest("POST", ts.URL, nil) + err := c.doPlainRequest(req, "test.nsid", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDoPlainRequest_APIError(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(map[string]string{ + "error": "AuthRequired", + "message": "token expired", + }) + })) + defer ts.Close() + + c := &Client{} + req, _ := http.NewRequest("GET", ts.URL, nil) + err := c.doPlainRequest(req, "test.nsid", nil) + if err == nil { + t.Fatal("expected error, got nil") + } + + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("expected *APIError, got %T", err) + } + if apiErr.StatusCode != 403 { + t.Errorf("got status %d, want 403", apiErr.StatusCode) + } + if apiErr.ErrorType != "AuthRequired" { + t.Errorf("got error type %q, want AuthRequired", apiErr.ErrorType) + } + if apiErr.Message != "token expired" { + t.Errorf("got message %q, want 'token expired'", apiErr.Message) + } +} + +func TestDoPlainRequest_RateLimit(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Retry-After", "30") + w.WriteHeader(http.StatusTooManyRequests) + json.NewEncoder(w).Encode(map[string]string{"error": "RateLimited"}) + })) + defer ts.Close() + + c := &Client{} + req, _ := http.NewRequest("GET", ts.URL, nil) + err := c.doPlainRequest(req, "test.nsid", nil) + if err == nil { + t.Fatal("expected error, got nil") + } + + rlErr, ok := err.(*RateLimitError) + if !ok { + t.Fatalf("expected *RateLimitError, got %T", err) + } + if rlErr.RetryAfter != 30*time.Second { + t.Errorf("got retry after %v, want 30s", rlErr.RetryAfter) + } +} + +func TestDoPlainRequest_ErrorFallbackStatusText(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("not json")) + })) + defer ts.Close() + + c := &Client{} + req, _ := http.NewRequest("GET", ts.URL, nil) + err := c.doPlainRequest(req, "test.nsid", nil) + if err == nil { + t.Fatal("expected error, got nil") + } + + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("expected *APIError, got %T", err) + } + if apiErr.ErrorType != "Internal Server Error" { + t.Errorf("got error type %q, want 'Internal Server Error'", apiErr.ErrorType) + } +} + +func TestFormatPullURL(t *testing.T) { + got := FormatPullURL("alice.bsky.social/my-project") + want := "https://tangled.sh/alice.bsky.social/my-project/pulls" + if got != want { + t.Errorf("FormatPullURL() = %q, want %q", got, want) + } +} + +func TestFormatIssueURL(t *testing.T) { + got := FormatIssueURL("alice.bsky.social/my-project") + want := "https://tangled.sh/alice.bsky.social/my-project/issues" + if got != want { + t.Errorf("FormatIssueURL() = %q, want %q", got, want) + } +} + +func TestParsePullRecord(t *testing.T) { + value := `{ + "$type": "sh.tangled.repo.pull", + "title": "Add feature", + "body": "Some description", + "target": { + "repo": "at://did:plc:abc/sh.tangled.repo/tid1", + "branch": "main" + }, + "source": { + "branch": "feature-branch", + "sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "repo": "at://did:plc:abc/sh.tangled.repo/tid1" + }, + "createdAt": "2026-03-28T10:00:00Z" + }` + + rec := RecordEntry{ + URI: "at://did:plc:xyz/sh.tangled.repo.pull/tid999", + CID: "bafytest", + Value: json.RawMessage(value), + } + + entry, err := parsePullRecord(rec, "did:plc:xyz") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if entry.Title != "Add feature" { + t.Errorf("title = %q, want 'Add feature'", entry.Title) + } + if entry.Body != "Some description" { + t.Errorf("body = %q, want 'Some description'", entry.Body) + } + if entry.TargetRepo != "at://did:plc:abc/sh.tangled.repo/tid1" { + t.Errorf("targetRepo = %q", entry.TargetRepo) + } + if entry.TargetRef != "main" { + t.Errorf("targetRef = %q, want 'main'", entry.TargetRef) + } + if entry.SourceRef != "feature-branch" { + t.Errorf("sourceRef = %q, want 'feature-branch'", entry.SourceRef) + } + if entry.SourceSha != "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" { + t.Errorf("sourceSha = %q", entry.SourceSha) + } + if entry.Rkey != "tid999" { + t.Errorf("rkey = %q, want 'tid999'", entry.Rkey) + } + if entry.AuthorDID != "did:plc:xyz" { + t.Errorf("authorDID = %q, want 'did:plc:xyz'", entry.AuthorDID) + } +} + +func TestParsePullRecord_NoBody(t *testing.T) { + value := `{ + "$type": "sh.tangled.repo.pull", + "title": "Fix typo", + "target": {"repo": "at://did:plc:abc/sh.tangled.repo/tid1", "branch": "main"}, + "createdAt": "2026-03-28T10:00:00Z" + }` + + rec := RecordEntry{ + URI: "at://did:plc:xyz/sh.tangled.repo.pull/tid888", + Value: json.RawMessage(value), + } + + entry, err := parsePullRecord(rec, "did:plc:xyz") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if entry.Body != "" { + t.Errorf("body = %q, want empty", entry.Body) + } + if entry.SourceRef != "" { + t.Errorf("sourceRef = %q, want empty", entry.SourceRef) + } +} + +func TestParsePullRecord_InvalidJSON(t *testing.T) { + rec := RecordEntry{ + URI: "at://did:plc:xyz/sh.tangled.repo.pull/tid777", + Value: json.RawMessage(`{invalid`), + } + + _, err := parsePullRecord(rec, "did:plc:xyz") + if err == nil { + t.Fatal("expected error for invalid JSON") + } +} + +func TestPaginate(t *testing.T) { + callCount := 0 + items, err := Paginate(t.Context(), func(cursor string) ([]string, string, error) { + callCount++ + switch callCount { + case 1: + if cursor != "" { + t.Errorf("first call cursor = %q, want empty", cursor) + } + return []string{"a", "b"}, "page2", nil + case 2: + if cursor != "page2" { + t.Errorf("second call cursor = %q, want 'page2'", cursor) + } + return []string{"c"}, "", nil + default: + t.Fatal("unexpected call") + return nil, "", nil + } + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(items) != 3 { + t.Fatalf("got %d items, want 3", len(items)) + } + if items[0] != "a" || items[1] != "b" || items[2] != "c" { + t.Errorf("got %v, want [a b c]", items) + } +} + +func TestGetFromKnot(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/xrpc/sh.tangled.repo.tree" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("repo") != "did:plc:abc/myrepo" { + t.Errorf("unexpected repo param: %s", r.URL.Query().Get("repo")) + } + if r.Header.Get("Accept") != "application/json" { + t.Errorf("missing Accept header") + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"ref": "main"}) + })) + defer ts.Close() + + c := &Client{} + var out struct { + Ref string `json:"ref"` + } + params := map[string][]string{"repo": {"did:plc:abc/myrepo"}} + err := c.GetFromKnot(t.Context(), ts.URL, "sh.tangled.repo.tree", params, &out) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Ref != "main" { + t.Errorf("got ref %q, want 'main'", out.Ref) + } +} diff --git a/api/errors_test.go b/api/errors_test.go new file mode 100644 index 0000000..563aeb6 --- /dev/null +++ b/api/errors_test.go @@ -0,0 +1,71 @@ +package api + +import ( + "errors" + "testing" + "time" +) + +func TestAPIError_Error(t *testing.T) { + tests := []struct { + name string + err *APIError + want string + }{ + { + name: "with message", + err: &APIError{StatusCode: 403, ErrorType: "AuthRequired", Message: "token expired"}, + want: "AuthRequired: token expired (HTTP 403)", + }, + { + name: "without message", + err: &APIError{StatusCode: 500, ErrorType: "InternalServerError"}, + want: "InternalServerError (HTTP 500)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.err.Error(); got != tt.want { + t.Errorf("Error() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestAPIError_StatusChecks(t *testing.T) { + e404 := &APIError{StatusCode: 404} + e403 := &APIError{StatusCode: 403} + e401 := &APIError{StatusCode: 401} + e500 := &APIError{StatusCode: 500} + + if !e404.IsNotFound() { + t.Error("404 should be not found") + } + if e500.IsNotFound() { + t.Error("500 should not be not found") + } + if !e403.IsForbidden() { + t.Error("403 should be forbidden") + } + if !e401.IsUnauthorized() { + t.Error("401 should be unauthorized") + } +} + +func TestRateLimitError(t *testing.T) { + inner := &APIError{StatusCode: 429, ErrorType: "RateLimited"} + rl := &RateLimitError{RetryAfter: 30 * time.Second, Inner: inner} + + if rl.Error() != "rate limited: retry after 30s" { + t.Errorf("Error() = %q", rl.Error()) + } + + var apiErr *APIError + if !errors.As(rl, &apiErr) { + t.Error("RateLimitError should unwrap to APIError") + } + if apiErr.StatusCode != 429 { + t.Errorf("unwrapped status = %d, want 429", apiErr.StatusCode) + } +} diff --git a/api/issue.go b/api/issue.go index 27b7433..da03b76 100644 --- a/api/issue.go +++ b/api/issue.go @@ -2,100 +2,210 @@ package api import ( "context" + "encoding/json" "fmt" - "net/url" - "strconv" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +const ( + // IssueRecordNSID is the AT Protocol collection for Tangled issues. + IssueRecordNSID = "sh.tangled.repo.issue" + // IssueCommentNSID is the AT Protocol collection for issue comments. + IssueCommentNSID = "sh.tangled.repo.issue.comment" + // IssueStateNSID is the AT Protocol collection for issue state changes. + IssueStateNSID = "sh.tangled.repo.issue.state" + + IssueStateOpen = "sh.tangled.repo.issue.state.open" + IssueStateClosed = "sh.tangled.repo.issue.state.closed" ) -// IssueListParams controls filtering for issue listing. -type IssueListParams struct { - Repo string - Status string // open, closed, all - Cursor string - Limit int +// issueRecord is written to the user's PDS when creating an issue. +type issueRecord struct { + Type string `json:"$type"` + Repo string `json:"repo"` // AT URI of the target repo + Title string `json:"title"` + Body *string `json:"body,omitempty"` + CreatedAt string `json:"createdAt"` +} + +// issueCommentRecord is written to the user's PDS when commenting on an issue. +type issueCommentRecord struct { + Type string `json:"$type"` + Issue string `json:"issue"` // AT URI of the issue + Body string `json:"body"` + CreatedAt string `json:"createdAt"` + ReplyTo *string `json:"replyTo,omitempty"` // AT URI of parent comment +} + +// issueStateRecord is written to change an issue's state. +type issueStateRecord struct { + Type string `json:"$type"` + Issue string `json:"issue"` // AT URI of the issue + State string `json:"state"` // IssueStateOpen or IssueStateClosed } -// ListIssues lists issues for a repository. -func (c *Client) ListIssues(ctx context.Context, params *IssueListParams) (*IssueList, error) { - qp := url.Values{} - qp.Set("repo", params.Repo) - if params.Status != "" && params.Status != "all" { - qp.Set("status", params.Status) +// IssueEntry represents a parsed issue record for display. +type IssueEntry struct { + URI string + Rkey string + AuthorDID string + Repo string // AT URI of the target repo + Title string + Body string + CreatedAt string +} + +// CreateIssue creates a new issue by writing an AT Protocol record to the user's PDS. +// repoURI is the AT URI of the target repository (at://did/sh.tangled.repo/rkey). +// Returns the rkey of the created issue. +func (c *Client) CreateIssue(ctx context.Context, repoURI, title, body string) (string, error) { + rkey := syntax.NewTIDClock(0).Next().String() + + var bodyPtr *string + if body != "" { + bodyPtr = &body } - if params.Cursor != "" { - qp.Set("cursor", params.Cursor) + + record := &issueRecord{ + Type: IssueRecordNSID, + Repo: repoURI, + Title: title, + Body: bodyPtr, + CreatedAt: time.Now().Format(time.RFC3339), } - if params.Limit > 0 { - qp.Set("limit", strconv.Itoa(params.Limit)) + + _, err := c.PutRecord(ctx, IssueRecordNSID, rkey, record) + if err != nil { + return "", err } - var out IssueList - err := c.Get(ctx, "sh.tangled.repo.issue.list", qp, &out) - return &out, err + return rkey, nil } -// GetIssue fetches a single issue by ID. -func (c *Client) GetIssue(ctx context.Context, repo string, id int) (*Issue, error) { - params := url.Values{} - params.Set("repo", repo) - params.Set("id", strconv.Itoa(id)) - var out Issue - err := c.Get(ctx, "sh.tangled.repo.issue.get", params, &out) - return &out, err -} +// ListOwnIssues lists issues created by the current user. If repoURI is +// non-empty, only issues targeting that repo are returned. +func (c *Client) ListOwnIssues(ctx context.Context, repoURI string) ([]IssueEntry, error) { + records, err := c.ListRecords(ctx, c.did.String(), IssueRecordNSID, "", 100) + if err != nil { + return nil, err + } + + var issues []IssueEntry + for _, rec := range records.Records { + var r struct { + Repo string `json:"repo"` + Title string `json:"title"` + Body *string `json:"body"` + CreatedAt string `json:"createdAt"` + } + if err := json.Unmarshal(rec.Value, &r); err != nil { + continue + } -// GetIssueComments fetches comments on an issue. -func (c *Client) GetIssueComments(ctx context.Context, repo string, id int) ([]Comment, error) { - params := url.Values{} - params.Set("repo", repo) - params.Set("id", strconv.Itoa(id)) - var out struct { - Comments []Comment `json:"comments"` + if repoURI != "" && r.Repo != repoURI { + continue + } + + body := "" + if r.Body != nil { + body = *r.Body + } + + issues = append(issues, IssueEntry{ + URI: rec.URI, + Rkey: RkeyFromURI(rec.URI), + AuthorDID: c.did.String(), + Repo: r.Repo, + Title: r.Title, + Body: body, + CreatedAt: r.CreatedAt, + }) } - err := c.Get(ctx, "sh.tangled.repo.issue.comments", params, &out) - return out.Comments, err + return issues, nil } -// CreateIssue creates a new issue. -func (c *Client) CreateIssue(ctx context.Context, repo, title, body string) (*Issue, error) { - input := map[string]interface{}{ - "repo": repo, - "title": title, - "body": body, +// GetIssueRecord fetches a specific issue record from a user's PDS. +func (c *Client) GetIssueRecord(ctx context.Context, authorDID, rkey string) (*IssueEntry, error) { + rec, err := c.GetRecord(ctx, authorDID, IssueRecordNSID, rkey) + if err != nil { + return nil, err + } + + var r struct { + Repo string `json:"repo"` + Title string `json:"title"` + Body *string `json:"body"` + CreatedAt string `json:"createdAt"` } - var out Issue - err := c.Post(ctx, "sh.tangled.repo.issue.create", input, &out) - return &out, err + if err := json.Unmarshal(rec.Value, &r); err != nil { + return nil, fmt.Errorf("failed to parse issue record: %w", err) + } + + body := "" + if r.Body != nil { + body = *r.Body + } + + return &IssueEntry{ + URI: rec.URI, + Rkey: rkey, + AuthorDID: authorDID, + Repo: r.Repo, + Title: r.Title, + Body: body, + CreatedAt: r.CreatedAt, + }, nil } -// CreateIssueComment adds a comment to an issue. -func (c *Client) CreateIssueComment(ctx context.Context, repo string, id int, body string) error { - input := map[string]interface{}{ - "repo": repo, - "id": id, - "body": body, +// CloseIssue writes an issue state record to close the issue. +// issueURI is the AT URI of the issue (at://did/sh.tangled.repo.issue/rkey). +func (c *Client) CloseIssue(ctx context.Context, issueURI string) error { + rkey := syntax.NewTIDClock(0).Next().String() + + record := &issueStateRecord{ + Type: IssueStateNSID, + Issue: issueURI, + State: IssueStateClosed, } - return c.Post(ctx, "sh.tangled.repo.issue.comment.create", input, nil) + + _, err := c.PutRecord(ctx, IssueStateNSID, rkey, record) + return err } -// CloseIssue closes an issue. -func (c *Client) CloseIssue(ctx context.Context, repo string, id int) error { - input := map[string]interface{}{ - "repo": repo, - "id": id, +// ReopenIssue writes an issue state record to reopen the issue. +func (c *Client) ReopenIssue(ctx context.Context, issueURI string) error { + rkey := syntax.NewTIDClock(0).Next().String() + + record := &issueStateRecord{ + Type: IssueStateNSID, + Issue: issueURI, + State: IssueStateOpen, } - return c.Post(ctx, "sh.tangled.repo.issue.close", input, nil) + + _, err := c.PutRecord(ctx, IssueStateNSID, rkey, record) + return err } -// ReopenIssue reopens an issue. -func (c *Client) ReopenIssue(ctx context.Context, repo string, id int) error { - input := map[string]interface{}{ - "repo": repo, - "id": id, +// CreateIssueComment adds a comment to an issue. +// issueURI is the AT URI of the issue. +func (c *Client) CreateIssueComment(ctx context.Context, issueURI, body string) error { + rkey := syntax.NewTIDClock(0).Next().String() + + record := &issueCommentRecord{ + Type: IssueCommentNSID, + Issue: issueURI, + Body: body, + CreatedAt: time.Now().Format(time.RFC3339), } - return c.Post(ctx, "sh.tangled.repo.issue.reopen", input, nil) + + _, err := c.PutRecord(ctx, IssueCommentNSID, rkey, record) + return err } -// FormatIssueURL returns the web URL for an issue. -func FormatIssueURL(repo string, id int) string { - return fmt.Sprintf("https://tangled.sh/%s/issues/%d", repo, id) +// FormatIssueURL returns the web URL for an issue on tangled.sh. +// Since the appview uses sequential numbers we can't map directly, +// so we link to the repo's issues page. +func FormatIssueURL(repo string) string { + return fmt.Sprintf("https://tangled.sh/%s/issues", repo) } diff --git a/api/pull.go b/api/pull.go index 04b0c03..47a9a1e 100644 --- a/api/pull.go +++ b/api/pull.go @@ -1,27 +1,72 @@ package api import ( + "bytes" + "compress/gzip" "context" + "encoding/json" "fmt" "net/url" - "strconv" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" ) -// PullListParams controls filtering for PR listing. -type PullListParams struct { - Repo string - Status string // open, closed, merged, all - Cursor string - Limit int +const ( + // PullRecordNSID is the AT Protocol collection for Tangled pull requests. + PullRecordNSID = "sh.tangled.repo.pull" + // PullCommentNSID is the AT Protocol collection for PR comments. + PullCommentNSID = "sh.tangled.repo.pull.comment" + // PullStatusNSID is the AT Protocol collection for PR status changes. + PullStatusNSID = "sh.tangled.repo.pull.status" + // MergeNSID is the knot XRPC method for merging. + MergeNSID = "sh.tangled.repo.merge" + // MergeCheckNSID is the knot XRPC method for checking merge conflicts. + MergeCheckNSID = "sh.tangled.repo.mergeCheck" + + PullStatusOpen = "sh.tangled.repo.pull.status.open" + PullStatusClosed = "sh.tangled.repo.pull.status.closed" + PullStatusMerged = "sh.tangled.repo.pull.status.merged" +) + +// pullRecord is written to the user's PDS when creating a PR. +type pullRecord struct { + Type string `json:"$type"` + Title string `json:"title"` + Body *string `json:"body,omitempty"` + Target *pullTarget `json:"target"` + Source *pullSource `json:"source,omitempty"` + PatchBlob json.RawMessage `json:"patchBlob"` + CreatedAt string `json:"createdAt"` +} + +type pullTarget struct { + Repo string `json:"repo"` // AT URI of the target repo + Branch string `json:"branch"` // target branch name +} + +type pullSource struct { + Branch string `json:"branch"` + Sha string `json:"sha"` // 40-char commit hash + Repo *string `json:"repo,omitempty"` // AT URI (for forks, same as target for same-repo) +} + +// pullCommentRecord is written to the user's PDS when commenting on a PR. +type pullCommentRecord struct { + Type string `json:"$type"` + Pull string `json:"pull"` // AT URI of the PR + Body string `json:"body"` + CreatedAt string `json:"createdAt"` } -// PullViewParams identifies a specific PR. -type PullViewParams struct { - Repo string - ID int +// pullStatusRecord is written to change a PR's status. +type pullStatusRecord struct { + Type string `json:"$type"` + Pull string `json:"pull"` // AT URI of the PR + Status string `json:"status"` // PullStatusOpen, PullStatusClosed, or PullStatusMerged } -// MergeInput is the request body for sh.tangled.repo.merge. +// MergeInput is the request body for sh.tangled.repo.merge on a knot. type MergeInput struct { DID string `json:"did"` Name string `json:"name"` @@ -43,10 +88,10 @@ type MergeCheckInput struct { // MergeCheckResponse is the response from sh.tangled.repo.mergeCheck. type MergeCheckResponse struct { - IsConflicted bool `json:"is_conflicted"` - Conflicts []MergeConflict `json:"conflicts,omitempty"` - Message string `json:"message,omitempty"` - Error string `json:"error,omitempty"` + IsConflicted bool `json:"is_conflicted"` + Conflicts []MergeConflict `json:"conflicts,omitempty"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` } type MergeConflict struct { @@ -54,99 +99,285 @@ type MergeConflict struct { Reason string `json:"reason"` } -// ListPulls lists pull requests for a repository. -func (c *Client) ListPulls(ctx context.Context, params *PullListParams) (*PullList, error) { - qp := url.Values{} - qp.Set("repo", params.Repo) - if params.Status != "" && params.Status != "all" { - qp.Set("status", params.Status) +// PullEntry represents a parsed pull request record for display. +type PullEntry struct { + URI string + Rkey string + AuthorDID string + Title string + Body string + TargetRepo string // AT URI of target repo + TargetRef string // target branch + SourceRef string // source branch + SourceSha string // source commit SHA + CreatedAt string +} + +// CreatePullParams holds the parameters for creating a pull request. +type CreatePullParams struct { + Title string + Body string + TargetRepo string // AT URI of the target repo + TargetRef string // target branch (e.g. "main") + SourceRef string // source branch + SourceSha string // 40-char HEAD SHA of the source branch + Patch []byte // raw format-patch content +} + +// CreatePull creates a pull request by uploading the patch blob and writing +// an AT Protocol record to the user's PDS. Returns the rkey. +func (c *Client) CreatePull(ctx context.Context, params *CreatePullParams) (string, error) { + // Gzip-compress the patch + var compressed bytes.Buffer + gz := gzip.NewWriter(&compressed) + if _, err := gz.Write(params.Patch); err != nil { + return "", fmt.Errorf("failed to compress patch: %w", err) + } + if err := gz.Close(); err != nil { + return "", fmt.Errorf("failed to finalize patch compression: %w", err) } - if params.Cursor != "" { - qp.Set("cursor", params.Cursor) + + // Upload blob to PDS + blobRef, err := c.UploadBlob(ctx, &compressed, "text/x-patch") + if err != nil { + return "", err } - if params.Limit > 0 { - qp.Set("limit", strconv.Itoa(params.Limit)) + + rkey := syntax.NewTIDClock(0).Next().String() + + var bodyPtr *string + if params.Body != "" { + bodyPtr = ¶ms.Body } - var out PullList - err := c.Get(ctx, "sh.tangled.repo.pull.list", qp, &out) - return &out, err + + sourceRepo := params.TargetRepo // same-repo PR + record := &pullRecord{ + Type: PullRecordNSID, + Title: params.Title, + Body: bodyPtr, + Target: &pullTarget{ + Repo: params.TargetRepo, + Branch: params.TargetRef, + }, + Source: &pullSource{ + Branch: params.SourceRef, + Sha: params.SourceSha, + Repo: &sourceRepo, + }, + PatchBlob: blobRef, + CreatedAt: time.Now().Format(time.RFC3339), + } + + _, err = c.PutRecord(ctx, PullRecordNSID, rkey, record) + if err != nil { + return "", err + } + return rkey, nil } -// GetPull fetches a single pull request by ID. -func (c *Client) GetPull(ctx context.Context, repo string, id int) (*Pull, error) { - params := url.Values{} - params.Set("repo", repo) - params.Set("id", strconv.Itoa(id)) - var out Pull - err := c.Get(ctx, "sh.tangled.repo.pull.get", params, &out) - return &out, err +// ListOwnPulls lists pull requests created by the current user. If repoURI +// is non-empty, only PRs targeting that repo are returned. +func (c *Client) ListOwnPulls(ctx context.Context, repoURI string) ([]PullEntry, error) { + records, err := c.ListRecords(ctx, c.did.String(), PullRecordNSID, "", 100) + if err != nil { + return nil, err + } + + var pulls []PullEntry + for _, rec := range records.Records { + entry, err := parsePullRecord(rec, c.did.String()) + if err != nil { + continue + } + if repoURI != "" && entry.TargetRepo != repoURI { + continue + } + pulls = append(pulls, *entry) + } + return pulls, nil } -// GetPullDiff fetches the diff for a pull request. -func (c *Client) GetPullDiff(ctx context.Context, repo string, id int) (string, error) { - params := url.Values{} - params.Set("repo", repo) - params.Set("id", strconv.Itoa(id)) - var out struct { - Diff string `json:"diff"` +// GetPullRecord fetches a specific PR record from a user's PDS. +func (c *Client) GetPullRecord(ctx context.Context, authorDID, rkey string) (*PullEntry, error) { + rec, err := c.GetRecord(ctx, authorDID, PullRecordNSID, rkey) + if err != nil { + return nil, err } - err := c.Get(ctx, "sh.tangled.repo.pull.diff", params, &out) - return out.Diff, err + return parsePullRecord(*rec, authorDID) } -// GetPullComments fetches comments on a pull request. -func (c *Client) GetPullComments(ctx context.Context, repo string, id int) ([]Comment, error) { - params := url.Values{} - params.Set("repo", repo) - params.Set("id", strconv.Itoa(id)) - var out struct { - Comments []Comment `json:"comments"` +func parsePullRecord(rec RecordEntry, authorDID string) (*PullEntry, error) { + var r struct { + Title string `json:"title"` + Body *string `json:"body"` + Target *struct { + Repo string `json:"repo"` + Branch string `json:"branch"` + } `json:"target"` + Source *struct { + Branch string `json:"branch"` + Sha string `json:"sha"` + Repo *string `json:"repo"` + } `json:"source"` + CreatedAt string `json:"createdAt"` } - err := c.Get(ctx, "sh.tangled.repo.pull.comments", params, &out) - return out.Comments, err + if err := json.Unmarshal(rec.Value, &r); err != nil { + return nil, fmt.Errorf("failed to parse pull record: %w", err) + } + + entry := &PullEntry{ + URI: rec.URI, + Rkey: RkeyFromURI(rec.URI), + AuthorDID: authorDID, + Title: r.Title, + CreatedAt: r.CreatedAt, + } + if r.Body != nil { + entry.Body = *r.Body + } + if r.Target != nil { + entry.TargetRepo = r.Target.Repo + entry.TargetRef = r.Target.Branch + } + if r.Source != nil { + entry.SourceRef = r.Source.Branch + entry.SourceSha = r.Source.Sha + } + return entry, nil +} + +// ClosePull writes a PR status record to close the PR. +func (c *Client) ClosePull(ctx context.Context, pullURI string) error { + rkey := syntax.NewTIDClock(0).Next().String() + + record := &pullStatusRecord{ + Type: PullStatusNSID, + Pull: pullURI, + Status: PullStatusClosed, + } + + _, err := c.PutRecord(ctx, PullStatusNSID, rkey, record) + return err +} + +// ReopenPull writes a PR status record to reopen the PR. +func (c *Client) ReopenPull(ctx context.Context, pullURI string) error { + rkey := syntax.NewTIDClock(0).Next().String() + + record := &pullStatusRecord{ + Type: PullStatusNSID, + Pull: pullURI, + Status: PullStatusOpen, + } + + _, err := c.PutRecord(ctx, PullStatusNSID, rkey, record) + return err } // CreatePullComment adds a comment to a pull request. -func (c *Client) CreatePullComment(ctx context.Context, repo string, id int, body string) error { - input := map[string]interface{}{ - "repo": repo, - "id": id, - "body": body, +func (c *Client) CreatePullComment(ctx context.Context, pullURI, body string) error { + rkey := syntax.NewTIDClock(0).Next().String() + + record := &pullCommentRecord{ + Type: PullCommentNSID, + Pull: pullURI, + Body: body, + CreatedAt: time.Now().Format(time.RFC3339), } - return c.Post(ctx, "sh.tangled.repo.pull.comment.create", input, nil) + + _, err := c.PutRecord(ctx, PullCommentNSID, rkey, record) + return err } -// MergePull merges a pull request. -func (c *Client) MergePull(ctx context.Context, input *MergeInput) error { - return c.Post(ctx, "sh.tangled.repo.merge", input, nil) +// MergePull merges a pull request by calling the knot's sh.tangled.repo.merge endpoint. +func (c *Client) MergePull(ctx context.Context, knotURL string, input *MergeInput) error { + return c.PostToKnot(ctx, knotURL, MergeNSID, input, nil) } // CheckMerge checks if a merge would have conflicts. -func (c *Client) CheckMerge(ctx context.Context, input *MergeCheckInput) (*MergeCheckResponse, error) { +func (c *Client) CheckMerge(ctx context.Context, knotURL string, input *MergeCheckInput) (*MergeCheckResponse, error) { var out MergeCheckResponse - err := c.Post(ctx, "sh.tangled.repo.mergeCheck", input, &out) + err := c.PostToKnot(ctx, knotURL, MergeCheckNSID, input, &out) return &out, err } -// ClosePull closes a pull request. -func (c *Client) ClosePull(ctx context.Context, repo string, id int) error { - input := map[string]interface{}{ - "repo": repo, - "id": id, +// GetPullPatch downloads the patch content for a PR by reading the record's +// patchBlob via the PDS blob endpoint. Returns the decompressed patch. +func (c *Client) GetPullPatch(ctx context.Context, authorDID, rkey string) (string, error) { + rec, err := c.GetRecord(ctx, authorDID, PullRecordNSID, rkey) + if err != nil { + return "", err + } + + // Extract the blob CID from the record + var r struct { + PatchBlob *struct { + Ref *struct { + Link string `json:"$link"` + } `json:"ref"` + } `json:"patchBlob"` + } + if err := json.Unmarshal(rec.Value, &r); err != nil { + return "", fmt.Errorf("failed to parse pull record: %w", err) + } + if r.PatchBlob == nil || r.PatchBlob.Ref == nil { + return "", fmt.Errorf("pull request has no patch blob") + } + + // Download blob from PDS via com.atproto.sync.getBlob + blobData, err := c.GetBlob(ctx, authorDID, r.PatchBlob.Ref.Link) + if err != nil { + return "", err + } + + // Decompress gzip + gz, err := gzip.NewReader(bytes.NewReader(blobData)) + if err != nil { + // Might not be gzipped (older records) + return string(blobData), nil } - return c.Post(ctx, "sh.tangled.repo.pull.close", input, nil) + defer gz.Close() + + var buf bytes.Buffer + if _, err := buf.ReadFrom(gz); err != nil { + return "", fmt.Errorf("failed to decompress patch: %w", err) + } + return buf.String(), nil } -// ReopenPull reopens a pull request. -func (c *Client) ReopenPull(ctx context.Context, repo string, id int) error { - input := map[string]interface{}{ - "repo": repo, - "id": id, +// GetBlob downloads a blob from the PDS. +func (c *Client) GetBlob(ctx context.Context, did, cid string) ([]byte, error) { + nsid, err := syntax.ParseNSID("com.atproto.sync.getBlob") + if err != nil { + return nil, err } - return c.Post(ctx, "sh.tangled.repo.pull.reopen", input, nil) + + params := map[string]any{ + "did": did, + "cid": cid, + } + + var buf bytes.Buffer + if err := c.pdsClient.LexDo(ctx, "GET", "", nsid.String(), params, nil, &buf); err != nil { + return nil, fmt.Errorf("failed to download blob: %w", err) + } + return buf.Bytes(), nil +} + +// FormatPullURL returns the web URL for a repo's pull requests on tangled.sh. +func FormatPullURL(repo string) string { + return fmt.Sprintf("https://tangled.sh/%s/pulls", repo) } -// FormatPullURL returns the web URL for a pull request. -func FormatPullURL(repo string, id int) string { - return fmt.Sprintf("https://tangled.sh/%s/pull/%d", repo, id) +// GetRepoDiff fetches a diff between two refs from a knot (for viewing PR changes). +func (c *Client) GetRepoDiff(ctx context.Context, knotURL, repo, base, head string) (string, error) { + params := url.Values{} + params.Set("repo", repo) + params.Set("base", base) + params.Set("head", head) + var out struct { + Diff string `json:"diff"` + } + err := c.GetFromKnot(ctx, knotURL, "sh.tangled.repo.diff", params, &out) + return out.Diff, err } diff --git a/api/repo.go b/api/repo.go index 5481a4c..6651a0e 100644 --- a/api/repo.go +++ b/api/repo.go @@ -2,6 +2,7 @@ package api import ( "context" + "fmt" "net/url" "strconv" "time" @@ -160,7 +161,14 @@ func (c *Client) CreateRepo(ctx context.Context, params *CreateRepoParams) error } // DeleteRepo deletes a repository from a knot and removes the PDS record. +// The PDS record must be deleted first — the knot verifies it's gone before proceeding. func (c *Client) DeleteRepo(ctx context.Context, knotURL string, did, name, rkey string) error { + // Step 1: Delete the PDS record first (knot checks it's gone) + if err := c.DeleteRecord(ctx, RepoRecordNSID, rkey); err != nil { + return fmt.Errorf("failed to delete PDS record: %w", err) + } + + // Step 2: Tell the knot to remove the bare repo input := struct { DID string `json:"did"` Name string `json:"name"` @@ -175,8 +183,7 @@ func (c *Client) DeleteRepo(ctx context.Context, knotURL string, did, name, rkey return err } - // Also remove the PDS record - return c.DeleteRecord(ctx, RepoRecordNSID, rkey) + return nil } // GetTree fetches the file tree of a repository from a knot. diff --git a/api/types.go b/api/types.go index 3525961..d42524a 100644 --- a/api/types.go +++ b/api/types.go @@ -5,81 +5,13 @@ type PageInfo struct { Cursor string `json:"cursor"` } -// Repo represents a Tangled repository. -type Repo struct { - DID string `json:"did"` - Handle string `json:"handle"` - Name string `json:"name"` - Knot string `json:"knot"` - Description string `json:"description"` - DefaultBranch string `json:"defaultBranch"` - CreatedAt string `json:"createdAt"` - IsFork bool `json:"isFork"` -} - -// RepoList is a paginated list of repos. -type RepoList struct { - Repos []Repo `json:"repos"` - Cursor string `json:"cursor"` -} - -// Pull represents a pull request. -type Pull struct { - ID int `json:"id"` - OwnerDID string `json:"ownerDid"` - Repo string `json:"repo"` - Title string `json:"title"` - Body string `json:"body"` - Status string `json:"status"` // open, closed, merged - SourceRef string `json:"sourceRef"` - TargetRef string `json:"targetRef"` - Round int `json:"round"` - CreatedAt string `json:"createdAt"` - AuthorDID string `json:"authorDid"` - AuthorHandle string `json:"authorHandle"` -} - -// PullList is a paginated list of pull requests. -type PullList struct { - Pulls []Pull `json:"pulls"` - Cursor string `json:"cursor"` -} - -// Issue represents an issue. -type Issue struct { - ID int `json:"id"` - OwnerDID string `json:"ownerDid"` - Repo string `json:"repo"` - Title string `json:"title"` - Body string `json:"body"` - Status string `json:"status"` // open, closed - CreatedAt string `json:"createdAt"` - AuthorDID string `json:"authorDid"` - AuthorHandle string `json:"authorHandle"` -} - -// IssueList is a paginated list of issues. -type IssueList struct { - Issues []Issue `json:"issues"` - Cursor string `json:"cursor"` -} - -// Comment represents a comment on a PR or issue. -type Comment struct { - ID string `json:"id"` - Body string `json:"body"` - CreatedAt string `json:"createdAt"` - AuthorDID string `json:"authorDid"` - AuthorHandle string `json:"authorHandle"` -} - // Knot represents a knot server. type Knot struct { - DID string `json:"did"` - Handle string `json:"handle"` - Name string `json:"name"` - Host string `json:"host"` - MemberCount int `json:"memberCount"` + DID string `json:"did"` + Handle string `json:"handle"` + Name string `json:"name"` + Host string `json:"host"` + MemberCount int `json:"memberCount"` } // KnotMember represents a member of a knot. diff --git a/context/remote_test.go b/context/remote_test.go new file mode 100644 index 0000000..025bb66 --- /dev/null +++ b/context/remote_test.go @@ -0,0 +1,138 @@ +package context + +import ( + "testing" + + gitpkg "knot.eric.wien/tng/git" +) + +func TestParseRepoRef(t *testing.T) { + tests := []struct { + input string + wantOwner string + wantName string + wantErr bool + }{ + {"alice.bsky.social/my-project", "alice.bsky.social", "my-project", false}, + {"did:plc:abc/repo", "did:plc:abc", "repo", false}, + {"just-a-name", "", "", true}, + {"/missing", "", "", true}, + {"missing/", "", "", true}, + {"", "", "", true}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + repo, err := ParseRepoRef(tt.input) + if tt.wantErr { + if err == nil { + t.Errorf("expected error for %q", tt.input) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if repo.Owner != tt.wantOwner || repo.Name != tt.wantName { + t.Errorf("ParseRepoRef(%q) = {%s, %s}, want {%s, %s}", + tt.input, repo.Owner, repo.Name, tt.wantOwner, tt.wantName) + } + }) + } +} + +func TestTranslateRemotes(t *testing.T) { + gitRemotes := []gitpkg.Remote{ + {Name: "origin", FetchURL: "git@tangled.sh:alice/repo", PushURL: "git@tangled.sh:alice/repo"}, + {Name: "github", FetchURL: "git@github.com:alice/repo", PushURL: "git@github.com:alice/repo"}, + {Name: "upstream", FetchURL: "https://tangled.sh/bob/repo", PushURL: "https://tangled.sh/bob/repo"}, + } + + remotes := TranslateRemotes(gitRemotes) + + if len(remotes) != 2 { + t.Fatalf("got %d tangled remotes, want 2", len(remotes)) + } + + // upstream should come first (priority 3 > 1) + if remotes[0].Remote.Name != "upstream" { + t.Errorf("first remote = %q, want 'upstream'", remotes[0].Remote.Name) + } + if remotes[0].Repo.Owner != "bob" { + t.Errorf("first remote owner = %q, want 'bob'", remotes[0].Repo.Owner) + } + + if remotes[1].Remote.Name != "origin" { + t.Errorf("second remote = %q, want 'origin'", remotes[1].Remote.Name) + } + if remotes[1].Repo.Owner != "alice" { + t.Errorf("second remote owner = %q, want 'alice'", remotes[1].Repo.Owner) + } +} + +func TestTranslateRemotes_NoTangled(t *testing.T) { + gitRemotes := []gitpkg.Remote{ + {Name: "origin", FetchURL: "git@github.com:alice/repo"}, + } + + remotes := TranslateRemotes(gitRemotes) + if len(remotes) != 0 { + t.Errorf("got %d remotes, want 0", len(remotes)) + } +} + +func TestTranslateRemotes_TangledNamedRemote(t *testing.T) { + gitRemotes := []gitpkg.Remote{ + {Name: "tangled", FetchURL: "git@tangled.sh:alice/repo"}, + {Name: "origin", FetchURL: "git@tangled.sh:alice/repo"}, + } + + remotes := TranslateRemotes(gitRemotes) + if len(remotes) != 2 { + t.Fatalf("got %d remotes, want 2", len(remotes)) + } + // "tangled" (priority 2) should come before "origin" (priority 1) + if remotes[0].Remote.Name != "tangled" { + t.Errorf("first remote = %q, want 'tangled'", remotes[0].Remote.Name) + } +} + +func TestRemotes_FindByName(t *testing.T) { + remotes := Remotes{ + {Remote: &gitpkg.Remote{Name: "origin"}, Repo: gitpkg.TangledRepo{Owner: "alice", Name: "repo"}}, + {Remote: &gitpkg.Remote{Name: "upstream"}, Repo: gitpkg.TangledRepo{Owner: "bob", Name: "repo"}}, + } + + r, err := remotes.FindByName("upstream") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if r.Repo.Owner != "bob" { + t.Errorf("found owner = %q, want 'bob'", r.Repo.Owner) + } + + _, err = remotes.FindByName("nonexistent") + if err == nil { + t.Error("expected error for nonexistent remote") + } +} + +func TestRemotes_FindByRepo(t *testing.T) { + remotes := Remotes{ + {Remote: &gitpkg.Remote{Name: "origin"}, Repo: gitpkg.TangledRepo{Owner: "alice", Name: "repo1"}}, + {Remote: &gitpkg.Remote{Name: "upstream"}, Repo: gitpkg.TangledRepo{Owner: "bob", Name: "repo2"}}, + } + + r, err := remotes.FindByRepo("bob", "repo2") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if r.Remote.Name != "upstream" { + t.Errorf("found remote = %q, want 'upstream'", r.Remote.Name) + } + + _, err = remotes.FindByRepo("alice", "nonexistent") + if err == nil { + t.Error("expected error for nonexistent repo") + } +} diff --git a/git/client.go b/git/client.go index bffa00f..26e8c7f 100644 --- a/git/client.go +++ b/git/client.go @@ -108,6 +108,28 @@ func (c *Client) run(args ...string) (string, error) { return stdout.String(), nil } +// FormatPatch generates a format-patch from the given base ref to HEAD. +func (c *Client) FormatPatch(base string) ([]byte, error) { + cmd := exec.Command(c.GitPath, "format-patch", "--stdout", base+"..HEAD") + cmd.Dir = c.RepoDir + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("git format-patch: %w\n%s", err, stderr.String()) + } + return stdout.Bytes(), nil +} + +// RevParse returns the SHA for a given ref. +func (c *Client) RevParse(ref string) (string, error) { + out, err := c.run("rev-parse", ref) + if err != nil { + return "", err + } + return strings.TrimSpace(out), nil +} + func (c *Client) runGlobal(args ...string) (string, error) { cmd := exec.Command(c.GitPath, args...) var stdout, stderr bytes.Buffer diff --git a/git/client_test.go b/git/client_test.go new file mode 100644 index 0000000..62a8390 --- /dev/null +++ b/git/client_test.go @@ -0,0 +1,199 @@ +package git + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func setupTestRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + + cmds := [][]string{ + {"git", "init"}, + {"git", "config", "user.email", "test@example.com"}, + {"git", "config", "user.name", "Test"}, + {"git", "checkout", "-b", "main"}, + } + for _, args := range cmds { + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%v: %s", err, out) + } + } + + // Create initial commit + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("# test\n"), 0o644); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{ + {"git", "add", "README.md"}, + {"git", "commit", "-m", "initial commit"}, + } { + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%v: %s", err, out) + } + } + + return dir +} + +func TestCurrentBranch(t *testing.T) { + dir := setupTestRepo(t) + c := &Client{GitPath: "git", RepoDir: dir} + + branch, err := c.CurrentBranch() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if branch != "main" { + t.Errorf("got branch %q, want 'main'", branch) + } +} + +func TestIsInsideWorkTree(t *testing.T) { + dir := setupTestRepo(t) + + c := &Client{GitPath: "git", RepoDir: dir} + if !c.IsInsideWorkTree() { + t.Error("expected to be inside work tree") + } + + c2 := &Client{GitPath: "git", RepoDir: t.TempDir()} + if c2.IsInsideWorkTree() { + t.Error("expected not to be inside work tree") + } +} + +func TestTopLevelDir(t *testing.T) { + dir := setupTestRepo(t) + c := &Client{GitPath: "git", RepoDir: dir} + + top, err := c.TopLevelDir() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Resolve symlinks for macOS /private/tmp vs /tmp + resolved, _ := filepath.EvalSymlinks(dir) + if top != resolved { + t.Errorf("got %q, want %q", top, resolved) + } +} + +func TestRevParse(t *testing.T) { + dir := setupTestRepo(t) + c := &Client{GitPath: "git", RepoDir: dir} + + sha, err := c.RevParse("HEAD") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(sha) != 40 { + t.Errorf("expected 40 char SHA, got %d chars: %q", len(sha), sha) + } +} + +func TestFormatPatch(t *testing.T) { + dir := setupTestRepo(t) + + // Create a feature branch with a commit + for _, args := range [][]string{ + {"git", "checkout", "-b", "feature"}, + } { + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%v: %s", err, out) + } + } + + if err := os.WriteFile(filepath.Join(dir, "new-file.txt"), []byte("hello\n"), 0o644); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{ + {"git", "add", "new-file.txt"}, + {"git", "commit", "-m", "add new file"}, + } { + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%v: %s", err, out) + } + } + + c := &Client{GitPath: "git", RepoDir: dir} + patch, err := c.FormatPatch("main") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(patch) == 0 { + t.Fatal("expected non-empty patch") + } + if !strings.Contains(string(patch), "add new file") { + t.Error("patch should contain commit message") + } + if !strings.Contains(string(patch), "new-file.txt") { + t.Error("patch should reference the changed file") + } +} + +func TestFormatPatch_NoDiff(t *testing.T) { + dir := setupTestRepo(t) + c := &Client{GitPath: "git", RepoDir: dir} + + // Format-patch from HEAD to HEAD should produce empty patch + patch, err := c.FormatPatch("HEAD") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(patch) != 0 { + t.Errorf("expected empty patch, got %d bytes", len(patch)) + } +} + +func TestConfig(t *testing.T) { + dir := setupTestRepo(t) + c := &Client{GitPath: "git", RepoDir: dir} + + if err := c.SetConfig("tng.test-key", "test-value"); err != nil { + t.Fatalf("SetConfig: %v", err) + } + + val, err := c.Config("tng.test-key") + if err != nil { + t.Fatalf("Config: %v", err) + } + if val != "test-value" { + t.Errorf("got %q, want 'test-value'", val) + } +} + +func TestRemotes(t *testing.T) { + dir := setupTestRepo(t) + + // Add a remote + cmd := exec.Command("git", "remote", "add", "origin", "git@tangled.sh:alice/repo") + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%v: %s", err, out) + } + + c := &Client{GitPath: "git", RepoDir: dir} + remotes, err := c.Remotes() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(remotes) != 1 { + t.Fatalf("got %d remotes, want 1", len(remotes)) + } + if remotes[0].Name != "origin" { + t.Errorf("remote name = %q, want 'origin'", remotes[0].Name) + } +} diff --git a/git/remote.go b/git/remote.go index 583fc55..11bd064 100644 --- a/git/remote.go +++ b/git/remote.go @@ -35,24 +35,51 @@ func (r TangledRepo) WebURL() string { } var ( - // git@tangled.sh:handle/repo-name(.git)? - sshPattern = regexp.MustCompile(`^git@tangled\.sh:([^/]+)/([^/.]+?)(?:\.git)?$`) - // https://tangled.sh/handle/repo-name(.git)? - httpsPattern = regexp.MustCompile(`^https://tangled\.sh/([^/]+)/([^/]+?)(?:\.git)?$`) + // git@:handle/repo-name(.git)? + // Matches tangled.sh and any knot host (knot.*, *.example.com, etc.) + sshPattern = regexp.MustCompile(`^git@([^:]+):([^/]+)/([^/.]+?)(?:\.git)?$`) + // https:///handle/repo-name(.git)? + httpsPattern = regexp.MustCompile(`^https?://([^/]+)/([^/]+)/([^/]+?)(?:\.git)?$`) ) +// knownTangledHosts contains hostnames that are always recognized as Tangled. +var knownTangledHosts = map[string]bool{ + "tangled.sh": true, +} + // ParseTangledURL extracts owner and repo name from a Tangled remote URL. -// Returns nil if the URL is not a Tangled remote. +// Recognizes tangled.sh directly and knot hosts via git config markers +// set during `tng repo clone`. +// Returns nil if the URL is not a recognized Tangled remote. func ParseTangledURL(rawURL string) *TangledRepo { if m := sshPattern.FindStringSubmatch(rawURL); m != nil { - return &TangledRepo{Owner: m[1], Name: m[2]} + host, owner, name := m[1], m[2], m[3] + if isTangledHost(host) { + return &TangledRepo{Owner: owner, Name: name} + } } if m := httpsPattern.FindStringSubmatch(rawURL); m != nil { - return &TangledRepo{Owner: m[1], Name: m[2]} + host, owner, name := m[1], m[2], m[3] + if isTangledHost(host) { + return &TangledRepo{Owner: owner, Name: name} + } } return nil } +// isTangledHost checks if a hostname belongs to the Tangled network. +// A host is recognized if it's tangled.sh or starts with "knot.". +func isTangledHost(host string) bool { + if knownTangledHosts[host] { + return true + } + // Knot hostnames conventionally start with "knot." + if strings.HasPrefix(host, "knot.") { + return true + } + return false +} + // ParseRemotes parses `git remote -v` output into Remote structs. func ParseRemotes(output string) []Remote { byName := map[string]*Remote{} diff --git a/git/remote_test.go b/git/remote_test.go new file mode 100644 index 0000000..b49932e --- /dev/null +++ b/git/remote_test.go @@ -0,0 +1,146 @@ +package git + +import ( + "testing" +) + +func TestParseTangledURL(t *testing.T) { + tests := []struct { + name string + input string + want *TangledRepo + }{ + { + name: "ssh", + input: "git@tangled.sh:alice.bsky.social/my-project", + want: &TangledRepo{Owner: "alice.bsky.social", Name: "my-project"}, + }, + { + name: "ssh with .git", + input: "git@tangled.sh:alice.bsky.social/my-project.git", + want: &TangledRepo{Owner: "alice.bsky.social", Name: "my-project"}, + }, + { + name: "https", + input: "https://tangled.sh/alice.bsky.social/my-project", + want: &TangledRepo{Owner: "alice.bsky.social", Name: "my-project"}, + }, + { + name: "https with .git", + input: "https://tangled.sh/alice.bsky.social/my-project.git", + want: &TangledRepo{Owner: "alice.bsky.social", Name: "my-project"}, + }, + { + name: "non-tangled ssh", + input: "git@github.com:user/repo", + want: nil, + }, + { + name: "non-tangled https", + input: "https://github.com/user/repo", + want: nil, + }, + { + name: "knot ssh url", + input: "git@knot.example.com:alice.bsky.social/my-project", + want: &TangledRepo{Owner: "alice.bsky.social", Name: "my-project"}, + }, + { + name: "knot https url", + input: "https://knot.example.com/alice.bsky.social/my-project", + want: &TangledRepo{Owner: "alice.bsky.social", Name: "my-project"}, + }, + { + name: "empty", + input: "", + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ParseTangledURL(tt.input) + if tt.want == nil { + if got != nil { + t.Errorf("ParseTangledURL(%q) = %+v, want nil", tt.input, got) + } + return + } + if got == nil { + t.Fatalf("ParseTangledURL(%q) = nil, want %+v", tt.input, tt.want) + } + if got.Owner != tt.want.Owner || got.Name != tt.want.Name { + t.Errorf("ParseTangledURL(%q) = {%s, %s}, want {%s, %s}", + tt.input, got.Owner, got.Name, tt.want.Owner, tt.want.Name) + } + }) + } +} + +func TestTangledRepo_URLs(t *testing.T) { + repo := TangledRepo{Owner: "alice.bsky.social", Name: "my-project"} + + if got := repo.FullName(); got != "alice.bsky.social/my-project" { + t.Errorf("FullName() = %q", got) + } + if got := repo.CloneSSH(); got != "git@tangled.sh:alice.bsky.social/my-project" { + t.Errorf("CloneSSH() = %q", got) + } + if got := repo.CloneHTTPS(); got != "https://tangled.sh/alice.bsky.social/my-project" { + t.Errorf("CloneHTTPS() = %q", got) + } + if got := repo.WebURL(); got != "https://tangled.sh/alice.bsky.social/my-project" { + t.Errorf("WebURL() = %q", got) + } +} + +func TestParseRemotes(t *testing.T) { + input := `origin git@tangled.sh:alice.bsky.social/my-project (fetch) +origin git@tangled.sh:alice.bsky.social/my-project (push) +upstream https://tangled.sh/bob.bsky.social/my-project (fetch) +upstream https://tangled.sh/bob.bsky.social/my-project (push) +` + remotes := ParseRemotes(input) + if len(remotes) != 2 { + t.Fatalf("got %d remotes, want 2", len(remotes)) + } + + if remotes[0].Name != "origin" { + t.Errorf("remotes[0].Name = %q, want 'origin'", remotes[0].Name) + } + if remotes[0].FetchURL != "git@tangled.sh:alice.bsky.social/my-project" { + t.Errorf("remotes[0].FetchURL = %q", remotes[0].FetchURL) + } + if remotes[0].PushURL != "git@tangled.sh:alice.bsky.social/my-project" { + t.Errorf("remotes[0].PushURL = %q", remotes[0].PushURL) + } + + if remotes[1].Name != "upstream" { + t.Errorf("remotes[1].Name = %q, want 'upstream'", remotes[1].Name) + } + if remotes[1].FetchURL != "https://tangled.sh/bob.bsky.social/my-project" { + t.Errorf("remotes[1].FetchURL = %q", remotes[1].FetchURL) + } +} + +func TestParseRemotes_Empty(t *testing.T) { + remotes := ParseRemotes("") + if len(remotes) != 0 { + t.Errorf("got %d remotes for empty input, want 0", len(remotes)) + } +} + +func TestParseRemotes_MalformedLines(t *testing.T) { + input := `origin git@tangled.sh:alice/repo (fetch) +bad-line +also-bad url +origin git@tangled.sh:alice/repo (push) +` + remotes := ParseRemotes(input) + if len(remotes) != 1 { + t.Fatalf("got %d remotes, want 1", len(remotes)) + } + if remotes[0].FetchURL == "" || remotes[0].PushURL == "" { + t.Error("expected both fetch and push URLs") + } +} diff --git a/internal/config/auth_config_test.go b/internal/config/auth_config_test.go new file mode 100644 index 0000000..cbe70d8 --- /dev/null +++ b/internal/config/auth_config_test.go @@ -0,0 +1,162 @@ +package config + +import ( + "testing" +) + +func TestFormatHandle(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"alice.bsky.social", "alice.bsky.social"}, + {"@alice.bsky.social", "alice.bsky.social"}, + {"", ""}, + } + + for _, tt := range tests { + if got := FormatHandle(tt.input); got != tt.want { + t.Errorf("FormatHandle(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestFormatDID(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"did:plc:abc123", "did:plc:abc123"}, + {"did:plc:4cvte3gr2l65lukolfy5rgma", "did:plc:4cvte3gr2l65luko..."}, + {"short", "short"}, + } + + for _, tt := range tests { + if got := FormatDID(tt.input); got != tt.want { + t.Errorf("FormatDID(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestFormatAccount(t *testing.T) { + got := FormatAccount("alice.bsky.social", "did:plc:abc123") + want := "alice.bsky.social (did:plc:abc123)" + if got != want { + t.Errorf("FormatAccount() = %q, want %q", got, want) + } + + got = FormatAccount("", "did:plc:abc123") + if got != "did:plc:abc123" { + t.Errorf("FormatAccount() with no handle = %q, want DID", got) + } +} + +func TestSessionFilePath(t *testing.T) { + path := SessionFilePath("did:plc:abc123") + if path == "" { + t.Error("expected non-empty path") + } + // Colons should be replaced with underscores + if contains(path, ":") { + t.Errorf("path %q should not contain colons", path) + } +} + +func contains(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +func TestAccountOperations(t *testing.T) { + t.Setenv("TNG_CONFIG_DIR", t.TempDir()) + + // Start with no accounts + accounts, err := LoadAccounts() + if err != nil { + t.Fatalf("LoadAccounts: %v", err) + } + if len(accounts) != 0 { + t.Errorf("got %d accounts, want 0", len(accounts)) + } + + // Add first account (should become default) + err = AddAccount(AccountInfo{DID: "did:plc:aaa", Handle: "alice", SessionID: "s1"}) + if err != nil { + t.Fatalf("AddAccount: %v", err) + } + + active, err := ActiveAccount() + if err != nil { + t.Fatalf("ActiveAccount: %v", err) + } + if active == nil { + t.Fatal("expected active account") + } + if active.DID != "did:plc:aaa" { + t.Errorf("active DID = %q, want 'did:plc:aaa'", active.DID) + } + if !active.IsDefault { + t.Error("first account should be default") + } + + // Add second account (should not be default) + err = AddAccount(AccountInfo{DID: "did:plc:bbb", Handle: "bob", SessionID: "s2"}) + if err != nil { + t.Fatalf("AddAccount: %v", err) + } + + accounts, _ = LoadAccounts() + if len(accounts) != 2 { + t.Fatalf("got %d accounts, want 2", len(accounts)) + } + + // Active should still be alice + active, _ = ActiveAccount() + if active.DID != "did:plc:aaa" { + t.Errorf("active should still be alice, got %q", active.DID) + } + + // Remove default account — bob should be promoted + err = RemoveAccount("did:plc:aaa") + if err != nil { + t.Fatalf("RemoveAccount: %v", err) + } + + accounts, _ = LoadAccounts() + if len(accounts) != 1 { + t.Fatalf("got %d accounts after removal, want 1", len(accounts)) + } + if !accounts[0].IsDefault { + t.Error("remaining account should be promoted to default") + } + if accounts[0].DID != "did:plc:bbb" { + t.Errorf("remaining account = %q, want bob", accounts[0].DID) + } +} + +func TestAddAccount_Update(t *testing.T) { + t.Setenv("TNG_CONFIG_DIR", t.TempDir()) + + err := AddAccount(AccountInfo{DID: "did:plc:aaa", Handle: "alice-old", SessionID: "s1"}) + if err != nil { + t.Fatal(err) + } + + // Update handle + err = AddAccount(AccountInfo{DID: "did:plc:aaa", Handle: "alice-new", SessionID: "s2"}) + if err != nil { + t.Fatal(err) + } + + accounts, _ := LoadAccounts() + if len(accounts) != 1 { + t.Fatalf("got %d accounts, want 1 (should update, not duplicate)", len(accounts)) + } + if accounts[0].Handle != "alice-new" { + t.Errorf("handle = %q, want 'alice-new'", accounts[0].Handle) + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..95d2937 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,101 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoad_DefaultsWhenMissing(t *testing.T) { + t.Setenv("TNG_CONFIG_DIR", t.TempDir()) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg == nil { + t.Fatal("expected non-nil config") + } + if cfg.Editor != "" { + t.Errorf("editor = %q, want empty", cfg.Editor) + } +} + +func TestLoad_ParsesYAML(t *testing.T) { + dir := t.TempDir() + t.Setenv("TNG_CONFIG_DIR", dir) + + content := `editor: vim +git_protocol: ssh +default_knot: knot.example.com +api_host: https://custom.tangled.sh +` + if err := os.WriteFile(filepath.Join(dir, "config.yml"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Editor != "vim" { + t.Errorf("editor = %q, want 'vim'", cfg.Editor) + } + if cfg.GitProtocol != "ssh" { + t.Errorf("git_protocol = %q, want 'ssh'", cfg.GitProtocol) + } + if cfg.DefaultKnot != "knot.example.com" { + t.Errorf("default_knot = %q", cfg.DefaultKnot) + } + if cfg.APIHost != "https://custom.tangled.sh" { + t.Errorf("api_host = %q", cfg.APIHost) + } +} + +func TestHost(t *testing.T) { + cfg := &Config{} + if got := cfg.Host(); got != DefaultAPIHost { + t.Errorf("Host() = %q, want default %q", got, DefaultAPIHost) + } + + cfg.APIHost = "https://custom.host" + if got := cfg.Host(); got != "https://custom.host" { + t.Errorf("Host() = %q, want custom", got) + } + + t.Setenv("TNG_HOST", "https://env.host") + if got := cfg.Host(); got != "https://env.host" { + t.Errorf("Host() = %q, want env override", got) + } +} + +func TestSaveAndLoad(t *testing.T) { + t.Setenv("TNG_CONFIG_DIR", t.TempDir()) + + cfg := &Config{ + Editor: "nvim", + DefaultKnot: "knot.test.com", + } + if err := cfg.Save(); err != nil { + t.Fatalf("Save: %v", err) + } + + loaded, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if loaded.Editor != "nvim" { + t.Errorf("editor = %q, want 'nvim'", loaded.Editor) + } + if loaded.DefaultKnot != "knot.test.com" { + t.Errorf("default_knot = %q, want 'knot.test.com'", loaded.DefaultKnot) + } +} + +func TestDir_EnvOverrides(t *testing.T) { + t.Setenv("TNG_CONFIG_DIR", "/custom/path") + t.Setenv("XDG_CONFIG_HOME", "") + if got := Dir(); got != "/custom/path" { + t.Errorf("Dir() = %q, want '/custom/path'", got) + } +} diff --git a/pkg/cmd/issue/close/close.go b/pkg/cmd/issue/close/close.go index f2454ae..5a065ac 100644 --- a/pkg/cmd/issue/close/close.go +++ b/pkg/cmd/issue/close/close.go @@ -2,11 +2,9 @@ package close import ( "fmt" - "strconv" "github.com/spf13/cobra" "knot.eric.wien/tng/api" - gitpkg "knot.eric.wien/tng/git" "knot.eric.wien/tng/pkg/cmdutil" "knot.eric.wien/tng/pkg/iostreams" ) @@ -14,34 +12,30 @@ import ( type Options struct { IO *iostreams.IOStreams ApiClient func() (*api.Client, error) - BaseRepo func() (*gitpkg.TangledRepo, error) - Repo string - ID int + Rkey string + Author string } func NewCmdClose(f *cmdutil.Factory) *cobra.Command { opts := &Options{ IO: f.IOStreams, ApiClient: f.ApiClient, - BaseRepo: f.BaseRepo, } cmd := &cobra.Command{ - Use: "close ", + Use: "close ", Short: "Close an issue", - Example: ` tng issue close 7`, - Args: cmdutil.ExactArgs(1, "issue number is required"), + Long: `Close an issue by writing a state record. The rkey identifies the issue +to close (shown in 'tng issue list' output).`, + Example: ` tng issue close 3jxn7q7bklr2r`, + Args: cmdutil.ExactArgs(1, "issue rkey is required"), RunE: func(cmd *cobra.Command, args []string) error { - id, err := strconv.Atoi(args[0]) - if err != nil { - return fmt.Errorf("invalid issue number: %s", args[0]) - } - opts.ID = id + opts.Rkey = args[0] return closeRun(cmd, opts) }, } - cmd.Flags().StringVarP(&opts.Repo, "repo", "R", "", "Repository (owner/name)") + cmd.Flags().StringVar(&opts.Author, "author", "", "DID of the issue author (defaults to you)") return cmd } @@ -49,24 +43,22 @@ func NewCmdClose(f *cmdutil.Factory) *cobra.Command { func closeRun(cmd *cobra.Command, opts *Options) error { cs := iostreams.NewColorScheme(opts.IO.ColorEnabled()) - repoRef := opts.Repo - if repoRef == "" { - repo, err := opts.BaseRepo() - if err != nil { - return err - } - repoRef = repo.FullName() - } - client, err := opts.ApiClient() if err != nil { return err } - if err := client.CloseIssue(cmd.Context(), repoRef, opts.ID); err != nil { + authorDID := opts.Author + if authorDID == "" { + authorDID = client.DID().String() + } + + issueURI := fmt.Sprintf("at://%s/%s/%s", authorDID, api.IssueRecordNSID, opts.Rkey) + + if err := client.CloseIssue(cmd.Context(), issueURI); err != nil { return fmt.Errorf("failed to close issue: %w", err) } - fmt.Fprintf(opts.IO.ErrOut, "%s Closed issue #%d\n", cs.SuccessIcon(), opts.ID) + fmt.Fprintf(opts.IO.ErrOut, "%s Closed issue %s\n", cs.SuccessIcon(), opts.Rkey) return nil } diff --git a/pkg/cmd/issue/comment/comment.go b/pkg/cmd/issue/comment/comment.go index 33d8482..46a3f65 100644 --- a/pkg/cmd/issue/comment/comment.go +++ b/pkg/cmd/issue/comment/comment.go @@ -2,11 +2,9 @@ package comment import ( "fmt" - "strconv" "github.com/spf13/cobra" "knot.eric.wien/tng/api" - gitpkg "knot.eric.wien/tng/git" "knot.eric.wien/tng/pkg/cmdutil" "knot.eric.wien/tng/pkg/iostreams" ) @@ -14,9 +12,8 @@ import ( type Options struct { IO *iostreams.IOStreams ApiClient func() (*api.Client, error) - BaseRepo func() (*gitpkg.TangledRepo, error) - Repo string - ID int + Rkey string + Author string Body string } @@ -24,25 +21,20 @@ func NewCmdComment(f *cmdutil.Factory) *cobra.Command { opts := &Options{ IO: f.IOStreams, ApiClient: f.ApiClient, - BaseRepo: f.BaseRepo, } cmd := &cobra.Command{ - Use: "comment ", + Use: "comment ", Short: "Comment on an issue", - Example: ` tng issue comment 7 --body "Thanks for reporting."`, - Args: cmdutil.ExactArgs(1, "issue number is required"), + Example: ` tng issue comment 3jxn7q7bklr2r --body "Thanks for reporting."`, + Args: cmdutil.ExactArgs(1, "issue rkey is required"), RunE: func(cmd *cobra.Command, args []string) error { - id, err := strconv.Atoi(args[0]) - if err != nil { - return fmt.Errorf("invalid issue number: %s", args[0]) - } - opts.ID = id + opts.Rkey = args[0] return commentRun(cmd, opts) }, } - cmd.Flags().StringVarP(&opts.Repo, "repo", "R", "", "Repository (owner/name)") + cmd.Flags().StringVar(&opts.Author, "author", "", "DID of the issue author (defaults to you)") cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "Comment body (required)") cmd.MarkFlagRequired("body") @@ -52,24 +44,22 @@ func NewCmdComment(f *cmdutil.Factory) *cobra.Command { func commentRun(cmd *cobra.Command, opts *Options) error { cs := iostreams.NewColorScheme(opts.IO.ColorEnabled()) - repoRef := opts.Repo - if repoRef == "" { - repo, err := opts.BaseRepo() - if err != nil { - return err - } - repoRef = repo.FullName() - } - client, err := opts.ApiClient() if err != nil { return err } - if err := client.CreateIssueComment(cmd.Context(), repoRef, opts.ID, opts.Body); err != nil { + authorDID := opts.Author + if authorDID == "" { + authorDID = client.DID().String() + } + + issueURI := fmt.Sprintf("at://%s/%s/%s", authorDID, api.IssueRecordNSID, opts.Rkey) + + if err := client.CreateIssueComment(cmd.Context(), issueURI, opts.Body); err != nil { return fmt.Errorf("failed to add comment: %w", err) } - fmt.Fprintf(opts.IO.ErrOut, "%s Added comment to issue #%d\n", cs.SuccessIcon(), opts.ID) + fmt.Fprintf(opts.IO.ErrOut, "%s Added comment to issue %s\n", cs.SuccessIcon(), opts.Rkey) return nil } diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index dc9d861..7265c80 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -48,28 +48,54 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { func createRun(cmd *cobra.Command, opts *Options) error { cs := iostreams.NewColorScheme(opts.IO.ColorEnabled()) - repoRef := opts.Repo - if repoRef == "" { - repo, err := opts.BaseRepo() - if err != nil { - return err - } - repoRef = repo.FullName() + client, err := opts.ApiClient() + if err != nil { + return err } - client, err := opts.ApiClient() + // Resolve repo to AT URI + owner, name, err := resolveRepoRef(opts) if err != nil { return err } - issue, err := client.CreateIssue(cmd.Context(), repoRef, opts.Title, opts.Body) + resolved, err := client.ResolveRepo(cmd.Context(), owner, name) + if err != nil { + return fmt.Errorf("failed to resolve repository: %w", err) + } + + rkey, err := client.CreateIssue(cmd.Context(), resolved.URI, opts.Title, opts.Body) if err != nil { return fmt.Errorf("failed to create issue: %w", err) } - url := api.FormatIssueURL(repoRef, issue.ID) - fmt.Fprintf(opts.IO.ErrOut, "%s Created issue #%d\n", cs.SuccessIcon(), issue.ID) - fmt.Fprintln(opts.IO.Out, url) + fmt.Fprintf(opts.IO.ErrOut, "%s Created issue on %s/%s (rkey: %s)\n", + cs.SuccessIcon(), owner, name, rkey) return nil } + +func resolveRepoRef(opts *Options) (string, string, error) { + if opts.Repo != "" { + parts := splitRepoRef(opts.Repo) + if len(parts) != 2 { + return "", "", fmt.Errorf("expected owner/name format, got %q", opts.Repo) + } + return parts[0], parts[1], nil + } + + repo, err := opts.BaseRepo() + if err != nil { + return "", "", fmt.Errorf("could not determine repository: %w\nUse --repo to specify", err) + } + return repo.Owner, repo.Name, nil +} + +func splitRepoRef(ref string) []string { + for i := range ref { + if ref[i] == '/' { + return []string{ref[:i], ref[i+1:]} + } + } + return []string{ref} +} diff --git a/pkg/cmd/issue/list/list.go b/pkg/cmd/issue/list/list.go index 397f515..7155a64 100644 --- a/pkg/cmd/issue/list/list.go +++ b/pkg/cmd/issue/list/list.go @@ -15,8 +15,7 @@ type Options struct { ApiClient func() (*api.Client, error) BaseRepo func() (*gitpkg.TangledRepo, error) Repo string - Status string - Limit int + Web bool } func NewCmdList(f *cmdutil.Factory) *cobra.Command { @@ -27,10 +26,15 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { } cmd := &cobra.Command{ - Use: "list", - Short: "List issues", + Use: "list", + Short: "List your issues", + Long: `List issues you have created. Optionally filter by repository. + +Note: This shows only issues you authored. Use --web to see all issues +for a repository on tangled.sh.`, Example: ` tng issue list - tng issue list --status closed`, + tng issue list --repo alice.bsky.social/my-project + tng issue list --web`, Aliases: []string{"ls"}, Args: cmdutil.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { @@ -38,9 +42,8 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { }, } - cmd.Flags().StringVarP(&opts.Repo, "repo", "R", "", "Repository (owner/name)") - cmd.Flags().StringVarP(&opts.Status, "status", "s", "open", "Filter by status: open, closed, all") - cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum number of issues") + cmd.Flags().StringVarP(&opts.Repo, "repo", "R", "", "Filter by repository (owner/name)") + cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open issues page on tangled.sh") return cmd } @@ -48,13 +51,21 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { func listRun(cmd *cobra.Command, opts *Options) error { cs := iostreams.NewColorScheme(opts.IO.ColorEnabled()) + // Resolve repo ref for web URL or filtering repoRef := opts.Repo if repoRef == "" { - repo, err := opts.BaseRepo() - if err != nil { - return err + if repo, err := opts.BaseRepo(); err == nil { + repoRef = repo.FullName() } - repoRef = repo.FullName() + } + + if opts.Web { + if repoRef == "" { + return fmt.Errorf("could not determine repository; use --repo") + } + url := api.FormatIssueURL(repoRef) + fmt.Fprintln(opts.IO.Out, url) + return nil } client, err := opts.ApiClient() @@ -62,38 +73,46 @@ func listRun(cmd *cobra.Command, opts *Options) error { return err } - result, err := client.ListIssues(cmd.Context(), &api.IssueListParams{ - Repo: repoRef, - Status: opts.Status, - Limit: opts.Limit, - }) + // Optionally resolve to AT URI for filtering + var repoURI string + if repoRef != "" { + parts := splitRepoRef(repoRef) + if len(parts) == 2 { + if resolved, err := client.ResolveRepo(cmd.Context(), parts[0], parts[1]); err == nil { + repoURI = resolved.URI + } + } + } + + issues, err := client.ListOwnIssues(cmd.Context(), repoURI) if err != nil { return fmt.Errorf("failed to list issues: %w", err) } - if len(result.Issues) == 0 { - fmt.Fprintf(opts.IO.ErrOut, "No %s issues found.\n", opts.Status) + if len(issues) == 0 { + fmt.Fprintln(opts.IO.ErrOut, "No issues found.") + if repoRef != "" { + fmt.Fprintf(opts.IO.ErrOut, "Use --web to see all issues at %s\n", api.FormatIssueURL(repoRef)) + } return nil } - for _, issue := range result.Issues { - statusColor := cs.Green - if issue.Status == "closed" { - statusColor = cs.Red - } - - author := issue.AuthorHandle - if author == "" { - author = issue.AuthorDID - } - - fmt.Fprintf(opts.IO.Out, "%s #%-4d %s %s\n", - statusColor(issue.Status), - issue.ID, + for _, issue := range issues { + fmt.Fprintf(opts.IO.Out, "%s %s %s\n", + cs.Gray(issue.Rkey), cs.Bold(issue.Title), - cs.Gray(author), + cs.Gray(issue.CreatedAt), ) } return nil } + +func splitRepoRef(ref string) []string { + for i := range ref { + if ref[i] == '/' { + return []string{ref[:i], ref[i+1:]} + } + } + return []string{ref} +} diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index 0ac109b..41c6f81 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -2,7 +2,6 @@ package view import ( "fmt" - "strconv" "github.com/spf13/cobra" "knot.eric.wien/tng/api" @@ -15,10 +14,9 @@ type Options struct { IO *iostreams.IOStreams ApiClient func() (*api.Client, error) BaseRepo func() (*gitpkg.TangledRepo, error) - Repo string - ID int + Rkey string + Author string Web bool - Comments bool } func NewCmdView(f *cmdutil.Factory) *cobra.Command { @@ -29,25 +27,26 @@ func NewCmdView(f *cmdutil.Factory) *cobra.Command { } cmd := &cobra.Command{ - Use: "view ", + Use: "view ", Short: "View an issue", - Example: ` tng issue view 7 - tng issue view 7 --comments - tng issue view 7 --web`, - Args: cmdutil.ExactArgs(1, "issue number is required"), + Long: `Display the details of an issue by its record key. + +The rkey is shown in the output of 'tng issue list'. By default, looks up +the issue under your own DID. Use --author to view someone else's issue.`, + Example: ` tng issue view 3jxn7q7bklr2r + tng issue view 3jxn7q7bklr2r --author did:plc:abc123 + tng issue view --web`, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - id, err := strconv.Atoi(args[0]) - if err != nil { - return fmt.Errorf("invalid issue number: %s", args[0]) + if len(args) > 0 { + opts.Rkey = args[0] } - opts.ID = id return viewRun(cmd, opts) }, } - cmd.Flags().StringVarP(&opts.Repo, "repo", "R", "", "Repository (owner/name)") - cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open in web browser") - cmd.Flags().BoolVarP(&opts.Comments, "comments", "c", false, "Show comments") + cmd.Flags().StringVar(&opts.Author, "author", "", "DID of the issue author (defaults to you)") + cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open issues page on tangled.sh") return cmd } @@ -55,19 +54,21 @@ func NewCmdView(f *cmdutil.Factory) *cobra.Command { func viewRun(cmd *cobra.Command, opts *Options) error { cs := iostreams.NewColorScheme(opts.IO.ColorEnabled()) - repoRef := opts.Repo - if repoRef == "" { - repo, err := opts.BaseRepo() - if err != nil { - return err + if opts.Web { + repoRef := "" + if repo, err := opts.BaseRepo(); err == nil { + repoRef = repo.FullName() + } + if repoRef != "" { + fmt.Fprintln(opts.IO.Out, api.FormatIssueURL(repoRef)) + } else { + fmt.Fprintln(opts.IO.Out, "https://tangled.sh") } - repoRef = repo.FullName() + return nil } - if opts.Web { - url := api.FormatIssueURL(repoRef, opts.ID) - fmt.Fprintln(opts.IO.Out, url) - return nil + if opts.Rkey == "" { + return fmt.Errorf("issue rkey is required") } client, err := opts.ApiClient() @@ -75,48 +76,23 @@ func viewRun(cmd *cobra.Command, opts *Options) error { return err } - issue, err := client.GetIssue(cmd.Context(), repoRef, opts.ID) - if err != nil { - return fmt.Errorf("failed to fetch issue: %w", err) - } - - statusColor := cs.Green - if issue.Status == "closed" { - statusColor = cs.Red + authorDID := opts.Author + if authorDID == "" { + authorDID = client.DID().String() } - author := issue.AuthorHandle - if author == "" { - author = issue.AuthorDID + issue, err := client.GetIssueRecord(cmd.Context(), authorDID, opts.Rkey) + if err != nil { + return fmt.Errorf("failed to fetch issue: %w", err) } - fmt.Fprintf(opts.IO.Out, "%s #%d\n", cs.Bold(issue.Title), issue.ID) - fmt.Fprintf(opts.IO.Out, "%s Author: %s Created: %s\n", - statusColor(issue.Status), author, issue.CreatedAt) + fmt.Fprintf(opts.IO.Out, "%s\n", cs.Bold(issue.Title)) + fmt.Fprintf(opts.IO.Out, "Author: %s Created: %s\n", issue.AuthorDID, issue.CreatedAt) + fmt.Fprintf(opts.IO.Out, "Rkey: %s\n", cs.Gray(issue.Rkey)) if issue.Body != "" { fmt.Fprintf(opts.IO.Out, "\n%s\n", issue.Body) } - if opts.Comments { - comments, err := client.GetIssueComments(cmd.Context(), repoRef, opts.ID) - if err != nil { - return fmt.Errorf("failed to fetch comments: %w", err) - } - if len(comments) > 0 { - fmt.Fprintf(opts.IO.Out, "\n%s\n", cs.Bold("Comments")) - for _, c := range comments { - commentAuthor := c.AuthorHandle - if commentAuthor == "" { - commentAuthor = c.AuthorDID - } - fmt.Fprintf(opts.IO.Out, "\n%s %s\n", cs.Bold(commentAuthor), cs.Gray(c.CreatedAt)) - fmt.Fprintln(opts.IO.Out, c.Body) - } - } - } - - fmt.Fprintf(opts.IO.Out, "\n%s\n", cs.Gray(api.FormatIssueURL(repoRef, opts.ID))) - return nil } diff --git a/pkg/cmd/pr/close/close.go b/pkg/cmd/pr/close/close.go index 3bfb95c..30e4bd2 100644 --- a/pkg/cmd/pr/close/close.go +++ b/pkg/cmd/pr/close/close.go @@ -2,11 +2,9 @@ package close import ( "fmt" - "strconv" "github.com/spf13/cobra" "knot.eric.wien/tng/api" - gitpkg "knot.eric.wien/tng/git" "knot.eric.wien/tng/pkg/cmdutil" "knot.eric.wien/tng/pkg/iostreams" ) @@ -14,34 +12,30 @@ import ( type Options struct { IO *iostreams.IOStreams ApiClient func() (*api.Client, error) - BaseRepo func() (*gitpkg.TangledRepo, error) - Repo string - ID int + Rkey string + Author string } func NewCmdClose(f *cmdutil.Factory) *cobra.Command { opts := &Options{ IO: f.IOStreams, ApiClient: f.ApiClient, - BaseRepo: f.BaseRepo, } cmd := &cobra.Command{ - Use: "close ", + Use: "close ", Short: "Close a pull request", - Example: ` tng pr close 42`, - Args: cmdutil.ExactArgs(1, "pull request number is required"), + Long: `Close a pull request by writing a status record. The rkey identifies the PR +to close (shown in 'tng pr list' output).`, + Example: ` tng pr close 3jxn7q7bklr2r`, + Args: cmdutil.ExactArgs(1, "pull request rkey is required"), RunE: func(cmd *cobra.Command, args []string) error { - id, err := strconv.Atoi(args[0]) - if err != nil { - return fmt.Errorf("invalid pull request number: %s", args[0]) - } - opts.ID = id + opts.Rkey = args[0] return closeRun(cmd, opts) }, } - cmd.Flags().StringVarP(&opts.Repo, "repo", "R", "", "Repository (owner/name)") + cmd.Flags().StringVar(&opts.Author, "author", "", "DID of the PR author (defaults to you)") return cmd } @@ -49,24 +43,22 @@ func NewCmdClose(f *cmdutil.Factory) *cobra.Command { func closeRun(cmd *cobra.Command, opts *Options) error { cs := iostreams.NewColorScheme(opts.IO.ColorEnabled()) - repoRef := opts.Repo - if repoRef == "" { - repo, err := opts.BaseRepo() - if err != nil { - return err - } - repoRef = repo.FullName() - } - client, err := opts.ApiClient() if err != nil { return err } - if err := client.ClosePull(cmd.Context(), repoRef, opts.ID); err != nil { + authorDID := opts.Author + if authorDID == "" { + authorDID = client.DID().String() + } + + pullURI := fmt.Sprintf("at://%s/%s/%s", authorDID, api.PullRecordNSID, opts.Rkey) + + if err := client.ClosePull(cmd.Context(), pullURI); err != nil { return fmt.Errorf("failed to close pull request: %w", err) } - fmt.Fprintf(opts.IO.ErrOut, "%s Closed pull request #%d\n", cs.SuccessIcon(), opts.ID) + fmt.Fprintf(opts.IO.ErrOut, "%s Closed pull request %s\n", cs.SuccessIcon(), opts.Rkey) return nil } diff --git a/pkg/cmd/pr/comment/comment.go b/pkg/cmd/pr/comment/comment.go index b6f28ab..540ce21 100644 --- a/pkg/cmd/pr/comment/comment.go +++ b/pkg/cmd/pr/comment/comment.go @@ -2,11 +2,9 @@ package comment import ( "fmt" - "strconv" "github.com/spf13/cobra" "knot.eric.wien/tng/api" - gitpkg "knot.eric.wien/tng/git" "knot.eric.wien/tng/pkg/cmdutil" "knot.eric.wien/tng/pkg/iostreams" ) @@ -14,9 +12,8 @@ import ( type Options struct { IO *iostreams.IOStreams ApiClient func() (*api.Client, error) - BaseRepo func() (*gitpkg.TangledRepo, error) - Repo string - ID int + Rkey string + Author string Body string } @@ -24,25 +21,20 @@ func NewCmdComment(f *cmdutil.Factory) *cobra.Command { opts := &Options{ IO: f.IOStreams, ApiClient: f.ApiClient, - BaseRepo: f.BaseRepo, } cmd := &cobra.Command{ - Use: "comment ", + Use: "comment ", Short: "Comment on a pull request", - Example: ` tng pr comment 42 --body "Looks good!"`, - Args: cmdutil.ExactArgs(1, "pull request number is required"), + Example: ` tng pr comment 3jxn7q7bklr2r --body "Looks good!"`, + Args: cmdutil.ExactArgs(1, "pull request rkey is required"), RunE: func(cmd *cobra.Command, args []string) error { - id, err := strconv.Atoi(args[0]) - if err != nil { - return fmt.Errorf("invalid pull request number: %s", args[0]) - } - opts.ID = id + opts.Rkey = args[0] return commentRun(cmd, opts) }, } - cmd.Flags().StringVarP(&opts.Repo, "repo", "R", "", "Repository (owner/name)") + cmd.Flags().StringVar(&opts.Author, "author", "", "DID of the PR author (defaults to you)") cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "Comment body (required)") cmd.MarkFlagRequired("body") @@ -52,24 +44,22 @@ func NewCmdComment(f *cmdutil.Factory) *cobra.Command { func commentRun(cmd *cobra.Command, opts *Options) error { cs := iostreams.NewColorScheme(opts.IO.ColorEnabled()) - repoRef := opts.Repo - if repoRef == "" { - repo, err := opts.BaseRepo() - if err != nil { - return err - } - repoRef = repo.FullName() - } - client, err := opts.ApiClient() if err != nil { return err } - if err := client.CreatePullComment(cmd.Context(), repoRef, opts.ID, opts.Body); err != nil { + authorDID := opts.Author + if authorDID == "" { + authorDID = client.DID().String() + } + + pullURI := fmt.Sprintf("at://%s/%s/%s", authorDID, api.PullRecordNSID, opts.Rkey) + + if err := client.CreatePullComment(cmd.Context(), pullURI, opts.Body); err != nil { return fmt.Errorf("failed to add comment: %w", err) } - fmt.Fprintf(opts.IO.ErrOut, "%s Added comment to pull request #%d\n", cs.SuccessIcon(), opts.ID) + fmt.Fprintf(opts.IO.ErrOut, "%s Added comment to pull request %s\n", cs.SuccessIcon(), opts.Rkey) return nil } diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index c784494..e573c30 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -12,6 +12,7 @@ import ( type Options struct { IO *iostreams.IOStreams + GitClient *gitpkg.Client ApiClient func() (*api.Client, error) BaseRepo func() (*gitpkg.TangledRepo, error) Branch func() (string, error) @@ -24,6 +25,7 @@ type Options struct { func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { opts := &Options{ IO: f.IOStreams, + GitClient: f.GitClient, ApiClient: f.ApiClient, BaseRepo: f.BaseRepo, Branch: f.Branch, @@ -32,9 +34,12 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Create a pull request", - Long: "Create a pull request on the current repository.", - Example: ` tng pr create --title "Add feature" --body "Description" - tng pr create -t "Fix bug" -b "main" --head "fix-branch"`, + Long: `Create a pull request from the current branch. + +Generates a format-patch from the base branch to HEAD, uploads it to your PDS +as an AT Protocol record, targeting the repository detected from git remotes.`, + Example: ` tng pr create --title "Add feature" + tng pr create -t "Fix bug" -b main --head fix-branch`, Args: cmdutil.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return createRun(cmd, opts) @@ -43,7 +48,7 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { cmd.Flags().StringVarP(&opts.Title, "title", "t", "", "Title of the pull request (required)") cmd.Flags().StringVarP(&opts.Body, "body", "B", "", "Body of the pull request") - cmd.Flags().StringVarP(&opts.Base, "base", "b", "", "Target branch (defaults to repo default)") + cmd.Flags().StringVarP(&opts.Base, "base", "b", "", "Target branch (defaults to main)") cmd.Flags().StringVarP(&opts.Head, "head", "H", "", "Source branch (defaults to current branch)") cmd.MarkFlagRequired("title") @@ -53,13 +58,10 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { func createRun(cmd *cobra.Command, opts *Options) error { cs := iostreams.NewColorScheme(opts.IO.ColorEnabled()) - repo, err := opts.BaseRepo() - if err != nil { - return err - } - + // Resolve source branch head := opts.Head if head == "" { + var err error head, err = opts.Branch() if err != nil { return fmt.Errorf("could not determine current branch: %w", err) @@ -71,29 +73,56 @@ func createRun(cmd *cobra.Command, opts *Options) error { base = "main" } + // Get HEAD SHA + sha, err := opts.GitClient.RevParse("HEAD") + if err != nil { + return fmt.Errorf("could not get HEAD SHA: %w", err) + } + + // Generate format-patch + patch, err := opts.GitClient.FormatPatch(base) + if err != nil { + return fmt.Errorf("failed to generate patch: %w", err) + } + if len(patch) == 0 { + return fmt.Errorf("no changes between %s and HEAD", base) + } + + // Resolve target repo + repo, err := opts.BaseRepo() + if err != nil { + return err + } + client, err := opts.ApiClient() if err != nil { return err } - input := map[string]interface{}{ - "repo": repo.FullName(), - "title": opts.Title, - "body": opts.Body, - "base": base, - "head": head, + resolved, err := client.ResolveRepo(cmd.Context(), repo.Owner, repo.Name) + if err != nil { + return fmt.Errorf("failed to resolve repository: %w", err) } - var result struct { - ID int `json:"id"` + // Create the PR + params := &api.CreatePullParams{ + Title: opts.Title, + Body: opts.Body, + TargetRepo: resolved.URI, + TargetRef: base, + SourceRef: head, + SourceSha: sha, + Patch: patch, } - if err := client.Post(cmd.Context(), "sh.tangled.repo.pull.create", input, &result); err != nil { + + rkey, err := client.CreatePull(cmd.Context(), params) + if err != nil { return fmt.Errorf("failed to create pull request: %w", err) } - url := api.FormatPullURL(repo.FullName(), result.ID) - fmt.Fprintf(opts.IO.ErrOut, "%s Created pull request #%d\n", cs.SuccessIcon(), result.ID) - fmt.Fprintln(opts.IO.Out, url) + fmt.Fprintf(opts.IO.ErrOut, "%s Created pull request on %s/%s (rkey: %s)\n", + cs.SuccessIcon(), repo.Owner, repo.Name, rkey) + fmt.Fprintf(opts.IO.ErrOut, " %s -> %s\n", cs.Cyan(head), cs.Cyan(base)) return nil } diff --git a/pkg/cmd/pr/diff/diff.go b/pkg/cmd/pr/diff/diff.go index 3c55e7b..b08a627 100644 --- a/pkg/cmd/pr/diff/diff.go +++ b/pkg/cmd/pr/diff/diff.go @@ -2,11 +2,9 @@ package diff import ( "fmt" - "strconv" "github.com/spf13/cobra" "knot.eric.wien/tng/api" - gitpkg "knot.eric.wien/tng/git" "knot.eric.wien/tng/pkg/cmdutil" "knot.eric.wien/tng/pkg/iostreams" ) @@ -14,59 +12,51 @@ import ( type Options struct { IO *iostreams.IOStreams ApiClient func() (*api.Client, error) - BaseRepo func() (*gitpkg.TangledRepo, error) - Repo string - ID int + Rkey string + Author string } func NewCmdDiff(f *cmdutil.Factory) *cobra.Command { opts := &Options{ IO: f.IOStreams, ApiClient: f.ApiClient, - BaseRepo: f.BaseRepo, } cmd := &cobra.Command{ - Use: "diff ", + Use: "diff ", Short: "View pull request diff", - Long: "Display the diff of a pull request.", - Example: ` tng pr diff 42`, - Args: cmdutil.ExactArgs(1, "pull request number is required"), + Long: `Display the patch content of a pull request. + +Downloads and decompresses the patch blob from the PR's AT Protocol record.`, + Example: ` tng pr diff 3jxn7q7bklr2r`, + Args: cmdutil.ExactArgs(1, "pull request rkey is required"), RunE: func(cmd *cobra.Command, args []string) error { - id, err := strconv.Atoi(args[0]) - if err != nil { - return fmt.Errorf("invalid pull request number: %s", args[0]) - } - opts.ID = id + opts.Rkey = args[0] return diffRun(cmd, opts) }, } - cmd.Flags().StringVarP(&opts.Repo, "repo", "R", "", "Repository (owner/name)") + cmd.Flags().StringVar(&opts.Author, "author", "", "DID of the PR author (defaults to you)") return cmd } func diffRun(cmd *cobra.Command, opts *Options) error { - repoRef := opts.Repo - if repoRef == "" { - repo, err := opts.BaseRepo() - if err != nil { - return err - } - repoRef = repo.FullName() - } - client, err := opts.ApiClient() if err != nil { return err } - diff, err := client.GetPullDiff(cmd.Context(), repoRef, opts.ID) + authorDID := opts.Author + if authorDID == "" { + authorDID = client.DID().String() + } + + patch, err := client.GetPullPatch(cmd.Context(), authorDID, opts.Rkey) if err != nil { return fmt.Errorf("failed to fetch diff: %w", err) } - fmt.Fprint(opts.IO.Out, diff) + fmt.Fprint(opts.IO.Out, patch) return nil } diff --git a/pkg/cmd/pr/list/list.go b/pkg/cmd/pr/list/list.go index 6d20b93..00aebb6 100644 --- a/pkg/cmd/pr/list/list.go +++ b/pkg/cmd/pr/list/list.go @@ -15,8 +15,7 @@ type Options struct { ApiClient func() (*api.Client, error) BaseRepo func() (*gitpkg.TangledRepo, error) Repo string - Status string - Limit int + Web bool } func NewCmdList(f *cmdutil.Factory) *cobra.Command { @@ -28,11 +27,14 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "list", - Short: "List pull requests", - Long: "List pull requests for a repository.", + Short: "List your pull requests", + Long: `List pull requests you have created. Optionally filter by repository. + +Note: This shows only PRs you authored. Use --web to see all pull requests +for a repository on tangled.sh.`, Example: ` tng pr list - tng pr list --status closed - tng pr list --repo alice.bsky.social/my-project`, + tng pr list --repo alice.bsky.social/my-project + tng pr list --web`, Aliases: []string{"ls"}, Args: cmdutil.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { @@ -40,9 +42,8 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { }, } - cmd.Flags().StringVarP(&opts.Repo, "repo", "R", "", "Repository (owner/name)") - cmd.Flags().StringVarP(&opts.Status, "status", "s", "open", "Filter by status: open, closed, merged, all") - cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum number of PRs to list") + cmd.Flags().StringVarP(&opts.Repo, "repo", "R", "", "Filter by repository (owner/name)") + cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open pull requests page on tangled.sh") return cmd } @@ -52,11 +53,18 @@ func listRun(cmd *cobra.Command, opts *Options) error { repoRef := opts.Repo if repoRef == "" { - repo, err := opts.BaseRepo() - if err != nil { - return err + if repo, err := opts.BaseRepo(); err == nil { + repoRef = repo.FullName() + } + } + + if opts.Web { + if repoRef == "" { + return fmt.Errorf("could not determine repository; use --repo") } - repoRef = repo.FullName() + url := api.FormatPullURL(repoRef) + fmt.Fprintln(opts.IO.Out, url) + return nil } client, err := opts.ApiClient() @@ -64,41 +72,50 @@ func listRun(cmd *cobra.Command, opts *Options) error { return err } - result, err := client.ListPulls(cmd.Context(), &api.PullListParams{ - Repo: repoRef, - Status: opts.Status, - Limit: opts.Limit, - }) + var repoURI string + if repoRef != "" { + parts := splitRepoRef(repoRef) + if len(parts) == 2 { + if resolved, err := client.ResolveRepo(cmd.Context(), parts[0], parts[1]); err == nil { + repoURI = resolved.URI + } + } + } + + pulls, err := client.ListOwnPulls(cmd.Context(), repoURI) if err != nil { return fmt.Errorf("failed to list pull requests: %w", err) } - if len(result.Pulls) == 0 { - fmt.Fprintf(opts.IO.ErrOut, "No %s pull requests found.\n", opts.Status) + if len(pulls) == 0 { + fmt.Fprintln(opts.IO.ErrOut, "No pull requests found.") + if repoRef != "" { + fmt.Fprintf(opts.IO.ErrOut, "Use --web to see all PRs at %s\n", api.FormatPullURL(repoRef)) + } return nil } - for _, pr := range result.Pulls { - statusColor := cs.Green - switch pr.Status { - case "closed": - statusColor = cs.Red - case "merged": - statusColor = cs.Cyan - } - - author := pr.AuthorHandle - if author == "" { - author = pr.AuthorDID + for _, pr := range pulls { + refs := "" + if pr.SourceRef != "" && pr.TargetRef != "" { + refs = fmt.Sprintf(" %s -> %s", cs.Cyan(pr.SourceRef), cs.Cyan(pr.TargetRef)) } - - fmt.Fprintf(opts.IO.Out, "%s #%-4d %s %s\n", - statusColor(pr.Status), - pr.ID, + fmt.Fprintf(opts.IO.Out, "%s %s%s %s\n", + cs.Gray(pr.Rkey), cs.Bold(pr.Title), - cs.Gray(author), + refs, + cs.Gray(pr.CreatedAt), ) } return nil } + +func splitRepoRef(ref string) []string { + for i := range ref { + if ref[i] == '/' { + return []string{ref[:i], ref[i+1:]} + } + } + return []string{ref} +} diff --git a/pkg/cmd/pr/merge/merge.go b/pkg/cmd/pr/merge/merge.go index e9d41d9..8300985 100644 --- a/pkg/cmd/pr/merge/merge.go +++ b/pkg/cmd/pr/merge/merge.go @@ -1,8 +1,8 @@ package merge import ( + "encoding/json" "fmt" - "strconv" "github.com/spf13/cobra" "knot.eric.wien/tng/api" @@ -15,8 +15,8 @@ type Options struct { IO *iostreams.IOStreams ApiClient func() (*api.Client, error) BaseRepo func() (*gitpkg.TangledRepo, error) - Repo string - ID int + Rkey string + Author string } func NewCmdMerge(f *cmdutil.Factory) *cobra.Command { @@ -27,22 +27,21 @@ func NewCmdMerge(f *cmdutil.Factory) *cobra.Command { } cmd := &cobra.Command{ - Use: "merge ", + Use: "merge ", Short: "Merge a pull request", - Long: "Merge a pull request into its target branch.", - Example: ` tng pr merge 42`, - Args: cmdutil.ExactArgs(1, "pull request number is required"), + Long: `Merge a pull request into its target branch. + +Fetches the PR record, downloads the patch, and sends a merge request to the +knot hosting the target repository.`, + Example: ` tng pr merge 3jxn7q7bklr2r`, + Args: cmdutil.ExactArgs(1, "pull request rkey is required"), RunE: func(cmd *cobra.Command, args []string) error { - id, err := strconv.Atoi(args[0]) - if err != nil { - return fmt.Errorf("invalid pull request number: %s", args[0]) - } - opts.ID = id + opts.Rkey = args[0] return mergeRun(cmd, opts) }, } - cmd.Flags().StringVarP(&opts.Repo, "repo", "R", "", "Repository (owner/name)") + cmd.Flags().StringVar(&opts.Author, "author", "", "DID of the PR author (defaults to you)") return cmd } @@ -50,47 +49,62 @@ func NewCmdMerge(f *cmdutil.Factory) *cobra.Command { func mergeRun(cmd *cobra.Command, opts *Options) error { cs := iostreams.NewColorScheme(opts.IO.ColorEnabled()) - repoRef := opts.Repo - if repoRef == "" { - repo, err := opts.BaseRepo() - if err != nil { - return err - } - repoRef = repo.FullName() - } - client, err := opts.ApiClient() if err != nil { return err } - // Get PR details first for the merge - pr, err := client.GetPull(cmd.Context(), repoRef, opts.ID) + authorDID := opts.Author + if authorDID == "" { + authorDID = client.DID().String() + } + + // Get the PR record + pr, err := client.GetPullRecord(cmd.Context(), authorDID, opts.Rkey) if err != nil { return fmt.Errorf("failed to fetch pull request: %w", err) } - if pr.Status != "open" { - return fmt.Errorf("pull request #%d is %s, not open", opts.ID, pr.Status) + // Download the patch + patch, err := client.GetPullPatch(cmd.Context(), authorDID, opts.Rkey) + if err != nil { + return fmt.Errorf("failed to fetch patch: %w", err) } - // Get the diff/patch for the merge - patch, err := client.GetPullDiff(cmd.Context(), repoRef, opts.ID) + // Resolve the target repo to get the knot URL + targetDID := api.DIDFromURI(pr.TargetRepo) + targetRkey := api.RkeyFromURI(pr.TargetRepo) + + targetRec, err := client.GetRecord(cmd.Context(), targetDID, api.RepoRecordNSID, targetRkey) if err != nil { - return fmt.Errorf("failed to fetch pull request diff: %w", err) + return fmt.Errorf("failed to resolve target repository: %w", err) + } + + // Parse repo record for name and knot + var repoInfo struct { + Name string `json:"name"` + Knot string `json:"knot"` + } + if err := parseRecordValue(targetRec.Value, &repoInfo); err != nil { + return fmt.Errorf("failed to parse repository record: %w", err) } + knotURL := "https://" + repoInfo.Knot input := &api.MergeInput{ - DID: pr.OwnerDID, - Name: pr.Repo, + DID: targetDID, + Name: repoInfo.Name, Branch: pr.TargetRef, Patch: patch, } - if err := client.MergePull(cmd.Context(), input); err != nil { + if err := client.MergePull(cmd.Context(), knotURL, input); err != nil { return fmt.Errorf("merge failed: %w", err) } - fmt.Fprintf(opts.IO.ErrOut, "%s Merged pull request #%d\n", cs.SuccessIcon(), opts.ID) + fmt.Fprintf(opts.IO.ErrOut, "%s Merged pull request %s\n", cs.SuccessIcon(), opts.Rkey) return nil } + +func parseRecordValue(data []byte, v interface{}) error { + return json.Unmarshal(data, v) +} diff --git a/pkg/cmd/pr/view/view.go b/pkg/cmd/pr/view/view.go index b1e52ea..1d93d97 100644 --- a/pkg/cmd/pr/view/view.go +++ b/pkg/cmd/pr/view/view.go @@ -2,7 +2,6 @@ package view import ( "fmt" - "strconv" "github.com/spf13/cobra" "knot.eric.wien/tng/api" @@ -15,10 +14,9 @@ type Options struct { IO *iostreams.IOStreams ApiClient func() (*api.Client, error) BaseRepo func() (*gitpkg.TangledRepo, error) - Repo string - ID int + Rkey string + Author string Web bool - Comments bool } func NewCmdView(f *cmdutil.Factory) *cobra.Command { @@ -29,26 +27,26 @@ func NewCmdView(f *cmdutil.Factory) *cobra.Command { } cmd := &cobra.Command{ - Use: "view ", + Use: "view ", Short: "View a pull request", - Long: "Display the details of a pull request, including its description and review rounds.", - Example: ` tng pr view 42 - tng pr view 42 --comments - tng pr view 42 --web`, - Args: cmdutil.ExactArgs(1, "pull request number is required"), + Long: `Display the details of a pull request by its record key. + +The rkey is shown in the output of 'tng pr list'. By default, looks up +the PR under your own DID. Use --author to view someone else's PR.`, + Example: ` tng pr view 3jxn7q7bklr2r + tng pr view 3jxn7q7bklr2r --author did:plc:abc123 + tng pr view --web`, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - id, err := strconv.Atoi(args[0]) - if err != nil { - return fmt.Errorf("invalid pull request number: %s", args[0]) + if len(args) > 0 { + opts.Rkey = args[0] } - opts.ID = id return viewRun(cmd, opts) }, } - cmd.Flags().StringVarP(&opts.Repo, "repo", "R", "", "Repository (owner/name)") - cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open in web browser") - cmd.Flags().BoolVarP(&opts.Comments, "comments", "c", false, "Show comments") + cmd.Flags().StringVar(&opts.Author, "author", "", "DID of the PR author (defaults to you)") + cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open pull requests page on tangled.sh") return cmd } @@ -56,19 +54,21 @@ func NewCmdView(f *cmdutil.Factory) *cobra.Command { func viewRun(cmd *cobra.Command, opts *Options) error { cs := iostreams.NewColorScheme(opts.IO.ColorEnabled()) - repoRef := opts.Repo - if repoRef == "" { - repo, err := opts.BaseRepo() - if err != nil { - return err + if opts.Web { + repoRef := "" + if repo, err := opts.BaseRepo(); err == nil { + repoRef = repo.FullName() + } + if repoRef != "" { + fmt.Fprintln(opts.IO.Out, api.FormatPullURL(repoRef)) + } else { + fmt.Fprintln(opts.IO.Out, "https://tangled.sh") } - repoRef = repo.FullName() + return nil } - if opts.Web { - url := api.FormatPullURL(repoRef, opts.ID) - fmt.Fprintln(opts.IO.Out, url) - return nil + if opts.Rkey == "" { + return fmt.Errorf("pull request rkey is required") } client, err := opts.ApiClient() @@ -76,58 +76,26 @@ func viewRun(cmd *cobra.Command, opts *Options) error { return err } - pr, err := client.GetPull(cmd.Context(), repoRef, opts.ID) - if err != nil { - return fmt.Errorf("failed to fetch pull request: %w", err) + authorDID := opts.Author + if authorDID == "" { + authorDID = client.DID().String() } - // Status with color - statusColor := cs.Green - switch pr.Status { - case "closed": - statusColor = cs.Red - case "merged": - statusColor = cs.Cyan + pr, err := client.GetPullRecord(cmd.Context(), authorDID, opts.Rkey) + if err != nil { + return fmt.Errorf("failed to fetch pull request: %w", err) } - author := pr.AuthorHandle - if author == "" { - author = pr.AuthorDID + fmt.Fprintf(opts.IO.Out, "%s\n", cs.Bold(pr.Title)) + if pr.SourceRef != "" && pr.TargetRef != "" { + fmt.Fprintf(opts.IO.Out, "%s -> %s\n", cs.Cyan(pr.SourceRef), cs.Cyan(pr.TargetRef)) } - - fmt.Fprintf(opts.IO.Out, "%s #%d\n", cs.Bold(pr.Title), pr.ID) - fmt.Fprintf(opts.IO.Out, "%s %s -> %s Round %d\n", - statusColor(pr.Status), - cs.Cyan(pr.SourceRef), - cs.Cyan(pr.TargetRef), - pr.Round, - ) - fmt.Fprintf(opts.IO.Out, "Author: %s Created: %s\n", author, pr.CreatedAt) + fmt.Fprintf(opts.IO.Out, "Author: %s Created: %s\n", pr.AuthorDID, pr.CreatedAt) + fmt.Fprintf(opts.IO.Out, "Rkey: %s\n", cs.Gray(pr.Rkey)) if pr.Body != "" { fmt.Fprintf(opts.IO.Out, "\n%s\n", pr.Body) } - // Comments - if opts.Comments { - comments, err := client.GetPullComments(cmd.Context(), repoRef, opts.ID) - if err != nil { - return fmt.Errorf("failed to fetch comments: %w", err) - } - if len(comments) > 0 { - fmt.Fprintf(opts.IO.Out, "\n%s\n", cs.Bold("Comments")) - for _, c := range comments { - commentAuthor := c.AuthorHandle - if commentAuthor == "" { - commentAuthor = c.AuthorDID - } - fmt.Fprintf(opts.IO.Out, "\n%s %s\n", cs.Bold(commentAuthor), cs.Gray(c.CreatedAt)) - fmt.Fprintln(opts.IO.Out, c.Body) - } - } - } - - fmt.Fprintf(opts.IO.Out, "\n%s\n", cs.Gray(api.FormatPullURL(repoRef, opts.ID))) - return nil } diff --git a/pkg/cmd/repo/clone/clone.go b/pkg/cmd/repo/clone/clone.go index 20b5bbe..bc6fe20 100644 --- a/pkg/cmd/repo/clone/clone.go +++ b/pkg/cmd/repo/clone/clone.go @@ -1,59 +1,134 @@ package clone import ( + "encoding/json" "fmt" "strings" "github.com/spf13/cobra" - "knot.eric.wien/tng/git" + "knot.eric.wien/tng/api" + "knot.eric.wien/tng/internal/config" "knot.eric.wien/tng/pkg/cmdutil" "knot.eric.wien/tng/pkg/iostreams" + gitpkg "knot.eric.wien/tng/git" ) type Options struct { IO *iostreams.IOStreams - GitClient *git.Client + GitClient *gitpkg.Client + ApiClient func() (*api.Client, error) + Config func() (*config.Config, error) Repo string Dir string + Knot string } func NewCmdClone(f *cmdutil.Factory) *cobra.Command { opts := &Options{ IO: f.IOStreams, GitClient: f.GitClient, + ApiClient: f.ApiClient, + Config: f.Config, } cmd := &cobra.Command{ Use: "clone [directory]", Short: "Clone a Tangled repository", - Long: "Clone a Tangled repository to the local machine via SSH.", + Long: `Clone a Tangled repository to the local machine via SSH. + +The repository is cloned from the knot where it is hosted. The knot is +resolved automatically from the owner's AT Protocol records, or can be +specified explicitly with --knot.`, Example: ` tng repo clone alice.bsky.social/my-project - tng repo clone alice.bsky.social/my-project ./my-dir`, + tng repo clone alice.bsky.social/my-project ./my-dir + tng repo clone alice.bsky.social/my-project --knot knot.example.com`, Args: cmdutil.MinimumArgs(1, "repository reference is required (owner/repo)"), RunE: func(cmd *cobra.Command, args []string) error { opts.Repo = args[0] if len(args) > 1 { opts.Dir = args[1] } - return cloneRun(opts) + return cloneRun(cmd, opts) }, } + cmd.Flags().StringVarP(&opts.Knot, "knot", "k", "", "Knot hostname to clone from (skips record lookup)") + return cmd } -func cloneRun(opts *Options) error { +func cloneRun(cmd *cobra.Command, opts *Options) error { cs := iostreams.NewColorScheme(opts.IO.ColorEnabled()) - - // Build SSH clone URL repo := opts.Repo - if !strings.Contains(repo, "/") { - return fmt.Errorf("expected owner/repo format, got %q", repo) + + // If it's already a full git URL, clone directly + if isGitURL(repo) { + return doClone(opts, repo, cs) + } + + // Expect owner/repo format + owner, repoName, err := parseOwnerRepo(repo) + if err != nil { + return err + } + + // Resolve the knot + knotHost := opts.Knot + if knotHost == "" { + knotHost, err = resolveKnot(cmd, opts, owner, repoName) + if err != nil { + return err + } + } + + // Strip any protocol prefix from knot for SSH URL + knotHost = stripProtocol(knotHost) + + cloneURL := fmt.Sprintf("git@%s:%s/%s", knotHost, owner, repoName) + return doClone(opts, cloneURL, cs) +} + +// resolveKnot looks up the knot hostname for a repo by reading the owner's +// AT Protocol records. +func resolveKnot(cmd *cobra.Command, opts *Options, owner, repoName string) (string, error) { + client, err := opts.ApiClient() + if err != nil { + return "", fmt.Errorf("authentication required to resolve knot (or use --knot): %w", err) + } + + // If owner matches the current user's handle, use their DID for the lookup + ownerID := owner + account, _ := config.ActiveAccount() + if account != nil && account.Handle == owner { + ownerID = account.DID + } + + records, err := client.ListRepos(cmd.Context(), ownerID, "", 100) + if err != nil { + return "", fmt.Errorf("failed to look up repos for %s: %w", owner, err) + } + + for _, rec := range records.Records { + var r struct { + Name string `json:"name"` + Knot string `json:"knot"` + } + if err := json.Unmarshal(rec.Value, &r); err != nil { + continue + } + if r.Name == repoName { + if r.Knot == "" { + return "", fmt.Errorf("repo %s/%s has no knot assigned", owner, repoName) + } + return r.Knot, nil + } } - cloneURL := fmt.Sprintf("git@tangled.sh:%s", repo) + return "", fmt.Errorf("repo %s/%s not found in AT Protocol records", owner, repoName) +} - fmt.Fprintf(opts.IO.ErrOut, "Cloning %s...\n", repo) +func doClone(opts *Options, cloneURL string, cs *iostreams.ColorScheme) error { + fmt.Fprintf(opts.IO.ErrOut, "Cloning from %s...\n", cloneURL) if err := opts.GitClient.Clone(cloneURL, opts.Dir); err != nil { return fmt.Errorf("clone failed: %w", err) @@ -61,10 +136,53 @@ func cloneRun(opts *Options) error { dir := opts.Dir if dir == "" { - parts := strings.Split(repo, "/") - dir = parts[len(parts)-1] + // Derive directory name from the URL + dir = dirFromURL(cloneURL) } fmt.Fprintf(opts.IO.ErrOut, "%s Cloned to %s\n", cs.SuccessIcon(), cs.Bold(dir)) return nil } + +// parseOwnerRepo splits "owner/repo" into its parts. +func parseOwnerRepo(ref string) (string, string, error) { + parts := strings.SplitN(ref, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", fmt.Errorf("expected owner/repo format, got %q", ref) + } + return parts[0], parts[1], nil +} + +// isGitURL returns true if the string looks like a full git URL. +func isGitURL(s string) bool { + return strings.HasPrefix(s, "git@") || + strings.HasPrefix(s, "ssh://") || + strings.HasPrefix(s, "https://") || + strings.HasPrefix(s, "http://") +} + +// stripProtocol removes https:// or http:// prefix from a host string. +func stripProtocol(host string) string { + host = strings.TrimPrefix(host, "https://") + host = strings.TrimPrefix(host, "http://") + // Remove trailing slash + host = strings.TrimRight(host, "/") + return host +} + +// dirFromURL extracts the directory name from a clone URL. +func dirFromURL(url string) string { + // Handle git@host:owner/repo format + if idx := strings.LastIndex(url, "/"); idx != -1 { + name := url[idx+1:] + name = strings.TrimSuffix(name, ".git") + return name + } + // Handle git@host:repo format + if idx := strings.LastIndex(url, ":"); idx != -1 { + name := url[idx+1:] + name = strings.TrimSuffix(name, ".git") + return name + } + return url +} diff --git a/pkg/cmd/repo/clone/clone_test.go b/pkg/cmd/repo/clone/clone_test.go new file mode 100644 index 0000000..4415d17 --- /dev/null +++ b/pkg/cmd/repo/clone/clone_test.go @@ -0,0 +1,108 @@ +package clone + +import ( + "testing" +) + +func TestParseOwnerRepo(t *testing.T) { + tests := []struct { + input string + wantOwner string + wantName string + wantErr bool + }{ + {"alice.bsky.social/my-project", "alice.bsky.social", "my-project", false}, + {"did:plc:abc123/repo-name", "did:plc:abc123", "repo-name", false}, + {"just-a-name", "", "", true}, + {"/missing-owner", "", "", true}, + {"missing-name/", "", "", true}, + {"", "", "", true}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + owner, name, err := parseOwnerRepo(tt.input) + if tt.wantErr { + if err == nil { + t.Errorf("parseOwnerRepo(%q) expected error", tt.input) + } + return + } + if err != nil { + t.Fatalf("parseOwnerRepo(%q) unexpected error: %v", tt.input, err) + } + if owner != tt.wantOwner || name != tt.wantName { + t.Errorf("parseOwnerRepo(%q) = (%q, %q), want (%q, %q)", + tt.input, owner, name, tt.wantOwner, tt.wantName) + } + }) + } +} + +func TestIsGitURL(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"git@knot.example.com:alice/repo", true}, + {"ssh://git@knot.example.com/alice/repo", true}, + {"https://knot.example.com/alice/repo", true}, + {"http://knot.example.com/alice/repo", true}, + {"alice.bsky.social/my-project", false}, + {"just-a-name", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := isGitURL(tt.input) + if got != tt.want { + t.Errorf("isGitURL(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestStripProtocol(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"https://knot.example.com", "knot.example.com"}, + {"http://knot.example.com", "knot.example.com"}, + {"knot.example.com", "knot.example.com"}, + {"https://knot.example.com/", "knot.example.com"}, + {"https://knot.example.com///", "knot.example.com"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := stripProtocol(tt.input) + if got != tt.want { + t.Errorf("stripProtocol(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestDirFromURL(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"git@knot.example.com:alice/my-project", "my-project"}, + {"git@knot.example.com:alice/my-project.git", "my-project"}, + {"https://knot.example.com/alice/my-project", "my-project"}, + {"https://knot.example.com/alice/my-project.git", "my-project"}, + {"git@knot.example.com:my-project", "my-project"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := dirFromURL(tt.input) + if got != tt.want { + t.Errorf("dirFromURL(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go index 3568969..720d051 100644 --- a/pkg/cmd/repo/create/create.go +++ b/pkg/cmd/repo/create/create.go @@ -6,6 +6,7 @@ import ( "github.com/spf13/cobra" "knot.eric.wien/tng/api" + gitpkg "knot.eric.wien/tng/git" "knot.eric.wien/tng/internal/config" "knot.eric.wien/tng/pkg/cmdutil" "knot.eric.wien/tng/pkg/iostreams" @@ -13,6 +14,7 @@ import ( type Options struct { IO *iostreams.IOStreams + GitClient *gitpkg.Client ApiClient func() (*api.Client, error) Config func() (*config.Config, error) Name string @@ -25,6 +27,7 @@ type Options struct { func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { opts := &Options{ IO: f.IOStreams, + GitClient: f.GitClient, ApiClient: f.ApiClient, Config: f.Config, } @@ -94,5 +97,27 @@ func createRun(cmd *cobra.Command, opts *Options) error { fmt.Fprintf(opts.IO.ErrOut, "%s Created repository %s on %s\n", cs.SuccessIcon(), cs.Bold(opts.Name), knotURL) + if opts.Clone { + // Build SSH clone URL: git@:/ + knotHost := strings.TrimPrefix(knotURL, "https://") + knotHost = strings.TrimPrefix(knotHost, "http://") + knotHost = strings.TrimRight(knotHost, "/") + + // Use handle if available, fall back to DID + owner := client.DID().String() + if account, _ := config.ActiveAccount(); account != nil && account.Handle != "" { + owner = account.Handle + } + + cloneURL := fmt.Sprintf("git@%s:%s/%s", knotHost, owner, opts.Name) + fmt.Fprintf(opts.IO.ErrOut, "Cloning %s...\n", cloneURL) + + if err := opts.GitClient.Clone(cloneURL, ""); err != nil { + return fmt.Errorf("repo created but clone failed: %w", err) + } + + fmt.Fprintf(opts.IO.ErrOut, "%s Cloned to %s\n", cs.SuccessIcon(), cs.Bold(opts.Name)) + } + return nil } diff --git a/pkg/cmd/repo/delete/delete.go b/pkg/cmd/repo/delete/delete.go new file mode 100644 index 0000000..d17d3fb --- /dev/null +++ b/pkg/cmd/repo/delete/delete.go @@ -0,0 +1,116 @@ +package delete + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "knot.eric.wien/tng/api" + "knot.eric.wien/tng/pkg/cmdutil" + "knot.eric.wien/tng/pkg/iostreams" +) + +type Options struct { + IO *iostreams.IOStreams + ApiClient func() (*api.Client, error) + Repo string + Confirm bool +} + +func NewCmdDelete(f *cmdutil.Factory) *cobra.Command { + opts := &Options{ + IO: f.IOStreams, + ApiClient: f.ApiClient, + } + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a repository", + Long: `Delete a repository from its knot and remove the AT Protocol record from your PDS. + +This action is irreversible. You must confirm with --yes unless running interactively.`, + Example: ` tng repo delete alice.bsky.social/my-project + tng repo delete alice.bsky.social/my-project --yes`, + Args: cmdutil.ExactArgs(1, "repository (owner/name) is required"), + RunE: func(cmd *cobra.Command, args []string) error { + opts.Repo = args[0] + return deleteRun(cmd, opts) + }, + } + + cmd.Flags().BoolVarP(&opts.Confirm, "yes", "y", false, "Skip confirmation prompt") + + return cmd +} + +func deleteRun(cmd *cobra.Command, opts *Options) error { + cs := iostreams.NewColorScheme(opts.IO.ColorEnabled()) + + owner, name, err := parseOwnerRepo(opts.Repo) + if err != nil { + return err + } + + if !opts.Confirm { + if !opts.IO.IsInteractive() { + return fmt.Errorf("cannot confirm deletion non-interactively; use --yes to confirm") + } + fmt.Fprintf(opts.IO.ErrOut, "%s This will permanently delete %s. Type the repo name to confirm: ", + cs.WarningIcon(), cs.Bold(opts.Repo)) + + var input string + if _, err := fmt.Fscanln(opts.IO.In, &input); err != nil { + return fmt.Errorf("failed to read confirmation: %w", err) + } + if input != name { + return fmt.Errorf("confirmation did not match; aborting") + } + } + + client, err := opts.ApiClient() + if err != nil { + return err + } + + // Resolve repo to get knot URL and rkey + resolved, err := client.ResolveRepo(cmd.Context(), owner, name) + if err != nil { + return fmt.Errorf("failed to resolve repository: %w", err) + } + + // Look up repo record to get the full value (need DID for knot delete) + rec, err := client.GetRecord(cmd.Context(), resolved.OwnerDID, api.RepoRecordNSID, resolved.Rkey) + if err != nil { + return fmt.Errorf("failed to fetch repo record: %w", err) + } + + var repoVal struct { + Name string `json:"name"` + Knot string `json:"knot"` + } + if err := json.Unmarshal(rec.Value, &repoVal); err != nil { + return fmt.Errorf("failed to parse repo record: %w", err) + } + + if err := client.DeleteRepo(cmd.Context(), resolved.KnotURL, resolved.OwnerDID, repoVal.Name, resolved.Rkey); err != nil { + return fmt.Errorf("failed to delete repository: %w", err) + } + + fmt.Fprintf(opts.IO.ErrOut, "%s Deleted repository %s from %s\n", + cs.SuccessIcon(), cs.Bold(opts.Repo), resolved.Knot) + + return nil +} + +func parseOwnerRepo(ref string) (string, string, error) { + for i := range ref { + if ref[i] == '/' { + owner, name := ref[:i], ref[i+1:] + if owner == "" || name == "" { + break + } + return owner, name, nil + } + } + return "", "", fmt.Errorf("expected owner/name format, got %q", ref) +} diff --git a/pkg/cmd/repo/repo.go b/pkg/cmd/repo/repo.go index 1be3c82..704bcc4 100644 --- a/pkg/cmd/repo/repo.go +++ b/pkg/cmd/repo/repo.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/cobra" cloneCmd "knot.eric.wien/tng/pkg/cmd/repo/clone" createCmd "knot.eric.wien/tng/pkg/cmd/repo/create" + deleteCmd "knot.eric.wien/tng/pkg/cmd/repo/delete" listCmd "knot.eric.wien/tng/pkg/cmd/repo/list" viewCmd "knot.eric.wien/tng/pkg/cmd/repo/view" "knot.eric.wien/tng/pkg/cmdutil" @@ -20,6 +21,7 @@ func NewCmdRepo(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(createCmd.NewCmdCreate(f)) cmd.AddCommand(cloneCmd.NewCmdClone(f)) + cmd.AddCommand(deleteCmd.NewCmdDelete(f)) cmd.AddCommand(viewCmd.NewCmdView(f)) cmd.AddCommand(listCmd.NewCmdList(f)) diff --git a/pkg/cmd/repo/view/view.go b/pkg/cmd/repo/view/view.go index d4142f1..62d5398 100644 --- a/pkg/cmd/repo/view/view.go +++ b/pkg/cmd/repo/view/view.go @@ -50,17 +50,23 @@ func viewRun(cmd *cobra.Command, opts *Options) error { cs := iostreams.NewColorScheme(opts.IO.ColorEnabled()) // Resolve repo - var repoRef string + var owner, name string if opts.Repo != "" { - repoRef = opts.Repo + parts := splitRepoRef(opts.Repo) + if len(parts) != 2 { + return fmt.Errorf("expected owner/name format, got %q", opts.Repo) + } + owner, name = parts[0], parts[1] } else { repo, err := opts.BaseRepo() if err != nil { return err } - repoRef = repo.FullName() + owner, name = repo.Owner, repo.Name } + repoRef := owner + "/" + name + if opts.Web { url := fmt.Sprintf("https://tangled.sh/%s", repoRef) fmt.Fprintln(opts.IO.Out, url) @@ -72,16 +78,25 @@ func viewRun(cmd *cobra.Command, opts *Options) error { return err } - // TODO: resolve knotURL from repo AT Protocol record or git remote - knotURL := "https://tangled.sh" - tree, err := client.GetTree(cmd.Context(), knotURL, repoRef, "", "") + // Resolve the repo to find the knot + resolved, err := client.ResolveRepo(cmd.Context(), owner, name) + if err != nil { + return fmt.Errorf("failed to resolve repository: %w", err) + } + + // The knot XRPC repo param is "did/name" + knotRepoRef := resolved.OwnerDID + "/" + resolved.Name + + tree, err := client.GetTree(cmd.Context(), resolved.KnotURL, knotRepoRef, "", "") if err != nil { return fmt.Errorf("failed to fetch repository: %w", err) } // Header fmt.Fprintf(opts.IO.Out, "%s\n", cs.Bold(repoRef)) - fmt.Fprintf(opts.IO.Out, "%s\n\n", cs.Gray("https://tangled.sh/"+repoRef)) + fmt.Fprintf(opts.IO.Out, "%s %s\n\n", + cs.Gray("https://tangled.sh/"+repoRef), + cs.Gray("["+resolved.Knot+"]")) // Last commit if tree.LastCommit != nil { @@ -115,3 +130,12 @@ func viewRun(cmd *cobra.Command, opts *Options) error { return nil } + +func splitRepoRef(ref string) []string { + for i := range ref { + if ref[i] == '/' { + return []string{ref[:i], ref[i+1:]} + } + } + return []string{ref} +} diff --git a/pkg/iostreams/iostreams_test.go b/pkg/iostreams/iostreams_test.go new file mode 100644 index 0000000..be86806 --- /dev/null +++ b/pkg/iostreams/iostreams_test.go @@ -0,0 +1,57 @@ +package iostreams + +import ( + "testing" +) + +func TestColorScheme_Disabled(t *testing.T) { + cs := NewColorScheme(false) + + if got := cs.Bold("text"); got != "text" { + t.Errorf("Bold() with disabled = %q, want plain 'text'", got) + } + if got := cs.Red("text"); got != "text" { + t.Errorf("Red() with disabled = %q", got) + } + if got := cs.Green("text"); got != "text" { + t.Errorf("Green() with disabled = %q", got) + } + if got := cs.Gray("text"); got != "text" { + t.Errorf("Gray() with disabled = %q", got) + } +} + +func TestColorScheme_Enabled(t *testing.T) { + cs := NewColorScheme(true) + + if got := cs.Bold("text"); got == "text" { + t.Error("Bold() with enabled should have ANSI codes") + } + if got := cs.Red("text"); got == "text" { + t.Error("Red() with enabled should have ANSI codes") + } +} + +func TestColorScheme_Icons(t *testing.T) { + cs := NewColorScheme(false) + + if got := cs.SuccessIcon(); got != "✓" { + t.Errorf("SuccessIcon() = %q, want '✓'", got) + } + if got := cs.WarningIcon(); got != "!" { + t.Errorf("WarningIcon() = %q, want '!'", got) + } + if got := cs.ErrorIcon(); got != "✗" { + t.Errorf("ErrorIcon() = %q, want '✗'", got) + } +} + +func TestTest(t *testing.T) { + ios := Test() + if ios.ColorEnabled() { + t.Error("test IOStreams should have color disabled") + } + if ios.IsInteractive() { + t.Error("test IOStreams should not be interactive") + } +}