From 730f637518c8329f6ae2ad13b8002bff4e8306ae Mon Sep 17 00:00:00 2001 From: ssig33 Date: Tue, 19 May 2026 08:52:05 +0900 Subject: [PATCH] =?UTF-8?q?[=E8=87=AA=E5=8B=95=E3=82=B3=E3=83=9F=E3=83=83?= =?UTF-8?q?=E3=83=88]=20esa.io=20API=20=E3=82=92=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E3=81=99=E3=82=8B=20Go=20=E8=A3=BD=20CLI=20=E3=83=84=E3=83=BC?= =?UTF-8?q?=E3=83=AB=E3=82=92=E6=96=B0=E8=A6=8F=E4=BD=9C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - esa.io API クライアントの実装と単体テストを追加 - 記事の取得・検索・作成・更新を行うサブコマンドを実装 - GitHub Actions による CI ワークフローを設定 - プロジェクトの基本ファイル(README、.gitignore、LICENSE、go.mod)を作成 --- .github/workflows/ci.yml | 36 ++++++ .gitignore | 4 + LICENSE | 13 ++ README.md | 89 +++++++++++++ esa/client.go | 224 +++++++++++++++++++++++++++++++++ esa/client_test.go | 223 +++++++++++++++++++++++++++++++++ go.mod | 3 + main.go | 261 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 853 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 esa/client.go create mode 100644 esa/client_test.go create mode 100644 go.mod create mode 100644 main.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..599b514 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Build & Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: 'stable' + check-latest: true + cache: true + + - name: Verify dependencies + run: go mod verify + + - name: Vet + run: go vet ./... + + - name: Build + run: go build -v ./... + + - name: Test + run: go test -race -v ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e657731 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/esacli +/dist/ +*.test +*.out diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c6c7def --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + Version 2, December 2004 + + Copyright (C) 2004 Sam Hocevar + + Everyone is permitted to copy and distribute verbatim or modified + copies of this license document, and changing it is allowed as long + as the name is changed. + + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. You just DO WHAT THE FUCK YOU WANT TO. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9eeb743 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# esacli + +esa.io API v1 を叩く Go 製の CLI。記事の取得・検索・作成・更新ができる。 + +## インストール + +``` +go install gitea.ssig33.com/ssig33/esacli@latest +``` + +もしくはソースからビルド。 + +``` +git clone https://gitea.ssig33.com/ssig33/esacli +cd esacli +go build -o esacli . +``` + +## 設定 + +以下の環境変数を読む。 + +| 変数名 | 内容 | +| --- | --- | +| `ESA_TOKEN` | esa.io のアクセストークン (必須) | +| `ESA_TEAM` | esa.io のチーム名 (必須) | +| `ESA_BASE_URL` | API のベース URL を上書きする (省略可、デバッグ用) | + +トークンは `https://[team].esa.io/user/applications` から発行する。 + +## 使い方 + +``` +esacli [options] +``` + +### get — 記事を 1 件取得 + +``` +esacli get -number 102 +esacli get -number 102 -body # body_md だけを出力 +``` + +### search — 記事を検索 + +``` +esacli search -q "esa API" +esacli search -q "tag:dev" -per-page 50 -sort updated -order desc +``` + +検索クエリの書式は [esa の検索方法ドキュメント](https://docs.esa.io/posts/104) を参照。 + +### create — 記事を作成 + +``` +esacli create -name "新しい記事" -body "# 本文" -tags "go,cli" -category "dev/2026/05" +esacli create -name "長文記事" -body-file ./article.md -wip=false -message "first commit" +echo "# stdin から本文" | esacli create -name "stdin 経由" -body-file - +``` + +`-wip` のデフォルトは `true`。 + +### update — 記事を更新 + +``` +esacli update -number 5 -name "タイトル更新" +esacli update -number 5 -body-file ./article.md -message "本文更新" +esacli update -number 5 -wip false +``` + +`-wip` は `true` / `false` を文字列で渡す。指定しなければ変更しない。 + +## 出力 + +成功時は JSON を整形して標準出力に書く。失敗時は標準エラーにメッセージを出し、終了コード `1` で終わる。 + +## 開発 + +``` +go test ./... +go vet ./... +go build ./... +``` + +`main` ブランチへの push と PR で GitHub Actions が `go vet`・`go build`・`go test -race` を実行する。 + +## ライセンス + +WTFPL。詳しくは [LICENSE](./LICENSE)。 diff --git a/esa/client.go b/esa/client.go new file mode 100644 index 0000000..f9ee5ed --- /dev/null +++ b/esa/client.go @@ -0,0 +1,224 @@ +package esa + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" +) + +const defaultBaseURL = "https://api.esa.io" + +type Client struct { + BaseURL string + Team string + Token string + HTTPClient *http.Client +} + +func NewClient(team, token string) *Client { + return &Client{ + BaseURL: defaultBaseURL, + Team: team, + Token: token, + HTTPClient: &http.Client{Timeout: 30 * time.Second}, + } +} + +type User struct { + Myself bool `json:"myself"` + Name string `json:"name"` + ScreenName string `json:"screen_name"` + Icon string `json:"icon"` +} + +type Post struct { + Number int `json:"number"` + Name string `json:"name"` + FullName string `json:"full_name"` + WIP bool `json:"wip"` + BodyMD string `json:"body_md"` + BodyHTML string `json:"body_html"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Message string `json:"message"` + URL string `json:"url"` + Tags []string `json:"tags"` + Category string `json:"category"` + RevisionNumber int `json:"revision_number"` + CreatedBy User `json:"created_by"` + UpdatedBy User `json:"updated_by"` + Kind string `json:"kind"` + CommentsCount int `json:"comments_count"` + TasksCount int `json:"tasks_count"` + DoneTasksCount int `json:"done_tasks_count"` + StargazersCount int `json:"stargazers_count"` + WatchersCount int `json:"watchers_count"` + Star bool `json:"star"` + Watch bool `json:"watch"` +} + +type PostsResponse struct { + Posts []Post `json:"posts"` + PrevPage *int `json:"prev_page"` + NextPage *int `json:"next_page"` + TotalCount int `json:"total_count"` + Page int `json:"page"` + PerPage int `json:"per_page"` + MaxPerPage int `json:"max_per_page"` +} + +type PostInput struct { + Name string `json:"name,omitempty"` + BodyMD string `json:"body_md,omitempty"` + Tags []string `json:"tags,omitempty"` + Category string `json:"category,omitempty"` + WIP *bool `json:"wip,omitempty"` + Message string `json:"message,omitempty"` +} + +type postCreateRequest struct { + Post PostInput `json:"post"` +} + +type APIError struct { + StatusCode int + ErrorCode string `json:"error"` + Message string `json:"message"` +} + +func (e *APIError) Error() string { + if e.ErrorCode == "" { + return fmt.Sprintf("esa API error: status=%d", e.StatusCode) + } + return fmt.Sprintf("esa API error: status=%d error=%s message=%s", e.StatusCode, e.ErrorCode, e.Message) +} + +func (c *Client) do(ctx context.Context, method, path string, query url.Values, body any) ([]byte, error) { + u, err := url.Parse(c.BaseURL + path) + if err != nil { + return nil, fmt.Errorf("parse url: %w", err) + } + if len(query) > 0 { + u.RawQuery = query.Encode() + } + + var reqBody io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal body: %w", err) + } + reqBody = bytes.NewReader(b) + } + + req, err := http.NewRequestWithContext(ctx, method, u.String(), reqBody) + if err != nil { + return nil, fmt.Errorf("new request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.Token) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("http: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + + if resp.StatusCode >= 400 { + apiErr := &APIError{StatusCode: resp.StatusCode} + _ = json.Unmarshal(respBody, apiErr) + return nil, apiErr + } + + return respBody, nil +} + +func (c *Client) GetPost(ctx context.Context, number int) (*Post, error) { + path := fmt.Sprintf("/v1/teams/%s/posts/%d", url.PathEscape(c.Team), number) + body, err := c.do(ctx, http.MethodGet, path, nil, nil) + if err != nil { + return nil, err + } + var p Post + if err := json.Unmarshal(body, &p); err != nil { + return nil, fmt.Errorf("unmarshal post: %w", err) + } + return &p, nil +} + +type SearchOptions struct { + Query string + Page int + PerPage int + Sort string + Order string +} + +func (c *Client) SearchPosts(ctx context.Context, opts SearchOptions) (*PostsResponse, error) { + path := fmt.Sprintf("/v1/teams/%s/posts", url.PathEscape(c.Team)) + q := url.Values{} + if opts.Query != "" { + q.Set("q", opts.Query) + } + if opts.Page > 0 { + q.Set("page", strconv.Itoa(opts.Page)) + } + if opts.PerPage > 0 { + q.Set("per_page", strconv.Itoa(opts.PerPage)) + } + if opts.Sort != "" { + q.Set("sort", opts.Sort) + } + if opts.Order != "" { + q.Set("order", opts.Order) + } + body, err := c.do(ctx, http.MethodGet, path, q, nil) + if err != nil { + return nil, err + } + var r PostsResponse + if err := json.Unmarshal(body, &r); err != nil { + return nil, fmt.Errorf("unmarshal posts: %w", err) + } + return &r, nil +} + +func (c *Client) CreatePost(ctx context.Context, input PostInput) (*Post, error) { + path := fmt.Sprintf("/v1/teams/%s/posts", url.PathEscape(c.Team)) + body, err := c.do(ctx, http.MethodPost, path, nil, postCreateRequest{Post: input}) + if err != nil { + return nil, err + } + var p Post + if err := json.Unmarshal(body, &p); err != nil { + return nil, fmt.Errorf("unmarshal post: %w", err) + } + return &p, nil +} + +func (c *Client) UpdatePost(ctx context.Context, number int, input PostInput) (*Post, error) { + path := fmt.Sprintf("/v1/teams/%s/posts/%d", url.PathEscape(c.Team), number) + body, err := c.do(ctx, http.MethodPatch, path, nil, postCreateRequest{Post: input}) + if err != nil { + return nil, err + } + var p Post + if err := json.Unmarshal(body, &p); err != nil { + return nil, fmt.Errorf("unmarshal post: %w", err) + } + return &p, nil +} diff --git a/esa/client_test.go b/esa/client_test.go new file mode 100644 index 0000000..aa548fd --- /dev/null +++ b/esa/client_test.go @@ -0,0 +1,223 @@ +package esa + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func newTestClient(t *testing.T, handler http.Handler) (*Client, func()) { + t.Helper() + srv := httptest.NewServer(handler) + c := NewClient("docs", "test-token") + c.BaseURL = srv.URL + return c, srv.Close +} + +func TestGetPost(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got, want := r.Method, http.MethodGet; got != want { + t.Errorf("method = %q, want %q", got, want) + } + if got, want := r.URL.Path, "/v1/teams/docs/posts/5"; got != want { + t.Errorf("path = %q, want %q", got, want) + } + if got, want := r.Header.Get("Authorization"), "Bearer test-token"; got != want { + t.Errorf("auth header = %q, want %q", got, want) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "number": 5, + "name": "hi!", + "full_name": "dev/2015/05/10/hi! #api #dev", + "wip": false, + "body_md": "# Getting Started\n", + "tags": ["api", "dev"], + "category": "dev/2015/05/10", + "revision_number": 1 + }`)) + }) + c, cleanup := newTestClient(t, handler) + defer cleanup() + + post, err := c.GetPost(context.Background(), 5) + if err != nil { + t.Fatalf("GetPost: %v", err) + } + if post.Number != 5 { + t.Errorf("Number = %d, want 5", post.Number) + } + if post.Name != "hi!" { + t.Errorf("Name = %q, want %q", post.Name, "hi!") + } + if got, want := post.BodyMD, "# Getting Started\n"; got != want { + t.Errorf("BodyMD = %q, want %q", got, want) + } + if got, want := post.Tags, []string{"api", "dev"}; !equalStrings(got, want) { + t.Errorf("Tags = %v, want %v", got, want) + } +} + +func TestSearchPosts(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got, want := r.Method, http.MethodGet; got != want { + t.Errorf("method = %q, want %q", got, want) + } + if got, want := r.URL.Path, "/v1/teams/docs/posts"; got != want { + t.Errorf("path = %q, want %q", got, want) + } + q := r.URL.Query() + if got, want := q.Get("q"), "hello"; got != want { + t.Errorf("q param = %q, want %q", got, want) + } + if got, want := q.Get("per_page"), "10"; got != want { + t.Errorf("per_page = %q, want %q", got, want) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "posts": [ + {"number": 1, "name": "hi!", "body_md": "# Getting Started"}, + {"number": 2, "name": "bye!", "body_md": "# Bye"} + ], + "prev_page": null, + "next_page": null, + "total_count": 2, + "page": 1, + "per_page": 10, + "max_per_page": 100 + }`)) + }) + c, cleanup := newTestClient(t, handler) + defer cleanup() + + resp, err := c.SearchPosts(context.Background(), SearchOptions{Query: "hello", PerPage: 10}) + if err != nil { + t.Fatalf("SearchPosts: %v", err) + } + if resp.TotalCount != 2 { + t.Errorf("TotalCount = %d, want 2", resp.TotalCount) + } + if len(resp.Posts) != 2 { + t.Fatalf("len(Posts) = %d, want 2", len(resp.Posts)) + } + if resp.Posts[0].Number != 1 { + t.Errorf("Posts[0].Number = %d, want 1", resp.Posts[0].Number) + } +} + +func TestCreatePost(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got, want := r.Method, http.MethodPost; got != want { + t.Errorf("method = %q, want %q", got, want) + } + if got, want := r.URL.Path, "/v1/teams/docs/posts"; got != want { + t.Errorf("path = %q, want %q", got, want) + } + if got, want := r.Header.Get("Content-Type"), "application/json"; got != want { + t.Errorf("content-type = %q, want %q", got, want) + } + var body struct { + Post PostInput `json:"post"` + } + raw, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatalf("decode body: %v (raw=%s)", err, raw) + } + if body.Post.Name != "hi!" { + t.Errorf("post.name = %q, want %q", body.Post.Name, "hi!") + } + if body.Post.WIP == nil || *body.Post.WIP { + t.Errorf("post.wip should be false (got %v)", body.Post.WIP) + } + w.WriteHeader(http.StatusCreated) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "number": 5, + "name": "hi!", + "body_md": "# Getting Started\n", + "wip": false, + "category": "dev/2015/05/10", + "tags": ["api", "dev"] + }`)) + }) + c, cleanup := newTestClient(t, handler) + defer cleanup() + + wip := false + post, err := c.CreatePost(context.Background(), PostInput{ + Name: "hi!", + BodyMD: "# Getting Started\n", + Tags: []string{"api", "dev"}, + Category: "dev/2015/05/10", + WIP: &wip, + Message: "first commit", + }) + if err != nil { + t.Fatalf("CreatePost: %v", err) + } + if post.Number != 5 { + t.Errorf("Number = %d, want 5", post.Number) + } + if post.WIP { + t.Errorf("WIP = true, want false") + } +} + +func TestUpdatePost(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got, want := r.Method, http.MethodPatch; got != want { + t.Errorf("method = %q, want %q", got, want) + } + if got, want := r.URL.Path, "/v1/teams/docs/posts/5"; got != want { + t.Errorf("path = %q, want %q", got, want) + } + var body struct { + Post PostInput `json:"post"` + } + raw, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.Post.Name != "updated" { + t.Errorf("post.name = %q, want %q", body.Post.Name, "updated") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "number": 5, + "name": "updated", + "body_md": "new body", + "revision_number": 2 + }`)) + }) + c, cleanup := newTestClient(t, handler) + defer cleanup() + + post, err := c.UpdatePost(context.Background(), 5, PostInput{ + Name: "updated", + BodyMD: "new body", + }) + if err != nil { + t.Fatalf("UpdatePost: %v", err) + } + if post.Name != "updated" { + t.Errorf("Name = %q, want %q", post.Name, "updated") + } + if post.RevisionNumber != 2 { + t.Errorf("RevisionNumber = %d, want 2", post.RevisionNumber) + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ecce339 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module gitea.ssig33.com/ssig33/esacli + +go 1.25.4 diff --git a/main.go b/main.go new file mode 100644 index 0000000..dd74068 --- /dev/null +++ b/main.go @@ -0,0 +1,261 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "strconv" + "strings" + + "gitea.ssig33.com/ssig33/esacli/esa" +) + +const usage = `esacli - esa.io API CLI + +Usage: + esacli [options] + +Commands: + get Fetch a single post + search Search posts + create Create a new post + update Update an existing post + help Show this help + +Environment variables: + ESA_TOKEN esa.io API access token (required) + ESA_TEAM esa.io team name (required) + +Run "esacli -h" for command-specific options. +` + +func main() { + if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} + +func run(args []string, stdout, stderr io.Writer) error { + if len(args) == 0 { + fmt.Fprint(stderr, usage) + return errors.New("no command specified") + } + + cmd := args[0] + rest := args[1:] + + switch cmd { + case "help", "-h", "--help": + fmt.Fprint(stdout, usage) + return nil + case "get": + return runGet(rest, stdout) + case "search": + return runSearch(rest, stdout) + case "create": + return runCreate(rest, stdout) + case "update": + return runUpdate(rest, stdout) + default: + fmt.Fprint(stderr, usage) + return fmt.Errorf("unknown command: %s", cmd) + } +} + +func newClient() (*esa.Client, error) { + token := os.Getenv("ESA_TOKEN") + if token == "" { + return nil, errors.New("ESA_TOKEN is not set") + } + team := os.Getenv("ESA_TEAM") + if team == "" { + return nil, errors.New("ESA_TEAM is not set") + } + c := esa.NewClient(team, token) + if base := os.Getenv("ESA_BASE_URL"); base != "" { + c.BaseURL = base + } + return c, nil +} + +func runGet(args []string, stdout io.Writer) error { + fs := flag.NewFlagSet("get", flag.ContinueOnError) + number := fs.Int("number", 0, "post number (required)") + bodyOnly := fs.Bool("body", false, "print only body_md instead of JSON") + if err := fs.Parse(args); err != nil { + return err + } + if *number <= 0 { + return errors.New("get: -number is required") + } + c, err := newClient() + if err != nil { + return err + } + post, err := c.GetPost(context.Background(), *number) + if err != nil { + return err + } + if *bodyOnly { + fmt.Fprint(stdout, post.BodyMD) + return nil + } + return printJSON(stdout, post) +} + +func runSearch(args []string, stdout io.Writer) error { + fs := flag.NewFlagSet("search", flag.ContinueOnError) + query := fs.String("q", "", "search query") + page := fs.Int("page", 0, "page number") + perPage := fs.Int("per-page", 0, "results per page (max 100)") + sort := fs.String("sort", "", "sort field (updated, created, number, stars, watches, comments, best_match)") + order := fs.String("order", "", "order (desc, asc)") + if err := fs.Parse(args); err != nil { + return err + } + c, err := newClient() + if err != nil { + return err + } + resp, err := c.SearchPosts(context.Background(), esa.SearchOptions{ + Query: *query, + Page: *page, + PerPage: *perPage, + Sort: *sort, + Order: *order, + }) + if err != nil { + return err + } + return printJSON(stdout, resp) +} + +func runCreate(args []string, stdout io.Writer) error { + fs := flag.NewFlagSet("create", flag.ContinueOnError) + name := fs.String("name", "", "post title (required)") + bodyFile := fs.String("body-file", "", "path to file containing body_md ('-' for stdin)") + body := fs.String("body", "", "body_md (use -body-file for longer content)") + tagsCSV := fs.String("tags", "", "comma-separated tags") + category := fs.String("category", "", "category") + wip := fs.Bool("wip", true, "WIP flag") + message := fs.String("message", "", "commit-like message") + if err := fs.Parse(args); err != nil { + return err + } + if *name == "" { + return errors.New("create: -name is required") + } + bodyMD, err := resolveBody(*body, *bodyFile) + if err != nil { + return err + } + c, err := newClient() + if err != nil { + return err + } + wipVal := *wip + post, err := c.CreatePost(context.Background(), esa.PostInput{ + Name: *name, + BodyMD: bodyMD, + Tags: splitCSV(*tagsCSV), + Category: *category, + WIP: &wipVal, + Message: *message, + }) + if err != nil { + return err + } + return printJSON(stdout, post) +} + +func runUpdate(args []string, stdout io.Writer) error { + fs := flag.NewFlagSet("update", flag.ContinueOnError) + number := fs.Int("number", 0, "post number (required)") + name := fs.String("name", "", "new title") + bodyFile := fs.String("body-file", "", "path to file containing body_md ('-' for stdin)") + body := fs.String("body", "", "body_md (use -body-file for longer content)") + tagsCSV := fs.String("tags", "", "comma-separated tags") + category := fs.String("category", "", "category") + wipFlag := fs.String("wip", "", "WIP flag (true/false); empty leaves unchanged") + message := fs.String("message", "", "commit-like message") + if err := fs.Parse(args); err != nil { + return err + } + if *number <= 0 { + return errors.New("update: -number is required") + } + bodyMD, err := resolveBody(*body, *bodyFile) + if err != nil { + return err + } + input := esa.PostInput{ + Name: *name, + BodyMD: bodyMD, + Tags: splitCSV(*tagsCSV), + Category: *category, + Message: *message, + } + if *wipFlag != "" { + v, err := strconv.ParseBool(*wipFlag) + if err != nil { + return fmt.Errorf("update: -wip: %w", err) + } + input.WIP = &v + } + c, err := newClient() + if err != nil { + return err + } + post, err := c.UpdatePost(context.Background(), *number, input) + if err != nil { + return err + } + return printJSON(stdout, post) +} + +func resolveBody(inline, file string) (string, error) { + if inline != "" && file != "" { + return "", errors.New("specify only one of -body or -body-file") + } + if file == "" { + return inline, nil + } + if file == "-" { + b, err := io.ReadAll(os.Stdin) + if err != nil { + return "", fmt.Errorf("read stdin: %w", err) + } + return string(b), nil + } + b, err := os.ReadFile(file) + if err != nil { + return "", fmt.Errorf("read body file: %w", err) + } + return string(b), nil +} + +func splitCSV(s string) []string { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +func printJSON(w io.Writer, v any) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(v) +}