@boxxy/closeout 0.2.4 → 0.2.5
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 +8 -6
- package/dist/index.d.ts +1 -0
- package/dist/phases/merge.d.ts +1 -0
- package/dist/utils/build-criteria.d.ts +4 -0
- package/dist/utils/collect-open-item-problems.d.ts +2 -0
- package/dist/utils/collect-relation-problems.d.ts +2 -0
- package/dist/utils/find-latest-conclusion.d.ts +2 -0
- package/dist/utils/find-stopped-reason.d.ts +3 -0
- package/package.json +6 -5
- package/src/dedupe-open-items.ts +24 -14
- package/src/index.ts +1 -0
- package/src/phase-kinds.ts +4 -2
- package/src/phases/build.ts +13 -1
- package/src/phases/design.ts +57 -5
- package/src/phases/merge.ts +74 -0
- package/src/phases/triage.ts +28 -66
- package/src/utils/build-criteria.ts +11 -0
- package/src/utils/collect-open-item-problems.ts +13 -0
- package/src/utils/collect-relation-problems.ts +68 -0
- package/src/utils/find-latest-conclusion.ts +9 -0
- package/src/utils/find-stopped-reason.ts +19 -0
package/README.md
CHANGED
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
</p>
|
|
12
12
|
</div>
|
|
13
13
|
|
|
14
|
-
boxxy's phase kinds: triage, design, build, review and
|
|
15
|
-
attempt, checks the close-out
|
|
16
|
-
|
|
14
|
+
boxxy's phase kinds: triage, design, build, review, verify and merge. Each one gives the input of an
|
|
15
|
+
attempt, checks the close-out sent at the end of it, and settles the attempt from that close-out.
|
|
16
|
+
The merge phase kind runs on a job worker, and its drive decides each read of the job.
|
|
17
17
|
|
|
18
18
|
The package does no I/O: the caller passes in the item, its attempts, the attempt's evidence rows
|
|
19
19
|
and what a close-out may name.
|
|
@@ -30,15 +30,17 @@ The package runs on [Bun](https://bun.sh) 1.3 or newer.
|
|
|
30
30
|
|
|
31
31
|
## Features
|
|
32
32
|
|
|
33
|
-
- `triage`, `design`, `build` and `
|
|
34
|
-
Each parser returns the stored close-out, or every problem with it in one pass.
|
|
33
|
+
- `triage`, `design`, `build`, `verify` and `merge`, and `phaseKinds`, which lists them for the
|
|
34
|
+
rules loader. Each parser returns the stored close-out, or every problem with it in one pass.
|
|
35
|
+
`merge` drives each read with `decideMerge` from `@boxxy/train`, and a `merged` settle asks for
|
|
36
|
+
the `record-merge` effect.
|
|
35
37
|
- `createReviewPhase`, which builds the review phase kind on the review gate's `buildCommand`. The
|
|
36
38
|
server adds it to `phaseKinds`.
|
|
37
39
|
- `countUnrelatedLines`, the changed lines of a later review in files that had no open finding.
|
|
38
40
|
- `closeOutLimits`, the byte limit of a close-out, and `measureJsonBytes`, which measures a value as
|
|
39
41
|
stored.
|
|
40
42
|
- `dedupeOpenItems`, which matches each open item the agent reports to an open item with the same
|
|
41
|
-
title.
|
|
43
|
+
kind and title.
|
|
42
44
|
|
|
43
45
|
## Documentation
|
|
44
46
|
|
package/dist/index.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export { phaseKinds } from './phase-kinds.ts';
|
|
|
5
5
|
export { build } from './phases/build.ts';
|
|
6
6
|
export { design } from './phases/design.ts';
|
|
7
7
|
export { createReviewPhase } from './phases/create-review-phase.ts';
|
|
8
|
+
export { merge } from './phases/merge.ts';
|
|
8
9
|
export { triage } from './phases/triage.ts';
|
|
9
10
|
export { verify } from './phases/verify.ts';
|
|
10
11
|
export { countUnrelatedLines } from './review/count-unrelated-lines.ts';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const merge: import("@boxxy/model").PhaseKind<unknown, import("@boxxy/content-hash").JsonValue, readonly string[]>;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boxxy/closeout",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "boxxy's phase kinds: the input, the close-out parser and the settle rule of triage, design, build and
|
|
3
|
+
"version": "0.2.5",
|
|
4
|
+
"description": "boxxy's phase kinds: the input, the close-out parser and the settle rule of triage, design, build, review, verify and merge",
|
|
5
5
|
"homepage": "https://github.com/zgeoff/boxxy/tree/main/modules/closeout#readme",
|
|
6
6
|
"bugs": "https://github.com/zgeoff/boxxy/issues",
|
|
7
7
|
"license": "MIT",
|
|
@@ -30,14 +30,15 @@
|
|
|
30
30
|
"typecheck": "tsrs --noEmit"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@boxxy/model": "0.2.
|
|
33
|
+
"@boxxy/model": "0.2.5",
|
|
34
|
+
"@boxxy/train": "0.2.5",
|
|
34
35
|
"tiny-invariant": "1.3.3",
|
|
35
36
|
"ts-pattern": "5.9.2",
|
|
36
37
|
"zod": "4.6.7"
|
|
37
38
|
},
|
|
38
39
|
"devDependencies": {
|
|
39
|
-
"@boxxy/planning": "0.2.
|
|
40
|
-
"@boxxy/rules": "0.2.
|
|
40
|
+
"@boxxy/planning": "0.2.5",
|
|
41
|
+
"@boxxy/rules": "0.2.5",
|
|
41
42
|
"@faker-js/faker": "10.6.0"
|
|
42
43
|
},
|
|
43
44
|
"peerDependencies": {
|
package/src/dedupe-open-items.ts
CHANGED
|
@@ -1,46 +1,56 @@
|
|
|
1
|
-
import type { ItemId, ItemView, OpenItem } from '@boxxy/model';
|
|
1
|
+
import type { ItemId, ItemKindName, ItemView, OpenItem } from '@boxxy/model';
|
|
2
2
|
import { normaliseTitle } from './normalise-title.ts';
|
|
3
3
|
|
|
4
|
+
// An entry matches an open item of its own kind whose title is equal after normalising.
|
|
4
5
|
export function dedupeOpenItems(
|
|
5
6
|
entries: readonly OpenItem[],
|
|
6
7
|
open: readonly ItemView[],
|
|
7
8
|
): { readonly file: readonly OpenItem[]; readonly matched: readonly ItemId[] } {
|
|
8
|
-
const
|
|
9
|
-
const matched = entries.flatMap((entry) =>
|
|
9
|
+
const openByKey = collectOpenByKey(open);
|
|
10
|
+
const matched = entries.flatMap((entry) => openByKey.get(toDedupeKey(entry)) ?? []);
|
|
10
11
|
|
|
11
|
-
return { file: collectNewEntries(entries,
|
|
12
|
+
return { file: collectNewEntries(entries, openByKey), matched: [...new Set(matched)] };
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
function
|
|
15
|
-
const
|
|
15
|
+
function collectOpenByKey(open: readonly ItemView[]): Map<string, ItemId> {
|
|
16
|
+
const openByKey = new Map<string, ItemId>();
|
|
16
17
|
|
|
17
18
|
for (const item of open) {
|
|
18
|
-
const
|
|
19
|
+
const key = toDedupeKey(item);
|
|
19
20
|
|
|
20
|
-
if (!
|
|
21
|
-
|
|
21
|
+
if (!openByKey.has(key)) {
|
|
22
|
+
openByKey.set(key, item.id);
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
return
|
|
26
|
+
return openByKey;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface Titled {
|
|
30
|
+
readonly kind: ItemKindName;
|
|
31
|
+
readonly title: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function toDedupeKey(entry: Titled): string {
|
|
35
|
+
return JSON.stringify([entry.kind, normaliseTitle(entry.title)]);
|
|
26
36
|
}
|
|
27
37
|
|
|
28
38
|
function collectNewEntries(
|
|
29
39
|
entries: readonly OpenItem[],
|
|
30
|
-
|
|
40
|
+
openByKey: ReadonlyMap<string, ItemId>,
|
|
31
41
|
): OpenItem[] {
|
|
32
42
|
const seen = new Set<string>();
|
|
33
43
|
|
|
34
44
|
const file: OpenItem[] = [];
|
|
35
45
|
|
|
36
46
|
for (const entry of entries) {
|
|
37
|
-
const
|
|
47
|
+
const key = toDedupeKey(entry);
|
|
38
48
|
|
|
39
|
-
if (!
|
|
49
|
+
if (!openByKey.has(key) && !seen.has(key)) {
|
|
40
50
|
file.push(entry);
|
|
41
51
|
}
|
|
42
52
|
|
|
43
|
-
seen.add(
|
|
53
|
+
seen.add(key);
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
return file;
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ export { phaseKinds } from './phase-kinds.ts';
|
|
|
5
5
|
export { build } from './phases/build.ts';
|
|
6
6
|
export { design } from './phases/design.ts';
|
|
7
7
|
export { createReviewPhase } from './phases/create-review-phase.ts';
|
|
8
|
+
export { merge } from './phases/merge.ts';
|
|
8
9
|
export { triage } from './phases/triage.ts';
|
|
9
10
|
export { verify } from './phases/verify.ts';
|
|
10
11
|
export { countUnrelatedLines } from './review/count-unrelated-lines.ts';
|
package/src/phase-kinds.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { PhaseKind } from '@boxxy/model';
|
|
2
2
|
import { build } from './phases/build.ts';
|
|
3
3
|
import { design } from './phases/design.ts';
|
|
4
|
+
import { merge } from './phases/merge.ts';
|
|
4
5
|
import { triage } from './phases/triage.ts';
|
|
5
6
|
import { verify } from './phases/verify.ts';
|
|
6
7
|
|
|
7
8
|
// The phase kinds that need no port, in pipeline order. The server adds the review phase kind,
|
|
8
|
-
// which it builds on its review gate.
|
|
9
|
-
|
|
9
|
+
// which it builds on its review gate. The merge phase kind loads only on a server that builds its
|
|
10
|
+
// job worker, because the loader refuses a step whose worker the server did not build.
|
|
11
|
+
export const phaseKinds: readonly PhaseKind[] = [triage, design, build, verify, merge];
|
package/src/phases/build.ts
CHANGED
|
@@ -24,9 +24,13 @@ import type { z } from 'zod';
|
|
|
24
24
|
import { buildSchemaParser } from '../build-schema-parser.ts';
|
|
25
25
|
import { missingCriteria } from '../missing-criteria.ts';
|
|
26
26
|
import { buildCriterionTexts } from '../utils/build-criterion-texts.ts';
|
|
27
|
+
import { collectOpenItemProblems } from '../utils/collect-open-item-problems.ts';
|
|
28
|
+
import { collectRelationProblems } from '../utils/collect-relation-problems.ts';
|
|
27
29
|
import { findLatestCloseOut } from '../utils/find-latest-close-out.ts';
|
|
30
|
+
import { findLatestConclusion } from '../utils/find-latest-conclusion.ts';
|
|
28
31
|
import { findLatestDesign } from '../utils/find-latest-design.ts';
|
|
29
32
|
import { findPullRequestLink } from '../utils/find-pull-request-link.ts';
|
|
33
|
+
import { findStoppedReason } from '../utils/find-stopped-reason.ts';
|
|
30
34
|
|
|
31
35
|
const buildBodySchema = buildCloseOutSchema.omit({ frictionDropped: true });
|
|
32
36
|
|
|
@@ -67,7 +71,11 @@ function buildBuildInput(ctx: PhaseInputContext): BuildInput {
|
|
|
67
71
|
return {
|
|
68
72
|
acceptance: buildCriterionTexts(ctx.item),
|
|
69
73
|
design: findLatestDesign(ctx.history),
|
|
74
|
+
conclusion: findLatestConclusion(ctx.history),
|
|
70
75
|
sentBack: findSentBack(ctx.history),
|
|
76
|
+
stopped: findStoppedReason(ctx.history, 'build', buildCloseOutSchema, (body) =>
|
|
77
|
+
body.result.kind === 'blocked' ? body.result.reason : null,
|
|
78
|
+
),
|
|
71
79
|
pullRequest: findPullRequestLink(ctx.pullRequests),
|
|
72
80
|
};
|
|
73
81
|
}
|
|
@@ -125,13 +133,16 @@ function collectBuildProblems(closeOut: BuildCloseOut, ctx: CloseOutContext): Cl
|
|
|
125
133
|
...[...unknownCriteria].map((criterion) => ({ kind: 'unknown-criterion' as const, criterion })),
|
|
126
134
|
...[...foreignEvidence].map((evidence) => ({ kind: 'foreign-evidence' as const, evidence })),
|
|
127
135
|
...(missing.length > 0 ? [{ kind: 'built-without-evidence' as const, criteria: missing }] : []),
|
|
136
|
+
...collectOpenItemProblems(closeOut.openItems, ctx),
|
|
137
|
+
...collectRelationProblems(closeOut.relations ?? [], [], ctx),
|
|
128
138
|
];
|
|
129
139
|
}
|
|
130
140
|
|
|
131
141
|
function collectBuildEffects(closeOut: BuildCloseOut, item: ItemView): Effect[] {
|
|
132
142
|
const acceptance = buildCitedAcceptance(item.acceptance, closeOut.criteria);
|
|
133
143
|
|
|
134
|
-
// a failed build
|
|
144
|
+
// a failed build keeps the item's acceptance list: the next build of the item cites its evidence
|
|
145
|
+
// again
|
|
135
146
|
const isChanged =
|
|
136
147
|
closeOut.result.kind === 'built' &&
|
|
137
148
|
acceptance.some(
|
|
@@ -141,6 +152,7 @@ function collectBuildEffects(closeOut: BuildCloseOut, item: ItemView): Effect[]
|
|
|
141
152
|
return [
|
|
142
153
|
...closeOut.openItems.map((openItem) => ({ kind: 'file-item' as const, item: openItem })),
|
|
143
154
|
...(isChanged ? [{ kind: 'set-acceptance' as const, criteria: acceptance }] : []),
|
|
155
|
+
...(closeOut.relations ?? []).map((relation) => ({ kind: 'add-relation' as const, relation })),
|
|
144
156
|
...closeOut.friction.map((friction) => ({ kind: 'record-proposal' as const, friction })),
|
|
145
157
|
];
|
|
146
158
|
}
|
package/src/phases/design.ts
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
AcceptanceInput,
|
|
3
|
+
CloseOutContext,
|
|
4
|
+
CloseOutProblem,
|
|
5
|
+
DesignCloseOut,
|
|
6
|
+
Effect,
|
|
7
|
+
RelationDraft,
|
|
8
|
+
} from '@boxxy/model';
|
|
2
9
|
import { brandSchemas, definePhase, designCloseOutSchema, toJSONValue } from '@boxxy/model';
|
|
3
10
|
import { match } from 'ts-pattern';
|
|
4
11
|
import type { z } from 'zod';
|
|
5
12
|
import { buildSchemaParser } from '../build-schema-parser.ts';
|
|
13
|
+
import { buildCriteria } from '../utils/build-criteria.ts';
|
|
6
14
|
import { buildCriterionTexts } from '../utils/build-criterion-texts.ts';
|
|
15
|
+
import { collectOpenItemProblems } from '../utils/collect-open-item-problems.ts';
|
|
16
|
+
import { collectRelationProblems } from '../utils/collect-relation-problems.ts';
|
|
17
|
+
import { findStoppedReason } from '../utils/find-stopped-reason.ts';
|
|
7
18
|
|
|
8
19
|
const designBodySchema = designCloseOutSchema.omit({ frictionDropped: true });
|
|
9
20
|
|
|
@@ -12,17 +23,22 @@ export const design = definePhase({
|
|
|
12
23
|
version: 1,
|
|
13
24
|
worker: brandSchemas.workerKindName.parse('atc.session'),
|
|
14
25
|
outcomes: ['designed', 'blocked', 'abandoned'],
|
|
15
|
-
needsAcceptance:
|
|
26
|
+
needsAcceptance: false,
|
|
16
27
|
mode: (item) => (item.fields.designMode === 'attended' ? 'attended' : 'agent'),
|
|
17
28
|
input: (ctx) =>
|
|
18
|
-
toJSONValue({
|
|
29
|
+
toJSONValue({
|
|
30
|
+
acceptance: buildCriterionTexts(ctx.item),
|
|
31
|
+
stopped: findStoppedReason(ctx.history, 'design', designCloseOutSchema, (body) =>
|
|
32
|
+
body.result.kind === 'blocked' ? body.result.reason : null,
|
|
33
|
+
),
|
|
34
|
+
} satisfies AcceptanceInput),
|
|
19
35
|
closeOut: buildSchemaParser({
|
|
20
36
|
schema: designBodySchema,
|
|
21
37
|
toBody: (raw: z.output<typeof designBodySchema>, frictionDropped): DesignCloseOut => ({
|
|
22
38
|
...raw,
|
|
23
39
|
frictionDropped,
|
|
24
40
|
}),
|
|
25
|
-
check:
|
|
41
|
+
check: collectDesignProblems,
|
|
26
42
|
}),
|
|
27
43
|
|
|
28
44
|
// A blocked or abandoned design did not reach its goal, so its attempt fails. The failure is
|
|
@@ -36,7 +52,43 @@ export const design = definePhase({
|
|
|
36
52
|
}))
|
|
37
53
|
.exhaustive(),
|
|
38
54
|
outcome: input.closeOut.result.kind,
|
|
39
|
-
effects: input.closeOut
|
|
55
|
+
effects: collectDesignEffects(input.closeOut),
|
|
40
56
|
frictionDropped: input.closeOut.frictionDropped,
|
|
41
57
|
}),
|
|
42
58
|
});
|
|
59
|
+
|
|
60
|
+
function collectDesignProblems(closeOut: DesignCloseOut, ctx: CloseOutContext): CloseOutProblem[] {
|
|
61
|
+
const openItems = closeOut.result.kind === 'designed' ? (closeOut.result.openItems ?? []) : [];
|
|
62
|
+
|
|
63
|
+
return [
|
|
64
|
+
...collectOpenItemProblems(openItems, ctx),
|
|
65
|
+
...collectRelationProblems(getDesignRelations(closeOut), [], ctx),
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getDesignRelations(closeOut: DesignCloseOut): readonly RelationDraft[] {
|
|
70
|
+
return match(closeOut.result)
|
|
71
|
+
.with({ kind: 'designed' }, { kind: 'blocked' }, (result) => result.relations ?? [])
|
|
72
|
+
.with({ kind: 'abandoned' }, () => [])
|
|
73
|
+
.exhaustive();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function collectDesignEffects(closeOut: DesignCloseOut): Effect[] {
|
|
77
|
+
const designed = closeOut.result.kind === 'designed' ? closeOut.result : null;
|
|
78
|
+
const acceptance = designed?.set?.acceptance;
|
|
79
|
+
|
|
80
|
+
return [
|
|
81
|
+
...(designed?.openItems ?? []).map((openItem) => ({
|
|
82
|
+
kind: 'file-item' as const,
|
|
83
|
+
item: openItem,
|
|
84
|
+
})),
|
|
85
|
+
...(acceptance === undefined
|
|
86
|
+
? []
|
|
87
|
+
: [{ kind: 'set-acceptance' as const, criteria: buildCriteria(acceptance) }]),
|
|
88
|
+
...getDesignRelations(closeOut).map((relation) => ({
|
|
89
|
+
kind: 'add-relation' as const,
|
|
90
|
+
relation,
|
|
91
|
+
})),
|
|
92
|
+
...closeOut.friction.map((friction) => ({ kind: 'record-proposal' as const, friction })),
|
|
93
|
+
];
|
|
94
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { AttemptRow, MergeCloseOut, MergeInput, MergeRequest } from '@boxxy/model';
|
|
2
|
+
import {
|
|
3
|
+
brandSchemas,
|
|
4
|
+
definePhase,
|
|
5
|
+
mergeCloseOutSchema,
|
|
6
|
+
mergeInputSchema,
|
|
7
|
+
mergeReadingSchema,
|
|
8
|
+
toJSONValue,
|
|
9
|
+
} from '@boxxy/model';
|
|
10
|
+
import { decideMerge } from '@boxxy/train';
|
|
11
|
+
import invariant from 'tiny-invariant';
|
|
12
|
+
import { buildSchemaParser } from '../build-schema-parser.ts';
|
|
13
|
+
import { findPullRequestLink } from '../utils/find-pull-request-link.ts';
|
|
14
|
+
|
|
15
|
+
// No agent decides a merge: the runner calls the pure merge rule on each read of the job, and
|
|
16
|
+
// writes the close-out that the rule finishes with.
|
|
17
|
+
export const merge = definePhase({
|
|
18
|
+
name: brandSchemas.phaseName.parse('merge'),
|
|
19
|
+
version: 1,
|
|
20
|
+
worker: brandSchemas.workerKindName.parse('merge.forge'),
|
|
21
|
+
outcomes: ['merged', 'repair', 'stale', 'unverified', 'closed', 'stalled', 'refused', 'blocked'],
|
|
22
|
+
needsAcceptance: true,
|
|
23
|
+
input: (ctx) => {
|
|
24
|
+
const input: MergeInput = { pullRequest: findPullRequestLink(ctx.pullRequests) };
|
|
25
|
+
|
|
26
|
+
return toJSONValue(input);
|
|
27
|
+
},
|
|
28
|
+
drive: (ctx) => {
|
|
29
|
+
invariant(ctx.rules.merge !== null, 'the loader refuses a merge step with no merge block');
|
|
30
|
+
|
|
31
|
+
return decideMerge({
|
|
32
|
+
observed: mergeReadingSchema.parse(ctx.observed),
|
|
33
|
+
rules: ctx.rules.merge,
|
|
34
|
+
deadlinePassed: ctx.deadlinePassed,
|
|
35
|
+
acted: findMergeRequest(ctx.attempt),
|
|
36
|
+
now: ctx.now,
|
|
37
|
+
actRetryMinutes: ctx.actRetryMinutes,
|
|
38
|
+
});
|
|
39
|
+
},
|
|
40
|
+
closeOut: buildSchemaParser({
|
|
41
|
+
schema: mergeCloseOutSchema,
|
|
42
|
+
toBody: (raw): MergeCloseOut => raw,
|
|
43
|
+
check: () => [],
|
|
44
|
+
}),
|
|
45
|
+
settle: (input) => {
|
|
46
|
+
const closeOut = input.closeOut;
|
|
47
|
+
const isMerged = closeOut.outcome === 'merged';
|
|
48
|
+
const pullRequest = mergeInputSchema.parse(input.attempt.input).pullRequest;
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
state: isMerged ? 'succeeded' : 'failed',
|
|
52
|
+
failure: isMerged ? null : closeOut.outcome,
|
|
53
|
+
outcome: closeOut.outcome,
|
|
54
|
+
effects:
|
|
55
|
+
isMerged && pullRequest !== null
|
|
56
|
+
? [
|
|
57
|
+
{
|
|
58
|
+
kind: 'record-merge',
|
|
59
|
+
pullRequest: { repo: pullRequest.repo, number: pullRequest.number },
|
|
60
|
+
mergeCommit: closeOut.mergeCommit,
|
|
61
|
+
},
|
|
62
|
+
]
|
|
63
|
+
: [],
|
|
64
|
+
frictionDropped: 0,
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// the last request that the runner marked on the attempt row, with the forge's answer once one came
|
|
70
|
+
function findMergeRequest(attempt: AttemptRow): MergeRequest | null {
|
|
71
|
+
return attempt.actedAt === null || attempt.actedHead === null
|
|
72
|
+
? null
|
|
73
|
+
: { at: attempt.actedAt, head: attempt.actedHead, answer: attempt.actedAnswer };
|
|
74
|
+
}
|
package/src/phases/triage.ts
CHANGED
|
@@ -2,7 +2,6 @@ import type {
|
|
|
2
2
|
AcceptanceInput,
|
|
3
3
|
CloseOutContext,
|
|
4
4
|
CloseOutProblem,
|
|
5
|
-
Criterion,
|
|
6
5
|
Effect,
|
|
7
6
|
TriageCloseOut,
|
|
8
7
|
} from '@boxxy/model';
|
|
@@ -10,7 +9,11 @@ import { brandSchemas, definePhase, toJSONValue, triageCloseOutSchema } from '@b
|
|
|
10
9
|
import { match } from 'ts-pattern';
|
|
11
10
|
import type { z } from 'zod';
|
|
12
11
|
import { buildSchemaParser } from '../build-schema-parser.ts';
|
|
12
|
+
import { buildCriteria } from '../utils/build-criteria.ts';
|
|
13
13
|
import { buildCriterionTexts } from '../utils/build-criterion-texts.ts';
|
|
14
|
+
import { collectOpenItemProblems } from '../utils/collect-open-item-problems.ts';
|
|
15
|
+
import { collectRelationProblems } from '../utils/collect-relation-problems.ts';
|
|
16
|
+
import { findStoppedReason } from '../utils/find-stopped-reason.ts';
|
|
14
17
|
|
|
15
18
|
const triageBodySchema = triageCloseOutSchema.omit({ frictionDropped: true });
|
|
16
19
|
|
|
@@ -18,10 +21,15 @@ export const triage = definePhase({
|
|
|
18
21
|
name: brandSchemas.phaseName.parse('triage'),
|
|
19
22
|
version: 1,
|
|
20
23
|
worker: brandSchemas.workerKindName.parse('atc.session'),
|
|
21
|
-
outcomes: ['accepted', 'duplicate', 'declined'],
|
|
24
|
+
outcomes: ['accepted', 'duplicate', 'declined', 'needs-owner', 'split'],
|
|
22
25
|
needsAcceptance: false,
|
|
23
26
|
input: (ctx) =>
|
|
24
|
-
toJSONValue({
|
|
27
|
+
toJSONValue({
|
|
28
|
+
acceptance: buildCriterionTexts(ctx.item),
|
|
29
|
+
stopped: findStoppedReason(ctx.history, 'triage', triageCloseOutSchema, (body) =>
|
|
30
|
+
body.result.kind === 'needs-owner' ? body.result.question : null,
|
|
31
|
+
),
|
|
32
|
+
} satisfies AcceptanceInput),
|
|
25
33
|
closeOut: buildSchemaParser({
|
|
26
34
|
schema: triageBodySchema,
|
|
27
35
|
toBody: (raw: z.output<typeof triageBodySchema>, frictionDropped): TriageCloseOut => ({
|
|
@@ -43,10 +51,12 @@ function collectTriageProblems(closeOut: TriageCloseOut, ctx: CloseOutContext):
|
|
|
43
51
|
const acceptance = closeOut.set?.acceptance ?? ctx.item.acceptance;
|
|
44
52
|
const kind = closeOut.set?.kind;
|
|
45
53
|
const duplicateOf = closeOut.result.kind === 'duplicate' ? [closeOut.result.of] : [];
|
|
54
|
+
const parts = closeOut.result.kind === 'split' ? closeOut.result.openItems : [];
|
|
46
55
|
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
56
|
+
const implied = duplicateOf.map((target) => ({
|
|
57
|
+
type: brandSchemas.relationTypeName.parse('duplicate-of'),
|
|
58
|
+
target,
|
|
59
|
+
}));
|
|
50
60
|
|
|
51
61
|
return [
|
|
52
62
|
...(closeOut.result.kind === 'accepted' && acceptance.length === 0
|
|
@@ -55,63 +65,17 @@ function collectTriageProblems(closeOut: TriageCloseOut, ctx: CloseOutContext):
|
|
|
55
65
|
...(kind !== undefined && !ctx.world.kinds.has(kind)
|
|
56
66
|
? [{ kind: 'unknown-kind' as const, itemKind: kind }]
|
|
57
67
|
: []),
|
|
58
|
-
...
|
|
59
|
-
.filter((item) => !ctx.world.items.has(item))
|
|
60
|
-
.map((item) => ({ kind: 'unknown-item' as const, item })),
|
|
68
|
+
...collectOpenItemProblems(parts, ctx),
|
|
61
69
|
...duplicateOf
|
|
62
70
|
.filter((item) => item === ctx.item.id)
|
|
63
71
|
.map(() => ({
|
|
64
72
|
kind: 'phase-rule' as const,
|
|
65
73
|
message: 'an item is not a duplicate of itself',
|
|
66
74
|
})),
|
|
67
|
-
...closeOut.relations
|
|
68
|
-
...collectRepeatedRelations(closeOut, duplicateOf),
|
|
69
|
-
];
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function collectRelationProblems(
|
|
73
|
-
relation: TriageCloseOut['relations'][number],
|
|
74
|
-
ctx: CloseOutContext,
|
|
75
|
-
): CloseOutProblem[] {
|
|
76
|
-
const refused = (reason: 'unknown-type' | 'self'): CloseOutProblem => ({
|
|
77
|
-
kind: 'refused-relation',
|
|
78
|
-
type: relation.type,
|
|
79
|
-
target: relation.target,
|
|
80
|
-
reason,
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
return [
|
|
84
|
-
...(ctx.world.relationTypes.has(relation.type) ? [] : [refused('unknown-type')]),
|
|
85
|
-
...(relation.target === ctx.item.id ? [refused('self')] : []),
|
|
86
|
-
];
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// Each relation is one row of the store, so a close-out names each pair of type and target once.
|
|
90
|
-
// A duplicate's `of` names the `duplicate-of` relation already.
|
|
91
|
-
function collectRepeatedRelations(
|
|
92
|
-
closeOut: TriageCloseOut,
|
|
93
|
-
duplicateOf: readonly TriageCloseOut['relations'][number]['target'][],
|
|
94
|
-
): CloseOutProblem[] {
|
|
95
|
-
const named = [
|
|
96
|
-
...duplicateOf.map((target) => ({ type: 'duplicate-of', target })),
|
|
97
|
-
...closeOut.relations,
|
|
75
|
+
...collectRelationProblems(closeOut.relations, implied, ctx),
|
|
98
76
|
];
|
|
99
|
-
|
|
100
|
-
return named.flatMap((relation, index) =>
|
|
101
|
-
named
|
|
102
|
-
.slice(0, index)
|
|
103
|
-
.some((earlier) => earlier.type === relation.type && earlier.target === relation.target)
|
|
104
|
-
? [
|
|
105
|
-
{
|
|
106
|
-
kind: 'phase-rule' as const,
|
|
107
|
-
message: `the close-out names ${relation.type} ${relation.target} more than once`,
|
|
108
|
-
},
|
|
109
|
-
]
|
|
110
|
-
: [],
|
|
111
|
-
);
|
|
112
77
|
}
|
|
113
78
|
|
|
114
|
-
// A duplicate closes with `of`, which adds the `duplicate-of` relation in the same change.
|
|
115
79
|
function collectTriageEffects(closeOut: TriageCloseOut): Effect[] {
|
|
116
80
|
const set = closeOut.set ?? {};
|
|
117
81
|
|
|
@@ -123,25 +87,23 @@ function collectTriageEffects(closeOut: TriageCloseOut): Effect[] {
|
|
|
123
87
|
...(set.acceptance === undefined
|
|
124
88
|
? []
|
|
125
89
|
: [{ kind: 'set-acceptance' as const, criteria: buildCriteria(set.acceptance) }]),
|
|
90
|
+
...(set.body === undefined ? [] : [{ kind: 'set-body' as const, body: set.body }]),
|
|
126
91
|
...closeOut.relations.map((relation) => ({ kind: 'add-relation' as const, relation })),
|
|
127
|
-
...
|
|
92
|
+
...buildResultEffects(closeOut.result),
|
|
128
93
|
...closeOut.friction.map((friction) => ({ kind: 'record-proposal' as const, friction })),
|
|
129
94
|
];
|
|
130
95
|
}
|
|
131
96
|
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
id: brandSchemas.criterionId.parse(`a${index + 1}`),
|
|
136
|
-
text: criterion.text,
|
|
137
|
-
evidence: [],
|
|
138
|
-
}));
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function buildCloseEffects(result: TriageCloseOut['result']): Effect[] {
|
|
97
|
+
// A duplicate closes with `of`, which adds the `duplicate-of` relation in the same change. A split
|
|
98
|
+
// files its parts and closes the item; a hold edge withholds the close, and the parts still file.
|
|
99
|
+
function buildResultEffects(result: TriageCloseOut['result']): Effect[] {
|
|
142
100
|
return match(result)
|
|
143
101
|
.returnType<Effect[]>()
|
|
144
|
-
.with({ kind: 'accepted' }, () => [])
|
|
102
|
+
.with({ kind: 'accepted' }, { kind: 'needs-owner' }, () => [])
|
|
103
|
+
.with({ kind: 'split' }, (split) => [
|
|
104
|
+
...split.openItems.map((openItem) => ({ kind: 'file-item' as const, item: openItem })),
|
|
105
|
+
{ kind: 'close-item' as const, reason: 'split', of: null },
|
|
106
|
+
])
|
|
145
107
|
.with({ kind: 'duplicate' }, (duplicate) => [
|
|
146
108
|
{ kind: 'close-item', reason: 'duplicate', of: duplicate.of },
|
|
147
109
|
])
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Criterion } from '@boxxy/model';
|
|
2
|
+
import { brandSchemas } from '@boxxy/model';
|
|
3
|
+
|
|
4
|
+
// The list replaces the item's list whole, so its criteria are numbered from `a1`.
|
|
5
|
+
export function buildCriteria(acceptance: readonly { readonly text: string }[]): Criterion[] {
|
|
6
|
+
return acceptance.map((criterion, index) => ({
|
|
7
|
+
id: brandSchemas.criterionId.parse(`a${index + 1}`),
|
|
8
|
+
text: criterion.text,
|
|
9
|
+
evidence: [],
|
|
10
|
+
}));
|
|
11
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CloseOutContext, CloseOutProblem, OpenItem } from '@boxxy/model';
|
|
2
|
+
|
|
3
|
+
// An open item files as an item of its kind, so a kind the rules lack is refused, once per kind.
|
|
4
|
+
export function collectOpenItemProblems(
|
|
5
|
+
openItems: readonly OpenItem[],
|
|
6
|
+
ctx: CloseOutContext,
|
|
7
|
+
): CloseOutProblem[] {
|
|
8
|
+
const kinds = new Set(openItems.map((openItem) => openItem.kind));
|
|
9
|
+
|
|
10
|
+
return [...kinds]
|
|
11
|
+
.filter((kind) => !ctx.world.kinds.has(kind))
|
|
12
|
+
.map((kind) => ({ kind: 'unknown-kind', itemKind: kind }));
|
|
13
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { CloseOutContext, CloseOutProblem, RelationDraft } from '@boxxy/model';
|
|
2
|
+
import { getRelationEnds } from '@boxxy/model';
|
|
3
|
+
|
|
4
|
+
// `implied` holds the relations a result names already, such as a duplicate's `duplicate-of`. They
|
|
5
|
+
// count for the unknown items and the repeats only, so a duplicate of itself is one phase rule.
|
|
6
|
+
export function collectRelationProblems(
|
|
7
|
+
relations: readonly RelationDraft[],
|
|
8
|
+
implied: readonly RelationDraft[],
|
|
9
|
+
ctx: CloseOutContext,
|
|
10
|
+
): CloseOutProblem[] {
|
|
11
|
+
const named = [...implied, ...relations];
|
|
12
|
+
|
|
13
|
+
const others = named
|
|
14
|
+
.map((relation) => getRelationEnds(relation, ctx.item.id).other)
|
|
15
|
+
.filter((item) => item !== ctx.item.id);
|
|
16
|
+
|
|
17
|
+
return [
|
|
18
|
+
...[...new Set(others)]
|
|
19
|
+
.filter((item) => !ctx.world.items.has(item))
|
|
20
|
+
.map((item) => ({ kind: 'unknown-item' as const, item })),
|
|
21
|
+
...relations.flatMap((relation) => collectRefusals(relation, ctx)),
|
|
22
|
+
...collectRepeats(named, ctx),
|
|
23
|
+
];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// `target` of the problem is the other end, whichever way the draft points.
|
|
27
|
+
function collectRefusals(relation: RelationDraft, ctx: CloseOutContext): CloseOutProblem[] {
|
|
28
|
+
const other = getRelationEnds(relation, ctx.item.id).other;
|
|
29
|
+
|
|
30
|
+
const refused = (reason: 'unknown-type' | 'self'): CloseOutProblem => ({
|
|
31
|
+
kind: 'refused-relation',
|
|
32
|
+
type: relation.type,
|
|
33
|
+
target: other,
|
|
34
|
+
reason,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return [
|
|
38
|
+
...(ctx.world.relationTypes.has(relation.type) ? [] : [refused('unknown-type')]),
|
|
39
|
+
...(other === ctx.item.id ? [refused('self')] : []),
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Each relation is one row of the store, so a close-out names each relation of a type, source and
|
|
44
|
+
// target once.
|
|
45
|
+
function collectRepeats(named: readonly RelationDraft[], ctx: CloseOutContext): CloseOutProblem[] {
|
|
46
|
+
const keyed = named.map((relation) => {
|
|
47
|
+
const ends = getRelationEnds(relation, ctx.item.id);
|
|
48
|
+
|
|
49
|
+
return { relation, key: JSON.stringify([relation.type, ends.source, ends.target]) };
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return keyed.flatMap((entry, index) =>
|
|
53
|
+
keyed.slice(0, index).some((earlier) => earlier.key === entry.key)
|
|
54
|
+
? [
|
|
55
|
+
{
|
|
56
|
+
kind: 'phase-rule' as const,
|
|
57
|
+
message: `the close-out names ${formatRelationDraft(entry.relation)} more than once`,
|
|
58
|
+
},
|
|
59
|
+
]
|
|
60
|
+
: [],
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function formatRelationDraft(relation: RelationDraft): string {
|
|
65
|
+
return 'source' in relation
|
|
66
|
+
? `${relation.type} from ${relation.source}`
|
|
67
|
+
: `${relation.type} ${relation.target}`;
|
|
68
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { AttemptRow } from '@boxxy/model';
|
|
2
|
+
import { designCloseOutSchema } from '@boxxy/model';
|
|
3
|
+
import { findLatestCloseOut } from './find-latest-close-out.ts';
|
|
4
|
+
|
|
5
|
+
export function findLatestConclusion(history: readonly AttemptRow[]): string | null {
|
|
6
|
+
const closeOut = findLatestCloseOut(history, 'design', designCloseOutSchema);
|
|
7
|
+
|
|
8
|
+
return closeOut?.result.kind === 'designed' ? (closeOut.result.conclusion ?? null) : null;
|
|
9
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { AttemptRow } from '@boxxy/model';
|
|
2
|
+
import type { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
// Where the item's latest attempt of a phase that closed out stopped: what `pickReason` gives for
|
|
5
|
+
// its close-out, or null. An attempt that ended with no close-out, such as at its timeout, sent
|
|
6
|
+
// nothing to carry on from, so the one before it counts.
|
|
7
|
+
export function findStoppedReason<Body>(
|
|
8
|
+
history: readonly AttemptRow[],
|
|
9
|
+
phase: string,
|
|
10
|
+
schema: z.ZodType<Body>,
|
|
11
|
+
pickReason: (body: Body) => string | null,
|
|
12
|
+
): string | null {
|
|
13
|
+
const latest = history
|
|
14
|
+
.filter((row) => row.phase === phase)
|
|
15
|
+
.map((row) => schema.safeParse(row.closeOut))
|
|
16
|
+
.findLast((parsed) => parsed.success);
|
|
17
|
+
|
|
18
|
+
return latest?.data === undefined ? null : pickReason(latest.data);
|
|
19
|
+
}
|