@defrex/autobuild 0.10.1 → 0.11.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.
- package/README.md +1 -1
- package/SPEC.md +15 -1
- package/bin/ab-dispatch-kernel.ts +1 -1
- package/docs/configuration.md +32 -3
- package/docs/operator-api.md +17 -0
- package/docs/remote-ticket-protocol.md +5 -0
- package/package.json +3 -3
- package/packages/core/src/cli/binary.ts +3 -3
- package/packages/core/src/cli/dashboard/model.ts +4 -0
- package/packages/core/src/cli/dashboard/render.ts +37 -7
- package/packages/core/src/cli/dispatch-frontend.ts +16 -1
- package/packages/core/src/cli/dispatch-process.ts +25 -7
- package/packages/core/src/cli/dispatch.ts +64 -6
- package/packages/core/src/cli/dispatcher-liveness.ts +91 -0
- package/packages/core/src/cli/help.ts +8 -5
- package/packages/core/src/cli/repository-status.ts +45 -5
- package/packages/core/src/cli/watch.ts +159 -14
- package/packages/core/src/config/live.ts +1 -0
- package/packages/core/src/config/schema.ts +9 -2
- package/packages/core/src/events/globs.ts +8 -3
- package/packages/core/src/events/repository.ts +51 -2
- package/packages/core/src/integration/harness.ts +2 -1
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +18 -0
- package/packages/core/src/kernel/dispatch-status.ts +9 -1
- package/packages/core/src/kernel/dispatcher-liveness.ts +70 -0
- package/packages/core/src/kernel/reducer-registry.ts +4 -0
- package/packages/core/src/kernel/ticket-source-health.ts +30 -0
- package/packages/core/src/operator/query.ts +6 -3
- package/packages/core/src/ports/tickets/contract.ts +25 -0
- package/packages/core/src/ports/tickets/fake.ts +10 -8
- package/packages/core/src/ports/tickets/file.ts +1 -0
- package/packages/core/src/ports/tickets/linear.ts +241 -49
- package/packages/core/src/ports/tickets/remote-protocol.ts +2 -0
- package/packages/core/src/ports/types.ts +17 -0
- package/packages/core/src/processes/dispatcher.ts +277 -73
- package/packages/core/src/processes/repo-view.ts +2 -1
- package/packages/core/src/store/adapter.ts +1 -0
- package/packages/core/src/store/contract.ts +109 -0
- package/packages/core/src/store/digest.ts +14 -2
- package/packages/core/src/store/local/store.ts +18 -5
- package/packages/core/src/store/remote/protocol.ts +1 -0
- package/packages/core/src/store/repo-state-events.ts +49 -59
- package/packages/core/src/store/types.ts +5 -1
- package/skills/guide/SKILL.md +31 -0
- package/skills/operate/SKILL.md +39 -4
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
TicketSource,
|
|
19
19
|
TicketUpdate,
|
|
20
20
|
} from '../types'
|
|
21
|
+
import { TicketSourceRateLimitError } from '../types'
|
|
21
22
|
import { validateTicketUpdate } from './update'
|
|
22
23
|
|
|
23
24
|
export const LINEAR_API_URL = 'https://api.linear.app/graphql'
|
|
@@ -69,14 +70,36 @@ function isEntityNotFound(error: unknown): boolean {
|
|
|
69
70
|
export type LinearFetch = (
|
|
70
71
|
url: string,
|
|
71
72
|
init: { method: 'POST'; headers: Record<string, string>; body: string },
|
|
72
|
-
) => Promise<{
|
|
73
|
+
) => Promise<{
|
|
74
|
+
ok: boolean
|
|
75
|
+
status: number
|
|
76
|
+
headers?: { get(name: string): string | null }
|
|
77
|
+
json(): Promise<unknown>
|
|
78
|
+
}>
|
|
73
79
|
|
|
74
80
|
// ── Wire shapes (small structural types per operation) ───────────────────────
|
|
75
81
|
|
|
76
82
|
interface GqlIssueRelation {
|
|
77
83
|
id?: string
|
|
78
84
|
type: string
|
|
79
|
-
issue: { id?: string; identifier: string } | null
|
|
85
|
+
issue: { id?: string; identifier: string; state?: { type: string } | null } | null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface PageInfo {
|
|
89
|
+
hasNextPage: boolean
|
|
90
|
+
endCursor: string | null
|
|
91
|
+
}
|
|
92
|
+
interface RelationOwner {
|
|
93
|
+
inverseRelations?: { nodes: GqlIssueRelation[]; pageInfo?: PageInfo }
|
|
94
|
+
}
|
|
95
|
+
interface DependencyIssue extends RelationOwner {
|
|
96
|
+
id?: string
|
|
97
|
+
identifier: string
|
|
98
|
+
state: { type: string } | null
|
|
99
|
+
}
|
|
100
|
+
interface Connection<T> {
|
|
101
|
+
nodes: T[]
|
|
102
|
+
pageInfo: PageInfo
|
|
80
103
|
}
|
|
81
104
|
|
|
82
105
|
interface GqlIssue {
|
|
@@ -91,7 +114,7 @@ interface GqlIssue {
|
|
|
91
114
|
* `{issue: A, relatedIssue: B, type: "blocks"}` reads "A blocks B", so an
|
|
92
115
|
* issue's blockers are its inverse `blocks` relations, and the blocker is
|
|
93
116
|
* each relation's `issue`. Relation ids are required by the delete API. */
|
|
94
|
-
inverseRelations?: { nodes: GqlIssueRelation[] }
|
|
117
|
+
inverseRelations?: { nodes: GqlIssueRelation[]; pageInfo?: PageInfo }
|
|
95
118
|
}
|
|
96
119
|
|
|
97
120
|
/**
|
|
@@ -105,14 +128,14 @@ interface GqlIssue {
|
|
|
105
128
|
const RESOLVED_STATE_TYPES = new Set(['completed', 'canceled'])
|
|
106
129
|
|
|
107
130
|
/** Native inverse `blocks` relations for one blocked issue. */
|
|
108
|
-
function blockerRelationsOf(issue:
|
|
131
|
+
function blockerRelationsOf(issue: RelationOwner): GqlIssueRelation[] {
|
|
109
132
|
return (issue.inverseRelations?.nodes ?? []).filter(
|
|
110
133
|
(relation) => relation.type === 'blocks' && relation.issue !== null,
|
|
111
134
|
)
|
|
112
135
|
}
|
|
113
136
|
|
|
114
137
|
/** The identifiers blocking `issue`; unrelated relation kinds are ignored. */
|
|
115
|
-
function blockersOf(issue:
|
|
138
|
+
function blockersOf(issue: RelationOwner): string[] {
|
|
116
139
|
return [...new Set(blockerRelationsOf(issue).map((relation) => relation.issue!.identifier))]
|
|
117
140
|
}
|
|
118
141
|
|
|
@@ -175,16 +198,45 @@ interface LinearTeamInfo {
|
|
|
175
198
|
labelIds: Map<string, string>
|
|
176
199
|
}
|
|
177
200
|
|
|
178
|
-
/**
|
|
179
|
-
*
|
|
180
|
-
*
|
|
201
|
+
/** Direct issue reads and grooming retain the existing 250-relation window.
|
|
202
|
+
* Dependency graph reads accumulate that window in smaller pages; ready
|
|
203
|
+
* listings exhaust relations so they can safely report blocker completion. */
|
|
181
204
|
export const LINEAR_RELATION_PAGE_SIZE = 250
|
|
182
205
|
|
|
183
206
|
const ISSUE_FIELDS =
|
|
184
207
|
'id identifier title description url state { name type } labels { nodes { name } } ' +
|
|
185
208
|
`inverseRelations(first: ${LINEAR_RELATION_PAGE_SIZE}) { nodes { id type issue { id identifier } } }`
|
|
186
209
|
|
|
187
|
-
|
|
210
|
+
// Linear computes complexity from requested bounds, not returned counts:
|
|
211
|
+
// https://linear.app/developers/rate-limiting. Scalars cost .1, objects 1.
|
|
212
|
+
// Dependency: 25 * (1.2 + 1.1 + 100 * 2.2 + 1.2) + wrappers < 5,600.
|
|
213
|
+
// Ready: 10 * (10 + 50 * 1.1 + 100 * 3.5 + 1.2) + wrappers < 4,200.
|
|
214
|
+
// Continuations: <=25 * (2 + 100 * 2.2 + 1.2) < 5,600, or
|
|
215
|
+
// <=10 * (2 + 100 * 3.5 + 1.2) < 3,600. Every shape stays below 8,000.
|
|
216
|
+
const DEPENDENCY_PAGE_SIZE = 25
|
|
217
|
+
const READY_PAGE_SIZE = 10
|
|
218
|
+
const RELATION_PAGE_SIZE = 100
|
|
219
|
+
const READY_LABEL_PAGE_SIZE = 50
|
|
220
|
+
const PAGE_INFO = 'pageInfo { hasNextPage endCursor }'
|
|
221
|
+
const DEPENDENCY_RELATIONS = 'type issue { identifier }'
|
|
222
|
+
const READY_RELATIONS = 'id type issue { id identifier state { type } }'
|
|
223
|
+
const relationFields = (ready: boolean) => (ready ? READY_RELATIONS : DEPENDENCY_RELATIONS)
|
|
224
|
+
const dependencyFields = `id identifier state { type } inverseRelations(first: ${RELATION_PAGE_SIZE}) { nodes { ${DEPENDENCY_RELATIONS} } ${PAGE_INFO} }`
|
|
225
|
+
const DEPENDENCY_QUERY = `query Dependencies($filter: IssueFilter!, $first: Int!, $after: String) { issues(filter: $filter, first: $first, after: $after, includeArchived: true) { nodes { ${dependencyFields} } ${PAGE_INFO} } }`
|
|
226
|
+
const DEPENDENCY_FALLBACK_QUERY = `query Dependency($id: String!) { issue(id: $id) { ${dependencyFields} } }`
|
|
227
|
+
const READY_FIELDS = `id identifier title description url state { name type } labels(first: ${READY_LABEL_PAGE_SIZE}) { nodes { name } } inverseRelations(first: ${RELATION_PAGE_SIZE}) { nodes { ${READY_RELATIONS} } ${PAGE_INFO} }`
|
|
228
|
+
const LIST_READY_QUERY = `query ListReady($filter: IssueFilter!, $first: Int!, $after: String) { issues(filter: $filter, first: $first, after: $after) { nodes { ${READY_FIELDS} } ${PAGE_INFO} } }`
|
|
229
|
+
|
|
230
|
+
/** Refuse partial success and cursor loops on every connection independently. */
|
|
231
|
+
function nextCursor(info: PageInfo | undefined, seen: Set<string>): string | null {
|
|
232
|
+
if (!info || typeof info.hasNextPage !== 'boolean') throw new Error('linear: missing pageInfo')
|
|
233
|
+
if (!info.hasNextPage) return null
|
|
234
|
+
const cursor = info.endCursor
|
|
235
|
+
if (!cursor || seen.has(cursor)) throw new Error('linear: invalid or repeated pagination cursor')
|
|
236
|
+
seen.add(cursor)
|
|
237
|
+
return cursor
|
|
238
|
+
}
|
|
239
|
+
|
|
188
240
|
const GET_ISSUE_QUERY = `query GetIssue($id: String!) { issue(id: $id) { ${ISSUE_FIELDS} } }`
|
|
189
241
|
const RESOLVE_ISSUE_QUERY = `query ResolveIssue($id: String!) { issue(id: $id) { id } }`
|
|
190
242
|
const ISSUE_STATE_QUERY = `query IssueState($id: String!) { issue(id: $id) { id state { name type } } }`
|
|
@@ -206,6 +258,8 @@ export class LinearTicketSource implements TicketSource {
|
|
|
206
258
|
private readonly teamKey: string
|
|
207
259
|
private readonly claimedState: string
|
|
208
260
|
private readonly createState: string | undefined
|
|
261
|
+
private readonly now: () => number
|
|
262
|
+
private cooldown?: TicketSourceRateLimitError
|
|
209
263
|
private readonly fetchFn: LinearFetch
|
|
210
264
|
|
|
211
265
|
/** identifier → Linear UUID, cached per instance. */
|
|
@@ -220,12 +274,14 @@ export class LinearTicketSource implements TicketSource {
|
|
|
220
274
|
apiKey: string
|
|
221
275
|
teamKey: string
|
|
222
276
|
fetchFn?: LinearFetch
|
|
277
|
+
now?: () => number
|
|
223
278
|
/** Workflow state claim() moves the issue to (claim-before-launch, §12). */
|
|
224
279
|
claimedState?: string
|
|
225
280
|
/** Workflow state create() files new issues into; absent = Linear's team
|
|
226
281
|
* default (whatever the team's default state is, e.g. Backlog). */
|
|
227
282
|
createState?: string
|
|
228
283
|
}) {
|
|
284
|
+
this.now = opts.now ?? Date.now
|
|
229
285
|
this.apiKey = opts.apiKey
|
|
230
286
|
this.teamKey = opts.teamKey
|
|
231
287
|
this.claimedState = opts.claimedState ?? 'In Progress'
|
|
@@ -283,13 +339,31 @@ export class LinearTicketSource implements TicketSource {
|
|
|
283
339
|
labels: { some: { name: { eq: label } } },
|
|
284
340
|
}))
|
|
285
341
|
}
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
342
|
+
const tickets: Ticket[] = []
|
|
343
|
+
const seen = new Set<string>()
|
|
344
|
+
let after: string | null = null
|
|
345
|
+
do {
|
|
346
|
+
const data: { issues: Connection<GqlIssue> } = await this.gql('listReady', LIST_READY_QUERY, {
|
|
347
|
+
filter,
|
|
348
|
+
first: READY_PAGE_SIZE,
|
|
349
|
+
after,
|
|
350
|
+
})
|
|
351
|
+
await this.completeRelations(data.issues.nodes, true)
|
|
352
|
+
for (const issue of data.issues.nodes) {
|
|
353
|
+
const relations = issue.inverseRelations!.nodes
|
|
354
|
+
tickets.push({
|
|
355
|
+
...this.toTicket(issue),
|
|
356
|
+
blockersResolved: relations.every(
|
|
357
|
+
(relation) =>
|
|
358
|
+
relation.type !== 'blocks' ||
|
|
359
|
+
(!!relation.issue?.identifier &&
|
|
360
|
+
RESOLVED_STATE_TYPES.has(relation.issue.state?.type ?? '')),
|
|
361
|
+
),
|
|
362
|
+
})
|
|
363
|
+
}
|
|
364
|
+
after = nextCursor(data.issues.pageInfo, seen)
|
|
365
|
+
} while (after !== null)
|
|
366
|
+
return { tickets, diagnostics: [] }
|
|
293
367
|
}
|
|
294
368
|
|
|
295
369
|
async get(id: string): Promise<Ticket | null> {
|
|
@@ -506,43 +580,132 @@ export class LinearTicketSource implements TicketSource {
|
|
|
506
580
|
}
|
|
507
581
|
}
|
|
508
582
|
|
|
509
|
-
/**
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
* blockers). Deliberately uncached: a blocker completing between ticks must
|
|
513
|
-
* be visible on the very next pass, so only the identifier→UUID map (which
|
|
514
|
-
* never changes) is cached.
|
|
515
|
-
*/
|
|
583
|
+
/** One request per team/UUID group per issue page, plus batched relation
|
|
584
|
+
* continuation pages. No cache: workflow changes are visible next tick.
|
|
585
|
+
* Filter schema: https://github.com/linear/linear/blob/master/packages/sdk/src/schema.graphql */
|
|
516
586
|
async dependencyStates(ids: string[]): Promise<DependencyState[]> {
|
|
517
|
-
const
|
|
587
|
+
const groups = new Map<
|
|
588
|
+
string,
|
|
589
|
+
{ filter: Record<string, unknown>; values: Set<string | number> }
|
|
590
|
+
>()
|
|
591
|
+
const lookups = new Map<string, string>()
|
|
592
|
+
const fallback = new Set<string>()
|
|
518
593
|
for (const id of ids) {
|
|
519
|
-
|
|
594
|
+
const match = /^([A-Z][A-Z0-9-]*)-([1-9][0-9]*)$/.exec(id)
|
|
595
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)
|
|
596
|
+
const number = match ? Number(match[2]) : 0
|
|
597
|
+
if (!uuid && !(match && Number.isSafeInteger(number))) {
|
|
598
|
+
fallback.add(id)
|
|
599
|
+
lookups.set(id, id)
|
|
600
|
+
continue
|
|
601
|
+
}
|
|
602
|
+
const key = uuid ? 'uuid' : match![1]!
|
|
603
|
+
const group = groups.get(key) ?? {
|
|
604
|
+
filter: uuid ? {} : { team: { key: { eq: key } } },
|
|
605
|
+
values: new Set<string | number>(),
|
|
606
|
+
}
|
|
607
|
+
group.values.add(uuid ? id.toLowerCase() : number)
|
|
608
|
+
groups.set(key, group)
|
|
609
|
+
lookups.set(id, uuid ? id.toLowerCase() : id)
|
|
610
|
+
}
|
|
611
|
+
const found = new Map<string, DependencyIssue>()
|
|
612
|
+
for (const [key, group] of groups) {
|
|
613
|
+
const filter = {
|
|
614
|
+
...group.filter,
|
|
615
|
+
[key === 'uuid' ? 'id' : 'number']: { in: [...group.values] },
|
|
616
|
+
}
|
|
617
|
+
const seen = new Set<string>()
|
|
618
|
+
let after: string | null = null
|
|
619
|
+
do {
|
|
620
|
+
const data: { issues: Connection<DependencyIssue> } = await this.gql(
|
|
621
|
+
'dependencyStates',
|
|
622
|
+
DEPENDENCY_QUERY,
|
|
623
|
+
{
|
|
624
|
+
filter,
|
|
625
|
+
first: DEPENDENCY_PAGE_SIZE,
|
|
626
|
+
after,
|
|
627
|
+
},
|
|
628
|
+
)
|
|
629
|
+
await this.completeRelations(data.issues.nodes, false)
|
|
630
|
+
for (const issue of data.issues.nodes) {
|
|
631
|
+
found.set(issue.identifier, issue)
|
|
632
|
+
if (issue.id) found.set(issue.id.toLowerCase(), issue)
|
|
633
|
+
}
|
|
634
|
+
after = nextCursor(data.issues.pageInfo, seen)
|
|
635
|
+
} while (after !== null)
|
|
636
|
+
}
|
|
637
|
+
for (const id of fallback) {
|
|
638
|
+
let data: { issue: DependencyIssue | null }
|
|
520
639
|
try {
|
|
521
|
-
data = await this.gql
|
|
522
|
-
id,
|
|
523
|
-
})
|
|
640
|
+
data = await this.gql('dependencyStates', DEPENDENCY_FALLBACK_QUERY, { id })
|
|
524
641
|
} catch (error) {
|
|
525
|
-
|
|
526
|
-
// the dispatcher must be able to say "AUT-99 does not exist" rather
|
|
527
|
-
// than bailing out of the ticket's whole dependency evaluation.
|
|
528
|
-
if (isEntityNotFound(error)) {
|
|
529
|
-
states.push({ id, exists: false, resolved: false, blockedBy: [] })
|
|
530
|
-
continue
|
|
531
|
-
}
|
|
642
|
+
if (isEntityNotFound(error)) continue
|
|
532
643
|
throw error
|
|
533
644
|
}
|
|
534
|
-
if (
|
|
535
|
-
|
|
536
|
-
|
|
645
|
+
if (data.issue) {
|
|
646
|
+
await this.completeRelations([data.issue], false)
|
|
647
|
+
found.set(id, data.issue)
|
|
537
648
|
}
|
|
538
|
-
|
|
649
|
+
}
|
|
650
|
+
return ids.map((id) => {
|
|
651
|
+
const issue = found.get(lookups.get(id)!)
|
|
652
|
+
return {
|
|
539
653
|
id,
|
|
540
|
-
exists:
|
|
541
|
-
resolved: RESOLVED_STATE_TYPES.has(
|
|
542
|
-
blockedBy: blockersOf(
|
|
654
|
+
exists: !!issue,
|
|
655
|
+
resolved: !!issue && RESOLVED_STATE_TYPES.has(issue.state?.type ?? ''),
|
|
656
|
+
blockedBy: issue ? blockersOf(issue) : [],
|
|
657
|
+
}
|
|
658
|
+
})
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** Each alias owns a cursor. Graph reads retain the historic first 250
|
|
662
|
+
* relations; listing reads exhaust relations, including blockers after 250. */
|
|
663
|
+
private async completeRelations(issues: DependencyIssue[], ready: boolean): Promise<void> {
|
|
664
|
+
const pending = issues.map((issue) => ({
|
|
665
|
+
issue,
|
|
666
|
+
seen: new Set<string>(),
|
|
667
|
+
after: null as string | null,
|
|
668
|
+
}))
|
|
669
|
+
for (const item of pending)
|
|
670
|
+
item.after = nextCursor(item.issue.inverseRelations?.pageInfo, item.seen)
|
|
671
|
+
for (;;) {
|
|
672
|
+
const batch = pending.filter(
|
|
673
|
+
(item) =>
|
|
674
|
+
item.after !== null &&
|
|
675
|
+
(ready || item.issue.inverseRelations!.nodes.length < LINEAR_RELATION_PAGE_SIZE),
|
|
676
|
+
)
|
|
677
|
+
if (batch.length === 0) return
|
|
678
|
+
const definitions: string[] = []
|
|
679
|
+
const selections: string[] = []
|
|
680
|
+
const variables: Record<string, unknown> = {}
|
|
681
|
+
batch.forEach((item, n) => {
|
|
682
|
+
if (!item.issue.id) throw new Error('linear: relation continuation requires issue id')
|
|
683
|
+
definitions.push(`$id${n}: String!, $first${n}: Int!, $after${n}: String!`)
|
|
684
|
+
selections.push(
|
|
685
|
+
`r${n}: issue(id: $id${n}) { inverseRelations(first: $first${n}, after: $after${n}) { nodes { ${relationFields(ready)} } ${PAGE_INFO} } }`,
|
|
686
|
+
)
|
|
687
|
+
variables[`id${n}`] = item.issue.id
|
|
688
|
+
variables[`first${n}`] = ready
|
|
689
|
+
? RELATION_PAGE_SIZE
|
|
690
|
+
: Math.min(
|
|
691
|
+
RELATION_PAGE_SIZE,
|
|
692
|
+
LINEAR_RELATION_PAGE_SIZE - item.issue.inverseRelations!.nodes.length,
|
|
693
|
+
)
|
|
694
|
+
variables[`after${n}`] = item.after
|
|
695
|
+
})
|
|
696
|
+
const data = await this.gql<Record<string, RelationOwner | null>>(
|
|
697
|
+
'relations',
|
|
698
|
+
`query Relations(${definitions.join(', ')}) { ${selections.join(' ')} }`,
|
|
699
|
+
variables,
|
|
700
|
+
)
|
|
701
|
+
batch.forEach((item, n) => {
|
|
702
|
+
const connection = data[`r${n}`]?.inverseRelations
|
|
703
|
+
const after = nextCursor(connection?.pageInfo, item.seen)
|
|
704
|
+
if (!connection) throw new Error('linear: missing relation continuation')
|
|
705
|
+
item.issue.inverseRelations!.nodes.push(...connection.nodes)
|
|
706
|
+
item.after = after
|
|
543
707
|
})
|
|
544
708
|
}
|
|
545
|
-
return states
|
|
546
709
|
}
|
|
547
710
|
|
|
548
711
|
// ── Plumbing ───────────────────────────────────────────────────────────────
|
|
@@ -654,6 +817,7 @@ export class LinearTicketSource implements TicketSource {
|
|
|
654
817
|
query: string,
|
|
655
818
|
variables: Record<string, unknown>,
|
|
656
819
|
): Promise<T> {
|
|
820
|
+
if (this.cooldown && this.now() < this.cooldown.resetAt) throw this.cooldown
|
|
657
821
|
const response = await this.fetchFn(LINEAR_API_URL, {
|
|
658
822
|
method: 'POST',
|
|
659
823
|
headers: {
|
|
@@ -662,20 +826,47 @@ export class LinearTicketSource implements TicketSource {
|
|
|
662
826
|
},
|
|
663
827
|
body: JSON.stringify({ query, variables }),
|
|
664
828
|
})
|
|
665
|
-
|
|
666
|
-
|
|
829
|
+
const http = `linear ${operation}: HTTP ${response.status}`
|
|
830
|
+
let raw: unknown
|
|
831
|
+
try {
|
|
832
|
+
raw = await response.json()
|
|
833
|
+
} catch (error) {
|
|
834
|
+
if (!response.ok) throw new Error(http)
|
|
835
|
+
throw error
|
|
667
836
|
}
|
|
668
|
-
const payload = (
|
|
837
|
+
const payload = (raw !== null && typeof raw === 'object' ? raw : {}) as {
|
|
669
838
|
data?: T | null
|
|
670
839
|
errors?: GqlError[]
|
|
671
840
|
}
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
841
|
+
const errors = Array.isArray(payload.errors)
|
|
842
|
+
? payload.errors.filter((e) => e !== null && typeof e === 'object')
|
|
843
|
+
: []
|
|
844
|
+
if (errors.some((e) => e.extensions?.code === 'RATELIMITED')) {
|
|
845
|
+
const now = this.now()
|
|
846
|
+
const reset = Number(response.headers?.get('x-ratelimit-requests-reset'))
|
|
847
|
+
// Missing, expired or unrepresentable provider dates get a bounded fallback.
|
|
848
|
+
const resetAt =
|
|
849
|
+
Number.isFinite(reset) && reset > now && reset > 0 && reset <= 8.64e15
|
|
850
|
+
? reset
|
|
851
|
+
: now + 60_000
|
|
852
|
+
this.cooldown = new TicketSourceRateLimitError(this.name, resetAt)
|
|
853
|
+
throw this.cooldown
|
|
854
|
+
}
|
|
855
|
+
if (errors.length > 0) {
|
|
856
|
+
const messages = errors
|
|
857
|
+
.map((e) => `${e.extensions?.code ? `${e.extensions.code}: ` : ''}${e.message}`)
|
|
858
|
+
.join('; ')
|
|
859
|
+
throw new LinearGqlError(
|
|
860
|
+
`${response.ok ? `linear ${operation}` : http}: GraphQL errors — ${messages}`,
|
|
861
|
+
errors,
|
|
862
|
+
)
|
|
675
863
|
}
|
|
864
|
+
if (!response.ok) throw new Error(http)
|
|
676
865
|
if (payload.data === undefined || payload.data === null) {
|
|
677
866
|
throw new Error(`linear ${operation}: response has no data`)
|
|
678
867
|
}
|
|
868
|
+
// An older in-flight success must not cancel a still-active cooldown.
|
|
869
|
+
if (this.cooldown && this.now() >= this.cooldown.resetAt) this.cooldown = undefined
|
|
679
870
|
return payload.data
|
|
680
871
|
}
|
|
681
872
|
|
|
@@ -836,6 +1027,7 @@ export class LinearTicketSource implements TicketSource {
|
|
|
836
1027
|
title: issue.title,
|
|
837
1028
|
body: issue.description ?? '',
|
|
838
1029
|
state: issue.state?.name,
|
|
1030
|
+
resolved: RESOLVED_STATE_TYPES.has(issue.state?.type ?? ''),
|
|
839
1031
|
labels: issue.labels.nodes.map((label) => label.name),
|
|
840
1032
|
...(blockedBy.length > 0 ? { blockedBy } : {}),
|
|
841
1033
|
}
|
|
@@ -15,7 +15,9 @@ export const ticketWireSchema = z.strictObject({
|
|
|
15
15
|
title: z.string(),
|
|
16
16
|
body: z.string(),
|
|
17
17
|
state: z.string().optional(),
|
|
18
|
+
resolved: z.boolean(),
|
|
18
19
|
labels: z.array(z.string()),
|
|
20
|
+
blockersResolved: z.boolean().optional(),
|
|
19
21
|
blockedBy: z.array(z.string()).optional(),
|
|
20
22
|
})
|
|
21
23
|
export const ticketListingWireSchema = z.strictObject({
|
|
@@ -59,7 +59,12 @@ export interface Ticket {
|
|
|
59
59
|
/** The ticket body — pre-build, the spec lives here (§6.3). */
|
|
60
60
|
body: string
|
|
61
61
|
state?: string
|
|
62
|
+
/** Native completion fact, identical to DependencyState.resolved. */
|
|
63
|
+
resolved: boolean
|
|
62
64
|
labels: string[]
|
|
65
|
+
/** Listing-time source fact that all declared blockers are resolved. Only
|
|
66
|
+
* true permits skipping dependency reads; consumers fall back when absent. */
|
|
67
|
+
blockersResolved?: boolean
|
|
63
68
|
/** Source-local ids of tickets declared as blockers. Absent ≡ none. */
|
|
64
69
|
blockedBy?: string[]
|
|
65
70
|
}
|
|
@@ -131,6 +136,18 @@ export interface TicketStateInfo {
|
|
|
131
136
|
about?: string
|
|
132
137
|
}
|
|
133
138
|
|
|
139
|
+
/** Source methods may reject with this failure; it is not a dependency verdict. */
|
|
140
|
+
export class TicketSourceRateLimitError extends Error {
|
|
141
|
+
constructor(
|
|
142
|
+
readonly source: string,
|
|
143
|
+
/** UTC epoch milliseconds at which a caller may try again. */
|
|
144
|
+
readonly resetAt: number,
|
|
145
|
+
) {
|
|
146
|
+
super(`${source}: rate limited until ${new Date(resetAt).toISOString()}`)
|
|
147
|
+
this.name = 'TicketSourceRateLimitError'
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
134
151
|
export interface TicketSource {
|
|
135
152
|
readonly name: string
|
|
136
153
|
/** Ready tickets matching the dispatch criteria (label/state — §3.3), plus
|