[自動コミット] esa.io API を操作する Go 製 CLI ツールを新規作成
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:
2026-05-19 08:52:05 +09:00
commit 730f637518
8 changed files with 853 additions and 0 deletions

36
.github/workflows/ci.yml vendored Normal file
View 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
View File

@@ -0,0 +1,4 @@
/esacli
/dist/
*.test
*.out

13
LICENSE Normal file
View 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
View 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
View 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
View 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
}

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module gitea.ssig33.com/ssig33/esacli
go 1.25.4

261
main.go Normal file
View 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)
}