[自動コミット] esa.io API を操作する Go 製 CLI ツールを新規作成
All checks were successful
CI / Build & Test (push) Successful in 34s
All checks were successful
CI / Build & Test (push) Successful in 34s
- esa.io API クライアントの実装と単体テストを追加 - 記事の取得・検索・作成・更新を行うサブコマンドを実装 - GitHub Actions による CI ワークフローを設定 - プロジェクトの基本ファイル(README、.gitignore、LICENSE、go.mod)を作成
This commit is contained in:
36
.github/workflows/ci.yml
vendored
Normal file
36
.github/workflows/ci.yml
vendored
Normal file
@@ -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 ./...
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/esacli
|
||||
/dist/
|
||||
*.test
|
||||
*.out
|
||||
13
LICENSE
Normal file
13
LICENSE
Normal file
@@ -0,0 +1,13 @@
|
||||
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||
Version 2, December 2004
|
||||
|
||||
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
|
||||
|
||||
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.
|
||||
89
README.md
Normal file
89
README.md
Normal file
@@ -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 <command> [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)。
|
||||
224
esa/client.go
Normal file
224
esa/client.go
Normal file
@@ -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
|
||||
}
|
||||
223
esa/client_test.go
Normal file
223
esa/client_test.go
Normal file
@@ -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
|
||||
}
|
||||
261
main.go
Normal file
261
main.go
Normal file
@@ -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 <command> [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 <command> -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)
|
||||
}
|
||||
Reference in New Issue
Block a user