lalph 0.3.35 → 0.3.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "lalph",
3
3
  "type": "module",
4
- "version": "0.3.35",
4
+ "version": "0.3.36",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -23,13 +23,13 @@
23
23
  "@changesets/changelog-github": "^0.5.2",
24
24
  "@changesets/cli": "^2.29.8",
25
25
  "@effect/language-service": "^0.75.1",
26
- "@effect/platform-node": "4.0.0-beta.21",
26
+ "@effect/platform-node": "4.0.0-beta.28",
27
27
  "@linear/sdk": "^75.0.0",
28
28
  "@octokit/plugin-rest-endpoint-methods": "^17.0.0",
29
29
  "@octokit/types": "^16.0.0",
30
30
  "@typescript/native-preview": "7.0.0-dev.20260219.1",
31
31
  "concurrently": "^9.2.1",
32
- "effect": "4.0.0-beta.21",
32
+ "effect": "4.0.0-beta.28",
33
33
  "husky": "^9.1.7",
34
34
  "lint-staged": "^16.2.7",
35
35
  "octokit": "^5.0.5",
@@ -73,7 +73,7 @@ export class TokenManager extends ServiceMap.Service<TokenManager>()(
73
73
  ).pipe(
74
74
  HttpClientRequest.bodyUrlParams({
75
75
  client_id: clientId,
76
- scope: "repo read:user",
76
+ scope: "repo read:user read:project",
77
77
  }),
78
78
  httpClient.execute,
79
79
  Effect.flatMap(
package/src/Github.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Api } from "@octokit/plugin-rest-endpoint-methods"
2
2
  import type { OctokitResponse } from "@octokit/types"
3
3
  import {
4
+ Array,
4
5
  Cache,
5
6
  Data,
6
7
  DateTime,
@@ -14,6 +15,7 @@ import {
14
15
  ServiceMap,
15
16
  Stream,
16
17
  String,
18
+ Unify,
17
19
  } from "effect"
18
20
  import { Octokit } from "octokit"
19
21
  import { IssueSource, IssueSourceError } from "./IssueSource.ts"
@@ -39,6 +41,10 @@ export interface GithubService {
39
41
  readonly request: <A>(
40
42
  f: (_: GithubApi) => Promise<A>,
41
43
  ) => Effect.Effect<A, GithubError, never>
44
+ readonly graphql: <A>(
45
+ query: string,
46
+ variables?: Record<string, unknown>,
47
+ ) => Effect.Effect<A, GithubError, never>
42
48
  readonly wrap: <A, Args extends Array<unknown>>(
43
49
  f: (_: GithubApi) => (...args: Args) => Promise<GithubResponse<A>>,
44
50
  ) => (...args: Args) => Effect.Effect<A, GithubError, never>
@@ -107,7 +113,7 @@ export class Github extends ServiceMap.Service<Github, GithubService>()(
107
113
  }
108
114
  })
109
115
 
110
- return octokit.rest
116
+ return octokit
111
117
  }),
112
118
  idleTimeToLive: "1 minute",
113
119
  })
