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.
Files changed (102) hide show
  1. package/README.md +171 -26
  2. package/cmd/relay-flow/beads_composition_test.go +451 -0
  3. package/cmd/relay-flow/commands_test.go +593 -15
  4. package/cmd/relay-flow/main.go +356 -119
  5. package/cmd/relay-flow/scenario_test.go +246 -35
  6. package/cmd/relay-flow/serve.go +5 -2
  7. package/examples/beads-workflow.yaml +74 -0
  8. package/examples/default-story-workflow.yaml +88 -0
  9. package/go.mod +2 -1
  10. package/go.sum +2 -0
  11. package/internal/config/config.go +13 -2
  12. package/internal/config/merge_test.go +18 -0
  13. package/internal/execution/goworkflows/activities.go +136 -87
  14. package/internal/execution/goworkflows/end_feedback_test.go +124 -0
  15. package/internal/execution/goworkflows/engine.go +41 -8
  16. package/internal/execution/goworkflows/engine_test.go +100 -22
  17. package/internal/execution/goworkflows/fakes_test.go +63 -25
  18. package/internal/execution/goworkflows/interpreter.go +45 -30
  19. package/internal/execution/goworkflows/mailbox_test.go +85 -0
  20. package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
  21. package/internal/execution/goworkflows/node_runtime_test.go +72 -23
  22. package/internal/execution/goworkflows/recovery_test.go +7 -7
  23. package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
  24. package/internal/execution/goworkflows/retry_log_test.go +11 -11
  25. package/internal/harness/contract_test.go +15 -0
  26. package/internal/harness/factory.go +25 -3
  27. package/internal/harness/harness.go +30 -4
  28. package/internal/harness/opencode/opencode.go +125 -10
  29. package/internal/harness/opencode/opencode_test.go +183 -0
  30. package/internal/harness/opencode/repo_setup.go +361 -0
  31. package/internal/harness/plugin_selection_test.go +5 -5
  32. package/internal/paths/paths.go +18 -16
  33. package/internal/recover/recover.go +11 -6
  34. package/internal/repo/repo.go +13 -0
  35. package/internal/repo/service.go +10 -0
  36. package/internal/repo/service_test.go +55 -6
  37. package/internal/router/router.go +3 -2
  38. package/internal/router/router_test.go +87 -0
  39. package/internal/run/manager.go +14 -1
  40. package/internal/run/run.go +6 -4
  41. package/internal/run/run_manager_test.go +21 -1
  42. package/internal/runner/contract_test.go +64 -26
  43. package/internal/runner/orca/orca.go +30 -54
  44. package/internal/runner/orca/orca_test.go +143 -4
  45. package/internal/runner/orca/orcacli/orcacli.go +5 -0
  46. package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
  47. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
  48. package/internal/runner/runner.go +15 -8
  49. package/internal/task/auth_test.go +48 -0
  50. package/internal/task/beads/bdcli/bdcli.go +323 -0
  51. package/internal/task/beads/bdcli/bdcli_test.go +297 -0
  52. package/internal/task/beads/bdcli/testdata/array.json +1 -0
  53. package/internal/task/beads/bdcli/testdata/children.json +1 -0
  54. package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
  55. package/internal/task/beads/bdcli/testdata/commented.json +1 -0
  56. package/internal/task/beads/bdcli/testdata/comments.json +1 -0
  57. package/internal/task/beads/bdcli/testdata/created.json +1 -0
  58. package/internal/task/beads/bdcli/testdata/object.json +1 -0
  59. package/internal/task/beads/bdcli/testdata/ready.json +1 -0
  60. package/internal/task/beads/bdcli/testdata/show.json +1 -0
  61. package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
  62. package/internal/task/beads/bdcli/testdata/updated.json +1 -0
  63. package/internal/task/beads/beads.go +840 -0
  64. package/internal/task/beads/beads_test.go +609 -0
  65. package/internal/task/beads/comments_test.go +242 -0
  66. package/internal/task/beads/config_compatibility_test.go +163 -0
  67. package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
  68. package/internal/task/beads/repo_composition_test.go +232 -0
  69. package/internal/task/beads/runtime_config_test.go +81 -0
  70. package/internal/task/beads/status_compatibility_test.go +233 -0
  71. package/internal/task/beads/status_test.go +257 -0
  72. package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
  73. package/internal/task/beads/validation_test.go +110 -0
  74. package/internal/task/contract_test.go +12 -0
  75. package/internal/task/factory.go +52 -3
  76. package/internal/task/jira/auth.go +209 -0
  77. package/internal/task/jira/auth_test.go +160 -0
  78. package/internal/task/jira/effects_test.go +39 -0
  79. package/internal/task/jira/filters_test.go +96 -16
  80. package/internal/task/jira/helpers_test.go +29 -19
  81. package/internal/task/jira/jira.go +263 -90
  82. package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
  83. package/internal/task/jira/normalize.go +32 -14
  84. package/internal/task/jira/rest/adf.go +165 -0
  85. package/internal/task/jira/rest/adf_test.go +60 -0
  86. package/internal/task/jira/rest/client.go +573 -0
  87. package/internal/task/jira/rest/client_test.go +381 -0
  88. package/internal/task/jira/templates_test.go +118 -0
  89. package/internal/task/jira/transition_defaults_test.go +22 -18
  90. package/internal/task/jira/validation_test.go +1 -1
  91. package/internal/task/task.go +32 -0
  92. package/internal/workflow/report_test.go +45 -0
  93. package/internal/workflow/workflow.go +9 -6
  94. package/internal/workflow/workflow_test.go +14 -12
  95. package/package.json +2 -1
  96. package/internal/task/jira/acli/acli.go +0 -306
  97. package/internal/task/jira/acli/acli_test.go +0 -208
  98. package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
  99. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
  100. package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
  101. /package/internal/task/{jira/acli/testdata/search_success.json → beads/bdcli/testdata/empty.json} +0 -0
  102. /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
+ }