@llm4ts/flow 0.11.0 → 0.13.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,759 @@ 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
+ // ---------------------------------------------------------------------------
89
86
 
90
- export const setFieldsRequest = (
87
+ export const workItemShowArgs = (
88
+ config: AdoConfig,
89
+ id: number,
90
+ expand?: "relations" | "all"
91
+ ): ReadonlyArray<string> => [
92
+ "boards",
93
+ "work-item",
94
+ "show",
95
+ "--id",
96
+ String(id),
97
+ ...(expand === undefined ? [] : ["--expand", expand]),
98
+ ...org(config),
99
+ ...json
100
+ ]
101
+
102
+ export const fieldArgs = (fields: Readonly<Record<string, string>>): ReadonlyArray<string> => {
103
+ const pairs = Object.entries(fields).map(([name, value]) => `${name}=${value}`)
104
+ return pairs.length === 0 ? [] : ["--fields", ...pairs]
105
+ }
106
+
107
+ export const workItemUpdateArgs = (
91
108
  config: AdoConfig,
92
109
  id: number,
93
110
  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
- })
111
+ ): ReadonlyArray<string> => [
112
+ "boards",
113
+ "work-item",
114
+ "update",
115
+ "--id",
116
+ String(id),
117
+ ...fieldArgs(fields),
118
+ ...org(config),
119
+ ...json
120
+ ]
121
+
122
+ export const workItemCommentArgs = (
123
+ config: AdoConfig,
124
+ id: number,
125
+ text: string
126
+ ): ReadonlyArray<string> => [
127
+ "boards",
128
+ "work-item",
129
+ "update",
130
+ "--id",
131
+ String(id),
132
+ "--discussion",
133
+ text,
134
+ ...org(config),
135
+ ...json
136
+ ]
137
+
138
+ export const workItemCreateArgs = (
139
+ config: AdoConfig,
140
+ workItemType: string,
141
+ title: string,
142
+ description: string,
143
+ tags: ReadonlyArray<string>
144
+ ): ReadonlyArray<string> => [
145
+ "boards",
146
+ "work-item",
147
+ "create",
148
+ "--title",
149
+ title,
150
+ "--type",
151
+ workItemType,
152
+ "--description",
153
+ description,
154
+ "--project",
155
+ config.project,
156
+ ...fieldArgs(tags.length === 0 ? {} : { "System.Tags": tags.join("; ") }),
157
+ ...org(config),
158
+ ...json
159
+ ]
160
+
161
+ export const commentsArgs = (config: AdoConfig, id: number): ReadonlyArray<string> => [
162
+ "devops",
163
+ "invoke",
164
+ "--area",
165
+ "wit",
166
+ "--resource",
167
+ "comments",
168
+ "--route-parameters",
169
+ `project=${config.project}`,
170
+ `workItemId=${String(id)}`,
171
+ "--api-version",
172
+ config.apiVersion,
173
+ ...org(config),
174
+ ...json
175
+ ]
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // WIQL
179
+ // ---------------------------------------------------------------------------
101
180
 
