Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 6 additions & 5 deletions apps/docs/content/docs/integrations/github.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1010,7 +1010,7 @@ Get the branch protection rules for a specific branch, including status checks,

### GitHub Update Branch Protection

Update branch protection rules for a specific branch, including status checks, review requirements, admin enforcement, and push restrictions.
Replace the branch protection configuration for a branch. This endpoint sets the whole configuration at once: any of the four settings you leave out is disabled, so send every protection you want kept, not only the ones you are changing.

#### Input

Expand All @@ -1019,10 +1019,10 @@ Update branch protection rules for a specific branch, including status checks, r
| `owner` | string | Yes | Repository owner \(user or organization\) |
| `repo` | string | Yes | Repository name |
| `branch` | string | Yes | Branch name |
| `required_status_checks` | object | Yes | Required status check configuration \(null to disable\). Object with strict \(boolean\) and contexts \(string array\) |
| `enforce_admins` | boolean | Yes | Whether to enforce restrictions for administrators |
| `required_pull_request_reviews` | object | Yes | PR review requirements \(null to disable\). Object with optional required_approving_review_count, dismiss_stale_reviews, require_code_owner_reviews |
| `restrictions` | object | Yes | Push restrictions \(null to disable\). Object with users \(string array\) and teams \(string array\) |
| `required_status_checks` | object | No | Required status checks, as an object with strict \(boolean\) and contexts \(string array\). Leave out to DISABLE required status checks — this endpoint replaces the whole configuration, it does not merge. |
| `enforce_admins` | boolean | No | Whether to enforce protections for administrators. Leave out to DISABLE admin enforcement — this endpoint replaces the whole configuration, it does not merge. |
| `required_pull_request_reviews` | object | No | Pull request review requirements, as an object with optional required_approving_review_count, dismiss_stale_reviews, require_code_owner_reviews. Leave out to DISABLE required reviews — this endpoint replaces the whole configuration, it does not merge. |
| `restrictions` | object | No | Push restrictions, as an object with users \(string array\) and teams \(string array\). Leave out to DISABLE push restrictions — this endpoint replaces the whole configuration, it does not merge. |
| `apiKey` | string | Yes | GitHub Personal Access Token |

#### Output
Expand Down Expand Up @@ -1813,6 +1813,7 @@ List workflow runs for a repository. Supports filtering by actor, branch, event,
| --------- | ---- | -------- | ----------- |
| `owner` | string | Yes | Repository owner \(user or organization\) |
| `repo` | string | Yes | Repository name |
| `workflow_id` | string | No | Limit results to one workflow, by numeric ID or filename \(e.g., "main.yaml"\). Omit to list runs across the whole repository. |
| `actor` | string | No | Filter by user who triggered the workflow |
| `branch` | string | No | Filter by branch name |
| `event` | string | No | Filter by event type \(e.g., push, pull_request, workflow_dispatch\) |
Expand Down
193 changes: 193 additions & 0 deletions apps/sim/blocks/blocks/github.test.ts
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)
})
})
110 changes: 103 additions & 7 deletions apps/sim/blocks/blocks/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)',
Expand Down Expand Up @@ -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',
Comment thread
waleedlatif1 marked this conversation as resolved.
Comment thread
waleedlatif1 marked this conversation as resolved.
condition: { field: 'operation', value: 'github_update_branch_protection' },
mode: 'advanced',
},
{
id: 'enforce_admins',
Expand All @@ -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
{
Expand Down Expand Up @@ -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

@cubic-dev-ai cubic-dev-ai Bot Aug 29, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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
Check if this issue is valid — if so, understand the root cause and fix it. At apps/sim/blocks/blocks/github.ts, line 2351:

<comment>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.</comment>

<file context>
@@ -2279,6 +2329,44 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
+           * same-named leftover (`title` from Create Issue, `sort` from a
+           * search) would otherwise be sent under this operation.
+           */
+          if (!(subBlockId in params)) continue
+          const value = params[subBlockId]
+          mapped[toolParam] = value === '' || value === null ? undefined : value
</file context>
Fix with cubic

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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:

  • UI path: operator switched to Update Milestone, milestone_title untouched, a stale title lingers from Create Issue.
  • Agent path: the model addressed the tool param directly as title, because the tool param name is what the LLM schema exposes.

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: content is required on both reaction tools, so dropping a model-supplied content fails the call outright. That's a worse, more frequent regression than the stale value.

What I think saves the reachable case: a subBlock the editor has rendered is in block state, so under Update Milestone milestone_title is present (blank) rather than absent, and the presence rule then clears the stale title. The leak needs milestone_title absent while title is present — the operation selected without its own form ever being rendered.

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 generic-handler if you agree that's the right home.

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
Comment thread
waleedlatif1 marked this conversation as resolved.
Comment thread
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: {
Expand Down Expand Up @@ -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' },
Expand Down Expand Up @@ -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' },
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/tools/generated/tool-metadata.ts

Large diffs are not rendered by default.

Loading
Loading