[自動コミット] 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

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
}