diff --git a/packages/channels/base/src/types.ts b/packages/channels/base/src/types.ts index ebf2bb0b23b..f5338cc5646 100644 --- a/packages/channels/base/src/types.ts +++ b/packages/channels/base/src/types.ts @@ -384,7 +384,9 @@ export type ChannelConfigFieldKind = | 'secret' | 'boolean' | 'number' - | 'enum'; + | 'enum' + | 'string-list' + | 'record'; export interface ChannelConfigFieldDescriptor { key: string; @@ -393,6 +395,7 @@ export interface ChannelConfigFieldDescriptor { required?: boolean; envResolvable?: boolean; options?: ReadonlyArray<{ value: string; label: string }>; + default?: string; description?: string; } diff --git a/packages/channels/github/src/index.ts b/packages/channels/github/src/index.ts index 1f872aeae69..9e613000dd2 100644 --- a/packages/channels/github/src/index.ts +++ b/packages/channels/github/src/index.ts @@ -9,6 +9,88 @@ export const plugin: ChannelPlugin = { requiredConfigFields: ['token'], envResolvableConfigFields: ['baseUrl'], defaultSessionScope: 'chat_thread', + management: { + fields: [ + { + key: 'token', + label: 'Personal Access Token', + kind: 'secret', + required: true, + envResolvable: true, + description: 'Classic PAT with "notifications" scope', + }, + { + key: 'baseUrl', + label: 'Base URL', + kind: 'string', + envResolvable: true, + description: + 'GitHub Enterprise API root (e.g. https://ghe.example.com/api/v3). Leave empty for github.com', + }, + { + key: 'groupPolicy', + label: 'Group Policy', + kind: 'enum', + required: true, + description: 'Must be "Open" for notifications to flow', + default: 'open', + options: [ + { value: 'open', label: 'Open' }, + { value: 'allowlist', label: 'Allowlist' }, + { value: 'disabled', label: 'Disabled' }, + ], + }, + { + key: 'senderPolicy', + label: 'Sender Policy', + kind: 'enum', + required: true, + description: 'Use "Allowlist" with allowed users on public repos', + options: [ + { value: 'allowlist', label: 'Allowlist' }, + { value: 'pairing', label: 'Pairing' }, + { value: 'open', label: 'Open' }, + ], + }, + { + key: 'allowedUsers', + label: 'Allowed Users', + kind: 'string-list', + description: 'GitHub usernames, used by Allowlist and Pairing policies', + }, + { + key: 'reasonFilter', + label: 'Reason Filter', + kind: 'string-list', + description: + 'Optional. Comma-separated notification reasons to process. ' + + 'Leave empty to process all.', + options: [ + { value: 'mention', label: 'mention' }, + { value: 'review_requested', label: 'review_requested' }, + { value: 'assign', label: 'assign' }, + { value: 'author', label: 'author' }, + { value: 'comment', label: 'comment' }, + { value: 'ci_activity', label: 'ci_activity' }, + { value: 'manual', label: 'manual' }, + { value: 'state_change', label: 'state_change' }, + { value: 'subscribed', label: 'subscribed' }, + { value: 'team_mention', label: 'team_mention' }, + { value: 'security_alert', label: 'security_alert' }, + { value: 'approval_requested', label: 'approval_requested' }, + { value: 'invitation', label: 'invitation' }, + { + value: 'member_feature_requested', + label: 'member_feature_requested', + }, + { + value: 'security_advisory_credit', + label: 'security_advisory_credit', + }, + ], + }, + ], + }, createChannel: (name, config, bridge, options) => new GithubChannel(name, config, bridge, options), }; diff --git a/packages/channels/gitlab/src/index.ts b/packages/channels/gitlab/src/index.ts index fe48f7b3342..fe40f1f4820 100644 --- a/packages/channels/gitlab/src/index.ts +++ b/packages/channels/gitlab/src/index.ts @@ -9,6 +9,106 @@ export const plugin: ChannelPlugin = { requiredConfigFields: ['token'], envResolvableConfigFields: ['baseUrl'], defaultSessionScope: 'chat_thread', + management: { + fields: [ + { + key: 'token', + label: 'Personal Access Token', + kind: 'secret', + required: true, + envResolvable: true, + description: 'PAT with "read_api" + "api" scopes', + }, + { + key: 'baseUrl', + label: 'Base URL', + kind: 'string', + envResolvable: true, + description: + 'Self-hosted instance URL (e.g. https://gitlab.example.com). Leave empty for gitlab.com', + }, + { + key: 'groupPolicy', + label: 'Group Policy', + kind: 'enum', + required: true, + description: 'Must be "Open" or "Allowlist" for todos to be processed', + default: 'open', + options: [ + { value: 'open', label: 'Open' }, + { value: 'allowlist', label: 'Allowlist' }, + { value: 'disabled', label: 'Disabled' }, + ], + }, + { + key: 'senderPolicy', + label: 'Sender Policy', + kind: 'enum', + required: true, + description: 'Use "Allowlist" with allowed users on public projects', + options: [ + { value: 'allowlist', label: 'Allowlist' }, + { value: 'pairing', label: 'Pairing' }, + { value: 'open', label: 'Open' }, + ], + }, + { + key: 'allowedUsers', + label: 'Allowed Users', + kind: 'string-list', + description: 'GitLab usernames, used by Allowlist and Pairing policies', + }, + { + key: 'action_prompt_template', + label: 'Action Templates', + kind: 'record', + required: true, + description: + 'Only actions with a template are processed; others are skipped. ' + + 'Template variables: %project%, %project_url%, %author%, %target_type%, %iid%, %title%, %description%, %todo_id%. ' + + 'Use %% for a literal %. ' + + 'Example for "mentioned": Project: %project% | Author: %author% | Title: %title%', + options: [ + { + value: 'mentioned', + label: 'Mentioned — @bot in a comment or description', + }, + { + value: 'directly_addressed', + label: 'Directly Addressed — comment starts with @bot', + }, + { + value: 'assigned', + label: 'Assigned — bot assigned to an issue or MR', + }, + { + value: 'review_requested', + label: 'Review Requested — bot requested as MR reviewer', + }, + { + value: 'approval_required', + label: 'Approval Required — MR needs bot approval', + }, + { + value: 'marked', + label: "Marked — someone stars bot's comment/issue/MR", + }, + { + value: 'build_failed', + label: 'Build Failed — CI/CD pipeline fails on bot branch/MR', + }, + { + value: 'unmergeable', + label: 'Unmergeable — MR becomes unmergeable (conflicts)', + }, + { + value: 'merge_train_removed', + label: 'Merge Train Removed — MR removed from merge train', + }, + ], + }, + ], + }, createChannel: (name, config, bridge, options) => new GitlabChannel(name, config, bridge, options), }; diff --git a/packages/cli/src/commands/channel/channel-registry.test.ts b/packages/cli/src/commands/channel/channel-registry.test.ts index fd72c9ee821..5b68d249a8e 100644 --- a/packages/cli/src/commands/channel/channel-registry.test.ts +++ b/packages/cli/src/commands/channel/channel-registry.test.ts @@ -16,7 +16,7 @@ describe('channel registry', () => { ]); expect( catalog.filter((entry) => entry.manageable).map((entry) => entry.type), - ).toEqual(['dingtalk', 'wecom', 'feishu']); + ).toEqual(['dingtalk', 'wecom', 'feishu', 'github', 'gitlab']); expect( catalog.find((entry) => entry.type === 'dingtalk')?.fields, ).toContainEqual( @@ -26,6 +26,36 @@ describe('channel registry', () => { required: true, }), ); + for (const type of ['github', 'gitlab'] as const) { + const fields = catalog.find((entry) => entry.type === type)?.fields; + expect(fields).toContainEqual( + expect.objectContaining({ + key: 'token', + kind: 'secret', + required: true, + }), + ); + expect(fields).toContainEqual( + expect.objectContaining({ + key: 'groupPolicy', + kind: 'enum', + required: true, + }), + ); + expect(fields).toContainEqual( + expect.objectContaining({ + key: 'senderPolicy', + kind: 'enum', + required: true, + }), + ); + expect(fields).toContainEqual( + expect.objectContaining({ + key: 'allowedUsers', + kind: 'string-list', + }), + ); + } expect(JSON.stringify(catalog)).not.toContain('createChannel'); }); }); diff --git a/packages/cli/src/serve/channel-settings-store.test.ts b/packages/cli/src/serve/channel-settings-store.test.ts index 5ec5b6c9e01..07ada91ee78 100644 --- a/packages/cli/src/serve/channel-settings-store.test.ts +++ b/packages/cli/src/serve/channel-settings-store.test.ts @@ -66,6 +66,16 @@ describe('WorkspaceChannelSettingsStore', () => { ], }, { key: 'literalOnly', label: 'Literal only', kind: 'string' }, + { key: 'tags', label: 'Tags', kind: 'string-list' }, + { + key: 'templates', + label: 'Templates', + kind: 'record', + options: [ + { value: 'greeting', label: 'Greeting' }, + { value: 'farewell', label: 'Farewell' }, + ], + }, ], }, createChannel() { @@ -356,6 +366,39 @@ describe('WorkspaceChannelSettingsStore', () => { clientSecret: { operation: 'replace', value: 'secret' } as const, }, }, + { + label: 'string-list with non-string items', + config: { + type: 'management-validation-test', + clientId: 'client-id', + tags: [1, 2], + }, + secrets: { + clientSecret: { operation: 'replace', value: 'secret' } as const, + }, + }, + { + label: 'string-list not an array', + config: { + type: 'management-validation-test', + clientId: 'client-id', + tags: 'single', + }, + secrets: { + clientSecret: { operation: 'replace', value: 'secret' } as const, + }, + }, + { + label: 'record with non-string value', + config: { + type: 'management-validation-test', + clientId: 'client-id', + templates: { greeting: 123 }, + }, + secrets: { + clientSecret: { operation: 'replace', value: 'secret' } as const, + }, + }, ])('rejects $label without writing', async ({ config, secrets }) => { const store = new WorkspaceChannelSettingsStore(workspace); const before = fs.readFileSync(settingsPath, 'utf8'); @@ -410,6 +453,38 @@ describe('WorkspaceChannelSettingsStore', () => { }); }); + it('accepts string-list and record descriptor fields', async () => { + const store = new WorkspaceChannelSettingsStore(workspace); + + const next = await store.upsert('bot', { + expectedRevision: store.snapshot().revision, + config: { + type: 'management-validation-test', + clientId: 'client-id', + tags: ['alpha', 'beta'], + templates: { + greeting: 'hi %user%', + farewell: 'bye', + // record options are UI hints, not a closed set: undeclared keys + // must be accepted (GitLab action_name set drifts server-side) + attention_requested: 'ping', + }, + }, + secrets: { + clientSecret: { operation: 'replace', value: 'secret' }, + }, + }); + + expect(next.channels['bot']).toMatchObject({ + tags: ['alpha', 'beta'], + templates: { + greeting: 'hi %user%', + farewell: 'bye', + attention_requested: 'ping', + }, + }); + }); + it('rejects clearing an existing required secret without writing', async () => { writeWorkspaceSettings(`{ "$version": 4, diff --git a/packages/cli/src/serve/channel-settings-store.ts b/packages/cli/src/serve/channel-settings-store.ts index 6f1268f8f5a..d4458fc302b 100644 --- a/packages/cli/src/serve/channel-settings-store.ts +++ b/packages/cli/src/serve/channel-settings-store.ts @@ -230,7 +230,13 @@ function assertDescriptorValue( Number.isFinite(value)) || (field.kind === 'enum' && typeof value === 'string' && - field.options?.some((option) => option.value === value) === true); + field.options?.some((option) => option.value === value) === true) || + (field.kind === 'string-list' && + Array.isArray(value) && + value.every((item) => typeof item === 'string')) || + (field.kind === 'record' && + isRecord(value) && + Object.values(value).every((v) => typeof v === 'string')); if (!valid) { throw invalidConfig(`Channel field "${field.key}" has an invalid value.`); } diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 3fbf57cef67..b3341e501ee 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -2929,7 +2929,9 @@ export type DaemonChannelConfigFieldKind = | 'secret' | 'boolean' | 'number' - | 'enum'; + | 'enum' + | 'string-list' + | 'record'; export interface DaemonChannelConfigFieldDescriptor { key: string; @@ -2938,6 +2940,7 @@ export interface DaemonChannelConfigFieldDescriptor { required?: boolean; envResolvable?: boolean; options?: ReadonlyArray<{ value: string; label: string }>; + default?: string; description?: string; } diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.module.css b/packages/web-shell/client/components/channels/ChannelEditorDialog.module.css index 45f5a497037..8849b351304 100644 --- a/packages/web-shell/client/components/channels/ChannelEditorDialog.module.css +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.module.css @@ -82,6 +82,31 @@ font-size: 11px; } +.fieldDescription { + margin: 0; + color: var(--muted-foreground); + font-size: 11px; + line-height: 1.5; +} + +.recordFields { + display: flex; + flex-direction: column; + gap: 8px; +} + +.recordRow { + display: flex; + flex-direction: column; + gap: 3px; +} + +.recordLabel { + font-size: 12px; + font-weight: 500; + color: var(--muted-foreground); +} + .secretState { display: flex; flex-wrap: wrap; diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx index 303118678a0..75326c5dfdf 100644 --- a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx @@ -51,16 +51,12 @@ import { ChannelPairingRequests } from './ChannelPairingRequests'; import { buildChannelUpsertRequest, createChannelEditorDraft, + hasDescriptorSenderPolicy, validateChannelEditorDraft, type ChannelEditorDraft, type ChannelEditorValidationCode, } from './channel-editor-state'; - -const PLATFORM_MARKS: Record = { - dingtalk: 'D', - wecom: 'W', - feishu: 'F', -}; +import { PLATFORM_MARKS } from './channel-platform'; const FIELD_LABEL_KEYS: Record> = { dingtalk: { @@ -76,6 +72,23 @@ const FIELD_LABEL_KEYS: Record> = { clientId: 'channels.editor.field.feishu.clientId', clientSecret: 'channels.editor.field.feishu.clientSecret', }, + github: { + token: 'channels.editor.field.github.token', + baseUrl: 'channels.editor.field.github.baseUrl', + groupPolicy: 'channels.editor.field.github.groupPolicy', + senderPolicy: 'channels.editor.field.github.senderPolicy', + allowedUsers: 'channels.editor.field.github.allowedUsers', + reasonFilter: 'channels.editor.field.github.reasonFilter', + }, + gitlab: { + token: 'channels.editor.field.gitlab.token', + baseUrl: 'channels.editor.field.gitlab.baseUrl', + groupPolicy: 'channels.editor.field.gitlab.groupPolicy', + senderPolicy: 'channels.editor.field.gitlab.senderPolicy', + allowedUsers: 'channels.editor.field.gitlab.allowedUsers', + action_prompt_template: + 'channels.editor.field.gitlab.action_prompt_template', + }, }; export interface ChannelEditorDialogProps { @@ -113,11 +126,16 @@ function configuredAllowedUsers(instance?: DaemonChannelInstanceSnapshot) { : []; } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + function FieldShell({ id, label, required, hint, + description, error, children, }: { @@ -125,6 +143,7 @@ function FieldShell({ label: string; required?: boolean; hint?: string; + description?: string; error?: string; children: ReactNode; }) { @@ -142,6 +161,9 @@ function FieldShell({ {hint ? {hint} : null} {children} + {description ? ( +

{description}

+ ) : null} {error ? (

{error} @@ -187,12 +209,24 @@ export function ChannelEditorDialog({ return key ? t(key) : field.label; }; + const fieldDescription = (field: DaemonChannelConfigFieldDescriptor) => { + const labelKey = FIELD_LABEL_KEYS[descriptor.type]?.[field.key]; + if (labelKey) { + const descKey = `${labelKey}.description`; + const translated = t(descKey); + if (translated !== descKey) return translated; + } + return field.description; + }; + const validationMessage = ( field: DaemonChannelConfigFieldDescriptor | undefined, code: ChannelEditorValidationCode, ) => { if (code === 'duplicate') return t('channels.editor.validation.duplicate'); if (code === 'invalid') return t('channels.editor.validation.invalidName'); + if (code === 'invalidOption') + return t('channels.editor.validation.invalidOption'); if (code === 'number') return t('channels.editor.validation.number'); if (code === 'policy') return t('channels.editor.validation.policy'); return t('channels.editor.validation.required', { @@ -271,6 +305,7 @@ export function ChannelEditorDialog({ id={id} label={fieldLabel(field)} required={field.required} + description={fieldDescription(field)} hint={ field.envResolvable ? t('channels.editor.environmentReference') @@ -364,6 +399,7 @@ export function ChannelEditorDialog({ id={id} label={fieldLabel(field)} required={field.required} + description={fieldDescription(field)} error={error} > + updateRecord(option.value, event.target.value) + } + /> + + ); + })} + + + ); + } return (

@@ -510,68 +603,87 @@ export function ChannelEditorDialog({ {descriptor.fields.map(renderField)} -
-

- {t('channels.editor.section.access')} -

- - setDraft((current) => ({ - ...current, - senderPolicy: - value === 'pairing' || value === 'open' ? value : '', - })) - } - > - {(['pairing', 'open'] as const).map((policy) => ( - - ))} - - {errors['senderPolicy'] ? ( -

- {errors['senderPolicy']} -

- ) : null} - {draft.senderPolicy === 'pairing' ? ( - instance?.config.senderPolicy === 'pairing' ? ( - - ) : ( - - - - {t('channels.editor.pairing.saveFirst.title')} - - - {t('channels.editor.pairing.saveFirst.description')} - - - ) - ) : null} -
+ {(() => { + const descriptorPolicy = hasDescriptorSenderPolicy(descriptor); + const effectivePolicy = descriptorPolicy + ? String(draft.values['senderPolicy'] ?? '') + : draft.senderPolicy; + const showRadioGroup = !descriptorPolicy; + const showPairing = effectivePolicy === 'pairing'; + if (!showRadioGroup && !showPairing) return null; + return ( +
+

+ {t('channels.editor.section.access')} +

+ {showRadioGroup ? ( + <> + + setDraft((current) => ({ + ...current, + senderPolicy: + value === 'pairing' || value === 'open' + ? value + : '', + })) + } + > + {(['pairing', 'open'] as const).map((policy) => ( + + ))} + + {errors['senderPolicy'] ? ( +

+ {errors['senderPolicy']} +

+ ) : null} + + ) : null} + {showPairing ? ( + instance?.config.senderPolicy === 'pairing' ? ( + + ) : ( + + + + {t('channels.editor.pairing.saveFirst.title')} + + + {t('channels.editor.pairing.saveFirst.description')} + + + ) + ) : null} +
+ ); + })()}