Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
dcac8c5
feat(channels): add Web Shell management support for GitHub and GitLab
OrbitZore Aug 1, 2026
8b37f3a
test(channels): update channel-registry manageable expectation for Gi…
OrbitZore Aug 1, 2026
07cfd2f
test(web-shell): add gitlab manageable:false negative case for symmetry
OrbitZore Aug 1, 2026
af525b9
fix(web-shell): add GitHub/GitLab platform marks, i18n labels, and em…
OrbitZore Aug 1, 2026
5c13870
Merge branch 'main' into feat/webshell-github-gitlab-management
wenshao Aug 1, 2026
b2eb883
Merge branch 'main' into feat/webshell-github-gitlab-management
wenshao Aug 1, 2026
63c32c5
feat(web-shell): descriptor-driven groupPolicy, senderPolicy, allowed…
OrbitZore Aug 1, 2026
ac558ab
feat(web-shell): render field descriptions below inputs in channel ed…
OrbitZore Aug 1, 2026
2d04bb1
Merge commit 'b2eb883ba' into feat/webshell-github-gitlab-management
OrbitZore Aug 1, 2026
e10ad1c
feat(web-shell): add reasonFilter and action_prompt_template fields w…
OrbitZore Aug 1, 2026
a62f738
fix(cli): validate string-list and record kinds in assertDescriptorValue
OrbitZore Aug 2, 2026
50b8642
fix(web-shell): prevent silent config rewrite and record crash in editor
OrbitZore Aug 2, 2026
da16a19
refactor(web-shell): extract hasDescriptorSenderPolicy helper, hide e…
OrbitZore Aug 2, 2026
1275ac8
fix(cli): accept undeclared record keys in assertDescriptorValue
OrbitZore Aug 2, 2026
be88732
fix(web-shell): harden channel editor parsing, defaults, and validation
OrbitZore Aug 2, 2026
1d42fbd
fix(web-shell): use distinct validation message for string-list optio…
OrbitZore Aug 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/channels/base/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,9 @@ export type ChannelConfigFieldKind =
| 'secret'
| 'boolean'
| 'number'
| 'enum';
| 'enum'
| 'string-list'
| 'record';

export interface ChannelConfigFieldDescriptor {
key: string;
Expand All @@ -393,6 +395,7 @@ export interface ChannelConfigFieldDescriptor {
required?: boolean;
envResolvable?: boolean;
options?: ReadonlyArray<{ value: string; label: string }>;
default?: string;
description?: string;
}

Expand Down
82 changes: 82 additions & 0 deletions packages/channels/github/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
],
},
],
},
Comment on lines +12 to +93

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Missing groupPolicy in the management descriptor makes channels created from the Web Shell silently dead. — Failure scenario: a user creates a GitHub channel from the Web Shell UI, enters a PAT and optional base URL. The channel connects and reports healthy. buildChannelUpsertRequest (channel-editor-state.ts:172) writes only descriptor fields plus senderPolicy, so groupPolicy is absent from the persisted config. GroupGate (GroupGate.ts:13) defaults to 'disabled', which drops every isGroup: true envelope (GroupGate.ts:36-38). All four GitHub adapter envelope sites (GithubAdapter.ts:1005,1055,1099,1249) unconditionally set isGroup: true, so every message is silently dropped before dispatch. The channel polls, marks notifications as read (advancing the cursor), and dispatches nothing. On GitLab the same flow marks todos as done without dispatching them — those todos are permanently lost (data loss).

This differs from the DingTalk/WeCom/Feishu channels this PR follows: those adapters emit both isGroup: true and isGroup: false envelopes, so DM messages bypass the group gate. GitHub and GitLab have no DM path — every message goes through the group gate.

Suggested change
management: {
fields: [
{
key: 'token',
label: 'Personal Access Token',
kind: 'secret',
required: true,
envResolvable: true,
},
{
key: 'baseUrl',
label: 'Base URL',
kind: 'string',
envResolvable: true,
},
],
},
management: {
fields: [
{
key: 'token',
label: 'Personal Access Token',
kind: 'secret',
required: true,
envResolvable: true,
},
{
key: 'baseUrl',
label: 'Base URL',
kind: 'string',
envResolvable: true,
},
{
key: 'groupPolicy',
label: 'Group Policy',
kind: 'enum',
required: true,
options: [
{ value: 'open', label: 'Open' },
{ value: 'allowlist', label: 'Allowlist' },
{ value: 'disabled', label: 'Disabled' },
],
},
],
},