102
- export const createPullRequestRequest = (
181
+ // WIQL string literals are single-quoted; a quote inside a value is escaped
182
+ // by doubling it. Every caller-supplied value goes through here so a tag
183
+ // like "won't fix" cannot terminate the literal and rewrite the query.
184
+ export const quoteWiql = (value: string): string => `'${value.replace(/'/g, "''")}'`
185
+
186
+ export const workItemFields: ReadonlyArray<string> = [
187
+ "System.Id",
188
+ "System.Title",
189
+ "System.Description",
190
+ "System.State",
191
+ "System.Tags",
192
+ "System.CreatedBy",
193
+ "System.ChangedDate",
194
+ "Microsoft.VSTS.Common.AcceptanceCriteria"
195
+ ]
196
+
197
+ export const wiqlFor = (filter: WorkItemFilter): string => {
198
+ const clauses = [
199
+ "[System.TeamProject] = @project",
200
+ ...(filter.state === "all"
201
+ ? []
202
+ : filter.state === "closed"
203
+ ? ["[System.State] = 'Closed'"]
204
+ : ["[System.State] <> 'Closed'"]),
205
+ ...(filter.tags ?? []).map((tag) => `[System.Tags] CONTAINS ${quoteWiql(tag)}`),
206
+ ...(filter.assignedTo === undefined
207
+ ? []
208
+ : [`[System.AssignedTo] = ${quoteWiql(filter.assignedTo)}`])
209
+ ]
210
+ const select = workItemFields.map((name) => `[${name}]`).join(", ")
211
+ return (
212
+ `SELECT TOP ${String(filter.limit ?? 100)} ${select} FROM WorkItems ` +
213
+ `WHERE ${clauses.join(" AND ")} ORDER BY [System.Id] ASC`
214
+ )
215
+ }
216
+
217
+ export const queryArgs = (config: AdoConfig, wiql: string): ReadonlyArray<string> => [
218
+ "boards",
219
+ "query",
220
+ "--wiql",
221
+ wiql,
222
+ "--project",
223
+ config.project,
224
+ ...org(config),
225
+ ...json
226
+ ]
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // Pull request argv
230
+ // ---------------------------------------------------------------------------
231
+
232
+ export const prCreateArgs = (
103
233
  config: AdoConfig,
104
234
  sourceRef: string,
105
235
  targetRef: string,
106
236
  title: string,
237
+ description: string,
238
+ draft = false
239
+ ): ReadonlyArray<string> => [
240
+ "repos",
241
+ "pr",
242
+ "create",
243
+ "--repository",
244
+ config.repository,
245
+ "--project",
246
+ config.project,
247
+ "--source-branch",
248
+ branchName(sourceRef),
249
+ "--target-branch",
250
+ branchName(targetRef),
251
+ "--title",
252
+ title,
253
+ "--description",
254
+ description,
255
+ ...(draft ? ["--draft", "true"] : []),
256
+ ...org(config),
257
+ ...json
258
+ ]
259
+
260
+ export const prListArgs = (config: AdoConfig, sourceRef?: string): ReadonlyArray<string> => [
261
+ "repos",
262
+ "pr",
263
+ "list",
264
+ "--repository",
265
+ config.repository,
266
+ "--project",
267
+ config.project,
268
+ "--status",
269
+ "active",
270
+ ...(sourceRef === undefined ? [] : ["--source-branch", branchName(sourceRef)]),
271
+ ...org(config),
272
+ ...json
273
+ ]
274
+
275
+ export const prUpdateArgs = (
276
+ config: AdoConfig,
277
+ id: number,
278
+ title: string,
107
279
  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
- })
118
- })
280
+ ): ReadonlyArray<string> => [
281
+ "repos",
282
+ "pr",
283
+ "update",
284
+ "--id",
285
+ String(id),
286
+ "--title",
287
+ title,
288
+ "--description",
289
+ description,
290
+ ...org(config),
291
+ ...json
292
+ ]
293
+
294
+ export const prCommentArgs = (
295
+ config: AdoConfig,
296
+ id: number,
297
+ text: string
298
+ ): ReadonlyArray<string> => [
299
+ "repos",
300
+ "pr",
301
+ "thread",
302
+ "create",
303
+ "--id",
304
+ String(id),
305
+ "--content",
306
+ text,
307
+ "--project",
308
+ config.project,
309
+ ...org(config),
310
+ ...json
311
+ ]
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // Development links
315
+ // ---------------------------------------------------------------------------
316
+
317
+ // The "Development" section of a work item is a set of ArtifactLink
318
+ // relations pointing at git objects. Their URLs are `vstfs:` URIs carrying
319
+ // GUIDs, not names — which is why `repository` below exists: a caller that
320
+ // knows a repository by name has to resolve its id before it can link
321
+ // anything to it, and has to reverse the mapping to read a link back.
322
+ export const GitArtifactKind = Schema.Literals(["Branch", "PullRequest", "Commit"])
323
+ export type GitArtifactKind = typeof GitArtifactKind.Type
324
+
325
+ export class GitArtifact extends Schema.Class<GitArtifact>("GitArtifact")({
326
+ kind: GitArtifactKind,
327
+ projectId: Schema.String,
328
+ repositoryId: Schema.String,
329
+ // Branch name, pull request id, or commit sha, by kind.
330
+ value: Schema.String
331
+ }) {}
332
+
333
+ export const gitArtifactKinds: ReadonlyArray<GitArtifactKind> = ["Branch", "PullRequest", "Commit"]
119
334
 
