Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions docs/plan.md

Large diffs are not rendered by default.

38 changes: 38 additions & 0 deletions internal/jira/addworklog.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package jira

import (
"context"
"fmt"
"net/http"
"time"
)

// newWorklog is the body of a new worklog: when the work started, in Jira's layout, and how long
// it took.
type newWorklog struct {
Started string `json:"started"`
TimeSpentSeconds int64 `json:"timeSpentSeconds"`
}

// AddWorklog logs worklog on the ticket key as the user, in whole seconds (decision 189). Jira
// keeps its defaults: it emails the ticket's watchers, and takes the time off the ticket's
// remaining estimate. A key that isn't a Jira key fails before anything is sent.
func (c *Client) AddWorklog(ctx context.Context, key string, worklog Worklog) error {
const action = "add a Jira worklog"

// The key is a path segment, which "." or ".." would move.
if !IsKey(key) {
return fmt.Errorf("%s: %q isn't a Jira key", action, key)
}

req, err := newPost(ctx, c.settings.API.JoinPath("rest/api/3/issue", key, "worklog"),
newWorklog{
Started: worklog.Started.Format(startedLayout),
TimeSpentSeconds: int64(worklog.Spent / time.Second),
})
if err != nil {
return fmt.Errorf("%s: %w", action, err)
}

return c.do(req, http.StatusCreated, action, nil)
}
105 changes: 105 additions & 0 deletions internal/jira/addworklog_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package jira_test

import (
"io"
"net/http"
"testing"
"time"

"github.com/codedogapp/bakhyard/internal/jira"
)

// worklogPath is where the fake Jira takes WEB-9's worklogs.
const worklogPath = apiPath + "/rest/api/3/issue/WEB-9/worklog"

// added is what the fake Jira got in a request.
type added struct {
method, path, query, contentType, accept, user, password, body string
}

// adder returns a client whose fake Jira answers every request with status and body, and the
// requests it got.
func adder(t *testing.T, status int, body string) (*jira.Client, *[]added) {
t.Helper()

var got []added

client := clientOf(t, func(w http.ResponseWriter, r *http.Request) {
sent, err := io.ReadAll(r.Body)
if err != nil {
t.Error(err)
}

user, password, _ := r.BasicAuth()
got = append(got, added{
r.Method, r.URL.Path, r.URL.RawQuery, r.Header.Get("Content-Type"),
r.Header.Get("Accept"), user, password, string(sent),
})

w.WriteHeader(status)
_, _ = io.WriteString(w, body)
})

return client, &got
}

func zone(t *testing.T, name string) *time.Location {
t.Helper()

loc, err := time.LoadLocation(name)
if err != nil {
t.Fatal(err)
}

return loc
}

func TestAddWorklogPostsTheWorklog(t *testing.T) {
client, got := adder(t, http.StatusCreated, `{"id":"100028","timeSpentSeconds":5400}`)
start := time.Date(2026, 10, 26, 9, 0, 0, 0, zone(t, "Europe/Budapest"))

err := client.AddWorklog(t.Context(), "WEB-9", jira.Worklog{
Started: start, Spent: 90 * time.Minute,
})

want := added{
http.MethodPost, worklogPath, "", "application/json", "application/json", email, token,
`{"started":"2026-10-26T09:00:00.000+0100","timeSpentSeconds":5400}`,
}
if err != nil || len(*got) != 1 || (*got)[0] != want {
t.Errorf("got %v and the requests %+v, want no error and one request %+v", err, *got,
want)
}
}

func TestAddWorklogSendsTheStartAsItIs(t *testing.T) {
for _, test := range []struct {
started time.Time
spent time.Duration
want string
}{
{
time.Date(2026, 10, 26, 8, 0, 0, 0, time.UTC), time.Minute,
`{"started":"2026-10-26T08:00:00.000+0000","timeSpentSeconds":60}`,
},
{
time.Date(2026, 7, 1, 9, 0, 0, 0, zone(t, "America/St_Johns")), 24 * time.Hour,
`{"started":"2026-07-01T09:00:00.000-0230","timeSpentSeconds":86400}`,
},
{
time.Date(2026, 10, 26, 9, 0, 0, 250_900_000, zone(t, "Europe/Budapest")),
1500 * time.Millisecond,
`{"started":"2026-10-26T09:00:00.250+0100","timeSpentSeconds":1}`,
},
} {
client, got := adder(t, http.StatusCreated, "")

err := client.AddWorklog(t.Context(), "WEB-9", jira.Worklog{
Started: test.started, Spent: test.spent,
})
if err != nil || len(*got) != 1 || (*got)[0].body != test.want {
t.Errorf("%v for %v: got %v and the requests %+v, want the body %s", test.started,
test.spent, err, *got, test.want)
}
}
}
99 changes: 99 additions & 0 deletions internal/jira/addworklogfail_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package jira_test

