@openchamber/web 1.5.5 → 1.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server/index.js CHANGED
@@ -721,6 +721,18 @@ const sanitizeSettingsUpdate = (payload) => {
721
721
  if (typeof candidate.markdownDisplayMode === 'string' && candidate.markdownDisplayMode.length > 0) {
722
722
  result.markdownDisplayMode = candidate.markdownDisplayMode;
723
723
  }
724
+ if (typeof candidate.githubClientId === 'string') {
725
+ const trimmed = candidate.githubClientId.trim();
726
+ if (trimmed.length > 0) {
727
+ result.githubClientId = trimmed;
728
+ }
729
+ }
730
+ if (typeof candidate.githubScopes === 'string') {
731
+ const trimmed = candidate.githubScopes.trim();
732
+ if (trimmed.length > 0) {
733
+ result.githubScopes = trimmed;
734
+ }
735
+ }
724
736
  if (typeof candidate.showReasoningTraces === 'boolean') {
725
737
  result.showReasoningTraces = candidate.showReasoningTraces;
726
738
  }
@@ -3839,6 +3851,1053 @@ async function main(options = {}) {
3839
3851
  return authLibrary;
3840
3852
  };
3841
3853
 
3854
+ // ================= GitHub OAuth (Device Flow) =================
3855
+
3856
+ // Note: scopes may be overridden via OPENCHAMBER_GITHUB_SCOPES or settings.json (see github-auth.js).
3857
+
3858
+ let githubLibraries = null;
3859
+ const getGitHubLibraries = async () => {
3860
+ if (!githubLibraries) {
3861
+ const [auth, device, octokit] = await Promise.all([
3862
+ import('./lib/github-auth.js'),
3863
+ import('./lib/github-device-flow.js'),
3864
+ import('./lib/github-octokit.js'),
3865
+ ]);
3866
+ githubLibraries = { ...auth, ...device, ...octokit };
3867
+ }
3868
+ return githubLibraries;
3869
+ };
3870
+
3871
+ const getGitHubUserSummary = async (octokit) => {
3872
+ const me = await octokit.rest.users.getAuthenticated();
3873
+
3874
+ let email = typeof me.data.email === 'string' ? me.data.email : null;
3875
+ if (!email) {
3876
+ try {
3877
+ const emails = await octokit.rest.users.listEmailsForAuthenticatedUser({ per_page: 100 });
3878
+ const list = Array.isArray(emails?.data) ? emails.data : [];
3879
+ const primaryVerified = list.find((e) => e && e.primary && e.verified && typeof e.email === 'string');
3880
+ const anyVerified = list.find((e) => e && e.verified && typeof e.email === 'string');
3881
+ email = primaryVerified?.email || anyVerified?.email || null;
3882
+ } catch {
3883
+ // ignore (scope might be missing)
3884
+ }
3885
+ }
3886
+
3887
+ return {
3888
+ login: me.data.login,
3889
+ id: me.data.id,
3890
+ avatarUrl: me.data.avatar_url,
3891
+ name: typeof me.data.name === 'string' ? me.data.name : null,
3892
+ email,
3893
+ };
3894
+ };
3895
+
3896
+ app.get('/api/github/auth/status', async (_req, res) => {
3897
+ try {
3898
+ const { getGitHubAuth, getOctokitOrNull, clearGitHubAuth } = await getGitHubLibraries();
3899
+ const auth = getGitHubAuth();
3900
+ if (!auth?.accessToken) {
3901
+ return res.json({ connected: false });
3902
+ }
3903
+
3904
+ const octokit = getOctokitOrNull();
3905
+ if (!octokit) {
3906
+ return res.json({ connected: false });
3907
+ }
3908
+
3909
+ let user = null;
3910
+ try {
3911
+ user = await getGitHubUserSummary(octokit);
3912
+ } catch (error) {
3913
+ if (error?.status === 401) {
3914
+ clearGitHubAuth();
3915
+ return res.json({ connected: false });
3916
+ }
3917
+ }
3918
+
3919
+ const fallback = auth.user;
3920
+ const mergedUser = user || fallback;
3921
+
3922
+ return res.json({
3923
+ connected: true,
3924
+ user: mergedUser,
3925
+ scope: auth.scope,
3926
+ });
3927
+ } catch (error) {
3928
+ console.error('Failed to get GitHub auth status:', error);
3929
+ return res.status(500).json({ error: error.message || 'Failed to get GitHub auth status' });
3930
+ }
3931
+ });
3932
+
3933
+ app.post('/api/github/auth/start', async (_req, res) => {
3934
+ try {
3935
+ const { getGitHubClientId, getGitHubScopes, startDeviceFlow } = await getGitHubLibraries();
3936
+ const clientId = getGitHubClientId();
3937
+ if (!clientId) {
3938
+ return res.status(400).json({
3939
+ error: 'GitHub OAuth client not configured. Set OPENCHAMBER_GITHUB_CLIENT_ID.',
3940
+ });
3941
+ }
3942
+
3943
+ const scope = getGitHubScopes();
3944
+
3945
+ const payload = await startDeviceFlow({
3946
+ clientId,
3947
+ scope,
3948
+ });
3949
+
3950
+ return res.json({
3951
+ deviceCode: payload.device_code,
3952
+ userCode: payload.user_code,
3953
+ verificationUri: payload.verification_uri,
3954
+ verificationUriComplete: payload.verification_uri_complete,
3955
+ expiresIn: payload.expires_in,
3956
+ interval: payload.interval,
3957
+ scope,
3958
+ });
3959
+ } catch (error) {
3960
+ console.error('Failed to start GitHub device flow:', error);
3961
+ return res.status(500).json({ error: error.message || 'Failed to start GitHub device flow' });
3962
+ }
3963
+ });
3964
+
3965
+ app.post('/api/github/auth/complete', async (req, res) => {
3966
+ try {
3967
+ const { getGitHubClientId, exchangeDeviceCode, setGitHubAuth } = await getGitHubLibraries();
3968
+ const clientId = getGitHubClientId();
3969
+ if (!clientId) {
3970
+ return res.status(400).json({
3971
+ error: 'GitHub OAuth client not configured. Set OPENCHAMBER_GITHUB_CLIENT_ID.',
3972
+ });
3973
+ }
3974
+
3975
+ const deviceCode = typeof req.body?.deviceCode === 'string'
3976
+ ? req.body.deviceCode
3977
+ : (typeof req.body?.device_code === 'string' ? req.body.device_code : '');
3978
+
3979
+ if (!deviceCode) {
3980
+ return res.status(400).json({ error: 'deviceCode is required' });
3981
+ }
3982
+
3983
+ const payload = await exchangeDeviceCode({ clientId, deviceCode });
3984
+
3985
+ if (payload?.error) {
3986
+ return res.json({
3987
+ connected: false,
3988
+ status: payload.error,
3989
+ error: payload.error_description || payload.error,
3990
+ });
3991
+ }
3992
+
3993
+ const accessToken = payload?.access_token;
3994
+ if (!accessToken) {
3995
+ return res.status(500).json({ error: 'Missing access_token from GitHub' });
3996
+ }
3997
+
3998
+ const { Octokit } = await import('@octokit/rest');
3999
+ const octokit = new Octokit({ auth: accessToken });
4000
+ const user = await getGitHubUserSummary(octokit);
4001
+
4002
+ setGitHubAuth({
4003
+ accessToken,
4004
+ scope: typeof payload.scope === 'string' ? payload.scope : '',
4005
+ tokenType: typeof payload.token_type === 'string' ? payload.token_type : 'bearer',
4006
+ user,
4007
+ });
4008
+
4009
+ return res.json({
4010
+ connected: true,
4011
+ user,
4012
+ scope: typeof payload.scope === 'string' ? payload.scope : '',
4013
+ });
4014
+ } catch (error) {
4015
+ console.error('Failed to complete GitHub device flow:', error);
4016
+ return res.status(500).json({ error: error.message || 'Failed to complete GitHub device flow' });
4017
+ }
4018
+ });
4019
+
4020
+ app.delete('/api/github/auth', async (_req, res) => {
4021
+ try {
4022
+ const { clearGitHubAuth } = await getGitHubLibraries();
4023
+ const removed = clearGitHubAuth();
4024
+ return res.json({ success: true, removed });
4025
+ } catch (error) {
4026
+ console.error('Failed to disconnect GitHub:', error);
4027
+ return res.status(500).json({ error: error.message || 'Failed to disconnect GitHub' });
4028
+ }
4029
+ });
4030
+
4031
+ app.get('/api/github/me', async (_req, res) => {
4032
+ try {
4033
+ const { getOctokitOrNull, clearGitHubAuth } = await getGitHubLibraries();
4034
+ const octokit = getOctokitOrNull();
4035
+ if (!octokit) {
4036
+ return res.status(401).json({ error: 'GitHub not connected' });
4037
+ }
4038
+ let user;
4039
+ try {
4040
+ user = await getGitHubUserSummary(octokit);
4041
+ } catch (error) {
4042
+ if (error?.status === 401) {
4043
+ clearGitHubAuth();
4044
+ return res.status(401).json({ error: 'GitHub token expired or revoked' });
4045
+ }
4046
+ throw error;
4047
+ }
4048
+ return res.json(user);
4049
+ } catch (error) {
4050
+ console.error('Failed to fetch GitHub user:', error);
4051
+ return res.status(500).json({ error: error.message || 'Failed to fetch GitHub user' });
4052
+ }
4053
+ });
4054
+
4055
+ // ================= GitHub PR APIs =================
4056
+
4057
+ app.get('/api/github/pr/status', async (req, res) => {
4058
+ try {
4059
+ const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
4060
+ const branch = typeof req.query?.branch === 'string' ? req.query.branch.trim() : '';
4061
+ if (!directory || !branch) {
4062
+ return res.status(400).json({ error: 'directory and branch are required' });
4063
+ }
4064
+
4065
+ const { getOctokitOrNull, getGitHubAuth } = await getGitHubLibraries();
4066
+ const octokit = getOctokitOrNull();
4067
+ if (!octokit) {
4068
+ return res.json({ connected: false });
4069
+ }
4070
+
4071
+ const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js');
4072
+ const { repo } = await resolveGitHubRepoFromDirectory(directory);
4073
+ if (!repo) {
4074
+ return res.json({ connected: true, repo: null, branch, pr: null, checks: null, canMerge: false });
4075
+ }
4076
+
4077
+ // Find PR for this branch (same-repo assumption)
4078
+ const list = await octokit.rest.pulls.list({
4079
+ owner: repo.owner,
4080
+ repo: repo.repo,
4081
+ state: 'open',
4082
+ head: `${repo.owner}:${branch}`,
4083
+ per_page: 10,
4084
+ });
4085
+
4086
+ let first = Array.isArray(list?.data) ? list.data[0] : null;
4087
+
4088
+ // Fork PR support: head owner != base owner. If no PR found via head filter,
4089
+ // fall back to listing open PRs and matching by head ref name.
4090
+ if (!first) {
4091
+ const openList = await octokit.rest.pulls.list({
4092
+ owner: repo.owner,
4093
+ repo: repo.repo,
4094
+ state: 'open',
4095
+ per_page: 100,
4096
+ });
4097
+ const matches = Array.isArray(openList?.data)
4098
+ ? openList.data.filter((pr) => pr?.head?.ref === branch)
4099
+ : [];
4100
+ first = matches[0] ?? null;
4101
+ }
4102
+ if (!first) {
4103
+ return res.json({ connected: true, repo, branch, pr: null, checks: null, canMerge: false });
4104
+ }
4105
+
4106
+ // Enrich with mergeability fields
4107
+ const prFull = await octokit.rest.pulls.get({ owner: repo.owner, repo: repo.repo, pull_number: first.number });
4108
+ const prData = prFull?.data;
4109
+ if (!prData) {
4110
+ return res.json({ connected: true, repo, branch, pr: null, checks: null, canMerge: false });
4111
+ }
4112
+
4113
+ // Checks summary: prefer check-runs (Actions), fallback to classic statuses.
4114
+ let checks = null;
4115
+ const sha = prData.head?.sha;
4116
+ if (sha) {
4117
+ try {
4118
+ const runs = await octokit.rest.checks.listForRef({
4119
+ owner: repo.owner,
4120
+ repo: repo.repo,
4121
+ ref: sha,
4122
+ per_page: 100,
4123
+ });
4124
+ const checkRuns = Array.isArray(runs?.data?.check_runs) ? runs.data.check_runs : [];
4125
+ if (checkRuns.length > 0) {
4126
+ const counts = { success: 0, failure: 0, pending: 0 };
4127
+ for (const run of checkRuns) {
4128
+ const status = run?.status;
4129
+ const conclusion = run?.conclusion;
4130
+ if (status === 'queued' || status === 'in_progress') {
4131
+ counts.pending += 1;
4132
+ continue;
4133
+ }
4134
+ if (!conclusion) {
4135
+ counts.pending += 1;
4136
+ continue;
4137
+ }
4138
+ if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') {
4139
+ counts.success += 1;
4140
+ } else {
4141
+ counts.failure += 1;
4142
+ }
4143
+ }
4144
+ const total = counts.success + counts.failure + counts.pending;
4145
+ const state = counts.failure > 0
4146
+ ? 'failure'
4147
+ : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
4148
+ checks = { state, total, ...counts };
4149
+ }
4150
+ } catch {
4151
+ // ignore and fall back
4152
+ }
4153
+
4154
+ if (!checks) {
4155
+ try {
4156
+ const combined = await octokit.rest.repos.getCombinedStatusForRef({
4157
+ owner: repo.owner,
4158
+ repo: repo.repo,
4159
+ ref: sha,
4160
+ });
4161
+ const statuses = Array.isArray(combined?.data?.statuses) ? combined.data.statuses : [];
4162
+ const counts = { success: 0, failure: 0, pending: 0 };
4163
+ statuses.forEach((s) => {
4164
+ if (s.state === 'success') counts.success += 1;
4165
+ else if (s.state === 'failure' || s.state === 'error') counts.failure += 1;
4166
+ else if (s.state === 'pending') counts.pending += 1;
4167
+ });
4168
+ const total = counts.success + counts.failure + counts.pending;
4169
+ const state = counts.failure > 0
4170
+ ? 'failure'
4171
+ : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
4172
+ checks = { state, total, ...counts };
4173
+ } catch {
4174
+ checks = null;
4175
+ }
4176
+ }
4177
+ }
4178
+
4179
+ // Permission check (best-effort)
4180
+ let canMerge = false;
4181
+ try {
4182
+ const auth = getGitHubAuth();
4183
+ const username = auth?.user?.login;
4184
+ if (username) {
4185
+ const perm = await octokit.rest.repos.getCollaboratorPermissionLevel({
4186
+ owner: repo.owner,
4187
+ repo: repo.repo,
4188
+ username,
4189
+ });
4190
+ const level = perm?.data?.permission;
4191
+ canMerge = level === 'admin' || level === 'maintain' || level === 'write';
4192
+ }
4193
+ } catch {
4194
+ canMerge = false;
4195
+ }
4196
+
4197
+ const mergedState = prData.merged ? 'merged' : (prData.state === 'closed' ? 'closed' : 'open');
4198
+
4199
+ return res.json({
4200
+ connected: true,
4201
+ repo,
4202
+ branch,
4203
+ pr: {
4204
+ number: prData.number,
4205
+ title: prData.title,
4206
+ url: prData.html_url,
4207
+ state: mergedState,
4208
+ draft: Boolean(prData.draft),
4209
+ base: prData.base?.ref,
4210
+ head: prData.head?.ref,
4211
+ headSha: prData.head?.sha,
4212
+ mergeable: prData.mergeable,
4213
+ mergeableState: prData.mergeable_state,
4214
+ },
4215
+ checks,
4216
+ canMerge,
4217
+ });
4218
+ } catch (error) {
4219
+ if (error?.status === 401) {
4220
+ const { clearGitHubAuth } = await getGitHubLibraries();
4221
+ clearGitHubAuth();
4222
+ return res.json({ connected: false });
4223
+ }
4224
+ console.error('Failed to load GitHub PR status:', error);
4225
+ return res.status(500).json({ error: error.message || 'Failed to load GitHub PR status' });
4226
+ }
4227
+ });
4228
+
4229
+ app.post('/api/github/pr/create', async (req, res) => {
4230
+ try {
4231
+ const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
4232
+ const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
4233
+ const head = typeof req.body?.head === 'string' ? req.body.head.trim() : '';
4234
+ const base = typeof req.body?.base === 'string' ? req.body.base.trim() : '';
4235
+ const body = typeof req.body?.body === 'string' ? req.body.body : undefined;
4236
+ const draft = typeof req.body?.draft === 'boolean' ? req.body.draft : undefined;
4237
+ if (!directory || !title || !head || !base) {
4238
+ return res.status(400).json({ error: 'directory, title, head, base are required' });
4239
+ }
4240
+
4241
+ const { getOctokitOrNull } = await getGitHubLibraries();
4242
+ const octokit = getOctokitOrNull();
4243
+ if (!octokit) {
4244
+ return res.status(401).json({ error: 'GitHub not connected' });
4245
+ }
4246
+
4247
+ const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js');
4248
+ const { repo } = await resolveGitHubRepoFromDirectory(directory);
4249
+ if (!repo) {
4250
+ return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
4251
+ }
4252
+
4253
+ const created = await octokit.rest.pulls.create({
4254
+ owner: repo.owner,
4255
+ repo: repo.repo,
4256
+ title,
4257
+ head,
4258
+ base,
4259
+ ...(typeof body === 'string' ? { body } : {}),
4260
+ ...(typeof draft === 'boolean' ? { draft } : {}),
4261
+ });
4262
+
4263
+ const pr = created?.data;
4264
+ if (!pr) {
4265
+ return res.status(500).json({ error: 'Failed to create PR' });
4266
+ }
4267
+
4268
+ return res.json({
4269
+ number: pr.number,
4270
+ title: pr.title,
4271
+ url: pr.html_url,
4272
+ state: pr.state === 'closed' ? 'closed' : 'open',
4273
+ draft: Boolean(pr.draft),
4274
+ base: pr.base?.ref,
4275
+ head: pr.head?.ref,
4276
+ headSha: pr.head?.sha,
4277
+ mergeable: pr.mergeable,
4278
+ mergeableState: pr.mergeable_state,
4279
+ });
4280
+ } catch (error) {
4281
+ console.error('Failed to create GitHub PR:', error);
4282
+ return res.status(500).json({ error: error.message || 'Failed to create GitHub PR' });
4283
+ }
4284
+ });
4285
+
4286
+ app.post('/api/github/pr/merge', async (req, res) => {
4287
+ try {
4288
+ const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
4289
+ const number = typeof req.body?.number === 'number' ? req.body.number : null;
4290
+ const method = typeof req.body?.method === 'string' ? req.body.method : 'merge';
4291
+ if (!directory || !number) {
4292
+ return res.status(400).json({ error: 'directory and number are required' });
4293
+ }
4294
+
4295
+ const { getOctokitOrNull } = await getGitHubLibraries();
4296
+ const octokit = getOctokitOrNull();
4297
+ if (!octokit) {
4298
+ return res.status(401).json({ error: 'GitHub not connected' });
4299
+ }
4300
+
4301
+ const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js');
4302
+ const { repo } = await resolveGitHubRepoFromDirectory(directory);
4303
+ if (!repo) {
4304
+ return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
4305
+ }
4306
+
4307
+ try {
4308
+ const result = await octokit.rest.pulls.merge({
4309
+ owner: repo.owner,
4310
+ repo: repo.repo,
4311
+ pull_number: number,
4312
+ merge_method: method,
4313
+ });
4314
+ return res.json({ merged: Boolean(result?.data?.merged), message: result?.data?.message });
4315
+ } catch (error) {
4316
+ if (error?.status === 403) {
4317
+ return res.status(403).json({ error: 'Not authorized to merge this PR' });
4318
+ }
4319
+ if (error?.status === 405 || error?.status === 409) {
4320
+ return res.json({ merged: false, message: error?.message || 'PR not mergeable' });
4321
+ }
4322
+ throw error;
4323
+ }
4324
+ } catch (error) {
4325
+ console.error('Failed to merge GitHub PR:', error);
4326
+ return res.status(500).json({ error: error.message || 'Failed to merge GitHub PR' });
4327
+ }
4328
+ });
4329
+
4330
+ app.post('/api/github/pr/ready', async (req, res) => {
4331
+ try {
4332
+ const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
4333
+ const number = typeof req.body?.number === 'number' ? req.body.number : null;
4334
+ if (!directory || !number) {
4335
+ return res.status(400).json({ error: 'directory and number are required' });
4336
+ }
4337
+
4338
+ const { getOctokitOrNull } = await getGitHubLibraries();
4339
+ const octokit = getOctokitOrNull();
4340
+ if (!octokit) {
4341
+ return res.status(401).json({ error: 'GitHub not connected' });
4342
+ }
4343
+
4344
+ const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js');
4345
+ const { repo } = await resolveGitHubRepoFromDirectory(directory);
4346
+ if (!repo) {
4347
+ return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
4348
+ }
4349
+
4350
+ const pr = await octokit.rest.pulls.get({ owner: repo.owner, repo: repo.repo, pull_number: number });
4351
+ const nodeId = pr?.data?.node_id;
4352
+ if (!nodeId) {
4353
+ return res.status(500).json({ error: 'Failed to resolve PR node id' });
4354
+ }
4355
+
4356
+ if (pr?.data?.draft === false) {
4357
+ return res.json({ ready: true });
4358
+ }
4359
+
4360
+ try {
4361
+ await octokit.graphql(
4362
+ `mutation($pullRequestId: ID!) {\n markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) {\n pullRequest {\n id\n isDraft\n }\n }\n}`,
4363
+ { pullRequestId: nodeId }
4364
+ );
4365
+ } catch (error) {
4366
+ if (error?.status === 403) {
4367
+ return res.status(403).json({ error: 'Not authorized to mark PR ready' });
4368
+ }
4369
+ throw error;
4370
+ }
4371
+
4372
+ return res.json({ ready: true });
4373
+ } catch (error) {
4374
+ console.error('Failed to mark PR ready:', error);
4375
+ return res.status(500).json({ error: error.message || 'Failed to mark PR ready' });
4376
+ }
4377
+ });
4378
+
4379
+ // ================= GitHub Issue APIs =================
4380
+
4381
+ app.get('/api/github/issues/list', async (req, res) => {
4382
+ try {
4383
+ const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
4384
+ const page = typeof req.query?.page === 'string' ? Number(req.query.page) : 1;
4385
+ if (!directory) {
4386
+ return res.status(400).json({ error: 'directory is required' });
4387
+ }
4388
+
4389
+ const { getOctokitOrNull } = await getGitHubLibraries();
4390
+ const octokit = getOctokitOrNull();
4391
+ if (!octokit) {
4392
+ return res.json({ connected: false });
4393
+ }
4394
+
4395
+ const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js');
4396
+ const { repo } = await resolveGitHubRepoFromDirectory(directory);
4397
+ if (!repo) {
4398
+ return res.json({ connected: true, repo: null, issues: [] });
4399
+ }
4400
+
4401
+ const list = await octokit.rest.issues.listForRepo({
4402
+ owner: repo.owner,
4403
+ repo: repo.repo,
4404
+ state: 'open',
4405
+ per_page: 50,
4406
+ page: Number.isFinite(page) && page > 0 ? page : 1,
4407
+ });
4408
+ const link = typeof list?.headers?.link === 'string' ? list.headers.link : '';
4409
+ const hasMore = /rel="next"/.test(link);
4410
+ const issues = (Array.isArray(list?.data) ? list.data : [])
4411
+ .filter((item) => !item?.pull_request)
4412
+ .map((item) => ({
4413
+ number: item.number,
4414
+ title: item.title,
4415
+ url: item.html_url,
4416
+ state: item.state === 'closed' ? 'closed' : 'open',
4417
+ author: item.user ? { login: item.user.login, id: item.user.id, avatarUrl: item.user.avatar_url } : null,
4418
+ labels: Array.isArray(item.labels)
4419
+ ? item.labels
4420
+ .map((label) => {
4421
+ if (typeof label === 'string') return null;
4422
+ const name = typeof label?.name === 'string' ? label.name : '';
4423
+ if (!name) return null;
4424
+ return { name, color: typeof label?.color === 'string' ? label.color : undefined };
4425
+ })
4426
+ .filter(Boolean)
4427
+ : [],
4428
+ }));
4429
+
4430
+ return res.json({ connected: true, repo, issues, page: Number.isFinite(page) && page > 0 ? page : 1, hasMore });
4431
+ } catch (error) {
4432
+ console.error('Failed to list GitHub issues:', error);
4433
+ return res.status(500).json({ error: error.message || 'Failed to list GitHub issues' });
4434
+ }
4435
+ });
4436
+
4437
+ app.get('/api/github/issues/get', async (req, res) => {
4438
+ try {
4439
+ const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
4440
+ const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null;
4441
+ if (!directory || !number) {
4442
+ return res.status(400).json({ error: 'directory and number are required' });
4443
+ }
4444
+
4445
+ const { getOctokitOrNull } = await getGitHubLibraries();
4446
+ const octokit = getOctokitOrNull();
4447
+ if (!octokit) {
4448
+ return res.json({ connected: false });
4449
+ }
4450
+
4451
+ const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js');
4452
+ const { repo } = await resolveGitHubRepoFromDirectory(directory);
4453
+ if (!repo) {
4454
+ return res.json({ connected: true, repo: null, issue: null });
4455
+ }
4456
+
4457
+ const result = await octokit.rest.issues.get({ owner: repo.owner, repo: repo.repo, issue_number: number });
4458
+ const issue = result?.data;
4459
+ if (!issue || issue.pull_request) {
4460
+ return res.status(400).json({ error: 'Not a GitHub issue' });
4461
+ }
4462
+
4463
+ return res.json({
4464
+ connected: true,
4465
+ repo,
4466
+ issue: {
4467
+ number: issue.number,
4468
+ title: issue.title,
4469
+ url: issue.html_url,
4470
+ state: issue.state === 'closed' ? 'closed' : 'open',
4471
+ body: issue.body || '',
4472
+ createdAt: issue.created_at,
4473
+ updatedAt: issue.updated_at,
4474
+ author: issue.user ? { login: issue.user.login, id: issue.user.id, avatarUrl: issue.user.avatar_url } : null,
4475
+ assignees: Array.isArray(issue.assignees)
4476
+ ? issue.assignees
4477
+ .map((u) => (u ? { login: u.login, id: u.id, avatarUrl: u.avatar_url } : null))
4478
+ .filter(Boolean)
4479
+ : [],
4480
+ labels: Array.isArray(issue.labels)
4481
+ ? issue.labels
4482
+ .map((label) => {
4483
+ if (typeof label === 'string') return null;
4484
+ const name = typeof label?.name === 'string' ? label.name : '';
4485
+ if (!name) return null;
4486
+ return { name, color: typeof label?.color === 'string' ? label.color : undefined };
4487
+ })
4488
+ .filter(Boolean)
4489
+ : [],
4490
+ },
4491
+ });
4492
+ } catch (error) {
4493
+ console.error('Failed to fetch GitHub issue:', error);
4494
+ return res.status(500).json({ error: error.message || 'Failed to fetch GitHub issue' });
4495
+ }
4496
+ });
4497
+
4498
+ app.get('/api/github/issues/comments', async (req, res) => {
4499
+ try {
4500
+ const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
4501
+ const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null;
4502
+ if (!directory || !number) {
4503
+ return res.status(400).json({ error: 'directory and number are required' });
4504
+ }
4505
+
4506
+ const { getOctokitOrNull } = await getGitHubLibraries();
4507
+ const octokit = getOctokitOrNull();
4508
+ if (!octokit) {
4509
+ return res.json({ connected: false });
4510
+ }
4511
+
4512
+ const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js');
4513
+ const { repo } = await resolveGitHubRepoFromDirectory(directory);
4514
+ if (!repo) {
4515
+ return res.json({ connected: true, repo: null, comments: [] });
4516
+ }
4517
+
4518
+ const result = await octokit.rest.issues.listComments({
4519
+ owner: repo.owner,
4520
+ repo: repo.repo,
4521
+ issue_number: number,
4522
+ per_page: 100,
4523
+ });
4524
+ const comments = (Array.isArray(result?.data) ? result.data : [])
4525
+ .map((comment) => ({
4526
+ id: comment.id,
4527
+ url: comment.html_url,
4528
+ body: comment.body || '',
4529
+ createdAt: comment.created_at,
4530
+ updatedAt: comment.updated_at,
4531
+ author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null,
4532
+ }));
4533
+
4534
+ return res.json({ connected: true, repo, comments });
4535
+ } catch (error) {
4536
+ console.error('Failed to fetch GitHub issue comments:', error);
4537
+ return res.status(500).json({ error: error.message || 'Failed to fetch GitHub issue comments' });
4538
+ }
4539
+ });
4540
+
4541
+ // ================= GitHub Pull Request Context APIs =================
4542
+
4543
+ app.get('/api/github/pulls/list', async (req, res) => {
4544
+ try {
4545
+ const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
4546
+ const page = typeof req.query?.page === 'string' ? Number(req.query.page) : 1;
4547
+ if (!directory) {
4548
+ return res.status(400).json({ error: 'directory is required' });
4549
+ }
4550
+
4551
+ const { getOctokitOrNull } = await getGitHubLibraries();
4552
+ const octokit = getOctokitOrNull();
4553
+ if (!octokit) {
4554
+ return res.json({ connected: false });
4555
+ }
4556
+
4557
+ const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js');
4558
+ const { repo } = await resolveGitHubRepoFromDirectory(directory);
4559
+ if (!repo) {
4560
+ return res.json({ connected: true, repo: null, prs: [] });
4561
+ }
4562
+
4563
+ const list = await octokit.rest.pulls.list({
4564
+ owner: repo.owner,
4565
+ repo: repo.repo,
4566
+ state: 'open',
4567
+ per_page: 50,
4568
+ page: Number.isFinite(page) && page > 0 ? page : 1,
4569
+ });
4570
+
4571
+ const link = typeof list?.headers?.link === 'string' ? list.headers.link : '';
4572
+ const hasMore = /rel="next"/.test(link);
4573
+
4574
+ const prs = (Array.isArray(list?.data) ? list.data : []).map((pr) => {
4575
+ const mergedState = pr.merged_at ? 'merged' : (pr.state === 'closed' ? 'closed' : 'open');
4576
+ const headRepo = pr.head?.repo
4577
+ ? {
4578
+ owner: pr.head.repo.owner?.login,
4579
+ repo: pr.head.repo.name,
4580
+ url: pr.head.repo.html_url,
4581
+ cloneUrl: pr.head.repo.clone_url,
4582
+ }
4583
+ : null;
4584
+ return {
4585
+ number: pr.number,
4586
+ title: pr.title,
4587
+ url: pr.html_url,
4588
+ state: mergedState,
4589
+ draft: Boolean(pr.draft),
4590
+ base: pr.base?.ref,
4591
+ head: pr.head?.ref,
4592
+ headSha: pr.head?.sha,
4593
+ mergeable: pr.mergeable,
4594
+ mergeableState: pr.mergeable_state,
4595
+ author: pr.user ? { login: pr.user.login, id: pr.user.id, avatarUrl: pr.user.avatar_url } : null,
4596
+ headLabel: pr.head?.label,
4597
+ headRepo: headRepo && headRepo.owner && headRepo.repo && headRepo.url
4598
+ ? headRepo
4599
+ : null,
4600
+ };
4601
+ });
4602
+
4603
+ return res.json({ connected: true, repo, prs, page: Number.isFinite(page) && page > 0 ? page : 1, hasMore });
4604
+ } catch (error) {
4605
+ if (error?.status === 401) {
4606
+ const { clearGitHubAuth } = await getGitHubLibraries();
4607
+ clearGitHubAuth();
4608
+ return res.json({ connected: false });
4609
+ }
4610
+ console.error('Failed to list GitHub PRs:', error);
4611
+ return res.status(500).json({ error: error.message || 'Failed to list GitHub PRs' });
4612
+ }
4613
+ });
4614
+
4615
+ app.get('/api/github/pulls/context', async (req, res) => {
4616
+ try {
4617
+ const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
4618
+ const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null;
4619
+ const includeDiff = req.query?.diff === '1' || req.query?.diff === 'true';
4620
+ const includeCheckDetails = req.query?.checkDetails === '1' || req.query?.checkDetails === 'true';
4621
+ if (!directory || !number) {
4622
+ return res.status(400).json({ error: 'directory and number are required' });
4623
+ }
4624
+
4625
+ const { getOctokitOrNull } = await getGitHubLibraries();
4626
+ const octokit = getOctokitOrNull();
4627
+ if (!octokit) {
4628
+ return res.json({ connected: false });
4629
+ }
4630
+
4631
+ const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js');
4632
+ const { repo } = await resolveGitHubRepoFromDirectory(directory);
4633
+ if (!repo) {
4634
+ return res.json({ connected: true, repo: null, pr: null });
4635
+ }
4636
+
4637
+ const prResp = await octokit.rest.pulls.get({ owner: repo.owner, repo: repo.repo, pull_number: number });
4638
+ const prData = prResp?.data;
4639
+ if (!prData) {
4640
+ return res.status(404).json({ error: 'PR not found' });
4641
+ }
4642
+
4643
+ const headRepo = prData.head?.repo
4644
+ ? {
4645
+ owner: prData.head.repo.owner?.login,
4646
+ repo: prData.head.repo.name,
4647
+ url: prData.head.repo.html_url,
4648
+ cloneUrl: prData.head.repo.clone_url,
4649
+ }
4650
+ : null;
4651
+
4652
+ const mergedState = prData.merged ? 'merged' : (prData.state === 'closed' ? 'closed' : 'open');
4653
+ const pr = {
4654
+ number: prData.number,
4655
+ title: prData.title,
4656
+ url: prData.html_url,
4657
+ state: mergedState,
4658
+ draft: Boolean(prData.draft),
4659
+ base: prData.base?.ref,
4660
+ head: prData.head?.ref,
4661
+ headSha: prData.head?.sha,
4662
+ mergeable: prData.mergeable,
4663
+ mergeableState: prData.mergeable_state,
4664
+ author: prData.user ? { login: prData.user.login, id: prData.user.id, avatarUrl: prData.user.avatar_url } : null,
4665
+ headLabel: prData.head?.label,
4666
+ headRepo: headRepo && headRepo.owner && headRepo.repo && headRepo.url ? headRepo : null,
4667
+ body: prData.body || '',
4668
+ createdAt: prData.created_at,
4669
+ updatedAt: prData.updated_at,
4670
+ };
4671
+
4672
+ const issueCommentsResp = await octokit.rest.issues.listComments({
4673
+ owner: repo.owner,
4674
+ repo: repo.repo,
4675
+ issue_number: number,
4676
+ per_page: 100,
4677
+ });
4678
+ const issueComments = (Array.isArray(issueCommentsResp?.data) ? issueCommentsResp.data : []).map((comment) => ({
4679
+ id: comment.id,
4680
+ url: comment.html_url,
4681
+ body: comment.body || '',
4682
+ createdAt: comment.created_at,
4683
+ updatedAt: comment.updated_at,
4684
+ author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null,
4685
+ }));
4686
+
4687
+ const reviewCommentsResp = await octokit.rest.pulls.listReviewComments({
4688
+ owner: repo.owner,
4689
+ repo: repo.repo,
4690
+ pull_number: number,
4691
+ per_page: 100,
4692
+ });
4693
+ const reviewComments = (Array.isArray(reviewCommentsResp?.data) ? reviewCommentsResp.data : []).map((comment) => ({
4694
+ id: comment.id,
4695
+ url: comment.html_url,
4696
+ body: comment.body || '',
4697
+ createdAt: comment.created_at,
4698
+ updatedAt: comment.updated_at,
4699
+ path: comment.path,
4700
+ line: typeof comment.line === 'number' ? comment.line : null,
4701
+ position: typeof comment.position === 'number' ? comment.position : null,
4702
+ author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null,
4703
+ }));
4704
+
4705
+ const filesResp = await octokit.rest.pulls.listFiles({
4706
+ owner: repo.owner,
4707
+ repo: repo.repo,
4708
+ pull_number: number,
4709
+ per_page: 100,
4710
+ });
4711
+ const files = (Array.isArray(filesResp?.data) ? filesResp.data : []).map((f) => ({
4712
+ filename: f.filename,
4713
+ status: f.status,
4714
+ additions: f.additions,
4715
+ deletions: f.deletions,
4716
+ changes: f.changes,
4717
+ patch: f.patch,
4718
+ }));
4719
+
4720
+ // checks summary (same logic as status endpoint)
4721
+ let checks = null;
4722
+ let checkRunsOut = undefined;
4723
+ const sha = prData.head?.sha;
4724
+ if (sha) {
4725
+ try {
4726
+ const runs = await octokit.rest.checks.listForRef({ owner: repo.owner, repo: repo.repo, ref: sha, per_page: 100 });
4727
+ const checkRuns = Array.isArray(runs?.data?.check_runs) ? runs.data.check_runs : [];
4728
+ if (checkRuns.length > 0) {
4729
+ const parsedJobs = new Map();
4730
+ if (includeCheckDetails) {
4731
+ // Prefetch actions jobs per runId.
4732
+ const runIds = new Set();
4733
+ const jobIds = new Map();
4734
+ for (const run of checkRuns) {
4735
+ const details = typeof run.details_url === 'string' ? run.details_url : '';
4736
+ const match = details.match(/\/actions\/runs\/(\d+)(?:\/job\/(\d+))?/);
4737
+ if (match) {
4738
+ const runId = Number(match[1]);
4739
+ const jobId = match[2] ? Number(match[2]) : null;
4740
+ if (Number.isFinite(runId) && runId > 0) {
4741
+ runIds.add(runId);
4742
+ if (jobId && Number.isFinite(jobId) && jobId > 0) {
4743
+ jobIds.set(details, { runId, jobId });
4744
+ } else {
4745
+ jobIds.set(details, { runId, jobId: null });
4746
+ }
4747
+ }
4748
+ }
4749
+ }
4750
+
4751
+ for (const runId of runIds) {
4752
+ try {
4753
+ const jobsResp = await octokit.rest.actions.listJobsForWorkflowRun({
4754
+ owner: repo.owner,
4755
+ repo: repo.repo,
4756
+ run_id: runId,
4757
+ per_page: 100,
4758
+ });
4759
+ const jobs = Array.isArray(jobsResp?.data?.jobs) ? jobsResp.data.jobs : [];
4760
+ parsedJobs.set(runId, jobs);
4761
+ } catch {
4762
+ parsedJobs.set(runId, []);
4763
+ }
4764
+ }
4765
+ }
4766
+
4767
+ checkRunsOut = checkRuns.map((run) => {
4768
+ const detailsUrl = typeof run.details_url === 'string' ? run.details_url : undefined;
4769
+ let job = undefined;
4770
+ if (includeCheckDetails && detailsUrl) {
4771
+ const match = detailsUrl.match(/\/actions\/runs\/(\d+)(?:\/job\/(\d+))?/);
4772
+ const runId = match ? Number(match[1]) : null;
4773
+ const jobId = match && match[2] ? Number(match[2]) : null;
4774
+ if (runId && Number.isFinite(runId)) {
4775
+ const jobs = parsedJobs.get(runId) || [];
4776
+ const matched = jobId
4777
+ ? jobs.find((j) => j.id === jobId)
4778
+ : null;
4779
+ const picked = matched || jobs.find((j) => j.name === run.name) || null;
4780
+ if (picked) {
4781
+ job = {
4782
+ runId,
4783
+ jobId: picked.id,
4784
+ url: picked.html_url,
4785
+ name: picked.name,
4786
+ conclusion: picked.conclusion,
4787
+ steps: Array.isArray(picked.steps)
4788
+ ? picked.steps.map((s) => ({
4789
+ name: s.name,
4790
+ status: s.status,
4791
+ conclusion: s.conclusion,
4792
+ number: s.number,
4793
+ }))
4794
+ : undefined,
4795
+ };
4796
+ } else {
4797
+ job = { runId, ...(jobId ? { jobId } : {}), url: detailsUrl };
4798
+ }
4799
+ }
4800
+ }
4801
+
4802
+ return {
4803
+ id: run.id,
4804
+ name: run.name,
4805
+ app: run.app
4806
+ ? {
4807
+ name: run.app.name || undefined,
4808
+ slug: run.app.slug || undefined,
4809
+ }
4810
+ : undefined,
4811
+ status: run.status,
4812
+ conclusion: run.conclusion,
4813
+ detailsUrl,
4814
+ output: run.output
4815
+ ? {
4816
+ title: run.output.title || undefined,
4817
+ summary: run.output.summary || undefined,
4818
+ text: run.output.text || undefined,
4819
+ }
4820
+ : undefined,
4821
+ ...(job ? { job } : {}),
4822
+ };
4823
+ });
4824
+ const counts = { success: 0, failure: 0, pending: 0 };
4825
+ for (const run of checkRuns) {
4826
+ const status = run?.status;
4827
+ const conclusion = run?.conclusion;
4828
+ if (status === 'queued' || status === 'in_progress') {
4829
+ counts.pending += 1;
4830
+ continue;
4831
+ }
4832
+ if (!conclusion) {
4833
+ counts.pending += 1;
4834
+ continue;
4835
+ }
4836
+ if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') {
4837
+ counts.success += 1;
4838
+ } else {
4839
+ counts.failure += 1;
4840
+ }
4841
+ }
4842
+ const total = counts.success + counts.failure + counts.pending;
4843
+ const state = counts.failure > 0 ? 'failure' : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
4844
+ checks = { state, total, ...counts };
4845
+ }
4846
+ } catch {
4847
+ // ignore and fall back
4848
+ }
4849
+ if (!checks) {
4850
+ try {
4851
+ const combined = await octokit.rest.repos.getCombinedStatusForRef({ owner: repo.owner, repo: repo.repo, ref: sha });
4852
+ const statuses = Array.isArray(combined?.data?.statuses) ? combined.data.statuses : [];
4853
+ const counts = { success: 0, failure: 0, pending: 0 };
4854
+ statuses.forEach((s) => {
4855
+ if (s.state === 'success') counts.success += 1;
4856
+ else if (s.state === 'failure' || s.state === 'error') counts.failure += 1;
4857
+ else if (s.state === 'pending') counts.pending += 1;
4858
+ });
4859
+ const total = counts.success + counts.failure + counts.pending;
4860
+ const state = counts.failure > 0 ? 'failure' : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
4861
+ checks = { state, total, ...counts };
4862
+ } catch {
4863
+ checks = null;
4864
+ }
4865
+ }
4866
+ }
4867
+
4868
+ let diff = undefined;
4869
+ if (includeDiff) {
4870
+ const diffResp = await octokit.request('GET /repos/{owner}/{repo}/pulls/{pull_number}', {
4871
+ owner: repo.owner,
4872
+ repo: repo.repo,
4873
+ pull_number: number,
4874
+ headers: { accept: 'application/vnd.github.v3.diff' },
4875
+ });
4876
+ diff = typeof diffResp?.data === 'string' ? diffResp.data : undefined;
4877
+ }
4878
+
4879
+ return res.json({
4880
+ connected: true,
4881
+ repo,
4882
+ pr,
4883
+ issueComments,
4884
+ reviewComments,
4885
+ files,
4886
+ ...(diff ? { diff } : {}),
4887
+ checks,
4888
+ ...(Array.isArray(checkRunsOut) ? { checkRuns: checkRunsOut } : {}),
4889
+ });
4890
+ } catch (error) {
4891
+ if (error?.status === 401) {
4892
+ const { clearGitHubAuth } = await getGitHubLibraries();
4893
+ clearGitHubAuth();
4894
+ return res.json({ connected: false });
4895
+ }
4896
+ console.error('Failed to load GitHub PR context:', error);
4897
+ return res.status(500).json({ error: error.message || 'Failed to load GitHub PR context' });
4898
+ }
4899
+ });
4900
+
3842
4901
  app.get('/api/provider/:providerId/source', async (req, res) => {
3843
4902
  try {
3844
4903
  const { providerId } = req.params;
@@ -4313,6 +5372,97 @@ async function main(options = {}) {
4313
5372
  }
4314
5373
  });
4315
5374
 
5375
+ app.post('/api/git/pr-description', async (req, res) => {
5376
+ const { getRangeDiff, getRangeFiles } = await getGitLibraries();
5377
+ try {
5378
+ const directory = req.query.directory;
5379
+ if (!directory || typeof directory !== 'string') {
5380
+ return res.status(400).json({ error: 'directory parameter is required' });
5381
+ }
5382
+
5383
+ const base = typeof req.body?.base === 'string' ? req.body.base.trim() : '';
5384
+ const head = typeof req.body?.head === 'string' ? req.body.head.trim() : '';
5385
+ if (!base || !head) {
5386
+ return res.status(400).json({ error: 'base and head are required' });
5387
+ }
5388
+
5389
+ const filesToDiff = await getRangeFiles(directory, { base, head });
5390
+
5391
+ const diffs = [];
5392
+ for (const filePath of filesToDiff) {
5393
+ const diff = await getRangeDiff(directory, { base, head, path: filePath, contextLines: 3 }).catch(() => '');
5394
+ if (diff && diff.trim().length > 0) {
5395
+ diffs.push({ path: filePath, diff });
5396
+ }
5397
+ }
5398
+ if (diffs.length === 0) {
5399
+ return res.status(400).json({ error: 'No diffs available for base...head' });
5400
+ }
5401
+
5402
+ const diffSummaries = diffs.map(({ path, diff }) => `FILE: ${path}\n${diff}`).join('\n\n');
5403
+
5404
+ const prompt = `You are drafting a GitHub Pull Request title + description. Respond in JSON of the shape {"title": string, "body": string} (ONLY JSON in response, no markdown fences) with these rules:\n- title: concise, sentence case, <= 80 chars, no trailing punctuation, no commit-style prefixes (no "feat:", "fix:")\n- body: GitHub-flavored markdown with these sections in this order: Summary, Testing, Notes\n- Summary: 3-6 bullet points describing user-visible changes; avoid internal helper function names\n- Testing: bullet list ("- Not tested" allowed)\n- Notes: bullet list; include breaking/rollout notes only when relevant\n\nContext:\n- base branch: ${base}\n- head branch: ${head}\n\nDiff summary:\n${diffSummaries}`;
5405
+
5406
+ const model = 'gpt-5-nano';
5407
+
5408
+ const completionTimeout = createTimeoutSignal(LONG_REQUEST_TIMEOUT_MS);
5409
+ let response;
5410
+ try {
5411
+ response = await fetch('https://opencode.ai/zen/v1/responses', {
5412
+ method: 'POST',
5413
+ headers: { 'Content-Type': 'application/json' },
5414
+ body: JSON.stringify({
5415
+ model,
5416
+ input: [{ role: 'user', content: prompt }],
5417
+ max_output_tokens: 1200,
5418
+ stream: false,
5419
+ reasoning: { effort: 'low' },
5420
+ }),
5421
+ signal: completionTimeout.signal,
5422
+ });
5423
+ } finally {
5424
+ completionTimeout.cleanup();
5425
+ }
5426
+
5427
+ if (!response.ok) {
5428
+ const errorBody = await response.json().catch(() => ({}));
5429
+ console.error('PR description generation failed:', errorBody);
5430
+ return res.status(502).json({ error: 'Failed to generate PR description' });
5431
+ }
5432
+
5433
+ const data = await response.json();
5434
+ const raw = data?.output?.find((item) => item?.type === 'message')?.content?.find((item) => item?.type === 'output_text')?.text?.trim();
5435
+ if (!raw) {
5436
+ return res.status(502).json({ error: 'No PR description returned by generator' });
5437
+ }
5438
+
5439
+ const cleanedJson = stripJsonMarkdownWrapper(raw);
5440
+ const extractedJson = extractJsonObject(cleanedJson) || extractJsonObject(raw);
5441
+ const candidates = [cleanedJson, extractedJson, raw].filter((candidate, index, array) => {
5442
+ return candidate && array.indexOf(candidate) === index;
5443
+ });
5444
+
5445
+ for (const candidate of candidates) {
5446
+ if (!(candidate.startsWith('{') || candidate.startsWith('['))) {
5447
+ continue;
5448
+ }
5449
+ try {
5450
+ const parsed = JSON.parse(candidate);
5451
+ const title = typeof parsed?.title === 'string' ? parsed.title : '';
5452
+ const body = typeof parsed?.body === 'string' ? parsed.body : '';
5453
+ return res.json({ title, body });
5454
+ } catch (parseError) {
5455
+ console.warn('PR description generation returned non-JSON body:', parseError);
5456
+ }
5457
+ }
5458
+
5459
+ return res.json({ title: '', body: raw });
5460
+ } catch (error) {
5461
+ console.error('Failed to generate PR description:', error);
5462
+ return res.status(500).json({ error: error.message || 'Failed to generate PR description' });
5463
+ }
5464
+ });
5465
+
4316
5466
  app.post('/api/git/pull', async (req, res) => {
4317
5467
  const { pull } = await getGitLibraries();
4318
5468
  try {