120
- const field = (fields: Readonly<Record<string, JsonValue>>, name: string): string => {
121
- const value = fields[name]
122
- return typeof value === "string" ? value : ""
335
+ // The CLI and the REST payloads name these links in prose, not by kind.
336
+ const linkNames: Readonly<Record<GitArtifactKind, string>> = {
337
+ Branch: "Branch",
338
+ PullRequest: "Pull Request",
339
+ Commit: "Fixed in Commit"
123
340
  }
124
341
 
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)
342
+ export const artifactLinkName = (kind: GitArtifactKind): string => linkNames[kind]
343
+
344
+ export const artifactKindOfName = (name: string): GitArtifactKind | undefined =>
345
+ gitArtifactKinds.find((kind) => linkNames[kind].toLowerCase() === name.trim().toLowerCase())
346
+
347
+ const uriSegment: Readonly<Record<GitArtifactKind, string>> = {
348
+ Branch: "Ref",
349
+ PullRequest: "PullRequestId",
350
+ Commit: "Commit"
351
+ }
352
+
353
+ // `vstfs:///Git/Ref/{project}%2F{repo}%2FGB{branch}` — the whole
354
+ // project/repo/value triple is ONE percent-encoded segment, which is what
355
+ // lets a branch name contain slashes without splitting the URI.
356
+ export const artifactUri = (artifact: GitArtifact): string => {
357
+ const value = artifact.kind === "Branch" ? `GB${artifact.value}` : artifact.value
358
+ return (
359
+ `vstfs:///Git/${uriSegment[artifact.kind]}/` +
360
+ encodeURIComponent(`${artifact.projectId}/${artifact.repositoryId}/${value}`)
361
+ )
362
+ }
363
+
364
+ export const parseArtifactUri = (uri: string): GitArtifact | undefined => {
365
+ const match = /^vstfs:\/\/\/Git\/(Ref|PullRequestId|Commit)\/(.+)$/.exec(uri.trim())
366
+ const segment = match?.[1]
367
+ const encoded = match?.[2]
368
+ if (segment === undefined || encoded === undefined) {
369
+ return undefined
370
+ }
371
+ const kind = gitArtifactKinds.find((candidate) => uriSegment[candidate] === segment)
372
+ let decoded: string
373
+ try {
374
+ decoded = decodeURIComponent(encoded)
375
+ } catch {
376
+ // A malformed escape is a link we cannot act on, not a crash.
377
+ return undefined
378
+ }
379
+ // Split into exactly three: a branch name may contain further slashes.
380
+ const first = decoded.indexOf("/")
381
+ const second = decoded.indexOf("/", first + 1)
382
+ if (kind === undefined || first < 0 || second < 0) {
383
+ return undefined
384
+ }
385
+ const rest = decoded.slice(second + 1)
386
+ const value = kind === "Branch" ? (rest.startsWith("GB") ? rest.slice(2) : rest) : rest
387
+ return value.length === 0
388
+ ? undefined
389
+ : GitArtifact.make({
390
+ kind,
391
+ projectId: decoded.slice(0, first),
392
+ repositoryId: decoded.slice(first + 1, second),
393
+ value
152
394
  })
153
- )
395
+ }
396
+
397
+ export const relationAddArgs = (
398
+ config: AdoConfig,
399
+ id: number,
400
+ artifact: GitArtifact
401
+ ): ReadonlyArray<string> => [
402
+ "boards",
403
+ "work-item",
404
+ "relation",
405
+ "add",
406
+ "--id",
407
+ String(id),
408
+ "--relation-type",
409
+ artifactLinkName(artifact.kind),
410
+ "--target-url",
411
+ artifactUri(artifact),
412
+ ...org(config),
413
+ ...json
414
+ ]
415
+
416
+ export const repositoryShowArgs = (
417
+ config: AdoConfig,
418
+ repository: string
419
+ ): ReadonlyArray<string> => [
420
+ "repos",
421
+ "show",
422
+ "--repository",
423
+ repository,
424
+ "--project",
425
+ config.project,
426
+ ...org(config),
427
+ ...json
428
+ ]
429
+
430
+ export class GitRepository extends Schema.Class<GitRepository>("GitRepository")({
431
+ id: Schema.String,
432
+ name: Schema.String,
433
+ projectId: Schema.String,
434
+ projectName: Schema.String,
435
+ defaultBranch: Schema.String,
436
+ webUrl: Schema.String
437
+ }) {}
438
+
439
+ export const prPolicyArgs = (config: AdoConfig, id: number): ReadonlyArray<string> => [
440
+ "repos",
441
+ "pr",
442
+ "policy",
443
+ "list",
444
+ "--id",
445
+ String(id),
446
+ ...org(config),
447
+ ...json
448
+ ]
449
+
450
+ export const prCompleteArgs = (
451
+ config: AdoConfig,
452
+ id: number,
453
+ squash: boolean,
454
+ deleteSourceBranch: boolean
455
+ ): ReadonlyArray<string> => [
456
+ "repos",
457
+ "pr",
458
+ "update",
459
+ "--id",
460
+ String(id),
461
+ "--status",
462
+ "completed",
463
+ "--squash",
464
+ squash ? "true" : "false",
465
+ "--delete-source-branch",
466
+ deleteSourceBranch ? "true" : "false",
467
+ ...org(config),
468
+ ...json
469
+ ]
470
+
471
+ // ---------------------------------------------------------------------------
472
+ // Parsing
473
+ // ---------------------------------------------------------------------------
474
+
475
+ // System.CreatedBy is an identity object on current API versions and a bare
476
+ // display string on older ones; both decode to the display name.
477
+ const Identity = Schema.Union([
478
+ Schema.String,
479
+ Schema.Struct({
480
+ displayName: Schema.optionalKey(Schema.String),
481
+ uniqueName: Schema.optionalKey(Schema.String)
482
+ })
483
+ ])
484
+
485
+ const identityName = (value: typeof Identity.Type | undefined): string =>
486
+ value === undefined
487
+ ? ""
488
+ : typeof value === "string"
489
+ ? value
490
+ : (value.displayName ?? value.uniqueName ?? "")
491
+
492
+ const AdoFields = Schema.Struct({
493
+ "System.Title": Schema.optionalKey(Schema.String),
494
+ "System.Description": Schema.optionalKey(Schema.String),
495
+ "System.State": Schema.optionalKey(Schema.String),
496
+ "System.Tags": Schema.optionalKey(Schema.String),
497
+ "System.CreatedBy": Schema.optionalKey(Identity),
498
+ "System.ChangedDate": Schema.optionalKey(Schema.String),
499
+ "Microsoft.VSTS.Common.AcceptanceCriteria": Schema.optionalKey(Schema.String)
500
+ })
501
+
502
+ const AdoWorkItem = Schema.Struct({
503
+ id: Schema.Int,
504
+ fields: AdoFields
505
+ })
506
+
507
+ export const parseTags = (raw: string): ReadonlyArray<string> =>
508
+ raw
509
+ .split(";")
510
+ .map((tag) => tag.trim())
511
+ .filter((tag) => tag.length > 0)
512
+
513
+ const toWorkItem = (item: typeof AdoWorkItem.Type): WorkItem =>
514
+ WorkItem.make({
515
+ id: item.id,
516
+ title: item.fields["System.Title"] ?? "",
517
+ description: item.fields["System.Description"] ?? "",
518
+ acceptanceCriteria: item.fields["Microsoft.VSTS.Common.AcceptanceCriteria"] ?? "",
519
+ state: item.fields["System.State"] ?? "",
520
+ tags: parseTags(item.fields["System.Tags"] ?? ""),
521
+ createdBy: identityName(item.fields["System.CreatedBy"]),
522
+ changedDate: item.fields["System.ChangedDate"] ?? ""
523
+ })
524
+
525
+ const decodeFailure =
526
+ (message: string) =>
527
+ (error: unknown): ProcessError =>
528
+ ProcessError.make({ message, detail: String(error) })
529
+
530
+ export const parseWorkItem = (payload: string): Effect.Effect<WorkItem, ProcessError> =>
531
+ Schema.decodeUnknownEffect(Schema.fromJsonString(AdoWorkItem))(payload).pipe(
532
+ Effect.map(toWorkItem),
533
+ Effect.mapError(decodeFailure("az boards work-item show"))
154
534
  )
