-
Notifications
You must be signed in to change notification settings - Fork 3.8k
fix(github): wire block fields that never reached the API #7272
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2621684
406c3a4
4fd4045
bc5e8df
5040596
1d85f0c
d752e20
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,193 @@ | ||
| import { describe, expect, it, vi } from 'vitest' | ||
|
|
||
| vi.unmock('@/blocks/registry') | ||
| vi.unmock('@/tools/registry') | ||
|
|
||
| import { evaluateSubBlockCondition } from '@/lib/workflows/subblocks/visibility' | ||
| import { GitHubBlock } from '@/blocks/blocks/github' | ||
| import type { SubBlockConfig } from '@/blocks/types' | ||
| import { tools as toolRegistry } from '@/tools/registry' | ||
| import type { ToolConfig } from '@/tools/types' | ||
|
|
||
| /** | ||
| * Params the surface supplies rather than the user, so a missing subBlock for | ||
| * one is not a wiring defect. | ||
| */ | ||
| const INJECTED_PARAMS = new Set(['apiKey', 'accessToken', 'credential']) | ||
|
|
||
| type AnyRecord = Record<string, unknown> | ||
|
|
||
| const subBlocks = GitHubBlock.subBlocks as SubBlockConfig[] | ||
| const selectTool = (GitHubBlock.tools.config as { tool: (p: AnyRecord) => string }).tool | ||
| const mapParams = (GitHubBlock.tools.config as { params?: (p: AnyRecord) => AnyRecord } | undefined) | ||
| ?.params | ||
|
|
||
| const operations: string[] = ( | ||
| (subBlocks.find((sb) => sb.id === 'operation')?.options as { id: string }[] | undefined) ?? [] | ||
| ).map((option) => option.id) | ||
|
|
||
| /** | ||
| * The subBlocks that can be rendered for an operation, evaluated with the | ||
| * block's own condition evaluator rather than a reimplementation of it. | ||
| * | ||
| * "Can be" rather than "are": a compound condition gates a subBlock on a second | ||
| * field as well as the operation — `github_comment`'s `path` and `line` need | ||
| * `commentType: 'file_comment'` — so the secondary gate is satisfied here | ||
| * before evaluating. That is the right question for a reachability guard, which | ||
| * asks whether an operation has *any* way to supply a required param, not | ||
| * whether one particular editor state happens to show it. Hand-rolling the | ||
| * match instead would silently ignore `and:` and over-report, which is the same | ||
| * false confidence this file exists to remove. | ||
| */ | ||
| function visibleSubBlocks(operation: string): SubBlockConfig[] { | ||
| return subBlocks.filter((sb) => { | ||
| const condition = sb.condition | ||
| if (!condition) return true | ||
| if (typeof condition === 'function') return false | ||
|
|
||
| const values: AnyRecord = { operation } | ||
| const secondary = (condition as { and?: { field: string; value: unknown } }).and | ||
| if (secondary) { | ||
| values[secondary.field] = Array.isArray(secondary.value) | ||
| ? secondary.value[0] | ||
| : secondary.value | ||
| } | ||
|
|
||
| return condition.field === 'operation' && evaluateSubBlockCondition(condition, values) | ||
| }) | ||
| } | ||
|
|
||
| /** | ||
| * Reproduces `generic-handler`'s merge: the mapper's output is spread over the | ||
| * raw subBlock values, so a mapper key always wins. | ||
| */ | ||
| function resolveToolInputs(operation: string, extra: AnyRecord = {}): AnyRecord { | ||
| const inputs: AnyRecord = { operation, ...extra } | ||
| for (const sb of visibleSubBlocks(operation)) { | ||
| if (sb.id === 'operation' || sb.id in inputs) continue | ||
| inputs[sb.id] = `value-for-${sb.id}` | ||
| } | ||
| return mapParams ? { ...inputs, ...mapParams(inputs) } : inputs | ||
| } | ||
|
|
||
| describe('GitHub block param wiring', () => { | ||
| it('exposes at least one operation', () => { | ||
| expect(operations.length).toBeGreaterThan(0) | ||
| }) | ||
|
|
||
| /** | ||
| * The regression guard for this whole class of bug. A subBlock binds to a tool | ||
| * param only when its id matches the param name (`tools/params.ts`), so an id | ||
| * that merely *looks* related is inert and the operation fails at the API. | ||
| */ | ||
| it('supplies every required tool param for every operation', () => { | ||
| const unsatisfied: string[] = [] | ||
|
|
||
| for (const operation of operations) { | ||
| const toolId = selectTool({ operation }) | ||
| const tool = (toolRegistry as Record<string, ToolConfig>)[toolId] | ||
| if (!tool?.params) continue | ||
|
|
||
| const resolved = resolveToolInputs(operation) | ||
| for (const [paramName, spec] of Object.entries(tool.params)) { | ||
| if (!spec.required || INJECTED_PARAMS.has(paramName)) continue | ||
| if (resolved[paramName] === undefined) { | ||
| unsatisfied.push(`${operation} -> ${toolId}.${paramName}`) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| expect(unsatisfied).toEqual([]) | ||
| }) | ||
|
|
||
| it('sends a public gist as a real boolean when the user picks Public', () => { | ||
| const resolved = resolveToolInputs('github_create_gist', { gist_public: 'true' }) | ||
|
|
||
| expect(resolved.public).toBe(true) | ||
| }) | ||
|
|
||
| it('sends a secret gist as a real boolean when the user picks Secret', () => { | ||
| const resolved = resolveToolInputs('github_create_gist', { gist_public: 'false' }) | ||
|
|
||
| expect(resolved.public).toBe(false) | ||
| }) | ||
|
|
||
| it('coerces enforce_admins to a boolean rather than the dropdown string', () => { | ||
| const resolved = resolveToolInputs('github_update_branch_protection', { | ||
| enforce_admins: 'true', | ||
| }) | ||
|
|
||
| expect(resolved.enforce_admins).toBe(true) | ||
| }) | ||
|
|
||
| /** | ||
| * `generic-handler` merges `{ ...inputs, ...params(inputs) }`, so an | ||
| * unconditional assignment writes `undefined` over a value the model supplied | ||
| * directly under the tool's own param name. Every alias must be guarded. | ||
| */ | ||
| it('never clobbers a model-supplied param when its alias subBlock is empty', () => { | ||
| if (!mapParams) return | ||
|
|
||
| const modelSupplied: AnyRecord = { | ||
| operation: 'github_create_issue_reaction', | ||
| owner: 'octocat', | ||
| repo: 'hello', | ||
| issue_number: 1, | ||
| content: '+1', | ||
| } | ||
|
|
||
| const merged = { ...modelSupplied, ...mapParams(modelSupplied) } | ||
|
|
||
| expect(merged.content).toBe('+1') | ||
| }) | ||
|
|
||
| /** | ||
| * An alias key that is *absent* means the caller addressed the tool param | ||
| * directly — the agent path — so the mapper must not touch it. An alias key | ||
| * that is *present but blank* means the operator cleared the field, and the | ||
| * target has to be cleared with it: stored block state keeps values for | ||
| * fields the current operation does not render, so a same-named leftover | ||
| * (`title` from Create Issue, `sort` from a search) would otherwise be sent. | ||
| */ | ||
| it('clears a stale canonical value when its alias field is blank', () => { | ||
| if (!mapParams) return | ||
|
|
||
| const merged = { | ||
| operation: 'github_update_milestone', | ||
| milestone_title: '', | ||
| title: 'left over from Create Issue', | ||
| ...mapParams({ | ||
| operation: 'github_update_milestone', | ||
| milestone_title: '', | ||
| title: 'left over from Create Issue', | ||
| }), | ||
| } | ||
|
|
||
| expect(merged.title).toBeUndefined() | ||
| }) | ||
|
|
||
| it('leaves the tool param alone when the alias key is absent entirely', () => { | ||
| if (!mapParams) return | ||
|
|
||
| const direct = { operation: 'github_update_milestone', title: 'model supplied' } | ||
| const merged = { ...direct, ...mapParams(direct) } | ||
|
|
||
| expect(merged.title).toBe('model supplied') | ||
| }) | ||
|
|
||
| /** | ||
| * `protected` is a tri-state in the UI and a boolean on the wire; `list_branches` | ||
| * appends the filter whenever it is not `undefined`, so the "All" sentinel would | ||
| * be sent as the invalid `protected=all`. | ||
| */ | ||
| it('omits the protection filter when All is selected', () => { | ||
| const resolved = resolveToolInputs('github_list_branches', { protected: 'all' }) | ||
|
|
||
| expect(resolved.protected).toBeUndefined() | ||
| }) | ||
|
|
||
| it('still sends the protection filter as a boolean when one is chosen', () => { | ||
| expect(resolveToolInputs('github_list_branches', { protected: 'true' }).protected).toBe(true) | ||
| expect(resolveToolInputs('github_list_branches', { protected: 'false' }).protected).toBe(false) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,52 @@ import { getTrigger } from '@/triggers' | |
| /** Reviewers can be named individually or by team slug; either identifies the request. */ | ||
| const REVIEWER_FIELD = ['reviewers', 'team_reviewers'] as const | ||
|
|
||
| /** | ||
| * SubBlock ids that differ from the tool param they feed, keyed by operation. | ||
| * | ||
| * A subBlock binds to a tool param only when its id matches the param name | ||
| * (`resolveSubBlockForParam` in `@/tools/params`), so these fields would | ||
| * otherwise be inert: the control renders, the user fills it, and the value | ||
| * never reaches the request. The ids cannot simply be renamed — a subBlock id | ||
| * is persisted workflow state, so changing one needs a `_removed_` migration. | ||
| * | ||
| * Keyed by operation rather than flattened because several targets collide: | ||
| * `fork_sort` and `milestone_sort` both feed `sort`, and stored block state | ||
| * keeps values for fields the current operation does not render, so a flat | ||
| * table would let a stale sibling win. | ||
| */ | ||
| const SUBBLOCK_PARAM_ALIASES: Record<string, Readonly<Record<string, string>>> = { | ||
| github_create_gist: { gist_public: 'public' }, | ||
| github_fork_repo: { fork_name: 'name' }, | ||
| github_list_forks: { fork_sort: 'sort' }, | ||
| github_create_milestone: { milestone_title: 'title', milestone_description: 'description' }, | ||
| github_update_milestone: { milestone_title: 'title', milestone_description: 'description' }, | ||
| github_list_milestones: { milestone_state: 'state', milestone_sort: 'sort' }, | ||
| github_create_issue_reaction: { reaction_content: 'content' }, | ||
| github_create_comment_reaction: { reaction_content: 'content' }, | ||
| } | ||
|
|
||
| /** | ||
| * Tool params declared `type: 'boolean'` whose subBlock is a dropdown. | ||
| * | ||
| * A dropdown option id is a string, so `'false'` arrives truthy and every one | ||
| * of these silently inverts. Each name means the same thing across every GitHub | ||
| * operation that takes it, so a flat set is unambiguous. Only the exact strings | ||
| * are converted, which leaves an already-boolean value untouched. | ||
| */ | ||
| /** The dropdown id meaning "do not filter", which must become an omitted param. */ | ||
| const TRI_STATE_ANY = 'all' | ||
|
|
||
| const BOOLEAN_TOOL_PARAMS = [ | ||
| 'draft', | ||
| 'protected', | ||
| 'enforce_admins', | ||
| 'prerelease', | ||
| 'project_public', | ||
| 'public', | ||
| 'default_branch_only', | ||
| ] as const | ||
|
|
||
| export const GitHubBlock: BlockConfig<GitHubResponse> = { | ||
| type: 'github', | ||
| name: 'GitHub (Legacy)', | ||
|
|
@@ -932,9 +978,8 @@ export const GitHubBlock: BlockConfig<GitHubResponse> = { | |
| id: 'required_status_checks', | ||
| title: 'Required Status Checks', | ||
| type: 'short-input', | ||
| placeholder: 'JSON: {"strict":true,"contexts":["ci/test"]}', | ||
| placeholder: 'JSON: {"strict":true,"contexts":["ci/test"]} — leave blank to disable', | ||
|
waleedlatif1 marked this conversation as resolved.
|
||
| condition: { field: 'operation', value: 'github_update_branch_protection' }, | ||
| mode: 'advanced', | ||
| }, | ||
| { | ||
| id: 'enforce_admins', | ||
|
|
@@ -945,15 +990,20 @@ export const GitHubBlock: BlockConfig<GitHubResponse> = { | |
| { label: 'Yes', id: 'true' }, | ||
| ], | ||
| condition: { field: 'operation', value: 'github_update_branch_protection' }, | ||
| mode: 'advanced', | ||
| }, | ||
| { | ||
| id: 'required_pull_request_reviews', | ||
| title: 'Required PR Reviews', | ||
| type: 'short-input', | ||
| placeholder: 'JSON: {"required_approving_review_count":1}', | ||
| placeholder: 'JSON: {"required_approving_review_count":1} — leave blank to disable', | ||
| condition: { field: 'operation', value: 'github_update_branch_protection' }, | ||
| }, | ||
| { | ||
| id: 'restrictions', | ||
| title: 'Push Restrictions', | ||
| type: 'short-input', | ||
| placeholder: 'JSON: {"users":[],"teams":[]} — leave blank to disable', | ||
| condition: { field: 'operation', value: 'github_update_branch_protection' }, | ||
| mode: 'advanced', | ||
| }, | ||
| // Issue operations parameters | ||
| { | ||
|
|
@@ -2279,6 +2329,44 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, | |
| return 'github_repo_info' | ||
| } | ||
| }, | ||
| params: (params) => { | ||
| const operation = typeof params.operation === 'string' ? params.operation : '' | ||
| const mapped: Record<string, unknown> = {} | ||
|
|
||
| for (const [subBlockId, toolParam] of Object.entries( | ||
| SUBBLOCK_PARAM_ALIASES[operation] ?? {} | ||
| )) { | ||
| /** | ||
| * An absent alias key means the caller addressed the tool param | ||
| * directly — the agent path — so it must be left alone; writing here | ||
| * would clobber a model-supplied value, because the handler merges | ||
| * `{ ...inputs, ...params(inputs) }`. | ||
| * | ||
| * A key that is present but blank means the operator cleared the | ||
| * field, and the target has to be cleared with it. Block state keeps | ||
| * values for fields the current operation does not render, so a | ||
| * same-named leftover (`title` from Create Issue, `sort` from a | ||
| * search) would otherwise be sent under this operation. | ||
| */ | ||
| if (!(subBlockId in params)) continue | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When an operation switch leaves the alias field unset, this guard lets a stale canonical value from another operation reach the request. Filter persisted inputs to fields for the selected operation, or otherwise distinguish block-state inputs from direct tool inputs before preserving the canonical value. Prompt for AI agents
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thought about this one for a while. The blank case you raised earlier is fixed; this residual is genuinely undecidable at this layer, and I'd rather say so than paper over it. From the mapper's inputs, these two are indistinguishable:
Both arrive as "alias key absent, canonical key present". Clearing unconditionally would fix the first and break the second — and break it hardest exactly where it hurts most: What I think saves the reachable case: a subBlock the editor has rendered is in block state, so under Update Milestone Your suggested fix — scoping persisted inputs to the active operation — is the right one, but it belongs upstream in the executor rather than in one integration's mapper: every block with per-operation fields has this shape, and solving it here would leave the other 250 untouched while adding a second, divergent notion of visibility. Happy to raise it against Leaving as-is deliberately, with the tradeoff recorded here rather than silently chosen. |
||
| const value = params[subBlockId] | ||
| mapped[toolParam] = value === '' || value === null ? undefined : value | ||
| } | ||
|
|
||
| for (const toolParam of BOOLEAN_TOOL_PARAMS) { | ||
| const value = toolParam in mapped ? mapped[toolParam] : params[toolParam] | ||
| if (value === 'true') mapped[toolParam] = true | ||
| else if (value === 'false') mapped[toolParam] = false | ||
|
waleedlatif1 marked this conversation as resolved.
waleedlatif1 marked this conversation as resolved.
|
||
| /** | ||
| * `protected` is a tri-state in the UI and a boolean on the wire. | ||
| * `list_branches` appends the filter whenever it is not `undefined`, | ||
| * so the "All" sentinel has to become an omission rather than the | ||
| * invalid `protected=all`. | ||
| */ else if (value === TRI_STATE_ANY) mapped[toolParam] = undefined | ||
| } | ||
|
|
||
| return mapped | ||
| }, | ||
| }, | ||
| }, | ||
| inputs: { | ||
|
|
@@ -2317,9 +2405,16 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, | |
| ref: { type: 'string', description: 'Branch, tag, or commit reference' }, | ||
| // Branch parameters | ||
| protected: { type: 'string', description: 'Protection status filter' }, | ||
| required_status_checks: { type: 'string', description: 'Required status checks JSON' }, | ||
| required_status_checks: { | ||
| type: 'json', | ||
| description: 'Required status checks (null to disable)', | ||
| }, | ||
| enforce_admins: { type: 'boolean', description: 'Enforce for admins' }, | ||
| required_pull_request_reviews: { type: 'string', description: 'Required PR reviews JSON' }, | ||
| required_pull_request_reviews: { | ||
| type: 'json', | ||
| description: 'Required PR reviews (null to disable)', | ||
| }, | ||
| restrictions: { type: 'json', description: 'Push restrictions (null to disable)' }, | ||
| // Issue parameters | ||
| labels: { type: 'string', description: 'Comma-separated labels' }, | ||
| assignees: { type: 'string', description: 'Comma-separated assignees' }, | ||
|
|
@@ -2354,6 +2449,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, | |
| description: { type: 'string', description: 'Description' }, | ||
| files: { type: 'string', description: 'Files JSON object' }, | ||
| gist_public: { type: 'boolean', description: 'Public gist status' }, | ||
| public: { type: 'boolean', description: 'Gist visibility sent to the API' }, | ||
| username: { type: 'string', description: 'GitHub username' }, | ||
| // Fork parameters | ||
| organization: { type: 'string', description: 'Target organization for fork' }, | ||
|
|
||
Large diffs are not rendered by default.
Uh oh!
There was an error while loading. Please reload this page.