Skip to content

Commit 99badc4

Browse files
committed
fix(github): scope each param alias to the operations that declare it
1 parent ff0eb6e commit 99badc4

2 files changed

Lines changed: 130 additions & 48 deletions

File tree

apps/sim/blocks/blocks/github.param-wiring.test.ts

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ const RENAMES = [
3939
] as const
4040

4141
describe('every renamed subBlock reaches its tool param', () => {
42-
it.each(RENAMES)('%s -> %s', (subBlockId, paramName) => {
43-
expect(map({ [subBlockId]: 'x' })).toHaveProperty(paramName)
42+
it.each(RENAMES)('%s -> %s', (subBlockId, paramName, toolId) => {
43+
expect(map({ operation: toolId, [subBlockId]: 'x' })).toHaveProperty(paramName)
4444
})
4545

4646
it.each(RENAMES)(
@@ -73,12 +73,16 @@ describe('guarded assignment protects the agent tool-calling path', () => {
7373
})
7474

7575
it('leaves a model-supplied value untouched when the block field is absent', () => {
76-
const modelArgs = { content: 'rocket', title: 'from the model' }
76+
const modelArgs = {
77+
operation: 'github_create_issue_reaction',
78+
content: 'rocket',
79+
title: 'from the model',
80+
}
7781
expect({ ...modelArgs, ...map(modelArgs) }).toEqual(modelArgs)
7882
})
7983

8084
it.each(['', null, undefined])('treats %o as not provided', (empty) => {
81-
expect(map({ reaction_content: empty })).toEqual({})
85+
expect(map({ operation: 'github_create_issue_reaction', reaction_content: empty })).toEqual({})
8286
})
8387
})
8488

@@ -88,15 +92,17 @@ describe('gist_public coercion', () => {
8892
['false', false],
8993
[true, true],
9094
])('maps %o to %o', (input, expected) => {
91-
expect(map({ gist_public: input }).public).toBe(expected)
95+
expect(map({ operation: 'github_create_gist', gist_public: input }).public).toBe(expected)
9296
})
9397

9498
it('omits public entirely when untouched, leaving the tool default', () => {
9599
expect(map({ operation: 'github_create_gist' })).not.toHaveProperty('public')
96100
})
97101

98102
it.each([null, undefined, ''])('treats %o as unset rather than Secret', (unset) => {
99-
expect(map({ gist_public: unset })).not.toHaveProperty('public')
103+
expect(map({ operation: 'github_create_gist', gist_public: unset })).not.toHaveProperty(
104+
'public'
105+
)
100106
})
101107

102108
/**
@@ -109,11 +115,12 @@ describe('gist_public coercion', () => {
109115
['false', false],
110116
[false, false],
111117
])('treats %o as an explicit Secret selection', (input, expected) => {
112-
expect(map({ gist_public: input }).public).toBe(expected)
118+
expect(map({ operation: 'github_create_gist', gist_public: input }).public).toBe(expected)
113119
})
114120

115121
it.each(['false', false])('overrides a model-supplied public for %o', (secret) => {
116-
expect({ public: true, ...map({ gist_public: secret }) }.public).toBe(false)
122+
const inputs = { operation: 'github_create_gist', gist_public: secret }
123+
expect({ public: true, ...map(inputs) }.public).toBe(false)
117124
})
118125

119126
it('matches the dropdown option ids the block actually renders', () => {
@@ -161,3 +168,54 @@ describe('the v2 block inherits the same mapper', () => {
161168
expect(GitHubV2Block.tools.config?.params).toBe(GitHubBlock.tools.config?.params)
162169
})
163170
})
171+
172+
/**
173+
* `shouldSerializeSubBlock` (`serializer/index.ts:91-93`) serializes a
174+
* non-empty `mode: 'advanced'` field WITHOUT evaluating its condition. Seven of
175+
* the aliased sources are advanced, so a value left behind by an earlier
176+
* operation is still present in `params` after the user switches operations.
177+
* An unscoped alias would rewrite it onto the new operation's tool param.
178+
*/
179+
describe('a stale advanced field cannot leak onto another operation', () => {
180+
it('does not turn a leftover milestone_title into github_update_pr title', () => {
181+
const mapped = map({ operation: 'github_update_pr', milestone_title: 'Q3 milestone' })
182+
expect(mapped).not.toHaveProperty('title')
183+
})
184+
185+
it('does not clobber the PR title the user actually typed', () => {
186+
const inputs = {
187+
operation: 'github_update_pr',
188+
title: 'Fix the parser',
189+
milestone_title: 'Q3 milestone',
190+
}
191+
expect({ ...inputs, ...map(inputs) }.title).toBe('Fix the parser')
192+
})
193+
194+
it.each([
195+
['github_create_pr', 'milestone_title', 'title'],
196+
['github_create_issue', 'milestone_description', 'description'],
197+
['github_list_issues', 'milestone_state', 'state'],
198+
['github_search_repos', 'milestone_sort', 'sort'],
199+
['github_search_repos', 'fork_sort', 'sort'],
200+
['github_create_gist', 'fork_name', 'name'],
201+
['github_update_project', 'gist_public', 'public'],
202+
])('%s ignores a stale %s', (operation, from, to) => {
203+
expect(map({ operation, [from]: 'stale' })).not.toHaveProperty(to)
204+
})
205+
206+
it('every advanced source is scoped to at least one operation', () => {
207+
const advanced = GitHubBlock.subBlocks.filter((s) => s.mode === 'advanced').map((s) => s.id)
208+
for (const src of [
209+
'milestone_title',
210+
'milestone_description',
211+
'milestone_state',
212+
'milestone_sort',
213+
'fork_name',
214+
'fork_sort',
215+
'gist_public',
216+
]) {
217+
expect(advanced, `${src} is expected to be an advanced field`).toContain(src)
218+
expect(map({ operation: '', [src]: 'x' })).toEqual({})
219+
}
220+
})
221+
})

apps/sim/blocks/blocks/github.ts

Lines changed: 64 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,44 @@ import { getTrigger } from '@/triggers'
99
/** Reviewers can be named individually or by team slug; either identifies the request. */
1010
const REVIEWER_FIELD = ['reviewers', 'team_reviewers'] as const
1111

12+
/**
13+
* Block subBlock ids that differ from the tool param they feed, each scoped to
14+
* the operations whose tool declares that target. `sort` has two sources and
15+
* `title`/`description`/`state` share their names with fields on other
16+
* operations, so the scoping is what keeps them from colliding.
17+
*
18+
* `toBoolean` marks a dropdown feeding a boolean tool param: a dropdown stores
19+
* its option id, so the value arrives as the string 'true'/'false' and the
20+
* generic handler only JSON-parses `json`/`array` inputs.
21+
*/
22+
const GITHUB_PARAM_ALIASES: ReadonlyArray<{
23+
from: string
24+
to: string
25+
operations: readonly string[]
26+
toBoolean?: true
27+
}> = [
28+
{
29+
from: 'reaction_content',
30+
to: 'content',
31+
operations: ['github_create_issue_reaction', 'github_create_comment_reaction'],
32+
},
33+
{
34+
from: 'milestone_title',
35+
to: 'title',
36+
operations: ['github_create_milestone', 'github_update_milestone'],
37+
},
38+
{
39+
from: 'milestone_description',
40+
to: 'description',
41+
operations: ['github_create_milestone', 'github_update_milestone'],
42+
},
43+
{ from: 'milestone_state', to: 'state', operations: ['github_list_milestones'] },
44+
{ from: 'milestone_sort', to: 'sort', operations: ['github_list_milestones'] },
45+
{ from: 'fork_name', to: 'name', operations: ['github_fork_repo'] },
46+
{ from: 'fork_sort', to: 'sort', operations: ['github_list_forks'] },
47+
{ from: 'gist_public', to: 'public', operations: ['github_create_gist'], toBoolean: true },
48+
]
49+
1250
export const GitHubBlock: BlockConfig<GitHubResponse> = {
1351
type: 'github',
1452
name: 'GitHub (Legacy)',
@@ -2284,53 +2322,39 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
22842322
*
22852323
* A tool param is populated only when a subBlock's `id` equals it — the
22862324
* serializer keys values by subBlock id, and nothing else renames them.
2287-
* Each field below renders, accepts input, and then arrives under a name
2288-
* its tool never reads.
2325+
* Each aliased field below renders, accepts input, and then arrives under
2326+
* a name its tool never reads.
2327+
*
2328+
* Every alias is scoped to the operations whose tool actually declares
2329+
* the target param, and that scoping is load-bearing. Seven of these
2330+
* sources are `mode: 'advanced'`, and `shouldSerializeSubBlock`
2331+
* (`serializer/index.ts:91-93`) serializes a non-empty advanced field
2332+
* WITHOUT evaluating its condition. So a `milestone_title` left over from
2333+
* an earlier operation is still in `params` after the user switches to,
2334+
* say, Update PR — and an unscoped alias would rewrite it to `title` and
2335+
* clobber the PR's own title with stale milestone data.
22892336
*
2290-
* Every assignment is guarded, and that is load-bearing rather than
2291-
* defensive. `generic-handler.ts` merges `{ ...inputs, ...params(inputs) }`
2292-
* and `providers/utils.ts` installs this same function as the provider
2293-
* `paramsTransform`, spreading its result over the model's tool-call
2294-
* arguments. An unconditional write would therefore clobber a
2295-
* model-supplied `content`/`title`/`sort` with `undefined` on the agent
2296-
* tool-calling path — which is the one path these fields work on today.
2337+
* Presence is tested rather than truthiness so that a deliberate `false`
2338+
* or `'false'` is not mistaken for an unset field; only nullish and empty
2339+
* defer to the tool's own default.
22972340
*
2298-
* `sort` has two sources and `title`/`description`/`state` share their
2299-
* names with fields on other operations. That is safe only because each
2300-
* source subBlock's `condition` binds it to a single operation, so at
2301-
* most one source of a given target is ever present in `params`.
2341+
* `generic-handler.ts` merges `{ ...inputs, ...params(inputs) }` and
2342+
* `providers/utils.ts` installs this as the provider `paramsTransform`,
2343+
* spreading over the model's tool-call arguments — so emitting a key the
2344+
* block did not supply would clobber a model-supplied value on the agent
2345+
* path, which is the one path these fields work on today.
23022346
*/
23032347
params: (params) => {
23042348
const result: Record<string, unknown> = {}
2349+
const operation = typeof params.operation === 'string' ? params.operation : ''
23052350

2306-
if (params.reaction_content) result.content = params.reaction_content
2307-
if (params.milestone_title) result.title = params.milestone_title
2308-
if (params.milestone_description) result.description = params.milestone_description
2309-
if (params.milestone_state) result.state = params.milestone_state
2310-
if (params.milestone_sort) result.sort = params.milestone_sort
2311-
if (params.fork_name) result.name = params.fork_name
2312-
if (params.fork_sort) result.sort = params.fork_sort
2351+
const isSet = (value: unknown) => value !== undefined && value !== null && value !== ''
23132352

2314-
/**
2315-
* A dropdown stores its option id, so this arrives as the string
2316-
* 'true'/'false' while the tool declares `public` as a boolean. The
2317-
* generic handler only JSON-parses `json`/`array` inputs, so nothing
2318-
* else coerces it.
2319-
*
2320-
* Presence is tested rather than truthiness, because boolean `false` is
2321-
* a real selection: the block declares this input as `boolean`, so a
2322-
* writer following that schema stores `false` rather than `'false'`.
2323-
* Under a truthy check the two disagree — `'false'` would force the
2324-
* gist secret while `false` was dropped, letting a model-supplied
2325-
* `public: true` through on the agent path. Only an unset field
2326-
* (nullish or empty) defers to the tool's own default.
2327-
*/
2328-
if (
2329-
params.gist_public !== undefined &&
2330-
params.gist_public !== null &&
2331-
params.gist_public !== ''
2332-
) {
2333-
result.public = params.gist_public === true || params.gist_public === 'true'
2353+
for (const alias of GITHUB_PARAM_ALIASES) {
2354+
if (!alias.operations.includes(operation)) continue
2355+
const value = params[alias.from]
2356+
if (!isSet(value)) continue
2357+
result[alias.to] = alias.toBoolean ? value === true || value === 'true' : value
23342358
}
23352359

23362360
return result

0 commit comments

Comments
 (0)