155
535
 
156
- export const parseWiqlIds = (json: string): Effect.Effect<ReadonlyArray<number>, ProcessError> =>
157
- Schema.decodeUnknownEffect(
158
- Schema.fromJsonString(
536
+ // `az boards query` flattens the WIQL result into a work-item array, so a
537
+ // queue poll is a single call rather than a fan-out over ids.
538
+ export const parseWorkItems = (
539
+ payload: string
540
+ ): Effect.Effect<ReadonlyArray<WorkItem>, ProcessError> =>
541
+ Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(AdoWorkItem)))(payload).pipe(
542
+ Effect.map((items) => items.map(toWorkItem)),
543
+ Effect.mapError(decodeFailure("az boards query"))
544
+ )
545
+
546
+ export const parseWorkItemIds = (
547
+ payload: string
548
+ ): Effect.Effect<ReadonlyArray<number>, ProcessError> =>
549
+ parseWorkItems(payload).pipe(Effect.map((items) => items.map((item) => item.id)))
550
+
551
+ const AdoComments = Schema.Struct({
552
+ comments: Schema.Array(
553
+ Schema.Struct({
554
+ id: Schema.Int,
555
+ text: Schema.optionalKey(Schema.String),
556
+ createdBy: Schema.optionalKey(Identity),
557
+ createdDate: Schema.optionalKey(Schema.String)
558
+ })
559
+ ).pipe(Schema.withConstructorDefault(Effect.succeed(Object.freeze([]))))
560
+ })
561
+
562
+ export const parseComments = (
563
+ payload: string
564
+ ): Effect.Effect<ReadonlyArray<WorkItemComment>, ProcessError> =>
565
+ Schema.decodeUnknownEffect(Schema.fromJsonString(AdoComments))(payload).pipe(
566
+ Effect.map((parsed) =>
567
+ parsed.comments.map((comment) =>
568
+ WorkItemComment.make({
569
+ id: comment.id,
570
+ author: identityName(comment.createdBy),
571
+ text: comment.text ?? "",
572
+ createdDate: comment.createdDate ?? ""
573
+ })
574
+ )
575
+ ),
576
+ Effect.mapError(decodeFailure("az devops invoke wit comments"))
577
+ )
578
+
579
+ // `relations` is absent — not empty — on a work item whose Development
580
+ // section has never been touched, which is every work item until this tool
581
+ // links one. optionalKey, because a constructor default does not apply on
582
+ // decode and a missing key would fail the normal case.
583
+ const AdoRelations = Schema.Struct({
584
+ relations: Schema.optionalKey(
585
+ Schema.Array(
159
586
  Schema.Struct({
160
- workItems: Schema.Array(
161
- Schema.Struct({
162
- id: Schema.Int
163
- })
164
- )
587
+ rel: Schema.optionalKey(Schema.String),
588
+ url: Schema.optionalKey(Schema.String),
589
+ attributes: Schema.optionalKey(Schema.Struct({ name: Schema.optionalKey(Schema.String) }))
165
590
  })
166
591
  )
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)
592
+ )
593
+ })
594
+
595
+ // A work item with no Development section decodes to an empty list rather
596
+ // than failing: "nothing linked yet" is the normal state, not an error.
597
+ export const parseDevelopmentLinks = (
598
+ payload: string
599
+ ): Effect.Effect<ReadonlyArray<GitArtifact>, ProcessError> =>
600
+ Schema.decodeUnknownEffect(Schema.fromJsonString(AdoRelations))(payload).pipe(
601
+ Effect.map((parsed) =>
602
+ (parsed.relations ?? [])
603
+ .filter((relation) => (relation.rel ?? "").toLowerCase() === "artifactlink")
604
+ .flatMap((relation) => {
605
+ const artifact = parseArtifactUri(relation.url ?? "")
606
+ if (artifact === undefined) {
607
+ return []
608
+ }
609
+ // The URI segment already fixes the kind; the attribute name is
610
+ // only a cross-check for the links whose segment is shared.
611
+ const named = artifactKindOfName(relation.attributes?.name ?? "")
612
+ return named === undefined || named === artifact.kind ? [artifact] : []
613
+ })
614
+ ),
615
+ Effect.mapError(decodeFailure("az boards work-item show --expand relations"))
616
+ )
617
+
618
+ const AdoRepository = Schema.Struct({
619
+ id: Schema.String,
620
+ name: Schema.String,
621
+ project: Schema.Struct({
622
+ id: Schema.String,
623
+ name: Schema.optionalKey(Schema.String)
624
+ }),
625
+ defaultBranch: Schema.optionalKey(Schema.String),
626
+ webUrl: Schema.optionalKey(Schema.String)
627
+ })
628
+
629
+ export const parseRepository = (payload: string): Effect.Effect<GitRepository, ProcessError> =>
630
+ Schema.decodeUnknownEffect(Schema.fromJsonString(AdoRepository))(payload).pipe(
631
+ Effect.map((repository) =>
632
+ GitRepository.make({
633
+ id: repository.id,
634
+ name: repository.name,
635
+ projectId: repository.project.id,
636
+ projectName: repository.project.name ?? "",
637
+ // Reported as a full ref; callers branch and push by short name.
638
+ defaultBranch: branchName(repository.defaultBranch ?? ""),
639
+ webUrl: repository.webUrl ?? ""
173
640
  })
174
- )
641
+ ),
642
+ Effect.mapError(decodeFailure("az repos show"))
175
643
  )
