@goodandready/dsh-github-ops 0.1.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 +81 -0
- package/LICENSE +21 -0
- package/README.md +163 -0
- package/README.ru.md +161 -0
- package/README.zh.md +151 -0
- package/cordis.patch.yml +3 -0
- package/lib/approval.js +168 -0
- package/lib/auth.js +59 -0
- package/lib/client.js +304 -0
- package/lib/commands.js +151 -0
- package/lib/github.js +315 -0
- package/lib/index.js +332 -0
- package/lib/jobs.js +107 -0
- package/lib/present.js +214 -0
- package/lib/repo-context.js +72 -0
- package/lib/review-rules.js +147 -0
- package/lib/tools/actions.js +107 -0
- package/lib/tools/api.js +44 -0
- package/lib/tools/issues.js +101 -0
- package/lib/tools/mirror.js +163 -0
- package/lib/tools/pulls.js +207 -0
- package/lib/tools/register-collab.js +233 -0
- package/lib/tools/register-core.js +259 -0
- package/lib/tools/register-jobs.js +57 -0
- package/lib/tools/register-mirror.js +88 -0
- package/lib/tools/register-ops.js +283 -0
- package/lib/tools/releases.js +111 -0
- package/lib/tools/repo-settings.js +207 -0
- package/lib/tools/repos.js +108 -0
- package/lib/tools/tags.js +68 -0
- package/package.json +73 -0
package/lib/approval.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Who approves a GitHub write, and why.
|
|
2
|
+
//
|
|
3
|
+
// Every state-changing tool maps to a named action. The action must be in `allowedActions`
|
|
4
|
+
// (otherwise it is denied outright), a destructive action is always asked even in an
|
|
5
|
+
// unattended run, and anything else asks the human with a sentence that says what is about
|
|
6
|
+
// to happen — not just "perform a GitHub write".
|
|
7
|
+
//
|
|
8
|
+
// `decide()` is pure: the policy is a function of the call and the settings, so it is fully
|
|
9
|
+
// testable and the listener stays trivial.
|
|
10
|
+
|
|
11
|
+
/** Tool → action name. A tool absent from this map is not a write. */
|
|
12
|
+
export const ACTION_BY_TOOL = {
|
|
13
|
+
gh_release_create: 'release-create',
|
|
14
|
+
gh_release_edit: 'release-edit',
|
|
15
|
+
gh_release_delete: 'release-delete',
|
|
16
|
+
gh_tag_create: 'tag-create',
|
|
17
|
+
gh_tag_delete: 'tag-delete',
|
|
18
|
+
gh_repo_create: 'repo-create',
|
|
19
|
+
gh_repo_edit: 'repo-edit',
|
|
20
|
+
issue_open: 'issue-open',
|
|
21
|
+
issue_comment: 'issue-comment',
|
|
22
|
+
issue_close: 'issue-close',
|
|
23
|
+
pr_create: 'pr-create',
|
|
24
|
+
pr_update: 'pr-update',
|
|
25
|
+
pr_merge: 'pr-merge',
|
|
26
|
+
review_post: 'review-post',
|
|
27
|
+
gh_variable_set: 'variable-set',
|
|
28
|
+
gh_variable_delete: 'variable-delete',
|
|
29
|
+
gh_secret_set: 'secret-set',
|
|
30
|
+
gh_secret_delete: 'secret-delete',
|
|
31
|
+
gh_ruleset_apply: 'ruleset-apply',
|
|
32
|
+
gh_ruleset_delete: 'ruleset-delete',
|
|
33
|
+
gh_branch_protection_set: 'branch-protection-set',
|
|
34
|
+
gh_branch_protection_delete: 'branch-protection-delete',
|
|
35
|
+
gh_run_rerun: 'run-rerun',
|
|
36
|
+
gh_run_cancel: 'run-cancel',
|
|
37
|
+
gh_mirror_publish: 'mirror-publish',
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Actions a single confirmation cannot make safe: always asked, never auto-approved. */
|
|
41
|
+
export const DESTRUCTIVE_ACTIONS = new Set([
|
|
42
|
+
'release-delete', 'tag-delete', 'variable-delete', 'secret-delete',
|
|
43
|
+
'ruleset-delete', 'branch-protection-delete', 'run-cancel',
|
|
44
|
+
])
|
|
45
|
+
|
|
46
|
+
export const ALL_ACTIONS = Object.freeze([...new Set(Object.values(ACTION_BY_TOOL))])
|
|
47
|
+
|
|
48
|
+
const MUTATING_METHODS = new Set(['POST', 'PATCH', 'PUT', 'DELETE'])
|
|
49
|
+
|
|
50
|
+
/** Which action a call is, or null when it changes nothing. */
|
|
51
|
+
export function actionFor(exec) {
|
|
52
|
+
const name = exec && exec.name
|
|
53
|
+
if (!name) return null
|
|
54
|
+
if (name === 'gh_api') {
|
|
55
|
+
const args = (exec && exec.arguments) || {}
|
|
56
|
+
if (args.graphql) return null // a query may still mutate; the GraphQL text cannot be judged here
|
|
57
|
+
const method = String(args.method || 'GET').toUpperCase()
|
|
58
|
+
return MUTATING_METHODS.has(method) ? 'api-call' : null
|
|
59
|
+
}
|
|
60
|
+
return ACTION_BY_TOOL[name] || null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A sentence a human can decide on. */
|
|
64
|
+
export function askReason(exec) {
|
|
65
|
+
const args = (exec && exec.arguments) || {}
|
|
66
|
+
const repo = args.repository || 'the default repository'
|
|
67
|
+
const name = exec && exec.name
|
|
68
|
+
switch (name) {
|
|
69
|
+
case 'gh_release_create':
|
|
70
|
+
return `create GitHub release ${args.tag}${args.draft ? ' (draft)' : ''} in ${repo}`
|
|
71
|
+
case 'gh_release_edit':
|
|
72
|
+
return `edit GitHub release ${args.tag || args.releaseId} in ${repo}${args.makeLatest ? ' and mark it latest' : ''}`
|
|
73
|
+
case 'gh_release_delete':
|
|
74
|
+
return `delete GitHub release ${args.tag || args.releaseId} in ${repo} (the git tag stays)`
|
|
75
|
+
case 'gh_tag_create':
|
|
76
|
+
return `create tag ${args.tag} in ${repo}`
|
|
77
|
+
case 'gh_tag_delete':
|
|
78
|
+
return `delete tag ${args.tag} in ${repo}`
|
|
79
|
+
case 'gh_repo_create':
|
|
80
|
+
return `create repository ${args.name}${args.org ? ` in ${args.org}` : ''}${args.private === false ? ' as PUBLIC' : ' (private)'}`
|
|
81
|
+
case 'gh_repo_edit':
|
|
82
|
+
return `change settings of ${repo}${args.private !== undefined ? ` (visibility: ${args.private ? 'private' : 'PUBLIC'})` : ''}`
|
|
83
|
+
case 'issue_open':
|
|
84
|
+
return `open issue "${args.title}" in ${repo}`
|
|
85
|
+
case 'issue_comment':
|
|
86
|
+
return `comment on #${args.number} in ${repo}`
|
|
87
|
+
case 'issue_close':
|
|
88
|
+
return `close #${args.number} in ${repo}`
|
|
89
|
+
case 'pr_create':
|
|
90
|
+
return `open pull request "${args.title}" (${args.head} → ${args.base || 'main'}) in ${repo}`
|
|
91
|
+
case 'pr_update':
|
|
92
|
+
return `update pull request #${args.number} in ${repo}`
|
|
93
|
+
case 'pr_merge':
|
|
94
|
+
return `MERGE pull request #${args.number} in ${repo} (${args.method || 'merge'})`
|
|
95
|
+
case 'review_post':
|
|
96
|
+
return `post a review on #${args.number} in ${repo}`
|
|
97
|
+
case 'gh_variable_set':
|
|
98
|
+
return `set variable ${args.name} in ${repo}`
|
|
99
|
+
case 'gh_variable_delete':
|
|
100
|
+
return `delete variable ${args.name} in ${repo}`
|
|
101
|
+
case 'gh_secret_set':
|
|
102
|
+
return `set secret ${args.name} in ${repo}`
|
|
103
|
+
case 'gh_secret_delete':
|
|
104
|
+
return `delete secret ${args.name} in ${repo}`
|
|
105
|
+
case 'gh_ruleset_apply':
|
|
106
|
+
return `apply ruleset ${args.name || args.rulesetId} to ${repo}`
|
|
107
|
+
case 'gh_ruleset_delete':
|
|
108
|
+
return `delete ruleset ${args.rulesetId} from ${repo}`
|
|
109
|
+
case 'gh_branch_protection_set':
|
|
110
|
+
return `replace branch protection on ${args.branch} in ${repo}`
|
|
111
|
+
case 'gh_branch_protection_delete':
|
|
112
|
+
return `remove branch protection from ${args.branch} in ${repo}`
|
|
113
|
+
case 'gh_run_rerun':
|
|
114
|
+
return `re-run workflow run ${args.runId} in ${repo}`
|
|
115
|
+
case 'gh_run_cancel':
|
|
116
|
+
return `cancel workflow run ${args.runId} in ${repo}`
|
|
117
|
+
case 'gh_mirror_publish':
|
|
118
|
+
return `publish a sanitized tree to the mirror of ${args.cwd || 'the checkout'} (${args.mirror || 'github'}/${args.branch || 'main'})`
|
|
119
|
+
case 'gh_api':
|
|
120
|
+
return `call the GitHub API: ${String(args.method || 'GET').toUpperCase()} ${args.path}`
|
|
121
|
+
default:
|
|
122
|
+
return `perform a GitHub write action (${name})`
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Whether an unattended run may proceed without a human. */
|
|
127
|
+
export function isUnattended(env = process.env) {
|
|
128
|
+
return String((env && env.DSH_GITHUB_OPS_UNATTENDED) || '') === '1'
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The policy, as a pure function.
|
|
133
|
+
*
|
|
134
|
+
* @returns {{kind: 'allow'|'ask'|'deny'|'continue', reason?: string, action?: string|null}}
|
|
135
|
+
*/
|
|
136
|
+
export function decide({ exec, allowedActions, autoApprove, unattended = false } = {}) {
|
|
137
|
+
const action = actionFor(exec)
|
|
138
|
+
if (!action) return { kind: 'continue', action: null }
|
|
139
|
+
|
|
140
|
+
const allowed = Array.isArray(allowedActions) && allowedActions.length ? allowedActions : ALL_ACTIONS
|
|
141
|
+
if (!allowed.includes(action)) {
|
|
142
|
+
return { kind: 'deny', action, reason: `dsh-github-ops: action "${action}" is not in allowedActions` }
|
|
143
|
+
}
|
|
144
|
+
if (unattended && !DESTRUCTIVE_ACTIONS.has(action) && Array.isArray(autoApprove) && autoApprove.includes(action)) {
|
|
145
|
+
return { kind: 'allow', action }
|
|
146
|
+
}
|
|
147
|
+
const destructive = DESTRUCTIVE_ACTIONS.has(action) ? ' This cannot be undone here.' : ''
|
|
148
|
+
return { kind: 'ask', action, reason: `dsh-github-ops: ${askReason(exec)}.${destructive}` }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Register the gate. Registration is an effect: disposing the plugin removes the listener.
|
|
153
|
+
*/
|
|
154
|
+
export function registerApprovalGate(ctx, getConfig) {
|
|
155
|
+
return ctx.on('tools/pre-execute', async (exec, next) => {
|
|
156
|
+
const cfg = (typeof getConfig === 'function' ? getConfig() : getConfig) || {}
|
|
157
|
+
const decision = decide({
|
|
158
|
+
exec,
|
|
159
|
+
allowedActions: cfg.allowedActions,
|
|
160
|
+
autoApprove: cfg.autoApprove,
|
|
161
|
+
unattended: cfg.unattended !== undefined ? cfg.unattended : isUnattended(),
|
|
162
|
+
})
|
|
163
|
+
if (decision.kind === 'continue') return next()
|
|
164
|
+
if (decision.kind === 'allow') return { kind: 'allow' }
|
|
165
|
+
if (decision.kind === 'deny') return { kind: 'deny', reason: decision.reason }
|
|
166
|
+
return { kind: 'ask', reason: decision.reason }
|
|
167
|
+
})
|
|
168
|
+
}
|
package/lib/auth.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Access resolution across the sources a GitHub user actually has.
|
|
2
|
+
//
|
|
3
|
+
// Order (tokenSource: "auto"): the DSH credentials service, then the environment
|
|
4
|
+
// variable of the same name, then the `gh` CLI session. The credential service stays
|
|
5
|
+
// the recommended source — it is the only one DSH manages and rotates — but a machine
|
|
6
|
+
// with `gh auth login` already done should not need a second copy of the same secret.
|
|
7
|
+
//
|
|
8
|
+
// The value never leaves this module except as the returned string: it is never written
|
|
9
|
+
// to logs, error messages, model-visible text or session events.
|
|
10
|
+
|
|
11
|
+
export const NO_ACCESS_GUIDANCE = 'Configure one of: 1) a DSH credential with this name '
|
|
12
|
+
+ '($DSH_HOME/.credentials.yaml) — recommended, 2) the same-named environment variable, '
|
|
13
|
+
+ '3) `gh auth login` for the gh CLI. Set tokenSource in the plugin settings to pin one source.'
|
|
14
|
+
|
|
15
|
+
export const ACCESS_SOURCES = ['credentials', 'env', 'gh']
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve access for one operation. Nothing is cached: a rotated credential reaches the
|
|
19
|
+
* very next call.
|
|
20
|
+
*
|
|
21
|
+
* @param credentials - the credentials service (needs `resolve`)
|
|
22
|
+
* @param tokenEnv - credential / environment-variable name, e.g. GITHUB_TOKEN
|
|
23
|
+
* @param tokenSource - 'auto' | 'credentials' | 'env' | 'gh'
|
|
24
|
+
* @param env - environment map (defaults to process.env)
|
|
25
|
+
* @param runGh - async () => string; returns the gh CLI access value, '' when unavailable
|
|
26
|
+
* @returns {Promise<{ value: string, source: string, guidance: string }>}
|
|
27
|
+
*/
|
|
28
|
+
export async function resolveAccess({ credentials, tokenEnv, tokenSource = 'auto', env = process.env, runGh } = {}) {
|
|
29
|
+
const order = tokenSource === 'auto' ? ACCESS_SOURCES : [tokenSource]
|
|
30
|
+
for (const source of order) {
|
|
31
|
+
if (source === 'credentials') {
|
|
32
|
+
try {
|
|
33
|
+
const resolved = credentials && typeof credentials.resolve === 'function'
|
|
34
|
+
? await credentials.resolve(tokenEnv)
|
|
35
|
+
: null
|
|
36
|
+
const value = resolved && resolved.value ? String(resolved.value) : ''
|
|
37
|
+
if (value) return { value, source, guidance: '' }
|
|
38
|
+
} catch {
|
|
39
|
+
// an unset or unknown credential is a normal miss, not an error
|
|
40
|
+
}
|
|
41
|
+
continue
|
|
42
|
+
}
|
|
43
|
+
if (source === 'env') {
|
|
44
|
+
const value = env && tokenEnv ? String(env[tokenEnv] || '') : ''
|
|
45
|
+
if (value) return { value, source, guidance: '' }
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
if (source === 'gh') {
|
|
49
|
+
let value = ''
|
|
50
|
+
try {
|
|
51
|
+
value = runGh ? String((await runGh()) || '').trim() : ''
|
|
52
|
+
} catch {
|
|
53
|
+
value = ''
|
|
54
|
+
}
|
|
55
|
+
if (value) return { value, source, guidance: '' }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { value: '', source: '', guidance: NO_ACCESS_GUIDANCE }
|
|
59
|
+
}
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// dsh-github-ops — browser half: the plugin settings card.
|
|
2
|
+
//
|
|
3
|
+
// The card lives on the plugin's own page (the plugin-list seat `plugins.item`), the
|
|
4
|
+
// seat the current core renders, with the legacy `settings.plugin.item` card kept for
|
|
5
|
+
// older cores. The page view renders bare: the host draws the title, the icon, the
|
|
6
|
+
// crumb and the padding, so the form must not add a second frame.
|
|
7
|
+
//
|
|
8
|
+
// Source language is English; Simplified Chinese ships as the second locale. Russian is
|
|
9
|
+
// provided by dsh-russian-lang at runtime and is never hardcoded here.
|
|
10
|
+
|
|
11
|
+
window.__ModuleLoader__.load({
|
|
12
|
+
id: '@goodandready/dsh-github-ops',
|
|
13
|
+
factory: (require) => {
|
|
14
|
+
var module = { exports: {} }
|
|
15
|
+
var exports = module.exports
|
|
16
|
+
const React = require('react')
|
|
17
|
+
|
|
18
|
+
const NS = 'dsh-github-ops'
|
|
19
|
+
const ROW_ID = 'dsh-github-ops'
|
|
20
|
+
const PKG = '@goodandready/dsh-github-ops'
|
|
21
|
+
const ROW_CONFIG_KEY = PKG + '#' + ROW_ID
|
|
22
|
+
|
|
23
|
+
const en = {
|
|
24
|
+
title: 'GitHub ops',
|
|
25
|
+
sub: 'Releases, tags, a guarded API pass-through, mirror publication and repository settings.',
|
|
26
|
+
cardHint: 'Token from DSH credentials; settings keep only its name.',
|
|
27
|
+
tokenEnv: 'Access name',
|
|
28
|
+
tokenSource: 'Access source',
|
|
29
|
+
tokenSourceHint: '"auto" tries the DSH credential, then the environment variable, then the gh CLI session. Pin it to use one source only.',
|
|
30
|
+
tokenEnvHint: 'Name looked up in the DSH credentials, then the environment, then the gh CLI. The value is never stored here.',
|
|
31
|
+
defaultRepository: 'Default repository',
|
|
32
|
+
defaultRepositoryHint: 'owner/repo used when a tool call omits the repository. Leave empty to always pass it explicitly.',
|
|
33
|
+
baseUrl: 'API base URL',
|
|
34
|
+
baseUrlHint: 'Change it for GitHub Enterprise; the default is the public API.',
|
|
35
|
+
timeoutMs: 'Request timeout, ms',
|
|
36
|
+
maxRetries: 'Retries for a failed read',
|
|
37
|
+
maxRetriesHint: 'A read (GET/HEAD) is retried on a network failure, a timeout or a 5xx. Writes are never retried automatically.',
|
|
38
|
+
reviewRules: 'Review rule overrides (JSON)',
|
|
39
|
+
reviewRulesHint: 'Optional. sensitivePaths, sensitiveSeverity, attentionPaths, migrationPaths, testsRequired, sourcePatterns, testPatterns, largeDiffLines. Example: {"largeDiffLines": 400, "sensitivePaths": [".env", "private/**"]}',
|
|
40
|
+
timeoutMsHint: 'How long one GitHub request may take before it is aborted.',
|
|
41
|
+
statusLoading: 'Reading settings…',
|
|
42
|
+
statusUnavailable: 'Settings service is not available in this build: values cannot be edited here.',
|
|
43
|
+
statusReadOnly: 'Settings are read-only for this session.',
|
|
44
|
+
save: 'Save',
|
|
45
|
+
saving: 'Saving…',
|
|
46
|
+
saved: 'Saved',
|
|
47
|
+
savedPartial: 'Saved, except: ',
|
|
48
|
+
noChanges: 'Nothing to change.',
|
|
49
|
+
invalidNumber: 'Enter a positive number.',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const zh = {
|
|
53
|
+
title: 'GitHub 操作',
|
|
54
|
+
sub: '发布、标签、受保护的 API 直通、镜像发布与仓库设置。',
|
|
55
|
+
cardHint: '令牌来自 DSH 凭据服务;设置里只保存凭据名称。',
|
|
56
|
+
tokenEnv: '访问名称',
|
|
57
|
+
tokenSource: '访问来源',
|
|
58
|
+
tokenSourceHint: '"auto" 依次尝试 DSH 凭据、环境变量与 gh CLI 会话;固定为某一项则只使用该来源。',
|
|
59
|
+
tokenEnvHint: '依次在 DSH 凭据、环境变量与 gh CLI 中查找该名称;取值本身不会保存在这里。',
|
|
60
|
+
defaultRepository: '默认仓库',
|
|
61
|
+
defaultRepositoryHint: '调用未指定仓库时使用的 owner/repo;留空表示每次都必须显式传入。',
|
|
62
|
+
baseUrl: 'API 地址',
|
|
63
|
+
baseUrlHint: 'GitHub Enterprise 时修改;默认使用公共 API。',
|
|
64
|
+
timeoutMs: '请求超时(毫秒)',
|
|
65
|
+
maxRetries: '读取失败重试次数',
|
|
66
|
+
maxRetriesHint: '读取(GET/HEAD)在网络故障、超时或 5xx 时重试;写入不会自动重试。',
|
|
67
|
+
reviewRules: '评审规则覆盖(JSON)',
|
|
68
|
+
reviewRulesHint: '可选。sensitivePaths、sensitiveSeverity、attentionPaths、migrationPaths、testsRequired、sourcePatterns、testPatterns、largeDiffLines。',
|
|
69
|
+
timeoutMsHint: '单个 GitHub 请求在被中断前允许的时长。',
|
|
70
|
+
statusLoading: '正在读取设置…',
|
|
71
|
+
statusUnavailable: '当前构建没有设置服务:这里无法编辑取值。',
|
|
72
|
+
statusReadOnly: '本次会话中设置为只读。',
|
|
73
|
+
save: '保存',
|
|
74
|
+
saving: '保存中…',
|
|
75
|
+
saved: '已保存',
|
|
76
|
+
savedPartial: '已保存,除了:',
|
|
77
|
+
noChanges: '没有需要修改的内容。',
|
|
78
|
+
invalidNumber: '请输入正数。',
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let ChevronIcon = null
|
|
82
|
+
try {
|
|
83
|
+
const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
84
|
+
ChevronIcon = primitives && (primitives.IconChevronDownOutline14 || primitives.IconChevronDownOutline)
|
|
85
|
+
} catch {
|
|
86
|
+
ChevronIcon = null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const FALLBACK_CHEVRON = React.createElement(
|
|
90
|
+
'svg',
|
|
91
|
+
{ width: 14, height: 14, viewBox: '0 0 14 14', fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, 'aria-hidden': 'true' },
|
|
92
|
+
React.createElement('path', { d: 'M3.5 5.25L7 8.75L10.5 5.25' }),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
let cssInstalled = false
|
|
96
|
+
function ensureCss() {
|
|
97
|
+
if (cssInstalled || typeof document === 'undefined') return
|
|
98
|
+
cssInstalled = true
|
|
99
|
+
if (document.querySelector('style[data-dsh-plugin="dsh-github-ops"]')) return
|
|
100
|
+
const style = document.createElement('style')
|
|
101
|
+
style.dataset.dshPlugin = 'dsh-github-ops'
|
|
102
|
+
style.textContent = [
|
|
103
|
+
'.gho-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none}',
|
|
104
|
+
'.gho-head{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;display:flex;align-items:center;gap:12px;padding:14px 16px}',
|
|
105
|
+
'.gho-title{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}',
|
|
106
|
+
'.gho-sub{color:var(--dsw-alias-label-secondary);font-size:13px}',
|
|
107
|
+
'.gho-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}',
|
|
108
|
+
'.gho-field{display:flex;flex-direction:column;gap:6px;padding:12px 0}',
|
|
109
|
+
'.gho-label{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary)}',
|
|
110
|
+
'.gho-hint{font-size:12px;color:var(--dsw-alias-label-secondary)}',
|
|
111
|
+
'.gho-input-area{height:auto;min-height:64px;padding:8px 12px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;resize:vertical}',
|
|
112
|
+
'.gho-input{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px}',
|
|
113
|
+
'.gho-foot{border-top:1px solid var(--dsw-alias-border-l2);display:flex;justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px}',
|
|
114
|
+
'.gho-save{appearance:none;font:inherit;cursor:pointer;border:1px solid transparent;border-radius:8px;padding:5px 14px;font-size:13px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}',
|
|
115
|
+
'.gho-save[disabled]{opacity:.6;cursor:default}',
|
|
116
|
+
'.gho-note{font-size:12px;color:var(--dsw-alias-label-secondary);margin-right:auto}',
|
|
117
|
+
'.gho-chev{margin-left:auto;flex:none;color:var(--dsw-alias-label-tertiary);transition:transform .16s}',
|
|
118
|
+
'.gho-chev-open{transform:rotate(180deg)}',
|
|
119
|
+
].join('\n')
|
|
120
|
+
document.head.appendChild(style)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function makeTranslator(locale) {
|
|
124
|
+
const dict = String(locale || '').startsWith('zh') ? zh : en
|
|
125
|
+
const fallback = en
|
|
126
|
+
return (key) => dict[key] || fallback[key] || key
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Settings form. It checks the snapshot STATUS, not the value: while the host has
|
|
131
|
+
* not answered, or does not know the namespace, the fields must not look editable.
|
|
132
|
+
*/
|
|
133
|
+
function GitHubOpsCard(props) {
|
|
134
|
+
ensureCss()
|
|
135
|
+
const ctx = props.ctx
|
|
136
|
+
const page = !!(props && props.view === 'page')
|
|
137
|
+
const t = props.t || makeTranslator(props.locale)
|
|
138
|
+
const [open, setOpen] = React.useState(!!page)
|
|
139
|
+
const [snapshot, setSnapshot] = React.useState({ status: 'loading', value: {} })
|
|
140
|
+
const [draft, setDraft] = React.useState(null)
|
|
141
|
+
const [saving, setSaving] = React.useState(false)
|
|
142
|
+
const [message, setMessage] = React.useState('')
|
|
143
|
+
|
|
144
|
+
let scope = null
|
|
145
|
+
try {
|
|
146
|
+
scope = ctx && ctx.settingsScope && ctx.settingsScope.bind({ namespace: NS })
|
|
147
|
+
} catch {
|
|
148
|
+
scope = null
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
React.useEffect(() => {
|
|
152
|
+
if (!scope || typeof scope.subscribe !== 'function') {
|
|
153
|
+
setSnapshot({ status: 'unavailable', value: {} })
|
|
154
|
+
return undefined
|
|
155
|
+
}
|
|
156
|
+
const read = () => {
|
|
157
|
+
const next = scope.getSnapshot()
|
|
158
|
+
setSnapshot(next || { status: 'unavailable', value: {} })
|
|
159
|
+
setDraft((current) => current || { ...(next && next.value ? next.value : {}) })
|
|
160
|
+
}
|
|
161
|
+
read()
|
|
162
|
+
return scope.subscribe(read)
|
|
163
|
+
}, [scope])
|
|
164
|
+
|
|
165
|
+
if (props && props.view === 'summary') {
|
|
166
|
+
return React.createElement('div', { className: 'gho-sub' }, t('sub'))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const status = snapshot.status
|
|
170
|
+
const value = snapshot.value || {}
|
|
171
|
+
const ready = status === 'ready'
|
|
172
|
+
const writable = ready && snapshot.writable !== false
|
|
173
|
+
const current = draft || { ...value }
|
|
174
|
+
|
|
175
|
+
const change = (key, next) => {
|
|
176
|
+
setMessage('')
|
|
177
|
+
setDraft({ ...current, [key]: next })
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const save = async () => {
|
|
181
|
+
if (!scope || typeof scope.set !== 'function') return
|
|
182
|
+
setSaving(true)
|
|
183
|
+
const failed = []
|
|
184
|
+
const keys = ['tokenEnv', 'tokenSource', 'defaultRepository', 'baseUrl', 'timeoutMs', 'maxRetries', 'reviewRulesJson']
|
|
185
|
+
for (const key of keys) {
|
|
186
|
+
if (current[key] === value[key]) continue
|
|
187
|
+
let next = current[key]
|
|
188
|
+
if (key === 'timeoutMs' || key === 'maxRetries') {
|
|
189
|
+
const numeric = Number(next)
|
|
190
|
+
if (!Number.isFinite(numeric) || numeric <= 0) {
|
|
191
|
+
failed.push(`${t(key)}: ${t('invalidNumber')}`)
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
next = numeric
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
await scope.set(key, next)
|
|
198
|
+
} catch (err) {
|
|
199
|
+
failed.push(`${key}: ${(err && err.message) || err}`)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
setSaving(false)
|
|
203
|
+
setMessage(failed.length ? t('savedPartial') + failed.join('; ') : t('saved'))
|
|
204
|
+
setDraft({ ...current })
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const field = (key, labelKey, hintKey, kind) => React.createElement('div', { className: 'gho-field' },
|
|
208
|
+
React.createElement('label', { className: 'gho-label', htmlFor: `${NS}-${key}` }, t(labelKey)),
|
|
209
|
+
React.createElement('div', { className: 'gho-hint' }, t(hintKey)),
|
|
210
|
+
kind === 'json'
|
|
211
|
+
? React.createElement('textarea', {
|
|
212
|
+
id: `${NS}-${key}`,
|
|
213
|
+
className: 'gho-input gho-input-area',
|
|
214
|
+
rows: 3,
|
|
215
|
+
spellCheck: false,
|
|
216
|
+
value: current[key] === undefined || current[key] === null ? '' : String(current[key]),
|
|
217
|
+
disabled: !writable,
|
|
218
|
+
onChange: (event) => change(key, event.target.value),
|
|
219
|
+
})
|
|
220
|
+
: React.createElement('input', {
|
|
221
|
+
id: `${NS}-${key}`,
|
|
222
|
+
className: 'gho-input',
|
|
223
|
+
type: kind === 'number' ? 'number' : 'text',
|
|
224
|
+
value: current[key] === undefined || current[key] === null ? '' : String(current[key]),
|
|
225
|
+
disabled: !writable,
|
|
226
|
+
onChange: (event) => change(key, event.target.value),
|
|
227
|
+
}),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
const body = React.createElement('div', { className: 'gho-body' },
|
|
231
|
+
status === 'loading' ? React.createElement('div', { className: 'gho-hint' }, t('statusLoading')) : null,
|
|
232
|
+
status === 'unavailable' ? React.createElement('div', { className: 'gho-hint' }, t('statusUnavailable')) : null,
|
|
233
|
+
ready && snapshot.writable === false ? React.createElement('div', { className: 'gho-hint' }, t('statusReadOnly')) : null,
|
|
234
|
+
field('tokenEnv', 'tokenEnv', 'tokenEnvHint'),
|
|
235
|
+
field('tokenSource', 'tokenSource', 'tokenSourceHint'),
|
|
236
|
+
field('defaultRepository', 'defaultRepository', 'defaultRepositoryHint'),
|
|
237
|
+
field('baseUrl', 'baseUrl', 'baseUrlHint'),
|
|
238
|
+
field('timeoutMs', 'timeoutMs', 'timeoutMsHint', 'number'),
|
|
239
|
+
field('maxRetries', 'maxRetries', 'maxRetriesHint', 'number'),
|
|
240
|
+
field('reviewRulesJson', 'reviewRules', 'reviewRulesHint', 'json'),
|
|
241
|
+
React.createElement('div', { className: 'gho-foot' },
|
|
242
|
+
React.createElement('span', { className: 'gho-note' }, message),
|
|
243
|
+
React.createElement('button', {
|
|
244
|
+
type: 'button',
|
|
245
|
+
className: 'gho-save',
|
|
246
|
+
disabled: !writable || saving,
|
|
247
|
+
onClick: save,
|
|
248
|
+
}, saving ? t('saving') : t('save')),
|
|
249
|
+
),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
return React.createElement(page ? 'div' : 'li', { className: page ? 'gho-page' : 'gho-card' },
|
|
253
|
+
React.createElement('button', {
|
|
254
|
+
type: 'button',
|
|
255
|
+
className: 'gho-head',
|
|
256
|
+
style: page ? { display: 'none' } : undefined,
|
|
257
|
+
'aria-expanded': page ? true : open,
|
|
258
|
+
onClick: () => setOpen((v) => !v),
|
|
259
|
+
},
|
|
260
|
+
React.createElement('span', { className: 'gho-head-text' },
|
|
261
|
+
React.createElement('span', { className: 'gho-title' }, t('title')),
|
|
262
|
+
React.createElement('span', { className: 'gho-sub' }, t('cardHint')),
|
|
263
|
+
),
|
|
264
|
+
React.createElement('span', { className: 'gho-chev' + (open ? ' gho-chev-open' : '') }, ChevronIcon ? React.createElement(ChevronIcon) : FALLBACK_CHEVRON),
|
|
265
|
+
),
|
|
266
|
+
(page || open) ? body : null,
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function apply(ctx) {
|
|
271
|
+
try {
|
|
272
|
+
if (ctx.locale && typeof ctx.locale.register === 'function') {
|
|
273
|
+
ctx.locale.register(NS, { en, zh })
|
|
274
|
+
}
|
|
275
|
+
} catch {
|
|
276
|
+
/* dictionary already registered */
|
|
277
|
+
}
|
|
278
|
+
const seats = [
|
|
279
|
+
{ name: 'plugins.item', key: null, id: ROW_ID, order: 60, label: () => 'GitHub ops' },
|
|
280
|
+
{ name: 'plugins.row.config', key: ROW_CONFIG_KEY },
|
|
281
|
+
{ name: 'settings.plugin.item', key: NS },
|
|
282
|
+
]
|
|
283
|
+
for (const seat of seats) {
|
|
284
|
+
try {
|
|
285
|
+
ctx.slots.inject(seat.name, () => {
|
|
286
|
+
const entry = { name: seat.name, locale: NS, inject: () => ({ ctx }) }
|
|
287
|
+
if (seat.key) entry.key = seat.key
|
|
288
|
+
if (seat.id) entry.id = seat.id
|
|
289
|
+
if (seat.order) entry.order = seat.order
|
|
290
|
+
if (seat.label) entry.label = seat.label
|
|
291
|
+
return ctx.slots.register(entry, GitHubOpsCard)
|
|
292
|
+
})
|
|
293
|
+
} catch {
|
|
294
|
+
/* a core without this seat: the remaining seats still carry the card */
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
exports.inject = ['slots', 'locale', 'settingsScope']
|
|
300
|
+
exports.apply = apply
|
|
301
|
+
exports.GitHubOpsCard = GitHubOpsCard
|
|
302
|
+
return module.exports
|
|
303
|
+
},
|
|
304
|
+
})
|