@alecsibilia/luca 14.0.0-alpha.32 → 14.0.0-alpha.34
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/CHANGELOG.md +8 -0
- package/board/package.json +1 -1
- package/board/server/board-vocabulary-engine.ts +9 -1
- package/board/server/describe-record.ts +10 -2
- package/board/server/luca-version.ts +1 -1
- package/engine/cli/setup-build-cache-check.ts +52 -0
- package/engine/cli/setup-repo-checks.ts +15 -2
- package/engine/config/config-fields.ts +13 -0
- package/engine/config/config-reload.ts +18 -1
- package/engine/config/engine-config-schema.ts +67 -1
- package/engine/config/engine-config.ts +2 -0
- package/engine/config/test-commands.ts +55 -2
- package/engine/core/build-cache-copy.ts +206 -0
- package/engine/core/build-cache.ts +87 -0
- package/engine/core/build-context.ts +9 -0
- package/engine/core/execute-already-done-check.ts +1 -0
- package/engine/core/execute-build.ts +16 -1
- package/engine/core/execute-gates.ts +21 -1
- package/engine/core/execute.ts +6 -1
- package/engine/core/review-text.ts +15 -2
- package/engine/gates/gate-runner.ts +29 -2
- package/engine/gates/gate-schemas.ts +10 -0
- package/engine/git/git-adapter.ts +3 -1
- package/engine/git/git-join.ts +4 -0
- package/engine/journal/build-cache-entries.ts +62 -0
- package/engine/journal/journal-entry-schema.ts +6 -0
- package/engine/journal/journal-kinds.ts +2 -0
- package/engine/journal/journal-record-schema.ts +6 -0
- package/engine/journal/replay.ts +4 -0
- package/engine/journal/ticket-replay.ts +2 -0
- package/engine/models/calibration.ts +449 -0
- package/engine/models/model-score-facts.ts +21 -0
- package/engine/models/model-score.ts +22 -1
- package/engine/testing/calibration-fixtures.ts +174 -0
- package/engine/testing/model-score-fixtures.ts +6 -1
- package/engine/testing/practice-repo.ts +5 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
package/board/package.json
CHANGED
|
@@ -35,7 +35,15 @@ export const ENGINE_VOCABULARY = {
|
|
|
35
35
|
gates_run: z.looseObject({
|
|
36
36
|
target: z.string(),
|
|
37
37
|
ok: z.boolean(),
|
|
38
|
-
|
|
38
|
+
/** `skipped`: a ticket gate didn't run the test command (#722). */
|
|
39
|
+
checks: z.array(
|
|
40
|
+
z.looseObject({
|
|
41
|
+
name: z.string(),
|
|
42
|
+
ok: z.boolean(),
|
|
43
|
+
command: z.string().optional(),
|
|
44
|
+
skipped: z.boolean().optional(),
|
|
45
|
+
})
|
|
46
|
+
),
|
|
39
47
|
}),
|
|
40
48
|
ticket_joined: z.looseObject({
|
|
41
49
|
ok: z.boolean(),
|
|
@@ -238,13 +238,21 @@ export const describeRecord = ({
|
|
|
238
238
|
? ' after joining'
|
|
239
239
|
: ''
|
|
240
240
|
const who = record.ticket === null ? 'Final review: ' : at
|
|
241
|
+
// The test commands a ticket gate skipped by path (#722).
|
|
242
|
+
const skipped = record.content.checks
|
|
243
|
+
.filter((check) => check.skipped === true)
|
|
244
|
+
.map((check) => check.command ?? check.name)
|
|
245
|
+
const skips =
|
|
246
|
+
record.content.target === 'ticket' && skipped.length > 0
|
|
247
|
+
? ` Skipped (no changed file matches its paths): ${skipped.join(', ')}.`
|
|
248
|
+
: ''
|
|
241
249
|
return record.content.ok
|
|
242
250
|
? event({
|
|
243
|
-
text: `${who}checks passed${where}
|
|
251
|
+
text: `${who}checks passed${where}.${skips}`,
|
|
244
252
|
tone: 'success',
|
|
245
253
|
})
|
|
246
254
|
: event({
|
|
247
|
-
text: `${who}checks failed${where}: ${failed.join(', ') || 'no detail'}
|
|
255
|
+
text: `${who}checks failed${where}: ${failed.join(', ') || 'no detail'}.${skips}`,
|
|
248
256
|
tone: 'danger',
|
|
249
257
|
})
|
|
250
258
|
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The Luca version of this board, stamped when the package was packed. */
|
|
2
|
-
export const LUCA_VERSION: string = "14.0.0-alpha.
|
|
2
|
+
export const LUCA_VERSION: string = "14.0.0-alpha.34"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doctor's `build_cache` check (#721): each `build_cache_paths` path should
|
|
3
|
+
* be ignored by git in the repo's checkout, since Luca never copies one
|
|
4
|
+
* that isn't, so a copied cache never ends up in a commit.
|
|
5
|
+
*/
|
|
6
|
+
import uniq from 'lodash/uniq'
|
|
7
|
+
|
|
8
|
+
import { ok, warning, type Found } from './doctor-checks'
|
|
9
|
+
|
|
10
|
+
import { buildCachePathsOf, type EngineConfig } from '../config/engine-config'
|
|
11
|
+
import { ignoredAmong, isGlob, matchesIn } from '../core/build-cache'
|
|
12
|
+
import { createGitAdapter } from '../git/git-adapter'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The `build_cache` check for a config with build cache paths: a warning
|
|
16
|
+
* naming each path git doesn't ignore in the checkout, made into one; else
|
|
17
|
+
* OK. A plain entry is checked as written, even before its folder is there;
|
|
18
|
+
* a glob, by its matches in the checkout.
|
|
19
|
+
*/
|
|
20
|
+
export const checkBuildCache = async ({
|
|
21
|
+
repo,
|
|
22
|
+
config,
|
|
23
|
+
}: {
|
|
24
|
+
repo: string
|
|
25
|
+
config: EngineConfig
|
|
26
|
+
}): Promise<Found> => {
|
|
27
|
+
const paths: string[] = []
|
|
28
|
+
for (const entry of buildCachePathsOf({ config })) {
|
|
29
|
+
paths.push(
|
|
30
|
+
...(isGlob(entry) ? await matchesIn({ entry, dir: repo }) : [entry])
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
const checked = uniq(paths)
|
|
34
|
+
const ignored = await ignoredAmong({
|
|
35
|
+
git: createGitAdapter({ repo_root: repo }),
|
|
36
|
+
cwd: repo,
|
|
37
|
+
paths: checked,
|
|
38
|
+
})
|
|
39
|
+
const missing = checked.filter((path) => !ignored.has(path))
|
|
40
|
+
if (missing.length === 0) {
|
|
41
|
+
return ok('Each build_cache_paths path is ignored by git')
|
|
42
|
+
}
|
|
43
|
+
return warning({
|
|
44
|
+
detail: missing
|
|
45
|
+
.map(
|
|
46
|
+
(path) =>
|
|
47
|
+
`\`build_cache_paths\` path ${path} isn't ignored by git, so Luca won't copy it.`
|
|
48
|
+
)
|
|
49
|
+
.join(' '),
|
|
50
|
+
fix: missing.map((path) => `Add ${path} to .gitignore.`).join(' '),
|
|
51
|
+
})
|
|
52
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Doctor's repo group (`repoChecks`), which `luca setup` runs once too: the
|
|
3
|
-
* labels, the config, its junit commands (#668), the GitHub remote, issue
|
|
3
|
+
* labels, the config, its junit commands (#668) and build caches (#721), the GitHub remote, issue
|
|
4
4
|
* links, the base branch on `origin`, and the memory vault. Read-only.
|
|
5
5
|
*/
|
|
6
6
|
import { join } from 'node:path'
|
|
@@ -12,10 +12,12 @@ import {
|
|
|
12
12
|
type DoctorCheck,
|
|
13
13
|
type Found,
|
|
14
14
|
} from './doctor-checks'
|
|
15
|
+
import { checkBuildCache } from './setup-build-cache-check'
|
|
15
16
|
import { errorText, findConfig } from './setup-config'
|
|
16
17
|
import { checkJunit } from './setup-junit-check'
|
|
17
18
|
|
|
18
19
|
import {
|
|
20
|
+
buildCachePathsOf,
|
|
19
21
|
ENGINE_CONFIG_FILE,
|
|
20
22
|
hasJunitCommand,
|
|
21
23
|
loadEngineConfig,
|
|
@@ -223,7 +225,8 @@ const checkConfig = async ({
|
|
|
223
225
|
/**
|
|
224
226
|
* `luca setup`'s checks as doctor's repo group, read-only: they create no
|
|
225
227
|
* label and write no file. Each problem carries its fix. The `junit` check
|
|
226
|
-
* (#668) is there only when the config loads and has a junit command
|
|
228
|
+
* (#668) is there only when the config loads and has a junit command, and
|
|
229
|
+
* the `build_cache` check (#721) only when it has build cache paths.
|
|
227
230
|
*
|
|
228
231
|
* @example
|
|
229
232
|
* const checks = await repoChecks({ repo: process.cwd(), github, memory })
|
|
@@ -248,12 +251,22 @@ export const repoChecks = async ({
|
|
|
248
251
|
loaded.ok && hasJunitCommand({ config: loaded.config })
|
|
249
252
|
? [['junit', () => checkJunit({ repo, config: loaded.config })]]
|
|
250
253
|
: []
|
|
254
|
+
const build_cache: [string, () => Promise<Found>][] =
|
|
255
|
+
loaded.ok && buildCachePathsOf({ config: loaded.config }).length > 0
|
|
256
|
+
? [
|
|
257
|
+
[
|
|
258
|
+
'build_cache',
|
|
259
|
+
() => checkBuildCache({ repo, config: loaded.config }),
|
|
260
|
+
],
|
|
261
|
+
]
|
|
262
|
+
: []
|
|
251
263
|
return checksFor({
|
|
252
264
|
group: 'repo',
|
|
253
265
|
checks: [
|
|
254
266
|
['labels', () => checkLabels({ repo, github, github_repo })],
|
|
255
267
|
['config', () => checkConfig({ repo, loaded })],
|
|
256
268
|
...junit,
|
|
269
|
+
...build_cache,
|
|
257
270
|
['github_remote', async () => checkGitHubRemote({ github_repo })],
|
|
258
271
|
['issue_links', () => checkIssueLinks({ github, github_repo })],
|
|
259
272
|
['base_branch', () => checkBaseBranch({ repo, base_branch })],
|
|
@@ -104,6 +104,19 @@ export const modelsOf = ({ config }: { config: EngineConfig }): ModelsConfig =>
|
|
|
104
104
|
export const autoRetryOf = ({ config }: { config: EngineConfig }): boolean =>
|
|
105
105
|
config.auto_retry ?? true
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* The repo's build-cache paths and globs (#721): the config's
|
|
109
|
+
* `build_cache_paths`, else none.
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* buildCachePathsOf({ config: { ...config, build_cache_paths: ['cache'] } }) // ['cache']
|
|
113
|
+
*/
|
|
114
|
+
export const buildCachePathsOf = ({
|
|
115
|
+
config,
|
|
116
|
+
}: {
|
|
117
|
+
config: EngineConfig
|
|
118
|
+
}): string[] => config.build_cache_paths ?? []
|
|
119
|
+
|
|
107
120
|
/**
|
|
108
121
|
* The feature map's path (#583), in the form a worktree-relative path takes
|
|
109
122
|
* everywhere else (`normalize`, no leading `./`), or `null` with no map.
|
|
@@ -3,7 +3,7 @@ import omit from 'lodash/omit'
|
|
|
3
3
|
import reduce from 'lodash/reduce'
|
|
4
4
|
import { z } from 'zod'
|
|
5
5
|
|
|
6
|
-
import { autoRetryOf } from './config-fields'
|
|
6
|
+
import { autoRetryOf, buildCachePathsOf } from './config-fields'
|
|
7
7
|
import type { EngineConfig } from './engine-config'
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -32,6 +32,9 @@ import type { EngineConfig } from './engine-config'
|
|
|
32
32
|
* - `auto_retry`: whether the engine retries a routine stuck item once by
|
|
33
33
|
* itself (#705). Only an item stuck after the resume is retried, so
|
|
34
34
|
* turning it on leaves an item already told to the owner waiting.
|
|
35
|
+
* - `build_cache_paths`: the build caches cloned into each new ticket
|
|
36
|
+
* worktree (#721). Each new worktree reads it then, so a new value counts
|
|
37
|
+
* from the next ticket started.
|
|
35
38
|
*/
|
|
36
39
|
export const RELOADED_FIELDS = [
|
|
37
40
|
'prepare',
|
|
@@ -42,6 +45,7 @@ export const RELOADED_FIELDS = [
|
|
|
42
45
|
'test_case_timeout_ms',
|
|
43
46
|
'leftover_allow',
|
|
44
47
|
'auto_retry',
|
|
48
|
+
'build_cache_paths',
|
|
45
49
|
] as const
|
|
46
50
|
|
|
47
51
|
/**
|
|
@@ -141,6 +145,12 @@ export const ConfigChangeSchema = z.discriminatedUnion('field', [
|
|
|
141
145
|
from: z.boolean(),
|
|
142
146
|
to: z.boolean(),
|
|
143
147
|
}),
|
|
148
|
+
// Never left out: a config without it reads as `[]`.
|
|
149
|
+
z.object({
|
|
150
|
+
field: z.literal('build_cache_paths'),
|
|
151
|
+
from: z.array(z.string().min(1)),
|
|
152
|
+
to: z.array(z.string().min(1)),
|
|
153
|
+
}),
|
|
144
154
|
])
|
|
145
155
|
|
|
146
156
|
export type ConfigChange = z.infer<typeof ConfigChangeSchema>
|
|
@@ -202,6 +212,11 @@ export const configChanges = ({
|
|
|
202
212
|
from: autoRetryOf({ config: current }),
|
|
203
213
|
to: autoRetryOf({ config: next }),
|
|
204
214
|
},
|
|
215
|
+
{
|
|
216
|
+
field: 'build_cache_paths',
|
|
217
|
+
from: buildCachePathsOf({ config: current }),
|
|
218
|
+
to: buildCachePathsOf({ config: next }),
|
|
219
|
+
},
|
|
205
220
|
]
|
|
206
221
|
return all.filter(({ from, to }) => !isEqual(from, to))
|
|
207
222
|
}
|
|
@@ -244,6 +259,8 @@ const applyChange = ({
|
|
|
244
259
|
: { ...config, leftover_allow: change.to }
|
|
245
260
|
case 'auto_retry':
|
|
246
261
|
return { ...config, auto_retry: change.to }
|
|
262
|
+
case 'build_cache_paths':
|
|
263
|
+
return { ...config, build_cache_paths: change.to }
|
|
247
264
|
}
|
|
248
265
|
}
|
|
249
266
|
|
|
@@ -45,12 +45,64 @@ const isFileInsideRepo = (path: string): boolean => {
|
|
|
45
45
|
)
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Whether a `build_cache_paths` entry names a path or glob inside the repo
|
|
50
|
+
* (#721): not empty, not absolute, and, once cleaned, neither the repo root
|
|
51
|
+
* nor a path that climbs out of it with `..`.
|
|
52
|
+
*/
|
|
53
|
+
const isCachePathInsideRepo = (path: string): boolean => {
|
|
54
|
+
const clean = normalize(path)
|
|
55
|
+
return (
|
|
56
|
+
path !== '' &&
|
|
57
|
+
!isAbsolute(path) &&
|
|
58
|
+
clean !== '.' &&
|
|
59
|
+
clean !== './' &&
|
|
60
|
+
clean !== '..' &&
|
|
61
|
+
!clean.startsWith('../')
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** One `build_cache_paths` entry, its trailing `/` dropped. */
|
|
66
|
+
const BuildCachePathSchema = z
|
|
67
|
+
.string()
|
|
68
|
+
.refine(isCachePathInsideRepo, {
|
|
69
|
+
message:
|
|
70
|
+
'each build_cache_paths entry must be a path or glob inside the repo, relative to its root: not empty, not an absolute path, not the repo root, and not one that leaves the repo with ".."',
|
|
71
|
+
})
|
|
72
|
+
.transform((path) => path.replace(/\/+$/, ''))
|
|
73
|
+
|
|
48
74
|
/** One JUnit file a junit test command writes, relative to the repo root. */
|
|
49
75
|
const JunitPathSchema = z.string().min(1).refine(isFileInsideRepo, {
|
|
50
76
|
message:
|
|
51
77
|
'must be a file inside the repo, relative to its root: not an absolute path, not one that leaves the repo with "..", and not ending in "/"',
|
|
52
78
|
})
|
|
53
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Whether a `paths` glob stays inside the repo (#722): not absolute, and,
|
|
82
|
+
* once cleaned (`normalize`), not one that climbs out of it with `..`.
|
|
83
|
+
*/
|
|
84
|
+
const isGlobInsideRepo = (glob: string): boolean => {
|
|
85
|
+
const clean = normalize(glob)
|
|
86
|
+
return !isAbsolute(glob) && clean !== '..' && !clean.startsWith('../')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The paths a `checks.test` command covers (#722): repo-relative globs. At a
|
|
91
|
+
* ticket's gates the command runs only when the ticket changed a file under
|
|
92
|
+
* them.
|
|
93
|
+
*/
|
|
94
|
+
const TestPathsSchema = z
|
|
95
|
+
.array(
|
|
96
|
+
z.string().refine((glob) => glob !== '' && isGlobInsideRepo(glob), {
|
|
97
|
+
message:
|
|
98
|
+
'each paths entry must be a glob inside the repo, relative to its root: not empty, not an absolute path, and not one that leaves the repo with ".."',
|
|
99
|
+
})
|
|
100
|
+
)
|
|
101
|
+
.min(1, {
|
|
102
|
+
message:
|
|
103
|
+
'paths must list at least one glob; leave paths out to run the command at every gate',
|
|
104
|
+
})
|
|
105
|
+
|
|
54
106
|
/** The text a slow command's `run_files` must hold, where the files go (#683). */
|
|
55
107
|
export const RUN_FILES_PLACEHOLDER = '{files}'
|
|
56
108
|
|
|
@@ -60,7 +112,8 @@ export const RUN_FILES_PLACEHOLDER = '{files}'
|
|
|
60
112
|
* `junit_path` (#668), one path or a list; no other kind may have one. A slow
|
|
61
113
|
* entry may also name the test files it owns (`file_patterns`) and how to run
|
|
62
114
|
* some of them (`run_files`, #683); they come together, and a `checks.test`
|
|
63
|
-
* entry may have neither.
|
|
115
|
+
* entry may have neither. Only a `checks.test` entry may name the paths it
|
|
116
|
+
* covers (`paths`, #722).
|
|
64
117
|
*/
|
|
65
118
|
const testCommandEntrySchema = ({ slow }: { slow: boolean }) =>
|
|
66
119
|
z.union([
|
|
@@ -74,6 +127,11 @@ const testCommandEntrySchema = ({ slow }: { slow: boolean }) =>
|
|
|
74
127
|
.optional(),
|
|
75
128
|
file_patterns: z.array(z.string().min(1)).min(1).optional(),
|
|
76
129
|
run_files: z.string().min(1).optional(),
|
|
130
|
+
paths: TestPathsSchema.optional(),
|
|
131
|
+
})
|
|
132
|
+
.refine(({ paths }) => !slow || paths === undefined, {
|
|
133
|
+
message:
|
|
134
|
+
'checks.slow_test: paths belongs only on checks.test entries.',
|
|
77
135
|
})
|
|
78
136
|
.refine(
|
|
79
137
|
({ file_patterns, run_files }) =>
|
|
@@ -351,6 +409,14 @@ export const EngineConfigSchema = z.object({
|
|
|
351
409
|
* as `true` too (`autoRetryOf`).
|
|
352
410
|
*/
|
|
353
411
|
auto_retry: z.boolean().default(true).optional(),
|
|
412
|
+
/**
|
|
413
|
+
* The repo's build-cache folders (#721), as paths or globs relative to
|
|
414
|
+
* the repo root, such as `packages/ios/.derived`: each new ticket
|
|
415
|
+
* worktree gets the ones git ignores cloned in from the run branch's
|
|
416
|
+
* worktree, or else the repo's checkout. Left out, `[]`; a config
|
|
417
|
+
* written before it may lack it (`buildCachePathsOf`).
|
|
418
|
+
*/
|
|
419
|
+
build_cache_paths: z.array(BuildCachePathSchema).default([]).optional(),
|
|
354
420
|
})
|
|
355
421
|
|
|
356
422
|
export type EngineConfig = z.infer<typeof EngineConfigSchema>
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
|
|
19
19
|
export {
|
|
20
20
|
autoRetryOf,
|
|
21
|
+
buildCachePathsOf,
|
|
21
22
|
DEFAULT_PREPARE_CONCURRENCY,
|
|
22
23
|
DEFAULT_PREPARE_TIMEOUT_MS,
|
|
23
24
|
decisionModelOf,
|
|
@@ -60,6 +61,7 @@ export {
|
|
|
60
61
|
ownSlowCommands,
|
|
61
62
|
slowTestCommands,
|
|
62
63
|
testCommands,
|
|
64
|
+
testCommandsToRun,
|
|
63
65
|
type OwnSlowCommand,
|
|
64
66
|
type TestCommand,
|
|
65
67
|
} from './test-commands'
|
|
@@ -8,12 +8,14 @@ import type { EngineConfig, TestResults } from './engine-config-schema'
|
|
|
8
8
|
/**
|
|
9
9
|
* One test command the engine runs, how it reads the results, and the JUnit
|
|
10
10
|
* files a `junit` command writes (#668), relative to the repo root: `[]`
|
|
11
|
-
* for every other kind.
|
|
11
|
+
* for every other kind. `paths` are the globs it covers at a ticket's gates
|
|
12
|
+
* (#722), or `null` when it covers everything.
|
|
12
13
|
*/
|
|
13
14
|
export type TestCommand = {
|
|
14
15
|
run: string
|
|
15
16
|
results: TestResults
|
|
16
17
|
junit_path: string[]
|
|
18
|
+
paths: string[] | null
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
/** Whether `run` is a `bun test` command, which gives bun's per-test results. */
|
|
@@ -36,7 +38,12 @@ const commandsOf = (value: EngineConfig['checks']['test']): TestCommand[] => {
|
|
|
36
38
|
if (value === undefined) return []
|
|
37
39
|
return (typeof value === 'string' ? [value] : value).map((entry) =>
|
|
38
40
|
typeof entry === 'string'
|
|
39
|
-
? {
|
|
41
|
+
? {
|
|
42
|
+
run: entry,
|
|
43
|
+
results: defaultResults(entry),
|
|
44
|
+
junit_path: [],
|
|
45
|
+
paths: null,
|
|
46
|
+
}
|
|
40
47
|
: {
|
|
41
48
|
run: entry.run,
|
|
42
49
|
results: entry.results ?? defaultResults(entry.run),
|
|
@@ -44,10 +51,56 @@ const commandsOf = (value: EngineConfig['checks']['test']): TestCommand[] => {
|
|
|
44
51
|
typeof entry.junit_path === 'string'
|
|
45
52
|
? [entry.junit_path]
|
|
46
53
|
: (entry.junit_path ?? []),
|
|
54
|
+
paths: entry.paths ?? null,
|
|
47
55
|
}
|
|
48
56
|
)
|
|
49
57
|
}
|
|
50
58
|
|
|
59
|
+
/** Whether `file` matches one of the globs. */
|
|
60
|
+
const matchesAny = ({
|
|
61
|
+
file,
|
|
62
|
+
globs,
|
|
63
|
+
}: {
|
|
64
|
+
file: string
|
|
65
|
+
globs: string[]
|
|
66
|
+
}): boolean => globs.some((glob) => new Bun.Glob(glob).match(file))
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The test commands a ticket's gate runs, and the ones it skips (#722), each
|
|
70
|
+
* in config order. A command with no `paths` always runs; one with `paths`
|
|
71
|
+
* runs only when a changed file matches one of them. If a changed test file
|
|
72
|
+
* (by `test_file_patterns`) matches no command's `paths`, every command
|
|
73
|
+
* runs, so a ticket's own tests always run at its gates.
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* testCommandsToRun({ commands: [a, b], changed_files: ['src/b/x.ts'], test_file_patterns: ['**\/*.test.ts'] })
|
|
77
|
+
* // { run: [b], skipped: [a] } when a has paths ['src/a/**'] and b ['src/b/**']
|
|
78
|
+
*/
|
|
79
|
+
export const testCommandsToRun = ({
|
|
80
|
+
commands,
|
|
81
|
+
changed_files,
|
|
82
|
+
test_file_patterns,
|
|
83
|
+
}: {
|
|
84
|
+
commands: TestCommand[]
|
|
85
|
+
changed_files: string[]
|
|
86
|
+
test_file_patterns: string[]
|
|
87
|
+
}): { run: TestCommand[]; skipped: TestCommand[] } => {
|
|
88
|
+
const covered = commands.flatMap(({ paths }) => paths ?? [])
|
|
89
|
+
const uncoveredTest = changed_files.some(
|
|
90
|
+
(file) =>
|
|
91
|
+
matchesAny({ file, globs: test_file_patterns }) &&
|
|
92
|
+
!matchesAny({ file, globs: covered })
|
|
93
|
+
)
|
|
94
|
+
if (uncoveredTest) return { run: commands, skipped: [] }
|
|
95
|
+
const runs = ({ paths }: TestCommand): boolean =>
|
|
96
|
+
paths === null ||
|
|
97
|
+
changed_files.some((file) => matchesAny({ file, globs: paths }))
|
|
98
|
+
return {
|
|
99
|
+
run: commands.filter(runs),
|
|
100
|
+
skipped: commands.filter((command) => !runs(command)),
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
51
104
|
/**
|
|
52
105
|
* Every test command in the config, in order, each with its results kind
|
|
53
106
|
* and `junit_path`. A missing `results` is `bun` for a `bun test` command,
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Warm builds (#721): `create_ticket_worktree` clones the repo's
|
|
3
|
+
* `build_cache_paths` into a ticket's new worktree, from the run branch's
|
|
4
|
+
* worktree or else the repo's checkout, both only read. Each copy goes to a
|
|
5
|
+
* temporary folder in the run folder first and is then renamed into place,
|
|
6
|
+
* so a worktree never holds a half-made copy. Nothing here ever stops the
|
|
7
|
+
* ticket: a failed scan, git check, copy or temporary folder only skips
|
|
8
|
+
* paths, each journaled with its reason.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync } from 'node:fs'
|
|
11
|
+
import { mkdir, rename, rm } from 'node:fs/promises'
|
|
12
|
+
import { dirname, join } from 'node:path'
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
copyDir,
|
|
16
|
+
ignoredAmong,
|
|
17
|
+
isGlob,
|
|
18
|
+
matchesIn,
|
|
19
|
+
type CopyDirResult,
|
|
20
|
+
} from './build-cache'
|
|
21
|
+
import type { BuildContext } from './build-context'
|
|
22
|
+
|
|
23
|
+
import { buildCachePathsOf } from '../config/engine-config'
|
|
24
|
+
import type {
|
|
25
|
+
BuildCacheSkipReason,
|
|
26
|
+
BuildCacheSource,
|
|
27
|
+
} from '../journal/build-cache-entries'
|
|
28
|
+
|
|
29
|
+
/** One source folder a cache may be copied from. */
|
|
30
|
+
type Source = { from: BuildCacheSource; dir: string }
|
|
31
|
+
|
|
32
|
+
/** One match of an entry, in the first source that has it. */
|
|
33
|
+
type Match = Source & { path: string }
|
|
34
|
+
|
|
35
|
+
/** An error's text. */
|
|
36
|
+
const messageOf = (error: unknown): string =>
|
|
37
|
+
error instanceof Error ? error.message : String(error)
|
|
38
|
+
|
|
39
|
+
/** Removes a folder; one it can't remove is left, and never throws. */
|
|
40
|
+
const removeQuietly = (dir: string): Promise<void> =>
|
|
41
|
+
rm(dir, { recursive: true, force: true }).catch(() => undefined)
|
|
42
|
+
|
|
43
|
+
/** The run's two warm sources, in order: the run branch's worktree, then the checkout. */
|
|
44
|
+
const sourcesOf = ({ context }: { context: BuildContext }): Source[] => {
|
|
45
|
+
const started = context.journal
|
|
46
|
+
.read()
|
|
47
|
+
.find((record) => record.kind === 'run_started')
|
|
48
|
+
const repo = started?.kind === 'run_started' ? started.content.repo : null
|
|
49
|
+
const run_branch = context.state.run_branch?.path ?? null
|
|
50
|
+
const sources: Source[] = []
|
|
51
|
+
if (run_branch !== null) {
|
|
52
|
+
sources.push({ from: 'run_branch', dir: run_branch })
|
|
53
|
+
}
|
|
54
|
+
if (repo !== null) sources.push({ from: 'checkout', dir: repo })
|
|
55
|
+
return sources
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* An entry's matches, each from the first source that has it. A source it
|
|
60
|
+
* can't scan has no match.
|
|
61
|
+
*/
|
|
62
|
+
const matchesOf = async ({
|
|
63
|
+
entry,
|
|
64
|
+
sources,
|
|
65
|
+
}: {
|
|
66
|
+
entry: string
|
|
67
|
+
sources: Source[]
|
|
68
|
+
}): Promise<Match[]> => {
|
|
69
|
+
const found: Match[] = []
|
|
70
|
+
for (const source of sources) {
|
|
71
|
+
const paths = await matchesIn({ entry, dir: source.dir }).catch(
|
|
72
|
+
(): string[] => []
|
|
73
|
+
)
|
|
74
|
+
for (const path of paths) {
|
|
75
|
+
if (!found.some((match) => match.path === path)) {
|
|
76
|
+
found.push({ ...source, path })
|
|
77
|
+
}
|
|
78
|
+
if (!isGlob(entry)) return found
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return found
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Copies a match to `temp`, then renames it into the worktree. */
|
|
85
|
+
const copyInto = async ({
|
|
86
|
+
context,
|
|
87
|
+
match,
|
|
88
|
+
temp,
|
|
89
|
+
worktree,
|
|
90
|
+
}: {
|
|
91
|
+
context: BuildContext
|
|
92
|
+
match: Match
|
|
93
|
+
temp: string
|
|
94
|
+
worktree: string
|
|
95
|
+
}): Promise<CopyDirResult> => {
|
|
96
|
+
const copy = context.copy_dir ?? copyDir
|
|
97
|
+
try {
|
|
98
|
+
const copied = await copy({
|
|
99
|
+
from: join(match.dir, match.path),
|
|
100
|
+
to: temp,
|
|
101
|
+
})
|
|
102
|
+
if (!copied.ok) return copied
|
|
103
|
+
const target = join(worktree, match.path)
|
|
104
|
+
await mkdir(dirname(target), { recursive: true })
|
|
105
|
+
await rename(temp, target)
|
|
106
|
+
return { ok: true }
|
|
107
|
+
} catch (error) {
|
|
108
|
+
return { ok: false, error: messageOf(error) }
|
|
109
|
+
} finally {
|
|
110
|
+
await removeQuietly(temp)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Clones the config's build caches that git ignores into ticket `ticket`'s
|
|
116
|
+
* new worktree, journaling `build_cache_skipped` for each path not copied
|
|
117
|
+
* and one `build_cache_copied` when any was.
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* await copyBuildCaches({ context, ticket: 11, worktree })
|
|
121
|
+
*/
|
|
122
|
+
export const copyBuildCaches = async ({
|
|
123
|
+
context,
|
|
124
|
+
ticket,
|
|
125
|
+
worktree,
|
|
126
|
+
}: {
|
|
127
|
+
context: BuildContext
|
|
128
|
+
ticket: number
|
|
129
|
+
worktree: string
|
|
130
|
+
}): Promise<void> => {
|
|
131
|
+
const entries = buildCachePathsOf({ config: context.config })
|
|
132
|
+
if (entries.length === 0) return
|
|
133
|
+
const skip = (content: {
|
|
134
|
+
path: string
|
|
135
|
+
reason: BuildCacheSkipReason
|
|
136
|
+
error?: string
|
|
137
|
+
}) =>
|
|
138
|
+
context.journal.append({
|
|
139
|
+
kind: 'build_cache_skipped',
|
|
140
|
+
ticket,
|
|
141
|
+
role: null,
|
|
142
|
+
content,
|
|
143
|
+
})
|
|
144
|
+
// A crashed try's leftovers go first; this try's go at the end. When
|
|
145
|
+
// the temporary folder can't be cleared or made, each copy fails.
|
|
146
|
+
const temps = join(context.run_dir, 'cache-copies', String(ticket))
|
|
147
|
+
const uncleared = await rm(temps, { recursive: true, force: true }).then(
|
|
148
|
+
() => null,
|
|
149
|
+
messageOf
|
|
150
|
+
)
|
|
151
|
+
const sources = sourcesOf({ context })
|
|
152
|
+
const matches: Match[] = []
|
|
153
|
+
for (const entry of entries) {
|
|
154
|
+
const found = await matchesOf({ entry, sources })
|
|
155
|
+
if (found.length === 0) skip({ path: entry, reason: 'no_source' })
|
|
156
|
+
for (const match of found) {
|
|
157
|
+
if (!matches.some(({ path }) => path === match.path)) {
|
|
158
|
+
matches.push(match)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (matches.length === 0) return
|
|
163
|
+
const ignored = await ignoredAmong({
|
|
164
|
+
git: context.git,
|
|
165
|
+
cwd: worktree,
|
|
166
|
+
paths: matches.map(({ path }) => path),
|
|
167
|
+
}).catch(() => new Set<string>())
|
|
168
|
+
const unmade =
|
|
169
|
+
uncleared ??
|
|
170
|
+
(await mkdir(temps, { recursive: true }).then(() => null, messageOf))
|
|
171
|
+
const started = performance.now()
|
|
172
|
+
const copied: { path: string; from: BuildCacheSource }[] = []
|
|
173
|
+
for (const [index, match] of matches.entries()) {
|
|
174
|
+
if (!ignored.has(match.path)) {
|
|
175
|
+
skip({ path: match.path, reason: 'not_ignored' })
|
|
176
|
+
} else if (existsSync(join(worktree, match.path))) {
|
|
177
|
+
skip({ path: match.path, reason: 'already_present' })
|
|
178
|
+
} else if (unmade !== null) {
|
|
179
|
+
skip({ path: match.path, reason: 'copy_failed', error: unmade })
|
|
180
|
+
} else {
|
|
181
|
+
const done = await copyInto({
|
|
182
|
+
context,
|
|
183
|
+
match,
|
|
184
|
+
temp: join(temps, String(index)),
|
|
185
|
+
worktree,
|
|
186
|
+
})
|
|
187
|
+
if (done.ok) copied.push({ path: match.path, from: match.from })
|
|
188
|
+
else {
|
|
189
|
+
skip({
|
|
190
|
+
path: match.path,
|
|
191
|
+
reason: 'copy_failed',
|
|
192
|
+
error: done.error,
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const ms = Math.round(performance.now() - started)
|
|
198
|
+
await removeQuietly(temps)
|
|
199
|
+
if (copied.length === 0) return
|
|
200
|
+
context.journal.append({
|
|
201
|
+
kind: 'build_cache_copied',
|
|
202
|
+
ticket,
|
|
203
|
+
role: null,
|
|
204
|
+
content: { paths: copied, ms },
|
|
205
|
+
})
|
|
206
|
+
}
|