176
644
 
645
+ const AdoPr = Schema.Struct({
646
+ pullRequestId: Schema.Int,
647
+ repository: Schema.Struct({
648
+ id: Schema.String,
649
+ project: Schema.Struct({ id: Schema.String })
650
+ })
651
+ })
652
+
653
+ const toPullRequest = (config: AdoConfig, pr: typeof AdoPr.Type): AdoPullRequest =>
654
+ AdoPullRequest.make({
655
+ id: pr.pullRequestId,
656
+ repoId: pr.repository.id,
657
+ projectId: pr.repository.project.id,
658
+ webUrl:
659
+ `${config.orgUrl}/${config.project}/_git/` +
660
+ `${config.repository}/pullrequest/${String(pr.pullRequestId)}`
661
+ })
662
+
177
663
  export const parsePullRequest = (
178
664
  config: AdoConfig,
179
- json: string
665
+ payload: string
180
666
  ): 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
- )
667
+ Schema.decodeUnknownEffect(Schema.fromJsonString(AdoPr))(payload).pipe(
668
+ Effect.map((pr) => toPullRequest(config, pr)),
669
+ Effect.mapError(decodeFailure("az repos pr"))
670
+ )
671
+
672
+ export const parsePullRequests = (
673
+ config: AdoConfig,
674
+ payload: string
675
+ ): Effect.Effect<ReadonlyArray<AdoPullRequest>, ProcessError> =>
676
+ Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(AdoPr)))(payload).pipe(
677
+ Effect.map((prs) => prs.map((pr) => toPullRequest(config, pr))),
678
+ Effect.mapError(decodeFailure("az repos pr list"))
679
+ )
680
+
681
+ const AdoPolicies = Schema.Array(
682
+ Schema.Struct({
683
+ status: Schema.optionalKey(Schema.String)
684
+ })
685
+ )
686
+
687
+ // Policy evaluation statuses: queued/running are still deciding, and
688
+ // rejected/broken have already decided against the PR.
689
+ export const outcomeFromPolicies = (payload: string): Effect.Effect<PolicyOutcome, ProcessError> =>
690
+ Schema.decodeUnknownEffect(Schema.fromJsonString(AdoPolicies))(payload).pipe(
691
+ Effect.map((policies) => {
692
+ const statuses = policies.map((policy) => policy.status?.toLowerCase() ?? "")
693
+ return statuses.some((value) => ["queued", "running"].includes(value))
694
+ ? "Pending"
695
+ : statuses.some((value) => ["rejected", "broken"].includes(value))
696
+ ? "Failure"
697
+ : "Success"
698
+ }),
699
+ Effect.mapError(decodeFailure("az repos pr policy list"))
210
700
  )
