Skip to content

Commit 784992f

Browse files
v0.3.50: debounce moved server side, hasWorkflowChanged fixes, advanced mode/serializer fix, jira fix, billing notifs
2 parents d45324b + 5218dd4 commit 784992f

File tree

73 files changed

+7565
-1768
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

73 files changed

+7565
-1768
lines changed

apps/docs/content/docs/tools/jira.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ Retrieve detailed information about a specific Jira issue
5858
| Parameter | Type | Required | Description |
5959
| --------- | ---- | -------- | ----------- |
6060
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
61-
| `projectId` | string | No | Jira project ID to retrieve issues from. If not provided, all issues will be retrieved. |
61+
| `projectId` | string | No | Jira project ID \(optional; not required to retrieve a single issue\). |
6262
| `issueKey` | string | Yes | Jira issue key to retrieve \(e.g., PROJ-123\) |
6363
| `cloudId` | string | No | Jira Cloud ID for the instance. If not provided, it will be fetched using the domain. |
6464

apps/sim/app/api/tools/discord/channels/route.ts

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,8 @@ export async function POST(request: Request) {
8585

8686
logger.info(`Fetching all Discord channels for server: ${serverId}`)
8787

88-
// Fetch all channels from Discord API
88+
// Listing guild channels with a bot token is allowed if the bot is in the guild.
89+
// Keep the request, but if unauthorized, return an empty list so the selector doesn't hard fail.
8990
const response = await fetch(`https://discord.com/api/v10/guilds/${serverId}/channels`, {
9091
method: 'GET',
9192
headers: {
@@ -95,20 +96,14 @@ export async function POST(request: Request) {
9596
})
9697

9798
if (!response.ok) {
98-
logger.error('Discord API error:', {
99-
status: response.status,
100-
statusText: response.statusText,
101-
})
102-
103-
let errorMessage
104-
try {
105-
const errorData = await response.json()
106-
logger.error('Error details:', errorData)
107-
errorMessage = errorData.message || `Failed to fetch channels (${response.status})`
108-
} catch (_e) {
109-
errorMessage = `Failed to fetch channels: ${response.status} ${response.statusText}`
110-
}
111-
return NextResponse.json({ error: errorMessage }, { status: response.status })
99+
logger.warn(
100+
'Discord API returned non-OK for channels; returning empty list to avoid UX break',
101+
{
102+
status: response.status,
103+
statusText: response.statusText,
104+
}
105+
)
106+
return NextResponse.json({ channels: [] })
112107
}
113108

114109
const channels = (await response.json()) as DiscordChannel[]

apps/sim/app/api/tools/discord/servers/route.ts

Lines changed: 8 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -64,46 +64,14 @@ export async function POST(request: Request) {
6464
})
6565
}
6666

67-
// Otherwise, fetch all servers the bot is in
68-
logger.info('Fetching all Discord servers')
69-
70-
const response = await fetch('https://discord.com/api/v10/users/@me/guilds', {
71-
method: 'GET',
72-
headers: {
73-
Authorization: `Bot ${botToken}`,
74-
'Content-Type': 'application/json',
75-
},
76-
})
77-
78-
if (!response.ok) {
79-
logger.error('Discord API error:', {
80-
status: response.status,
81-
statusText: response.statusText,
82-
})
83-
84-
let errorMessage
85-
try {
86-
const errorData = await response.json()
87-
logger.error('Error details:', errorData)
88-
errorMessage = errorData.message || `Failed to fetch servers (${response.status})`
89-
} catch (_e) {
90-
errorMessage = `Failed to fetch servers: ${response.status} ${response.statusText}`
91-
}
92-
return NextResponse.json({ error: errorMessage }, { status: response.status })
93-
}
94-
95-
const servers = (await response.json()) as DiscordServer[]
96-
logger.info(`Successfully fetched ${servers.length} servers`)
97-
98-
return NextResponse.json({
99-
servers: servers.map((server: DiscordServer) => ({
100-
id: server.id,
101-
name: server.name,
102-
icon: server.icon
103-
? `https://cdn.discordapp.com/icons/${server.id}/${server.icon}.png`
104-
: null,
105-
})),
106-
})
67+
// Listing guilds via REST requires a user OAuth2 access token with the 'guilds' scope.
68+
// A bot token cannot call /users/@me/guilds and will return 401.
69+
// Since this selector only has a bot token, return an empty list instead of erroring
70+
// and let users provide a Server ID in advanced mode.
71+
logger.info(
72+
'Skipping guild listing: bot token cannot list /users/@me/guilds; returning empty list'
73+
)
74+
return NextResponse.json({ servers: [] })
10775
} catch (error) {
10876
logger.error('Error processing request:', error)
10977
return NextResponse.json(

apps/sim/app/api/tools/jira/issues/route.ts

Lines changed: 95 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,40 @@ export const dynamic = 'force-dynamic'
66

77
const logger = createLogger('JiraIssuesAPI')
88

9+
// Helper functions
10+
const createErrorResponse = async (response: Response, defaultMessage: string) => {
11+
try {
12+
const errorData = await response.json()
13+
return errorData.message || errorData.errorMessages?.[0] || defaultMessage
14+
} catch {
15+
return defaultMessage
16+
}
17+
}
18+
19+
const validateRequiredParams = (domain: string | null, accessToken: string | null) => {
20+
if (!domain) {
21+
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
22+
}
23+
if (!accessToken) {
24+
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
25+
}
26+
return null
27+
}
28+
929
export async function POST(request: Request) {
1030
try {
1131
const { domain, accessToken, issueKeys = [], cloudId: providedCloudId } = await request.json()
1232

13-
if (!domain) {
14-
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
15-
}
16-
17-
if (!accessToken) {
18-
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
19-
}
33+
const validationError = validateRequiredParams(domain || null, accessToken || null)
34+
if (validationError) return validationError
2035

2136
if (issueKeys.length === 0) {
2237
logger.info('No issue keys provided, returning empty result')
2338
return NextResponse.json({ issues: [] })
2439
}
2540

2641
// Use provided cloudId or fetch it if not provided
27-
const cloudId = providedCloudId || (await getJiraCloudId(domain, accessToken))
42+
const cloudId = providedCloudId || (await getJiraCloudId(domain!, accessToken!))
2843

2944
// Build the URL using cloudId for Jira API
3045
const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/bulkfetch`
@@ -53,47 +68,24 @@ export async function POST(request: Request) {
5368

5469
if (!response.ok) {
5570
logger.error(`Jira API error: ${response.status} ${response.statusText}`)
56-
let errorMessage
57-
58-
try {
59-
const errorData = await response.json()
60-
logger.error('Error details:', JSON.stringify(errorData, null, 2))
61-
errorMessage = errorData.message || `Failed to fetch Jira issues (${response.status})`
62-
} catch (e) {
63-
logger.error('Could not parse error response as JSON:', e)
64-
65-
try {
66-
const _text = await response.text()
67-
errorMessage = `Failed to fetch Jira issues: ${response.status} ${response.statusText}`
68-
} catch (_textError) {
69-
errorMessage = `Failed to fetch Jira issues: ${response.status} ${response.statusText}`
70-
}
71-
}
72-
71+
const errorMessage = await createErrorResponse(
72+
response,
73+
`Failed to fetch Jira issues (${response.status})`
74+
)
7375
return NextResponse.json({ error: errorMessage }, { status: response.status })
7476
}
7577

7678
const data = await response.json()
77-
78-
if (data.issues && data.issues.length > 0) {
79-
data.issues.slice(0, 3).forEach((issue: any) => {
80-
logger.info(`- ${issue.key}: ${issue.fields.summary}`)
81-
})
82-
}
83-
84-
return NextResponse.json({
85-
issues: data.issues
86-
? data.issues.map((issue: any) => ({
87-
id: issue.key,
88-
name: issue.fields.summary,
89-
mimeType: 'jira/issue',
90-
url: `https://${domain}/browse/${issue.key}`,
91-
modifiedTime: issue.fields.updated,
92-
webViewLink: `https://${domain}/browse/${issue.key}`,
93-
}))
94-
: [],
95-
cloudId, // Return the cloudId so it can be cached
96-
})
79+
const issues = (data.issues || []).map((issue: any) => ({
80+
id: issue.key,
81+
name: issue.fields.summary,
82+
mimeType: 'jira/issue',
83+
url: `https://${domain}/browse/${issue.key}`,
84+
modifiedTime: issue.fields.updated,
85+
webViewLink: `https://${domain}/browse/${issue.key}`,
86+
}))
87+
88+
return NextResponse.json({ issues, cloudId })
9789
} catch (error) {
9890
logger.error('Error fetching Jira issues:', error)
9991
return NextResponse.json(
@@ -111,83 +103,79 @@ export async function GET(request: Request) {
111103
const providedCloudId = url.searchParams.get('cloudId')
112104
const query = url.searchParams.get('query') || ''
113105
const projectId = url.searchParams.get('projectId') || ''
106+
const manualProjectId = url.searchParams.get('manualProjectId') || ''
107+
const all = url.searchParams.get('all')?.toLowerCase() === 'true'
108+
const limitParam = Number.parseInt(url.searchParams.get('limit') || '', 10)
109+
const limit = Number.isFinite(limitParam) && limitParam > 0 ? limitParam : 0
114110

115-
if (!domain) {
116-
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
117-
}
118-
119-
if (!accessToken) {
120-
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
121-
}
122-
123-
// Use provided cloudId or fetch it if not provided
124-
const cloudId = providedCloudId || (await getJiraCloudId(domain, accessToken))
125-
logger.info('Using cloud ID:', cloudId)
126-
127-
// Build query parameters
128-
const params = new URLSearchParams()
129-
130-
// Only add query if it exists
131-
if (query) {
132-
params.append('query', query)
133-
}
111+
const validationError = validateRequiredParams(domain || null, accessToken || null)
112+
if (validationError) return validationError
134113

114+
const cloudId = providedCloudId || (await getJiraCloudId(domain!, accessToken!))
135115
let data: any
136116

137117
if (query) {
138-
const apiUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/picker?${params.toString()}`
139-
logger.info(`Fetching Jira issue suggestions from: ${apiUrl}`)
118+
const params = new URLSearchParams({ query })
119+
const apiUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/picker?${params}`
140120
const response = await fetch(apiUrl, {
141-
method: 'GET',
142121
headers: {
143122
Authorization: `Bearer ${accessToken}`,
144123
Accept: 'application/json',
145124
},
146125
})
147-
logger.info('Response status:', response.status, response.statusText)
126+
148127
if (!response.ok) {
149-
logger.error(`Jira API error: ${response.status} ${response.statusText}`)
150-
let errorMessage
151-
try {
152-
const errorData = await response.json()
153-
logger.error('Error details:', errorData)
154-
errorMessage =
155-
errorData.message || `Failed to fetch issue suggestions (${response.status})`
156-
} catch (_e) {
157-
errorMessage = `Failed to fetch issue suggestions: ${response.status} ${response.statusText}`
158-
}
128+
const errorMessage = await createErrorResponse(
129+
response,
130+
`Failed to fetch issue suggestions (${response.status})`
131+
)
159132
return NextResponse.json({ error: errorMessage }, { status: response.status })
160133
}
161134
data = await response.json()
162-
} else if (projectId) {
163-
// When no query, list latest issues for the selected project using Search API
164-
const searchParams = new URLSearchParams()
165-
searchParams.append('jql', `project=${projectId} ORDER BY updated DESC`)
166-
searchParams.append('maxResults', '25')
167-
searchParams.append('fields', 'summary,key')
168-
const searchUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search?${searchParams.toString()}`
169-
logger.info(`Fetching Jira issues via search from: ${searchUrl}`)
170-
const response = await fetch(searchUrl, {
171-
method: 'GET',
172-
headers: {
173-
Authorization: `Bearer ${accessToken}`,
174-
Accept: 'application/json',
175-
},
176-
})
177-
if (!response.ok) {
178-
let errorMessage
179-
try {
180-
const errorData = await response.json()
181-
logger.error('Jira Search API error details:', errorData)
182-
errorMessage =
183-
errorData.errorMessages?.[0] || `Failed to fetch issues (${response.status})`
184-
} catch (_e) {
185-
errorMessage = `Failed to fetch issues: ${response.status} ${response.statusText}`
186-
}
187-
return NextResponse.json({ error: errorMessage }, { status: response.status })
135+
} else if (projectId || manualProjectId) {
136+
const SAFETY_CAP = 1000
137+
const PAGE_SIZE = 100
138+
const target = Math.min(all ? limit || SAFETY_CAP : 25, SAFETY_CAP)
139+
const projectKey = (projectId || manualProjectId).trim()
140+
141+
const buildSearchUrl = (startAt: number) => {
142+
const params = new URLSearchParams({
143+
jql: `project=${projectKey} ORDER BY updated DESC`,
144+
maxResults: String(Math.min(PAGE_SIZE, target)),
145+
startAt: String(startAt),
146+
fields: 'summary,key,updated',
147+
})
148+
return `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search?${params}`
188149
}
189-
const searchData = await response.json()
190-
const issues = (searchData.issues || []).map((it: any) => ({
150+
151+
let startAt = 0
152+
let collected: any[] = []
153+
let total = 0
154+
155+
do {
156+
const response = await fetch(buildSearchUrl(startAt), {
157+
headers: {
158+
Authorization: `Bearer ${accessToken}`,
159+
Accept: 'application/json',
160+
},
161+
})
162+
163+
if (!response.ok) {
164+
const errorMessage = await createErrorResponse(
165+
response,
166+
`Failed to fetch issues (${response.status})`
167+
)
168+
return NextResponse.json({ error: errorMessage }, { status: response.status })
169+
}
170+
171+
const page = await response.json()
172+
const issues = page.issues || []
173+
total = page.total || issues.length
174+
collected = collected.concat(issues)
175+
startAt += PAGE_SIZE
176+
} while (all && collected.length < Math.min(total, target))
177+
178+
const issues = collected.slice(0, target).map((it: any) => ({
191179
key: it.key,
192180
summary: it.fields?.summary || it.key,
193181
}))
@@ -196,10 +184,7 @@ export async function GET(request: Request) {
196184
data = { sections: [], cloudId }
197185
}
198186

199-
return NextResponse.json({
200-
...data,
201-
cloudId, // Return the cloudId so it can be cached
202-
})
187+
return NextResponse.json({ ...data, cloudId })
203188
} catch (error) {
204189
logger.error('Error fetching Jira issue suggestions:', error)
205190
return NextResponse.json(

apps/sim/app/api/tools/jira/write/route.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,7 @@ export async function POST(request: Request) {
4242
return NextResponse.json({ error: 'Summary is required' }, { status: 400 })
4343
}
4444

45-
if (!issueType) {
46-
logger.error('Missing issue type in request')
47-
return NextResponse.json({ error: 'Issue type is required' }, { status: 400 })
48-
}
45+
const normalizedIssueType = issueType || 'Task'
4946

5047
// Use provided cloudId or fetch it if not provided
5148
const cloudId = providedCloudId || (await getJiraCloudId(domain, accessToken))
@@ -62,7 +59,7 @@ export async function POST(request: Request) {
6259
id: projectId,
6360
},
6461
issuetype: {
65-
name: issueType,
62+
name: normalizedIssueType,
6663
},
6764
summary: summary,
6865
}

0 commit comments

Comments
 (0)