jackherd 1.7.0 → 1.8.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/CHANGELOG.md +43 -0
- package/INSTALL.md +4 -2
- package/{NOTICE → NOTICE.txt} +1 -1
- package/README.md +94 -1033
- package/assets/icons/bark-dark.svg +1 -1
- package/assets/icons/bark.svg +1 -1
- package/assets/icons/bowl-dark.svg +1 -1
- package/assets/icons/bowl.svg +1 -1
- package/assets/icons/decide-dark.svg +1 -1
- package/assets/icons/decide.svg +1 -1
- package/assets/icons/ear-dark.svg +1 -1
- package/assets/icons/ear.svg +1 -1
- package/assets/icons/lanes-dark.svg +1 -1
- package/assets/icons/lanes.svg +1 -1
- package/assets/icons/plan-dark.svg +1 -1
- package/assets/icons/plan.svg +1 -1
- package/dist/{chunk-OBUWTQ6Z.js → chunk-M6DDZEW4.js} +24 -10
- package/dist/config.d.ts +11 -5
- package/dist/config.js +53 -21
- package/dist/plan-format.d.ts +73 -7
- package/dist/plan-format.js +180 -100
- package/docs/workflow-dark.svg +1 -1
- package/docs/workflow.svg +1 -1
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/herdr-panes.ts +2 -12
- package/kit/hooks/hooks/approval-gate/index.ts +10 -8
- package/kit/hooks/hooks/approval-gate/run.ts +21 -12
- package/kit/hooks/hooks/ask-hint/decision.ts +3 -3
- package/kit/hooks/hooks/git-guard/decision.ts +189 -15
- package/kit/hooks/hooks/git-guard/index.ts +13 -3
- package/kit/hooks/hooks/git-guard/push-target.ts +85 -0
- package/kit/hooks/hooks/git-guard/repository.ts +63 -0
- package/kit/hooks/hooks/merge-guard/check-free.ts +1 -13
- package/kit/hooks/hooks/merge-guard/decision.ts +342 -102
- package/kit/hooks/hooks/merge-guard/index.ts +6 -4
- package/kit/hooks/hooks/merge-guard/plain-path.ts +15 -0
- package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +23 -0
- package/kit/hooks/hooks/merge-guard/plan-approval-settings.ts +142 -0
- package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +196 -0
- package/kit/hooks/hooks/merge-guard/plan-approval.ts +168 -0
- package/kit/hooks/hooks/merge-guard/plan-growth.ts +40 -0
- package/kit/hooks/hooks/merge-guard/plan-pointers.ts +194 -0
- package/kit/hooks/hooks/merge-guard/pull-request.ts +67 -21
- package/kit/hooks/hooks/merge-guard/run.ts +118 -29
- package/kit/hooks/hooks/merge-guard/settings.ts +8 -8
- package/kit/hooks/hooks/merge-guard/worker.ts +3 -3
- package/kit/hooks/hooks/shared/base-branch.ts +23 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +93 -0
- package/kit/hooks/hooks/shared/command-text-runners.ts +71 -0
- package/kit/hooks/hooks/shared/command.ts +14 -2
- package/kit/hooks/hooks/shared/commit.ts +2 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +4 -1
- package/kit/hooks/hooks/shared/json.ts +10 -0
- package/kit/hooks/hooks/shared/lane.ts +57 -0
- package/kit/hooks/hooks/shared/once.ts +8 -0
- package/kit/hooks/hooks/shared/own-pane.ts +31 -0
- package/kit/hooks/hooks/shared/pane-agent.ts +24 -0
- package/kit/hooks/hooks/shared/plan-open-phases.ts +114 -0
- package/kit/hooks/hooks/shared/program-position.ts +148 -5
- package/kit/hooks/hooks/shared/shell-aliases.ts +258 -0
- package/kit/hooks/hooks/shared/shell-snapshots.ts +68 -0
- package/kit/hooks/hooks/shared/shell.ts +167 -20
- package/kit/hooks/hooks.json +11 -2
- package/kit/hooks/statusline.sh +29 -16
- package/kit/review/skills/diagnose/SKILL.md +1 -1
- package/kit/review/skills/review/SKILL.md +2 -0
- package/kit/rules/rules/kit/testing.md +2 -5
- package/kit/rules/rules/kit/writing-for-agents.md +3 -1
- package/kit/workflow/agents/spec-review.md +1 -1
- package/kit/workflow/skills/follow/SKILL.md +3 -3
- package/kit/workflow/skills/grill/SKILL.md +1 -1
- package/kit/workflow/skills/implement/SKILL.md +10 -11
- package/kit/workflow/skills/orchestrate/SKILL.md +155 -117
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +17 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +27 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -0
- package/kit/workflow/skills/orchestrate/references/sensitive-input.md +23 -0
- package/kit/workflow/skills/plan/SKILL.md +5 -2
- package/kit/workflow/skills/plan/references/template-English.md +6 -0
- package/kit/workflow/skills/plan/references/template-German.md +6 -0
- package/package.json +3 -2
- package/recipes/README.md +21 -0
- package/recipes/github-issues.md +23 -0
- package/recipes/github-projects.md +22 -0
- package/recipes/jira-cli.md +26 -0
- package/recipes/jira-rest.md +24 -0
- package/recipes/linear.md +25 -0
- package/recipes/sentry.md +18 -0
- package/recipes/sources/jira.ts +51 -0
- package/recipes/sources/linear.ts +19 -0
- package/recipes/sources/sentry.ts +12 -0
- package/recipes/sources.md +80 -0
- package/recipes/status-page.md +19 -0
- package/schemas/jackherd.schema.json +42 -18
- package/src/check/command.ts +2 -2
- package/src/check/parallel-report.ts +19 -9
- package/src/cli.ts +1 -0
- package/src/config/context-shares.ts +130 -0
- package/src/config/legacy-names.ts +8 -0
- package/src/config/read-config.ts +12 -4
- package/src/config/schema.ts +75 -20
- package/src/context/auto-compact.ts +68 -0
- package/src/context/command.ts +68 -17
- package/src/context/coordinator-name.ts +27 -9
- package/src/context/index.ts +0 -1
- package/src/context/level.ts +18 -11
- package/src/context/report.ts +6 -3
- package/src/plan-format/dependencies.ts +144 -32
- package/src/plan-format/index.ts +6 -0
- package/src/plan-format/lanes.ts +6 -0
- package/src/plan-format/progress.ts +19 -8
- package/src/plans/command.ts +14 -5
- package/src/plans/current-phase.ts +63 -7
- package/src/plans/current-task.ts +51 -0
- package/src/plans/ensure-panes.ts +25 -8
- package/src/plans/frame.ts +99 -0
- package/src/plans/git.ts +8 -0
- package/src/plans/herd-memory.ts +107 -0
- package/src/plans/lane-plans.ts +5 -3
- package/src/plans/lane-summary.ts +222 -0
- package/src/plans/layout-phase.ts +148 -0
- package/src/plans/layout-summary.ts +140 -0
- package/src/plans/layout.ts +164 -420
- package/src/plans/live.ts +11 -6
- package/src/plans/plan-panes.ts +15 -0
- package/src/plans/planned.ts +9 -42
- package/src/plans/selection.ts +2 -6
- package/src/plans/settings.ts +2 -7
- package/src/plans/snapshot.ts +38 -39
- package/src/plans/symbols.ts +2 -2
- package/src/plans/texts.ts +32 -31
- package/src/plans/view.ts +0 -1
- package/src/plans/worked-phases.ts +2 -2
- package/src/settled/board-row.ts +133 -0
- package/src/settled/check.ts +129 -0
- package/src/settled/command.ts +65 -0
- package/src/settled/evidence-version.ts +14 -0
- package/src/settled/options.ts +69 -0
- package/src/settled/send.ts +27 -0
- package/src/settled/settle.ts +87 -0
- package/src/settled/topics.ts +56 -0
- package/src/sync/command.ts +16 -1
- package/src/sync/render.ts +13 -4
- package/src/sync/sync.ts +25 -5
- package/src/terminal/herdr.ts +46 -10
- package/src/terminal/none.ts +1 -0
- package/src/terminal/types.ts +15 -2
- package/src/update/command.ts +4 -2
- package/src/update/update.ts +33 -9
- package/src/vocabulary/index.ts +47 -11
- package/src/watch/appointments.ts +205 -0
- package/src/watch/command.ts +62 -19
- package/src/watch/lane-report.ts +11 -3
- package/src/watch/texts.ts +35 -59
- package/src/watch/watch-events.ts +21 -28
- package/kit/hooks/hooks/approval-gate/lane.ts +0 -14
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { baseBranchOf } from '../shared/base-branch'
|
|
4
|
+
import { mainCheckoutOf } from '../shared/checkout'
|
|
5
|
+
import { commandOutput } from '../shared/command'
|
|
6
|
+
import { parseJsonObject } from '../shared/json'
|
|
7
|
+
import type { PushContext } from './push-target'
|
|
8
|
+
|
|
9
|
+
const CONFIG_PATH = join('.jackherd', 'config.json')
|
|
10
|
+
|
|
11
|
+
/** `%(push)` is `refs/remotes/<remote>/<branch>`; the branch is what follows the remote's own prefix. */
|
|
12
|
+
export const pushBranchOf = (remote: string, pushRef: string): string | undefined => {
|
|
13
|
+
const prefix = `refs/remotes/${remote}/`
|
|
14
|
+
return pushRef.startsWith(prefix) ? pushRef.slice(prefix.length) : undefined
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const git = (cwd: string, ...args: string[]): string => commandOutput(['git', ...args], cwd)
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The checkout's `.jackherd/config.json`: `{}` when there is none, `null` when it cannot be read or holds no JSON
|
|
21
|
+
* object, so a broken config refuses the push instead of guarding `main` while the project means another branch.
|
|
22
|
+
*/
|
|
23
|
+
const projectConfigOf = (checkout: string): Record<string, unknown> | null => {
|
|
24
|
+
const path = join(checkout, CONFIG_PATH)
|
|
25
|
+
if (!existsSync(path)) return {}
|
|
26
|
+
try {
|
|
27
|
+
return parseJsonObject(readFileSync(path, 'utf8')) ?? null
|
|
28
|
+
} catch {
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const currentBranch = (cwd: string): string | undefined => {
|
|
34
|
+
try {
|
|
35
|
+
return git(cwd, 'symbolic-ref', '--quiet', '--short', 'HEAD')
|
|
36
|
+
} catch {
|
|
37
|
+
return undefined
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The current branch and the branch its push goes to, as git's own push settings decide (`push.default`,
|
|
43
|
+
* `remote.pushDefault`, the upstream); undefined on a detached HEAD or a push ref git names oddly.
|
|
44
|
+
*/
|
|
45
|
+
const defaultPushBranches = (cwd: string): string[] | undefined => {
|
|
46
|
+
const branch = currentBranch(cwd)
|
|
47
|
+
if (branch === undefined) return undefined
|
|
48
|
+
const [remote = '', pushRef = ''] = git(
|
|
49
|
+
cwd,
|
|
50
|
+
'for-each-ref',
|
|
51
|
+
'--format=%(push:remotename)%00%(push)',
|
|
52
|
+
`refs/heads/${branch}`,
|
|
53
|
+
).split('\0')
|
|
54
|
+
if (pushRef === '') return [branch]
|
|
55
|
+
const pushBranch = pushBranchOf(remote, pushRef)
|
|
56
|
+
return pushBranch === undefined ? undefined : [branch, pushBranch]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The repository at `cwd`: its git state, and the base branch from the main checkout's `.jackherd/config.json`. */
|
|
60
|
+
export const repositoryPushContext = (cwd: string): PushContext => ({
|
|
61
|
+
baseBranch: () => baseBranchOf(projectConfigOf(mainCheckoutOf(cwd))),
|
|
62
|
+
defaultPushBranches: () => defaultPushBranches(cwd),
|
|
63
|
+
})
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { runGh } from './gh'
|
|
8
|
+
import { isPlainPath } from './plain-path'
|
|
8
9
|
import type { RepositoryFacts } from './pull-request'
|
|
9
10
|
|
|
10
11
|
/** What GitHub lists per file: the name, and the old name of a renamed file. */
|
|
@@ -13,19 +14,6 @@ const FILE_ENTRY_QUERY = '.[] | [.filename, .previous_filename] | @json'
|
|
|
13
14
|
/** GitHub lists at most this many files of a pull request and says nothing when it stops there. */
|
|
14
15
|
export const MAX_LISTED_FILES = 3000
|
|
15
16
|
|
|
16
|
-
const isControlCharacter = (character: string): boolean => {
|
|
17
|
-
const code = character.charCodeAt(0)
|
|
18
|
-
return code < 0x20 || code === 0x7f
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** A repository path as GitHub lists it: relative, no empty, `.` or `..` segment, no backslash, no control character. */
|
|
22
|
-
const isPlainPath = (file: string): boolean =>
|
|
23
|
-
file !== '' &&
|
|
24
|
-
!file.startsWith('/') &&
|
|
25
|
-
!file.includes('\\') &&
|
|
26
|
-
![...file].some(isControlCharacter) &&
|
|
27
|
-
!file.split('/').some((segment) => segment === '' || segment === '.' || segment === '..')
|
|
28
|
-
|
|
29
17
|
const isNameOrNull = (value: unknown): value is string | null => value === null || typeof value === 'string'
|
|
30
18
|
|
|
31
19
|
/**
|
|
@@ -1,27 +1,68 @@
|
|
|
1
|
+
import { commandTextRunnerIndex, searchableText } from '../shared/command-text-runners'
|
|
2
|
+
import { FULL_COMMIT } from '../shared/commit'
|
|
1
3
|
import {
|
|
2
4
|
inlineScript,
|
|
3
5
|
isOption,
|
|
4
6
|
isProgramPosition,
|
|
5
7
|
isShell,
|
|
6
8
|
programName,
|
|
9
|
+
runsInterpreterProgram,
|
|
7
10
|
} from '../shared/program-position'
|
|
8
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
expandingHereDocumentAdvice,
|
|
13
|
+
isExpanded,
|
|
14
|
+
parseShell,
|
|
15
|
+
ShellParseError,
|
|
16
|
+
type SimpleCommand,
|
|
17
|
+
UNREADABLE_ADVICE,
|
|
18
|
+
type Word,
|
|
19
|
+
} from '../shared/shell'
|
|
20
|
+
import {
|
|
21
|
+
definesShellAlias,
|
|
22
|
+
mayRunDefinedFunction,
|
|
23
|
+
NO_DEFINITIONS,
|
|
24
|
+
type ShellDefinitionHit,
|
|
25
|
+
type ShellDefinitions,
|
|
26
|
+
shellDefinitionAt,
|
|
27
|
+
shellDefinitionHint,
|
|
28
|
+
} from '../shared/shell-aliases'
|
|
9
29
|
|
|
10
30
|
export type MergeGuardDecision = { kind: 'pass' } | { kind: 'ask'; reason: string }
|
|
11
31
|
|
|
32
|
+
/**
|
|
33
|
+
* A plain `gh pr merge <number>`, with the head commit it names in `--match-head-commit <sha>`, if any: GitHub then
|
|
34
|
+
* merges only that commit, so what was checked is what gets merged.
|
|
35
|
+
*/
|
|
36
|
+
export type PlainMerge = { pullRequest: number; headCommit: string | undefined }
|
|
37
|
+
|
|
38
|
+
/** An approval that no longer covers the merge, and what the question should tell the user about it. */
|
|
39
|
+
export type MergeNote = { note: string }
|
|
40
|
+
|
|
12
41
|
export type GuardPolicy = {
|
|
13
42
|
/** The guarded commands as words: a program and the plain words after it, such as `gh pr merge`. */
|
|
14
43
|
commands: readonly string[]
|
|
15
44
|
/**
|
|
16
|
-
* Whether the merge of this pull request may pass without a question: the user approved
|
|
17
|
-
* matches `merge.withoutChecks`. Asked only for a plain `gh pr merge <number>`.
|
|
18
|
-
* A throw counts as not approved.
|
|
45
|
+
* Whether the merge of this pull request may pass without a question: the user approved its branch or the plan of
|
|
46
|
+
* its lane, or every file of it matches `merge.withoutChecks`. Asked only for a plain `gh pr merge <number>`.
|
|
47
|
+
* A throw counts as not approved; a note is not approved either, and the question carries it.
|
|
19
48
|
*/
|
|
20
|
-
isMergeApproved: (
|
|
49
|
+
isMergeApproved: (merge: PlainMerge) => boolean | MergeNote
|
|
50
|
+
/** The aliases and functions of the user's shell, read only when a word at a program position may be one; none by default. */
|
|
51
|
+
shellDefinitions?: () => ShellDefinitions
|
|
21
52
|
}
|
|
22
53
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
54
|
+
/**
|
|
55
|
+
* A guarded command the script runs or could run, and the pull request a merge names, when it names one plainly; or
|
|
56
|
+
* the shell alias or function that may run one (`definition`, whose name is then the `command`), or a program whose
|
|
57
|
+
* name is unknown (`note` says why it may run one).
|
|
58
|
+
*/
|
|
59
|
+
type Finding = { command: string; merge: PlainMerge | undefined; definition?: ShellDefinitionHit; note?: string }
|
|
60
|
+
|
|
61
|
+
/** What the search looks for: the guarded commands, and the shell definitions that may stand for one of them. */
|
|
62
|
+
type Search = { commands: readonly string[]; definitions: () => ShellDefinitions }
|
|
63
|
+
|
|
64
|
+
/** The parts of a `gh api` call that decide whether it writes, and where. */
|
|
65
|
+
type ApiCall = { method: Word | undefined; fields: boolean; override: boolean; endpoints: Word[] }
|
|
25
66
|
|
|
26
67
|
/** The one command whose guard an approval can lift. Every other guarded command asks each time. */
|
|
27
68
|
const MERGE_COMMAND = 'gh pr merge'
|
|
@@ -35,44 +76,59 @@ const OPTIONS_WITH_VALUE = new Set(['-R', '--repo', '--hostname'])
|
|
|
35
76
|
*/
|
|
36
77
|
const APPROVABLE_FLAGS = new Set(['--squash', '-s', '--merge', '-m', '--rebase', '-r', '--delete-branch', '-d'])
|
|
37
78
|
const PULL_REQUEST_NUMBER = /^[1-9]\d*$/
|
|
79
|
+
const HEAD_COMMIT_FLAG = '--match-head-commit'
|
|
38
80
|
const GLOB = /[*?[]/
|
|
39
81
|
|
|
40
|
-
/** Programs that run text they are given or read from elsewhere, so the words may not stand at the program. */
|
|
41
|
-
const OPAQUE_RUNNERS = new Set([
|
|
42
|
-
'xargs',
|
|
43
|
-
'find',
|
|
44
|
-
'trap',
|
|
45
|
-
'coproc',
|
|
46
|
-
'repeat',
|
|
47
|
-
'script',
|
|
48
|
-
'su',
|
|
49
|
-
'flock',
|
|
50
|
-
'ssh',
|
|
51
|
-
'parallel',
|
|
52
|
-
'ionice',
|
|
53
|
-
'chrt',
|
|
54
|
-
'taskset',
|
|
55
|
-
'strace',
|
|
56
|
-
'unbuffer',
|
|
57
|
-
'chroot',
|
|
58
|
-
'runuser',
|
|
59
|
-
'systemd-run',
|
|
60
|
-
'nsenter',
|
|
61
|
-
'unshare',
|
|
62
|
-
'firejail',
|
|
63
|
-
'pkexec',
|
|
64
|
-
'entr',
|
|
65
|
-
'watchexec',
|
|
66
|
-
'busybox',
|
|
67
|
-
])
|
|
68
|
-
|
|
69
82
|
const SOURCING = new Set(['source', '.'])
|
|
70
83
|
|
|
71
|
-
/** Interpreters that run program text given on the command line, and the options that give it. */
|
|
72
|
-
const INTERPRETERS = new Set(['python', 'python3', 'node', 'bun', 'deno', 'perl', 'ruby', 'php', 'lua'])
|
|
73
|
-
const INLINE_PROGRAM_OPTIONS = new Set(['-c', '-e', '-E', '-p', '-pe', '--eval', '--command', '-r'])
|
|
74
84
|
const INPUT_PATH = /^(\/dev\/stdin|\/dev\/fd\/|\/proc\/self\/fd\/)/
|
|
75
85
|
|
|
86
|
+
/** `gh api` is guarded only for a write that merges, writes a file, starts or reruns a workflow or moves a ref (D43); every other call passes. */
|
|
87
|
+
const API_COMMAND = 'gh api'
|
|
88
|
+
const API_WORD = 'api'
|
|
89
|
+
const READ_METHOD = 'GET'
|
|
90
|
+
const API_METHOD_OPTIONS = new Set(['-X', '--method'])
|
|
91
|
+
/** Options that add a request body, which makes the method POST unless one is named. */
|
|
92
|
+
const API_BODY_OPTIONS = new Set(['-f', '-F', '--field', '--raw-field', '--input'])
|
|
93
|
+
const API_OPTIONS_WITH_VALUE = new Set([
|
|
94
|
+
'-H',
|
|
95
|
+
'--header',
|
|
96
|
+
'-q',
|
|
97
|
+
'--jq',
|
|
98
|
+
'-t',
|
|
99
|
+
'--template',
|
|
100
|
+
'--hostname',
|
|
101
|
+
'--cache',
|
|
102
|
+
'-p',
|
|
103
|
+
'--preview',
|
|
104
|
+
])
|
|
105
|
+
/** A header that makes GitHub take another method than the request's own, so a GET may write. */
|
|
106
|
+
const METHOD_OVERRIDE_HEADER = /^\s*x-http-method(-override)?\s*:/i
|
|
107
|
+
const HEADER_OPTIONS = new Set(['-H', '--header'])
|
|
108
|
+
/**
|
|
109
|
+
* Endpoints that merge a pull request or a branch, write a file (a commit), dispatch a workflow or repository event,
|
|
110
|
+
* rerun a workflow (a deploy), or create, move or delete a ref.
|
|
111
|
+
*/
|
|
112
|
+
const DANGEROUS_ENDPOINTS = [
|
|
113
|
+
/(^|\/)pulls\/[^/]+\/merge\/?$/,
|
|
114
|
+
/(^|\/)merges\/?$/,
|
|
115
|
+
/(^|\/)contents(\/|$)/,
|
|
116
|
+
/(^|\/)dispatches\/?$/,
|
|
117
|
+
/(^|\/)actions\/(runs|jobs)\/[^/]+\/(rerun|rerun-failed-jobs)\/?$/,
|
|
118
|
+
/(^|\/)git\/refs(\/|$)/,
|
|
119
|
+
]
|
|
120
|
+
/** What the fixed end of an endpoint after an expansion may be, when the expansion can stand for the rest of the path. */
|
|
121
|
+
const DANGEROUS_ENDINGS = [
|
|
122
|
+
/(^|\/)merges?\/?$/,
|
|
123
|
+
/(^|\/)contents(\/|$)/,
|
|
124
|
+
/(^|\/)dispatches\/?$/,
|
|
125
|
+
/(^|\/)(rerun|rerun-failed-jobs)\/?$/,
|
|
126
|
+
/(^|\/)refs(\/|$)/,
|
|
127
|
+
]
|
|
128
|
+
const DANGEROUS_MENTION = /\b(merges?|contents|dispatches|rerun|refs)\b/
|
|
129
|
+
const EXPANDED_FUNCTION_NOTE =
|
|
130
|
+
'The program is expanded from a variable or a substitution and may name a shell function that runs one of guard.commands in .jackherd/config.json, so the user confirms it.'
|
|
131
|
+
|
|
76
132
|
const PASS: MergeGuardDecision = { kind: 'pass' }
|
|
77
133
|
|
|
78
134
|
/**
|
|
@@ -109,30 +165,152 @@ const matchesFrom = (operands: Word[], expected: string[]): boolean => {
|
|
|
109
165
|
return operand.value === expected[0] && matchesFrom(rest, expected.slice(1))
|
|
110
166
|
}
|
|
111
167
|
|
|
112
|
-
/** The
|
|
113
|
-
const
|
|
168
|
+
/** The operands without one `--match-head-commit <full sha>`, and that sha; undefined for any other use of the flag. */
|
|
169
|
+
const withoutHeadCommit = (operands: string[]): { rest: string[]; headCommit: string | undefined } | undefined => {
|
|
170
|
+
const at = operands.indexOf(HEAD_COMMIT_FLAG)
|
|
171
|
+
if (at === -1) return { rest: operands, headCommit: undefined }
|
|
172
|
+
const headCommit = operands[at + 1]
|
|
173
|
+
const rest = [...operands.slice(0, at), ...operands.slice(at + 2)]
|
|
174
|
+
const plain = headCommit !== undefined && FULL_COMMIT.test(headCommit) && !rest.includes(HEAD_COMMIT_FLAG)
|
|
175
|
+
return plain ? { rest, headCommit } : undefined
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* `gh pr merge <number>` in its plain form: literal words only, merge-method and delete-branch flags, and at most one
|
|
180
|
+
* `--match-head-commit <full sha>`.
|
|
181
|
+
*/
|
|
182
|
+
const plainMerge = (words: SimpleCommand, expected: string[]): PlainMerge | undefined => {
|
|
114
183
|
const after = words.slice(1)
|
|
115
184
|
const [first, second, ...rest] = after
|
|
116
185
|
if (after.some(isWildcard) || first?.value !== expected[0] || second?.value !== expected[1]) return undefined
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
|
|
186
|
+
const bound = withoutHeadCommit(rest.map(({ value }) => value))
|
|
187
|
+
const numbers = bound?.rest.filter((value) => !APPROVABLE_FLAGS.has(value)) ?? []
|
|
188
|
+
const only = numbers.length === 1 && PULL_REQUEST_NUMBER.test(numbers[0]) ? numbers[0] : undefined
|
|
189
|
+
return only === undefined ? undefined : { pullRequest: Number(only), headCommit: bound?.headCommit }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** An expansion in a word's value: the marker, with the name it keeps after it (`$REPO`, `${REPO}`). */
|
|
193
|
+
const EXPANSION_IN_VALUE = /\$(\{[^}]*\}|\w+)?/g
|
|
194
|
+
/** Everything up to and including the last expansion of a value. */
|
|
195
|
+
const UP_TO_LAST_EXPANSION = /^[\s\S]*\$(\{[^}]*\}|\w+)?/
|
|
196
|
+
|
|
197
|
+
/** A word that is nothing but expansions: it may become any number of words, options among them. */
|
|
198
|
+
const isOpaque = ({ value }: Word): boolean => isExpanded(value) && value.replace(EXPANSION_IN_VALUE, '') === ''
|
|
199
|
+
|
|
200
|
+
const isReadMethod = ({ value }: Word): boolean => !isExpanded(value) && value.toUpperCase() === READ_METHOD
|
|
201
|
+
|
|
202
|
+
/** An option of `gh api` split into its name and the value attached with `=` (or glued to `-X`), if any. */
|
|
203
|
+
const apiOption = ({ value, literal }: Word): { name: string; attached: Word | undefined } => {
|
|
204
|
+
if (value.startsWith('-X') && value.length > 2) return { name: '-X', attached: { value: value.slice(2), literal } }
|
|
205
|
+
const at = value.indexOf('=')
|
|
206
|
+
if (at === -1 || !value.startsWith('--')) return { name: value, attached: undefined }
|
|
207
|
+
return { name: value.slice(0, at), attached: { value: value.slice(at + 1), literal } }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** A short body option with its value glued on, as in `-fbody=x`. */
|
|
211
|
+
const isGluedBodyOption = (value: string): boolean => /^-[fF]./.test(value)
|
|
212
|
+
|
|
213
|
+
const readApiCall = (args: Word[]): ApiCall => {
|
|
214
|
+
const call: ApiCall = { method: undefined, fields: false, override: false, endpoints: [] }
|
|
215
|
+
for (let index = 0; index < args.length; index++) {
|
|
216
|
+
if (!isOption(args[index])) {
|
|
217
|
+
call.endpoints.push(args[index])
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
const { name, attached } = apiOption(args[index])
|
|
221
|
+
const takesNext =
|
|
222
|
+
attached === undefined &&
|
|
223
|
+
(API_METHOD_OPTIONS.has(name) || API_BODY_OPTIONS.has(name) || API_OPTIONS_WITH_VALUE.has(name))
|
|
224
|
+
if (API_METHOD_OPTIONS.has(name)) call.method = attached ?? args[index + 1]
|
|
225
|
+
if (API_BODY_OPTIONS.has(name) || isGluedBodyOption(name)) call.fields = true
|
|
226
|
+
const header = HEADER_OPTIONS.has(name) ? (attached ?? args[index + 1]) : undefined
|
|
227
|
+
if (header !== undefined && METHOD_OVERRIDE_HEADER.test(header.value)) call.override = true
|
|
228
|
+
if (takesNext) index++
|
|
229
|
+
}
|
|
230
|
+
return call
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const decodePercent = (path: string): string => {
|
|
234
|
+
try {
|
|
235
|
+
return decodeURIComponent(path)
|
|
236
|
+
} catch {
|
|
237
|
+
return path
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The path as GitHub routes it: without query and fragment, percent-decoded, in lower case, with single slashes. */
|
|
242
|
+
const normalizedPath = (value: string): string =>
|
|
243
|
+
decodePercent(value.split(/[?#]/)[0])
|
|
244
|
+
.toLowerCase()
|
|
245
|
+
.replace(/\/{2,}/g, '/')
|
|
246
|
+
|
|
247
|
+
/** After an expansion, only the endpoint's fixed end is known, and an empty end could be any path. */
|
|
248
|
+
const isDangerousEndpoint = ({ value }: Word): boolean => {
|
|
249
|
+
const path = normalizedPath(value)
|
|
250
|
+
if (DANGEROUS_ENDPOINTS.some((pattern) => pattern.test(path))) return true
|
|
251
|
+
if (!isExpanded(path)) return false
|
|
252
|
+
const ending = path.replace(UP_TO_LAST_EXPANSION, '')
|
|
253
|
+
return ending === '' || DANGEROUS_ENDINGS.some((pattern) => pattern.test(ending))
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Whether the arguments after `gh api` write to a dangerous endpoint: a method other than GET, named or implied by a
|
|
258
|
+
* request body. A word made only of expansions could be any argument, so it counts as such a write.
|
|
259
|
+
*/
|
|
260
|
+
const writesDangerously = (args: Word[]): boolean => {
|
|
261
|
+
if (args.some(isOpaque)) return true
|
|
262
|
+
const { method, fields, override, endpoints } = readApiCall(args)
|
|
263
|
+
const writes = override || (method === undefined ? fields : !isReadMethod(method))
|
|
264
|
+
return writes && endpoints.some(isDangerousEndpoint)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The words after the first plain `api` behind the program at `index`; all of them when `api` came from an expansion. */
|
|
268
|
+
const apiArguments = (words: SimpleCommand, index: number): Word[] => {
|
|
269
|
+
const after = words.slice(index + 1)
|
|
270
|
+
const at = after.findIndex((word) => word.literal && word.value === API_WORD)
|
|
271
|
+
return at === -1 ? after : after.slice(at + 1)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Every guarded command counts wherever it is found, except a `gh api` call that does not write dangerously. */
|
|
275
|
+
const isGuardedUse = (command: string, words: SimpleCommand, programIndex: number): boolean =>
|
|
276
|
+
command !== API_COMMAND || writesDangerously(apiArguments(words, programIndex))
|
|
277
|
+
|
|
278
|
+
/** In text the parse cannot see, `api` followed by the end of a dangerous endpoint is the sign; the method is unknown. */
|
|
279
|
+
const mentionsDangerousApiCall = (script: string): boolean => {
|
|
280
|
+
const at = findWord(script, API_WORD, 0)
|
|
281
|
+
return at !== -1 && DANGEROUS_MENTION.test(script.slice(at))
|
|
120
282
|
}
|
|
121
283
|
|
|
122
284
|
/** `sole`: the script is this one simple command, written plainly, the only shape an approval can lift. */
|
|
123
285
|
const findingOf = (command: string, words: SimpleCommand, index: number, sole: boolean): Finding | undefined => {
|
|
124
286
|
const expected = command.split(' ').slice(1)
|
|
125
287
|
if (!operandLists(words.slice(index + 1)).some((operands) => matchesFrom(operands, expected))) return undefined
|
|
288
|
+
if (!isGuardedUse(command, words, index)) return undefined
|
|
126
289
|
const plain = sole && words[index].literal && words[index].value === 'gh'
|
|
127
|
-
return { command,
|
|
290
|
+
return { command, merge: plain ? plainMerge(words.slice(index), expected) : undefined }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const programsOf = (commands: readonly string[]): string[] => [
|
|
294
|
+
...new Set(commands.map((command) => command.split(' ')[0])),
|
|
295
|
+
]
|
|
296
|
+
|
|
297
|
+
/** A shell alias or function at the word that may run one of the guarded programs: it hides the words after them. */
|
|
298
|
+
const definitionFinding = (words: SimpleCommand, index: number, { commands, definitions }: Search): Finding[] => {
|
|
299
|
+
const definition = shellDefinitionAt(words, index, definitions, programsOf(commands))
|
|
300
|
+
return definition ? [{ command: definition.name, merge: undefined, definition }] : []
|
|
128
301
|
}
|
|
129
302
|
|
|
130
|
-
const
|
|
303
|
+
const expandedFunctionFinding = (words: SimpleCommand, { commands, definitions }: Search, text: string): Finding[] =>
|
|
304
|
+
mayRunDefinedFunction(words, definitions, programsOf(commands), text)
|
|
305
|
+
? [{ command: '', merge: undefined, note: EXPANDED_FUNCTION_NOTE }]
|
|
306
|
+
: []
|
|
307
|
+
|
|
308
|
+
const callsOfWord = (words: SimpleCommand, index: number, search: Search, sole: boolean): Finding[] => {
|
|
131
309
|
if (!isProgramPosition(words, index)) return []
|
|
132
310
|
const program = programName(words[index])
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
311
|
+
const guarded = search.commands.filter((command) => command.split(' ')[0] === program)
|
|
312
|
+
if (guarded.length === 0) return definitionFinding(words, index, search)
|
|
313
|
+
return guarded.flatMap((command) => findingOf(command, words, index, sole && index === 0) ?? [])
|
|
136
314
|
}
|
|
137
315
|
|
|
138
316
|
const isWordCharacter = (character: string | undefined): boolean => character !== undefined && /\w/.test(character)
|
|
@@ -155,25 +333,19 @@ const namesInOrder = (script: string, words: string[]): boolean => {
|
|
|
155
333
|
return true
|
|
156
334
|
}
|
|
157
335
|
|
|
158
|
-
const callsNaming = (script: string, commands: readonly string[], wordsOf: (command: string) => string[]): Finding[] =>
|
|
159
|
-
commands
|
|
160
|
-
.filter((command) => namesInOrder(script, wordsOf(command)))
|
|
161
|
-
.map((command) => ({ command, pullRequest: undefined }))
|
|
162
|
-
|
|
163
|
-
/** Where the script cannot be parsed, a guarded program named as a whole word is enough to ask. */
|
|
164
|
-
const namedPrograms = (script: string, commands: readonly string[]): Finding[] =>
|
|
165
|
-
callsNaming(script, commands, (command) => command.split(' ').slice(0, 1))
|
|
166
|
-
|
|
167
336
|
/** Where the program is hidden or the words are handed over as text, the command words after the program are the sign. */
|
|
168
337
|
const mentionedCommandWords = (script: string, commands: readonly string[]): Finding[] =>
|
|
169
|
-
|
|
338
|
+
commands
|
|
339
|
+
.filter((command) =>
|
|
340
|
+
command === API_COMMAND ? mentionsDangerousApiCall(script) : namesInOrder(script, command.split(' ').slice(1)),
|
|
341
|
+
)
|
|
342
|
+
.map((command) => ({ command, merge: undefined }))
|
|
170
343
|
|
|
171
344
|
/** A program expanded from a variable or a substitution could be the guarded one. */
|
|
172
345
|
const runsExpandedProgram = (words: SimpleCommand): boolean =>
|
|
173
346
|
words.some((word, index) => !word.literal && isProgramPosition(words, index))
|
|
174
347
|
|
|
175
|
-
const runsOpaquely = (words: SimpleCommand): boolean =>
|
|
176
|
-
words.some((word, index) => isProgramPosition(words, index) && OPAQUE_RUNNERS.has(programName(word)))
|
|
348
|
+
const runsOpaquely = (words: SimpleCommand): boolean => commandTextRunnerIndex(words) !== -1
|
|
177
349
|
|
|
178
350
|
/**
|
|
179
351
|
* A shell, anywhere a program can stand, that reads its script from stdin (no operand, or `-s`), from a process
|
|
@@ -190,14 +362,14 @@ const readsScriptFromElsewhere = (words: SimpleCommand): boolean =>
|
|
|
190
362
|
return fromInput || operands.some((each) => !each.literal || INPUT_PATH.test(each.value))
|
|
191
363
|
})
|
|
192
364
|
|
|
193
|
-
/**
|
|
194
|
-
const
|
|
195
|
-
words.some(
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
365
|
+
/** Git handed an alias (`git -c alias.m=!…`) runs text the parse cannot see. */
|
|
366
|
+
const definesGitAlias = (words: SimpleCommand): boolean =>
|
|
367
|
+
words.some(
|
|
368
|
+
(word, index) =>
|
|
369
|
+
isProgramPosition(words, index) &&
|
|
370
|
+
programName(word) === 'git' &&
|
|
371
|
+
words.slice(index + 1).some(({ value }) => value.startsWith('alias.')),
|
|
372
|
+
)
|
|
201
373
|
|
|
202
374
|
/** An inline script that is itself an expansion (`eval "$X"`) hides its text from the parse of the call. */
|
|
203
375
|
const runsInlineExpansion = (words: SimpleCommand): boolean =>
|
|
@@ -217,71 +389,139 @@ const containsInOrder = (words: SimpleCommand, command: string): boolean => {
|
|
|
217
389
|
if (next === -1) return false
|
|
218
390
|
from = next + 1
|
|
219
391
|
}
|
|
220
|
-
return
|
|
392
|
+
return isGuardedUse(command, words, at)
|
|
221
393
|
}
|
|
222
394
|
|
|
223
|
-
|
|
395
|
+
/** `text` is the searchable text of the script around the command: a variable there may hold a function's name. */
|
|
396
|
+
const callsInCommand = (words: SimpleCommand, search: Search, sole: boolean, whole: string, text: string): Finding[] => {
|
|
224
397
|
const direct = words.flatMap((_word, index) => {
|
|
225
398
|
const script = inlineScript(words, index)
|
|
226
|
-
return script === undefined ? callsOfWord(words, index,
|
|
399
|
+
return script === undefined ? callsOfWord(words, index, search, sole) : callsIn(script, search, false, whole)
|
|
227
400
|
})
|
|
228
401
|
const found = new Set(direct.map(({ command }) => command))
|
|
229
|
-
const byWords = commands
|
|
402
|
+
const byWords = search.commands
|
|
230
403
|
.filter((command) => !found.has(command) && containsInOrder(words, command))
|
|
231
|
-
.map((command) => ({ command,
|
|
232
|
-
return [...direct, ...byWords]
|
|
404
|
+
.map((command) => ({ command, merge: undefined }))
|
|
405
|
+
return [...direct, ...byWords, ...expandedFunctionFinding(words, search, text)]
|
|
233
406
|
}
|
|
234
407
|
|
|
235
|
-
/**
|
|
408
|
+
/**
|
|
409
|
+
* Each check reads the whole script: a shell that reads stdin, an expanded program, an opaque runner or an interpreter
|
|
410
|
+
* handed program text hides what runs.
|
|
411
|
+
*/
|
|
236
412
|
const hidesWhatRuns = (words: SimpleCommand): boolean =>
|
|
237
413
|
runsExpandedProgram(words) ||
|
|
238
414
|
runsOpaquely(words) ||
|
|
239
415
|
readsScriptFromElsewhere(words) ||
|
|
240
416
|
runsInlineExpansion(words) ||
|
|
241
|
-
|
|
417
|
+
runsInterpreterProgram(words) ||
|
|
418
|
+
definesGitAlias(words) ||
|
|
419
|
+
definesShellAlias(words)
|
|
242
420
|
|
|
243
421
|
const callsInScriptWide = (whole: string, parsed: SimpleCommand[], commands: readonly string[]): Finding[] =>
|
|
244
|
-
parsed.some(hidesWhatRuns) ? mentionedCommandWords(whole, commands) : []
|
|
422
|
+
parsed.some(hidesWhatRuns) ? mentionedCommandWords(searchableText(whole, parsed), commands) : []
|
|
245
423
|
|
|
246
424
|
/** `whole` is the text of the outermost script: a nested script's input comes from there, so the words are searched in it. */
|
|
247
|
-
const callsIn = (script: string,
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
} catch (error) {
|
|
256
|
-
if (error instanceof ShellParseError) return namedPrograms(whole, commands)
|
|
257
|
-
throw error
|
|
258
|
-
}
|
|
425
|
+
const callsIn = (script: string, search: Search, top: boolean, whole: string): Finding[] => {
|
|
426
|
+
const parsed = parseShell(script)
|
|
427
|
+
const sole = top && parsed.length === 1 && !script.trimStart().startsWith('(')
|
|
428
|
+
const text = searchableText(whole, parsed)
|
|
429
|
+
return [
|
|
430
|
+
...parsed.flatMap((words) => callsInCommand(words, search, sole, whole, text)),
|
|
431
|
+
...callsInScriptWide(whole, parsed, search.commands),
|
|
432
|
+
]
|
|
259
433
|
}
|
|
260
434
|
|
|
261
|
-
const
|
|
262
|
-
if (command !== MERGE_COMMAND ||
|
|
435
|
+
const approvalOf = ({ command, merge }: Finding, policy: GuardPolicy): boolean | MergeNote => {
|
|
436
|
+
if (command !== MERGE_COMMAND || merge === undefined) return false
|
|
263
437
|
try {
|
|
264
|
-
return policy.isMergeApproved(
|
|
438
|
+
return policy.isMergeApproved(merge)
|
|
265
439
|
} catch {
|
|
266
440
|
return false
|
|
267
441
|
}
|
|
268
442
|
}
|
|
269
443
|
|
|
270
|
-
const
|
|
444
|
+
const guardedListSentence = (commands: string[]): string[] =>
|
|
445
|
+
commands.length === 0
|
|
446
|
+
? []
|
|
447
|
+
: [
|
|
448
|
+
`${commands.map((command) => `\`${command}\``).join(' and ')} is on the project's guarded list (guard.commands in .jackherd/config.json), so the user confirms it.`,
|
|
449
|
+
]
|
|
450
|
+
|
|
451
|
+
const askBecause = (findings: Finding[], notes: string[], script: string): MergeGuardDecision => {
|
|
452
|
+
const guarded = findings
|
|
453
|
+
.filter(({ definition, note }) => definition === undefined && note === undefined)
|
|
454
|
+
.map(({ command }) => command)
|
|
455
|
+
const definitions = [
|
|
456
|
+
...new Map(
|
|
457
|
+
findings.flatMap(({ definition }) => (definition ? [[definition.name, definition] as const] : [])),
|
|
458
|
+
).values(),
|
|
459
|
+
]
|
|
460
|
+
return {
|
|
461
|
+
kind: 'ask',
|
|
462
|
+
reason: [
|
|
463
|
+
...guardedListSentence([...new Set(guarded)]),
|
|
464
|
+
...definitions.map(
|
|
465
|
+
(definition) =>
|
|
466
|
+
`${shellDefinitionHint(definition)} It may run a command of guard.commands in .jackherd/config.json, so the user confirms it.`,
|
|
467
|
+
),
|
|
468
|
+
...new Set(findings.flatMap(({ note }) => note ?? [])),
|
|
469
|
+
...notes,
|
|
470
|
+
expandingHereDocumentAdvice(script),
|
|
471
|
+
]
|
|
472
|
+
.filter((part) => part !== '')
|
|
473
|
+
.join(' '),
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Quotes split a word (`g''h`), a variable builds it, an expansion hides a command: in text the parse cannot follow,
|
|
479
|
+
* no word search can tell whether a guarded command runs, so every unreadable script asks (fail closed).
|
|
480
|
+
*/
|
|
481
|
+
const askUnreadable = ({ message }: ShellParseError, script: string): MergeGuardDecision => ({
|
|
271
482
|
kind: 'ask',
|
|
272
|
-
reason:
|
|
483
|
+
reason: [
|
|
484
|
+
`The command could not be read (${message}), so it may run a command of guard.commands in .jackherd/config.json and the user confirms it.`,
|
|
485
|
+
UNREADABLE_ADVICE,
|
|
486
|
+
expandingHereDocumentAdvice(script),
|
|
487
|
+
]
|
|
488
|
+
.filter((part) => part !== '')
|
|
489
|
+
.join(' '),
|
|
273
490
|
})
|
|
274
491
|
|
|
492
|
+
const findingsOrUnreadable = (command: string, search: Search): Finding[] | ShellParseError => {
|
|
493
|
+
try {
|
|
494
|
+
return callsIn(command, search, true, command)
|
|
495
|
+
} catch (error) {
|
|
496
|
+
if (error instanceof ShellParseError) return error
|
|
497
|
+
throw error
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
275
501
|
/**
|
|
276
502
|
* Decides on a Bash command before it runs: `ask` when it runs, or could run, a guarded command (`gh pr merge`,
|
|
277
|
-
* `gh workflow run`
|
|
278
|
-
*
|
|
279
|
-
* `gh pr merge <number>` with merge-method and
|
|
280
|
-
*
|
|
503
|
+
* `gh workflow run`, `gh run rerun` and a `gh api` write to a merge, a file, a dispatch, a rerun or a ref by default), also behind options before the
|
|
504
|
+
* verb, a chain, a wrapper, `sh -c`, `eval`, a script piped into a shell, an interpreter's program text, a shell alias
|
|
505
|
+
* or function, or a program held in a variable. A script that is exactly `gh pr merge <number>` with merge-method and
|
|
506
|
+
* delete-branch flags and at most one `--match-head-commit <sha>` passes when the user approved it
|
|
507
|
+
* (`isMergeApproved`); any other shape of the merge, and every other guarded command, asks. A command that merely
|
|
508
|
+
* names the words (an argument, a commit message) is none. A script the parse cannot read asks whatever it names,
|
|
509
|
+
* unless the list is empty.
|
|
281
510
|
*/
|
|
282
511
|
export const decideBashCommand = (command: string, policy: GuardPolicy): MergeGuardDecision => {
|
|
283
|
-
|
|
512
|
+
if (policy.commands.length === 0) return PASS
|
|
513
|
+
const search = { commands: policy.commands, definitions: policy.shellDefinitions ?? (() => NO_DEFINITIONS) }
|
|
514
|
+
const findings = findingsOrUnreadable(command, search)
|
|
515
|
+
if (findings instanceof ShellParseError) return askUnreadable(findings, command)
|
|
284
516
|
if (findings.length === 0) return PASS
|
|
285
|
-
const unapproved = findings
|
|
286
|
-
|
|
517
|
+
const unapproved = findings
|
|
518
|
+
.map((finding) => ({ finding, approval: approvalOf(finding, policy) }))
|
|
519
|
+
.filter(({ approval }) => approval !== true)
|
|
520
|
+
if (unapproved.length === 0) return PASS
|
|
521
|
+
const notes = unapproved.flatMap(({ approval }) => (typeof approval === 'object' ? [approval.note] : []))
|
|
522
|
+
return askBecause(
|
|
523
|
+
unapproved.map(({ finding }) => finding),
|
|
524
|
+
[...new Set(notes)],
|
|
525
|
+
command,
|
|
526
|
+
)
|
|
287
527
|
}
|