211
701
 
702
+ // ---------------------------------------------------------------------------
703
+ // Tool
704
+ // ---------------------------------------------------------------------------
705
+
212
706
  export interface AzureDevOpsToolShape {
213
707
  readonly readWorkItem: (id: number) => Effect.Effect<WorkItem, FlowError>
708
+ readonly listWorkItems: (
709
+ filter?: WorkItemFilter
710
+ ) => Effect.Effect<ReadonlyArray<WorkItem>, FlowError>
214
711
  readonly wiqlIds: (query: string) => Effect.Effect<ReadonlyArray<number>, FlowError>
712
+ readonly readComments: (id: number) => Effect.Effect<ReadonlyArray<WorkItemComment>, FlowError>
713
+ // The work item's Development section: the branches, pull requests, and
714
+ // commits linked to it. Empty when nothing has been linked yet.
715
+ readonly developmentLinks: (id: number) => Effect.Effect<ReadonlyArray<GitArtifact>, FlowError>
716
+ readonly linkArtifact: (id: number, artifact: GitArtifact) => Effect.Effect<void, FlowError>
717
+ // Resolves a repository's GUIDs, which every artifact link needs and no
718
+ // caller can know from a repository name alone.
719
+ readonly repository: (name?: string) => Effect.Effect<GitRepository, FlowError>
215
720
  readonly setFields: (
216
721
  id: number,
217
722
  fields: Readonly<Record<string, string>>
218
723
  ) => Effect.Effect<void, FlowError>
219
724
  readonly setState: (id: number, state: string) => Effect.Effect<void, FlowError>
220
725
  readonly setAcceptanceCriteria: (id: number, text: string) => Effect.Effect<void, FlowError>
726
+ // Tags are one semicolon-joined field, so an edit is read-merge-write
727
+ // rather than the add/remove verbs a label API would offer.
728
+ readonly editTags: (
729
+ id: number,
730
+ add: ReadonlyArray<string>,
731
+ remove: ReadonlyArray<string>
732
+ ) => Effect.Effect<void, FlowError>
733
+ readonly writeComment: (id: number, text: string) => Effect.Effect<void, FlowError>
734
+ readonly createWorkItem: (
735
+ workItemType: string,
736
+ title: string,
737
+ description: string,
738
+ tags?: ReadonlyArray<string>
739
+ ) => Effect.Effect<WorkItem, FlowError>
221
740
  readonly createPr: (
222
741
  sourceRef: string,
223
742
  targetRef: string,
224
743
  title: string,
225
- body: string
744
+ body: string,
745
+ draft?: boolean
226
746
  ) => Effect.Effect<AdoPullRequest, FlowError>
747
+ readonly openPrForBranch: (
748
+ sourceRef: string
749
+ ) => Effect.Effect<AdoPullRequest | undefined, FlowError>
750
+ readonly updatePr: (
751
+ pr: AdoPullRequest,
752
+ title: string,
753
+ body: string
754
+ ) => Effect.Effect<void, FlowError>
755
+ readonly writePrComment: (pr: AdoPullRequest, body: string) => Effect.Effect<void, FlowError>
756
+ readonly prPolicies: (pr: AdoPullRequest) => Effect.Effect<PolicyOutcome, FlowError>
757
+ readonly completePr: (
758
+ pr: AdoPullRequest,
759
+ squash?: boolean,
760
+ deleteSourceBranch?: boolean
761
+ ) => Effect.Effect<void, FlowError>
762
+ }
763
+
764
+ const output = (result: ProcessResult): string => result.stdout.join("\n").trim()
765
+
766
+ export const mergeTags = (
767
+ current: ReadonlyArray<string>,
768
+ add: ReadonlyArray<string>,
769
+ remove: ReadonlyArray<string>
770
+ ): ReadonlyArray<string> => {
771
+ const removed = new Set(remove.map((tag) => tag.toLowerCase()))
772
+ const kept = current.filter((tag) => !removed.has(tag.toLowerCase()))
773
+ const present = new Set(kept.map((tag) => tag.toLowerCase()))
774
+ const added = add.filter(
775
+ (tag) => !present.has(tag.toLowerCase()) && !removed.has(tag.toLowerCase())
776
+ )
777
+ return [...kept, ...added]
227
778
  }
