Skip to content

Commit 3604c23

Browse files
committed
fix(github): resolve the PR head SHA for file comments
github_comment sent commit_id as undefined for every file comment: the param was hidden with no subBlock and no mapper write, so GitHub — which marks commit_id required on POST /pulls/{n}/comments — answered 422 on a path the commentType dropdown exposes. When commitId is absent the tool now fetches the pull request first and uses head.sha, mirroring how Jira resolves cloudId from domain. Also removes the position param, which GitHub marks deprecated ("Use line instead"); line is already a real subBlock.
1 parent c647676 commit 3604c23

4 files changed

Lines changed: 226 additions & 29 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
import { commentTool, commentV2Tool } from '@/tools/github/comment'
6+
import type { CreateCommentParams } from '@/tools/github/types'
7+
8+
const HEAD_SHA = 'a'.repeat(40)
9+
10+
const FILE_COMMENT_PARAMS: CreateCommentParams = {
11+
owner: 'octo',
12+
repo: 'demo',
13+
pullNumber: 7,
14+
body: 'Looks good',
15+
path: 'src/main.ts',
16+
line: 42,
17+
commentType: 'file_comment',
18+
apiKey: 'ghp_test',
19+
}
20+
21+
function pullRequestResponse(): Response {
22+
return Response.json({ number: 7, head: { sha: HEAD_SHA, ref: 'feature' } })
23+
}
24+
25+
function createdCommentResponse(): Response {
26+
return Response.json({
27+
id: 99,
28+
body: 'Looks good',
29+
html_url: 'https://github.com/octo/demo/pull/7#discussion_r99',
30+
path: 'src/main.ts',
31+
line: 42,
32+
side: 'RIGHT',
33+
commit_id: HEAD_SHA,
34+
created_at: '2026-01-01T00:00:00Z',
35+
updated_at: '2026-01-01T00:00:00Z',
36+
})
37+
}
38+
39+
describe('github_comment file comments', () => {
40+
const fetchMock = vi.fn()
41+
42+
beforeEach(() => {
43+
fetchMock.mockReset()
44+
vi.stubGlobal('fetch', fetchMock)
45+
})
46+
47+
afterEach(() => {
48+
vi.unstubAllGlobals()
49+
})
50+
51+
it('looks the pull request up when no commitId is supplied', () => {
52+
const url = commentTool.request.url as (params: CreateCommentParams) => string
53+
const method = commentTool.request.method as (params: CreateCommentParams) => string
54+
55+
expect(url(FILE_COMMENT_PARAMS)).toBe('https://api.github.com/repos/octo/demo/pulls/7')
56+
expect(method(FILE_COMMENT_PARAMS)).toBe('GET')
57+
expect(commentTool.request.body?.(FILE_COMMENT_PARAMS)).toBeUndefined()
58+
})
59+
60+
it('posts the resolved head SHA as commit_id', async () => {
61+
fetchMock.mockResolvedValueOnce(createdCommentResponse())
62+
63+
const result = await commentTool.transformResponse!(pullRequestResponse(), FILE_COMMENT_PARAMS)
64+
65+
expect(fetchMock).toHaveBeenCalledTimes(1)
66+
const [requestUrl, init] = fetchMock.mock.calls[0]
67+
expect(requestUrl).toBe('https://api.github.com/repos/octo/demo/pulls/7/comments')
68+
expect(init.method).toBe('POST')
69+
expect(JSON.parse(init.body)).toEqual({
70+
body: 'Looks good',
71+
commit_id: HEAD_SHA,
72+
path: 'src/main.ts',
73+
line: 42,
74+
side: 'RIGHT',
75+
})
76+
expect(result.success).toBe(true)
77+
expect(result.output.metadata.commit_id).toBe(HEAD_SHA)
78+
})
79+
80+
it('resolves the head SHA for the v2 tool as well', async () => {
81+
fetchMock.mockResolvedValueOnce(createdCommentResponse())
82+
83+
const result = await commentV2Tool.transformResponse!(
84+
pullRequestResponse(),
85+
FILE_COMMENT_PARAMS
86+
)
87+
88+
expect(JSON.parse(fetchMock.mock.calls[0][1].body).commit_id).toBe(HEAD_SHA)
89+
expect(result.output.commit_id).toBe(HEAD_SHA)
90+
})
91+
92+
it('posts directly when commitId is supplied', () => {
93+
const params = { ...FILE_COMMENT_PARAMS, commitId: 'b'.repeat(40) }
94+
const url = commentTool.request.url as (params: CreateCommentParams) => string
95+
const method = commentTool.request.method as (params: CreateCommentParams) => string
96+
97+
expect(url(params)).toBe('https://api.github.com/repos/octo/demo/pulls/7/comments')
98+
expect(method(params)).toBe('POST')
99+
expect(commentTool.request.body?.(params)).toEqual({
100+
body: 'Looks good',
101+
commit_id: 'b'.repeat(40),
102+
path: 'src/main.ts',
103+
line: 42,
104+
side: 'RIGHT',
105+
})
106+
})
107+
108+
it('fails with an actionable error when the pull request has no head SHA', async () => {
109+
await expect(
110+
commentTool.transformResponse!(Response.json({ number: 7 }), FILE_COMMENT_PARAMS)
111+
).rejects.toThrow(/no head commit SHA for pull request octo\/demo#7/)
112+
expect(fetchMock).not.toHaveBeenCalled()
113+
})
114+
115+
it('leaves general PR comments on the reviews endpoint', () => {
116+
const params: CreateCommentParams = {
117+
owner: 'octo',
118+
repo: 'demo',
119+
pullNumber: 7,
120+
body: 'Nice',
121+
commentType: 'pr_comment',
122+
apiKey: 'ghp_test',
123+
}
124+
const url = commentTool.request.url as (params: CreateCommentParams) => string
125+
126+
expect(url(params)).toBe('https://api.github.com/repos/octo/demo/pulls/7/reviews')
127+
expect(commentTool.request.body?.(params)).toEqual({ body: 'Nice', event: 'COMMENT' })
128+
})
129+
130+
it('no longer exposes the deprecated position parameter', () => {
131+
expect(commentTool.params.position).toBeUndefined()
132+
})
133+
})

apps/sim/tools/github/comment.ts

Lines changed: 92 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,82 @@
1+
import { isRecordLike } from '@sim/utils/object'
2+
import { readGitHubErrorMessage } from '@/tools/github/response-parsers'
13
import type { CreateCommentParams, CreateCommentResponse } from '@/tools/github/types'
24
import { COMMENT_OUTPUT_PROPERTIES, USER_OUTPUT } from '@/tools/github/types'
35
import type { ToolConfig } from '@/tools/types'
46

7+
const GITHUB_API_BASE = 'https://api.github.com'
8+
9+
function githubHeaders(apiKey: string): Record<string, string> {
10+
return {
11+
Accept: 'application/vnd.github.v3+json',
12+
Authorization: `Bearer ${apiKey}`,
13+
'X-GitHub-Api-Version': '2022-11-28',
14+
}
15+
}
16+
17+
function pullRequestUrl(params: CreateCommentParams): string {
18+
return `${GITHUB_API_BASE}/repos/${params.owner}/${params.repo}/pulls/${params.pullNumber}`
19+
}
20+
21+
/**
22+
* GitHub requires `commit_id` on a pull request review comment. When the caller did
23+
* not supply one, the pull request is fetched first so its head SHA can be used —
24+
* mirroring how Jira resolves a missing `cloudId` from `domain`.
25+
*/
26+
function needsCommitLookup(params: CreateCommentParams): boolean {
27+
return params.commentType === 'file_comment' && !params.commitId
28+
}
29+
30+
function fileCommentBody(params: CreateCommentParams, commitId: string): Record<string, any> {
31+
return {
32+
body: params.body,
33+
commit_id: commitId,
34+
path: params.path,
35+
line: params.line,
36+
side: params.side || 'RIGHT',
37+
}
38+
}
39+
40+
function readHeadSha(pullRequest: unknown): string | undefined {
41+
if (!isRecordLike(pullRequest) || !isRecordLike(pullRequest.head)) return undefined
42+
const sha = pullRequest.head.sha
43+
return typeof sha === 'string' && sha ? sha : undefined
44+
}
45+
46+
/**
47+
* Returns the raw GitHub comment payload. For a file comment created without an
48+
* explicit `commitId`, `response` holds the pull request lookup instead: its head
49+
* SHA is read and the comment is posted in a follow-up request.
50+
*/
51+
async function readCommentPayload(
52+
response: Response,
53+
params?: CreateCommentParams
54+
): Promise<Record<string, any>> {
55+
if (!params || !needsCommitLookup(params)) return response.json()
56+
57+
const commitId = readHeadSha(await response.json())
58+
if (!commitId) {
59+
throw new Error(
60+
`GitHub returned no head commit SHA for pull request ${params.owner}/${params.repo}#${params.pullNumber}. Set commitId to comment on a specific commit.`
61+
)
62+
}
63+
64+
const commentResponse = await fetch(`${pullRequestUrl(params)}/comments`, {
65+
method: 'POST',
66+
headers: { ...githubHeaders(params.apiKey), 'Content-Type': 'application/json' },
67+
body: JSON.stringify(fileCommentBody(params, commitId)),
68+
})
69+
70+
if (!commentResponse.ok) {
71+
throw new Error(
72+
(await readGitHubErrorMessage(commentResponse)) ??
73+
`Failed to create file comment (HTTP ${commentResponse.status})`
74+
)
75+
}
76+
77+
return commentResponse.json()
78+
}
79+
580
export const commentTool: ToolConfig<CreateCommentParams, CreateCommentResponse> = {
681
id: 'github_comment',
782
name: 'GitHub PR Commenter',
@@ -39,12 +114,6 @@ export const commentTool: ToolConfig<CreateCommentParams, CreateCommentResponse>
39114
visibility: 'user-or-llm',
40115
description: 'File path for review comment',
41116
},
42-
position: {
43-
type: 'number',
44-
required: false,
45-
visibility: 'hidden',
46-
description: 'Line number for review comment',
47-
},
48117
commentType: {
49118
type: 'string',
50119
required: false,
@@ -68,7 +137,7 @@ export const commentTool: ToolConfig<CreateCommentParams, CreateCommentResponse>
68137
type: 'string',
69138
required: false,
70139
visibility: 'hidden',
71-
description: 'The SHA of the commit to comment on',
140+
description: 'The SHA of the commit to comment on. Defaults to the pull request head commit.',
72141
},
73142
apiKey: {
74143
type: 'string',
@@ -80,26 +149,22 @@ export const commentTool: ToolConfig<CreateCommentParams, CreateCommentResponse>
80149

81150
request: {
82151
url: (params) => {
152+
if (needsCommitLookup(params)) {
153+
return pullRequestUrl(params)
154+
}
83155
if (params.path) {
84-
return `https://api.github.com/repos/${params.owner}/${params.repo}/pulls/${params.pullNumber}/comments`
156+
return `${pullRequestUrl(params)}/comments`
85157
}
86-
return `https://api.github.com/repos/${params.owner}/${params.repo}/pulls/${params.pullNumber}/reviews`
158+
return `${pullRequestUrl(params)}/reviews`
87159
},
88-
method: 'POST',
89-
headers: (params) => ({
90-
Accept: 'application/vnd.github.v3+json',
91-
Authorization: `Bearer ${params.apiKey}`,
92-
'X-GitHub-Api-Version': '2022-11-28',
93-
}),
160+
method: (params) => (needsCommitLookup(params) ? 'GET' : 'POST'),
161+
headers: (params) => githubHeaders(params.apiKey),
94162
body: (params) => {
163+
if (needsCommitLookup(params)) {
164+
return undefined
165+
}
95166
if (params.commentType === 'file_comment') {
96-
return {
97-
body: params.body,
98-
commit_id: params.commitId,
99-
path: params.path,
100-
line: params.line || params.position,
101-
side: params.side || 'RIGHT',
102-
}
167+
return fileCommentBody(params, params.commitId as string)
103168
}
104169
return {
105170
body: params.body,
@@ -108,8 +173,8 @@ export const commentTool: ToolConfig<CreateCommentParams, CreateCommentResponse>
108173
},
109174
},
110175

111-
transformResponse: async (response) => {
112-
const data = await response.json()
176+
transformResponse: async (response, params) => {
177+
const data = await readCommentPayload(response, params)
113178

114179
// Create a human-readable content string
115180
const content = `Comment created: "${data.body}"`
@@ -141,15 +206,15 @@ export const commentTool: ToolConfig<CreateCommentParams, CreateCommentResponse>
141206
},
142207
}
143208

144-
export const commentV2Tool: ToolConfig = {
209+
export const commentV2Tool: ToolConfig<CreateCommentParams> = {
145210
id: 'github_comment_v2',
146211
name: commentTool.name,
147212
description: commentTool.description,
148213
version: '2.0.0',
149214
params: commentTool.params,
150215
request: commentTool.request,
151-
transformResponse: async (response: Response) => {
152-
const data = await response.json()
216+
transformResponse: async (response: Response, params?: CreateCommentParams) => {
217+
const data = await readCommentPayload(response, params)
153218
return {
154219
success: true,
155220
output: {

apps/sim/tools/github/types.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -866,7 +866,6 @@ export interface PRV2OperationParams extends PROperationParams {
866866
export interface CreateCommentParams extends PROperationParams {
867867
body: string
868868
path?: string
869-
position?: number
870869
line?: number
871870
side?: string
872871
commitId?: string

0 commit comments

Comments
 (0)