Skip to content

Commit 552ebd1

Browse files
committed
fix(github): make branch protection usable and wire workflow_id to its endpoint
`github_update_branch_protection` could not be called. `restrictions` was a required tool param with no subBlock anywhere in the block, so every run threw "Restrictions is required for GitHub Update Branch Protection" before the request. The other three body fields were `required: true` behind `mode: 'advanced'` subBlocks that are hidden by default, and their subBlock types did not match their param types. GitHub documents all four as required but nullable - "Set to null to disable" - so "required" there means present in the body, which `null` satisfies. Sim's `required: true` means the user must supply a non-empty value, which is strictly stronger. The params are now optional and the body builder supplies the explicit `null` GitHub demands for each field left unset, normalizing the editor's JSON strings and the dropdown's 'true'/'false' along the way. A `restrictions` subBlock is added. `workflow_id` rendered for `github_list_workflow_runs` but the tool declared no such param and always hit the repo-wide `/actions/runs`. It now selects `GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs`, which GitHub documents as accepting the same query filters. No subBlock id is renamed or removed and no visibility is flipped.
1 parent b19553e commit 552ebd1

8 files changed

Lines changed: 401 additions & 44 deletions

File tree

‎apps/docs/content/docs/integrations/github.mdx‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1019,10 +1019,10 @@ Update branch protection rules for a specific branch, including status checks, r
10191019
| `owner` | string | Yes | Repository owner \(user or organization\) |
10201020
| `repo` | string | Yes | Repository name |
10211021
| `branch` | string | Yes | Branch name |
1022-
| `required_status_checks` | object | Yes | Required status check configuration \(null to disable\). Object with strict \(boolean\) and contexts \(string array\) |
1023-
| `enforce_admins` | boolean | Yes | Whether to enforce restrictions for administrators |
1024-
| `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 |
1025-
| `restrictions` | object | Yes | Push restrictions \(null to disable\). Object with users \(string array\) and teams \(string array\) |
1022+
| `required_status_checks` | object | No | Required status check configuration. Object with strict \(boolean\) and contexts \(string array\). Omit to disable status checks — GitHub receives an explicit null. |
1023+
| `enforce_admins` | boolean | No | Whether to enforce restrictions for administrators. Omit to disable admin enforcement — GitHub receives an explicit null. |
1024+
| `required_pull_request_reviews` | object | No | PR review requirements. Object with optional required_approving_review_count, dismiss_stale_reviews, require_code_owner_reviews. Omit to disable review requirements — GitHub receives an explicit null. |
1025+
| `restrictions` | object | No | Push restrictions, available only for organization-owned repositories. Object with users \(string array\), teams \(string array\) and optional apps \(string array\). Omit to disable push restrictions — GitHub receives an explicit null. |
10261026
| `apiKey` | string | Yes | GitHub Personal Access Token |
10271027

10281028
#### Output
@@ -1805,14 +1805,15 @@ Trigger a workflow dispatch event for a GitHub Actions workflow. The workflow mu
18051805

18061806
### GitHub List Workflow Runs
18071807

1808-
List workflow runs for a repository. Supports filtering by actor, branch, event, and status. Returns run details including status, conclusion, and links.
1808+
List workflow runs for a repository, or for a single workflow when a workflow ID or filename is given. Supports filtering by actor, branch, event, and status. Returns run details including status, conclusion, and links.
18091809

18101810
#### Input
18111811

18121812
| Parameter | Type | Required | Description |
18131813
| --------- | ---- | -------- | ----------- |
18141814
| `owner` | string | Yes | Repository owner \(user or organization\) |
18151815
| `repo` | string | Yes | Repository name |
1816+
| `workflow_id` | string | No | The ID of the workflow. You can also pass the workflow file name as a string \(e.g., ci.yml\). Omit to list runs across the whole repository. |
18161817
| `actor` | string | No | Filter by user who triggered the workflow |
18171818
| `branch` | string | No | Filter by branch name |
18181819
| `event` | string | No | Filter by event type \(e.g., push, pull_request, workflow_dispatch\) |

‎apps/sim/blocks/blocks/github.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -993,6 +993,14 @@ export const GitHubBlock: BlockConfig<GitHubResponse> = {
993993
condition: { field: 'operation', value: 'github_update_branch_protection' },
994994
mode: 'advanced',
995995
},
996+
{
997+
id: 'restrictions',
998+
title: 'Push Restrictions',
999+
type: 'short-input',
1000+
placeholder: 'JSON: {"users":["octocat"],"teams":["admins"]}',
1001+
condition: { field: 'operation', value: 'github_update_branch_protection' },
1002+
mode: 'advanced',
1003+
},
9961004
// Issue operations parameters
9971005
{
9981006
id: 'title',
@@ -2407,9 +2415,10 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
24072415
ref: { type: 'string', description: 'Branch, tag, or commit reference' },
24082416
// Branch parameters
24092417
protected: { type: 'string', description: 'Protection status filter' },
2410-
required_status_checks: { type: 'string', description: 'Required status checks JSON' },
2418+
required_status_checks: { type: 'json', description: 'Required status checks JSON' },
24112419
enforce_admins: { type: 'boolean', description: 'Enforce for admins' },
2412-
required_pull_request_reviews: { type: 'string', description: 'Required PR reviews JSON' },
2420+
required_pull_request_reviews: { type: 'json', description: 'Required PR reviews JSON' },
2421+
restrictions: { type: 'json', description: 'Push restrictions JSON' },
24132422
// Issue parameters
24142423
labels: { type: 'string', description: 'Comma-separated labels' },
24152424
assignees: { type: 'string', description: 'Comma-separated assignees' },

‎apps/sim/tools/generated/tool-metadata.ts‎

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it, vi } from 'vitest'
5+
import { GitHubBlock } from '@/blocks/blocks/github'
6+
import { listWorkflowRunsTool, listWorkflowRunsV2Tool } from '@/tools/github/list_workflow_runs'
7+
import {
8+
updateBranchProtectionTool,
9+
updateBranchProtectionV2Tool,
10+
} from '@/tools/github/update_branch_protection'
11+
import { validateRequiredParametersAfterMerge } from '@/tools/utils'
12+
13+
vi.unmock('@/tools/registry')
14+
15+
const BASE_PROTECTION_PARAMS = {
16+
owner: 'sim',
17+
repo: 'sim',
18+
branch: 'main',
19+
apiKey: 'token',
20+
}
21+
22+
/** The four body fields GitHub documents as "required" but nullable. */
23+
const NULLABLE_BODY_FIELDS = [
24+
'required_status_checks',
25+
'enforce_admins',
26+
'required_pull_request_reviews',
27+
'restrictions',
28+
] as const
29+
30+
const buildProtectionBody = (params: Record<string, unknown>) =>
31+
updateBranchProtectionTool.request.body?.(params as never) as Record<string, unknown>
32+
33+
const buildRunsUrl = (params: Record<string, unknown>) => {
34+
const url = listWorkflowRunsTool.request.url
35+
return typeof url === 'function' ? url(params as never) : url
36+
}
37+
38+
describe('github_update_branch_protection required modelling', () => {
39+
it.each(NULLABLE_BODY_FIELDS)('declares %s optional so the call can be made', (field) => {
40+
expect(updateBranchProtectionTool.params[field].required).toBe(false)
41+
})
42+
43+
it('exposes restrictions as a declared param', () => {
44+
expect(updateBranchProtectionTool.params.restrictions).toBeDefined()
45+
})
46+
47+
it('passes merge-time required validation with only owner/repo/branch supplied', () => {
48+
expect(() =>
49+
validateRequiredParametersAfterMerge(
50+
updateBranchProtectionTool.id,
51+
updateBranchProtectionTool as never,
52+
BASE_PROTECTION_PARAMS
53+
)
54+
).not.toThrow()
55+
})
56+
57+
it('shares the fixed params and request with the v2 tool', () => {
58+
expect(updateBranchProtectionV2Tool.params).toBe(updateBranchProtectionTool.params)
59+
expect(updateBranchProtectionV2Tool.request).toBe(updateBranchProtectionTool.request)
60+
})
61+
})
62+
63+
describe('github_update_branch_protection body builder', () => {
64+
it('emits an explicit null for every field left unset', () => {
65+
const body = buildProtectionBody(BASE_PROTECTION_PARAMS)
66+
for (const field of NULLABLE_BODY_FIELDS) {
67+
expect(body).toHaveProperty(field)
68+
expect(body[field]).toBeNull()
69+
}
70+
})
71+
72+
it('emits null rather than dropping a field left blank by the editor', () => {
73+
const body = buildProtectionBody({
74+
...BASE_PROTECTION_PARAMS,
75+
required_status_checks: '',
76+
enforce_admins: '',
77+
required_pull_request_reviews: '',
78+
restrictions: '',
79+
})
80+
for (const field of NULLABLE_BODY_FIELDS) {
81+
expect(body[field]).toBeNull()
82+
}
83+
})
84+
85+
it('parses the JSON strings the short-input subBlocks produce', () => {
86+
const body = buildProtectionBody({
87+
...BASE_PROTECTION_PARAMS,
88+
required_status_checks: '{"strict":true,"contexts":["ci/test"]}',
89+
required_pull_request_reviews: '{"required_approving_review_count":2}',
90+
restrictions: '{"users":["octocat"],"teams":["admins"]}',
91+
})
92+
expect(body.required_status_checks).toEqual({ strict: true, contexts: ['ci/test'] })
93+
expect(body.required_pull_request_reviews).toEqual({ required_approving_review_count: 2 })
94+
expect(body.restrictions).toEqual({ users: ['octocat'], teams: ['admins'] })
95+
})
96+
97+
it('passes already-structured objects through untouched', () => {
98+
const restrictions = { users: [], teams: [], apps: ['dependabot'] }
99+
const body = buildProtectionBody({ ...BASE_PROTECTION_PARAMS, restrictions })
100+
expect(body.restrictions).toEqual(restrictions)
101+
})
102+
103+
it('rejects a non-object JSON value instead of shipping it to GitHub', () => {
104+
expect(() =>
105+
buildProtectionBody({ ...BASE_PROTECTION_PARAMS, restrictions: '["octocat"]' })
106+
).toThrow(/JSON objects/)
107+
})
108+
109+
it("coerces the dropdown's 'true'/'false' strings to booleans", () => {
110+
expect(
111+
buildProtectionBody({ ...BASE_PROTECTION_PARAMS, enforce_admins: 'true' }).enforce_admins
112+
).toBe(true)
113+
expect(
114+
buildProtectionBody({ ...BASE_PROTECTION_PARAMS, enforce_admins: 'false' }).enforce_admins
115+
).toBe(false)
116+
})
117+
118+
it('preserves a real boolean false rather than treating it as unset', () => {
119+
expect(
120+
buildProtectionBody({ ...BASE_PROTECTION_PARAMS, enforce_admins: false }).enforce_admins
121+
).toBe(false)
122+
})
123+
})
124+
125+
describe('github_list_workflow_runs workflow_id', () => {
126+
it('declares an optional workflow_id param', () => {
127+
expect(listWorkflowRunsTool.params.workflow_id).toBeDefined()
128+
expect(listWorkflowRunsTool.params.workflow_id.required).toBe(false)
129+
})
130+
131+
it('targets the per-workflow endpoint when a workflow id is supplied', () => {
132+
expect(buildRunsUrl({ owner: 'sim', repo: 'sim', workflow_id: 'ci.yml' })).toBe(
133+
'https://api.github.com/repos/sim/sim/actions/workflows/ci.yml/runs'
134+
)
135+
})
136+
137+
it('accepts a numeric workflow id', () => {
138+
expect(buildRunsUrl({ owner: 'sim', repo: 'sim', workflow_id: 123456 })).toBe(
139+
'https://api.github.com/repos/sim/sim/actions/workflows/123456/runs'
140+
)
141+
})
142+
143+
it('keeps query filters on the per-workflow endpoint', () => {
144+
const url = buildRunsUrl({
145+
owner: 'sim',
146+
repo: 'sim',
147+
workflow_id: 'ci.yml',
148+
branch: 'main',
149+
status: 'completed',
150+
per_page: 50,
151+
})
152+
expect(url).toContain('/actions/workflows/ci.yml/runs?')
153+
expect(url).toContain('branch=main')
154+
expect(url).toContain('status=completed')
155+
expect(url).toContain('per_page=50')
156+
})
157+
158+
it.each([undefined, null, '', ' '])(
159+
'falls back to the repository-wide endpoint for %p',
160+
(workflow_id) => {
161+
expect(buildRunsUrl({ owner: 'sim', repo: 'sim', workflow_id })).toBe(
162+
'https://api.github.com/repos/sim/sim/actions/runs'
163+
)
164+
}
165+
)
166+
167+
it('escapes a workflow id so it cannot break out of its path segment', () => {
168+
expect(buildRunsUrl({ owner: 'sim', repo: 'sim', workflow_id: 'a/../b' })).toContain(
169+
'/actions/workflows/a%2F..%2Fb/runs'
170+
)
171+
})
172+
173+
it('shares the wired request with the v2 tool', () => {
174+
expect(listWorkflowRunsV2Tool.request).toBe(listWorkflowRunsTool.request)
175+
expect(listWorkflowRunsV2Tool.params).toBe(listWorkflowRunsTool.params)
176+
})
177+
})
178+
179+
describe('github block wiring', () => {
180+
const subBlocks = GitHubBlock.subBlocks
181+
182+
it('renders a restrictions subBlock scoped to update branch protection', () => {
183+
const restrictions = subBlocks.filter((sub) => sub.id === 'restrictions')
184+
expect(restrictions).toHaveLength(1)
185+
expect(restrictions[0].condition).toEqual({
186+
field: 'operation',
187+
value: 'github_update_branch_protection',
188+
})
189+
})
190+
191+
it('declares the JSON protection fields as json inputs so they are parsed', () => {
192+
for (const field of [
193+
'required_status_checks',
194+
'required_pull_request_reviews',
195+
'restrictions',
196+
]) {
197+
expect(GitHubBlock.inputs[field]).toEqual(expect.objectContaining({ type: 'json' }))
198+
}
199+
})
200+
201+
it('renders workflow_id for the operation whose tool now reads it', () => {
202+
const listRunsWorkflowId = subBlocks.find(
203+
(sub) =>
204+
sub.id === 'workflow_id' &&
205+
JSON.stringify(sub.condition).includes('github_list_workflow_runs')
206+
)
207+
expect(listRunsWorkflowId).toBeDefined()
208+
expect(listWorkflowRunsTool.params.workflow_id).toBeDefined()
209+
})
210+
211+
/**
212+
* `shouldSerializeSubBlock` (serializer/index.ts:91-93) serializes a non-empty
213+
* `mode: 'advanced'` field without evaluating its condition, so values entered
214+
* under one operation are still in `params` after the user switches operations.
215+
*/
216+
it('does not let a stale advanced value from another operation reach the mapper output', () => {
217+
const stale = {
218+
operation: 'github_update_branch_protection',
219+
restrictions: '{"users":["octocat"],"teams":[]}',
220+
milestone_title: 'Stale milestone left over from Create Milestone',
221+
milestone_description: 'Stale description',
222+
gist_public: 'true',
223+
fork_name: 'stale-fork',
224+
reaction_content: '+1',
225+
}
226+
const mapped = GitHubBlock.tools.config?.params?.(stale) ?? {}
227+
expect(mapped).toEqual({})
228+
})
229+
230+
it('does not let a stale branch-protection value reach an unrelated operation', () => {
231+
const stale = {
232+
operation: 'github_create_issue',
233+
restrictions: '{"users":["octocat"],"teams":[]}',
234+
enforce_admins: 'true',
235+
required_status_checks: '{"strict":true,"contexts":[]}',
236+
workflow_id: 'ci.yml',
237+
milestone_title: 'Stale milestone title',
238+
}
239+
const mapped = GitHubBlock.tools.config?.params?.(stale) ?? {}
240+
expect(mapped).toEqual({})
241+
})
242+
243+
it('still maps an alias for the operation that owns it', () => {
244+
const mapped = GitHubBlock.tools.config?.params?.({
245+
operation: 'github_create_milestone',
246+
milestone_title: 'v1.0',
247+
})
248+
expect(mapped).toEqual({ title: 'v1.0' })
249+
})
250+
251+
/**
252+
* `generic-handler.ts` merges `{ ...inputs, ...params(inputs) }` and
253+
* `providers/utils.ts` installs this same function as the provider
254+
* `paramsTransform`, spreading it over the model's tool-call arguments. Emitting
255+
* a key the block did not supply would overwrite a model-supplied value with
256+
* `undefined`, so an unset source must produce no key at all — not an undefined
257+
* one. `toEqual` treats an undefined-valued key as absent, so this asserts on
258+
* the key list.
259+
*/
260+
it.each([
261+
['github_create_milestone', 'milestone_title', 'title'],
262+
['github_create_milestone', 'milestone_description', 'description'],
263+
['github_list_milestones', 'milestone_state', 'state'],
264+
['github_fork_repo', 'fork_name', 'name'],
265+
['github_create_gist', 'gist_public', 'public'],
266+
['github_create_issue_reaction', 'reaction_content', 'content'],
267+
])('emits no %s key for an unset %s source', (operation, source, target) => {
268+
for (const unset of [undefined, null, '']) {
269+
const mapped = GitHubBlock.tools.config?.params?.({ operation, [source]: unset }) ?? {}
270+
expect(Object.keys(mapped)).not.toContain(target)
271+
}
272+
const omitted = GitHubBlock.tools.config?.params?.({ operation }) ?? {}
273+
expect(Object.keys(omitted)).toHaveLength(0)
274+
})
275+
276+
it('preserves a deliberate false rather than treating it as unset', () => {
277+
const mapped = GitHubBlock.tools.config?.params?.({
278+
operation: 'github_create_gist',
279+
gist_public: 'false',
280+
})
281+
expect(mapped).toEqual({ public: false })
282+
})
283+
})

‎apps/sim/tools/github/list_workflow_runs.ts‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export const listWorkflowRunsTool: ToolConfig<ListWorkflowRunsParams, ListWorkfl
1212
id: 'github_list_workflow_runs',
1313
name: 'GitHub List Workflow Runs',
1414
description:
15-
'List workflow runs for a repository. Supports filtering by actor, branch, event, and status. Returns run details including status, conclusion, and links.',
15+
'List workflow runs for a repository, or for a single workflow when a workflow ID or filename is given. Supports filtering by actor, branch, event, and status. Returns run details including status, conclusion, and links.',
1616
version: '1.0.0',
1717

1818
params: {
@@ -28,6 +28,13 @@ export const listWorkflowRunsTool: ToolConfig<ListWorkflowRunsParams, ListWorkfl
2828
visibility: 'user-or-llm',
2929
description: 'Repository name',
3030
},
31+
workflow_id: {
32+
type: 'string',
33+
required: false,
34+
visibility: 'user-or-llm',
35+
description:
36+
'The ID of the workflow. You can also pass the workflow file name as a string (e.g., ci.yml). Omit to list runs across the whole repository.',
37+
},
3138
actor: {
3239
type: 'string',
3340
required: false,
@@ -76,8 +83,20 @@ export const listWorkflowRunsTool: ToolConfig<ListWorkflowRunsParams, ListWorkfl
7683

7784
request: {
7885
url: (params) => {
86+
/**
87+
* A workflow id or filename selects the per-workflow endpoint
88+
* `GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs`, which
89+
* accepts the same query filters as the repository-wide `/actions/runs`.
90+
*/
91+
const workflowId =
92+
params.workflow_id === undefined || params.workflow_id === null
93+
? ''
94+
: String(params.workflow_id).trim()
95+
const basePath = `https://api.github.com/repos/${params.owner}/${params.repo}/actions`
7996
const url = new URL(
80-
`https://api.github.com/repos/${params.owner}/${params.repo}/actions/runs`
97+
workflowId === ''
98+
? `${basePath}/runs`
99+
: `${basePath}/workflows/${encodeURIComponent(workflowId)}/runs`
81100
)
82101
if (params.actor) {
83102
url.searchParams.append('actor', params.actor)

0 commit comments

Comments
 (0)