— qwen3.7-max via Qwen Code /review

createChannel: (name, config, bridge, options) =>
new GithubChannel(name, config, bridge, options),
};
100 changes: 100 additions & 0 deletions packages/channels/gitlab/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
],
},
],
},
Comment on lines +12 to +111

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Same issue as packages/channels/github/src/index.ts — missing groupPolicy field. — Failure scenario: a user creates a GitLab channel from the Web Shell. Same silent-dead channel behavior: GroupGate defaults to 'disabled', dropping every isGroup: true envelope. GitLab adapter emits isGroup: true envelopes (GitlabAdapter.ts:393). Todos are marked done and lost without dispatch. The GitLab adapter even logs a startup warning (GitlabAdapter.ts:81: groupPolicy is "${cfg.groupPolicy ?? 'disabled'}"; must be "open"…) — but this warning goes to stderr, not the Web Shell UI, so the user has no visibility.

Suggested change
management: {
fields: [
{
key: 'token',
label: 'Personal Access Token',
kind: 'secret',
required: true,
envResolvable: true,
},
{
key: 'baseUrl',
label: 'Base URL',
kind: 'string',
envResolvable: true,
},
],
},
management: {
fields: [
{
key: 'token',
label: 'Personal Access Token',
kind: 'secret',
required: true,
envResolvable: true,
},
{
key: 'baseUrl',
label: 'Base URL',
kind: 'string',
envResolvable: true,
},
{
key: 'groupPolicy',
label: 'Group Policy',
kind: 'enum',
required: true,
options: [
{ value: 'open', label: 'Open' },
{ value: 'allowlist', label: 'Allowlist' },
{ value: 'disabled', label: 'Disabled' },
],
},
],
},

— qwen3.7-max via Qwen Code /review

createChannel: (name, config, bridge, options) =>
new GitlabChannel(name, config, bridge, options),
};
32 changes: 31 additions & 1 deletion packages/cli/src/commands/channel/channel-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The test asserts the manageable type list but not the field descriptor shape. Reverting the management descriptors from the github/gitlab plugins leaves this test green — the test-efficacy probe confirmed this: both management descriptor hunks survived reversion with no test failure. — Concrete cost: if a future change accidentally sets kind: 'string' on the token field, the Web Shell editor would render it as a plain text input instead of a masked secret field, and no test would catch it.

Suggested change
).toEqual(['dingtalk', 'wecom', 'feishu', 'github', 'gitlab']);
).toEqual(['dingtalk', 'wecom', 'feishu', 'github', 'gitlab']);
expect(
catalog.find((entry) => entry.type === 'github')?.fields,
).toContainEqual(
expect.objectContaining({ key: 'token', kind: 'secret', required: true }),
);
expect(
catalog.find((entry) => entry.type === 'gitlab')?.fields,
).toContainEqual(
expect.objectContaining({ key: 'token', kind: 'secret', required: true }),
);
中文说明

该测试仅断言了可管理类型列表,未断言字段描述符的结构。测试效力探针确认:回退 github/gitlab 插件的 management 描述符后,所有测试仍然通过。具体代价:如果未来的变更意外将 token 字段的 kind 设为 'string',Web Shell 编辑器会将其渲染为明文输入框而非掩码密钥字段,且没有测试能捕获此回归。

— qwen3.8-max-preview via Qwen Code /review (v0.21.2)

expect(
catalog.find((entry) => entry.type === 'dingtalk')?.fields,
).toContainEqual(
Expand All @@ -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');
});
});
75 changes: 75 additions & 0 deletions packages/cli/src/serve/channel-settings-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/serve/channel-settings-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`);
}
Expand Down
5 changes: 4 additions & 1 deletion packages/sdk-typescript/src/daemon/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2929,7 +2929,9 @@ export type DaemonChannelConfigFieldKind =
| 'secret'
| 'boolean'
| 'number'
| 'enum';
| 'enum'
| 'string-list'
| 'record';

export interface DaemonChannelConfigFieldDescriptor {
key: string;
Expand All @@ -2938,6 +2940,7 @@ export interface DaemonChannelConfigFieldDescriptor {
required?: boolean;
envResolvable?: boolean;
options?: ReadonlyArray<{ value: string; label: string }>;
default?: string;
description?: string;
}

Expand Down
Loading
Loading