jackherd 1.7.1 → 1.8.1
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 +47 -0
- package/INSTALL.md +2 -0
- package/{NOTICE → NOTICE.txt} +1 -1
- package/README.md +86 -1037
- package/assets/branding/README.md +5 -7
- package/assets/branding/jackherd-logo-outline.svg +1 -0
- package/assets/branding/outline.test.ts +30 -0
- package/assets/icons/bark.svg +1 -1
- package/assets/icons/bowl.svg +1 -1
- package/assets/icons/decide.svg +1 -1
- package/assets/icons/ear.svg +1 -1
- package/assets/icons/lanes.svg +1 -1
- package/assets/icons/plan.svg +1 -1
- package/dist/{chunk-OBUWTQ6Z.js → chunk-OU2KALXP.js} +26 -10
- package/dist/config.d.ts +11 -5
- package/dist/config.js +53 -21
- package/dist/plan-format.d.ts +75 -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 +344 -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/merge-guard/xargs-program.ts +182 -0
- 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 +156 -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 +4 -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/read-config.ts +16 -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-standing.ts +5 -4
- 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/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 +2 -0
- package/src/update/config-shares.ts +30 -0
- package/src/update/update.ts +12 -1
- package/src/vocabulary/index.ts +51 -11
- package/src/watch/appointments.ts +206 -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/assets/icons/bark-dark.svg +0 -1
- package/assets/icons/bowl-dark.svg +0 -1
- package/assets/icons/decide-dark.svg +0 -1
- package/assets/icons/ear-dark.svg +0 -1
- package/assets/icons/lanes-dark.svg +0 -1
- package/assets/icons/plan-dark.svg +0 -1
- 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,69 @@
|
|
|
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'
|
|
29
|
+
import { resolvePlainXargs } from './xargs-program'
|
|
9
30
|
|
|
10
31
|
export type MergeGuardDecision = { kind: 'pass' } | { kind: 'ask'; reason: string }
|
|
11
32
|
|
|
33
|
+
/**
|
|
34
|
+
* A plain `gh pr merge <number>`, with the head commit it names in `--match-head-commit <sha>`, if any: GitHub then
|
|
35
|
+
* merges only that commit, so what was checked is what gets merged.
|
|
36
|
+
*/
|
|
37
|
+
export type PlainMerge = { pullRequest: number; headCommit: string | undefined }
|
|
38
|
+
|
|
39
|
+
/** An approval that no longer covers the merge, and what the question should tell the user about it. */
|
|
40
|
+
export type MergeNote = { note: string }
|
|
41
|
+
|
|
12
42
|
export type GuardPolicy = {
|
|
13
43
|
/** The guarded commands as words: a program and the plain words after it, such as `gh pr merge`. */
|
|
14
44
|
commands: readonly string[]
|
|
15
45
|
/**
|
|
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.
|
|
46
|
+
* Whether the merge of this pull request may pass without a question: the user approved its branch or the plan of
|
|
47
|
+
* its lane, or every file of it matches `merge.withoutChecks`. Asked only for a plain `gh pr merge <number>`.
|
|
48
|
+
* A throw counts as not approved; a note is not approved either, and the question carries it.
|
|
19
49
|
*/
|
|
20
|
-
isMergeApproved: (
|
|
50
|
+
isMergeApproved: (merge: PlainMerge) => boolean | MergeNote
|
|
51
|
+
/** The aliases and functions of the user's shell, read only when a word at a program position may be one; none by default. */
|
|
52
|
+
shellDefinitions?: () => ShellDefinitions
|
|
21
53
|
}
|
|
22
54
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
55
|
+
/**
|
|
56
|
+
* A guarded command the script runs or could run, and the pull request a merge names, when it names one plainly; or
|
|
57
|
+
* the shell alias or function that may run one (`definition`, whose name is then the `command`), or a program whose
|
|
58
|
+
* name is unknown (`note` says why it may run one).
|
|
59
|
+
*/
|
|
60
|
+
type Finding = { command: string; merge: PlainMerge | undefined; definition?: ShellDefinitionHit; note?: string }
|
|
61
|
+
|
|
62
|
+
/** What the search looks for: the guarded commands, and the shell definitions that may stand for one of them. */
|
|
63
|
+
type Search = { commands: readonly string[]; definitions: () => ShellDefinitions }
|
|
64
|
+
|
|
65
|
+
/** The parts of a `gh api` call that decide whether it writes, and where. */
|
|
66
|
+
type ApiCall = { method: Word | undefined; fields: boolean; override: boolean; endpoints: Word[] }
|
|
25
67
|
|
|
26
68
|
/** The one command whose guard an approval can lift. Every other guarded command asks each time. */
|
|
27
69
|
const MERGE_COMMAND = 'gh pr merge'
|
|
@@ -35,44 +77,59 @@ const OPTIONS_WITH_VALUE = new Set(['-R', '--repo', '--hostname'])
|
|
|
35
77
|
*/
|
|
36
78
|
const APPROVABLE_FLAGS = new Set(['--squash', '-s', '--merge', '-m', '--rebase', '-r', '--delete-branch', '-d'])
|
|
37
79
|
const PULL_REQUEST_NUMBER = /^[1-9]\d*$/
|
|
80
|
+
const HEAD_COMMIT_FLAG = '--match-head-commit'
|
|
38
81
|
const GLOB = /[*?[]/
|
|
39
82
|
|
|
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
83
|
const SOURCING = new Set(['source', '.'])
|
|
70
84
|
|
|
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
85
|
const INPUT_PATH = /^(\/dev\/stdin|\/dev\/fd\/|\/proc\/self\/fd\/)/
|
|
75
86
|
|
|
87
|
+
/** `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. */
|
|
88
|
+
const API_COMMAND = 'gh api'
|
|
89
|
+
const API_WORD = 'api'
|
|
90
|
+
const READ_METHOD = 'GET'
|
|
91
|
+
const API_METHOD_OPTIONS = new Set(['-X', '--method'])
|
|
92
|
+
/** Options that add a request body, which makes the method POST unless one is named. */
|
|
93
|
+
const API_BODY_OPTIONS = new Set(['-f', '-F', '--field', '--raw-field', '--input'])
|
|
94
|
+
const API_OPTIONS_WITH_VALUE = new Set([
|
|
95
|
+
'-H',
|
|
96
|
+
'--header',
|
|
97
|
+
'-q',
|
|
98
|
+
'--jq',
|
|
99
|
+
'-t',
|
|
100
|
+
'--template',
|
|
101
|
+
'--hostname',
|
|
102
|
+
'--cache',
|
|
103
|
+
'-p',
|
|
104
|
+
'--preview',
|
|
105
|
+
])
|
|
106
|
+
/** A header that makes GitHub take another method than the request's own, so a GET may write. */
|
|
107
|
+
const METHOD_OVERRIDE_HEADER = /^\s*x-http-method(-override)?\s*:/i
|
|
108
|
+
const HEADER_OPTIONS = new Set(['-H', '--header'])
|
|
109
|
+
/**
|
|
110
|
+
* Endpoints that merge a pull request or a branch, write a file (a commit), dispatch a workflow or repository event,
|
|
111
|
+
* rerun a workflow (a deploy), or create, move or delete a ref.
|
|
112
|
+
*/
|
|
113
|
+
const DANGEROUS_ENDPOINTS = [
|
|
114
|
+
/(^|\/)pulls\/[^/]+\/merge\/?$/,
|
|
115
|
+
/(^|\/)merges\/?$/,
|
|
116
|
+
/(^|\/)contents(\/|$)/,
|
|
117
|
+
/(^|\/)dispatches\/?$/,
|
|
118
|
+
/(^|\/)actions\/(runs|jobs)\/[^/]+\/(rerun|rerun-failed-jobs)\/?$/,
|
|
119
|
+
/(^|\/)git\/refs(\/|$)/,
|
|
120
|
+
]
|
|
121
|
+
/** What the fixed end of an endpoint after an expansion may be, when the expansion can stand for the rest of the path. */
|
|
122
|
+
const DANGEROUS_ENDINGS = [
|
|
123
|
+
/(^|\/)merges?\/?$/,
|
|
124
|
+
/(^|\/)contents(\/|$)/,
|
|
125
|
+
/(^|\/)dispatches\/?$/,
|
|
126
|
+
/(^|\/)(rerun|rerun-failed-jobs)\/?$/,
|
|
127
|
+
/(^|\/)refs(\/|$)/,
|
|
128
|
+
]
|
|
129
|
+
const DANGEROUS_MENTION = /\b(merges?|contents|dispatches|rerun|refs)\b/
|
|
130
|
+
const EXPANDED_FUNCTION_NOTE =
|
|
131
|
+
'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.'
|
|
132
|
+
|
|
76
133
|
const PASS: MergeGuardDecision = { kind: 'pass' }
|
|
77
134
|
|
|
78
135
|
/**
|
|
@@ -109,30 +166,152 @@ const matchesFrom = (operands: Word[], expected: string[]): boolean => {
|
|
|
109
166
|
return operand.value === expected[0] && matchesFrom(rest, expected.slice(1))
|
|
110
167
|
}
|
|
111
168
|
|
|
112
|
-
/** The
|
|
113
|
-
const
|
|
169
|
+
/** The operands without one `--match-head-commit <full sha>`, and that sha; undefined for any other use of the flag. */
|
|
170
|
+
const withoutHeadCommit = (operands: string[]): { rest: string[]; headCommit: string | undefined } | undefined => {
|
|
171
|
+
const at = operands.indexOf(HEAD_COMMIT_FLAG)
|
|
172
|
+
if (at === -1) return { rest: operands, headCommit: undefined }
|
|
173
|
+
const headCommit = operands[at + 1]
|
|
174
|
+
const rest = [...operands.slice(0, at), ...operands.slice(at + 2)]
|
|
175
|
+
const plain = headCommit !== undefined && FULL_COMMIT.test(headCommit) && !rest.includes(HEAD_COMMIT_FLAG)
|
|
176
|
+
return plain ? { rest, headCommit } : undefined
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* `gh pr merge <number>` in its plain form: literal words only, merge-method and delete-branch flags, and at most one
|
|
181
|
+
* `--match-head-commit <full sha>`.
|
|
182
|
+
*/
|
|
183
|
+
const plainMerge = (words: SimpleCommand, expected: string[]): PlainMerge | undefined => {
|
|
114
184
|
const after = words.slice(1)
|
|
115
185
|
const [first, second, ...rest] = after
|
|
116
186
|
if (after.some(isWildcard) || first?.value !== expected[0] || second?.value !== expected[1]) return undefined
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
|
|
187
|
+
const bound = withoutHeadCommit(rest.map(({ value }) => value))
|
|
188
|
+
const numbers = bound?.rest.filter((value) => !APPROVABLE_FLAGS.has(value)) ?? []
|
|
189
|
+
const only = numbers.length === 1 && PULL_REQUEST_NUMBER.test(numbers[0]) ? numbers[0] : undefined
|
|
190
|
+
return only === undefined ? undefined : { pullRequest: Number(only), headCommit: bound?.headCommit }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** An expansion in a word's value: the marker, with the name it keeps after it (`$REPO`, `${REPO}`). */
|
|
194
|
+
const EXPANSION_IN_VALUE = /\$(\{[^}]*\}|\w+)?/g
|
|
195
|
+
/** Everything up to and including the last expansion of a value. */
|
|
196
|
+
const UP_TO_LAST_EXPANSION = /^[\s\S]*\$(\{[^}]*\}|\w+)?/
|
|
197
|
+
|
|
198
|
+
/** A word that is nothing but expansions: it may become any number of words, options among them. */
|
|
199
|
+
const isOpaque = ({ value }: Word): boolean => isExpanded(value) && value.replace(EXPANSION_IN_VALUE, '') === ''
|
|
200
|
+
|
|
201
|
+
const isReadMethod = ({ value }: Word): boolean => !isExpanded(value) && value.toUpperCase() === READ_METHOD
|
|
202
|
+
|
|
203
|
+
/** An option of `gh api` split into its name and the value attached with `=` (or glued to `-X`), if any. */
|
|
204
|
+
const apiOption = ({ value, literal }: Word): { name: string; attached: Word | undefined } => {
|
|
205
|
+
if (value.startsWith('-X') && value.length > 2) return { name: '-X', attached: { value: value.slice(2), literal } }
|
|
206
|
+
const at = value.indexOf('=')
|
|
207
|
+
if (at === -1 || !value.startsWith('--')) return { name: value, attached: undefined }
|
|
208
|
+
return { name: value.slice(0, at), attached: { value: value.slice(at + 1), literal } }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** A short body option with its value glued on, as in `-fbody=x`. */
|
|
212
|
+
const isGluedBodyOption = (value: string): boolean => /^-[fF]./.test(value)
|
|
213
|
+
|
|
214
|
+
const readApiCall = (args: Word[]): ApiCall => {
|
|
215
|
+
const call: ApiCall = { method: undefined, fields: false, override: false, endpoints: [] }
|
|
216
|
+
for (let index = 0; index < args.length; index++) {
|
|
217
|
+
if (!isOption(args[index])) {
|
|
218
|
+
call.endpoints.push(args[index])
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
const { name, attached } = apiOption(args[index])
|
|
222
|
+
const takesNext =
|
|
223
|
+
attached === undefined &&
|
|
224
|
+
(API_METHOD_OPTIONS.has(name) || API_BODY_OPTIONS.has(name) || API_OPTIONS_WITH_VALUE.has(name))
|
|
225
|
+
if (API_METHOD_OPTIONS.has(name)) call.method = attached ?? args[index + 1]
|
|
226
|
+
if (API_BODY_OPTIONS.has(name) || isGluedBodyOption(name)) call.fields = true
|
|
227
|
+
const header = HEADER_OPTIONS.has(name) ? (attached ?? args[index + 1]) : undefined
|
|
228
|
+
if (header !== undefined && METHOD_OVERRIDE_HEADER.test(header.value)) call.override = true
|
|
229
|
+
if (takesNext) index++
|
|
230
|
+
}
|
|
231
|
+
return call
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const decodePercent = (path: string): string => {
|
|
235
|
+
try {
|
|
236
|
+
return decodeURIComponent(path)
|
|
237
|
+
} catch {
|
|
238
|
+
return path
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The path as GitHub routes it: without query and fragment, percent-decoded, in lower case, with single slashes. */
|
|
243
|
+
const normalizedPath = (value: string): string =>
|
|
244
|
+
decodePercent(value.split(/[?#]/)[0])
|
|
245
|
+
.toLowerCase()
|
|
246
|
+
.replace(/\/{2,}/g, '/')
|
|
247
|
+
|
|
248
|
+
/** After an expansion, only the endpoint's fixed end is known, and an empty end could be any path. */
|
|
249
|
+
const isDangerousEndpoint = ({ value }: Word): boolean => {
|
|
250
|
+
const path = normalizedPath(value)
|
|
251
|
+
if (DANGEROUS_ENDPOINTS.some((pattern) => pattern.test(path))) return true
|
|
252
|
+
if (!isExpanded(path)) return false
|
|
253
|
+
const ending = path.replace(UP_TO_LAST_EXPANSION, '')
|
|
254
|
+
return ending === '' || DANGEROUS_ENDINGS.some((pattern) => pattern.test(ending))
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Whether the arguments after `gh api` write to a dangerous endpoint: a method other than GET, named or implied by a
|
|
259
|
+
* request body. A word made only of expansions could be any argument, so it counts as such a write.
|
|
260
|
+
*/
|
|
261
|
+
const writesDangerously = (args: Word[]): boolean => {
|
|
262
|
+
if (args.some(isOpaque)) return true
|
|
263
|
+
const { method, fields, override, endpoints } = readApiCall(args)
|
|
264
|
+
const writes = override || (method === undefined ? fields : !isReadMethod(method))
|
|
265
|
+
return writes && endpoints.some(isDangerousEndpoint)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The words after the first plain `api` behind the program at `index`; all of them when `api` came from an expansion. */
|
|
269
|
+
const apiArguments = (words: SimpleCommand, index: number): Word[] => {
|
|
270
|
+
const after = words.slice(index + 1)
|
|
271
|
+
const at = after.findIndex((word) => word.literal && word.value === API_WORD)
|
|
272
|
+
return at === -1 ? after : after.slice(at + 1)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Every guarded command counts wherever it is found, except a `gh api` call that does not write dangerously. */
|
|
276
|
+
const isGuardedUse = (command: string, words: SimpleCommand, programIndex: number): boolean =>
|
|
277
|
+
command !== API_COMMAND || writesDangerously(apiArguments(words, programIndex))
|
|
278
|
+
|
|
279
|
+
/** In text the parse cannot see, `api` followed by the end of a dangerous endpoint is the sign; the method is unknown. */
|
|
280
|
+
const mentionsDangerousApiCall = (script: string): boolean => {
|
|
281
|
+
const at = findWord(script, API_WORD, 0)
|
|
282
|
+
return at !== -1 && DANGEROUS_MENTION.test(script.slice(at))
|
|
120
283
|
}
|
|
121
284
|
|
|
122
285
|
/** `sole`: the script is this one simple command, written plainly, the only shape an approval can lift. */
|
|
123
286
|
const findingOf = (command: string, words: SimpleCommand, index: number, sole: boolean): Finding | undefined => {
|
|
124
287
|
const expected = command.split(' ').slice(1)
|
|
125
288
|
if (!operandLists(words.slice(index + 1)).some((operands) => matchesFrom(operands, expected))) return undefined
|
|
289
|
+
if (!isGuardedUse(command, words, index)) return undefined
|
|
126
290
|
const plain = sole && words[index].literal && words[index].value === 'gh'
|
|
127
|
-
return { command,
|
|
291
|
+
return { command, merge: plain ? plainMerge(words.slice(index), expected) : undefined }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const programsOf = (commands: readonly string[]): string[] => [
|
|
295
|
+
...new Set(commands.map((command) => command.split(' ')[0])),
|
|
296
|
+
]
|
|
297
|
+
|
|
298
|
+
/** A shell alias or function at the word that may run one of the guarded programs: it hides the words after them. */
|
|
299
|
+
const definitionFinding = (words: SimpleCommand, index: number, { commands, definitions }: Search): Finding[] => {
|
|
300
|
+
const definition = shellDefinitionAt(words, index, definitions, programsOf(commands))
|
|
301
|
+
return definition ? [{ command: definition.name, merge: undefined, definition }] : []
|
|
128
302
|
}
|
|
129
303
|
|
|
130
|
-
const
|
|
304
|
+
const expandedFunctionFinding = (words: SimpleCommand, { commands, definitions }: Search, text: string): Finding[] =>
|
|
305
|
+
mayRunDefinedFunction(words, definitions, programsOf(commands), text)
|
|
306
|
+
? [{ command: '', merge: undefined, note: EXPANDED_FUNCTION_NOTE }]
|
|
307
|
+
: []
|
|
308
|
+
|
|
309
|
+
const callsOfWord = (words: SimpleCommand, index: number, search: Search, sole: boolean): Finding[] => {
|
|
131
310
|
if (!isProgramPosition(words, index)) return []
|
|
132
311
|
const program = programName(words[index])
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
312
|
+
const guarded = search.commands.filter((command) => command.split(' ')[0] === program)
|
|
313
|
+
if (guarded.length === 0) return definitionFinding(words, index, search)
|
|
314
|
+
return guarded.flatMap((command) => findingOf(command, words, index, sole && index === 0) ?? [])
|
|
136
315
|
}
|
|
137
316
|
|
|
138
317
|
const isWordCharacter = (character: string | undefined): boolean => character !== undefined && /\w/.test(character)
|
|
@@ -155,25 +334,19 @@ const namesInOrder = (script: string, words: string[]): boolean => {
|
|
|
155
334
|
return true
|
|
156
335
|
}
|
|
157
336
|
|
|
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
337
|
/** Where the program is hidden or the words are handed over as text, the command words after the program are the sign. */
|
|
168
338
|
const mentionedCommandWords = (script: string, commands: readonly string[]): Finding[] =>
|
|
169
|
-
|
|
339
|
+
commands
|
|
340
|
+
.filter((command) =>
|
|
341
|
+
command === API_COMMAND ? mentionsDangerousApiCall(script) : namesInOrder(script, command.split(' ').slice(1)),
|
|
342
|
+
)
|
|
343
|
+
.map((command) => ({ command, merge: undefined }))
|
|
170
344
|
|
|
171
345
|
/** A program expanded from a variable or a substitution could be the guarded one. */
|
|
172
346
|
const runsExpandedProgram = (words: SimpleCommand): boolean =>
|
|
173
347
|
words.some((word, index) => !word.literal && isProgramPosition(words, index))
|
|
174
348
|
|
|
175
|
-
const runsOpaquely = (words: SimpleCommand): boolean =>
|
|
176
|
-
words.some((word, index) => isProgramPosition(words, index) && OPAQUE_RUNNERS.has(programName(word)))
|
|
349
|
+
const runsOpaquely = (words: SimpleCommand): boolean => commandTextRunnerIndex(words) !== -1
|
|
177
350
|
|
|
178
351
|
/**
|
|
179
352
|
* A shell, anywhere a program can stand, that reads its script from stdin (no operand, or `-s`), from a process
|
|
@@ -190,14 +363,14 @@ const readsScriptFromElsewhere = (words: SimpleCommand): boolean =>
|
|
|
190
363
|
return fromInput || operands.some((each) => !each.literal || INPUT_PATH.test(each.value))
|
|
191
364
|
})
|
|
192
365
|
|
|
193
|
-
/**
|
|
194
|
-
const
|
|
195
|
-
words.some(
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
366
|
+
/** Git handed an alias (`git -c alias.m=!…`) runs text the parse cannot see. */
|
|
367
|
+
const definesGitAlias = (words: SimpleCommand): boolean =>
|
|
368
|
+
words.some(
|
|
369
|
+
(word, index) =>
|
|
370
|
+
isProgramPosition(words, index) &&
|
|
371
|
+
programName(word) === 'git' &&
|
|
372
|
+
words.slice(index + 1).some(({ value }) => value.startsWith('alias.')),
|
|
373
|
+
)
|
|
201
374
|
|
|
202
375
|
/** An inline script that is itself an expansion (`eval "$X"`) hides its text from the parse of the call. */
|
|
203
376
|
const runsInlineExpansion = (words: SimpleCommand): boolean =>
|
|
@@ -217,71 +390,140 @@ const containsInOrder = (words: SimpleCommand, command: string): boolean => {
|
|
|
217
390
|
if (next === -1) return false
|
|
218
391
|
from = next + 1
|
|
219
392
|
}
|
|
220
|
-
return
|
|
393
|
+
return isGuardedUse(command, words, at)
|
|
221
394
|
}
|
|
222
395
|
|
|
223
|
-
|
|
396
|
+
/** `text` is the searchable text of the script around the command: a variable there may hold a function's name. */
|
|
397
|
+
const callsInCommand = (words: SimpleCommand, search: Search, sole: boolean, whole: string, text: string): Finding[] => {
|
|
224
398
|
const direct = words.flatMap((_word, index) => {
|
|
225
399
|
const script = inlineScript(words, index)
|
|
226
|
-
return script === undefined ? callsOfWord(words, index,
|
|
400
|
+
return script === undefined ? callsOfWord(words, index, search, sole) : callsIn(script, search, false, whole)
|
|
227
401
|
})
|
|
228
402
|
const found = new Set(direct.map(({ command }) => command))
|
|
229
|
-
const byWords = commands
|
|
403
|
+
const byWords = search.commands
|
|
230
404
|
.filter((command) => !found.has(command) && containsInOrder(words, command))
|
|
231
|
-
.map((command) => ({ command,
|
|
232
|
-
return [...direct, ...byWords]
|
|
405
|
+
.map((command) => ({ command, merge: undefined }))
|
|
406
|
+
return [...direct, ...byWords, ...expandedFunctionFinding(words, search, text)]
|
|
233
407
|
}
|
|
234
408
|
|
|
235
|
-
/**
|
|
409
|
+
/**
|
|
410
|
+
* Each check reads the whole script: a shell that reads stdin, an expanded program, an opaque runner or an interpreter
|
|
411
|
+
* handed program text hides what runs.
|
|
412
|
+
*/
|
|
236
413
|
const hidesWhatRuns = (words: SimpleCommand): boolean =>
|
|
237
414
|
runsExpandedProgram(words) ||
|
|
238
415
|
runsOpaquely(words) ||
|
|
239
416
|
readsScriptFromElsewhere(words) ||
|
|
240
417
|
runsInlineExpansion(words) ||
|
|
241
|
-
|
|
418
|
+
runsInterpreterProgram(words) ||
|
|
419
|
+
definesGitAlias(words) ||
|
|
420
|
+
definesShellAlias(words)
|
|
242
421
|
|
|
243
422
|
const callsInScriptWide = (whole: string, parsed: SimpleCommand[], commands: readonly string[]): Finding[] =>
|
|
244
|
-
parsed.some(hidesWhatRuns) ? mentionedCommandWords(whole, commands) : []
|
|
423
|
+
parsed.some(hidesWhatRuns) ? mentionedCommandWords(searchableText(whole, parsed), commands) : []
|
|
245
424
|
|
|
246
425
|
/** `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
|
-
|
|
256
|
-
if (error instanceof ShellParseError) return namedPrograms(whole, commands)
|
|
257
|
-
throw error
|
|
258
|
-
}
|
|
426
|
+
const callsIn = (script: string, search: Search, top: boolean, whole: string): Finding[] => {
|
|
427
|
+
const guardedPrograms = programsOf(search.commands)
|
|
428
|
+
const parsed = parseShell(script).map((words) => resolvePlainXargs(words, guardedPrograms))
|
|
429
|
+
const sole = top && parsed.length === 1 && !script.trimStart().startsWith('(')
|
|
430
|
+
const text = searchableText(whole, parsed)
|
|
431
|
+
return [
|
|
432
|
+
...parsed.flatMap((words) => callsInCommand(words, search, sole, whole, text)),
|
|
433
|
+
...callsInScriptWide(whole, parsed, search.commands),
|
|
434
|
+
]
|
|
259
435
|
}
|
|
260
436
|
|
|
261
|
-
const
|
|
262
|
-
if (command !== MERGE_COMMAND ||
|
|
437
|
+
const approvalOf = ({ command, merge }: Finding, policy: GuardPolicy): boolean | MergeNote => {
|
|
438
|
+
if (command !== MERGE_COMMAND || merge === undefined) return false
|
|
263
439
|
try {
|
|
264
|
-
return policy.isMergeApproved(
|
|
440
|
+
return policy.isMergeApproved(merge)
|
|
265
441
|
} catch {
|
|
266
442
|
return false
|
|
267
443
|
}
|
|
268
444
|
}
|
|
269
445
|
|
|
270
|
-
const
|
|
446
|
+
const guardedListSentence = (commands: string[]): string[] =>
|
|
447
|
+
commands.length === 0
|
|
448
|
+
? []
|
|
449
|
+
: [
|
|
450
|
+
`${commands.map((command) => `\`${command}\``).join(' and ')} is on the project's guarded list (guard.commands in .jackherd/config.json), so the user confirms it.`,
|
|
451
|
+
]
|
|
452
|
+
|
|
453
|
+
const askBecause = (findings: Finding[], notes: string[], script: string): MergeGuardDecision => {
|
|
454
|
+
const guarded = findings
|
|
455
|
+
.filter(({ definition, note }) => definition === undefined && note === undefined)
|
|
456
|
+
.map(({ command }) => command)
|
|
457
|
+
const definitions = [
|
|
458
|
+
...new Map(
|
|
459
|
+
findings.flatMap(({ definition }) => (definition ? [[definition.name, definition] as const] : [])),
|
|
460
|
+
).values(),
|
|
461
|
+
]
|
|
462
|
+
return {
|
|
463
|
+
kind: 'ask',
|
|
464
|
+
reason: [
|
|
465
|
+
...guardedListSentence([...new Set(guarded)]),
|
|
466
|
+
...definitions.map(
|
|
467
|
+
(definition) =>
|
|
468
|
+
`${shellDefinitionHint(definition)} It may run a command of guard.commands in .jackherd/config.json, so the user confirms it.`,
|
|
469
|
+
),
|
|
470
|
+
...new Set(findings.flatMap(({ note }) => note ?? [])),
|
|
471
|
+
...notes,
|
|
472
|
+
expandingHereDocumentAdvice(script),
|
|
473
|
+
]
|
|
474
|
+
.filter((part) => part !== '')
|
|
475
|
+
.join(' '),
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Quotes split a word (`g''h`), a variable builds it, an expansion hides a command: in text the parse cannot follow,
|
|
481
|
+
* no word search can tell whether a guarded command runs, so every unreadable script asks (fail closed).
|
|
482
|
+
*/
|
|
483
|
+
const askUnreadable = ({ message }: ShellParseError, script: string): MergeGuardDecision => ({
|
|
271
484
|
kind: 'ask',
|
|
272
|
-
reason:
|
|
485
|
+
reason: [
|
|
486
|
+
`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.`,
|
|
487
|
+
UNREADABLE_ADVICE,
|
|
488
|
+
expandingHereDocumentAdvice(script),
|
|
489
|
+
]
|
|
490
|
+
.filter((part) => part !== '')
|
|
491
|
+
.join(' '),
|
|
273
492
|
})
|
|
274
493
|
|
|
494
|
+
const findingsOrUnreadable = (command: string, search: Search): Finding[] | ShellParseError => {
|
|
495
|
+
try {
|
|
496
|
+
return callsIn(command, search, true, command)
|
|
497
|
+
} catch (error) {
|
|
498
|
+
if (error instanceof ShellParseError) return error
|
|
499
|
+
throw error
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
275
503
|
/**
|
|
276
504
|
* 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
|
-
*
|
|
505
|
+
* `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
|
|
506
|
+
* verb, a chain, a wrapper, `sh -c`, `eval`, a script piped into a shell, an interpreter's program text, a shell alias
|
|
507
|
+
* or function, or a program held in a variable. A script that is exactly `gh pr merge <number>` with merge-method and
|
|
508
|
+
* delete-branch flags and at most one `--match-head-commit <sha>` passes when the user approved it
|
|
509
|
+
* (`isMergeApproved`); any other shape of the merge, and every other guarded command, asks. A command that merely
|
|
510
|
+
* names the words (an argument, a commit message) is none. A script the parse cannot read asks whatever it names,
|
|
511
|
+
* unless the list is empty.
|
|
281
512
|
*/
|
|
282
513
|
export const decideBashCommand = (command: string, policy: GuardPolicy): MergeGuardDecision => {
|
|
283
|
-
|
|
514
|
+
if (policy.commands.length === 0) return PASS
|
|
515
|
+
const search = { commands: policy.commands, definitions: policy.shellDefinitions ?? (() => NO_DEFINITIONS) }
|
|
516
|
+
const findings = findingsOrUnreadable(command, search)
|
|
517
|
+
if (findings instanceof ShellParseError) return askUnreadable(findings, command)
|
|
284
518
|
if (findings.length === 0) return PASS
|
|
285
|
-
const unapproved = findings
|
|
286
|
-
|
|
519
|
+
const unapproved = findings
|
|
520
|
+
.map((finding) => ({ finding, approval: approvalOf(finding, policy) }))
|
|
521
|
+
.filter(({ approval }) => approval !== true)
|
|
522
|
+
if (unapproved.length === 0) return PASS
|
|
523
|
+
const notes = unapproved.flatMap(({ approval }) => (typeof approval === 'object' ? [approval.note] : []))
|
|
524
|
+
return askBecause(
|
|
525
|
+
unapproved.map(({ finding }) => finding),
|
|
526
|
+
[...new Set(notes)],
|
|
527
|
+
command,
|
|
528
|
+
)
|
|
287
529
|
}
|