diff --git a/internal/cli/app.go b/internal/cli/app.go index bf055e5..86718a5 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -9,17 +9,14 @@ import ( "encoding/json" "fmt" "io" - "maps" "mime" "net/http" "net/url" "os" - "os/exec" "os/user" "path/filepath" "regexp" "slices" - "strconv" "strings" "text/tabwriter" "time" @@ -1203,90 +1200,65 @@ func repoBase(cfg *Config) string { return cfg.Remote + "/repos/" + repo + "/-" } -// httpGet sends a GET request with the X-DocStore-Identity header set. -func (a *App) httpGet(cfg *Config, urlStr string) (*http.Response, error) { - req, err := http.NewRequest("GET", urlStr, nil) +// doRequest is the single HTTP helper used by all sub-helpers below. +// method is the HTTP method, urlStr is the full URL, body is the JSON-encoded +// request body (nil for GET/DELETE), and identity is the X-DocStore-Identity +// header value (empty string to omit the header). +func (a *App) doRequest(method, urlStr string, body any, identity string) (*http.Response, error) { + var bodyReader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(data) + } + req, err := http.NewRequest(method, urlStr, bodyReader) if err != nil { return nil, err } - req.Header.Set("X-DocStore-Identity", cfg.Author) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if identity != "" { + req.Header.Set("X-DocStore-Identity", identity) + } return a.HTTP.Do(req) } +// httpGet sends a GET request with the X-DocStore-Identity header set. +func (a *App) httpGet(cfg *Config, urlStr string) (*http.Response, error) { + return a.doRequest("GET", urlStr, nil, cfg.Author) +} + // doGET sends a GET request without requiring a workspace config. func (a *App) doGET(urlStr string) (*http.Response, error) { - req, err := http.NewRequest("GET", urlStr, nil) - if err != nil { - return nil, err - } - return a.HTTP.Do(req) + return a.doRequest("GET", urlStr, nil, "") } // doDELETE sends a DELETE request without requiring a workspace config. func (a *App) doDELETE(urlStr string) (*http.Response, error) { - req, err := http.NewRequest("DELETE", urlStr, nil) - if err != nil { - return nil, err - } - return a.HTTP.Do(req) + return a.doRequest("DELETE", urlStr, nil, "") } // doPOSTJSON sends a POST request with JSON body without requiring a workspace config. func (a *App) doPOSTJSON(urlStr string, body any) (*http.Response, error) { - data, err := json.Marshal(body) - if err != nil { - return nil, err - } - req, err := http.NewRequest("POST", urlStr, bytes.NewReader(data)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - return a.HTTP.Do(req) + return a.doRequest("POST", urlStr, body, "") } // doPUTJSON sends a PUT request with JSON body without requiring a workspace config. func (a *App) doPUTJSON(urlStr string, body any) (*http.Response, error) { - data, err := json.Marshal(body) - if err != nil { - return nil, err - } - req, err := http.NewRequest("PUT", urlStr, bytes.NewReader(data)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - return a.HTTP.Do(req) + return a.doRequest("PUT", urlStr, body, "") } // postJSON sends a POST request with a JSON body and the X-DocStore-Identity header set. func (a *App) postJSON(cfg *Config, urlStr string, body any) (*http.Response, error) { - data, err := json.Marshal(body) - if err != nil { - return nil, err - } - req, err := http.NewRequest("POST", urlStr, bytes.NewReader(data)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-DocStore-Identity", cfg.Author) - return a.HTTP.Do(req) + return a.doRequest("POST", urlStr, body, cfg.Author) } // patchJSON sends a PATCH request with a JSON body and the X-DocStore-Identity header set. func (a *App) patchJSON(cfg *Config, urlStr string, body any) (*http.Response, error) { - data, err := json.Marshal(body) - if err != nil { - return nil, err - } - req, err := http.NewRequest("PATCH", urlStr, bytes.NewReader(data)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-DocStore-Identity", cfg.Author) - return a.HTTP.Do(req) + return a.doRequest("PATCH", urlStr, body, cfg.Author) } // readError extracts an error message from an API error response. @@ -1661,135 +1633,49 @@ func (a *App) Branches(status string, onlyDraft bool, includeDraft bool) error { return nil } -// Reviews lists reviews for a branch (defaults to current branch if empty). -// A review is marked [stale] if its sequence < the branch's head_sequence. -func (a *App) Reviews(branch string) error { +// TUI launches the terminal UI reading config from .ds/config.json. +func (a *App) TUI() error { cfg, err := a.loadConfig() if err != nil { return err } - if branch == "" { - branch = cfg.Branch - } - - headSeq, err := a.branchHeadSequence(cfg, branch) - if err != nil { - return err - } - - resp, err := a.httpGet(cfg, repoBase(cfg)+"/branch/"+url.PathEscape(branch)+"/reviews") - if err != nil { - return fmt.Errorf("fetching reviews: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return a.readError(resp) - } - - var reviews []model.Review - if err := json.NewDecoder(resp.Body).Decode(&reviews); err != nil { - return fmt.Errorf("decoding reviews: %w", err) - } - - if len(reviews) == 0 { - fmt.Fprintf(a.Out, "No reviews for branch '%s'\n", branch) - return nil - } - - fmt.Fprintf(a.Out, "%-36s %-30s %-4s %-10s %s\n", "ID", "REVIEWER", "SEQ", "STATUS", "BODY") - for _, r := range reviews { - stale := "" - if r.Sequence < headSeq { - stale = " [stale]" - } - fmt.Fprintf(a.Out, "%-36s %-30s %-4d %-10s %s%s\n", - r.ID, r.Reviewer, r.Sequence, string(r.Status), r.Body, stale) - } - return nil + return tui.Run(a.HTTP, cfg.Remote, cfg.Repo, cfg.Author) } -// Review submits a review for a branch. -func (a *App) Review(branch, status, body string) error { +// Resolve resolves a merge/rebase conflict for path. +// It expects .main and .branch to exist on disk (written by Rebase), +// reads the resolved content from itself, commits it to the current branch, +// and removes the conflict files. +func (a *App) Resolve(path string) error { cfg, err := a.loadConfig() if err != nil { return err } - if branch == "" { - branch = cfg.Branch - } - - reviewStatus := model.ReviewStatus(status) - if reviewStatus != model.ReviewApproved && reviewStatus != model.ReviewRejected { - return fmt.Errorf("status must be 'approved' or 'rejected'") - } - req := model.CreateReviewRequest{ - Branch: branch, - Status: reviewStatus, - Body: body, - } - resp, err := a.postJSON(cfg, repoBase(cfg)+"/review", req) - if err != nil { - return err - } - defer resp.Body.Close() + mainConflict := filepath.Join(a.Dir, filepath.FromSlash(path+".main")) + branchConflict := filepath.Join(a.Dir, filepath.FromSlash(path+".branch")) - if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { - return a.readError(resp) + if _, err := os.Stat(mainConflict); os.IsNotExist(err) { + return fmt.Errorf("no conflict file found: %s.main (run 'ds rebase' first)", path) } - - var reviewResp model.CreateReviewResponse - if err := json.NewDecoder(resp.Body).Decode(&reviewResp); err != nil { - return fmt.Errorf("decoding response: %w", err) + if _, err := os.Stat(branchConflict); os.IsNotExist(err) { + return fmt.Errorf("no conflict file found: %s.branch (run 'ds rebase' first)", path) } - fmt.Fprintf(a.Out, "Review submitted: %s (id: %s, sequence: %d)\n", status, reviewResp.ID, reviewResp.Sequence) - return nil -} - -// Comment creates an inline file annotation on a branch. -// The version_id is resolved automatically by fetching the file metadata from the server. -func (a *App) Comment(branch, path, body string) error { - cfg, err := a.loadConfig() + resolvedPath := filepath.Join(a.Dir, filepath.FromSlash(path)) + content, err := os.ReadFile(resolvedPath) if err != nil { - return err - } - if branch == "" { - branch = cfg.Branch + return fmt.Errorf("reading resolved file %s: %w", path, err) } - // Resolve version_id for the file on this branch. - q := url.Values{} - q.Set("branch", branch) - fileResp, err := a.httpGet(cfg, repoBase(cfg)+"/file/"+url.PathEscape(path)+"?"+q.Encode()) - if err != nil { - return fmt.Errorf("fetching file info: %w", err) - } - defer fileResp.Body.Close() - if fileResp.StatusCode == http.StatusNotFound { - return fmt.Errorf("file %q not found on branch %q", path, branch) - } - if fileResp.StatusCode != http.StatusOK { - return a.readError(fileResp) - } - var fileMeta struct { - VersionID string `json:"version_id"` - } - if err := json.NewDecoder(fileResp.Body).Decode(&fileMeta); err != nil { - return fmt.Errorf("decoding file info: %w", err) - } - if fileMeta.VersionID == "" { - return fmt.Errorf("file %q was deleted on branch %q", path, branch) + req := model.CommitRequest{ + Branch: cfg.Branch, + Files: []model.FileChange{{Path: path, Content: content}}, + Message: fmt.Sprintf("resolve conflict in %s", path), + Author: cfg.Author, } - req := model.CreateReviewCommentRequest{ - Branch: branch, - Path: path, - VersionID: fileMeta.VersionID, - Body: body, - } - resp, err := a.postJSON(cfg, repoBase(cfg)+"/comment", req) + resp, err := a.postJSON(cfg, repoBase(cfg)+"/commit", req) if err != nil { return err } @@ -1799,1675 +1685,33 @@ func (a *App) Comment(branch, path, body string) error { return a.readError(resp) } - var createResp model.CreateReviewCommentResponse - if err := json.NewDecoder(resp.Body).Decode(&createResp); err != nil { + var commitResp model.CommitResponse + if err := json.NewDecoder(resp.Body).Decode(&commitResp); err != nil { return fmt.Errorf("decoding response: %w", err) } - fmt.Fprintf(a.Out, "Comment created: id=%s, sequence=%d\n", createResp.ID, createResp.Sequence) - return nil -} - -// Comments lists inline file comments for a branch. If path is non-empty, only -// comments on that path are shown. -func (a *App) Comments(branch, path string) error { - cfg, err := a.loadConfig() - if err != nil { - return err - } - if branch == "" { - branch = cfg.Branch - } - - q := url.Values{} - if path != "" { - q.Set("path", path) - } - urlStr := repoBase(cfg) + "/branch/" + url.PathEscape(branch) + "/comments" - if len(q) > 0 { - urlStr += "?" + q.Encode() - } - - resp, err := a.httpGet(cfg, urlStr) - if err != nil { - return fmt.Errorf("fetching comments: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return a.readError(resp) - } - - var comments []model.ReviewComment - if err := json.NewDecoder(resp.Body).Decode(&comments); err != nil { - return fmt.Errorf("decoding comments: %w", err) - } - - if len(comments) == 0 { - fmt.Fprintf(a.Out, "No comments for branch '%s'\n", branch) - return nil - } - - fmt.Fprintf(a.Out, "%-36s %-40s %-4s %s\n", "ID", "PATH", "SEQ", "BODY") - for _, c := range comments { - fmt.Fprintf(a.Out, "%-36s %-40s %-4d %s\n", c.ID, c.Path, c.Sequence, c.Body) - } - return nil -} - -// Checks lists check runs for a branch (defaults to current branch if empty). -// If showAll is false, only the latest result per check_name is shown. -func (a *App) Checks(branch string, showAll bool) error { - cfg, err := a.loadConfig() - if err != nil { - return err - } - if branch == "" { - branch = cfg.Branch - } - - resp, err := a.httpGet(cfg, repoBase(cfg)+"/branch/"+url.PathEscape(branch)+"/checks") - if err != nil { - return fmt.Errorf("fetching checks: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return a.readError(resp) - } - - var checkRuns []model.CheckRun - if err := json.NewDecoder(resp.Body).Decode(&checkRuns); err != nil { - return fmt.Errorf("decoding checks: %w", err) - } - - if len(checkRuns) == 0 { - fmt.Fprintf(a.Out, "No check runs for branch '%s'\n", branch) - return nil - } - - headSeq, err := a.branchHeadSequence(cfg, branch) - if err != nil { - return err - } - - // Deduplicate by check_name keeping highest sequence unless --all is set. - if !showAll { - latest := make(map[string]model.CheckRun) - for _, c := range checkRuns { - prev, ok := latest[c.CheckName] - if !ok || c.Sequence > prev.Sequence { - latest[c.CheckName] = c - } - } - checkRuns = slices.Collect(maps.Values(latest)) - slices.SortFunc(checkRuns, func(a, b model.CheckRun) int { - return strings.Compare(a.CheckName, b.CheckName) - }) - } - - fmt.Fprintf(a.Out, "%-8s %-20s %-4s %-10s %s\n", "ID", "CHECK NAME", "SEQ", "STATUS", "REPORTER") - for _, c := range checkRuns { - stale := "" - if c.Sequence < headSeq { - stale = " [stale]" - } - id := c.ID - if len(id) > 8 { - id = id[:8] - } - fmt.Fprintf(a.Out, "%-8s %-20s %-4d %-10s %s%s\n", - id, c.CheckName, c.Sequence, string(c.Status), c.Reporter, stale) - if c.LogURL != nil { - fmt.Fprintf(a.Out, " log: %s\n", *c.LogURL) - } - } - return nil -} - -// Check reports a CI check result for a branch. -// logURL and sequence are optional; pass nil to omit them from the request. -func (a *App) Check(branch, name, status string, logURL *string, sequence *int64) error { - cfg, err := a.loadConfig() + st, err := a.loadState() if err != nil { return err } - if branch == "" { - branch = cfg.Branch - } - - checkStatus := model.CheckRunStatus(status) - if checkStatus != model.CheckRunPassed && checkStatus != model.CheckRunFailed && checkStatus != model.CheckRunPending { - return fmt.Errorf("status must be 'passed', 'failed', or 'pending'") - } - - req := model.CreateCheckRunRequest{ - Branch: branch, - CheckName: name, - Status: checkStatus, - LogURL: logURL, - Sequence: sequence, - } - resp, err := a.postJSON(cfg, repoBase(cfg)+"/check", req) - if err != nil { + st.Sequence = commitResp.Sequence + st.Files[path] = HashBytes(content) + if err := a.saveState(st); err != nil { return err } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { - return a.readError(resp) - } - var checkResp model.CreateCheckRunResponse - if err := json.NewDecoder(resp.Body).Decode(&checkResp); err != nil { - return fmt.Errorf("decoding response: %w", err) - } + // Best-effort cleanup of conflict files; the commit already succeeded. + _ = os.Remove(mainConflict) + _ = os.Remove(branchConflict) - fmt.Fprintf(a.Out, "Check run submitted: %s=%s (id: %s, sequence: %d)\n", name, status, checkResp.ID, checkResp.Sequence) + fmt.Fprintf(a.Out, "resolved %s, committed as sequence %d\n", path, commitResp.Sequence) return nil } -// branchHeadSequence returns the head sequence of a named branch. -func (a *App) branchHeadSequence(cfg *Config, branch string) (int64, error) { - resp, err := a.httpGet(cfg, repoBase(cfg)+"/branches") - if err != nil { - return 0, fmt.Errorf("fetching branches: %w", err) - } - defer resp.Body.Close() - - var branches []model.Branch - if err := json.NewDecoder(resp.Body).Decode(&branches); err != nil { - return 0, fmt.Errorf("decoding branches: %w", err) - } - - for _, b := range branches { - if b.Name == branch { - return b.HeadSequence, nil - } - } - return 0, fmt.Errorf("branch %q not found", branch) -} - -// RetryChecks requests a retry of CI checks for a branch. -// If checks is empty, all failed checks at the branch's current head sequence are retried. -func (a *App) RetryChecks(branch string, checks []string) error { - cfg, err := a.loadConfig() - if err != nil { - return err - } - if branch == "" { - branch = cfg.Branch - } - seq, err := a.branchHeadSequence(cfg, branch) - if err != nil { - return err - } - req := model.RetryChecksRequest{ - Branch: branch, - Sequence: seq, - Checks: checks, - } - resp, err := a.postJSON(cfg, repoBase(cfg)+"/checks/retry", req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusAccepted { - return a.readError(resp) - } - var retryResp model.RetryChecksResponse - if err := json.NewDecoder(resp.Body).Decode(&retryResp); err != nil { - return fmt.Errorf("decoding response: %w", err) - } - - if len(checks) == 0 { - fmt.Fprintf(a.Out, "Retrying all failed checks on branch '%s' at sequence %d (attempt %d)\n", branch, seq, retryResp.Attempt) - } else { - fmt.Fprintf(a.Out, "Retrying checks %v on branch '%s' at sequence %d (attempt %d)\n", checks, branch, seq, retryResp.Attempt) - } - return nil -} - -// TUI launches the terminal UI reading config from .ds/config.json. -func (a *App) TUI() error { - cfg, err := a.loadConfig() - if err != nil { - return err - } - return tui.Run(a.HTTP, cfg.Remote, cfg.Repo, cfg.Author) -} - -// Resolve resolves a merge/rebase conflict for path. -// It expects .main and .branch to exist on disk (written by Rebase), -// reads the resolved content from itself, commits it to the current branch, -// and removes the conflict files. -func (a *App) Resolve(path string) error { - cfg, err := a.loadConfig() - if err != nil { - return err - } - - mainConflict := filepath.Join(a.Dir, filepath.FromSlash(path+".main")) - branchConflict := filepath.Join(a.Dir, filepath.FromSlash(path+".branch")) - - if _, err := os.Stat(mainConflict); os.IsNotExist(err) { - return fmt.Errorf("no conflict file found: %s.main (run 'ds rebase' first)", path) - } - if _, err := os.Stat(branchConflict); os.IsNotExist(err) { - return fmt.Errorf("no conflict file found: %s.branch (run 'ds rebase' first)", path) - } - - resolvedPath := filepath.Join(a.Dir, filepath.FromSlash(path)) - content, err := os.ReadFile(resolvedPath) - if err != nil { - return fmt.Errorf("reading resolved file %s: %w", path, err) - } - - req := model.CommitRequest{ - Branch: cfg.Branch, - Files: []model.FileChange{{Path: path, Content: content}}, - Message: fmt.Sprintf("resolve conflict in %s", path), - Author: cfg.Author, - } - - resp, err := a.postJSON(cfg, repoBase(cfg)+"/commit", req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusCreated { - return a.readError(resp) - } - - var commitResp model.CommitResponse - if err := json.NewDecoder(resp.Body).Decode(&commitResp); err != nil { - return fmt.Errorf("decoding response: %w", err) - } - - st, err := a.loadState() - if err != nil { - return err - } - st.Sequence = commitResp.Sequence - st.Files[path] = HashBytes(content) - if err := a.saveState(st); err != nil { - return err - } - - // Best-effort cleanup of conflict files; the commit already succeeded. - _ = os.Remove(mainConflict) - _ = os.Remove(branchConflict) - - fmt.Fprintf(a.Out, "resolved %s, committed as sequence %d\n", path, commitResp.Sequence) - return nil -} - -// ImportGit imports a local git repository's default branch into docstore main. -// mode must be "replay" (default) or "squash". -// Shells out to git via os/exec — no new library dependency. -func (a *App) ImportGit(repoPath, mode string) error { - if mode == "" { - mode = "replay" - } - if mode != "replay" && mode != "squash" { - return fmt.Errorf("mode must be 'replay' or 'squash'") - } - - // Verify the path is a git repository. - if _, err := os.Stat(filepath.Join(repoPath, ".git")); os.IsNotExist(err) { - return fmt.Errorf("%s is not a git repository (no .git directory)", repoPath) - } - - // Verify git is available in PATH. - if _, err := exec.LookPath("git"); err != nil { - return fmt.Errorf("git not found in PATH: %w", err) - } - - cfg, err := a.loadConfig() - if err != nil { - return err - } - - if mode == "squash" { - return a.importGitSquash(cfg, repoPath) - } - return a.importGitReplay(cfg, repoPath) -} - -// importGitReplay imports each non-merge git commit as one docstore commit. -func (a *App) importGitReplay(cfg *Config, repoPath string) error { - // %s captures only the subject line; multi-line commit bodies are not imported. - out, err := exec.Command("git", "-C", repoPath, "log", "--reverse", "--no-merges", "--format=%H|%ae|%s", "HEAD").Output() - if err != nil { - return fmt.Errorf("git log failed: %w", err) - } - - raw := strings.TrimRight(string(out), "\n") - if raw == "" { - fmt.Fprintf(a.Out, "No commits to import.\n") - return nil - } - lines := strings.Split(raw, "\n") - total := len(lines) - fmt.Fprintf(a.Out, "Importing %d commits from %s...\n", total, repoPath) - - for i, line := range lines { - parts := strings.SplitN(line, "|", 3) - if len(parts) != 3 { - return fmt.Errorf("unexpected git log output: %q", line) - } - sha, email, subject := parts[0], parts[1], parts[2] - shortSHA := sha - if len(shortSHA) > 7 { - shortSHA = shortSHA[:7] - } - fmt.Fprintf(a.Out, " %d/%d %s %q (%s)\n", i+1, total, shortSHA, subject, email) - - // List files changed in this commit. --root handles the initial commit (no parent). - filesOut, err := exec.Command("git", "-C", repoPath, "diff-tree", "--no-commit-id", "--root", "-r", "--name-status", sha).Output() - if err != nil { - return fmt.Errorf("git diff-tree failed for %s: %w", shortSHA, err) - } - - var changes []model.FileChange - for fline := range strings.SplitSeq(strings.TrimRight(string(filesOut), "\n"), "\n") { - if fline == "" { - continue - } - fp := strings.SplitN(fline, "\t", 2) - if len(fp) != 2 { - continue - } - status, path := fp[0], filepath.ToSlash(fp[1]) - switch status { - case "A", "M": - content, err := exec.Command("git", "-C", repoPath, "show", sha+":"+path).Output() - if err != nil { - return fmt.Errorf("git show failed for %s:%s: %w", shortSHA, path, err) - } - ct := detectContentType(path, content) - changes = append(changes, model.FileChange{Path: path, Content: content, ContentType: ct}) - case "D": - changes = append(changes, model.FileChange{Path: path}) // nil Content = delete - } - } - - if len(changes) == 0 { - continue - } - - msg := fmt.Sprintf("[git-author: %s] %s", email, subject) - req := model.CommitRequest{ - Branch: "main", - Files: changes, - Message: msg, - Author: cfg.Author, - } - - resp, err := a.postJSON(cfg, repoBase(cfg)+"/commit", req) - if err != nil { - return fmt.Errorf("commit failed for %s: %w", shortSHA, err) - } - if resp.StatusCode != http.StatusCreated { - apiErr := a.readError(resp) - resp.Body.Close() - return fmt.Errorf("commit failed for %s: %w", shortSHA, apiErr) - } - resp.Body.Close() - } - - fmt.Fprintf(a.Out, "Done. %d commits imported.\n", total) - return nil -} - -// importGitSquash imports the entire repo at HEAD as a single docstore commit. -func (a *App) importGitSquash(cfg *Config, repoPath string) error { - // Detect default branch name. - branchOut, err := exec.Command("git", "-C", repoPath, "rev-parse", "--abbrev-ref", "HEAD").Output() - if err != nil { - return fmt.Errorf("git rev-parse failed: %w", err) - } - branch := strings.TrimSpace(string(branchOut)) - - // List all files at HEAD. - filesOut, err := exec.Command("git", "-C", repoPath, "ls-tree", "-r", "HEAD", "--name-only").Output() - if err != nil { - return fmt.Errorf("git ls-tree failed: %w", err) - } - - var filePaths []string - for f := range strings.SplitSeq(strings.TrimRight(string(filesOut), "\n"), "\n") { - if f != "" { - filePaths = append(filePaths, f) - } - } - - fmt.Fprintf(a.Out, "Collecting files from HEAD... %d files\n", len(filePaths)) - - // Get short SHA. - shaOut, err := exec.Command("git", "-C", repoPath, "rev-parse", "--short", "HEAD").Output() - if err != nil { - return fmt.Errorf("git rev-parse --short failed: %w", err) - } - shortSHA := strings.TrimSpace(string(shaOut)) - - var changes []model.FileChange - for _, path := range filePaths { - content, err := exec.Command("git", "-C", repoPath, "show", "HEAD:"+path).Output() - if err != nil { - return fmt.Errorf("git show failed for %s: %w", path, err) - } - slashPath := filepath.ToSlash(path) - ct := detectContentType(slashPath, content) - changes = append(changes, model.FileChange{Path: slashPath, Content: content, ContentType: ct}) - } - - fmt.Fprintf(a.Out, "Importing as single commit...\n") - - msg := fmt.Sprintf("[git-import] Squashed import of %s (%d files, HEAD %s)", branch, len(filePaths), shortSHA) - req := model.CommitRequest{ - Branch: "main", - Files: changes, - Message: msg, - Author: cfg.Author, - } - - resp, err := a.postJSON(cfg, repoBase(cfg)+"/commit", req) - if err != nil { - return fmt.Errorf("commit failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusCreated { - return a.readError(resp) - } - - fmt.Fprintf(a.Out, "Done. 1 commit imported (%d files).\n", len(filePaths)) - return nil -} - -// --------------------------------------------------------------------------- -// Org management -// --------------------------------------------------------------------------- - -// Orgs lists all organizations. -func (a *App) Orgs() error { - remote, err := a.loadRemote() - if err != nil { - return err - } - resp, err := a.doGET(remote + "/orgs") - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return a.readError(resp) - } - var r model.ListOrgsResponse - if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { - return fmt.Errorf("decoding response: %w", err) - } - fmt.Fprintf(a.Out, "%-30s %-20s %s\n", "NAME", "CREATED BY", "CREATED AT") - for _, org := range r.Orgs { - fmt.Fprintf(a.Out, "%-30s %-20s %s\n", org.Name, org.CreatedBy, org.CreatedAt.Format("2006-01-02")) - } - return nil -} - -// OrgsCreate creates a new organization. -func (a *App) OrgsCreate(name string) error { - remote, err := a.loadRemote() - if err != nil { - return err - } - resp, err := a.doPOSTJSON(remote+"/orgs", model.CreateOrgRequest{Name: name}) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusCreated { - return a.readError(resp) - } - var org model.Org - if err := json.NewDecoder(resp.Body).Decode(&org); err != nil { - return fmt.Errorf("decoding response: %w", err) - } - fmt.Fprintf(a.Out, "Created org '%s'\n", org.Name) - return nil -} - -// OrgsGet fetches and prints details for a single organization. -func (a *App) OrgsGet(name string) error { - remote, err := a.loadRemote() - if err != nil { - return err - } - resp, err := a.doGET(remote + "/orgs/" + name) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode == http.StatusNotFound { - return fmt.Errorf("org '%s' not found", name) - } - if resp.StatusCode != http.StatusOK { - return a.readError(resp) - } - var org model.Org - if err := json.NewDecoder(resp.Body).Decode(&org); err != nil { - return fmt.Errorf("decoding response: %w", err) - } - fmt.Fprintf(a.Out, "Name: %s\n", org.Name) - fmt.Fprintf(a.Out, "Created by: %s\n", org.CreatedBy) - fmt.Fprintf(a.Out, "Created at: %s\n", org.CreatedAt.Format("2006-01-02")) - return nil -} - -// OrgsDelete deletes an organization (fails if it still has repos). -func (a *App) OrgsDelete(name string) error { - remote, err := a.loadRemote() - if err != nil { - return err - } - resp, err := a.doDELETE(remote + "/orgs/" + name) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode == http.StatusConflict { - return fmt.Errorf("org '%s' still has repos", name) - } - if resp.StatusCode != http.StatusNoContent { - return a.readError(resp) - } - fmt.Fprintf(a.Out, "Deleted org '%s'\n", name) - return nil -} - -// OrgsRepos lists repositories within an organization. -func (a *App) OrgsRepos(orgName string) error { - remote, err := a.loadRemote() - if err != nil { - return err - } - resp, err := a.doGET(remote + "/orgs/" + orgName + "/repos") - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return a.readError(resp) - } - var r model.ReposResponse - if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { - return fmt.Errorf("decoding response: %w", err) - } - fmt.Fprintf(a.Out, "%-30s %-20s %-20s %s\n", "NAME", "OWNER", "CREATED BY", "CREATED AT") - for _, repo := range r.Repos { - fmt.Fprintf(a.Out, "%-30s %-20s %-20s %s\n", repo.Name, repo.Owner, repo.CreatedBy, repo.CreatedAt.Format("2006-01-02")) - } - return nil -} - -// --------------------------------------------------------------------------- -// Repo management -// --------------------------------------------------------------------------- - -// Repos lists all repositories. -func (a *App) Repos() error { - remote, err := a.loadRemote() - if err != nil { - return err - } - resp, err := a.doGET(remote + "/repos") - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return a.readError(resp) - } - var r model.ReposResponse - if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { - return fmt.Errorf("decoding response: %w", err) - } - fmt.Fprintf(a.Out, "%-30s %-20s %-20s %s\n", "NAME", "OWNER", "CREATED BY", "CREATED AT") - for _, repo := range r.Repos { - fmt.Fprintf(a.Out, "%-30s %-20s %-20s %s\n", repo.Name, repo.Owner, repo.CreatedBy, repo.CreatedAt.Format("2006-01-02")) - } - return nil -} - -// ReposCreate creates a new repository under the given owner organization. -func (a *App) ReposCreate(owner, name string) error { - remote, err := a.loadRemote() - if err != nil { - return err - } - resp, err := a.doPOSTJSON(remote+"/repos", model.CreateRepoRequest{Owner: owner, Name: name}) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode == http.StatusNotFound { - return fmt.Errorf("org '%s' not found", owner) - } - if resp.StatusCode != http.StatusCreated { - return a.readError(resp) - } - var repo model.Repo - if err := json.NewDecoder(resp.Body).Decode(&repo); err != nil { - return fmt.Errorf("decoding response: %w", err) - } - fmt.Fprintf(a.Out, "Created repo '%s'\n", repo.Name) - return nil -} - -// ReposDelete deletes a repository by full name (e.g., "acme/myrepo"). -func (a *App) ReposDelete(name string) error { - remote, err := a.loadRemote() - if err != nil { - return err - } - resp, err := a.doDELETE(remote + "/repos/" + name + "/-/") - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusNoContent { - return a.readError(resp) - } - fmt.Fprintf(a.Out, "Deleted repo '%s'\n", name) - return nil -} - -// RepoGet gets a single repository by full name (e.g., "acme/myrepo"). -func (a *App) RepoGet(fullName string) error { - remote, err := a.loadRemote() - if err != nil { - return err - } - resp, err := a.doGET(remote + "/repos/" + fullName) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode == http.StatusNotFound { - return fmt.Errorf("repo '%s' not found", fullName) - } - if resp.StatusCode != http.StatusOK { - return a.readError(resp) - } - var repo model.Repo - if err := json.NewDecoder(resp.Body).Decode(&repo); err != nil { - return fmt.Errorf("decoding response: %w", err) - } - fmt.Fprintf(a.Out, "%-30s %-20s %-20s %s\n", "NAME", "OWNER", "CREATED BY", "CREATED AT") - fmt.Fprintf(a.Out, "%-30s %-20s %-20s %s\n", repo.Name, repo.Owner, repo.CreatedBy, repo.CreatedAt.Format("2006-01-02")) - return nil -} - -// --------------------------------------------------------------------------- -// Branch management (CLI-level) -// --------------------------------------------------------------------------- - -// BranchDelete deletes a branch from the current repo by name. -func (a *App) BranchDelete(branch string) error { - cfg, err := a.loadConfig() - if err != nil { - return err - } - resp, err := a.doDELETE(repoBase(cfg) + "/branch/" + url.PathEscape(branch)) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode == http.StatusNotFound { - return fmt.Errorf("branch '%s' not found", branch) - } - if resp.StatusCode == http.StatusConflict { - return fmt.Errorf("branch '%s' is already merged or abandoned", branch) - } - if resp.StatusCode != http.StatusNoContent { - return a.readError(resp) - } - fmt.Fprintf(a.Out, "Deleted branch '%s'\n", branch) - return nil -} - -// --------------------------------------------------------------------------- -// Purge -// --------------------------------------------------------------------------- - -// Purge removes merged/abandoned branches and their unreachable data from the -// current repo. olderThan is a duration string like "30d". If dryRun is true -// the server reports what would be deleted without deleting anything. -func (a *App) Purge(olderThan string, dryRun bool) error { - cfg, err := a.loadConfig() - if err != nil { - return err - } - req := model.PurgeRequest{OlderThan: olderThan, DryRun: dryRun} - resp, err := a.postJSON(cfg, repoBase(cfg)+"/purge", req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode == http.StatusNotFound { - return fmt.Errorf("repo '%s' not found", cfg.Repo) - } - if resp.StatusCode != http.StatusOK { - return a.readError(resp) - } - var result model.PurgeResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return fmt.Errorf("decoding response: %w", err) - } - if dryRun { - fmt.Fprintf(a.Out, "[dry-run] would purge:\n") - } - fmt.Fprintf(a.Out, " branches purged: %d\n", result.BranchesPurged) - fmt.Fprintf(a.Out, " file commits deleted: %d\n", result.FileCommitsDeleted) - fmt.Fprintf(a.Out, " commits deleted: %d\n", result.CommitsDeleted) - fmt.Fprintf(a.Out, " documents deleted: %d\n", result.DocumentsDeleted) - fmt.Fprintf(a.Out, " reviews deleted: %d\n", result.ReviewsDeleted) - fmt.Fprintf(a.Out, " check runs deleted: %d\n", result.CheckRunsDeleted) - return nil -} - -// --------------------------------------------------------------------------- -// Org membership management -// --------------------------------------------------------------------------- - -// OrgMembersAdd adds or updates a member in an org with the given role. -func (a *App) OrgMembersAdd(org, identity, role string) error { - switch model.OrgRole(role) { - case model.OrgRoleOwner, model.OrgRoleMember: - default: - return fmt.Errorf("role must be 'owner' or 'member'") - } - remote, err := a.loadRemote() - if err != nil { - return err - } - resp, err := a.doPOSTJSON(remote+"/orgs/"+org+"/members/"+identity, model.AddOrgMemberRequest{Role: model.OrgRole(role)}) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { - return a.readError(resp) - } - fmt.Fprintf(a.Out, "Added '%s' to org '%s' as '%s'\n", identity, org, role) - return nil -} - -// OrgMembersRemove removes a member from an org. -func (a *App) OrgMembersRemove(org, identity string) error { - remote, err := a.loadRemote() - if err != nil { - return err - } - resp, err := a.doDELETE(remote + "/orgs/" + org + "/members/" + identity) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusNoContent { - return a.readError(resp) - } - fmt.Fprintf(a.Out, "Removed '%s' from org '%s'\n", identity, org) - return nil -} - -// OrgMembersList lists all members of an org. -func (a *App) OrgMembersList(org string) error { - remote, err := a.loadRemote() - if err != nil { - return err - } - resp, err := a.doGET(remote + "/orgs/" + org + "/members") - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return a.readError(resp) - } - var r model.OrgMembersResponse - if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { - return fmt.Errorf("decoding response: %w", err) - } - fmt.Fprintf(a.Out, "%-30s %s\n", "IDENTITY", "ROLE") - for _, m := range r.Members { - fmt.Fprintf(a.Out, "%-30s %s\n", m.Identity, string(m.Role)) - } - return nil -} - -// --------------------------------------------------------------------------- -// Org invite management -// --------------------------------------------------------------------------- - -// OrgInvitesCreate creates an invite for email with the given role and prints the token. -func (a *App) OrgInvitesCreate(org, email, role string) error { - switch model.OrgRole(role) { - case model.OrgRoleOwner, model.OrgRoleMember: - default: - return fmt.Errorf("role must be 'owner' or 'member'") - } - remote, err := a.loadRemote() - if err != nil { - return err - } - resp, err := a.doPOSTJSON(remote+"/orgs/"+org+"/invites", model.CreateInviteRequest{Email: email, Role: model.OrgRole(role)}) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { - return a.readError(resp) - } - var r model.CreateInviteResponse - if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { - return fmt.Errorf("decoding response: %w", err) - } - fmt.Fprintf(a.Out, "Invite created: id=%s token=%s\n", r.ID, r.Token) - return nil -} - -// OrgInvitesList lists all pending invites for an org. -func (a *App) OrgInvitesList(org string) error { - remote, err := a.loadRemote() - if err != nil { - return err - } - resp, err := a.doGET(remote + "/orgs/" + org + "/invites") - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return a.readError(resp) - } - var r model.OrgInvitesResponse - if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { - return fmt.Errorf("decoding response: %w", err) - } - fmt.Fprintf(a.Out, "%-36s %-30s %s\n", "ID", "EMAIL", "ROLE") - for _, inv := range r.Invites { - fmt.Fprintf(a.Out, "%-36s %-30s %s\n", inv.ID, inv.Email, string(inv.Role)) - } - return nil -} - -// OrgInvitesAccept accepts an invite using a token. -func (a *App) OrgInvitesAccept(org, token string) error { - remote, err := a.loadRemote() - if err != nil { - return err - } - resp, err := a.doPOSTJSON(remote+"/orgs/"+org+"/invites/"+token+"/accept", struct{}{}) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { - return a.readError(resp) - } - fmt.Fprintf(a.Out, "Accepted invite for org '%s'\n", org) - return nil -} - -// OrgInvitesRevoke revokes a pending invite by ID. -func (a *App) OrgInvitesRevoke(org, inviteID string) error { - remote, err := a.loadRemote() - if err != nil { - return err - } - resp, err := a.doDELETE(remote + "/orgs/" + org + "/invites/" + inviteID) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusNoContent { - return a.readError(resp) - } - fmt.Fprintf(a.Out, "Revoked invite '%s' from org '%s'\n", inviteID, org) - return nil -} - -// --------------------------------------------------------------------------- -// Role management -// --------------------------------------------------------------------------- - -// Roles lists roles for the current repository. -func (a *App) Roles() error { - cfg, err := a.loadConfig() - if err != nil { - return err - } - resp, err := a.doGET(repoBase(cfg) + "/roles") - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return a.readError(resp) - } - var r model.RolesResponse - if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { - return fmt.Errorf("decoding response: %w", err) - } - fmt.Fprintf(a.Out, "%-30s %s\n", "IDENTITY", "ROLE") - for _, role := range r.Roles { - fmt.Fprintf(a.Out, "%-30s %s\n", role.Identity, string(role.Role)) - } - return nil -} - -// RolesSet grants or updates a role for an identity on the current repository. -func (a *App) RolesSet(identity, role string) error { - switch model.RoleType(role) { - case model.RoleReader, model.RoleWriter, model.RoleMaintainer, model.RoleAdmin: - default: - return fmt.Errorf("role must be one of: reader, writer, maintainer, admin") - } - cfg, err := a.loadConfig() - if err != nil { - return err - } - resp, err := a.doPUTJSON(repoBase(cfg)+"/roles/"+identity, model.SetRoleRequest{Role: model.RoleType(role)}) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return a.readError(resp) - } - fmt.Fprintf(a.Out, "Set role '%s' for '%s'\n", role, identity) - return nil -} - -// RolesDelete removes a role assignment for an identity on the current repository. -func (a *App) RolesDelete(identity string) error { - cfg, err := a.loadConfig() - if err != nil { - return err - } - resp, err := a.doDELETE(repoBase(cfg) + "/roles/" + identity) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusNoContent { - return a.readError(resp) - } - fmt.Fprintf(a.Out, "Deleted role for '%s'\n", identity) - return nil -} - -// --------------------------------------------------------------------------- -// Release management -// --------------------------------------------------------------------------- - -// releaseEntry is a local type for decoding release API responses. -type releaseEntry struct { - ID string `json:"id"` - Repo string `json:"repo"` - Name string `json:"name"` - Sequence int64 `json:"sequence"` - Body string `json:"body,omitempty"` - CreatedBy string `json:"created_by"` - CreatedAt time.Time `json:"created_at"` -} - -type listReleasesResponse struct { - Releases []releaseEntry `json:"releases"` -} - -// ReleaseCreate creates a named release. If sequence is 0, the server defaults -// to the current main head sequence. -func (a *App) ReleaseCreate(name string, sequence int64, notes string) error { - cfg, err := a.loadConfig() - if err != nil { - return err - } - - body := map[string]any{ - "name": name, - } - if sequence != 0 { - body["sequence"] = sequence - } - if notes != "" { - body["body"] = notes - } - - resp, err := a.postJSON(cfg, repoBase(cfg)+"/releases", body) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusCreated { - return a.readError(resp) - } - var rel releaseEntry - if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { - return fmt.Errorf("decoding response: %w", err) - } - fmt.Fprintf(a.Out, "Created release '%s' at sequence %d\n", rel.Name, rel.Sequence) - return nil -} - -// ReleaseList lists all releases for the current repository. -func (a *App) ReleaseList() error { - cfg, err := a.loadConfig() - if err != nil { - return err - } - resp, err := a.httpGet(cfg, repoBase(cfg)+"/releases") - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return a.readError(resp) - } - var r listReleasesResponse - if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { - return fmt.Errorf("decoding response: %w", err) - } - fmt.Fprintf(a.Out, "%-20s %-10s %-20s %s\n", "NAME", "SEQUENCE", "CREATED BY", "CREATED AT") - for _, rel := range r.Releases { - fmt.Fprintf(a.Out, "%-20s %-10d %-20s %s\n", rel.Name, rel.Sequence, rel.CreatedBy, rel.CreatedAt.Format("2006-01-02")) - } - return nil -} - -// ReleaseShow prints release metadata and then shows the tree at that release's sequence. -func (a *App) ReleaseShow(name string) error { - cfg, err := a.loadConfig() - if err != nil { - return err - } - resp, err := a.httpGet(cfg, repoBase(cfg)+"/releases/"+name) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode == http.StatusNotFound { - return fmt.Errorf("release '%s' not found", name) - } - if resp.StatusCode != http.StatusOK { - return a.readError(resp) - } - var rel releaseEntry - if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { - return fmt.Errorf("decoding response: %w", err) - } - fmt.Fprintf(a.Out, "Name: %s\n", rel.Name) - fmt.Fprintf(a.Out, "Sequence: %d\n", rel.Sequence) - fmt.Fprintf(a.Out, "Created by: %s\n", rel.CreatedBy) - fmt.Fprintf(a.Out, "Created at: %s\n", rel.CreatedAt.Format("2006-01-02 15:04:05")) - if rel.Body != "" { - fmt.Fprintf(a.Out, "Notes:\n%s\n", rel.Body) - } - - // Show the tree at the release sequence. - q := url.Values{} - q.Set("at", fmt.Sprintf("%d", rel.Sequence)) - treeResp, err := a.httpGet(cfg, repoBase(cfg)+"/tree?"+q.Encode()) - if err != nil { - return err - } - defer treeResp.Body.Close() - if treeResp.StatusCode != http.StatusOK { - return a.readError(treeResp) - } - - type treeEntry struct { - Path string `json:"path"` - VersionID string `json:"version_id"` - ContentHash string `json:"content_hash"` - } - var entries []treeEntry - if err := json.NewDecoder(treeResp.Body).Decode(&entries); err != nil { - return fmt.Errorf("decoding tree: %w", err) - } - fmt.Fprintf(a.Out, "\nTree at sequence %d:\n", rel.Sequence) - for _, e := range entries { - fmt.Fprintf(a.Out, " %s\n", e.Path) - } - return nil -} - -// ReleaseDelete deletes a named release (admin only). -func (a *App) ReleaseDelete(name string) error { - cfg, err := a.loadConfig() - if err != nil { - return err - } - resp, err := a.doDELETE(repoBase(cfg) + "/releases/" + name) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode == http.StatusNotFound { - return fmt.Errorf("release '%s' not found", name) - } - if resp.StatusCode != http.StatusNoContent { - return a.readError(resp) - } - fmt.Fprintf(a.Out, "Deleted release '%s'\n", name) - return nil -} - -// ProposalOpen creates a new proposal for a branch. -// If branch is empty, the current workspace branch is used. -// BaseBranch defaults to "main" if empty. -func (a *App) ProposalOpen(branch, baseBranch, title, description string) error { - cfg, err := a.loadConfig() - if err != nil { - return err - } - if branch == "" { - branch = cfg.Branch - } - if baseBranch == "" { - baseBranch = "main" - } - if title == "" { - return fmt.Errorf("--title is required") - } - - req := model.CreateProposalRequest{ - Branch: branch, - BaseBranch: baseBranch, - Title: title, - Description: description, - } - resp, err := a.postJSON(cfg, repoBase(cfg)+"/proposals", req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { - return a.readError(resp) - } - - var proposalResp model.CreateProposalResponse - if err := json.NewDecoder(resp.Body).Decode(&proposalResp); err != nil { - return fmt.Errorf("decoding response: %w", err) - } - - fmt.Fprintf(a.Out, "Proposal opened: %s\n", proposalResp.ID) - return nil -} - -// ProposalList lists proposals for the repo. -// state defaults to "open" if empty. -func (a *App) ProposalList(state string) error { - cfg, err := a.loadConfig() - if err != nil { - return err - } - if state == "" { - state = "open" - } - - q := url.Values{} - q.Set("state", state) - resp, err := a.httpGet(cfg, repoBase(cfg)+"/proposals?"+q.Encode()) - if err != nil { - return fmt.Errorf("fetching proposals: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return a.readError(resp) - } - - var proposals []model.Proposal - if err := json.NewDecoder(resp.Body).Decode(&proposals); err != nil { - return fmt.Errorf("decoding proposals: %w", err) - } - - if len(proposals) == 0 { - fmt.Fprintf(a.Out, "No %s proposals\n", state) - return nil - } - - fmt.Fprintf(a.Out, "%-36s %-30s %-40s %-30s %-8s %s\n", - "ID", "BRANCH", "TITLE", "AUTHOR", "STATE", "CREATED") - for _, p := range proposals { - title := p.Title - if len(title) > 38 { - title = title[:37] + "…" - } - fmt.Fprintf(a.Out, "%-36s %-30s %-40s %-30s %-8s %s\n", - p.ID, p.Branch, title, p.Author, string(p.State), - p.CreatedAt.Format(time.RFC3339)) - } - return nil -} - -// ProposalClose closes an open proposal. -func (a *App) ProposalClose(proposalID string) error { - cfg, err := a.loadConfig() - if err != nil { - return err - } - - resp, err := a.postJSON(cfg, repoBase(cfg)+"/proposals/"+url.PathEscape(proposalID)+"/close", nil) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { - return a.readError(resp) - } - - fmt.Fprintf(a.Out, "Proposal %s closed\n", proposalID) - return nil -} - -// --------------------------------------------------------------------------- -// Subscription management -// --------------------------------------------------------------------------- - -// SubscriptionCreate creates a new webhook subscription. -func (a *App) SubscriptionCreate(webhookURL, secret string, repo *string, eventTypes []string) error { - remote, err := a.loadRemote() - if err != nil { - return err - } - webhookConfig, err := json.Marshal(map[string]string{"url": webhookURL, "secret": secret}) - if err != nil { - return fmt.Errorf("encoding webhook config: %w", err) - } - req := model.CreateSubscriptionRequest{ - Repo: repo, - EventTypes: eventTypes, - Backend: "webhook", - Config: json.RawMessage(webhookConfig), - } - resp, err := a.doPOSTJSON(remote+"/subscriptions", req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { - return a.readError(resp) - } - var sub model.EventSubscription - if err := json.NewDecoder(resp.Body).Decode(&sub); err != nil { - return fmt.Errorf("decoding response: %w", err) - } - fmt.Fprintf(a.Out, "Created subscription '%s'\n", sub.ID) - return nil -} - -// SubscriptionList lists all webhook subscriptions. -func (a *App) SubscriptionList() error { - remote, err := a.loadRemote() - if err != nil { - return err - } - resp, err := a.doGET(remote + "/subscriptions") - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return a.readError(resp) - } - var r model.ListSubscriptionsResponse - if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { - return fmt.Errorf("decoding response: %w", err) - } - fmt.Fprintf(a.Out, "%-36s %-20s %-10s %s\n", "ID", "REPO", "BACKEND", "SUSPENDED") - for _, sub := range r.Subscriptions { - repo := "(all)" - if sub.Repo != nil { - repo = *sub.Repo - } - suspended := "no" - if sub.SuspendedAt != nil { - suspended = sub.SuspendedAt.Format("2006-01-02") - } - fmt.Fprintf(a.Out, "%-36s %-20s %-10s %s\n", sub.ID, repo, sub.Backend, suspended) - } - return nil -} - -// SubscriptionDelete deletes a subscription by ID. -func (a *App) SubscriptionDelete(id string) error { - remote, err := a.loadRemote() - if err != nil { - return err - } - resp, err := a.doDELETE(remote + "/subscriptions/" + url.PathEscape(id)) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { - return a.readError(resp) - } - fmt.Fprintf(a.Out, "Deleted subscription '%s'\n", id) - return nil -} - -// SubscriptionResume resumes a suspended subscription by ID. -func (a *App) SubscriptionResume(id string) error { - remote, err := a.loadRemote() - if err != nil { - return err - } - resp, err := a.doPOSTJSON(remote+"/subscriptions/"+url.PathEscape(id)+"/resume", struct{}{}) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusNoContent { - return a.readError(resp) - } - fmt.Fprintf(a.Out, "Resumed subscription '%s'\n", id) - return nil -} - -// --------------------------------------------------------------------------- -// Issue management -// --------------------------------------------------------------------------- - -// IssueList lists issues for the repo. -// state defaults to "open" if empty. -func (a *App) IssueList(state, author string) error { - cfg, err := a.loadConfig() - if err != nil { - return err - } - if state == "" { - state = "open" - } - q := url.Values{} - q.Set("state", state) - if author != "" { - q.Set("author", author) - } - resp, err := a.httpGet(cfg, repoBase(cfg)+"/issues?"+q.Encode()) - if err != nil { - return fmt.Errorf("fetching issues: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return a.readError(resp) - } - var r model.ListIssuesResponse - if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { - return fmt.Errorf("decoding response: %w", err) - } - if len(r.Issues) == 0 { - fmt.Fprintf(a.Out, "No %s issues\n", state) - return nil - } - fmt.Fprintf(a.Out, "%-6s %-40s %-30s %-8s %s\n", "NUMBER", "TITLE", "AUTHOR", "STATE", "CREATED") - for _, iss := range r.Issues { - title := iss.Title - if len(title) > 38 { - title = title[:37] + "…" - } - fmt.Fprintf(a.Out, "%-6d %-40s %-30s %-8s %s\n", - iss.Number, title, iss.Author, string(iss.State), - iss.CreatedAt.Format(time.RFC3339)) - } - return nil -} - -// IssueCreate creates a new issue. -func (a *App) IssueCreate(title, body string) error { - cfg, err := a.loadConfig() - if err != nil { - return err - } - if title == "" { - return fmt.Errorf("--title is required") - } - req := model.CreateIssueRequest{ - Title: title, - Body: body, - } - resp, err := a.postJSON(cfg, repoBase(cfg)+"/issues", req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { - return a.readError(resp) - } - var r model.CreateIssueResponse - if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { - return fmt.Errorf("decoding response: %w", err) - } - fmt.Fprintf(a.Out, "Created issue #%d\n", r.Number) - return nil -} - -// IssueShow shows details for a single issue, including comments and refs. -func (a *App) IssueShow(number int64) error { - cfg, err := a.loadConfig() - if err != nil { - return err - } - base := repoBase(cfg) + "/issues/" + strconv.FormatInt(number, 10) - - resp, err := a.httpGet(cfg, base) - if err != nil { - return fmt.Errorf("fetching issue: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return a.readError(resp) - } - var iss model.Issue - if err := json.NewDecoder(resp.Body).Decode(&iss); err != nil { - return fmt.Errorf("decoding response: %w", err) - } - - fmt.Fprintf(a.Out, "Issue #%d: %s\n", iss.Number, iss.Title) - fmt.Fprintf(a.Out, "State: %s\n", string(iss.State)) - fmt.Fprintf(a.Out, "Author: %s\n", iss.Author) - fmt.Fprintf(a.Out, "Created: %s\n", iss.CreatedAt.Format(time.RFC3339)) - if iss.CloseReason != nil { - fmt.Fprintf(a.Out, "Closed: %s\n", string(*iss.CloseReason)) - } - if iss.Body != "" { - fmt.Fprintf(a.Out, "\n%s\n", iss.Body) - } - - resp2, err := a.httpGet(cfg, base+"/comments") - if err != nil { - return fmt.Errorf("fetching comments: %w", err) - } - defer resp2.Body.Close() - if resp2.StatusCode == http.StatusOK { - var cr model.ListIssueCommentsResponse - if err := json.NewDecoder(resp2.Body).Decode(&cr); err == nil && len(cr.Comments) > 0 { - fmt.Fprintf(a.Out, "\nComments (%d):\n", len(cr.Comments)) - for _, c := range cr.Comments { - fmt.Fprintf(a.Out, " [%s] %s: %s\n", c.CreatedAt.Format("2006-01-02"), c.Author, c.Body) - } - } - } - - resp3, err := a.httpGet(cfg, base+"/refs") - if err != nil { - return fmt.Errorf("fetching refs: %w", err) - } - defer resp3.Body.Close() - if resp3.StatusCode == http.StatusOK { - var rr model.ListIssueRefsResponse - if err := json.NewDecoder(resp3.Body).Decode(&rr); err == nil && len(rr.Refs) > 0 { - fmt.Fprintf(a.Out, "\nRefs (%d):\n", len(rr.Refs)) - for _, ref := range rr.Refs { - fmt.Fprintf(a.Out, " %s: %s\n", string(ref.RefType), ref.RefID) - } - } - } - - return nil -} - -// IssueClose closes an issue. -func (a *App) IssueClose(number int64, reason string) error { - cfg, err := a.loadConfig() - if err != nil { - return err - } - if reason == "" { - reason = string(model.IssueCloseReasonCompleted) - } - req := model.CloseIssueRequest{Reason: model.IssueCloseReason(reason)} - resp, err := a.postJSON(cfg, repoBase(cfg)+"/issues/"+strconv.FormatInt(number, 10)+"/close", req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return a.readError(resp) - } - fmt.Fprintf(a.Out, "Closed issue #%d\n", number) - return nil -} - -// IssueReopen reopens a closed issue. -func (a *App) IssueReopen(number int64) error { - cfg, err := a.loadConfig() - if err != nil { - return err - } - resp, err := a.postJSON(cfg, repoBase(cfg)+"/issues/"+strconv.FormatInt(number, 10)+"/reopen", model.ReopenIssueRequest{}) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return a.readError(resp) - } - fmt.Fprintf(a.Out, "Reopened issue #%d\n", number) - return nil -} - -// IssueCommentAdd adds a comment to an issue. -func (a *App) IssueCommentAdd(number int64, body string) error { - cfg, err := a.loadConfig() - if err != nil { - return err - } - if body == "" { - return fmt.Errorf("--body is required") - } - req := model.CreateIssueCommentRequest{Body: body} - resp, err := a.postJSON(cfg, repoBase(cfg)+"/issues/"+strconv.FormatInt(number, 10)+"/comments", req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { - return a.readError(resp) - } - var r model.CreateIssueCommentResponse - if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { - return fmt.Errorf("decoding response: %w", err) - } - fmt.Fprintf(a.Out, "Added comment %s\n", r.ID) - return nil -} - -// IssueCommentEdit edits an existing issue comment. -// issueNumber is required to construct the URL path. -func (a *App) IssueCommentEdit(issueNumber int64, commentID, body string) error { - cfg, err := a.loadConfig() - if err != nil { - return err - } - if body == "" { - return fmt.Errorf("--body is required") - } - req := model.UpdateIssueCommentRequest{Body: body} - urlPath := repoBase(cfg) + "/issues/" + strconv.FormatInt(issueNumber, 10) + "/comments/" + url.PathEscape(commentID) - resp, err := a.patchJSON(cfg, urlPath, req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return a.readError(resp) - } - fmt.Fprintf(a.Out, "Updated comment %s\n", commentID) - return nil -} - -// IssueRefs lists cross-references for an issue. -func (a *App) IssueRefs(number int64) error { - cfg, err := a.loadConfig() - if err != nil { - return err - } - resp, err := a.httpGet(cfg, repoBase(cfg)+"/issues/"+strconv.FormatInt(number, 10)+"/refs") - if err != nil { - return fmt.Errorf("fetching refs: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return a.readError(resp) - } - var r model.ListIssueRefsResponse - if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { - return fmt.Errorf("decoding response: %w", err) - } - if len(r.Refs) == 0 { - fmt.Fprintf(a.Out, "No refs for issue #%d\n", number) - return nil - } - fmt.Fprintf(a.Out, "%-10s %-36s %s\n", "TYPE", "REF_ID", "CREATED") - for _, ref := range r.Refs { - fmt.Fprintf(a.Out, "%-10s %-36s %s\n", - string(ref.RefType), ref.RefID, ref.CreatedAt.Format(time.RFC3339)) - } - return nil -} - -// IssueTie ties a proposal or commit ref to an issue. -func (a *App) IssueTie(number int64, refType, refID string) error { - cfg, err := a.loadConfig() - if err != nil { - return err - } - req := model.AddIssueRefRequest{ - RefType: model.IssueRefType(refType), - RefID: refID, - } - resp, err := a.postJSON(cfg, repoBase(cfg)+"/issues/"+strconv.FormatInt(number, 10)+"/refs", req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { - return a.readError(resp) - } - fmt.Fprintf(a.Out, "Tied %s %s to issue #%d\n", refType, refID, number) - return nil -} // --------------------------------------------------------------------------- // Repo-level secrets diff --git a/internal/cli/git_import.go b/internal/cli/git_import.go new file mode 100644 index 0000000..4aa65a7 --- /dev/null +++ b/internal/cli/git_import.go @@ -0,0 +1,196 @@ +package cli + +import ( + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "github.com/dlorenc/docstore/internal/model" +) + + +// ImportGit imports a local git repository's default branch into docstore main. +// mode must be "replay" (default) or "squash". +// Shells out to git via os/exec — no new library dependency. +func (a *App) ImportGit(repoPath, mode string) error { + if mode == "" { + mode = "replay" + } + if mode != "replay" && mode != "squash" { + return fmt.Errorf("mode must be 'replay' or 'squash'") + } + + // Verify the path is a git repository. + if _, err := os.Stat(filepath.Join(repoPath, ".git")); os.IsNotExist(err) { + return fmt.Errorf("%s is not a git repository (no .git directory)", repoPath) + } + + // Verify git is available in PATH. + if _, err := exec.LookPath("git"); err != nil { + return fmt.Errorf("git not found in PATH: %w", err) + } + + cfg, err := a.loadConfig() + if err != nil { + return err + } + + if mode == "squash" { + return a.importGitSquash(cfg, repoPath) + } + return a.importGitReplay(cfg, repoPath) +} + +// importGitReplay imports each non-merge git commit as one docstore commit. +func (a *App) importGitReplay(cfg *Config, repoPath string) error { + // %s captures only the subject line; multi-line commit bodies are not imported. + out, err := exec.Command("git", "-C", repoPath, "log", "--reverse", "--no-merges", "--format=%H|%ae|%s", "HEAD").Output() + if err != nil { + return fmt.Errorf("git log failed: %w", err) + } + + raw := strings.TrimRight(string(out), "\n") + if raw == "" { + fmt.Fprintf(a.Out, "No commits to import.\n") + return nil + } + lines := strings.Split(raw, "\n") + total := len(lines) + fmt.Fprintf(a.Out, "Importing %d commits from %s...\n", total, repoPath) + + for i, line := range lines { + parts := strings.SplitN(line, "|", 3) + if len(parts) != 3 { + return fmt.Errorf("unexpected git log output: %q", line) + } + sha, email, subject := parts[0], parts[1], parts[2] + shortSHA := sha + if len(shortSHA) > 7 { + shortSHA = shortSHA[:7] + } + fmt.Fprintf(a.Out, " %d/%d %s %q (%s)\n", i+1, total, shortSHA, subject, email) + + // List files changed in this commit. --root handles the initial commit (no parent). + filesOut, err := exec.Command("git", "-C", repoPath, "diff-tree", "--no-commit-id", "--root", "-r", "--name-status", sha).Output() + if err != nil { + return fmt.Errorf("git diff-tree failed for %s: %w", shortSHA, err) + } + + var changes []model.FileChange + for fline := range strings.SplitSeq(strings.TrimRight(string(filesOut), "\n"), "\n") { + if fline == "" { + continue + } + fp := strings.SplitN(fline, "\t", 2) + if len(fp) != 2 { + continue + } + status, path := fp[0], filepath.ToSlash(fp[1]) + switch status { + case "A", "M": + content, err := exec.Command("git", "-C", repoPath, "show", sha+":"+path).Output() + if err != nil { + return fmt.Errorf("git show failed for %s:%s: %w", shortSHA, path, err) + } + ct := detectContentType(path, content) + changes = append(changes, model.FileChange{Path: path, Content: content, ContentType: ct}) + case "D": + changes = append(changes, model.FileChange{Path: path}) // nil Content = delete + } + } + + if len(changes) == 0 { + continue + } + + msg := fmt.Sprintf("[git-author: %s] %s", email, subject) + req := model.CommitRequest{ + Branch: "main", + Files: changes, + Message: msg, + Author: cfg.Author, + } + + resp, err := a.postJSON(cfg, repoBase(cfg)+"/commit", req) + if err != nil { + return fmt.Errorf("commit failed for %s: %w", shortSHA, err) + } + if resp.StatusCode != http.StatusCreated { + apiErr := a.readError(resp) + resp.Body.Close() + return fmt.Errorf("commit failed for %s: %w", shortSHA, apiErr) + } + resp.Body.Close() + } + + fmt.Fprintf(a.Out, "Done. %d commits imported.\n", total) + return nil +} + +// importGitSquash imports the entire repo at HEAD as a single docstore commit. +func (a *App) importGitSquash(cfg *Config, repoPath string) error { + // Detect default branch name. + branchOut, err := exec.Command("git", "-C", repoPath, "rev-parse", "--abbrev-ref", "HEAD").Output() + if err != nil { + return fmt.Errorf("git rev-parse failed: %w", err) + } + branch := strings.TrimSpace(string(branchOut)) + + // List all files at HEAD. + filesOut, err := exec.Command("git", "-C", repoPath, "ls-tree", "-r", "HEAD", "--name-only").Output() + if err != nil { + return fmt.Errorf("git ls-tree failed: %w", err) + } + + var filePaths []string + for f := range strings.SplitSeq(strings.TrimRight(string(filesOut), "\n"), "\n") { + if f != "" { + filePaths = append(filePaths, f) + } + } + + fmt.Fprintf(a.Out, "Collecting files from HEAD... %d files\n", len(filePaths)) + + // Get short SHA. + shaOut, err := exec.Command("git", "-C", repoPath, "rev-parse", "--short", "HEAD").Output() + if err != nil { + return fmt.Errorf("git rev-parse --short failed: %w", err) + } + shortSHA := strings.TrimSpace(string(shaOut)) + + var changes []model.FileChange + for _, path := range filePaths { + content, err := exec.Command("git", "-C", repoPath, "show", "HEAD:"+path).Output() + if err != nil { + return fmt.Errorf("git show failed for %s: %w", path, err) + } + slashPath := filepath.ToSlash(path) + ct := detectContentType(slashPath, content) + changes = append(changes, model.FileChange{Path: slashPath, Content: content, ContentType: ct}) + } + + fmt.Fprintf(a.Out, "Importing as single commit...\n") + + msg := fmt.Sprintf("[git-import] Squashed import of %s (%d files, HEAD %s)", branch, len(filePaths), shortSHA) + req := model.CommitRequest{ + Branch: "main", + Files: changes, + Message: msg, + Author: cfg.Author, + } + + resp, err := a.postJSON(cfg, repoBase(cfg)+"/commit", req) + if err != nil { + return fmt.Errorf("commit failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + return a.readError(resp) + } + + fmt.Fprintf(a.Out, "Done. 1 commit imported (%d files).\n", len(filePaths)) + return nil +} \ No newline at end of file diff --git a/internal/cli/issues.go b/internal/cli/issues.go new file mode 100644 index 0000000..50a1a5f --- /dev/null +++ b/internal/cli/issues.go @@ -0,0 +1,295 @@ +package cli + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "time" + "github.com/dlorenc/docstore/internal/model" +) + +// --------------------------------------------------------------------------- +// Issue management +// --------------------------------------------------------------------------- + +// IssueList lists issues for the repo. +// state defaults to "open" if empty. +func (a *App) IssueList(state, author string) error { + cfg, err := a.loadConfig() + if err != nil { + return err + } + if state == "" { + state = "open" + } + q := url.Values{} + q.Set("state", state) + if author != "" { + q.Set("author", author) + } + resp, err := a.httpGet(cfg, repoBase(cfg)+"/issues?"+q.Encode()) + if err != nil { + return fmt.Errorf("fetching issues: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return a.readError(resp) + } + var r model.ListIssuesResponse + if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + if len(r.Issues) == 0 { + fmt.Fprintf(a.Out, "No %s issues\n", state) + return nil + } + fmt.Fprintf(a.Out, "%-6s %-40s %-30s %-8s %s\n", "NUMBER", "TITLE", "AUTHOR", "STATE", "CREATED") + for _, iss := range r.Issues { + title := iss.Title + if len(title) > 38 { + title = title[:37] + "…" + } + fmt.Fprintf(a.Out, "%-6d %-40s %-30s %-8s %s\n", + iss.Number, title, iss.Author, string(iss.State), + iss.CreatedAt.Format(time.RFC3339)) + } + return nil +} + +// IssueCreate creates a new issue. +func (a *App) IssueCreate(title, body string) error { + cfg, err := a.loadConfig() + if err != nil { + return err + } + if title == "" { + return fmt.Errorf("--title is required") + } + req := model.CreateIssueRequest{ + Title: title, + Body: body, + } + resp, err := a.postJSON(cfg, repoBase(cfg)+"/issues", req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return a.readError(resp) + } + var r model.CreateIssueResponse + if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + fmt.Fprintf(a.Out, "Created issue #%d\n", r.Number) + return nil +} + +// IssueShow shows details for a single issue, including comments and refs. +func (a *App) IssueShow(number int64) error { + cfg, err := a.loadConfig() + if err != nil { + return err + } + base := repoBase(cfg) + "/issues/" + strconv.FormatInt(number, 10) + + resp, err := a.httpGet(cfg, base) + if err != nil { + return fmt.Errorf("fetching issue: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return a.readError(resp) + } + var iss model.Issue + if err := json.NewDecoder(resp.Body).Decode(&iss); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + + fmt.Fprintf(a.Out, "Issue #%d: %s\n", iss.Number, iss.Title) + fmt.Fprintf(a.Out, "State: %s\n", string(iss.State)) + fmt.Fprintf(a.Out, "Author: %s\n", iss.Author) + fmt.Fprintf(a.Out, "Created: %s\n", iss.CreatedAt.Format(time.RFC3339)) + if iss.CloseReason != nil { + fmt.Fprintf(a.Out, "Closed: %s\n", string(*iss.CloseReason)) + } + if iss.Body != "" { + fmt.Fprintf(a.Out, "\n%s\n", iss.Body) + } + + resp2, err := a.httpGet(cfg, base+"/comments") + if err != nil { + return fmt.Errorf("fetching comments: %w", err) + } + defer resp2.Body.Close() + if resp2.StatusCode == http.StatusOK { + var cr model.ListIssueCommentsResponse + if err := json.NewDecoder(resp2.Body).Decode(&cr); err == nil && len(cr.Comments) > 0 { + fmt.Fprintf(a.Out, "\nComments (%d):\n", len(cr.Comments)) + for _, c := range cr.Comments { + fmt.Fprintf(a.Out, " [%s] %s: %s\n", c.CreatedAt.Format("2006-01-02"), c.Author, c.Body) + } + } + } + + resp3, err := a.httpGet(cfg, base+"/refs") + if err != nil { + return fmt.Errorf("fetching refs: %w", err) + } + defer resp3.Body.Close() + if resp3.StatusCode == http.StatusOK { + var rr model.ListIssueRefsResponse + if err := json.NewDecoder(resp3.Body).Decode(&rr); err == nil && len(rr.Refs) > 0 { + fmt.Fprintf(a.Out, "\nRefs (%d):\n", len(rr.Refs)) + for _, ref := range rr.Refs { + fmt.Fprintf(a.Out, " %s: %s\n", string(ref.RefType), ref.RefID) + } + } + } + + return nil +} + +// IssueClose closes an issue. +func (a *App) IssueClose(number int64, reason string) error { + cfg, err := a.loadConfig() + if err != nil { + return err + } + if reason == "" { + reason = string(model.IssueCloseReasonCompleted) + } + req := model.CloseIssueRequest{Reason: model.IssueCloseReason(reason)} + resp, err := a.postJSON(cfg, repoBase(cfg)+"/issues/"+strconv.FormatInt(number, 10)+"/close", req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return a.readError(resp) + } + fmt.Fprintf(a.Out, "Closed issue #%d\n", number) + return nil +} + +// IssueReopen reopens a closed issue. +func (a *App) IssueReopen(number int64) error { + cfg, err := a.loadConfig() + if err != nil { + return err + } + resp, err := a.postJSON(cfg, repoBase(cfg)+"/issues/"+strconv.FormatInt(number, 10)+"/reopen", model.ReopenIssueRequest{}) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return a.readError(resp) + } + fmt.Fprintf(a.Out, "Reopened issue #%d\n", number) + return nil +} + +// IssueCommentAdd adds a comment to an issue. +func (a *App) IssueCommentAdd(number int64, body string) error { + cfg, err := a.loadConfig() + if err != nil { + return err + } + if body == "" { + return fmt.Errorf("--body is required") + } + req := model.CreateIssueCommentRequest{Body: body} + resp, err := a.postJSON(cfg, repoBase(cfg)+"/issues/"+strconv.FormatInt(number, 10)+"/comments", req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return a.readError(resp) + } + var r model.CreateIssueCommentResponse + if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + fmt.Fprintf(a.Out, "Added comment %s\n", r.ID) + return nil +} + +// IssueCommentEdit edits an existing issue comment. +// issueNumber is required to construct the URL path. +func (a *App) IssueCommentEdit(issueNumber int64, commentID, body string) error { + cfg, err := a.loadConfig() + if err != nil { + return err + } + if body == "" { + return fmt.Errorf("--body is required") + } + req := model.UpdateIssueCommentRequest{Body: body} + urlPath := repoBase(cfg) + "/issues/" + strconv.FormatInt(issueNumber, 10) + "/comments/" + url.PathEscape(commentID) + resp, err := a.patchJSON(cfg, urlPath, req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return a.readError(resp) + } + fmt.Fprintf(a.Out, "Updated comment %s\n", commentID) + return nil +} + +// IssueRefs lists cross-references for an issue. +func (a *App) IssueRefs(number int64) error { + cfg, err := a.loadConfig() + if err != nil { + return err + } + resp, err := a.httpGet(cfg, repoBase(cfg)+"/issues/"+strconv.FormatInt(number, 10)+"/refs") + if err != nil { + return fmt.Errorf("fetching refs: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return a.readError(resp) + } + var r model.ListIssueRefsResponse + if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + if len(r.Refs) == 0 { + fmt.Fprintf(a.Out, "No refs for issue #%d\n", number) + return nil + } + fmt.Fprintf(a.Out, "%-10s %-36s %s\n", "TYPE", "REF_ID", "CREATED") + for _, ref := range r.Refs { + fmt.Fprintf(a.Out, "%-10s %-36s %s\n", + string(ref.RefType), ref.RefID, ref.CreatedAt.Format(time.RFC3339)) + } + return nil +} + +// IssueTie ties a proposal or commit ref to an issue. +func (a *App) IssueTie(number int64, refType, refID string) error { + cfg, err := a.loadConfig() + if err != nil { + return err + } + req := model.AddIssueRefRequest{ + RefType: model.IssueRefType(refType), + RefID: refID, + } + resp, err := a.postJSON(cfg, repoBase(cfg)+"/issues/"+strconv.FormatInt(number, 10)+"/refs", req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return a.readError(resp) + } + fmt.Fprintf(a.Out, "Tied %s %s to issue #%d\n", refType, refID, number) + return nil +} \ No newline at end of file diff --git a/internal/cli/org.go b/internal/cli/org.go new file mode 100644 index 0000000..47a9811 --- /dev/null +++ b/internal/cli/org.go @@ -0,0 +1,364 @@ +package cli + +import ( + "encoding/json" + "fmt" + "net/http" + "github.com/dlorenc/docstore/internal/model" +) + +// --------------------------------------------------------------------------- +// Org management +// --------------------------------------------------------------------------- + +// Orgs lists all organizations. +func (a *App) Orgs() error { + remote, err := a.loadRemote() + if err != nil { + return err + } + resp, err := a.doGET(remote + "/orgs") + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return a.readError(resp) + } + var r model.ListOrgsResponse + if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + fmt.Fprintf(a.Out, "%-30s %-20s %s\n", "NAME", "CREATED BY", "CREATED AT") + for _, org := range r.Orgs { + fmt.Fprintf(a.Out, "%-30s %-20s %s\n", org.Name, org.CreatedBy, org.CreatedAt.Format("2006-01-02")) + } + return nil +} + +// OrgsCreate creates a new organization. +func (a *App) OrgsCreate(name string) error { + remote, err := a.loadRemote() + if err != nil { + return err + } + resp, err := a.doPOSTJSON(remote+"/orgs", model.CreateOrgRequest{Name: name}) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + return a.readError(resp) + } + var org model.Org + if err := json.NewDecoder(resp.Body).Decode(&org); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + fmt.Fprintf(a.Out, "Created org '%s'\n", org.Name) + return nil +} + +// OrgsGet fetches and prints details for a single organization. +func (a *App) OrgsGet(name string) error { + remote, err := a.loadRemote() + if err != nil { + return err + } + resp, err := a.doGET(remote + "/orgs/" + name) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("org '%s' not found", name) + } + if resp.StatusCode != http.StatusOK { + return a.readError(resp) + } + var org model.Org + if err := json.NewDecoder(resp.Body).Decode(&org); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + fmt.Fprintf(a.Out, "Name: %s\n", org.Name) + fmt.Fprintf(a.Out, "Created by: %s\n", org.CreatedBy) + fmt.Fprintf(a.Out, "Created at: %s\n", org.CreatedAt.Format("2006-01-02")) + return nil +} + +// OrgsDelete deletes an organization (fails if it still has repos). +func (a *App) OrgsDelete(name string) error { + remote, err := a.loadRemote() + if err != nil { + return err + } + resp, err := a.doDELETE(remote + "/orgs/" + name) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusConflict { + return fmt.Errorf("org '%s' still has repos", name) + } + if resp.StatusCode != http.StatusNoContent { + return a.readError(resp) + } + fmt.Fprintf(a.Out, "Deleted org '%s'\n", name) + return nil +} + +// OrgsRepos lists repositories within an organization. +func (a *App) OrgsRepos(orgName string) error { + remote, err := a.loadRemote() + if err != nil { + return err + } + resp, err := a.doGET(remote + "/orgs/" + orgName + "/repos") + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return a.readError(resp) + } + var r model.ReposResponse + if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + fmt.Fprintf(a.Out, "%-30s %-20s %-20s %s\n", "NAME", "OWNER", "CREATED BY", "CREATED AT") + for _, repo := range r.Repos { + fmt.Fprintf(a.Out, "%-30s %-20s %-20s %s\n", repo.Name, repo.Owner, repo.CreatedBy, repo.CreatedAt.Format("2006-01-02")) + } + return nil +} + +// --------------------------------------------------------------------------- +// Org membership management +// --------------------------------------------------------------------------- + +// OrgMembersAdd adds or updates a member in an org with the given role. +func (a *App) OrgMembersAdd(org, identity, role string) error { + switch model.OrgRole(role) { + case model.OrgRoleOwner, model.OrgRoleMember: + default: + return fmt.Errorf("role must be 'owner' or 'member'") + } + remote, err := a.loadRemote() + if err != nil { + return err + } + resp, err := a.doPOSTJSON(remote+"/orgs/"+org+"/members/"+identity, model.AddOrgMemberRequest{Role: model.OrgRole(role)}) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return a.readError(resp) + } + fmt.Fprintf(a.Out, "Added '%s' to org '%s' as '%s'\n", identity, org, role) + return nil +} + +// OrgMembersRemove removes a member from an org. +func (a *App) OrgMembersRemove(org, identity string) error { + remote, err := a.loadRemote() + if err != nil { + return err + } + resp, err := a.doDELETE(remote + "/orgs/" + org + "/members/" + identity) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + return a.readError(resp) + } + fmt.Fprintf(a.Out, "Removed '%s' from org '%s'\n", identity, org) + return nil +} + +// OrgMembersList lists all members of an org. +func (a *App) OrgMembersList(org string) error { + remote, err := a.loadRemote() + if err != nil { + return err + } + resp, err := a.doGET(remote + "/orgs/" + org + "/members") + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return a.readError(resp) + } + var r model.OrgMembersResponse + if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + fmt.Fprintf(a.Out, "%-30s %s\n", "IDENTITY", "ROLE") + for _, m := range r.Members { + fmt.Fprintf(a.Out, "%-30s %s\n", m.Identity, string(m.Role)) + } + return nil +} + +// --------------------------------------------------------------------------- +// Org invite management +// --------------------------------------------------------------------------- + +// OrgInvitesCreate creates an invite for email with the given role and prints the token. +func (a *App) OrgInvitesCreate(org, email, role string) error { + switch model.OrgRole(role) { + case model.OrgRoleOwner, model.OrgRoleMember: + default: + return fmt.Errorf("role must be 'owner' or 'member'") + } + remote, err := a.loadRemote() + if err != nil { + return err + } + resp, err := a.doPOSTJSON(remote+"/orgs/"+org+"/invites", model.CreateInviteRequest{Email: email, Role: model.OrgRole(role)}) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return a.readError(resp) + } + var r model.CreateInviteResponse + if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + fmt.Fprintf(a.Out, "Invite created: id=%s token=%s\n", r.ID, r.Token) + return nil +} + +// OrgInvitesList lists all pending invites for an org. +func (a *App) OrgInvitesList(org string) error { + remote, err := a.loadRemote() + if err != nil { + return err + } + resp, err := a.doGET(remote + "/orgs/" + org + "/invites") + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return a.readError(resp) + } + var r model.OrgInvitesResponse + if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + fmt.Fprintf(a.Out, "%-36s %-30s %s\n", "ID", "EMAIL", "ROLE") + for _, inv := range r.Invites { + fmt.Fprintf(a.Out, "%-36s %-30s %s\n", inv.ID, inv.Email, string(inv.Role)) + } + return nil +} + +// OrgInvitesAccept accepts an invite using a token. +func (a *App) OrgInvitesAccept(org, token string) error { + remote, err := a.loadRemote() + if err != nil { + return err + } + resp, err := a.doPOSTJSON(remote+"/orgs/"+org+"/invites/"+token+"/accept", struct{}{}) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + return a.readError(resp) + } + fmt.Fprintf(a.Out, "Accepted invite for org '%s'\n", org) + return nil +} + +// OrgInvitesRevoke revokes a pending invite by ID. +func (a *App) OrgInvitesRevoke(org, inviteID string) error { + remote, err := a.loadRemote() + if err != nil { + return err + } + resp, err := a.doDELETE(remote + "/orgs/" + org + "/invites/" + inviteID) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + return a.readError(resp) + } + fmt.Fprintf(a.Out, "Revoked invite '%s' from org '%s'\n", inviteID, org) + return nil +} + +// --------------------------------------------------------------------------- +// Role management +// --------------------------------------------------------------------------- + +// Roles lists roles for the current repository. +func (a *App) Roles() error { + cfg, err := a.loadConfig() + if err != nil { + return err + } + resp, err := a.doGET(repoBase(cfg) + "/roles") + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return a.readError(resp) + } + var r model.RolesResponse + if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + fmt.Fprintf(a.Out, "%-30s %s\n", "IDENTITY", "ROLE") + for _, role := range r.Roles { + fmt.Fprintf(a.Out, "%-30s %s\n", role.Identity, string(role.Role)) + } + return nil +} + +// RolesSet grants or updates a role for an identity on the current repository. +func (a *App) RolesSet(identity, role string) error { + switch model.RoleType(role) { + case model.RoleReader, model.RoleWriter, model.RoleMaintainer, model.RoleAdmin: + default: + return fmt.Errorf("role must be one of: reader, writer, maintainer, admin") + } + cfg, err := a.loadConfig() + if err != nil { + return err + } + resp, err := a.doPUTJSON(repoBase(cfg)+"/roles/"+identity, model.SetRoleRequest{Role: model.RoleType(role)}) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return a.readError(resp) + } + fmt.Fprintf(a.Out, "Set role '%s' for '%s'\n", role, identity) + return nil +} + +// RolesDelete removes a role assignment for an identity on the current repository. +func (a *App) RolesDelete(identity string) error { + cfg, err := a.loadConfig() + if err != nil { + return err + } + resp, err := a.doDELETE(repoBase(cfg) + "/roles/" + identity) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + return a.readError(resp) + } + fmt.Fprintf(a.Out, "Deleted role for '%s'\n", identity) + return nil +} \ No newline at end of file diff --git a/internal/cli/proposals.go b/internal/cli/proposals.go new file mode 100644 index 0000000..66ce773 --- /dev/null +++ b/internal/cli/proposals.go @@ -0,0 +1,122 @@ +package cli + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" + "github.com/dlorenc/docstore/internal/model" +) + + +// ProposalOpen creates a new proposal for a branch. +// If branch is empty, the current workspace branch is used. +// BaseBranch defaults to "main" if empty. +func (a *App) ProposalOpen(branch, baseBranch, title, description string) error { + cfg, err := a.loadConfig() + if err != nil { + return err + } + if branch == "" { + branch = cfg.Branch + } + if baseBranch == "" { + baseBranch = "main" + } + if title == "" { + return fmt.Errorf("--title is required") + } + + req := model.CreateProposalRequest{ + Branch: branch, + BaseBranch: baseBranch, + Title: title, + Description: description, + } + resp, err := a.postJSON(cfg, repoBase(cfg)+"/proposals", req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return a.readError(resp) + } + + var proposalResp model.CreateProposalResponse + if err := json.NewDecoder(resp.Body).Decode(&proposalResp); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + + fmt.Fprintf(a.Out, "Proposal opened: %s\n", proposalResp.ID) + return nil +} + +// ProposalList lists proposals for the repo. +// state defaults to "open" if empty. +func (a *App) ProposalList(state string) error { + cfg, err := a.loadConfig() + if err != nil { + return err + } + if state == "" { + state = "open" + } + + q := url.Values{} + q.Set("state", state) + resp, err := a.httpGet(cfg, repoBase(cfg)+"/proposals?"+q.Encode()) + if err != nil { + return fmt.Errorf("fetching proposals: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return a.readError(resp) + } + + var proposals []model.Proposal + if err := json.NewDecoder(resp.Body).Decode(&proposals); err != nil { + return fmt.Errorf("decoding proposals: %w", err) + } + + if len(proposals) == 0 { + fmt.Fprintf(a.Out, "No %s proposals\n", state) + return nil + } + + fmt.Fprintf(a.Out, "%-36s %-30s %-40s %-30s %-8s %s\n", + "ID", "BRANCH", "TITLE", "AUTHOR", "STATE", "CREATED") + for _, p := range proposals { + title := p.Title + if len(title) > 38 { + title = title[:37] + "…" + } + fmt.Fprintf(a.Out, "%-36s %-30s %-40s %-30s %-8s %s\n", + p.ID, p.Branch, title, p.Author, string(p.State), + p.CreatedAt.Format(time.RFC3339)) + } + return nil +} + +// ProposalClose closes an open proposal. +func (a *App) ProposalClose(proposalID string) error { + cfg, err := a.loadConfig() + if err != nil { + return err + } + + resp, err := a.postJSON(cfg, repoBase(cfg)+"/proposals/"+url.PathEscape(proposalID)+"/close", nil) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + return a.readError(resp) + } + + fmt.Fprintf(a.Out, "Proposal %s closed\n", proposalID) + return nil +} \ No newline at end of file diff --git a/internal/cli/releases.go b/internal/cli/releases.go new file mode 100644 index 0000000..12ef123 --- /dev/null +++ b/internal/cli/releases.go @@ -0,0 +1,166 @@ +package cli + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" +) + + +// --------------------------------------------------------------------------- +// Release management +// --------------------------------------------------------------------------- + +// releaseEntry is a local type for decoding release API responses. +type releaseEntry struct { + ID string `json:"id"` + Repo string `json:"repo"` + Name string `json:"name"` + Sequence int64 `json:"sequence"` + Body string `json:"body,omitempty"` + CreatedBy string `json:"created_by"` + CreatedAt time.Time `json:"created_at"` +} + +type listReleasesResponse struct { + Releases []releaseEntry `json:"releases"` +} + +// ReleaseCreate creates a named release. If sequence is 0, the server defaults +// to the current main head sequence. +func (a *App) ReleaseCreate(name string, sequence int64, notes string) error { + cfg, err := a.loadConfig() + if err != nil { + return err + } + + body := map[string]any{ + "name": name, + } + if sequence != 0 { + body["sequence"] = sequence + } + if notes != "" { + body["body"] = notes + } + + resp, err := a.postJSON(cfg, repoBase(cfg)+"/releases", body) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + return a.readError(resp) + } + var rel releaseEntry + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + fmt.Fprintf(a.Out, "Created release '%s' at sequence %d\n", rel.Name, rel.Sequence) + return nil +} + +// ReleaseList lists all releases for the current repository. +func (a *App) ReleaseList() error { + cfg, err := a.loadConfig() + if err != nil { + return err + } + resp, err := a.httpGet(cfg, repoBase(cfg)+"/releases") + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return a.readError(resp) + } + var r listReleasesResponse + if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + fmt.Fprintf(a.Out, "%-20s %-10s %-20s %s\n", "NAME", "SEQUENCE", "CREATED BY", "CREATED AT") + for _, rel := range r.Releases { + fmt.Fprintf(a.Out, "%-20s %-10d %-20s %s\n", rel.Name, rel.Sequence, rel.CreatedBy, rel.CreatedAt.Format("2006-01-02")) + } + return nil +} + +// ReleaseShow prints release metadata and then shows the tree at that release's sequence. +func (a *App) ReleaseShow(name string) error { + cfg, err := a.loadConfig() + if err != nil { + return err + } + resp, err := a.httpGet(cfg, repoBase(cfg)+"/releases/"+name) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("release '%s' not found", name) + } + if resp.StatusCode != http.StatusOK { + return a.readError(resp) + } + var rel releaseEntry + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + fmt.Fprintf(a.Out, "Name: %s\n", rel.Name) + fmt.Fprintf(a.Out, "Sequence: %d\n", rel.Sequence) + fmt.Fprintf(a.Out, "Created by: %s\n", rel.CreatedBy) + fmt.Fprintf(a.Out, "Created at: %s\n", rel.CreatedAt.Format("2006-01-02 15:04:05")) + if rel.Body != "" { + fmt.Fprintf(a.Out, "Notes:\n%s\n", rel.Body) + } + + // Show the tree at the release sequence. + q := url.Values{} + q.Set("at", fmt.Sprintf("%d", rel.Sequence)) + treeResp, err := a.httpGet(cfg, repoBase(cfg)+"/tree?"+q.Encode()) + if err != nil { + return err + } + defer treeResp.Body.Close() + if treeResp.StatusCode != http.StatusOK { + return a.readError(treeResp) + } + + type treeEntry struct { + Path string `json:"path"` + VersionID string `json:"version_id"` + ContentHash string `json:"content_hash"` + } + var entries []treeEntry + if err := json.NewDecoder(treeResp.Body).Decode(&entries); err != nil { + return fmt.Errorf("decoding tree: %w", err) + } + fmt.Fprintf(a.Out, "\nTree at sequence %d:\n", rel.Sequence) + for _, e := range entries { + fmt.Fprintf(a.Out, " %s\n", e.Path) + } + return nil +} + +// ReleaseDelete deletes a named release (admin only). +func (a *App) ReleaseDelete(name string) error { + cfg, err := a.loadConfig() + if err != nil { + return err + } + resp, err := a.doDELETE(repoBase(cfg) + "/releases/" + name) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("release '%s' not found", name) + } + if resp.StatusCode != http.StatusNoContent { + return a.readError(resp) + } + fmt.Fprintf(a.Out, "Deleted release '%s'\n", name) + return nil +} \ No newline at end of file diff --git a/internal/cli/repo.go b/internal/cli/repo.go new file mode 100644 index 0000000..1125559 --- /dev/null +++ b/internal/cli/repo.go @@ -0,0 +1,175 @@ +package cli + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "github.com/dlorenc/docstore/internal/model" +) + +// --------------------------------------------------------------------------- +// Repo management +// --------------------------------------------------------------------------- + +// Repos lists all repositories. +func (a *App) Repos() error { + remote, err := a.loadRemote() + if err != nil { + return err + } + resp, err := a.doGET(remote + "/repos") + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return a.readError(resp) + } + var r model.ReposResponse + if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + fmt.Fprintf(a.Out, "%-30s %-20s %-20s %s\n", "NAME", "OWNER", "CREATED BY", "CREATED AT") + for _, repo := range r.Repos { + fmt.Fprintf(a.Out, "%-30s %-20s %-20s %s\n", repo.Name, repo.Owner, repo.CreatedBy, repo.CreatedAt.Format("2006-01-02")) + } + return nil +} + +// ReposCreate creates a new repository under the given owner organization. +func (a *App) ReposCreate(owner, name string) error { + remote, err := a.loadRemote() + if err != nil { + return err + } + resp, err := a.doPOSTJSON(remote+"/repos", model.CreateRepoRequest{Owner: owner, Name: name}) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("org '%s' not found", owner) + } + if resp.StatusCode != http.StatusCreated { + return a.readError(resp) + } + var repo model.Repo + if err := json.NewDecoder(resp.Body).Decode(&repo); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + fmt.Fprintf(a.Out, "Created repo '%s'\n", repo.Name) + return nil +} + +// ReposDelete deletes a repository by full name (e.g., "acme/myrepo"). +func (a *App) ReposDelete(name string) error { + remote, err := a.loadRemote() + if err != nil { + return err + } + resp, err := a.doDELETE(remote + "/repos/" + name + "/-/") + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + return a.readError(resp) + } + fmt.Fprintf(a.Out, "Deleted repo '%s'\n", name) + return nil +} + +// RepoGet gets a single repository by full name (e.g., "acme/myrepo"). +func (a *App) RepoGet(fullName string) error { + remote, err := a.loadRemote() + if err != nil { + return err + } + resp, err := a.doGET(remote + "/repos/" + fullName) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("repo '%s' not found", fullName) + } + if resp.StatusCode != http.StatusOK { + return a.readError(resp) + } + var repo model.Repo + if err := json.NewDecoder(resp.Body).Decode(&repo); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + fmt.Fprintf(a.Out, "%-30s %-20s %-20s %s\n", "NAME", "OWNER", "CREATED BY", "CREATED AT") + fmt.Fprintf(a.Out, "%-30s %-20s %-20s %s\n", repo.Name, repo.Owner, repo.CreatedBy, repo.CreatedAt.Format("2006-01-02")) + return nil +} + +// --------------------------------------------------------------------------- +// Branch management (CLI-level) +// --------------------------------------------------------------------------- + +// BranchDelete deletes a branch from the current repo by name. +func (a *App) BranchDelete(branch string) error { + cfg, err := a.loadConfig() + if err != nil { + return err + } + resp, err := a.doDELETE(repoBase(cfg) + "/branch/" + url.PathEscape(branch)) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("branch '%s' not found", branch) + } + if resp.StatusCode == http.StatusConflict { + return fmt.Errorf("branch '%s' is already merged or abandoned", branch) + } + if resp.StatusCode != http.StatusNoContent { + return a.readError(resp) + } + fmt.Fprintf(a.Out, "Deleted branch '%s'\n", branch) + return nil +} + +// --------------------------------------------------------------------------- +// Purge +// --------------------------------------------------------------------------- + +// Purge removes merged/abandoned branches and their unreachable data from the +// current repo. olderThan is a duration string like "30d". If dryRun is true +// the server reports what would be deleted without deleting anything. +func (a *App) Purge(olderThan string, dryRun bool) error { + cfg, err := a.loadConfig() + if err != nil { + return err + } + req := model.PurgeRequest{OlderThan: olderThan, DryRun: dryRun} + resp, err := a.postJSON(cfg, repoBase(cfg)+"/purge", req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("repo '%s' not found", cfg.Repo) + } + if resp.StatusCode != http.StatusOK { + return a.readError(resp) + } + var result model.PurgeResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + if dryRun { + fmt.Fprintf(a.Out, "[dry-run] would purge:\n") + } + fmt.Fprintf(a.Out, " branches purged: %d\n", result.BranchesPurged) + fmt.Fprintf(a.Out, " file commits deleted: %d\n", result.FileCommitsDeleted) + fmt.Fprintf(a.Out, " commits deleted: %d\n", result.CommitsDeleted) + fmt.Fprintf(a.Out, " documents deleted: %d\n", result.DocumentsDeleted) + fmt.Fprintf(a.Out, " reviews deleted: %d\n", result.ReviewsDeleted) + fmt.Fprintf(a.Out, " check runs deleted: %d\n", result.CheckRunsDeleted) + return nil +} \ No newline at end of file diff --git a/internal/cli/review.go b/internal/cli/review.go new file mode 100644 index 0000000..edce558 --- /dev/null +++ b/internal/cli/review.go @@ -0,0 +1,384 @@ +package cli + +import ( + "encoding/json" + "fmt" + "maps" + "net/http" + "net/url" + "slices" + "strings" + "github.com/dlorenc/docstore/internal/model" +) + + +// Reviews lists reviews for a branch (defaults to current branch if empty). +// A review is marked [stale] if its sequence < the branch's head_sequence. +func (a *App) Reviews(branch string) error { + cfg, err := a.loadConfig() + if err != nil { + return err + } + if branch == "" { + branch = cfg.Branch + } + + headSeq, err := a.branchHeadSequence(cfg, branch) + if err != nil { + return err + } + + resp, err := a.httpGet(cfg, repoBase(cfg)+"/branch/"+url.PathEscape(branch)+"/reviews") + if err != nil { + return fmt.Errorf("fetching reviews: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return a.readError(resp) + } + + var reviews []model.Review + if err := json.NewDecoder(resp.Body).Decode(&reviews); err != nil { + return fmt.Errorf("decoding reviews: %w", err) + } + + if len(reviews) == 0 { + fmt.Fprintf(a.Out, "No reviews for branch '%s'\n", branch) + return nil + } + + fmt.Fprintf(a.Out, "%-36s %-30s %-4s %-10s %s\n", "ID", "REVIEWER", "SEQ", "STATUS", "BODY") + for _, r := range reviews { + stale := "" + if r.Sequence < headSeq { + stale = " [stale]" + } + fmt.Fprintf(a.Out, "%-36s %-30s %-4d %-10s %s%s\n", + r.ID, r.Reviewer, r.Sequence, string(r.Status), r.Body, stale) + } + return nil +} + +// Review submits a review for a branch. +func (a *App) Review(branch, status, body string) error { + cfg, err := a.loadConfig() + if err != nil { + return err + } + if branch == "" { + branch = cfg.Branch + } + + reviewStatus := model.ReviewStatus(status) + if reviewStatus != model.ReviewApproved && reviewStatus != model.ReviewRejected { + return fmt.Errorf("status must be 'approved' or 'rejected'") + } + + req := model.CreateReviewRequest{ + Branch: branch, + Status: reviewStatus, + Body: body, + } + resp, err := a.postJSON(cfg, repoBase(cfg)+"/review", req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return a.readError(resp) + } + + var reviewResp model.CreateReviewResponse + if err := json.NewDecoder(resp.Body).Decode(&reviewResp); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + + fmt.Fprintf(a.Out, "Review submitted: %s (id: %s, sequence: %d)\n", status, reviewResp.ID, reviewResp.Sequence) + return nil +} + +// Comment creates an inline file annotation on a branch. +// The version_id is resolved automatically by fetching the file metadata from the server. +func (a *App) Comment(branch, path, body string) error { + cfg, err := a.loadConfig() + if err != nil { + return err + } + if branch == "" { + branch = cfg.Branch + } + + // Resolve version_id for the file on this branch. + q := url.Values{} + q.Set("branch", branch) + fileResp, err := a.httpGet(cfg, repoBase(cfg)+"/file/"+url.PathEscape(path)+"?"+q.Encode()) + if err != nil { + return fmt.Errorf("fetching file info: %w", err) + } + defer fileResp.Body.Close() + if fileResp.StatusCode == http.StatusNotFound { + return fmt.Errorf("file %q not found on branch %q", path, branch) + } + if fileResp.StatusCode != http.StatusOK { + return a.readError(fileResp) + } + var fileMeta struct { + VersionID string `json:"version_id"` + } + if err := json.NewDecoder(fileResp.Body).Decode(&fileMeta); err != nil { + return fmt.Errorf("decoding file info: %w", err) + } + if fileMeta.VersionID == "" { + return fmt.Errorf("file %q was deleted on branch %q", path, branch) + } + + req := model.CreateReviewCommentRequest{ + Branch: branch, + Path: path, + VersionID: fileMeta.VersionID, + Body: body, + } + resp, err := a.postJSON(cfg, repoBase(cfg)+"/comment", req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + return a.readError(resp) + } + + var createResp model.CreateReviewCommentResponse + if err := json.NewDecoder(resp.Body).Decode(&createResp); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + + fmt.Fprintf(a.Out, "Comment created: id=%s, sequence=%d\n", createResp.ID, createResp.Sequence) + return nil +} + +// Comments lists inline file comments for a branch. If path is non-empty, only +// comments on that path are shown. +func (a *App) Comments(branch, path string) error { + cfg, err := a.loadConfig() + if err != nil { + return err + } + if branch == "" { + branch = cfg.Branch + } + + q := url.Values{} + if path != "" { + q.Set("path", path) + } + urlStr := repoBase(cfg) + "/branch/" + url.PathEscape(branch) + "/comments" + if len(q) > 0 { + urlStr += "?" + q.Encode() + } + + resp, err := a.httpGet(cfg, urlStr) + if err != nil { + return fmt.Errorf("fetching comments: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return a.readError(resp) + } + + var comments []model.ReviewComment + if err := json.NewDecoder(resp.Body).Decode(&comments); err != nil { + return fmt.Errorf("decoding comments: %w", err) + } + + if len(comments) == 0 { + fmt.Fprintf(a.Out, "No comments for branch '%s'\n", branch) + return nil + } + + fmt.Fprintf(a.Out, "%-36s %-40s %-4s %s\n", "ID", "PATH", "SEQ", "BODY") + for _, c := range comments { + fmt.Fprintf(a.Out, "%-36s %-40s %-4d %s\n", c.ID, c.Path, c.Sequence, c.Body) + } + return nil +} + +// Checks lists check runs for a branch (defaults to current branch if empty). +// If showAll is false, only the latest result per check_name is shown. +func (a *App) Checks(branch string, showAll bool) error { + cfg, err := a.loadConfig() + if err != nil { + return err + } + if branch == "" { + branch = cfg.Branch + } + + resp, err := a.httpGet(cfg, repoBase(cfg)+"/branch/"+url.PathEscape(branch)+"/checks") + if err != nil { + return fmt.Errorf("fetching checks: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return a.readError(resp) + } + + var checkRuns []model.CheckRun + if err := json.NewDecoder(resp.Body).Decode(&checkRuns); err != nil { + return fmt.Errorf("decoding checks: %w", err) + } + + if len(checkRuns) == 0 { + fmt.Fprintf(a.Out, "No check runs for branch '%s'\n", branch) + return nil + } + + headSeq, err := a.branchHeadSequence(cfg, branch) + if err != nil { + return err + } + + // Deduplicate by check_name keeping highest sequence unless --all is set. + if !showAll { + latest := make(map[string]model.CheckRun) + for _, c := range checkRuns { + prev, ok := latest[c.CheckName] + if !ok || c.Sequence > prev.Sequence { + latest[c.CheckName] = c + } + } + checkRuns = slices.Collect(maps.Values(latest)) + slices.SortFunc(checkRuns, func(a, b model.CheckRun) int { + return strings.Compare(a.CheckName, b.CheckName) + }) + } + + fmt.Fprintf(a.Out, "%-8s %-20s %-4s %-10s %s\n", "ID", "CHECK NAME", "SEQ", "STATUS", "REPORTER") + for _, c := range checkRuns { + stale := "" + if c.Sequence < headSeq { + stale = " [stale]" + } + id := c.ID + if len(id) > 8 { + id = id[:8] + } + fmt.Fprintf(a.Out, "%-8s %-20s %-4d %-10s %s%s\n", + id, c.CheckName, c.Sequence, string(c.Status), c.Reporter, stale) + if c.LogURL != nil { + fmt.Fprintf(a.Out, " log: %s\n", *c.LogURL) + } + } + return nil +} + +// Check reports a CI check result for a branch. +// logURL and sequence are optional; pass nil to omit them from the request. +func (a *App) Check(branch, name, status string, logURL *string, sequence *int64) error { + cfg, err := a.loadConfig() + if err != nil { + return err + } + if branch == "" { + branch = cfg.Branch + } + + checkStatus := model.CheckRunStatus(status) + if checkStatus != model.CheckRunPassed && checkStatus != model.CheckRunFailed && checkStatus != model.CheckRunPending { + return fmt.Errorf("status must be 'passed', 'failed', or 'pending'") + } + + req := model.CreateCheckRunRequest{ + Branch: branch, + CheckName: name, + Status: checkStatus, + LogURL: logURL, + Sequence: sequence, + } + resp, err := a.postJSON(cfg, repoBase(cfg)+"/check", req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return a.readError(resp) + } + + var checkResp model.CreateCheckRunResponse + if err := json.NewDecoder(resp.Body).Decode(&checkResp); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + + fmt.Fprintf(a.Out, "Check run submitted: %s=%s (id: %s, sequence: %d)\n", name, status, checkResp.ID, checkResp.Sequence) + return nil +} + +// branchHeadSequence returns the head sequence of a named branch. +func (a *App) branchHeadSequence(cfg *Config, branch string) (int64, error) { + resp, err := a.httpGet(cfg, repoBase(cfg)+"/branches") + if err != nil { + return 0, fmt.Errorf("fetching branches: %w", err) + } + defer resp.Body.Close() + + var branches []model.Branch + if err := json.NewDecoder(resp.Body).Decode(&branches); err != nil { + return 0, fmt.Errorf("decoding branches: %w", err) + } + + for _, b := range branches { + if b.Name == branch { + return b.HeadSequence, nil + } + } + return 0, fmt.Errorf("branch %q not found", branch) +} + +// RetryChecks requests a retry of CI checks for a branch. +// If checks is empty, all failed checks at the branch's current head sequence are retried. +func (a *App) RetryChecks(branch string, checks []string) error { + cfg, err := a.loadConfig() + if err != nil { + return err + } + if branch == "" { + branch = cfg.Branch + } + + seq, err := a.branchHeadSequence(cfg, branch) + if err != nil { + return err + } + + req := model.RetryChecksRequest{ + Branch: branch, + Sequence: seq, + Checks: checks, + } + resp, err := a.postJSON(cfg, repoBase(cfg)+"/checks/retry", req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusAccepted { + return a.readError(resp) + } + + var retryResp model.RetryChecksResponse + if err := json.NewDecoder(resp.Body).Decode(&retryResp); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + + if len(checks) == 0 { + fmt.Fprintf(a.Out, "Retrying all failed checks on branch '%s' at sequence %d (attempt %d)\n", branch, seq, retryResp.Attempt) + } else { + fmt.Fprintf(a.Out, "Retrying checks %v on branch '%s' at sequence %d (attempt %d)\n", checks, branch, seq, retryResp.Attempt) + } + return nil +} \ No newline at end of file diff --git a/internal/cli/webhooks.go b/internal/cli/webhooks.go new file mode 100644 index 0000000..970bbf9 --- /dev/null +++ b/internal/cli/webhooks.go @@ -0,0 +1,114 @@ +package cli + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "github.com/dlorenc/docstore/internal/model" +) + +// --------------------------------------------------------------------------- +// Subscription management +// --------------------------------------------------------------------------- + +// SubscriptionCreate creates a new webhook subscription. +func (a *App) SubscriptionCreate(webhookURL, secret string, repo *string, eventTypes []string) error { + remote, err := a.loadRemote() + if err != nil { + return err + } + webhookConfig, err := json.Marshal(map[string]string{"url": webhookURL, "secret": secret}) + if err != nil { + return fmt.Errorf("encoding webhook config: %w", err) + } + req := model.CreateSubscriptionRequest{ + Repo: repo, + EventTypes: eventTypes, + Backend: "webhook", + Config: json.RawMessage(webhookConfig), + } + resp, err := a.doPOSTJSON(remote+"/subscriptions", req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return a.readError(resp) + } + var sub model.EventSubscription + if err := json.NewDecoder(resp.Body).Decode(&sub); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + fmt.Fprintf(a.Out, "Created subscription '%s'\n", sub.ID) + return nil +} + +// SubscriptionList lists all webhook subscriptions. +func (a *App) SubscriptionList() error { + remote, err := a.loadRemote() + if err != nil { + return err + } + resp, err := a.doGET(remote + "/subscriptions") + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return a.readError(resp) + } + var r model.ListSubscriptionsResponse + if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + fmt.Fprintf(a.Out, "%-36s %-20s %-10s %s\n", "ID", "REPO", "BACKEND", "SUSPENDED") + for _, sub := range r.Subscriptions { + repo := "(all)" + if sub.Repo != nil { + repo = *sub.Repo + } + suspended := "no" + if sub.SuspendedAt != nil { + suspended = sub.SuspendedAt.Format("2006-01-02") + } + fmt.Fprintf(a.Out, "%-36s %-20s %-10s %s\n", sub.ID, repo, sub.Backend, suspended) + } + return nil +} + +// SubscriptionDelete deletes a subscription by ID. +func (a *App) SubscriptionDelete(id string) error { + remote, err := a.loadRemote() + if err != nil { + return err + } + resp, err := a.doDELETE(remote + "/subscriptions/" + url.PathEscape(id)) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + return a.readError(resp) + } + fmt.Fprintf(a.Out, "Deleted subscription '%s'\n", id) + return nil +} + +// SubscriptionResume resumes a suspended subscription by ID. +func (a *App) SubscriptionResume(id string) error { + remote, err := a.loadRemote() + if err != nil { + return err + } + resp, err := a.doPOSTJSON(remote+"/subscriptions/"+url.PathEscape(id)+"/resume", struct{}{}) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + return a.readError(resp) + } + fmt.Fprintf(a.Out, "Resumed subscription '%s'\n", id) + return nil +} \ No newline at end of file