import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"

"github.com/codedogapp/bakhyard/internal/jira"
)

func TestAddWorklogFailsUnlessJiraAddsIt(t *testing.T) {
for _, status := range []int{
http.StatusOK, http.StatusNoContent, http.StatusBadRequest, http.StatusUnauthorized,
http.StatusForbidden, http.StatusNotFound, http.StatusRequestEntityTooLarge,
http.StatusInternalServerError,
} {
client, _ := adder(t, status, `{"errorMessages":["No"],"errors":{}}`)

err := client.AddWorklog(t.Context(), "WEB-9", jira.Worklog{
Started: time.Date(2026, 10, 26, 9, 0, 0, 0, time.UTC), Spent: time.Hour,
})

want := "add a Jira worklog: " + strconv.Itoa(status) + " " + http.StatusText(status)
if err == nil || err.Error() != want {
t.Errorf("%d: got %v, want %s", status, err, want)
}
}
}

func TestAddWorklogRefusesWhatIsntAKey(t *testing.T) {
for _, key := range []string{
"", ".", "..", "web-9", "WEB-0", "WEB-9/x", " WEB-9", "WEB-9\n", "../../myself",
} {
client, got := adder(t, http.StatusCreated, "")

err := client.AddWorklog(t.Context(), key, jira.Worklog{
Started: time.Date(2026, 10, 26, 9, 0, 0, 0, time.UTC), Spent: time.Hour,
})

want := "add a Jira worklog: " + strconv.Quote(key) + " isn't a Jira key"
if err == nil || err.Error() != want || len(*got) != 0 {
t.Errorf("%q: got %v after the requests %+v, want %s before any", key, err, *got,
want)
}
}
}

func TestAddWorklogFailsWithoutJira(t *testing.T) {
server := httptest.NewServer(http.NotFoundHandler())
server.Close()

client := jira.New(jira.Settings{
Site: parse(t, "https://acme.example"), API: parse(t, server.URL),
Email: email, Token: token,
})

err := client.AddWorklog(t.Context(), "WEB-9", jira.Worklog{
Started: time.Date(2026, 10, 26, 9, 0, 0, 0, time.UTC), Spent: time.Hour,
})
if err == nil || !strings.HasPrefix(err.Error(), "add a Jira worklog: ") ||
strings.Contains(err.Error(), token) {
t.Errorf("got %v, want an error that names the worklog", err)
}
}

func TestAddWorklogStopsWhenItsContextEnds(t *testing.T) {
client, got := adder(t, http.StatusCreated, "")

ctx, cancel := context.WithCancel(t.Context())
cancel()

err := client.AddWorklog(ctx, "WEB-9", jira.Worklog{
Started: time.Date(2026, 10, 26, 9, 0, 0, 0, time.UTC), Spent: time.Hour,
})
if !errors.Is(err, context.Canceled) || len(*got) != 0 {
t.Errorf("got %v after the requests %+v, want context.Canceled before any", err, *got)
}
}

func TestAddWorklogFailsOnAnAddressItCantAsk(t *testing.T) {
api := parse(t, "https://acme.example")
api.Host = "acme example"

client := jira.New(jira.Settings{
Site: parse(t, "https://acme.example"), API: api, Email: email, Token: token,
})

err := client.AddWorklog(t.Context(), "WEB-9", jira.Worklog{
Started: time.Date(2026, 10, 26, 9, 0, 0, 0, time.UTC), Spent: time.Hour,
})
if err == nil || !strings.HasPrefix(err.Error(), `add a Jira worklog: parse "https://acme`) {
t.Errorf("got %v, want an error that names the worklog", err)
}
}
44 changes: 34 additions & 10 deletions internal/jira/client.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
// Package jira reads the user's tickets and worklogs on a Jira Cloud site, through Jira's REST
// API v3 and a scoped API token: it finds the open tickets, fetches tickets by key and reads the
// user's worklogs, for each service that the Jira settings set up (docs/plan.md §2, decisions
// 103, 121, 133 and 180).
// API v3 and a scoped API token: it finds the open tickets, fetches tickets by key, and reads and
// adds the user's worklogs, for each service that the Jira settings set up (docs/plan.md §2,
// decisions 103, 121, 133, 180 and 189).
package jira

