jackherd 1.15.0 → 1.15.2
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 +15 -0
- package/README.md +17 -17
- package/dist/{chunk-V4HOLGFP.js → chunk-4LUFR7RC.js} +19 -1
- package/dist/config.d.ts +47 -2
- package/dist/config.js +65 -4
- package/dist/plan-format.js +3 -19
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +49 -4
- package/kit/hooks/hooks/merge-guard/decision.ts +3 -1
- package/kit/rules/rules/kit/pull-requests.md +1 -1
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +12 -0
- package/schemas/sources.schema.json +141 -0
- package/src/cli.ts +1 -0
- package/src/config/json-schema.ts +7 -3
- package/src/config/location.ts +7 -0
- package/src/config/personal-sources.ts +83 -0
- package/src/config/schema.ts +29 -0
- package/src/plans/herd-claim.ts +9 -31
- package/src/plans/settings.ts +20 -7
- package/src/plans/snapshot.ts +26 -19
- package/src/secrets/command.ts +163 -0
- package/src/secrets/environment.ts +45 -0
- package/src/secrets/hidden-input.ts +51 -0
- package/src/secrets/listing.ts +63 -0
- package/src/secrets/options.ts +45 -0
- package/src/secrets/store.ts +74 -0
- package/src/shared/process-info.ts +89 -0
- package/src/sync/command-context.ts +26 -4
- package/src/sync/render.ts +9 -4
- package/src/watch/command.ts +44 -12
- package/src/watch/plan-badges-read.ts +28 -10
- package/src/watch/source-dry-run-command.ts +23 -12
- package/src/watch/source-dry-run.ts +19 -2
- package/src/watch/source-reload.ts +100 -0
- package/src/watch/source-run.ts +63 -7
- package/src/watch/source-tickets.ts +15 -0
- package/src/watch/sources.ts +21 -0
- package/src/watch/watch-events.ts +9 -0
- package/src/watch/watched-source.ts +39 -0
- package/src/watch/watcher-lock.ts +8 -1
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"type": "object",
|
|
4
|
+
"properties": {
|
|
5
|
+
"$schema": {
|
|
6
|
+
"type": "string"
|
|
7
|
+
},
|
|
8
|
+
"sources": {
|
|
9
|
+
"default": [],
|
|
10
|
+
"type": "array",
|
|
11
|
+
"items": {
|
|
12
|
+
"type": "object",
|
|
13
|
+
"properties": {
|
|
14
|
+
"name": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"pattern": "^[A-Za-z0-9][\\w.-]*$"
|
|
17
|
+
},
|
|
18
|
+
"command": {
|
|
19
|
+
"minItems": 1,
|
|
20
|
+
"type": "array",
|
|
21
|
+
"items": {
|
|
22
|
+
"type": "string",
|
|
23
|
+
"minLength": 1
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"every": {
|
|
27
|
+
"default": 5,
|
|
28
|
+
"type": "integer",
|
|
29
|
+
"minimum": 1,
|
|
30
|
+
"maximum": 9007199254740991
|
|
31
|
+
},
|
|
32
|
+
"items": {
|
|
33
|
+
"type": "string"
|
|
34
|
+
},
|
|
35
|
+
"map": {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"properties": {
|
|
38
|
+
"id": {
|
|
39
|
+
"type": "string"
|
|
40
|
+
},
|
|
41
|
+
"updatedAt": {
|
|
42
|
+
"type": "string"
|
|
43
|
+
},
|
|
44
|
+
"title": {
|
|
45
|
+
"type": "string"
|
|
46
|
+
},
|
|
47
|
+
"url": {
|
|
48
|
+
"type": "string"
|
|
49
|
+
},
|
|
50
|
+
"body": {
|
|
51
|
+
"type": "string"
|
|
52
|
+
},
|
|
53
|
+
"author": {
|
|
54
|
+
"type": "string"
|
|
55
|
+
},
|
|
56
|
+
"status": {
|
|
57
|
+
"type": "string"
|
|
58
|
+
},
|
|
59
|
+
"count": {
|
|
60
|
+
"type": "string"
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"required": [
|
|
64
|
+
"id"
|
|
65
|
+
],
|
|
66
|
+
"additionalProperties": {
|
|
67
|
+
"type": "string"
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
"ignoreAuthors": {
|
|
71
|
+
"default": [],
|
|
72
|
+
"type": "array",
|
|
73
|
+
"items": {
|
|
74
|
+
"type": "string",
|
|
75
|
+
"minLength": 1
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
"reportLimit": {
|
|
79
|
+
"default": 5,
|
|
80
|
+
"type": "integer",
|
|
81
|
+
"minimum": 1,
|
|
82
|
+
"maximum": 9007199254740991
|
|
83
|
+
},
|
|
84
|
+
"label": {
|
|
85
|
+
"type": "string",
|
|
86
|
+
"minLength": 1,
|
|
87
|
+
"maxLength": 40,
|
|
88
|
+
"pattern": "^[^\\r\\n]+$"
|
|
89
|
+
},
|
|
90
|
+
"lane": {
|
|
91
|
+
"type": "string",
|
|
92
|
+
"minLength": 1
|
|
93
|
+
},
|
|
94
|
+
"ticket": {
|
|
95
|
+
"type": "boolean"
|
|
96
|
+
},
|
|
97
|
+
"tone": {
|
|
98
|
+
"type": "string",
|
|
99
|
+
"enum": [
|
|
100
|
+
"amber",
|
|
101
|
+
"teal",
|
|
102
|
+
"grey",
|
|
103
|
+
"red",
|
|
104
|
+
"green",
|
|
105
|
+
"blue",
|
|
106
|
+
"magenta"
|
|
107
|
+
]
|
|
108
|
+
},
|
|
109
|
+
"reportOn": {
|
|
110
|
+
"minItems": 1,
|
|
111
|
+
"maxItems": 10,
|
|
112
|
+
"type": "array",
|
|
113
|
+
"items": {
|
|
114
|
+
"type": "string",
|
|
115
|
+
"minLength": 1
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
"secrets": {
|
|
119
|
+
"maxItems": 10,
|
|
120
|
+
"type": "array",
|
|
121
|
+
"items": {
|
|
122
|
+
"type": "string",
|
|
123
|
+
"pattern": "^[A-Z][A-Z0-9_]*$"
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
"show": {
|
|
127
|
+
"type": "string",
|
|
128
|
+
"const": "entries"
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
"required": [
|
|
132
|
+
"name",
|
|
133
|
+
"command",
|
|
134
|
+
"map"
|
|
135
|
+
],
|
|
136
|
+
"additionalProperties": false
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
"additionalProperties": false
|
|
141
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -28,6 +28,7 @@ export const COMMANDS: CommandTable = {
|
|
|
28
28
|
mermaid: () => import('./mermaid/command'),
|
|
29
29
|
plan: () => import('./plan-contract/command'),
|
|
30
30
|
preview: () => import('./preview/command'),
|
|
31
|
+
secret: () => import('./secrets/command'),
|
|
31
32
|
settled: () => import('./settled/command'),
|
|
32
33
|
sync: () => import('./sync/command'),
|
|
33
34
|
task: () => import('./task-signals/command'),
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
-
import { jackherdConfigSchema } from './schema'
|
|
2
|
+
import { jackherdConfigSchema, personalSourcesSchema } from './schema'
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
`${JSON.stringify(z.toJSONSchema(
|
|
4
|
+
const renderJsonSchema = (schema: z.ZodType): string =>
|
|
5
|
+
`${JSON.stringify(z.toJSONSchema(schema, { io: 'input' }), null, 2)}\n`
|
|
6
|
+
|
|
7
|
+
export const renderConfigJsonSchema = (): string => renderJsonSchema(jackherdConfigSchema)
|
|
8
|
+
|
|
9
|
+
export const renderSourcesJsonSchema = (): string => renderJsonSchema(personalSourcesSchema)
|
package/src/config/location.ts
CHANGED
|
@@ -50,3 +50,10 @@ export const findProjectRoot = (start: string, home: string, exists: (path: stri
|
|
|
50
50
|
*/
|
|
51
51
|
export const locateConfigFile = (roots: string[], home: string, exists: (path: string) => boolean): string | null =>
|
|
52
52
|
[...roots, home].flatMap(configCandidates).find(exists) ?? null
|
|
53
|
+
|
|
54
|
+
export const PERSONAL_SOURCES_FILE = 'sources.json'
|
|
55
|
+
|
|
56
|
+
/** The directory a personal source runs in, and holds the personal sources file. */
|
|
57
|
+
export const personalSourcesDirectory = (home: string): string => join(home, TOOL_DIRECTORY)
|
|
58
|
+
|
|
59
|
+
export const personalSourcesPath = (home: string): string => join(personalSourcesDirectory(home), PERSONAL_SOURCES_FILE)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { errorMessage } from '../sync/error-message'
|
|
4
|
+
import { SOURCE_ORIGIN, type SourceOrigin, type SourceSettings } from '../watch/sources'
|
|
5
|
+
import { personalSourcesPath } from './location'
|
|
6
|
+
import { readResolvedConfig } from './read-config'
|
|
7
|
+
import { parsePersonalSourcesFile } from './schema'
|
|
8
|
+
|
|
9
|
+
export type MergedSources = { sources: SourceSettings[]; notes: string[] }
|
|
10
|
+
|
|
11
|
+
/** What a note starts with when an invalid personal file was left out. */
|
|
12
|
+
export const PERSONAL_IGNORED = 'personal sources ignored:'
|
|
13
|
+
|
|
14
|
+
const skippedPrefix = (name: string): string => `personal source ${name} in `
|
|
15
|
+
|
|
16
|
+
const skippedNote = (name: string, path: string): string =>
|
|
17
|
+
`${skippedPrefix(name)}${path} skipped: the repository has a source of that name`
|
|
18
|
+
|
|
19
|
+
/** Whether a merge note concerns the source `name`: the personal source of that name was skipped, or the file was ignored. */
|
|
20
|
+
export const isNoteAbout = (note: string, name: string): boolean =>
|
|
21
|
+
note.startsWith(skippedPrefix(name)) || note.startsWith(PERSONAL_IGNORED)
|
|
22
|
+
|
|
23
|
+
const withOrigin = (sources: readonly SourceSettings[], origin: SourceOrigin): SourceSettings[] =>
|
|
24
|
+
sources.map((source) => ({ ...source, origin }))
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The repository's sources, then the personal ones, each marked with its origin. A personal source whose name a
|
|
28
|
+
* repository source has is left out and named in a note; `path` is how the notes spell the personal file.
|
|
29
|
+
*/
|
|
30
|
+
export const mergedSources = (
|
|
31
|
+
repo: readonly SourceSettings[],
|
|
32
|
+
personal: readonly SourceSettings[],
|
|
33
|
+
path: string,
|
|
34
|
+
): MergedSources => {
|
|
35
|
+
const repoNames = new Set(repo.map(({ name }) => name))
|
|
36
|
+
const kept = personal.filter(({ name }) => !repoNames.has(name))
|
|
37
|
+
const skipped = personal.filter(({ name }) => repoNames.has(name))
|
|
38
|
+
return {
|
|
39
|
+
sources: [...withOrigin(repo, SOURCE_ORIGIN.repo), ...withOrigin(kept, SOURCE_ORIGIN.personal)],
|
|
40
|
+
notes: skipped.map(({ name }) => skippedNote(name, path)),
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The personal sources of `~/.jackherd/sources.json`, none when the file is missing; an invalid file throws with its path and the issues. */
|
|
45
|
+
export const readPersonalSources = (
|
|
46
|
+
home: string = homedir(),
|
|
47
|
+
exists: (path: string) => boolean = existsSync,
|
|
48
|
+
readText: (path: string) => string = (path) => readFileSync(path, 'utf8'),
|
|
49
|
+
): SourceSettings[] => {
|
|
50
|
+
const path = personalSourcesPath(home)
|
|
51
|
+
if (!exists(path)) return []
|
|
52
|
+
let raw: unknown
|
|
53
|
+
try {
|
|
54
|
+
raw = JSON.parse(readText(path))
|
|
55
|
+
} catch (error) {
|
|
56
|
+
throw new Error(`${path} is invalid: ${errorMessage(error)}`)
|
|
57
|
+
}
|
|
58
|
+
return parsePersonalSourcesFile(raw, path).sources
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The repository's sources with the personal ones of `home` merged in (`mergedSources`). An invalid personal file
|
|
63
|
+
* never stops the caller: its error becomes a note and the repository's sources stand alone.
|
|
64
|
+
*/
|
|
65
|
+
export const sourcesWithPersonal = (
|
|
66
|
+
repo: readonly SourceSettings[],
|
|
67
|
+
home: string = homedir(),
|
|
68
|
+
exists: (path: string) => boolean = existsSync,
|
|
69
|
+
readText?: (path: string) => string,
|
|
70
|
+
): MergedSources => {
|
|
71
|
+
try {
|
|
72
|
+
return mergedSources(repo, readPersonalSources(home, exists, readText), personalSourcesPath(home))
|
|
73
|
+
} catch (error) {
|
|
74
|
+
return { sources: withOrigin(repo, SOURCE_ORIGIN.repo), notes: [`${PERSONAL_IGNORED} ${errorMessage(error)}`] }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The sources of the project config of the first of `roots` that has one (`readResolvedConfig`), with the personal
|
|
80
|
+
* ones of `home` merged in; throws like `readResolvedConfig` when the project config is missing or invalid.
|
|
81
|
+
*/
|
|
82
|
+
export const configuredSources = (roots: string[], home: string = homedir()): MergedSources =>
|
|
83
|
+
sourcesWithPersonal(readResolvedConfig(roots, home).config.watch.sources, home)
|
package/src/config/schema.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import { checkConfigSchema } from '../check/schema'
|
|
3
3
|
import { claudePath, WORKTREES_DIRECTORY } from '../claude/paths'
|
|
4
|
+
import { SECRET_NAME } from '../secrets/options'
|
|
4
5
|
import { PLAN_LANGUAGE, RESPONSE_ADDRESS } from '../vocabulary'
|
|
5
6
|
import { CONTENT_FIELDS, SOURCE_FIELD } from '../watch/source-fields'
|
|
6
7
|
import { isSourcePath } from '../watch/source-path'
|
|
8
|
+
import { SOURCE_SHOW } from '../watch/sources'
|
|
7
9
|
import { BUN_FAILED_TEST_PATTERN, CLEAN_REVIEW_TEXT } from '../watch/watch-defaults'
|
|
8
10
|
import { MAX_AUTO_WINDOW_DAYS, windowDays } from './auto-window'
|
|
9
11
|
import { DEFAULT_BASE_BRANCH } from './base-branch'
|
|
@@ -289,6 +291,17 @@ const sourceSchema = z
|
|
|
289
291
|
* `author`, `body` and `url`; without it a change of that content reports.
|
|
290
292
|
*/
|
|
291
293
|
reportOn: z.array(z.string().min(1)).min(1).max(10).optional(),
|
|
294
|
+
/**
|
|
295
|
+
* Names of secrets the command needs, handed to it as environment variables of the same name; each is looked up
|
|
296
|
+
* in the keychain, then in the watcher's environment.
|
|
297
|
+
*/
|
|
298
|
+
secrets: z
|
|
299
|
+
.array(z.string().regex(SECRET_NAME, 'capital letters, digits and `_`, starting with a letter'))
|
|
300
|
+
.max(10)
|
|
301
|
+
.refine((names) => new Set(names).size === names.length, 'secret names must be unique')
|
|
302
|
+
.optional(),
|
|
303
|
+
/** `entries` shows the source's entries open, in a herd block of its own; not for a ticket source, whose entries are badges. */
|
|
304
|
+
show: z.literal(SOURCE_SHOW.entries).optional(),
|
|
292
305
|
})
|
|
293
306
|
.refine(canChange, {
|
|
294
307
|
message: 'map needs updatedAt, a content field (title, author, body, url) or reportOn',
|
|
@@ -298,6 +311,10 @@ const sourceSchema = z
|
|
|
298
311
|
message: 'ignoreAuthors needs map.author',
|
|
299
312
|
path: ['ignoreAuthors'],
|
|
300
313
|
})
|
|
314
|
+
.refine((source) => source.show === undefined || source.ticket !== true, {
|
|
315
|
+
message: 'show cannot be set on a ticket source',
|
|
316
|
+
path: ['show'],
|
|
317
|
+
})
|
|
301
318
|
.refine((source) => !source.reportOn?.includes(SOURCE_FIELD.id), {
|
|
302
319
|
message: 'reportOn cannot name id',
|
|
303
320
|
path: ['reportOn'],
|
|
@@ -592,6 +609,18 @@ const parseLabelled = <T extends z.ZodType>(schema: T, raw: unknown, label: stri
|
|
|
592
609
|
export const parseConfigFile = (raw: unknown, label: string): JackherdConfig =>
|
|
593
610
|
parseLabelled(jackherdConfigSchema, raw, label)
|
|
594
611
|
|
|
612
|
+
/** The personal sources file (`~/.jackherd/sources.json`): the same entries as `watch.sources`. */
|
|
613
|
+
export const personalSourcesSchema = z.strictObject({
|
|
614
|
+
$schema: z.string().optional(),
|
|
615
|
+
sources: z.array(sourceSchema).refine(hasUniqueNames, 'source names must be unique').default([]),
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
export type PersonalSourcesFile = z.infer<typeof personalSourcesSchema>
|
|
619
|
+
|
|
620
|
+
/** The personal sources file as read from JSON; invalid throws with `label` (its path) and the issues in words. */
|
|
621
|
+
export const parsePersonalSourcesFile = (raw: unknown, label: string): PersonalSourcesFile =>
|
|
622
|
+
parseLabelled(personalSourcesSchema, raw, label)
|
|
623
|
+
|
|
595
624
|
/** One `watch.sources` entry outside the config, such as a draft to try; invalid throws with `label` and the issues. */
|
|
596
625
|
export const parseSourceDraft = (raw: unknown, label: string): z.infer<typeof sourceSchema> =>
|
|
597
626
|
parseLabelled(sourceSchema, raw, label)
|
package/src/plans/herd-claim.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
|
-
import {
|
|
2
|
+
import { unlinkSync } from 'node:fs'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { ensurePrivateDirectory, writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
|
|
5
5
|
import {
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
systemLocation,
|
|
10
10
|
userDirectoryOf,
|
|
11
11
|
} from '../shared/private-location'
|
|
12
|
+
import { systemProcessInfo } from '../shared/process-info'
|
|
12
13
|
import { HERD_COMMAND } from './herd-command'
|
|
13
14
|
import { CHILD_MARKER, TAKEN_OVER_CODE, TAKEOVER_SIGNAL } from './restart-decision'
|
|
14
15
|
|
|
@@ -96,36 +97,13 @@ export const startExclusive = async (
|
|
|
96
97
|
start()
|
|
97
98
|
}
|
|
98
99
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return target.startsWith('/dev/') ? target : null
|
|
103
|
-
} catch {
|
|
104
|
-
return null
|
|
105
|
-
}
|
|
106
|
-
}
|
|
100
|
+
/** True when the words of a process hold the herd child's marker and the herd command. */
|
|
101
|
+
export const isHerdViewWords = (words: readonly string[] | null): boolean =>
|
|
102
|
+
Boolean(words?.includes(`${CHILD_MARKER}=1`) && words.includes(HERD_COMMAND.name))
|
|
107
103
|
|
|
108
|
-
const
|
|
109
|
-
try {
|
|
110
|
-
return readFileSync(path, 'utf8')
|
|
111
|
-
} catch {
|
|
112
|
-
return null
|
|
113
|
-
}
|
|
114
|
-
}
|
|
104
|
+
const processInfo = systemProcessInfo()
|
|
115
105
|
|
|
116
|
-
const isHerdViewProcess = (pid: number): boolean =>
|
|
117
|
-
const environment = readText(`/proc/${pid}/environ`)
|
|
118
|
-
const command = readText(`/proc/${pid}/cmdline`)
|
|
119
|
-
if (environment === null || command === null) return false
|
|
120
|
-
return environment.split('\0').includes(`${CHILD_MARKER}=1`) && command.split('\0').includes(HERD_COMMAND.name)
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const isGoneProcess = (pid: number): boolean => {
|
|
124
|
-
const stat = readText(`/proc/${pid}/stat`)
|
|
125
|
-
if (stat === null) return true
|
|
126
|
-
const stateIndex = stat.lastIndexOf(')') + 2
|
|
127
|
-
return stat.charAt(stateIndex) === 'Z'
|
|
128
|
-
}
|
|
106
|
+
const isHerdViewProcess = (pid: number): boolean => isHerdViewWords(processInfo.wordsOf(pid))
|
|
129
107
|
|
|
130
108
|
const signalProcess = (pid: number, signal: NodeJS.Signals): void => {
|
|
131
109
|
try {
|
|
@@ -158,7 +136,7 @@ export const herdClaimDeps = (file: string): ClaimDeps => ({
|
|
|
158
136
|
}
|
|
159
137
|
},
|
|
160
138
|
isHerdView: isHerdViewProcess,
|
|
161
|
-
isGone:
|
|
139
|
+
isGone: processInfo.isGone,
|
|
162
140
|
signal: signalProcess,
|
|
163
141
|
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
164
142
|
onExit: (run) => {
|
|
@@ -168,6 +146,6 @@ export const herdClaimDeps = (file: string): ClaimDeps => ({
|
|
|
168
146
|
|
|
169
147
|
/** Takes the terminal of stdin over from an older herd view; without a terminal (or `/proc`) it does nothing. */
|
|
170
148
|
export const claimOwnTerminal = async (): Promise<void> => {
|
|
171
|
-
const terminal = terminalOfStdin()
|
|
149
|
+
const terminal = processInfo.terminalOfStdin()
|
|
172
150
|
if (terminal !== null) await claimTerminal(herdClaimDeps(recordFileOf(terminal)))
|
|
173
151
|
}
|
package/src/plans/settings.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { PROJECT_CONFIG_PATH } from '../config/location'
|
|
|
5
5
|
import { readConfig } from '../config/read-config'
|
|
6
6
|
import type { ToneName } from '../config/tones'
|
|
7
7
|
import type { TicketSource } from '../watch/plan-badges'
|
|
8
|
+
import type { SourceShow } from '../watch/sources'
|
|
9
|
+
import { type WatchedSource, watchedSourceOf } from '../watch/watched-source'
|
|
8
10
|
|
|
9
11
|
export const CONFIG_PATH = PROJECT_CONFIG_PATH
|
|
10
12
|
|
|
@@ -20,14 +22,29 @@ export type PlansSettings = Pick<
|
|
|
20
22
|
context: JackherdConfig['plans']['context']
|
|
21
23
|
/** The `watch.sources` marked `ticket`, whose entries the view shows as badges. */
|
|
22
24
|
ticketSources: TicketSource[]
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
+
/**
|
|
26
|
+
* The other `watch.sources` by name, with the tone each names for a new report and, when set, how it shows its
|
|
27
|
+
* entries; the herd lists them in a block of their own.
|
|
28
|
+
*/
|
|
29
|
+
herdSources: { name: string; tone: ToneName | null; show?: SourceShow }[]
|
|
25
30
|
/** By source name: what the herd puts before a row of "Your turn" that came from the source, its `label` or else its name. */
|
|
26
31
|
sourceLabels: Record<string, string>
|
|
27
32
|
/** The tone of each role in the herd. */
|
|
28
33
|
tones: JackherdConfig['plans']['tones']
|
|
29
34
|
}
|
|
30
35
|
|
|
36
|
+
/** What the herd draws of the watcher's sources: from the watcher's state when it keeps them, else from the config. */
|
|
37
|
+
export type HerdSourceSettings = Pick<PlansSettings, 'ticketSources' | 'herdSources' | 'sourceLabels'>
|
|
38
|
+
|
|
39
|
+
/** The herd's view of `sources`, in their order: the tickets, the others with their tone and `show`, every label. */
|
|
40
|
+
export const herdSourceSettingsOf = (sources: readonly WatchedSource[]): HerdSourceSettings => ({
|
|
41
|
+
ticketSources: sources.flatMap(({ name, lane, ticket }) => (ticket ? [{ name, lane }] : [])),
|
|
42
|
+
herdSources: sources.flatMap(({ name, ticket, tone, show }) =>
|
|
43
|
+
ticket ? [] : [{ name, tone, ...(show === null ? {} : { show }) }],
|
|
44
|
+
),
|
|
45
|
+
sourceLabels: Object.fromEntries(sources.map(({ name, label }) => [name, label ?? name])),
|
|
46
|
+
})
|
|
47
|
+
|
|
31
48
|
/** The locale of the times the herd writes (the refresh error, the last fetch): English, with a 24-hour clock. */
|
|
32
49
|
export const CLOCK_LOCALE = 'en-GB'
|
|
33
50
|
|
|
@@ -45,11 +62,7 @@ export const plansSettingsOf = (config: JackherdConfig): PlansSettings => ({
|
|
|
45
62
|
shimmer: config.plans.shimmer,
|
|
46
63
|
linkKinds: config.plans.linkKinds,
|
|
47
64
|
context: config.plans.context,
|
|
48
|
-
|
|
49
|
-
ticket ? [{ name, lane: lane ?? null }] : [],
|
|
50
|
-
),
|
|
51
|
-
herdSources: config.watch.sources.flatMap(({ name, ticket, tone }) => (ticket ? [] : [{ name, tone: tone ?? null }])),
|
|
52
|
-
sourceLabels: Object.fromEntries(config.watch.sources.map(({ name, label }) => [name, label ?? name])),
|
|
65
|
+
...herdSourceSettingsOf(config.watch.sources.map(watchedSourceOf)),
|
|
53
66
|
tones: config.plans.tones,
|
|
54
67
|
})
|
|
55
68
|
|
package/src/plans/snapshot.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { isRestingAgent } from '../terminal/types'
|
|
|
23
23
|
import { laneReportPath, readLaneReport } from '../watch/lane-report-file'
|
|
24
24
|
import { restNote } from '../watch/lane-rest'
|
|
25
25
|
import type { SourceTicket, WatchBadges, WatchHealth } from '../watch/plan-badges'
|
|
26
|
-
import { readWatchReading } from '../watch/plan-badges-read'
|
|
26
|
+
import { readWatchReading, type SourcedWatchReading } from '../watch/plan-badges-read'
|
|
27
27
|
import type { SourceStatus } from '../watch/source-statuses'
|
|
28
28
|
import { assignSignals, laneOfDirectory, type UnplannedTask } from './active-tasks'
|
|
29
29
|
import { activityLineOf, type LaneSubagents } from './activity-read'
|
|
@@ -65,7 +65,7 @@ import { markStale, type PlannedPlan, plannedPlans } from './planned'
|
|
|
65
65
|
import type { PullRequest } from './pull-requests'
|
|
66
66
|
import type { ReadCache } from './read-cache'
|
|
67
67
|
import { type SentChip, sentChipsByLane } from './sent-files'
|
|
68
|
-
import { baseRefOf, boardRepoPathOf, type PlansSettings, worktreeOf } from './settings'
|
|
68
|
+
import { baseRefOf, boardRepoPathOf, type HerdSourceSettings, type PlansSettings, worktreeOf } from './settings'
|
|
69
69
|
import { signalledTitle } from './signalled-phase'
|
|
70
70
|
import { readAllSignalsSliced } from './signals-read'
|
|
71
71
|
import { createSliceGate, type SliceGate } from './slice-gate'
|
|
@@ -194,7 +194,7 @@ type LaneSet = { lanes: SprintLane[]; unlisted: UnlistedLane[]; sources: LaneSou
|
|
|
194
194
|
|
|
195
195
|
/** What a lane's view is decorated with after it was read. */
|
|
196
196
|
type Decorations = {
|
|
197
|
-
reading:
|
|
197
|
+
reading: SourcedWatchReading | null
|
|
198
198
|
phaseModels: readonly PhaseModelRecord[]
|
|
199
199
|
sprintDirectory: string
|
|
200
200
|
sentChips: ReadonlyMap<string | null, SentChip[]>
|
|
@@ -638,7 +638,7 @@ const decoratedViews = (
|
|
|
638
638
|
/** Each source with the tone and the label its config gives it. */
|
|
639
639
|
const withSourceStyle = (
|
|
640
640
|
statuses: readonly SourceStatus[],
|
|
641
|
-
{ herdSources, sourceLabels }:
|
|
641
|
+
{ herdSources, sourceLabels }: HerdSourceSettings,
|
|
642
642
|
): HerdSource[] =>
|
|
643
643
|
statuses.map((status) => {
|
|
644
644
|
const label = sourceLabels[status.name]
|
|
@@ -646,6 +646,24 @@ const withSourceStyle = (
|
|
|
646
646
|
return { ...status, tone, ...(label === undefined ? {} : { label }) }
|
|
647
647
|
})
|
|
648
648
|
|
|
649
|
+
/**
|
|
650
|
+
* The watcher's reading and the sources the view draws: those the watcher's state names, else the config's. The
|
|
651
|
+
* state is read every time, so a source the watcher took up since the herd started shows; the reading is null while
|
|
652
|
+
* the view follows no source and no pull request.
|
|
653
|
+
*/
|
|
654
|
+
const watchReadingOf = (
|
|
655
|
+
root: string,
|
|
656
|
+
now: number,
|
|
657
|
+
fallback: HerdSourceSettings,
|
|
658
|
+
results: readonly LaneResult[],
|
|
659
|
+
): { reading: SourcedWatchReading | null; sourceSettings: HerdSourceSettings } => {
|
|
660
|
+
const read = readWatchReading(root, now, fallback)
|
|
661
|
+
const { ticketSources, herdSources } = read.sourceSettings
|
|
662
|
+
const watched =
|
|
663
|
+
ticketSources.length > 0 || herdSources.length > 0 || results.some(({ view }) => view.pullRequests.length > 0)
|
|
664
|
+
return { reading: watched ? read : null, sourceSettings: read.sourceSettings }
|
|
665
|
+
}
|
|
666
|
+
|
|
649
667
|
/**
|
|
650
668
|
* The open phases no lane with an agent leads, a plan marked stale by its last change at `baseCommit`; none while the
|
|
651
669
|
* view shows one lane.
|
|
@@ -712,25 +730,14 @@ export const loadSnapshot = async (
|
|
|
712
730
|
const { parsed } = laneSet.sources
|
|
713
731
|
const listedPlans = await readListedPlans(source, base, sprintDirectory, markdown, fallbacks)
|
|
714
732
|
const documentsPlans = Object.assign({}, listedPlans, ...results.map((result) => result.texts))
|
|
715
|
-
const { ticketSources, herdSources } = settings
|
|
716
|
-
const watched =
|
|
717
|
-
ticketSources.length > 0 || herdSources.length > 0 || results.some(({ view }) => view.pullRequests.length > 0)
|
|
718
733
|
await gate.pass()
|
|
719
|
-
const reading =
|
|
720
|
-
|
|
721
|
-
root,
|
|
722
|
-
now,
|
|
723
|
-
ticketSources,
|
|
724
|
-
undefined,
|
|
725
|
-
undefined,
|
|
726
|
-
herdSources.map(({ name }) => name),
|
|
727
|
-
)
|
|
728
|
-
: null
|
|
734
|
+
const { reading, sourceSettings } = watchReadingOf(root, now, settings, results)
|
|
735
|
+
const { ticketSources } = sourceSettings
|
|
729
736
|
await gate.pass()
|
|
730
737
|
const waitingRecords = readWaitingRecords(root)
|
|
731
738
|
const waiting = withSourcePrefix(
|
|
732
739
|
waitingItemsOf(liveBoard, settings.planLanguage, waitingRecords, now),
|
|
733
|
-
|
|
740
|
+
sourceSettings.sourceLabels,
|
|
734
741
|
)
|
|
735
742
|
const sentChips = sentChipsByLane(waitingRecords.sent, worktrees, now)
|
|
736
743
|
const notice = fallbacks.length > 0 ? fallbackNotice(fallbacks, baseRef, refExists(root, baseRef)) : null
|
|
@@ -782,7 +789,7 @@ export const loadSnapshot = async (
|
|
|
782
789
|
waiting,
|
|
783
790
|
groomedDays: groomedDaysAgo(liveBoard, settings.planLanguage, new Date(now)),
|
|
784
791
|
yourTurn: yourTurnOfBoard(liveBoard, settings.planLanguage, waiting, now),
|
|
785
|
-
...(reading?.sources ? { sourceBlock: { sources: withSourceStyle(reading.sources,
|
|
792
|
+
...(reading?.sources ? { sourceBlock: { sources: withSourceStyle(reading.sources, sourceSettings), now } } : {}),
|
|
786
793
|
tones: settings.tones,
|
|
787
794
|
...(coordinator ? { coordinator: coordinator.view } : {}),
|
|
788
795
|
unplaced: unplacedOf(unplacedSources, laneSet.lanes, Boolean(options.lane)),
|