relay-flow 0.2.0-alpha → 0.2.2-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +171 -26
- package/cmd/relay-flow/beads_composition_test.go +451 -0
- package/cmd/relay-flow/commands_test.go +593 -15
- package/cmd/relay-flow/main.go +356 -119
- package/cmd/relay-flow/scenario_test.go +246 -35
- package/cmd/relay-flow/serve.go +5 -2
- package/examples/beads-workflow.yaml +74 -0
- package/examples/default-story-workflow.yaml +88 -0
- package/go.mod +2 -1
- package/go.sum +2 -0
- package/internal/config/config.go +13 -2
- package/internal/config/merge_test.go +18 -0
- package/internal/execution/goworkflows/activities.go +136 -87
- package/internal/execution/goworkflows/end_feedback_test.go +124 -0
- package/internal/execution/goworkflows/engine.go +41 -8
- package/internal/execution/goworkflows/engine_test.go +100 -22
- package/internal/execution/goworkflows/fakes_test.go +63 -25
- package/internal/execution/goworkflows/interpreter.go +45 -30
- package/internal/execution/goworkflows/mailbox_test.go +85 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
- package/internal/execution/goworkflows/node_runtime_test.go +72 -23
- package/internal/execution/goworkflows/recovery_test.go +7 -7
- package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
- package/internal/execution/goworkflows/retry_log_test.go +11 -11
- package/internal/harness/contract_test.go +15 -0
- package/internal/harness/factory.go +25 -3
- package/internal/harness/harness.go +30 -4
- package/internal/harness/opencode/opencode.go +125 -10
- package/internal/harness/opencode/opencode_test.go +183 -0
- package/internal/harness/opencode/repo_setup.go +361 -0
- package/internal/harness/plugin_selection_test.go +5 -5
- package/internal/paths/paths.go +18 -16
- package/internal/recover/recover.go +11 -6
- package/internal/repo/repo.go +13 -0
- package/internal/repo/service.go +10 -0
- package/internal/repo/service_test.go +55 -6
- package/internal/router/router.go +3 -2
- package/internal/router/router_test.go +87 -0
- package/internal/run/manager.go +14 -1
- package/internal/run/run.go +6 -4
- package/internal/run/run_manager_test.go +21 -1
- package/internal/runner/contract_test.go +64 -26
- package/internal/runner/orca/orca.go +30 -54
- package/internal/runner/orca/orca_test.go +143 -4
- package/internal/runner/orca/orcacli/orcacli.go +5 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
- package/internal/runner/runner.go +15 -8
- package/internal/task/auth_test.go +48 -0
- package/internal/task/beads/bdcli/bdcli.go +323 -0
- package/internal/task/beads/bdcli/bdcli_test.go +297 -0
- package/internal/task/beads/bdcli/testdata/array.json +1 -0
- package/internal/task/beads/bdcli/testdata/children.json +1 -0
- package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
- package/internal/task/beads/bdcli/testdata/commented.json +1 -0
- package/internal/task/beads/bdcli/testdata/comments.json +1 -0
- package/internal/task/beads/bdcli/testdata/created.json +1 -0
- package/internal/task/beads/bdcli/testdata/object.json +1 -0
- package/internal/task/beads/bdcli/testdata/ready.json +1 -0
- package/internal/task/beads/bdcli/testdata/show.json +1 -0
- package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
- package/internal/task/beads/bdcli/testdata/updated.json +1 -0
- package/internal/task/beads/beads.go +840 -0
- package/internal/task/beads/beads_test.go +609 -0
- package/internal/task/beads/comments_test.go +242 -0
- package/internal/task/beads/config_compatibility_test.go +163 -0
- package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
- package/internal/task/beads/repo_composition_test.go +232 -0
- package/internal/task/beads/runtime_config_test.go +81 -0
- package/internal/task/beads/status_compatibility_test.go +233 -0
- package/internal/task/beads/status_test.go +257 -0
- package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
- package/internal/task/beads/validation_test.go +110 -0
- package/internal/task/contract_test.go +12 -0
- package/internal/task/factory.go +52 -3
- package/internal/task/jira/auth.go +209 -0
- package/internal/task/jira/auth_test.go +160 -0
- package/internal/task/jira/effects_test.go +39 -0
- package/internal/task/jira/filters_test.go +96 -16
- package/internal/task/jira/helpers_test.go +29 -19
- package/internal/task/jira/jira.go +263 -90
- package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
- package/internal/task/jira/normalize.go +32 -14
- package/internal/task/jira/rest/adf.go +165 -0
- package/internal/task/jira/rest/adf_test.go +60 -0
- package/internal/task/jira/rest/client.go +573 -0
- package/internal/task/jira/rest/client_test.go +381 -0
- package/internal/task/jira/templates_test.go +118 -0
- package/internal/task/jira/transition_defaults_test.go +22 -18
- package/internal/task/jira/validation_test.go +1 -1
- package/internal/task/task.go +32 -0
- package/internal/workflow/report_test.go +45 -0
- package/internal/workflow/workflow.go +9 -6
- package/internal/workflow/workflow_test.go +14 -12
- package/package.json +2 -1
- package/internal/task/jira/acli/acli.go +0 -306
- package/internal/task/jira/acli/acli_test.go +0 -208
- package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
- /package/internal/task/{jira/acli/testdata/search_success.json → beads/bdcli/testdata/empty.json} +0 -0
- /package/internal/task/jira/testdata/{acli_search.json → jira_search_issues.json} +0 -0
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
// Package rest is the small Jira REST API v3 boundary used by the Jira task
|
|
2
|
+
// adapter. It intentionally covers only relay-flow operations.
|
|
3
|
+
package rest
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"bytes"
|
|
7
|
+
"context"
|
|
8
|
+
"encoding/json"
|
|
9
|
+
"errors"
|
|
10
|
+
"fmt"
|
|
11
|
+
"io"
|
|
12
|
+
"log/slog"
|
|
13
|
+
"math/rand/v2"
|
|
14
|
+
"net/http"
|
|
15
|
+
"net/url"
|
|
16
|
+
"strconv"
|
|
17
|
+
"strings"
|
|
18
|
+
"sync"
|
|
19
|
+
"time"
|
|
20
|
+
|
|
21
|
+
"github.com/rajpopat27/relay-flow/internal/retry"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
const maxResponseBytes = 16 << 20
|
|
25
|
+
|
|
26
|
+
var requestSlots = make(chan struct{}, 20)
|
|
27
|
+
|
|
28
|
+
type SubtaskSpec struct {
|
|
29
|
+
Title string
|
|
30
|
+
Description string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type CreatedSubtask struct {
|
|
34
|
+
ID string
|
|
35
|
+
Key string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type Client interface {
|
|
39
|
+
ValidateCredentials(context.Context) error
|
|
40
|
+
Search(context.Context, string) ([]byte, error)
|
|
41
|
+
ValidateAssignee(context.Context, string, string) error
|
|
42
|
+
ValidateStatus(context.Context, string, string) error
|
|
43
|
+
View(context.Context, string) ([]byte, error)
|
|
44
|
+
CreateSubtasks(context.Context, string, string, string, []SubtaskSpec) ([]CreatedSubtask, error)
|
|
45
|
+
UpdateMailbox(context.Context, string, string, string) error
|
|
46
|
+
EnsureLabel(context.Context, string, string) error
|
|
47
|
+
Transition(context.Context, string, string, string) error
|
|
48
|
+
ListComments(context.Context, string) ([]string, error)
|
|
49
|
+
AddComment(context.Context, string, string) error
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type issueState struct {
|
|
53
|
+
IssueType string
|
|
54
|
+
Status string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type transitionInfo struct {
|
|
58
|
+
ID string
|
|
59
|
+
SupportsAssignee bool
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type HTTPClient struct {
|
|
63
|
+
base string
|
|
64
|
+
email string
|
|
65
|
+
token string
|
|
66
|
+
http *http.Client
|
|
67
|
+
|
|
68
|
+
mu sync.Mutex
|
|
69
|
+
issues map[string]issueState
|
|
70
|
+
transitions map[string]transitionInfo
|
|
71
|
+
accounts map[string]string
|
|
72
|
+
statuses map[string]map[string]bool
|
|
73
|
+
subtaskTypes map[string]string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
func New(site, email, token string) (*HTTPClient, error) {
|
|
77
|
+
site = strings.TrimSpace(site)
|
|
78
|
+
if site == "" || strings.TrimSpace(email) == "" || token == "" {
|
|
79
|
+
return nil, errors.New("Jira site, email, and API token are required")
|
|
80
|
+
}
|
|
81
|
+
if !strings.Contains(site, "://") {
|
|
82
|
+
site = "https://" + site
|
|
83
|
+
}
|
|
84
|
+
u, err := url.Parse(site)
|
|
85
|
+
if err != nil || u.Scheme == "" || u.Host == "" {
|
|
86
|
+
return nil, fmt.Errorf("invalid Jira site %q", site)
|
|
87
|
+
}
|
|
88
|
+
if u.Scheme != "https" && u.Hostname() != "127.0.0.1" && u.Hostname() != "localhost" {
|
|
89
|
+
return nil, errors.New("Jira site must use HTTPS")
|
|
90
|
+
}
|
|
91
|
+
return &HTTPClient{
|
|
92
|
+
base: strings.TrimRight(u.String(), "/"),
|
|
93
|
+
email: strings.TrimSpace(email),
|
|
94
|
+
token: token,
|
|
95
|
+
http: &http.Client{Timeout: 30 * time.Second},
|
|
96
|
+
issues: map[string]issueState{},
|
|
97
|
+
transitions: map[string]transitionInfo{},
|
|
98
|
+
accounts: map[string]string{},
|
|
99
|
+
statuses: map[string]map[string]bool{},
|
|
100
|
+
subtaskTypes: map[string]string{},
|
|
101
|
+
}, nil
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
func (c *HTTPClient) ValidateCredentials(ctx context.Context) error {
|
|
105
|
+
var me struct {
|
|
106
|
+
AccountID string `json:"accountId"`
|
|
107
|
+
}
|
|
108
|
+
if err := c.request(ctx, http.MethodGet, "/rest/api/3/myself", nil, nil, &me, true); err != nil {
|
|
109
|
+
return fmt.Errorf("validate Jira credentials: %w", err)
|
|
110
|
+
}
|
|
111
|
+
if me.AccountID == "" {
|
|
112
|
+
return errors.New("validate Jira credentials: Jira returned no account ID")
|
|
113
|
+
}
|
|
114
|
+
return nil
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
func (c *HTTPClient) Search(ctx context.Context, jql string) ([]byte, error) {
|
|
118
|
+
issues := make([]json.RawMessage, 0)
|
|
119
|
+
next := ""
|
|
120
|
+
for {
|
|
121
|
+
body := map[string]any{
|
|
122
|
+
"jql": jql,
|
|
123
|
+
"maxResults": 100,
|
|
124
|
+
"fields": []string{
|
|
125
|
+
"key", "summary", "status", "issuetype", "labels", "assignee", "issuelinks",
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
if next != "" {
|
|
129
|
+
body["nextPageToken"] = next
|
|
130
|
+
}
|
|
131
|
+
var page struct {
|
|
132
|
+
Issues []json.RawMessage `json:"issues"`
|
|
133
|
+
NextPageToken string `json:"nextPageToken"`
|
|
134
|
+
IsLast bool `json:"isLast"`
|
|
135
|
+
}
|
|
136
|
+
if err := c.request(ctx, http.MethodPost, "/rest/api/3/search/jql", nil, body, &page, true); err != nil {
|
|
137
|
+
return nil, err
|
|
138
|
+
}
|
|
139
|
+
issues = append(issues, page.Issues...)
|
|
140
|
+
for _, raw := range page.Issues {
|
|
141
|
+
c.rememberIssue(raw)
|
|
142
|
+
}
|
|
143
|
+
if page.IsLast || page.NextPageToken == "" {
|
|
144
|
+
break
|
|
145
|
+
}
|
|
146
|
+
next = page.NextPageToken
|
|
147
|
+
}
|
|
148
|
+
return json.Marshal(issues)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
func (c *HTTPClient) ValidateAssignee(ctx context.Context, project, assignee string) error {
|
|
152
|
+
_, err := c.accountID(ctx, project, assignee)
|
|
153
|
+
return err
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
func (c *HTTPClient) accountID(ctx context.Context, project, assignee string) (string, error) {
|
|
157
|
+
c.mu.Lock()
|
|
158
|
+
account := c.accounts[project+"\x00"+assignee]
|
|
159
|
+
c.mu.Unlock()
|
|
160
|
+
if account != "" {
|
|
161
|
+
return account, nil
|
|
162
|
+
}
|
|
163
|
+
q := url.Values{"project": {project}, "query": {assignee}, "maxResults": {"50"}}
|
|
164
|
+
var users []struct {
|
|
165
|
+
AccountID string `json:"accountId"`
|
|
166
|
+
EmailAddress string `json:"emailAddress"`
|
|
167
|
+
DisplayName string `json:"displayName"`
|
|
168
|
+
}
|
|
169
|
+
if err := c.request(ctx, http.MethodGet, "/rest/api/3/user/assignable/search", q, nil, &users, true); err != nil {
|
|
170
|
+
return "", err
|
|
171
|
+
}
|
|
172
|
+
for _, user := range users {
|
|
173
|
+
if strings.EqualFold(user.EmailAddress, assignee) || strings.EqualFold(user.AccountID, assignee) || strings.EqualFold(user.DisplayName, assignee) {
|
|
174
|
+
c.mu.Lock()
|
|
175
|
+
c.accounts[project+"\x00"+assignee] = user.AccountID
|
|
176
|
+
c.mu.Unlock()
|
|
177
|
+
return user.AccountID, nil
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if len(users) == 1 && users[0].AccountID != "" {
|
|
181
|
+
c.mu.Lock()
|
|
182
|
+
c.accounts[project+"\x00"+assignee] = users[0].AccountID
|
|
183
|
+
c.mu.Unlock()
|
|
184
|
+
return users[0].AccountID, nil
|
|
185
|
+
}
|
|
186
|
+
return "", fmt.Errorf("assignee %q is not assignable in project %s", assignee, project)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
func (c *HTTPClient) ValidateStatus(ctx context.Context, project, status string) error {
|
|
190
|
+
c.mu.Lock()
|
|
191
|
+
statuses, ok := c.statuses[project]
|
|
192
|
+
c.mu.Unlock()
|
|
193
|
+
if !ok {
|
|
194
|
+
var issueTypes []struct {
|
|
195
|
+
ID string `json:"id"`
|
|
196
|
+
Subtask bool `json:"subtask"`
|
|
197
|
+
Statuses []struct {
|
|
198
|
+
Name string `json:"name"`
|
|
199
|
+
} `json:"statuses"`
|
|
200
|
+
}
|
|
201
|
+
path := "/rest/api/3/project/" + url.PathEscape(project) + "/statuses"
|
|
202
|
+
if err := c.request(ctx, http.MethodGet, path, nil, nil, &issueTypes, true); err != nil {
|
|
203
|
+
return err
|
|
204
|
+
}
|
|
205
|
+
statuses = map[string]bool{}
|
|
206
|
+
subtaskType := ""
|
|
207
|
+
for _, issueType := range issueTypes {
|
|
208
|
+
if issueType.Subtask && subtaskType == "" {
|
|
209
|
+
subtaskType = issueType.ID
|
|
210
|
+
}
|
|
211
|
+
for _, candidate := range issueType.Statuses {
|
|
212
|
+
statuses[strings.ToLower(candidate.Name)] = true
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
c.mu.Lock()
|
|
216
|
+
c.statuses[project] = statuses
|
|
217
|
+
if subtaskType != "" {
|
|
218
|
+
c.subtaskTypes[project] = subtaskType
|
|
219
|
+
}
|
|
220
|
+
c.mu.Unlock()
|
|
221
|
+
}
|
|
222
|
+
if !statuses[strings.ToLower(status)] {
|
|
223
|
+
return fmt.Errorf("status %q is not valid in project %s", status, project)
|
|
224
|
+
}
|
|
225
|
+
return nil
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
func (c *HTTPClient) View(ctx context.Context, key string) ([]byte, error) {
|
|
229
|
+
q := url.Values{"fields": {"summary,status,issuetype,labels,subtasks"}}
|
|
230
|
+
var raw json.RawMessage
|
|
231
|
+
if err := c.request(ctx, http.MethodGet, "/rest/api/3/issue/"+url.PathEscape(key), q, nil, &raw, true); err != nil {
|
|
232
|
+
return nil, err
|
|
233
|
+
}
|
|
234
|
+
c.rememberIssue(raw)
|
|
235
|
+
var issue struct {
|
|
236
|
+
Fields struct {
|
|
237
|
+
Subtasks []json.RawMessage `json:"subtasks"`
|
|
238
|
+
} `json:"fields"`
|
|
239
|
+
}
|
|
240
|
+
if json.Unmarshal(raw, &issue) == nil {
|
|
241
|
+
for _, subtask := range issue.Fields.Subtasks {
|
|
242
|
+
c.rememberIssue(subtask)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return raw, nil
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
func (c *HTTPClient) CreateSubtasks(ctx context.Context, parent, project, label string, specs []SubtaskSpec) ([]CreatedSubtask, error) {
|
|
249
|
+
created := make([]CreatedSubtask, 0, len(specs))
|
|
250
|
+
c.mu.Lock()
|
|
251
|
+
subtaskType := c.subtaskTypes[project]
|
|
252
|
+
c.mu.Unlock()
|
|
253
|
+
if subtaskType == "" {
|
|
254
|
+
if err := c.ValidateStatus(ctx, project, "To Do"); err != nil {
|
|
255
|
+
return nil, err
|
|
256
|
+
}
|
|
257
|
+
c.mu.Lock()
|
|
258
|
+
subtaskType = c.subtaskTypes[project]
|
|
259
|
+
c.mu.Unlock()
|
|
260
|
+
if subtaskType == "" {
|
|
261
|
+
return nil, fmt.Errorf("no subtask issue type found for project %s", project)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
for start := 0; start < len(specs); start += 50 {
|
|
265
|
+
end := min(start+50, len(specs))
|
|
266
|
+
updates := make([]any, 0, end-start)
|
|
267
|
+
for _, spec := range specs[start:end] {
|
|
268
|
+
updates = append(updates, map[string]any{"fields": map[string]any{
|
|
269
|
+
"project": map[string]any{"key": project},
|
|
270
|
+
"parent": map[string]any{"key": parent},
|
|
271
|
+
"issuetype": map[string]any{"id": subtaskType},
|
|
272
|
+
"summary": spec.Title,
|
|
273
|
+
"description": ADF(spec.Description),
|
|
274
|
+
"labels": []string{label},
|
|
275
|
+
}})
|
|
276
|
+
}
|
|
277
|
+
var result struct {
|
|
278
|
+
Issues []CreatedSubtask `json:"issues"`
|
|
279
|
+
Errors []struct {
|
|
280
|
+
FailedElementNumber int `json:"failedElementNumber"`
|
|
281
|
+
Status int `json:"status"`
|
|
282
|
+
} `json:"errors"`
|
|
283
|
+
}
|
|
284
|
+
err := c.request(ctx, http.MethodPost, "/rest/api/3/issue/bulk", nil, map[string]any{"issueUpdates": updates}, &result, false)
|
|
285
|
+
if err != nil {
|
|
286
|
+
return nil, err
|
|
287
|
+
}
|
|
288
|
+
if len(result.Errors) > 0 || len(result.Issues) != end-start {
|
|
289
|
+
return nil, fmt.Errorf("bulk create mailboxes: created %d of %d", len(result.Issues), end-start)
|
|
290
|
+
}
|
|
291
|
+
created = append(created, result.Issues...)
|
|
292
|
+
for _, issue := range result.Issues {
|
|
293
|
+
c.setIssueState(issue.Key, "Sub-task", "To Do")
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return created, nil
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
func (c *HTTPClient) UpdateMailbox(ctx context.Context, key, description, label string) error {
|
|
300
|
+
body := map[string]any{
|
|
301
|
+
"fields": map[string]any{"description": ADF(description)},
|
|
302
|
+
"update": map[string]any{"labels": []any{map[string]any{"add": label}}},
|
|
303
|
+
}
|
|
304
|
+
return c.request(ctx, http.MethodPut, "/rest/api/3/issue/"+url.PathEscape(key), nil, body, nil, false)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
func (c *HTTPClient) EnsureLabel(ctx context.Context, key, label string) error {
|
|
308
|
+
body := map[string]any{"update": map[string]any{"labels": []any{map[string]any{"add": label}}}}
|
|
309
|
+
return c.request(ctx, http.MethodPut, "/rest/api/3/issue/"+url.PathEscape(key), nil, body, nil, false)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
func (c *HTTPClient) Transition(ctx context.Context, key, target, assignee string) error {
|
|
313
|
+
state := c.issueState(key)
|
|
314
|
+
if state.Status == "" || state.IssueType == "" {
|
|
315
|
+
var err error
|
|
316
|
+
state, err = c.refreshIssueState(ctx, key)
|
|
317
|
+
if err != nil {
|
|
318
|
+
return err
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if strings.EqualFold(state.Status, target) {
|
|
322
|
+
if assignee == "" {
|
|
323
|
+
return nil
|
|
324
|
+
}
|
|
325
|
+
project := strings.SplitN(key, "-", 2)[0]
|
|
326
|
+
accountID, err := c.accountID(ctx, project, assignee)
|
|
327
|
+
if err != nil {
|
|
328
|
+
return err
|
|
329
|
+
}
|
|
330
|
+
return c.assign(ctx, key, accountID)
|
|
331
|
+
}
|
|
332
|
+
project := strings.SplitN(key, "-", 2)[0]
|
|
333
|
+
cacheKey := project + "\x00" + state.IssueType + "\x00" + state.Status + "\x00" + target
|
|
334
|
+
c.mu.Lock()
|
|
335
|
+
info, ok := c.transitions[cacheKey]
|
|
336
|
+
c.mu.Unlock()
|
|
337
|
+
if !ok || info.ID == "" {
|
|
338
|
+
var response struct {
|
|
339
|
+
Transitions []struct {
|
|
340
|
+
ID string `json:"id"`
|
|
341
|
+
To struct {
|
|
342
|
+
Name string `json:"name"`
|
|
343
|
+
} `json:"to"`
|
|
344
|
+
Fields map[string]json.RawMessage `json:"fields"`
|
|
345
|
+
} `json:"transitions"`
|
|
346
|
+
}
|
|
347
|
+
q := url.Values{"expand": {"transitions.fields"}}
|
|
348
|
+
if err := c.request(ctx, http.MethodGet, "/rest/api/3/issue/"+url.PathEscape(key)+"/transitions", q, nil, &response, true); err != nil {
|
|
349
|
+
return err
|
|
350
|
+
}
|
|
351
|
+
for _, transition := range response.Transitions {
|
|
352
|
+
if strings.EqualFold(transition.To.Name, target) {
|
|
353
|
+
_, supportsAssignee := transition.Fields["assignee"]
|
|
354
|
+
info = transitionInfo{ID: transition.ID, SupportsAssignee: supportsAssignee}
|
|
355
|
+
break
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if info.ID == "" {
|
|
359
|
+
current, err := c.refreshIssueState(ctx, key)
|
|
360
|
+
if err == nil && strings.EqualFold(current.Status, target) {
|
|
361
|
+
return nil
|
|
362
|
+
}
|
|
363
|
+
return fmt.Errorf("transition to %q is not available for %s", target, key)
|
|
364
|
+
}
|
|
365
|
+
if cacheKey != "\x00\x00"+target {
|
|
366
|
+
c.mu.Lock()
|
|
367
|
+
c.transitions[cacheKey] = info
|
|
368
|
+
c.mu.Unlock()
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
body := map[string]any{"transition": map[string]any{"id": info.ID}}
|
|
373
|
+
if assignee != "" {
|
|
374
|
+
accountID, err := c.accountID(ctx, project, assignee)
|
|
375
|
+
if err != nil {
|
|
376
|
+
return err
|
|
377
|
+
}
|
|
378
|
+
if info.SupportsAssignee {
|
|
379
|
+
body["fields"] = map[string]any{"assignee": map[string]any{"accountId": accountID}}
|
|
380
|
+
} else if err := c.assign(ctx, key, accountID); err != nil {
|
|
381
|
+
return err
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if err := c.request(ctx, http.MethodPost, "/rest/api/3/issue/"+url.PathEscape(key)+"/transitions", nil, body, nil, false); err != nil {
|
|
385
|
+
return err
|
|
386
|
+
}
|
|
387
|
+
c.setIssueState(key, state.IssueType, target)
|
|
388
|
+
return nil
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
func (c *HTTPClient) refreshIssueState(ctx context.Context, key string) (issueState, error) {
|
|
392
|
+
q := url.Values{"fields": {"status,issuetype"}}
|
|
393
|
+
var raw json.RawMessage
|
|
394
|
+
if err := c.request(ctx, http.MethodGet, "/rest/api/3/issue/"+url.PathEscape(key), q, nil, &raw, true); err != nil {
|
|
395
|
+
return issueState{}, err
|
|
396
|
+
}
|
|
397
|
+
c.rememberIssue(raw)
|
|
398
|
+
return c.issueState(key), nil
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
func (c *HTTPClient) assign(ctx context.Context, key, accountID string) error {
|
|
402
|
+
return c.request(ctx, http.MethodPut, "/rest/api/3/issue/"+url.PathEscape(key)+"/assignee", nil, map[string]any{"accountId": accountID}, nil, false)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
func (c *HTTPClient) ListComments(ctx context.Context, key string) ([]string, error) {
|
|
406
|
+
start := 0
|
|
407
|
+
comments := make([]string, 0)
|
|
408
|
+
for {
|
|
409
|
+
q := url.Values{"startAt": {strconv.Itoa(start)}, "maxResults": {"100"}, "orderBy": {"created"}}
|
|
410
|
+
var page struct {
|
|
411
|
+
Comments []struct {
|
|
412
|
+
Body json.RawMessage `json:"body"`
|
|
413
|
+
} `json:"comments"`
|
|
414
|
+
StartAt int `json:"startAt"`
|
|
415
|
+
MaxResults int `json:"maxResults"`
|
|
416
|
+
Total int `json:"total"`
|
|
417
|
+
}
|
|
418
|
+
if err := c.request(ctx, http.MethodGet, "/rest/api/3/issue/"+url.PathEscape(key)+"/comment", q, nil, &page, true); err != nil {
|
|
419
|
+
return nil, err
|
|
420
|
+
}
|
|
421
|
+
for _, comment := range page.Comments {
|
|
422
|
+
comments = append(comments, ADFText(comment.Body))
|
|
423
|
+
}
|
|
424
|
+
start = page.StartAt + len(page.Comments)
|
|
425
|
+
if start >= page.Total || len(page.Comments) == 0 {
|
|
426
|
+
break
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return comments, nil
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
func (c *HTTPClient) AddComment(ctx context.Context, key, body string) error {
|
|
433
|
+
return c.request(ctx, http.MethodPost, "/rest/api/3/issue/"+url.PathEscape(key)+"/comment", nil, map[string]any{"body": ADF(body)}, nil, false)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
func (c *HTTPClient) rememberIssue(raw json.RawMessage) {
|
|
437
|
+
var issue struct {
|
|
438
|
+
Key string `json:"key"`
|
|
439
|
+
Fields struct {
|
|
440
|
+
IssueType struct {
|
|
441
|
+
ID string `json:"id"`
|
|
442
|
+
Name string `json:"name"`
|
|
443
|
+
} `json:"issuetype"`
|
|
444
|
+
Status struct {
|
|
445
|
+
Name string `json:"name"`
|
|
446
|
+
} `json:"status"`
|
|
447
|
+
} `json:"fields"`
|
|
448
|
+
}
|
|
449
|
+
if json.Unmarshal(raw, &issue) == nil && issue.Key != "" {
|
|
450
|
+
issueType := issue.Fields.IssueType.ID
|
|
451
|
+
if issueType == "" {
|
|
452
|
+
issueType = issue.Fields.IssueType.Name
|
|
453
|
+
}
|
|
454
|
+
c.setIssueState(issue.Key, issueType, issue.Fields.Status.Name)
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
func (c *HTTPClient) setIssueState(key, issueType, status string) {
|
|
459
|
+
c.mu.Lock()
|
|
460
|
+
c.issues[key] = issueState{IssueType: issueType, Status: status}
|
|
461
|
+
c.mu.Unlock()
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
func (c *HTTPClient) issueState(key string) issueState {
|
|
465
|
+
c.mu.Lock()
|
|
466
|
+
defer c.mu.Unlock()
|
|
467
|
+
return c.issues[key]
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
func (c *HTTPClient) request(ctx context.Context, method, path string, query url.Values, body any, out any, safe bool) error {
|
|
471
|
+
var payload []byte
|
|
472
|
+
var err error
|
|
473
|
+
if body != nil {
|
|
474
|
+
payload, err = json.Marshal(body)
|
|
475
|
+
if err != nil {
|
|
476
|
+
return fmt.Errorf("encode Jira request: %w", err)
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
endpoint := c.base + path
|
|
480
|
+
if len(query) > 0 {
|
|
481
|
+
endpoint += "?" + query.Encode()
|
|
482
|
+
}
|
|
483
|
+
for attempt := 0; ; attempt++ {
|
|
484
|
+
select {
|
|
485
|
+
case requestSlots <- struct{}{}:
|
|
486
|
+
case <-ctx.Done():
|
|
487
|
+
return ctx.Err()
|
|
488
|
+
}
|
|
489
|
+
req, reqErr := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(payload))
|
|
490
|
+
if reqErr != nil {
|
|
491
|
+
<-requestSlots
|
|
492
|
+
return fmt.Errorf("build Jira request: %w", reqErr)
|
|
493
|
+
}
|
|
494
|
+
req.SetBasicAuth(c.email, c.token)
|
|
495
|
+
req.Header.Set("Accept", "application/json")
|
|
496
|
+
if body != nil {
|
|
497
|
+
req.Header.Set("Content-Type", "application/json")
|
|
498
|
+
}
|
|
499
|
+
slog.Debug("jira call", "method", method, "path", path)
|
|
500
|
+
resp, callErr := c.http.Do(req)
|
|
501
|
+
<-requestSlots
|
|
502
|
+
if callErr != nil {
|
|
503
|
+
return fmt.Errorf("Jira %s %s: %s", method, path, redact(callErr.Error(), c.token, c.email))
|
|
504
|
+
}
|
|
505
|
+
raw, readErr := readBounded(resp.Body)
|
|
506
|
+
resp.Body.Close()
|
|
507
|
+
if readErr != nil {
|
|
508
|
+
return fmt.Errorf("Jira %s %s: %w", method, path, readErr)
|
|
509
|
+
}
|
|
510
|
+
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
511
|
+
if out != nil && len(raw) > 0 {
|
|
512
|
+
if err := json.Unmarshal(raw, out); err != nil {
|
|
513
|
+
return fmt.Errorf("Jira %s %s: parse response: %w", method, path, err)
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
slog.Info("jira outcome", "method", method, "path", path, "result", "ok")
|
|
517
|
+
return nil
|
|
518
|
+
}
|
|
519
|
+
retryable := resp.StatusCode == http.StatusTooManyRequests || (safe && resp.StatusCode >= 500)
|
|
520
|
+
if retryable && attempt < 4 {
|
|
521
|
+
delay := retryDelay(resp.Header.Get("Retry-After"), attempt)
|
|
522
|
+
select {
|
|
523
|
+
case <-ctx.Done():
|
|
524
|
+
return ctx.Err()
|
|
525
|
+
case <-time.After(delay):
|
|
526
|
+
}
|
|
527
|
+
continue
|
|
528
|
+
}
|
|
529
|
+
message := strings.TrimSpace(string(raw))
|
|
530
|
+
message = redact(message, c.token, c.email)
|
|
531
|
+
if len(message) > 2048 {
|
|
532
|
+
message = message[:2048]
|
|
533
|
+
}
|
|
534
|
+
if message == "" {
|
|
535
|
+
message = resp.Status
|
|
536
|
+
}
|
|
537
|
+
slog.Info("jira outcome", "method", method, "path", path, "result", "error", "status", resp.StatusCode)
|
|
538
|
+
return fmt.Errorf("Jira %s %s: HTTP %d: %s", method, path, resp.StatusCode, message)
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
func redact(message string, secrets ...string) string {
|
|
543
|
+
for _, secret := range secrets {
|
|
544
|
+
if secret != "" {
|
|
545
|
+
message = strings.ReplaceAll(message, secret, "[REDACTED]")
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return message
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
func readBounded(r io.Reader) ([]byte, error) {
|
|
552
|
+
raw, err := io.ReadAll(io.LimitReader(r, maxResponseBytes+1))
|
|
553
|
+
if err != nil {
|
|
554
|
+
return nil, err
|
|
555
|
+
}
|
|
556
|
+
if len(raw) > maxResponseBytes {
|
|
557
|
+
return nil, errors.New("response exceeds 16 MiB")
|
|
558
|
+
}
|
|
559
|
+
return raw, nil
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
func retryDelay(value string, attempt int) time.Duration {
|
|
563
|
+
if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
|
|
564
|
+
return time.Duration(seconds) * time.Second
|
|
565
|
+
}
|
|
566
|
+
if when, err := http.ParseTime(value); err == nil {
|
|
567
|
+
if delay := time.Until(when); delay > 0 {
|
|
568
|
+
return delay
|
|
569
|
+
}
|
|
570
|
+
return 0
|
|
571
|
+
}
|
|
572
|
+
return retry.DefaultBackoffPolicy.Delay(attempt, rand.Float64())
|
|
573
|
+
}
|