228
779
 
229
780
  export const makeAzureDevOpsTool = (
230
781
  config: AdoConfig,
231
- http: HttpClientShape,
232
- events: FlowEventsShape,
233
- timeout: Duration.Duration = Duration.seconds(30)
782
+ process: ProcessExecutorShape,
783
+ workDir: string,
784
+ events: FlowEventsShape
234
785
  ): 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
- )
786
+ const run = (args: ReadonlyArray<string>): Effect.Effect<string, FlowError> =>
787
+ process.run(["az", ...args], workDir, {}).pipe(
788
+ Effect.mapError((error) =>
789
+ ProcessError.make({ message: `az ${args.join(" ")}`, detail: error.message })
790
+ ),
791
+ Effect.flatMap((result) =>
792
+ result.exitCode === 0
793
+ ? Effect.succeed(output(result))
794
+ : Effect.fail(
795
+ ProcessError.make({
796
+ message: `az ${args.join(" ")}`,
797
+ detail:
798
+ [...result.stdout, ...result.stderr].join("\n").trim() ||
799
+ `exit code ${result.exitCode}`
800
+ })
801
+ )
252
802
  )
803
+ )
253
804
 
254
805
  const read = <A>(
255
806
  operation: string,
@@ -260,32 +811,97 @@ export const makeAzureDevOpsTool = (
260
811
  effect: Effect.Effect<A, FlowError>
261
812
  ): Effect.Effect<A, FlowError> => guarded(Capabilities.AdoWrite, operation, events, effect)
262
813
 
814
+ const readWorkItem = (id: number): Effect.Effect<WorkItem, FlowError> =>
815
+ read("ado readWorkItem", run(workItemShowArgs(config, id)).pipe(Effect.flatMap(parseWorkItem)))
816
+
263
817
  const setFields = (
264
818
  id: number,
265
819
  fields: Readonly<Record<string, string>>
266
820
  ): Effect.Effect<void, FlowError> =>
267
- write("ado setFields", run(setFieldsRequest(config, id, fields)).pipe(Effect.asVoid))
821
+ write("ado setFields", run(workItemUpdateArgs(config, id, fields)).pipe(Effect.asVoid))
822
+
823
+ const listPrs = (sourceRef?: string): Effect.Effect<ReadonlyArray<AdoPullRequest>, FlowError> =>
824
+ run(prListArgs(config, sourceRef)).pipe(
825
+ Effect.flatMap((payload) => parsePullRequests(config, payload))
826
+ )
268
827
 
269
828
  return {
270
- readWorkItem: (id) =>
829
+ readWorkItem,
830
+ listWorkItems: (filter = {}) =>
271
831
  read(
272
- "ado readWorkItem",
273
- run(readWorkItemRequest(config, id)).pipe(Effect.flatMap(parseWorkItem))
832
+ "ado listWorkItems",
833
+ run(queryArgs(config, wiqlFor(filter))).pipe(Effect.flatMap(parseWorkItems))
274
834
  ),
275
835
  wiqlIds: (query) =>
276
- read("ado wiql", run(wiqlRequest(config, query)).pipe(Effect.flatMap(parseWiqlIds))),
836
+ read("ado wiql", run(queryArgs(config, query)).pipe(Effect.flatMap(parseWorkItemIds))),
837
+ readComments: (id) =>
838
+ read("ado readComments", run(commentsArgs(config, id)).pipe(Effect.flatMap(parseComments))),
839
+ developmentLinks: (id) =>
840
+ read(
841
+ "ado developmentLinks",
842
+ run(workItemShowArgs(config, id, "relations")).pipe(Effect.flatMap(parseDevelopmentLinks))
843
+ ),
844
+ linkArtifact: (id, artifact) =>
845
+ write("ado linkArtifact", run(relationAddArgs(config, id, artifact)).pipe(Effect.asVoid)),
846
+ repository: (name = config.repository) =>
847
+ read(
848
+ "ado repository",
849
+ run(repositoryShowArgs(config, name)).pipe(Effect.flatMap(parseRepository))
850
+ ),
277
851
  setFields,
278
852
  setState: (id, state) => setFields(id, { "System.State": state }),
279
853
  setAcceptanceCriteria: (id, text) =>
280
- setFields(id, {
281
- "Microsoft.VSTS.Common.AcceptanceCriteria": text
282
- }),
283
- createPr: (sourceRef, targetRef, title, body) =>
854
+ setFields(id, { "Microsoft.VSTS.Common.AcceptanceCriteria": text }),
855
+ editTags: (id, add, remove) =>
856
+ add.length === 0 && remove.length === 0
857
+ ? Effect.void
858
+ : readWorkItem(id).pipe(
859
+ Effect.flatMap((item) => {
860
+ const next = mergeTags(item.tags, add, remove)
861
+ return next.length === item.tags.length &&
862
+ next.every((tag, index) => tag === item.tags[index])
863
+ ? Effect.void
864
+ : setFields(id, { "System.Tags": next.join("; ") })
865
+ })
866
+ ),
867
+ writeComment: (id, text) =>
868
+ write("ado writeComment", run(workItemCommentArgs(config, id, text)).pipe(Effect.asVoid)),
869
+ createWorkItem: (workItemType, title, description, tags = []) =>
284
870
  write(
285
- "ado createPr",
286
- run(createPullRequestRequest(config, sourceRef, targetRef, title, body)).pipe(
287
- Effect.flatMap((json) => parsePullRequest(config, json))
871
+ "ado createWorkItem",
872
+ run(workItemCreateArgs(config, workItemType, title, description, tags)).pipe(
873
+ Effect.flatMap(parseWorkItem)
288
874
  )
875
+ ),
876
+ createPr: (sourceRef, targetRef, title, body, draft = false) =>
877
+ write(
878
+ "ado createPr",
879
+ // An active PR for the branch already IS the deliverable; creating a
880
+ // second one would fail on the server and lose the first's reviews.
881
+ Effect.flatMap(listPrs(sourceRef), (existing) => {
882
+ const open = existing[0]
883
+ return open !== undefined
884
+ ? Effect.succeed(open)
885
+ : run(prCreateArgs(config, sourceRef, targetRef, title, body, draft)).pipe(
886
+ Effect.flatMap((payload) => parsePullRequest(config, payload))
887
+ )
888
+ })
889
+ ),
890
+ openPrForBranch: (sourceRef) =>
891
+ read("ado listPrs", listPrs(sourceRef).pipe(Effect.map((prs) => prs[0]))),
892
+ updatePr: (pr, title, body) =>
893
+ write("ado updatePr", run(prUpdateArgs(config, pr.id, title, body)).pipe(Effect.asVoid)),
894
+ writePrComment: (pr, body) =>
895
+ write("ado writePrComment", run(prCommentArgs(config, pr.id, body)).pipe(Effect.asVoid)),
896
+ prPolicies: (pr) =>
897
+ read(
898
+ "ado prPolicies",
899
+ run(prPolicyArgs(config, pr.id)).pipe(Effect.flatMap(outcomeFromPolicies))
900
+ ),
901
+ completePr: (pr, squash = true, deleteSourceBranch = true) =>
902
+ write(
903
+ "ado completePr",
904
+ run(prCompleteArgs(config, pr.id, squash, deleteSourceBranch)).pipe(Effect.asVoid)
289
905
  )
290
906
  }
291
907
  }