@llm4ts/flow 0.10.1 → 0.12.0

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.
@@ -1,19 +1,26 @@
1
- import * as Duration from "effect/Duration"
2
1
  import * as Effect from "effect/Effect"
3
- import * as Redacted from "effect/Redacted"
4
2
  import * as Schema from "effect/Schema"
5
3
  import { Capabilities } from "@llm4ts/core/Capability"
6
- import type { HttpClientShape } from "@llm4ts/core/HttpClient"
7
- import type { JsonValue } from "@llm4ts/core/providers/CliSupport"
4
+ import type { ProcessExecutorShape, ProcessResult } from "@llm4ts/core/ProcessExecutor"
8
5
  import { ProcessError, type FlowError } from "./FlowError.ts"
9
6
  import type { FlowEventsShape } from "./FlowEvents.ts"
10
7
  import { guarded } from "./CapabilityGuard.ts"
11
8
 
12
- export class AdoRequest extends Schema.Class<AdoRequest>("AdoRequest")({
13
- method: Schema.String,
14
- url: Schema.String,
15
- body: Schema.optionalKey(Schema.String),
16
- contentType: Schema.String.pipe(Schema.withConstructorDefault(Effect.succeed("application/json")))
9
+ // Azure DevOps through the `az` CLI (ADR 0011), the sibling of GitHubTool's
10
+ // `gh` protocol: pure args builders, schema-decoded `--output json`, and
11
+ // capability guards around a ProcessExecutor. Credentials belong to the CLI
12
+ // (`az devops login`, or AZURE_DEVOPS_EXT_PAT in the process environment)
13
+ // this module never reads, holds, or forwards a PAT, so no secret can reach
14
+ // argv, a log line, or a persisted plan.
15
+
16
+ export class AdoConfig extends Schema.Class<AdoConfig>("AdoConfig")({
17
+ orgUrl: Schema.String,
18
+ project: Schema.String,
19
+ repository: Schema.String,
20
+ // Only `az devops invoke` (the comments REST resource, which has no
21
+ // first-class `az boards` verb) needs an explicit API version; every
22
+ // other call is a versioned CLI command.
23
+ apiVersion: Schema.String.pipe(Schema.withConstructorDefault(Effect.succeed("7.1-preview")))
17
24
  }) {}
18
25
 
19
26
  export class WorkItem extends Schema.Class<WorkItem>("WorkItem")({
@@ -22,7 +29,16 @@ export class WorkItem extends Schema.Class<WorkItem>("WorkItem")({
22
29
  description: Schema.String,
23
30
  acceptanceCriteria: Schema.String,
24
31
  state: Schema.String,
25
- tags: Schema.Array(Schema.String)
32
+ tags: Schema.Array(Schema.String),
33
+ createdBy: Schema.String,
34
+ changedDate: Schema.String
35
+ }) {}
36
+
37
+ export class WorkItemComment extends Schema.Class<WorkItemComment>("WorkItemComment")({
38
+ id: Schema.Int,
39
+ author: Schema.String,
40
+ text: Schema.String,
41
+ createdDate: Schema.String
26
42
  }) {}
27
43
 
28
44
  export class AdoPullRequest extends Schema.Class<AdoPullRequest>("AdoPullRequest")({
@@ -32,224 +48,555 @@ export class AdoPullRequest extends Schema.Class<AdoPullRequest>("AdoPullRequest
32
48
  webUrl: Schema.String
33
49
  }) {}
34
50
 
35
- export class AdoConfig extends Schema.Class<AdoConfig>("AdoConfig")({
36
- orgUrl: Schema.String,
37
- project: Schema.String,
38
- repository: Schema.String,
39
- pat: Schema.Redacted(Schema.String, {
40
- disallowJsonEncode: true
41
- }),
42
- apiVersion: Schema.String.pipe(Schema.withConstructorDefault(Effect.succeed("7.1")))
43
- }) {}
51
+ // Azure DevOps branch policies are the analogue of GitHub's check rollup.
52
+ // There is no policy status for a timed-out run, so the outcome set is the
53
+ // GitHub one minus "TimedOut".
54
+ export const PolicyOutcome = Schema.Literals(["Success", "Failure", "Pending"])
55
+ export type PolicyOutcome = typeof PolicyOutcome.Type
44
56
 
45
- const encodeBase64 = (value: string): string => {
46
- const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
47
- const bytes = new TextEncoder().encode(value)
48
- let encoded = ""
49
- for (let index = 0; index < bytes.length; index += 3) {
50
- const first = bytes[index] ?? 0
51
- const second = bytes[index + 1]
52
- const third = bytes[index + 2]
53
- const bits = (first << 16) | ((second ?? 0) << 8) | (third ?? 0)
54
- encoded += alphabet[(bits >>> 18) & 63] ?? ""
55
- encoded += alphabet[(bits >>> 12) & 63] ?? ""
56
- encoded += second === undefined ? "=" : (alphabet[(bits >>> 6) & 63] ?? "")
57
- encoded += third === undefined ? "=" : (alphabet[bits & 63] ?? "")
58
- }
59
- return encoded
57
+ export const WorkItemState = Schema.Literals(["open", "closed", "all"])
58
+ export type WorkItemState = typeof WorkItemState.Type
59
+
60
+ export interface WorkItemFilter {
61
+ readonly tags?: ReadonlyArray<string>
62
+ readonly state?: WorkItemState
63
+ readonly assignedTo?: string
64
+ readonly limit?: number
60
65
  }
61
66
 
62
- export const authorizationHeader = (config: AdoConfig): Readonly<Record<string, string>> => ({
63
- Authorization: `Basic ${encodeBase64(`:${Redacted.value(config.pat)}`)}`
64
- })
67
+ // `refs/heads/x` and `x` both name a branch across the Azure DevOps
68
+ // surface; the CLI wants the short form, so every ref is normalized once
69
+ // on the way into argv.
70
+ export const branchName = (ref: string): string => ref.trim().replace(/^refs\/heads\//, "")
65
71
 
66
- const witBase = (config: AdoConfig): string => `${config.orgUrl}/${config.project}/_apis/wit`
67
- const gitBase = (config: AdoConfig): string =>
68
- `${config.orgUrl}/${config.project}/_apis/git/repositories/${config.repository}`
69
-
70
- const patchOperations = (fields: Readonly<Record<string, string>>): ReadonlyArray<JsonValue> =>
71
- Object.entries(fields).map(([name, value]) => ({
72
- op: "add",
73
- path: `/fields/${name}`,
74
- value
75
- }))
76
-
77
- export const readWorkItemRequest = (config: AdoConfig, id: number): AdoRequest =>
78
- AdoRequest.make({
79
- method: "GET",
80
- url: `${witBase(config)}/workitems/${id}?$expand=relations&api-version=${config.apiVersion}`
81
- })
72
+ const org = (config: AdoConfig): ReadonlyArray<string> => [
73
+ "--org",
74
+ config.orgUrl,
75
+ // Without this the CLI probes the working directory's git remote and
76
+ // silently retargets another organization; a library must not guess.
77
+ "--detect",
78
+ "false"
79
+ ]
82
80
 
83
- export const wiqlRequest = (config: AdoConfig, query: string): AdoRequest =>
84
- AdoRequest.make({
85
- method: "POST",
86
- url: `${witBase(config)}/wiql?api-version=${config.apiVersion}`,
87
- body: JSON.stringify({ query })
88
- })
81
+ const json = ["--output", "json"]
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Work item argv
85
+ // ---------------------------------------------------------------------------
86
+
87
+ export const workItemShowArgs = (config: AdoConfig, id: number): ReadonlyArray<string> => [
88
+ "boards",
89
+ "work-item",
90
+ "show",
91
+ "--id",
92
+ String(id),
93
+ ...org(config),
94
+ ...json
95
+ ]
89
96
 
90
- export const setFieldsRequest = (
97
+ export const fieldArgs = (fields: Readonly<Record<string, string>>): ReadonlyArray<string> => {
98
+ const pairs = Object.entries(fields).map(([name, value]) => `${name}=${value}`)
99
+ return pairs.length === 0 ? [] : ["--fields", ...pairs]
100
+ }
101
+
102
+ export const workItemUpdateArgs = (
91
103
  config: AdoConfig,
92
104
  id: number,
93
105
  fields: Readonly<Record<string, string>>
94
- ): AdoRequest =>
95
- AdoRequest.make({
96
- method: "PATCH",
97
- url: `${witBase(config)}/workitems/${id}?api-version=${config.apiVersion}`,
98
- body: JSON.stringify(patchOperations(fields)),
99
- contentType: "application/json-patch+json"
100
- })
106
+ ): ReadonlyArray<string> => [
107
+ "boards",
108
+ "work-item",
109
+ "update",
110
+ "--id",
111
+ String(id),
112
+ ...fieldArgs(fields),
113
+ ...org(config),
114
+ ...json
115
+ ]
116
+
117
+ export const workItemCommentArgs = (
118
+ config: AdoConfig,
119
+ id: number,
120
+ text: string
121
+ ): ReadonlyArray<string> => [
122
+ "boards",
123
+ "work-item",
124
+ "update",
125
+ "--id",
126
+ String(id),
127
+ "--discussion",
128
+ text,
129
+ ...org(config),
130
+ ...json
131
+ ]
132
+
133
+ export const workItemCreateArgs = (
134
+ config: AdoConfig,
135
+ workItemType: string,
136
+ title: string,
137
+ description: string,
138
+ tags: ReadonlyArray<string>
139
+ ): ReadonlyArray<string> => [
140
+ "boards",
141
+ "work-item",
142
+ "create",
143
+ "--title",
144
+ title,
145
+ "--type",
146
+ workItemType,
147
+ "--description",
148
+ description,
149
+ "--project",
150
+ config.project,
151
+ ...fieldArgs(tags.length === 0 ? {} : { "System.Tags": tags.join("; ") }),
152
+ ...org(config),
153
+ ...json
154
+ ]
155
+
156
+ export const commentsArgs = (config: AdoConfig, id: number): ReadonlyArray<string> => [
157
+ "devops",
158
+ "invoke",
159
+ "--area",
160
+ "wit",
161
+ "--resource",
162
+ "comments",
163
+ "--route-parameters",
164
+ `project=${config.project}`,
165
+ `workItemId=${String(id)}`,
166
+ "--api-version",
167
+ config.apiVersion,
168
+ ...org(config),
169
+ ...json
170
+ ]
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // WIQL
174
+ // ---------------------------------------------------------------------------
101
175
 
102
- export const createPullRequestRequest = (
176
+ // WIQL string literals are single-quoted; a quote inside a value is escaped
177
+ // by doubling it. Every caller-supplied value goes through here so a tag
178
+ // like "won't fix" cannot terminate the literal and rewrite the query.
179
+ export const quoteWiql = (value: string): string => `'${value.replace(/'/g, "''")}'`
180
+
181
+ export const workItemFields: ReadonlyArray<string> = [
182
+ "System.Id",
183
+ "System.Title",
184
+ "System.Description",
185
+ "System.State",
186
+ "System.Tags",
187
+ "System.CreatedBy",
188
+ "System.ChangedDate",
189
+ "Microsoft.VSTS.Common.AcceptanceCriteria"
190
+ ]
191
+
192
+ export const wiqlFor = (filter: WorkItemFilter): string => {
193
+ const clauses = [
194
+ "[System.TeamProject] = @project",
195
+ ...(filter.state === "all"
196
+ ? []
197
+ : filter.state === "closed"
198
+ ? ["[System.State] = 'Closed'"]
199
+ : ["[System.State] <> 'Closed'"]),
200
+ ...(filter.tags ?? []).map((tag) => `[System.Tags] CONTAINS ${quoteWiql(tag)}`),
201
+ ...(filter.assignedTo === undefined
202
+ ? []
203
+ : [`[System.AssignedTo] = ${quoteWiql(filter.assignedTo)}`])
204
+ ]
205
+ const select = workItemFields.map((name) => `[${name}]`).join(", ")
206
+ return (
207
+ `SELECT TOP ${String(filter.limit ?? 100)} ${select} FROM WorkItems ` +
208
+ `WHERE ${clauses.join(" AND ")} ORDER BY [System.Id] ASC`
209
+ )
210
+ }
211
+
212
+ export const queryArgs = (config: AdoConfig, wiql: string): ReadonlyArray<string> => [
213
+ "boards",
214
+ "query",
215
+ "--wiql",
216
+ wiql,
217
+ "--project",
218
+ config.project,
219
+ ...org(config),
220
+ ...json
221
+ ]
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // Pull request argv
225
+ // ---------------------------------------------------------------------------
226
+
227
+ export const prCreateArgs = (
103
228
  config: AdoConfig,
104
229
  sourceRef: string,
105
230
  targetRef: string,
106
231
  title: string,
232
+ description: string,
233
+ draft = false
234
+ ): ReadonlyArray<string> => [
235
+ "repos",
236
+ "pr",
237
+ "create",
238
+ "--repository",
239
+ config.repository,
240
+ "--project",
241
+ config.project,
242
+ "--source-branch",
243
+ branchName(sourceRef),
244
+ "--target-branch",
245
+ branchName(targetRef),
246
+ "--title",
247
+ title,
248
+ "--description",
249
+ description,
250
+ ...(draft ? ["--draft", "true"] : []),
251
+ ...org(config),
252
+ ...json
253
+ ]
254
+
255
+ export const prListArgs = (config: AdoConfig, sourceRef?: string): ReadonlyArray<string> => [
256
+ "repos",
257
+ "pr",
258
+ "list",
259
+ "--repository",
260
+ config.repository,
261
+ "--project",
262
+ config.project,
263
+ "--status",
264
+ "active",
265
+ ...(sourceRef === undefined ? [] : ["--source-branch", branchName(sourceRef)]),
266
+ ...org(config),
267
+ ...json
268
+ ]
269
+
270
+ export const prUpdateArgs = (
271
+ config: AdoConfig,
272
+ id: number,
273
+ title: string,
107
274
  description: string
108
- ): AdoRequest =>
109
- AdoRequest.make({
110
- method: "POST",
111
- url: `${gitBase(config)}/pullrequests?api-version=${config.apiVersion}`,
112
- body: JSON.stringify({
113
- sourceRefName: sourceRef,
114
- targetRefName: targetRef,
115
- title,
116
- description
117
- })
275
+ ): ReadonlyArray<string> => [
276
+ "repos",
277
+ "pr",
278
+ "update",
279
+ "--id",
280
+ String(id),
281
+ "--title",
282
+ title,
283
+ "--description",
284
+ description,
285
+ ...org(config),
286
+ ...json
287
+ ]
288
+
289
+ export const prCommentArgs = (
290
+ config: AdoConfig,
291
+ id: number,
292
+ text: string
293
+ ): ReadonlyArray<string> => [
294
+ "repos",
295
+ "pr",
296
+ "thread",
297
+ "create",
298
+ "--id",
299
+ String(id),
300
+ "--content",
301
+ text,
302
+ "--project",
303
+ config.project,
304
+ ...org(config),
305
+ ...json
306
+ ]
307
+
308
+ export const prPolicyArgs = (config: AdoConfig, id: number): ReadonlyArray<string> => [
309
+ "repos",
310
+ "pr",
311
+ "policy",
312
+ "list",
313
+ "--id",
314
+ String(id),
315
+ ...org(config),
316
+ ...json
317
+ ]
318
+
319
+ export const prCompleteArgs = (
320
+ config: AdoConfig,
321
+ id: number,
322
+ squash: boolean,
323
+ deleteSourceBranch: boolean
324
+ ): ReadonlyArray<string> => [
325
+ "repos",
326
+ "pr",
327
+ "update",
328
+ "--id",
329
+ String(id),
330
+ "--status",
331
+ "completed",
332
+ "--squash",
333
+ squash ? "true" : "false",
334
+ "--delete-source-branch",
335
+ deleteSourceBranch ? "true" : "false",
336
+ ...org(config),
337
+ ...json
338
+ ]
339
+
340
+ // ---------------------------------------------------------------------------
341
+ // Parsing
342
+ // ---------------------------------------------------------------------------
343
+
344
+ // System.CreatedBy is an identity object on current API versions and a bare
345
+ // display string on older ones; both decode to the display name.
346
+ const Identity = Schema.Union([
347
+ Schema.String,
348
+ Schema.Struct({
349
+ displayName: Schema.optionalKey(Schema.String),
350
+ uniqueName: Schema.optionalKey(Schema.String)
118
351
  })
352
+ ])
119
353
 
120
- const field = (fields: Readonly<Record<string, JsonValue>>, name: string): string => {
121
- const value = fields[name]
122
- return typeof value === "string" ? value : ""
123
- }
354
+ const identityName = (value: typeof Identity.Type | undefined): string =>
355
+ value === undefined
356
+ ? ""
357
+ : typeof value === "string"
358
+ ? value
359
+ : (value.displayName ?? value.uniqueName ?? "")
124
360
 
125
- export const parseWorkItem = (json: string): Effect.Effect<WorkItem, ProcessError> =>
126
- Schema.decodeUnknownEffect(
127
- Schema.fromJsonString(
128
- Schema.Struct({
129
- id: Schema.Int,
130
- fields: Schema.Record(Schema.String, Schema.Json)
131
- })
132
- )
133
- )(json).pipe(
134
- Effect.map((item) => {
135
- const tags = field(item.fields, "System.Tags")
136
- .split(";")
137
- .map((tag) => tag.trim())
138
- .filter((tag) => tag.length > 0)
139
- return WorkItem.make({
140
- id: item.id,
141
- title: field(item.fields, "System.Title"),
142
- description: field(item.fields, "System.Description"),
143
- acceptanceCriteria: field(item.fields, "Microsoft.VSTS.Common.AcceptanceCriteria"),
144
- state: field(item.fields, "System.State"),
145
- tags
146
- })
147
- }),
148
- Effect.mapError((error) =>
149
- ProcessError.make({
150
- message: "ado parse work item",
151
- detail: String(error)
152
- })
153
- )
361
+ const AdoFields = Schema.Struct({
362
+ "System.Title": Schema.optionalKey(Schema.String),
363
+ "System.Description": Schema.optionalKey(Schema.String),
364
+ "System.State": Schema.optionalKey(Schema.String),
365
+ "System.Tags": Schema.optionalKey(Schema.String),
366
+ "System.CreatedBy": Schema.optionalKey(Identity),
367
+ "System.ChangedDate": Schema.optionalKey(Schema.String),
368
+ "Microsoft.VSTS.Common.AcceptanceCriteria": Schema.optionalKey(Schema.String)
369
+ })
370
+
371
+ const AdoWorkItem = Schema.Struct({
372
+ id: Schema.Int,
373
+ fields: AdoFields
374
+ })
375
+
376
+ export const parseTags = (raw: string): ReadonlyArray<string> =>
377
+ raw
378
+ .split(";")
379
+ .map((tag) => tag.trim())
380
+ .filter((tag) => tag.length > 0)
381
+
382
+ const toWorkItem = (item: typeof AdoWorkItem.Type): WorkItem =>
383
+ WorkItem.make({
384
+ id: item.id,
385
+ title: item.fields["System.Title"] ?? "",
386
+ description: item.fields["System.Description"] ?? "",
387
+ acceptanceCriteria: item.fields["Microsoft.VSTS.Common.AcceptanceCriteria"] ?? "",
388
+ state: item.fields["System.State"] ?? "",
389
+ tags: parseTags(item.fields["System.Tags"] ?? ""),
390
+ createdBy: identityName(item.fields["System.CreatedBy"]),
391
+ changedDate: item.fields["System.ChangedDate"] ?? ""
392
+ })
393
+
394
+ const decodeFailure =
395
+ (message: string) =>
396
+ (error: unknown): ProcessError =>
397
+ ProcessError.make({ message, detail: String(error) })
398
+
399
+ export const parseWorkItem = (payload: string): Effect.Effect<WorkItem, ProcessError> =>
400
+ Schema.decodeUnknownEffect(Schema.fromJsonString(AdoWorkItem))(payload).pipe(
401
+ Effect.map(toWorkItem),
402
+ Effect.mapError(decodeFailure("az boards work-item show"))
154
403
  )
155
404
 
156
- export const parseWiqlIds = (json: string): Effect.Effect<ReadonlyArray<number>, ProcessError> =>
157
- Schema.decodeUnknownEffect(
158
- Schema.fromJsonString(
159
- Schema.Struct({
160
- workItems: Schema.Array(
161
- Schema.Struct({
162
- id: Schema.Int
163
- })
164
- )
165
- })
166
- )
167
- )(json).pipe(
168
- Effect.map((result) => result.workItems.map((item) => item.id)),
169
- Effect.mapError((error) =>
170
- ProcessError.make({
171
- message: "ado parse wiql",
172
- detail: String(error)
173
- })
174
- )
405
+ // `az boards query` flattens the WIQL result into a work-item array, so a
406
+ // queue poll is a single call rather than a fan-out over ids.
407
+ export const parseWorkItems = (
408
+ payload: string
409
+ ): Effect.Effect<ReadonlyArray<WorkItem>, ProcessError> =>
410
+ Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(AdoWorkItem)))(payload).pipe(
411
+ Effect.map((items) => items.map(toWorkItem)),
412
+ Effect.mapError(decodeFailure("az boards query"))
413
+ )
414
+
415
+ export const parseWorkItemIds = (
416
+ payload: string
417
+ ): Effect.Effect<ReadonlyArray<number>, ProcessError> =>
418
+ parseWorkItems(payload).pipe(Effect.map((items) => items.map((item) => item.id)))
419
+
420
+ const AdoComments = Schema.Struct({
421
+ comments: Schema.Array(
422
+ Schema.Struct({
423
+ id: Schema.Int,
424
+ text: Schema.optionalKey(Schema.String),
425
+ createdBy: Schema.optionalKey(Identity),
426
+ createdDate: Schema.optionalKey(Schema.String)
427
+ })
428
+ ).pipe(Schema.withConstructorDefault(Effect.succeed(Object.freeze([]))))
429
+ })
430
+
431
+ export const parseComments = (
432
+ payload: string
433
+ ): Effect.Effect<ReadonlyArray<WorkItemComment>, ProcessError> =>
434
+ Schema.decodeUnknownEffect(Schema.fromJsonString(AdoComments))(payload).pipe(
435
+ Effect.map((parsed) =>
436
+ parsed.comments.map((comment) =>
437
+ WorkItemComment.make({
438
+ id: comment.id,
439
+ author: identityName(comment.createdBy),
440
+ text: comment.text ?? "",
441
+ createdDate: comment.createdDate ?? ""
442
+ })
443
+ )
444
+ ),
445
+ Effect.mapError(decodeFailure("az devops invoke wit comments"))
175
446
  )
176
447
 
448
+ const AdoPr = Schema.Struct({
449
+ pullRequestId: Schema.Int,
450
+ repository: Schema.Struct({
451
+ id: Schema.String,
452
+ project: Schema.Struct({ id: Schema.String })
453
+ })
454
+ })
455
+
456
+ const toPullRequest = (config: AdoConfig, pr: typeof AdoPr.Type): AdoPullRequest =>
457
+ AdoPullRequest.make({
458
+ id: pr.pullRequestId,
459
+ repoId: pr.repository.id,
460
+ projectId: pr.repository.project.id,
461
+ webUrl:
462
+ `${config.orgUrl}/${config.project}/_git/` +
463
+ `${config.repository}/pullrequest/${String(pr.pullRequestId)}`
464
+ })
465
+
177
466
  export const parsePullRequest = (
178
467
  config: AdoConfig,
179
- json: string
468
+ payload: string
180
469
  ): Effect.Effect<AdoPullRequest, ProcessError> =>
181
- Schema.decodeUnknownEffect(
182
- Schema.fromJsonString(
183
- Schema.Struct({
184
- pullRequestId: Schema.Int,
185
- repository: Schema.Struct({
186
- id: Schema.String,
187
- project: Schema.Struct({
188
- id: Schema.String
189
- })
190
- })
191
- })
192
- )
193
- )(json).pipe(
194
- Effect.map((result) =>
195
- AdoPullRequest.make({
196
- id: result.pullRequestId,
197
- repoId: result.repository.id,
198
- projectId: result.repository.project.id,
199
- webUrl:
200
- `${config.orgUrl}/${config.project}/_git/` +
201
- `${config.repository}/pullrequest/${result.pullRequestId}`
202
- })
203
- ),
204
- Effect.mapError((error) =>
205
- ProcessError.make({
206
- message: "ado parse pull request",
207
- detail: String(error)
208
- })
209
- )
470
+ Schema.decodeUnknownEffect(Schema.fromJsonString(AdoPr))(payload).pipe(
471
+ Effect.map((pr) => toPullRequest(config, pr)),
472
+ Effect.mapError(decodeFailure("az repos pr"))
473
+ )
474
+
475
+ export const parsePullRequests = (
476
+ config: AdoConfig,
477
+ payload: string
478
+ ): Effect.Effect<ReadonlyArray<AdoPullRequest>, ProcessError> =>
479
+ Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(AdoPr)))(payload).pipe(
480
+ Effect.map((prs) => prs.map((pr) => toPullRequest(config, pr))),
481
+ Effect.mapError(decodeFailure("az repos pr list"))
482
+ )
483
+
484
+ const AdoPolicies = Schema.Array(
485
+ Schema.Struct({
486
+ status: Schema.optionalKey(Schema.String)
487
+ })
488
+ )
489
+
490
+ // Policy evaluation statuses: queued/running are still deciding, and
491
+ // rejected/broken have already decided against the PR.
492
+ export const outcomeFromPolicies = (payload: string): Effect.Effect<PolicyOutcome, ProcessError> =>
493
+ Schema.decodeUnknownEffect(Schema.fromJsonString(AdoPolicies))(payload).pipe(
494
+ Effect.map((policies) => {
495
+ const statuses = policies.map((policy) => policy.status?.toLowerCase() ?? "")
496
+ return statuses.some((value) => ["queued", "running"].includes(value))
497
+ ? "Pending"
498
+ : statuses.some((value) => ["rejected", "broken"].includes(value))
499
+ ? "Failure"
500
+ : "Success"
501
+ }),
502
+ Effect.mapError(decodeFailure("az repos pr policy list"))
210
503
  )
211
504
 
505
+ // ---------------------------------------------------------------------------
506
+ // Tool
507
+ // ---------------------------------------------------------------------------
508
+
212
509
  export interface AzureDevOpsToolShape {
213
510
  readonly readWorkItem: (id: number) => Effect.Effect<WorkItem, FlowError>
511
+ readonly listWorkItems: (
512
+ filter?: WorkItemFilter
513
+ ) => Effect.Effect<ReadonlyArray<WorkItem>, FlowError>
214
514
  readonly wiqlIds: (query: string) => Effect.Effect<ReadonlyArray<number>, FlowError>
515
+ readonly readComments: (id: number) => Effect.Effect<ReadonlyArray<WorkItemComment>, FlowError>
215
516
  readonly setFields: (
216
517
  id: number,
217
518
  fields: Readonly<Record<string, string>>
218
519
  ) => Effect.Effect<void, FlowError>
219
520
  readonly setState: (id: number, state: string) => Effect.Effect<void, FlowError>
220
521
  readonly setAcceptanceCriteria: (id: number, text: string) => Effect.Effect<void, FlowError>
522
+ // Tags are one semicolon-joined field, so an edit is read-merge-write
523
+ // rather than the add/remove verbs a label API would offer.
524
+ readonly editTags: (
525
+ id: number,
526
+ add: ReadonlyArray<string>,
527
+ remove: ReadonlyArray<string>
528
+ ) => Effect.Effect<void, FlowError>
529
+ readonly writeComment: (id: number, text: string) => Effect.Effect<void, FlowError>
530
+ readonly createWorkItem: (
531
+ workItemType: string,
532
+ title: string,
533
+ description: string,
534
+ tags?: ReadonlyArray<string>
535
+ ) => Effect.Effect<WorkItem, FlowError>
221
536
  readonly createPr: (
222
537
  sourceRef: string,
223
538
  targetRef: string,
224
539
  title: string,
225
- body: string
540
+ body: string,
541
+ draft?: boolean
226
542
  ) => Effect.Effect<AdoPullRequest, FlowError>
543
+ readonly openPrForBranch: (
544
+ sourceRef: string
545
+ ) => Effect.Effect<AdoPullRequest | undefined, FlowError>
546
+ readonly updatePr: (
547
+ pr: AdoPullRequest,
548
+ title: string,
549
+ body: string
550
+ ) => Effect.Effect<void, FlowError>
551
+ readonly writePrComment: (pr: AdoPullRequest, body: string) => Effect.Effect<void, FlowError>
552
+ readonly prPolicies: (pr: AdoPullRequest) => Effect.Effect<PolicyOutcome, FlowError>
553
+ readonly completePr: (
554
+ pr: AdoPullRequest,
555
+ squash?: boolean,
556
+ deleteSourceBranch?: boolean
557
+ ) => Effect.Effect<void, FlowError>
558
+ }
559
+
560
+ const output = (result: ProcessResult): string => result.stdout.join("\n").trim()
561
+
562
+ export const mergeTags = (
563
+ current: ReadonlyArray<string>,
564
+ add: ReadonlyArray<string>,
565
+ remove: ReadonlyArray<string>
566
+ ): ReadonlyArray<string> => {
567
+ const removed = new Set(remove.map((tag) => tag.toLowerCase()))
568
+ const kept = current.filter((tag) => !removed.has(tag.toLowerCase()))
569
+ const present = new Set(kept.map((tag) => tag.toLowerCase()))
570
+ const added = add.filter(
571
+ (tag) => !present.has(tag.toLowerCase()) && !removed.has(tag.toLowerCase())
572
+ )
573
+ return [...kept, ...added]
227
574
  }
228
575
 
229
576
  export const makeAzureDevOpsTool = (
230
577
  config: AdoConfig,
231
- http: HttpClientShape,
232
- events: FlowEventsShape,
233
- timeout: Duration.Duration = Duration.seconds(30)
578
+ process: ProcessExecutorShape,
579
+ workDir: string,
580
+ events: FlowEventsShape
234
581
  ): AzureDevOpsToolShape => {
235
- const run = (request: AdoRequest): Effect.Effect<string, FlowError> =>
236
- http
237
- .send(
238
- request.method,
239
- request.url,
240
- request.body,
241
- authorizationHeader(config),
242
- request.contentType,
243
- timeout
244
- )
245
- .pipe(
246
- Effect.mapError((error) =>
247
- ProcessError.make({
248
- message: `ado ${request.method} ${request.url}`,
249
- detail: error.message
250
- })
251
- )
582
+ const run = (args: ReadonlyArray<string>): Effect.Effect<string, FlowError> =>
583
+ process.run(["az", ...args], workDir, {}).pipe(
584
+ Effect.mapError((error) =>
585
+ ProcessError.make({ message: `az ${args.join(" ")}`, detail: error.message })
586
+ ),
587
+ Effect.flatMap((result) =>
588
+ result.exitCode === 0
589
+ ? Effect.succeed(output(result))
590
+ : Effect.fail(
591
+ ProcessError.make({
592
+ message: `az ${args.join(" ")}`,
593
+ detail:
594
+ [...result.stdout, ...result.stderr].join("\n").trim() ||
595
+ `exit code ${result.exitCode}`
596
+ })
597
+ )
252
598
  )
599
+ )
253
600
 
254
601
  const read = <A>(
255
602
  operation: string,
@@ -260,32 +607,85 @@ export const makeAzureDevOpsTool = (
260
607
  effect: Effect.Effect<A, FlowError>
261
608
  ): Effect.Effect<A, FlowError> => guarded(Capabilities.AdoWrite, operation, events, effect)
262
609
 
610
+ const readWorkItem = (id: number): Effect.Effect<WorkItem, FlowError> =>
611
+ read("ado readWorkItem", run(workItemShowArgs(config, id)).pipe(Effect.flatMap(parseWorkItem)))
612
+
263
613
  const setFields = (
264
614
  id: number,
265
615
  fields: Readonly<Record<string, string>>
266
616
  ): Effect.Effect<void, FlowError> =>
267
- write("ado setFields", run(setFieldsRequest(config, id, fields)).pipe(Effect.asVoid))
617
+ write("ado setFields", run(workItemUpdateArgs(config, id, fields)).pipe(Effect.asVoid))
618
+
619
+ const listPrs = (sourceRef?: string): Effect.Effect<ReadonlyArray<AdoPullRequest>, FlowError> =>
620
+ run(prListArgs(config, sourceRef)).pipe(
621
+ Effect.flatMap((payload) => parsePullRequests(config, payload))
622
+ )
268
623
 
269
624
  return {
270
- readWorkItem: (id) =>
625
+ readWorkItem,
626
+ listWorkItems: (filter = {}) =>
271
627
  read(
272
- "ado readWorkItem",
273
- run(readWorkItemRequest(config, id)).pipe(Effect.flatMap(parseWorkItem))
628
+ "ado listWorkItems",
629
+ run(queryArgs(config, wiqlFor(filter))).pipe(Effect.flatMap(parseWorkItems))
274
630
  ),
275
631
  wiqlIds: (query) =>
276
- read("ado wiql", run(wiqlRequest(config, query)).pipe(Effect.flatMap(parseWiqlIds))),
632
+ read("ado wiql", run(queryArgs(config, query)).pipe(Effect.flatMap(parseWorkItemIds))),
633
+ readComments: (id) =>
634
+ read("ado readComments", run(commentsArgs(config, id)).pipe(Effect.flatMap(parseComments))),
277
635
  setFields,
278
636
  setState: (id, state) => setFields(id, { "System.State": state }),
279
637
  setAcceptanceCriteria: (id, text) =>
280
- setFields(id, {
281
- "Microsoft.VSTS.Common.AcceptanceCriteria": text
282
- }),
283
- createPr: (sourceRef, targetRef, title, body) =>
638
+ setFields(id, { "Microsoft.VSTS.Common.AcceptanceCriteria": text }),
639
+ editTags: (id, add, remove) =>
640
+ add.length === 0 && remove.length === 0
641
+ ? Effect.void
642
+ : readWorkItem(id).pipe(
643
+ Effect.flatMap((item) => {
644
+ const next = mergeTags(item.tags, add, remove)
645
+ return next.length === item.tags.length &&
646
+ next.every((tag, index) => tag === item.tags[index])
647
+ ? Effect.void
648
+ : setFields(id, { "System.Tags": next.join("; ") })
649
+ })
650
+ ),
651
+ writeComment: (id, text) =>
652
+ write("ado writeComment", run(workItemCommentArgs(config, id, text)).pipe(Effect.asVoid)),
653
+ createWorkItem: (workItemType, title, description, tags = []) =>
284
654
  write(
285
- "ado createPr",
286
- run(createPullRequestRequest(config, sourceRef, targetRef, title, body)).pipe(
287
- Effect.flatMap((json) => parsePullRequest(config, json))
655
+ "ado createWorkItem",
656
+ run(workItemCreateArgs(config, workItemType, title, description, tags)).pipe(
657
+ Effect.flatMap(parseWorkItem)
288
658
  )
659
+ ),
660
+ createPr: (sourceRef, targetRef, title, body, draft = false) =>
661
+ write(
662
+ "ado createPr",
663
+ // An active PR for the branch already IS the deliverable; creating a
664
+ // second one would fail on the server and lose the first's reviews.
665
+ Effect.flatMap(listPrs(sourceRef), (existing) => {
666
+ const open = existing[0]
667
+ return open !== undefined
668
+ ? Effect.succeed(open)
669
+ : run(prCreateArgs(config, sourceRef, targetRef, title, body, draft)).pipe(
670
+ Effect.flatMap((payload) => parsePullRequest(config, payload))
671
+ )
672
+ })
673
+ ),
674
+ openPrForBranch: (sourceRef) =>
675
+ read("ado listPrs", listPrs(sourceRef).pipe(Effect.map((prs) => prs[0]))),
676
+ updatePr: (pr, title, body) =>
677
+ write("ado updatePr", run(prUpdateArgs(config, pr.id, title, body)).pipe(Effect.asVoid)),
678
+ writePrComment: (pr, body) =>
679
+ write("ado writePrComment", run(prCommentArgs(config, pr.id, body)).pipe(Effect.asVoid)),
680
+ prPolicies: (pr) =>
681
+ read(
682
+ "ado prPolicies",
683
+ run(prPolicyArgs(config, pr.id)).pipe(Effect.flatMap(outcomeFromPolicies))
684
+ ),
685
+ completePr: (pr, squash = true, deleteSourceBranch = true) =>
686
+ write(
687
+ "ado completePr",
688
+ run(prCompleteArgs(config, pr.id, squash, deleteSourceBranch)).pipe(Effect.asVoid)
289
689
  )
290
690
  }
291
691
  }