@@ -118,9 +124,9 @@ export class Github extends ServiceMap.Service<Github, GithubService>()(
118
124
 
119
125
  const request = <A>(f: (_: GithubApi) => Promise<A>) =>
120
126
  getClient.pipe(
121
- Effect.flatMap((rest) =>
127
+ Effect.flatMap((client) =>
122
128
  Effect.tryPromise({
123
- try: () => f(rest),
129
+ try: () => f(client.rest),
124
130
  catch: (cause) => new GithubError({ cause }),
125
131
  }),
126
132
  ),
@@ -128,15 +134,27 @@ export class Github extends ServiceMap.Service<Github, GithubService>()(
128
134
  Effect.withSpan("Github.request"),
129
135
  )
130
136
 
137
+ const graphql = <A>(query: string, variables?: Record<string, unknown>) =>
138
+ getClient.pipe(
139
+ Effect.flatMap((client) =>
140
+ Effect.tryPromise({
141
+ try: () => client.graphql<A>(query, variables),
142
+ catch: (cause) => new GithubError({ cause }),
143
+ }),
144
+ ),
145
+ Effect.scoped,
146
+ Effect.withSpan("Github.graphql"),
147
+ )
148
+
131
149
  const wrap =
132
150
  <A, Args extends Array<unknown>>(
133
151
  f: (_: GithubApi) => (...args: Args) => Promise<OctokitResponse<A>>,
134
152
  ) =>
135
153
  (...args: Args) =>
136
154
  getClient.pipe(
137
- Effect.flatMap((rest) =>
155
+ Effect.flatMap((client) =>
138
156
  Effect.tryPromise({
139
- try: () => f(rest)(...args),
157
+ try: () => f(client.rest)(...args),
140
158
  catch: (cause) => new GithubError({ cause }),
141
159
  }),
142
160
  ),
@@ -150,9 +168,9 @@ export class Github extends ServiceMap.Service<Github, GithubService>()(
150
168
  ) =>
151
169
  Stream.paginate(0, (page) =>
152
170
  getClient.pipe(
153
- Effect.flatMap((rest) =>
171
+ Effect.flatMap((client) =>
154
172
  Effect.tryPromise({
155
- try: () => f(rest, page),
173
+ try: () => f(client.rest, page),
156
174
  catch: (cause) => new GithubError({ cause }),
157
175
  }),
158
176
  ),
@@ -163,7 +181,7 @@ export class Github extends ServiceMap.Service<Github, GithubService>()(
163
181
  ),
164
182
  )
165
183
 
166
- return { request, wrap, stream } as const
184
+ return { request, graphql, wrap, stream } as const
167
185
  }),
168
186
  },
169
187
  ) {
@@ -181,8 +199,9 @@ export const GithubIssueSource = Layer.effect(
181
199
  lookup: Effect.fnUntraced(
182
200
  function* (_projectId: ProjectId) {
183
201
  const labelFilter = yield* getOrSelectLabel
202
+ const projectFilter = yield* getOrSelectProjectFilter
184
203
  const autoMergeLabelName = yield* getOrSelectAutoMergeLabel
185
- return { labelFilter, autoMergeLabelName } as const
204
+ return { labelFilter, projectFilter, autoMergeLabelName } as const
186
205
  },
187
206
  Effect.orDie,
188
207
  (effect, projectId) =>
@@ -236,9 +255,94 @@ export const GithubIssueSource = Layer.effect(
236
255
  const presets = yield* getPresetsWithMetadata("github", PresetMetadata)
237
256
  const issuePresetMap = new Map<string, CliAgentPreset>()
238
257
 
239
- const issues = (options: {
258
+ const projects = Stream.paginate(null, (cursor: string | null) =>
259
+ github
260
+ .graphql<GithubProjectsQuery>(githubProjectsQuery, {
261
+ after: cursor,
262
+ })
263
+ .pipe(
264
+ Effect.map((data) => [
265
+ data.viewer.projectsV2.nodes,
266
+ Option.fromNullOr(data.viewer.projectsV2.pageInfo.endCursor),
267
+ ]),
268
+ ),
269
+ )
270
+
271
+ const listProjects = Stream.runCollect(projects).pipe(
272
+ Effect.map((a) =>
273
+ a.sort((left, right) => left.title.localeCompare(right.title)),
274
+ ),
275
+ )
276
+
277
+ const projectFilterSelect = Effect.gen(function* () {
278
+ const projects = yield* listProjects
279
+ const selectedProject = yield* Prompt.autoComplete({
280
+ message: "Select a GitHub project to filter issues by",
281
+ choices: [
282
+ {
283
+ title: "No project",
284
+ value: Option.none<GithubProject>(),
285
+ },
286
+ ].concat(
287
+ projects.map((project) => ({
288
+ title: `#${project.number} ${project.title}`,
289
+ description: project.url,
290
+ value: Option.some(project),
291
+ })),
292
+ ),
293
+ })
294
+ yield* Settings.setProject(
295
+ selectedProjectFilter,
296
+ Option.some(selectedProject),
297
+ )
298
+ return selectedProject
299
+ })
300
+
301
+ const getOrSelectProjectFilter = Effect.gen(function* () {
302
+ const project = yield* Settings.getProject(selectedProjectFilter)
303
+ if (Option.isSome(project)) {
304
+ return project.value
305
+ }
306
+ return yield* projectFilterSelect
307
+ })
308
+
309
+ const repository = `${cli.owner}/${cli.repo}`.toLowerCase()
310
+
311
+ const projectIssues = (project: GithubProject) => {
312
+ const threeDaysAgo = DateTime.nowUnsafe().pipe(
313
+ DateTime.subtract({ days: 3 }),
314
+ )
315
+ return Stream.paginate(null, (cursor: string | null) =>
316
+ github
317
+ .graphql<GithubProjectItemsQuery>(githubProjectItemsQuery, {
318
+ projectId: project.id,
319
+ after: cursor,
320
+ })
321
+ .pipe(
322
+ Effect.map((data) => [
323
+ data.node.items.nodes.map((item) => item.content),
324
+ Option.fromNullOr(data.node.items.pageInfo.endCursor),
325
+ ]),
326
+ ),
327
+ ).pipe(
328
+ Stream.filter(
329
+ (_) =>
330
+ _.repository.nameWithOwner.toLowerCase() === repository &&
331
+ (!_.closedAt ||
332
+ DateTime.makeUnsafe(_.closedAt).pipe(
333
+ DateTime.isGreaterThan(threeDaysAgo),
334
+ )),
335
+ ),
336
+ Stream.map((issue) => ({
337
+ ...issue,
338
+ state: issue.state.toLowerCase(),
339
+ labels: issue.labels.nodes.map((label) => label.name),
340
+ })),
341
+ )
342
+ }
343
+
344
+ const repoIssues = (options: {
240
345
  readonly labelFilter: Option.Option<string>
241
- readonly autoMergeLabelName: Option.Option<string>
242
346
  }) =>
243
347
  pipe(
244
348
  github.stream((rest, page) =>
@@ -253,6 +357,21 @@ export const GithubIssueSource = Layer.effect(
253
357
  ),
254
358
  Stream.merge(recentlyClosed),
255
359
  Stream.filter((issue) => issue.pull_request === undefined),
360
+ )
361
+
362
+ const issues = (options: {
363
+ readonly labelFilter: Option.Option<string>
364
+ readonly projectFilter: Option.Option<GithubProject>
365
+ readonly autoMergeLabelName: Option.Option<string>
366
+ }) => {
367
+ const source = Unify.unify(
368
+ Option.isSome(options.projectFilter)
369
+ ? projectIssues(options.projectFilter.value)
370
+ : repoIssues(options),
371
+ )
372
+
373
+ return pipe(
374
+ source,
256
375
  Stream.mapEffect(
257
376
  Effect.fnUntraced(function* (issue) {
258
377
  const id = `#${issue.number}`
@@ -294,6 +413,7 @@ export const GithubIssueSource = Layer.effect(
294
413
  Stream.runCollect,
295
414
  Effect.mapError((cause) => new IssueSourceError({ cause })),
296
415
  )
416
+ }
297
417
 
298
418
  const createIssue = github.wrap((rest) => rest.issues.create)
299
419
  const updateIssue = github.wrap((rest) => rest.issues.update)
@@ -362,7 +482,7 @@ export const GithubIssueSource = Layer.effect(
362
482
  ],
363
483
  })
364
484
 
365
- const blockedByNumbers = Array.from(
485
+ const blockedByNumbers = Array.fromIterable(
366
486
  new Set(
367
487
  issue.blockedBy
368
488
  .map((id) => Number(id.slice(1)))
@@ -405,7 +525,7 @@ export const GithubIssueSource = Layer.effect(
405
525
  issue_number: issueNumber,
406
526
  }),
407
527
  )
408
- const labels = Array.from(
528
+ const labels = Array.fromIterable(
409
529
  new Set([
410
530
  ...currentIssue.data.labels.flatMap((label) =>
411
531
  typeof label === "string"
@@ -531,6 +651,7 @@ export const GithubIssueSource = Layer.effect(
531
651
  ),
532
652
  reset: Effect.gen(function* () {
533
653
  const projectId = yield* CurrentProjectId
654
+ yield* Settings.setProject(selectedProjectFilter, Option.none())
534
655
  yield* Settings.setProject(labelFilter, Option.none())
535
656
  yield* Settings.setProject(autoMergeLabel, Option.none())
536
657
  yield* Cache.invalidate(projectSettings, projectId)
@@ -538,9 +659,13 @@ export const GithubIssueSource = Layer.effect(
538
659
  settings: (projectId) =>
539
660
  Effect.asVoid(Cache.get(projectSettings, projectId)),
540
661
  info: Effect.fnUntraced(function* (projectId) {
541
- const { labelFilter, autoMergeLabelName } = yield* Cache.get(
542
- projectSettings,
543
- projectId,
662
+ const { labelFilter, projectFilter, autoMergeLabelName } =
663
+ yield* Cache.get(projectSettings, projectId)
664
+ console.log(
665
+ ` GitHub project: ${Option.match(projectFilter, {
666
+ onNone: () => "None",
667
+ onSome: (value) => `#${value.number} ${value.title}`,
668
+ })}`,
544
669
  )
545
670
  console.log(
546
671
  ` Label filter: ${Option.match(labelFilter, {
@@ -611,6 +736,21 @@ export class GithubRepoNotFound extends Data.TaggedError("GithubRepoNotFound") {
611
736
  readonly message = "GitHub repository not found"
612
737
  }
613
738
 
739
+ // == project filter
740
+
741
+ const GithubProject = Schema.Struct({
742
+ id: Schema.String,
743
+ number: Schema.Number,
744
+ title: Schema.String,
745
+ url: Schema.String,
746
+ })
747
+ type GithubProject = typeof GithubProject.Type
748
+
749
+ const selectedProjectFilter = new ProjectSetting(
750
+ "github.projectFilter",
751
+ Schema.Option(GithubProject),
752
+ )
753
+
614
754
  // == label filter
615
755
 
616
756
  const labelFilter = new ProjectSetting(
@@ -667,8 +807,112 @@ const PresetMetadata = Schema.Struct({
667
807
  label: Schema.NonEmptyString,
668
808
  })
669
809
 
810
+ // == project helpers
811
+
812
+ type GithubProjectsQuery = {
813
+ readonly viewer: {
814
+ readonly projectsV2: {
815
+ readonly nodes: ReadonlyArray<{
816
+ readonly id: string
817
+ readonly number: number
818
+ readonly title: string
819
+ readonly closed: boolean
820
+ readonly url: string
821
+ }>
822
+ readonly pageInfo: {
823
+ readonly endCursor: string | null
824
+ readonly hasNextPage: boolean
825
+ }
826
+ }
827
+ }
828
+ }
829
+
830
+ type GithubProjectItemsQuery = {
831
+ readonly node: {
832
+ readonly items: {
833
+ readonly nodes: ReadonlyArray<{
834
+ readonly content: {
835
+ readonly __typename: string
836
+ readonly number: number
837
+ readonly repository: {
838
+ readonly nameWithOwner: string
839
+ }
840
+ readonly title: string
841
+ readonly body: string
842
+ readonly state: string
843
+ readonly labels: {
844
+ readonly nodes: ReadonlyArray<{
845
+ readonly name: string
846
+ }>
847
+ }
848
+ readonly closedAt: string | null
849
+ }
850
+ }>
851
+ readonly pageInfo: {
852
+ readonly endCursor: string | null
853
+ readonly hasNextPage: boolean
854
+ }
855
+ }
856
+ }
857
+ }
858
+
670
859
  // == helpers
671
860
 
861
+ const githubProjectsQuery = `
862
+ query GithubProjects($after: String) {
863
+ viewer {
864
+ projectsV2(first: 100, after: $after) {
865
+ nodes {
866
+ id
867
+ number
868
+ title
869
+ closed
870
+ url
871
+ }
872
+ pageInfo {
873
+ endCursor
874
+ hasNextPage
875
+ }
876
+ }
877
+ }
878
+ }
879
+ `
880
+
881
+ const githubProjectItemsQuery = `
882
+ query GithubProjectItems($projectId: ID!, $after: String) {
883
+ node(id: $projectId) {
884
+ ... on ProjectV2 {
885
+ items(first: 100, after: $after) {
886
+ nodes {
887
+ content {
888
+ __typename
889
+ ... on Issue {
890
+ number
891
+ repository {
892
+ nameWithOwner
893
+ }
894
+ labels(first: 20) {
895
+ nodes {
896
+ name
897
+ }
898
+ }
899
+ title
900
+ body
901
+ state
902
+ closedAt
903
+ }
904
+ }
905
+ }
906
+ pageInfo {
907
+ endCursor
908
+ hasNextPage
909
+ }
910
+ }
911
+ }
912
+ }
913
+ }
914
+ `
915
+
672
916
  const maybeNextPage = (page: number, linkHeader?: string) =>
673
917
  pipe(
674
918
  Option.fromNullishOr(linkHeader),
@@ -381,9 +381,11 @@ export const commandRoot = Command.make("lalph", {
381
381
  iterations,
382
382
  maxIterationMinutes,
383
383
  stallMinutes,
384
- specsDirectory,
385
- verbose,
386
384
  }).pipe(
385
+ Command.withSharedFlags({
386
+ specsDirectory,
387
+ verbose,
388
+ }),
387
389
  Command.withDescription(
388
390
  "Run the task loop across all enabled projects in parallel: pull issues from the current issue source and execute them with your configured agent preset(s). Use --iterations for a bounded run, and configure per-project concurrency via lalph projects edit.",
389
391
  ),