@openchamber/web 1.5.4 → 1.5.6

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
  }
@@ -1009,10 +1021,7 @@ const getUiSessionTokenFromRequest = (req) => {
1009
1021
  return null;
1010
1022
  };
1011
1023
 
1012
- const getPushSubscriptionsForUiSession = async (uiSessionToken) => {
1013
- if (!uiSessionToken) return [];
1014
- const store = await readPushSubscriptionsFromDisk();
1015
- const record = store.subscriptionsBySession?.[uiSessionToken];
1024
+ const normalizePushSubscriptions = (record) => {
1016
1025
  if (!Array.isArray(record)) return [];
1017
1026
  return record
1018
1027
  .map((entry) => {
@@ -1033,6 +1042,13 @@ const getPushSubscriptionsForUiSession = async (uiSessionToken) => {
1033
1042
  .filter(Boolean);
1034
1043
  };
1035
1044
 
1045
+ const getPushSubscriptionsForUiSession = async (uiSessionToken) => {
1046
+ if (!uiSessionToken) return [];
1047
+ const store = await readPushSubscriptionsFromDisk();
1048
+ const record = store.subscriptionsBySession?.[uiSessionToken];
1049
+ return normalizePushSubscriptions(record);
1050
+ };
1051
+
1036
1052
  const addOrUpdatePushSubscription = async (uiSessionToken, subscription, userAgent) => {
1037
1053
  if (!uiSessionToken) {
1038
1054
  return;
@@ -1108,71 +1124,75 @@ const buildSessionDeepLinkUrl = (sessionId) => {
1108
1124
  return `/?session=${encodeURIComponent(sessionId)}`;
1109
1125
  };
1110
1126
 
1111
- const sendPushToUiSession = async (uiSessionToken, payload) => {
1127
+ const sendPushToSubscription = async (sub, payload) => {
1112
1128
  await ensurePushInitialized();
1113
-
1114
- const subscriptions = await getPushSubscriptionsForUiSession(uiSessionToken);
1115
- if (subscriptions.length === 0) {
1116
- return;
1117
- }
1118
-
1119
1129
  const body = JSON.stringify(payload);
1120
1130
 
1121
- await Promise.all(subscriptions.map(async (sub) => {
1122
- const pushSubscription = {
1123
- endpoint: sub.endpoint,
1124
- keys: {
1125
- p256dh: sub.p256dh,
1126
- auth: sub.auth,
1127
- }
1128
- };
1131
+ const pushSubscription = {
1132
+ endpoint: sub.endpoint,
1133
+ keys: {
1134
+ p256dh: sub.p256dh,
1135
+ auth: sub.auth,
1136
+ }
1137
+ };
1129
1138
 
1130
- try {
1131
- await webPush.sendNotification(pushSubscription, body);
1132
- } catch (error) {
1133
- const statusCode = typeof error?.statusCode === 'number' ? error.statusCode : null;
1134
- if (statusCode === 410 || statusCode === 404) {
1135
- await removePushSubscriptionFromAllSessions(sub.endpoint);
1136
- return;
1137
- }
1138
- console.warn('[Push] Failed to send notification:', error);
1139
+ try {
1140
+ await webPush.sendNotification(pushSubscription, body);
1141
+ } catch (error) {
1142
+ const statusCode = typeof error?.statusCode === 'number' ? error.statusCode : null;
1143
+ if (statusCode === 410 || statusCode === 404) {
1144
+ await removePushSubscriptionFromAllSessions(sub.endpoint);
1145
+ return;
1139
1146
  }
1140
- }));
1147
+ console.warn('[Push] Failed to send notification:', error);
1148
+ }
1141
1149
  };
1142
1150
 
1143
1151
  const sendPushToAllUiSessions = async (payload, options = {}) => {
1144
1152
  const requireNoSse = options.requireNoSse === true;
1145
1153
  const store = await readPushSubscriptionsFromDisk();
1146
- const tokens = Object.keys(store.subscriptionsBySession || {});
1154
+ const sessions = store.subscriptionsBySession || {};
1155
+ const subscriptionsByEndpoint = new Map();
1156
+
1157
+ for (const [token, record] of Object.entries(sessions)) {
1158
+ const subscriptions = normalizePushSubscriptions(record);
1159
+ if (subscriptions.length === 0) continue;
1160
+
1161
+ for (const sub of subscriptions) {
1162
+ if (!subscriptionsByEndpoint.has(sub.endpoint)) {
1163
+ subscriptionsByEndpoint.set(sub.endpoint, sub);
1164
+ }
1165
+ }
1166
+ }
1147
1167
 
1148
- await Promise.all(tokens.map(async (token) => {
1149
- if (requireNoSse && isUiVisible(token)) {
1168
+ await Promise.all(Array.from(subscriptionsByEndpoint.entries()).map(async ([endpoint, sub]) => {
1169
+ if (requireNoSse && isAnyUiVisible()) {
1150
1170
  return;
1151
1171
  }
1152
- await sendPushToUiSession(token, payload);
1172
+ await sendPushToSubscription(sub, payload);
1153
1173
  }));
1154
1174
  };
1155
1175
 
1156
1176
  let pushInitialized = false;
1157
- const activeUiSseConnections = new Set();
1158
1177
 
1159
1178
 
1160
1179
 
1161
- const VISIBILITY_TTL_MS = 30000;
1162
1180
  const uiVisibilityByToken = new Map();
1181
+ let globalVisibilityState = false;
1163
1182
 
1164
1183
  const updateUiVisibility = (token, visible) => {
1165
1184
  if (!token) return;
1166
- uiVisibilityByToken.set(token, { visible: Boolean(visible), updatedAt: Date.now() });
1167
- };
1185
+ const now = Date.now();
1186
+ const nextVisible = Boolean(visible);
1187
+ uiVisibilityByToken.set(token, { visible: nextVisible, updatedAt: now });
1188
+ globalVisibilityState = nextVisible;
1168
1189
 
1169
- const isUiVisible = (token) => {
1170
- const entry = uiVisibilityByToken.get(token);
1171
- if (!entry) return false;
1172
- if (Date.now() - entry.updatedAt > VISIBILITY_TTL_MS) return false;
1173
- return entry.visible === true;
1174
1190
  };
1175
1191
 
1192
+ const isAnyUiVisible = () => globalVisibilityState === true;
1193
+
1194
+ const isUiVisible = (token) => uiVisibilityByToken.get(token)?.visible === true;
1195
+
1176
1196
  const resolveVapidSubject = async () => {
1177
1197
  const configured = process.env.OPENCHAMBER_VAPID_SUBJECT;
1178
1198
  if (typeof configured === 'string' && configured.trim().length > 0) {
@@ -2808,15 +2828,6 @@ async function main(options = {}) {
2808
2828
  });
2809
2829
 
2810
2830
  app.get('/api/global/event', async (req, res) => {
2811
- const uiToken = getUiSessionTokenFromRequest(req);
2812
- if (uiToken) {
2813
- activeUiSseConnections.add(uiToken);
2814
- const cleanupUiToken = () => {
2815
- activeUiSseConnections.delete(uiToken);
2816
- };
2817
- req.on('close', cleanupUiToken);
2818
- req.on('error', cleanupUiToken);
2819
- }
2820
2831
  let targetUrl;
2821
2832
  try {
2822
2833
  targetUrl = new URL(buildOpenCodeUrl('/global/event', ''));
@@ -2928,15 +2939,6 @@ async function main(options = {}) {
2928
2939
  });
2929
2940
 
2930
2941
  app.get('/api/event', async (req, res) => {
2931
- const uiToken = getUiSessionTokenFromRequest(req);
2932
- if (uiToken) {
2933
- activeUiSseConnections.add(uiToken);
2934
- const cleanupUiToken = () => {
2935
- activeUiSseConnections.delete(uiToken);
2936
- };
2937
- req.on('close', cleanupUiToken);
2938
- req.on('error', cleanupUiToken);
2939
- }
2940
2942
  let targetUrl;
2941
2943
  try {
2942
2944
  targetUrl = new URL(buildOpenCodeUrl('/event', ''));
@@ -3849,6 +3851,939 @@ async function main(options = {}) {
3849
3851
  return authLibrary;
3850
3852
  };
3851
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
+ const first = Array.isArray(list?.data) ? list.data[0] : null;
4086
+ if (!first) {
4087
+ return res.json({ connected: true, repo, branch, pr: null, checks: null, canMerge: false });
4088
+ }
4089
+
4090
+ // Enrich with mergeability fields
4091
+ const prFull = await octokit.rest.pulls.get({ owner: repo.owner, repo: repo.repo, pull_number: first.number });
4092
+ const prData = prFull?.data;
4093
+ if (!prData) {
4094
+ return res.json({ connected: true, repo, branch, pr: null, checks: null, canMerge: false });
4095
+ }
4096
+
4097
+ // Checks summary: prefer check-runs (Actions), fallback to classic statuses.
4098
+ let checks = null;
4099
+ const sha = prData.head?.sha;
4100
+ if (sha) {
4101
+ try {
4102
+ const runs = await octokit.rest.checks.listForRef({
4103
+ owner: repo.owner,
4104
+ repo: repo.repo,
4105
+ ref: sha,
4106
+ per_page: 100,
4107
+ });
4108
+ const checkRuns = Array.isArray(runs?.data?.check_runs) ? runs.data.check_runs : [];
4109
+ if (checkRuns.length > 0) {
4110
+ const counts = { success: 0, failure: 0, pending: 0 };
4111
+ for (const run of checkRuns) {
4112
+ const status = run?.status;
4113
+ const conclusion = run?.conclusion;
4114
+ if (status === 'queued' || status === 'in_progress') {
4115
+ counts.pending += 1;
4116
+ continue;
4117
+ }
4118
+ if (!conclusion) {
4119
+ counts.pending += 1;
4120
+ continue;
4121
+ }
4122
+ if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') {
4123
+ counts.success += 1;
4124
+ } else {
4125
+ counts.failure += 1;
4126
+ }
4127
+ }
4128
+ const total = counts.success + counts.failure + counts.pending;
4129
+ const state = counts.failure > 0
4130
+ ? 'failure'
4131
+ : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
4132
+ checks = { state, total, ...counts };
4133
+ }
4134
+ } catch {
4135
+ // ignore and fall back
4136
+ }
4137
+
4138
+ if (!checks) {
4139
+ try {
4140
+ const combined = await octokit.rest.repos.getCombinedStatusForRef({
4141
+ owner: repo.owner,
4142
+ repo: repo.repo,
4143
+ ref: sha,
4144
+ });
4145
+ const statuses = Array.isArray(combined?.data?.statuses) ? combined.data.statuses : [];
4146
+ const counts = { success: 0, failure: 0, pending: 0 };
4147
+ statuses.forEach((s) => {
4148
+ if (s.state === 'success') counts.success += 1;
4149
+ else if (s.state === 'failure' || s.state === 'error') counts.failure += 1;
4150
+ else if (s.state === 'pending') counts.pending += 1;
4151
+ });
4152
+ const total = counts.success + counts.failure + counts.pending;
4153
+ const state = counts.failure > 0
4154
+ ? 'failure'
4155
+ : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
4156
+ checks = { state, total, ...counts };
4157
+ } catch {
4158
+ checks = null;
4159
+ }
4160
+ }
4161
+ }
4162
+
4163
+ // Permission check (best-effort)
4164
+ let canMerge = false;
4165
+ try {
4166
+ const auth = getGitHubAuth();
4167
+ const username = auth?.user?.login;
4168
+ if (username) {
4169
+ const perm = await octokit.rest.repos.getCollaboratorPermissionLevel({
4170
+ owner: repo.owner,
4171
+ repo: repo.repo,
4172
+ username,
4173
+ });
4174
+ const level = perm?.data?.permission;
4175
+ canMerge = level === 'admin' || level === 'maintain' || level === 'write';
4176
+ }
4177
+ } catch {
4178
+ canMerge = false;
4179
+ }
4180
+
4181
+ const mergedState = prData.merged ? 'merged' : (prData.state === 'closed' ? 'closed' : 'open');
4182
+
4183
+ return res.json({
4184
+ connected: true,
4185
+ repo,
4186
+ branch,
4187
+ pr: {
4188
+ number: prData.number,
4189
+ title: prData.title,
4190
+ url: prData.html_url,
4191
+ state: mergedState,
4192
+ draft: Boolean(prData.draft),
4193
+ base: prData.base?.ref,
4194
+ head: prData.head?.ref,
4195
+ headSha: prData.head?.sha,
4196
+ mergeable: prData.mergeable,
4197
+ mergeableState: prData.mergeable_state,
4198
+ },
4199
+ checks,
4200
+ canMerge,
4201
+ });
4202
+ } catch (error) {
4203
+ if (error?.status === 401) {
4204
+ const { clearGitHubAuth } = await getGitHubLibraries();
4205
+ clearGitHubAuth();
4206
+ return res.json({ connected: false });
4207
+ }
4208
+ console.error('Failed to load GitHub PR status:', error);
4209
+ return res.status(500).json({ error: error.message || 'Failed to load GitHub PR status' });
4210
+ }
4211
+ });
4212
+
4213
+ app.post('/api/github/pr/create', async (req, res) => {
4214
+ try {
4215
+ const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
4216
+ const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
4217
+ const head = typeof req.body?.head === 'string' ? req.body.head.trim() : '';
4218
+ const base = typeof req.body?.base === 'string' ? req.body.base.trim() : '';
4219
+ const body = typeof req.body?.body === 'string' ? req.body.body : undefined;
4220
+ const draft = typeof req.body?.draft === 'boolean' ? req.body.draft : undefined;
4221
+ if (!directory || !title || !head || !base) {
4222
+ return res.status(400).json({ error: 'directory, title, head, base are required' });
4223
+ }
4224
+
4225
+ const { getOctokitOrNull } = await getGitHubLibraries();
4226
+ const octokit = getOctokitOrNull();
4227
+ if (!octokit) {
4228
+ return res.status(401).json({ error: 'GitHub not connected' });
4229
+ }
4230
+
4231
+ const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js');
4232
+ const { repo } = await resolveGitHubRepoFromDirectory(directory);
4233
+ if (!repo) {
4234
+ return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
4235
+ }
4236
+
4237
+ const created = await octokit.rest.pulls.create({
4238
+ owner: repo.owner,
4239
+ repo: repo.repo,
4240
+ title,
4241
+ head,
4242
+ base,
4243
+ ...(typeof body === 'string' ? { body } : {}),
4244
+ ...(typeof draft === 'boolean' ? { draft } : {}),
4245
+ });
4246
+
4247
+ const pr = created?.data;
4248
+ if (!pr) {
4249
+ return res.status(500).json({ error: 'Failed to create PR' });
4250
+ }
4251
+
4252
+ return res.json({
4253
+ number: pr.number,
4254
+ title: pr.title,
4255
+ url: pr.html_url,
4256
+ state: pr.state === 'closed' ? 'closed' : 'open',
4257
+ draft: Boolean(pr.draft),
4258
+ base: pr.base?.ref,
4259
+ head: pr.head?.ref,
4260
+ headSha: pr.head?.sha,
4261
+ mergeable: pr.mergeable,
4262
+ mergeableState: pr.mergeable_state,
4263
+ });
4264
+ } catch (error) {
4265
+ console.error('Failed to create GitHub PR:', error);
4266
+ return res.status(500).json({ error: error.message || 'Failed to create GitHub PR' });
4267
+ }
4268
+ });
4269
+
4270
+ app.post('/api/github/pr/merge', async (req, res) => {
4271
+ try {
4272
+ const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
4273
+ const number = typeof req.body?.number === 'number' ? req.body.number : null;
4274
+ const method = typeof req.body?.method === 'string' ? req.body.method : 'merge';
4275
+ if (!directory || !number) {
4276
+ return res.status(400).json({ error: 'directory and number are required' });
4277
+ }
4278
+
4279
+ const { getOctokitOrNull } = await getGitHubLibraries();
4280
+ const octokit = getOctokitOrNull();
4281
+ if (!octokit) {
4282
+ return res.status(401).json({ error: 'GitHub not connected' });
4283
+ }
4284
+
4285
+ const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js');
4286
+ const { repo } = await resolveGitHubRepoFromDirectory(directory);
4287
+ if (!repo) {
4288
+ return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
4289
+ }
4290
+
4291
+ try {
4292
+ const result = await octokit.rest.pulls.merge({
4293
+ owner: repo.owner,
4294
+ repo: repo.repo,
4295
+ pull_number: number,
4296
+ merge_method: method,
4297
+ });
4298
+ return res.json({ merged: Boolean(result?.data?.merged), message: result?.data?.message });
4299
+ } catch (error) {
4300
+ if (error?.status === 403) {
4301
+ return res.status(403).json({ error: 'Not authorized to merge this PR' });
4302
+ }
4303
+ if (error?.status === 405 || error?.status === 409) {
4304
+ return res.json({ merged: false, message: error?.message || 'PR not mergeable' });
4305
+ }
4306
+ throw error;
4307
+ }
4308
+ } catch (error) {
4309
+ console.error('Failed to merge GitHub PR:', error);
4310
+ return res.status(500).json({ error: error.message || 'Failed to merge GitHub PR' });
4311
+ }
4312
+ });
4313
+
4314
+ app.post('/api/github/pr/ready', async (req, res) => {
4315
+ try {
4316
+ const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
4317
+ const number = typeof req.body?.number === 'number' ? req.body.number : null;
4318
+ if (!directory || !number) {
4319
+ return res.status(400).json({ error: 'directory and number are required' });
4320
+ }
4321
+
4322
+ const { getOctokitOrNull } = await getGitHubLibraries();
4323
+ const octokit = getOctokitOrNull();
4324
+ if (!octokit) {
4325
+ return res.status(401).json({ error: 'GitHub not connected' });
4326
+ }
4327
+
4328
+ const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js');
4329
+ const { repo } = await resolveGitHubRepoFromDirectory(directory);
4330
+ if (!repo) {
4331
+ return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
4332
+ }
4333
+
4334
+ const pr = await octokit.rest.pulls.get({ owner: repo.owner, repo: repo.repo, pull_number: number });
4335
+ const nodeId = pr?.data?.node_id;
4336
+ if (!nodeId) {
4337
+ return res.status(500).json({ error: 'Failed to resolve PR node id' });
4338
+ }
4339
+
4340
+ if (pr?.data?.draft === false) {
4341
+ return res.json({ ready: true });
4342
+ }
4343
+
4344
+ try {
4345
+ await octokit.graphql(
4346
+ `mutation($pullRequestId: ID!) {\n markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) {\n pullRequest {\n id\n isDraft\n }\n }\n}`,
4347
+ { pullRequestId: nodeId }
4348
+ );
4349
+ } catch (error) {
4350
+ if (error?.status === 403) {
4351
+ return res.status(403).json({ error: 'Not authorized to mark PR ready' });
4352
+ }
4353
+ throw error;
4354
+ }
4355
+
4356
+ return res.json({ ready: true });
4357
+ } catch (error) {
4358
+ console.error('Failed to mark PR ready:', error);
4359
+ return res.status(500).json({ error: error.message || 'Failed to mark PR ready' });
4360
+ }
4361
+ });
4362
+
4363
+ // ================= GitHub Issue APIs =================
4364
+
4365
+ app.get('/api/github/issues/list', async (req, res) => {
4366
+ try {
4367
+ const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
4368
+ const page = typeof req.query?.page === 'string' ? Number(req.query.page) : 1;
4369
+ if (!directory) {
4370
+ return res.status(400).json({ error: 'directory is required' });
4371
+ }
4372
+
4373
+ const { getOctokitOrNull } = await getGitHubLibraries();
4374
+ const octokit = getOctokitOrNull();
4375
+ if (!octokit) {
4376
+ return res.json({ connected: false });
4377
+ }
4378
+
4379
+ const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js');
4380
+ const { repo } = await resolveGitHubRepoFromDirectory(directory);
4381
+ if (!repo) {
4382
+ return res.json({ connected: true, repo: null, issues: [] });
4383
+ }
4384
+
4385
+ const list = await octokit.rest.issues.listForRepo({
4386
+ owner: repo.owner,
4387
+ repo: repo.repo,
4388
+ state: 'open',
4389
+ per_page: 50,
4390
+ page: Number.isFinite(page) && page > 0 ? page : 1,
4391
+ });
4392
+ const link = typeof list?.headers?.link === 'string' ? list.headers.link : '';
4393
+ const hasMore = /rel="next"/.test(link);
4394
+ const issues = (Array.isArray(list?.data) ? list.data : [])
4395
+ .filter((item) => !item?.pull_request)
4396
+ .map((item) => ({
4397
+ number: item.number,
4398
+ title: item.title,
4399
+ url: item.html_url,
4400
+ state: item.state === 'closed' ? 'closed' : 'open',
4401
+ author: item.user ? { login: item.user.login, id: item.user.id, avatarUrl: item.user.avatar_url } : null,
4402
+ labels: Array.isArray(item.labels)
4403
+ ? item.labels
4404
+ .map((label) => {
4405
+ if (typeof label === 'string') return null;
4406
+ const name = typeof label?.name === 'string' ? label.name : '';
4407
+ if (!name) return null;
4408
+ return { name, color: typeof label?.color === 'string' ? label.color : undefined };
4409
+ })
4410
+ .filter(Boolean)
4411
+ : [],
4412
+ }));
4413
+
4414
+ return res.json({ connected: true, repo, issues, page: Number.isFinite(page) && page > 0 ? page : 1, hasMore });
4415
+ } catch (error) {
4416
+ console.error('Failed to list GitHub issues:', error);
4417
+ return res.status(500).json({ error: error.message || 'Failed to list GitHub issues' });
4418
+ }
4419
+ });
4420
+
4421
+ app.get('/api/github/issues/get', async (req, res) => {
4422
+ try {
4423
+ const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
4424
+ const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null;
4425
+ if (!directory || !number) {
4426
+ return res.status(400).json({ error: 'directory and number are required' });
4427
+ }
4428
+
4429
+ const { getOctokitOrNull } = await getGitHubLibraries();
4430
+ const octokit = getOctokitOrNull();
4431
+ if (!octokit) {
4432
+ return res.json({ connected: false });
4433
+ }
4434
+
4435
+ const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js');
4436
+ const { repo } = await resolveGitHubRepoFromDirectory(directory);
4437
+ if (!repo) {
4438
+ return res.json({ connected: true, repo: null, issue: null });
4439
+ }
4440
+
4441
+ const result = await octokit.rest.issues.get({ owner: repo.owner, repo: repo.repo, issue_number: number });
4442
+ const issue = result?.data;
4443
+ if (!issue || issue.pull_request) {
4444
+ return res.status(400).json({ error: 'Not a GitHub issue' });
4445
+ }
4446
+
4447
+ return res.json({
4448
+ connected: true,
4449
+ repo,
4450
+ issue: {
4451
+ number: issue.number,
4452
+ title: issue.title,
4453
+ url: issue.html_url,
4454
+ state: issue.state === 'closed' ? 'closed' : 'open',
4455
+ body: issue.body || '',
4456
+ createdAt: issue.created_at,
4457
+ updatedAt: issue.updated_at,
4458
+ author: issue.user ? { login: issue.user.login, id: issue.user.id, avatarUrl: issue.user.avatar_url } : null,
4459
+ assignees: Array.isArray(issue.assignees)
4460
+ ? issue.assignees
4461
+ .map((u) => (u ? { login: u.login, id: u.id, avatarUrl: u.avatar_url } : null))
4462
+ .filter(Boolean)
4463
+ : [],
4464
+ labels: Array.isArray(issue.labels)
4465
+ ? issue.labels
4466
+ .map((label) => {
4467
+ if (typeof label === 'string') return null;
4468
+ const name = typeof label?.name === 'string' ? label.name : '';
4469
+ if (!name) return null;
4470
+ return { name, color: typeof label?.color === 'string' ? label.color : undefined };
4471
+ })
4472
+ .filter(Boolean)
4473
+ : [],
4474
+ },
4475
+ });
4476
+ } catch (error) {
4477
+ console.error('Failed to fetch GitHub issue:', error);
4478
+ return res.status(500).json({ error: error.message || 'Failed to fetch GitHub issue' });
4479
+ }
4480
+ });
4481
+
4482
+ app.get('/api/github/issues/comments', async (req, res) => {
4483
+ try {
4484
+ const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
4485
+ const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null;
4486
+ if (!directory || !number) {
4487
+ return res.status(400).json({ error: 'directory and number are required' });
4488
+ }
4489
+
4490
+ const { getOctokitOrNull } = await getGitHubLibraries();
4491
+ const octokit = getOctokitOrNull();
4492
+ if (!octokit) {
4493
+ return res.json({ connected: false });
4494
+ }
4495
+
4496
+ const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js');
4497
+ const { repo } = await resolveGitHubRepoFromDirectory(directory);
4498
+ if (!repo) {
4499
+ return res.json({ connected: true, repo: null, comments: [] });
4500
+ }
4501
+
4502
+ const result = await octokit.rest.issues.listComments({
4503
+ owner: repo.owner,
4504
+ repo: repo.repo,
4505
+ issue_number: number,
4506
+ per_page: 100,
4507
+ });
4508
+ const comments = (Array.isArray(result?.data) ? result.data : [])
4509
+ .map((comment) => ({
4510
+ id: comment.id,
4511
+ url: comment.html_url,
4512
+ body: comment.body || '',
4513
+ createdAt: comment.created_at,
4514
+ updatedAt: comment.updated_at,
4515
+ author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null,
4516
+ }));
4517
+
4518
+ return res.json({ connected: true, repo, comments });
4519
+ } catch (error) {
4520
+ console.error('Failed to fetch GitHub issue comments:', error);
4521
+ return res.status(500).json({ error: error.message || 'Failed to fetch GitHub issue comments' });
4522
+ }
4523
+ });
4524
+
4525
+ // ================= GitHub Pull Request Context APIs =================
4526
+
4527
+ app.get('/api/github/pulls/list', async (req, res) => {
4528
+ try {
4529
+ const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
4530
+ const page = typeof req.query?.page === 'string' ? Number(req.query.page) : 1;
4531
+ if (!directory) {
4532
+ return res.status(400).json({ error: 'directory is required' });
4533
+ }
4534
+
4535
+ const { getOctokitOrNull } = await getGitHubLibraries();
4536
+ const octokit = getOctokitOrNull();
4537
+ if (!octokit) {
4538
+ return res.json({ connected: false });
4539
+ }
4540
+
4541
+ const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js');
4542
+ const { repo } = await resolveGitHubRepoFromDirectory(directory);
4543
+ if (!repo) {
4544
+ return res.json({ connected: true, repo: null, prs: [] });
4545
+ }
4546
+
4547
+ const list = await octokit.rest.pulls.list({
4548
+ owner: repo.owner,
4549
+ repo: repo.repo,
4550
+ state: 'open',
4551
+ per_page: 50,
4552
+ page: Number.isFinite(page) && page > 0 ? page : 1,
4553
+ });
4554
+
4555
+ const link = typeof list?.headers?.link === 'string' ? list.headers.link : '';
4556
+ const hasMore = /rel="next"/.test(link);
4557
+
4558
+ const prs = (Array.isArray(list?.data) ? list.data : []).map((pr) => {
4559
+ const mergedState = pr.merged_at ? 'merged' : (pr.state === 'closed' ? 'closed' : 'open');
4560
+ const headRepo = pr.head?.repo
4561
+ ? {
4562
+ owner: pr.head.repo.owner?.login,
4563
+ repo: pr.head.repo.name,
4564
+ url: pr.head.repo.html_url,
4565
+ cloneUrl: pr.head.repo.clone_url,
4566
+ }
4567
+ : null;
4568
+ return {
4569
+ number: pr.number,
4570
+ title: pr.title,
4571
+ url: pr.html_url,
4572
+ state: mergedState,
4573
+ draft: Boolean(pr.draft),
4574
+ base: pr.base?.ref,
4575
+ head: pr.head?.ref,
4576
+ headSha: pr.head?.sha,
4577
+ mergeable: pr.mergeable,
4578
+ mergeableState: pr.mergeable_state,
4579
+ author: pr.user ? { login: pr.user.login, id: pr.user.id, avatarUrl: pr.user.avatar_url } : null,
4580
+ headLabel: pr.head?.label,
4581
+ headRepo: headRepo && headRepo.owner && headRepo.repo && headRepo.url
4582
+ ? headRepo
4583
+ : null,
4584
+ };
4585
+ });
4586
+
4587
+ return res.json({ connected: true, repo, prs, page: Number.isFinite(page) && page > 0 ? page : 1, hasMore });
4588
+ } catch (error) {
4589
+ if (error?.status === 401) {
4590
+ const { clearGitHubAuth } = await getGitHubLibraries();
4591
+ clearGitHubAuth();
4592
+ return res.json({ connected: false });
4593
+ }
4594
+ console.error('Failed to list GitHub PRs:', error);
4595
+ return res.status(500).json({ error: error.message || 'Failed to list GitHub PRs' });
4596
+ }
4597
+ });
4598
+
4599
+ app.get('/api/github/pulls/context', async (req, res) => {
4600
+ try {
4601
+ const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
4602
+ const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null;
4603
+ const includeDiff = req.query?.diff === '1' || req.query?.diff === 'true';
4604
+ if (!directory || !number) {
4605
+ return res.status(400).json({ error: 'directory and number are required' });
4606
+ }
4607
+
4608
+ const { getOctokitOrNull } = await getGitHubLibraries();
4609
+ const octokit = getOctokitOrNull();
4610
+ if (!octokit) {
4611
+ return res.json({ connected: false });
4612
+ }
4613
+
4614
+ const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js');
4615
+ const { repo } = await resolveGitHubRepoFromDirectory(directory);
4616
+ if (!repo) {
4617
+ return res.json({ connected: true, repo: null, pr: null });
4618
+ }
4619
+
4620
+ const prResp = await octokit.rest.pulls.get({ owner: repo.owner, repo: repo.repo, pull_number: number });
4621
+ const prData = prResp?.data;
4622
+ if (!prData) {
4623
+ return res.status(404).json({ error: 'PR not found' });
4624
+ }
4625
+
4626
+ const headRepo = prData.head?.repo
4627
+ ? {
4628
+ owner: prData.head.repo.owner?.login,
4629
+ repo: prData.head.repo.name,
4630
+ url: prData.head.repo.html_url,
4631
+ cloneUrl: prData.head.repo.clone_url,
4632
+ }
4633
+ : null;
4634
+
4635
+ const mergedState = prData.merged ? 'merged' : (prData.state === 'closed' ? 'closed' : 'open');
4636
+ const pr = {
4637
+ number: prData.number,
4638
+ title: prData.title,
4639
+ url: prData.html_url,
4640
+ state: mergedState,
4641
+ draft: Boolean(prData.draft),
4642
+ base: prData.base?.ref,
4643
+ head: prData.head?.ref,
4644
+ headSha: prData.head?.sha,
4645
+ mergeable: prData.mergeable,
4646
+ mergeableState: prData.mergeable_state,
4647
+ author: prData.user ? { login: prData.user.login, id: prData.user.id, avatarUrl: prData.user.avatar_url } : null,
4648
+ headLabel: prData.head?.label,
4649
+ headRepo: headRepo && headRepo.owner && headRepo.repo && headRepo.url ? headRepo : null,
4650
+ body: prData.body || '',
4651
+ createdAt: prData.created_at,
4652
+ updatedAt: prData.updated_at,
4653
+ };
4654
+
4655
+ const issueCommentsResp = await octokit.rest.issues.listComments({
4656
+ owner: repo.owner,
4657
+ repo: repo.repo,
4658
+ issue_number: number,
4659
+ per_page: 100,
4660
+ });
4661
+ const issueComments = (Array.isArray(issueCommentsResp?.data) ? issueCommentsResp.data : []).map((comment) => ({
4662
+ id: comment.id,
4663
+ url: comment.html_url,
4664
+ body: comment.body || '',
4665
+ createdAt: comment.created_at,
4666
+ updatedAt: comment.updated_at,
4667
+ author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null,
4668
+ }));
4669
+
4670
+ const reviewCommentsResp = await octokit.rest.pulls.listReviewComments({
4671
+ owner: repo.owner,
4672
+ repo: repo.repo,
4673
+ pull_number: number,
4674
+ per_page: 100,
4675
+ });
4676
+ const reviewComments = (Array.isArray(reviewCommentsResp?.data) ? reviewCommentsResp.data : []).map((comment) => ({
4677
+ id: comment.id,
4678
+ url: comment.html_url,
4679
+ body: comment.body || '',
4680
+ createdAt: comment.created_at,
4681
+ updatedAt: comment.updated_at,
4682
+ path: comment.path,
4683
+ line: typeof comment.line === 'number' ? comment.line : null,
4684
+ position: typeof comment.position === 'number' ? comment.position : null,
4685
+ author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null,
4686
+ }));
4687
+
4688
+ const filesResp = await octokit.rest.pulls.listFiles({
4689
+ owner: repo.owner,
4690
+ repo: repo.repo,
4691
+ pull_number: number,
4692
+ per_page: 100,
4693
+ });
4694
+ const files = (Array.isArray(filesResp?.data) ? filesResp.data : []).map((f) => ({
4695
+ filename: f.filename,
4696
+ status: f.status,
4697
+ additions: f.additions,
4698
+ deletions: f.deletions,
4699
+ changes: f.changes,
4700
+ patch: f.patch,
4701
+ }));
4702
+
4703
+ // checks summary (same logic as status endpoint)
4704
+ let checks = null;
4705
+ const sha = prData.head?.sha;
4706
+ if (sha) {
4707
+ try {
4708
+ const runs = await octokit.rest.checks.listForRef({ owner: repo.owner, repo: repo.repo, ref: sha, per_page: 100 });
4709
+ const checkRuns = Array.isArray(runs?.data?.check_runs) ? runs.data.check_runs : [];
4710
+ if (checkRuns.length > 0) {
4711
+ const counts = { success: 0, failure: 0, pending: 0 };
4712
+ for (const run of checkRuns) {
4713
+ const status = run?.status;
4714
+ const conclusion = run?.conclusion;
4715
+ if (status === 'queued' || status === 'in_progress') {
4716
+ counts.pending += 1;
4717
+ continue;
4718
+ }
4719
+ if (!conclusion) {
4720
+ counts.pending += 1;
4721
+ continue;
4722
+ }
4723
+ if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') {
4724
+ counts.success += 1;
4725
+ } else {
4726
+ counts.failure += 1;
4727
+ }
4728
+ }
4729
+ const total = counts.success + counts.failure + counts.pending;
4730
+ const state = counts.failure > 0 ? 'failure' : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
4731
+ checks = { state, total, ...counts };
4732
+ }
4733
+ } catch {
4734
+ // ignore and fall back
4735
+ }
4736
+ if (!checks) {
4737
+ try {
4738
+ const combined = await octokit.rest.repos.getCombinedStatusForRef({ owner: repo.owner, repo: repo.repo, ref: sha });
4739
+ const statuses = Array.isArray(combined?.data?.statuses) ? combined.data.statuses : [];
4740
+ const counts = { success: 0, failure: 0, pending: 0 };
4741
+ statuses.forEach((s) => {
4742
+ if (s.state === 'success') counts.success += 1;
4743
+ else if (s.state === 'failure' || s.state === 'error') counts.failure += 1;
4744
+ else if (s.state === 'pending') counts.pending += 1;
4745
+ });
4746
+ const total = counts.success + counts.failure + counts.pending;
4747
+ const state = counts.failure > 0 ? 'failure' : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
4748
+ checks = { state, total, ...counts };
4749
+ } catch {
4750
+ checks = null;
4751
+ }
4752
+ }
4753
+ }
4754
+
4755
+ let diff = undefined;
4756
+ if (includeDiff) {
4757
+ const diffResp = await octokit.request('GET /repos/{owner}/{repo}/pulls/{pull_number}', {
4758
+ owner: repo.owner,
4759
+ repo: repo.repo,
4760
+ pull_number: number,
4761
+ headers: { accept: 'application/vnd.github.v3.diff' },
4762
+ });
4763
+ diff = typeof diffResp?.data === 'string' ? diffResp.data : undefined;
4764
+ }
4765
+
4766
+ return res.json({
4767
+ connected: true,
4768
+ repo,
4769
+ pr,
4770
+ issueComments,
4771
+ reviewComments,
4772
+ files,
4773
+ ...(diff ? { diff } : {}),
4774
+ checks,
4775
+ });
4776
+ } catch (error) {
4777
+ if (error?.status === 401) {
4778
+ const { clearGitHubAuth } = await getGitHubLibraries();
4779
+ clearGitHubAuth();
4780
+ return res.json({ connected: false });
4781
+ }
4782
+ console.error('Failed to load GitHub PR context:', error);
4783
+ return res.status(500).json({ error: error.message || 'Failed to load GitHub PR context' });
4784
+ }
4785
+ });
4786
+
3852
4787
  app.get('/api/provider/:providerId/source', async (req, res) => {
3853
4788
  try {
3854
4789
  const { providerId } = req.params;
@@ -4323,6 +5258,97 @@ async function main(options = {}) {
4323
5258
  }
4324
5259
  });
4325
5260
 
5261
+ app.post('/api/git/pr-description', async (req, res) => {
5262
+ const { getRangeDiff, getRangeFiles } = await getGitLibraries();
5263
+ try {
5264
+ const directory = req.query.directory;
5265
+ if (!directory || typeof directory !== 'string') {
5266
+ return res.status(400).json({ error: 'directory parameter is required' });
5267
+ }
5268
+
5269
+ const base = typeof req.body?.base === 'string' ? req.body.base.trim() : '';
5270
+ const head = typeof req.body?.head === 'string' ? req.body.head.trim() : '';
5271
+ if (!base || !head) {
5272
+ return res.status(400).json({ error: 'base and head are required' });
5273
+ }
5274
+
5275
+ const filesToDiff = await getRangeFiles(directory, { base, head });
5276
+
5277
+ const diffs = [];
5278
+ for (const filePath of filesToDiff) {
5279
+ const diff = await getRangeDiff(directory, { base, head, path: filePath, contextLines: 3 }).catch(() => '');
5280
+ if (diff && diff.trim().length > 0) {
5281
+ diffs.push({ path: filePath, diff });
5282
+ }
5283
+ }
5284
+ if (diffs.length === 0) {
5285
+ return res.status(400).json({ error: 'No diffs available for base...head' });
5286
+ }
5287
+
5288
+ const diffSummaries = diffs.map(({ path, diff }) => `FILE: ${path}\n${diff}`).join('\n\n');
5289
+
5290
+ 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}`;
5291
+
5292
+ const model = 'gpt-5-nano';
5293
+
5294
+ const completionTimeout = createTimeoutSignal(LONG_REQUEST_TIMEOUT_MS);
5295
+ let response;
5296
+ try {
5297
+ response = await fetch('https://opencode.ai/zen/v1/responses', {
5298
+ method: 'POST',
5299
+ headers: { 'Content-Type': 'application/json' },
5300
+ body: JSON.stringify({
5301
+ model,
5302
+ input: [{ role: 'user', content: prompt }],
5303
+ max_output_tokens: 1200,
5304
+ stream: false,
5305
+ reasoning: { effort: 'low' },
5306
+ }),
5307
+ signal: completionTimeout.signal,
5308
+ });
5309
+ } finally {
5310
+ completionTimeout.cleanup();
5311
+ }
5312
+
5313
+ if (!response.ok) {
5314
+ const errorBody = await response.json().catch(() => ({}));
5315
+ console.error('PR description generation failed:', errorBody);
5316
+ return res.status(502).json({ error: 'Failed to generate PR description' });
5317
+ }
5318
+
5319
+ const data = await response.json();
5320
+ const raw = data?.output?.find((item) => item?.type === 'message')?.content?.find((item) => item?.type === 'output_text')?.text?.trim();
5321
+ if (!raw) {
5322
+ return res.status(502).json({ error: 'No PR description returned by generator' });
5323
+ }
5324
+
5325
+ const cleanedJson = stripJsonMarkdownWrapper(raw);
5326
+ const extractedJson = extractJsonObject(cleanedJson) || extractJsonObject(raw);
5327
+ const candidates = [cleanedJson, extractedJson, raw].filter((candidate, index, array) => {
5328
+ return candidate && array.indexOf(candidate) === index;
5329
+ });
5330
+
5331
+ for (const candidate of candidates) {
5332
+ if (!(candidate.startsWith('{') || candidate.startsWith('['))) {
5333
+ continue;
5334
+ }
5335
+ try {
5336
+ const parsed = JSON.parse(candidate);
5337
+ const title = typeof parsed?.title === 'string' ? parsed.title : '';
5338
+ const body = typeof parsed?.body === 'string' ? parsed.body : '';
5339
+ return res.json({ title, body });
5340
+ } catch (parseError) {
5341
+ console.warn('PR description generation returned non-JSON body:', parseError);
5342
+ }
5343
+ }
5344
+
5345
+ return res.json({ title: '', body: raw });
5346
+ } catch (error) {
5347
+ console.error('Failed to generate PR description:', error);
5348
+ return res.status(500).json({ error: error.message || 'Failed to generate PR description' });
5349
+ }
5350
+ });
5351
+
4326
5352
  app.post('/api/git/pull', async (req, res) => {
4327
5353
  const { pull } = await getGitLibraries();
4328
5354
  try {