import (
"bytes"
"context"
"encoding/json/v2"
"fmt"
Expand Down Expand Up @@ -42,7 +43,7 @@ type Settings struct {
Token string
}

// Client reads tickets and worklogs on a Jira site.
// Client reads tickets and worklogs on a Jira site, and adds worklogs.
type Client struct {
settings Settings
client *http.Client
Expand Down Expand Up @@ -149,19 +150,38 @@ func (c *Client) page(ctx context.Context, jql, fields, token string) (page, err
return found, nil
}

// get asks Jira for target and reads Jira's answer into answer, as do does.
// get asks Jira for target and reads Jira's answer, a 200, into answer, as do does.
func (c *Client) get(ctx context.Context, target *url.URL, action string, answer any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
if err != nil {
return fmt.Errorf("%s: %w", action, err)
}

return c.do(req, action, answer)
return c.do(req, http.StatusOK, action, answer)
}

// do sends req to Jira as the user and reads Jira's answer into answer. Its errors start with
// action, such as "search Jira", or with "read Jira's answer" when the answer is no use.
func (c *Client) do(req *http.Request, action string, answer any) error {
// newPost returns a request that posts body to target as JSON.
func newPost(ctx context.Context, target *url.URL, body any) (*http.Request, error) {
data, err := json.Marshal(body)
if err != nil {
return nil, err
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(),
bytes.NewReader(data))
if err != nil {
return nil, err
}

req.Header.Set("Content-Type", "application/json")

return req, nil
}

// do sends req to Jira as the user and, unless answer is nil, reads Jira's answer into answer.
// Jira must answer with the status want. Its errors start with action, such as "search Jira",
// or with "read Jira's answer" when the answer is no use.
func (c *Client) do(req *http.Request, want int, action string, answer any) error {
req.SetBasicAuth(c.settings.Email, c.settings.Token)
req.Header.Set("Accept", "application/json")

Expand All @@ -172,10 +192,14 @@ func (c *Client) do(req *http.Request, action string, answer any) error {

defer func() { _ = res.Body.Close() }()

if res.StatusCode != http.StatusOK {
if res.StatusCode != want {
return fmt.Errorf("%s: %s", action, res.Status)
}

if answer == nil {
return nil
}

err = json.UnmarshalRead(io.LimitReader(res.Body, maxAnswer), answer)
if err != nil {
return fmt.Errorf("read Jira's answer: %w", err)
Expand Down
17 changes: 3 additions & 14 deletions internal/jira/fetch.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
package jira

import (
"bytes"
"context"
"encoding/json/v2"
"fmt"
"net/http"
"slices"
Expand Down Expand Up @@ -47,24 +45,15 @@ func (c *Client) Fetch(ctx context.Context, keys []string) ([]Ticket, error) {

// fetch returns Jira's answer for a batch of at most fetchSize keys.
func (c *Client) fetch(ctx context.Context, keys []string) (fetched, error) {
body, err := json.Marshal(bulkFetch{Keys: keys, Fields: []string{"summary", "status"}})
req, err := newPost(ctx, c.settings.API.JoinPath("rest/api/3/issue/bulkfetch"),
bulkFetch{Keys: keys, Fields: []string{"summary", "status"}})
if err != nil {
return fetched{}, fmt.Errorf("fetch Jira tickets: %w", err)
}

target := c.settings.API.JoinPath("rest/api/3/issue/bulkfetch")

req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(),
bytes.NewReader(body))
if err != nil {
return fetched{}, fmt.Errorf("fetch Jira tickets: %w", err)
}

req.Header.Set("Content-Type", "application/json")

var found fetched

err = c.do(req, "fetch Jira tickets", &found)
err = c.do(req, http.StatusOK, "fetch Jira tickets", &found)
if err != nil {
return fetched{}, err
}
Expand Down
13 changes: 13 additions & 0 deletions internal/jira/key.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package jira

import "regexp"

// keyRule matches a Jira ticket's key, as Jira makes them: its project's key, an upper-case
// letter and at least one more upper-case letter, digit or underscore, then a hyphen and the
// ticket's number (decision 125).
var keyRule = regexp.MustCompile(`^[A-Z][A-Z0-9_]+-[1-9][0-9]*$`)

// IsKey reports whether s is a Jira ticket's key, such as ABC-123.
func IsKey(s string) bool {
return keyRule.MatchString(s)
}
Loading
Loading