@memberjunction/server 5.27.1 → 5.29.0

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.
Files changed (50) hide show
  1. package/dist/config.d.ts +151 -0
  2. package/dist/config.d.ts.map +1 -1
  3. package/dist/config.js +15 -0
  4. package/dist/config.js.map +1 -1
  5. package/dist/generated/generated.d.ts +959 -5
  6. package/dist/generated/generated.d.ts.map +1 -1
  7. package/dist/generated/generated.js +4639 -280
  8. package/dist/generated/generated.js.map +1 -1
  9. package/dist/generic/ResolverBase.d.ts +14 -0
  10. package/dist/generic/ResolverBase.d.ts.map +1 -1
  11. package/dist/generic/ResolverBase.js +37 -3
  12. package/dist/generic/ResolverBase.js.map +1 -1
  13. package/dist/generic/RestoreContextInput.d.ts +27 -0
  14. package/dist/generic/RestoreContextInput.d.ts.map +1 -0
  15. package/dist/generic/RestoreContextInput.js +39 -0
  16. package/dist/generic/RestoreContextInput.js.map +1 -0
  17. package/dist/index.d.ts +2 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +21 -4
  20. package/dist/index.js.map +1 -1
  21. package/dist/resolvers/FeedbackResolver.d.ts +150 -0
  22. package/dist/resolvers/FeedbackResolver.d.ts.map +1 -0
  23. package/dist/resolvers/FeedbackResolver.js +876 -0
  24. package/dist/resolvers/FeedbackResolver.js.map +1 -0
  25. package/dist/resolvers/FileResolver.d.ts +27 -0
  26. package/dist/resolvers/FileResolver.d.ts.map +1 -1
  27. package/dist/resolvers/FileResolver.js +32 -3
  28. package/dist/resolvers/FileResolver.js.map +1 -1
  29. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +18 -1
  30. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  31. package/dist/resolvers/IntegrationDiscoveryResolver.js +247 -22
  32. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  33. package/dist/resolvers/MCPResolver.d.ts +77 -0
  34. package/dist/resolvers/MCPResolver.d.ts.map +1 -1
  35. package/dist/resolvers/MCPResolver.js +300 -1
  36. package/dist/resolvers/MCPResolver.js.map +1 -1
  37. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  38. package/dist/resolvers/RunAIAgentResolver.js +87 -32
  39. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  40. package/package.json +68 -66
  41. package/src/config.ts +19 -0
  42. package/src/generated/generated.ts +3430 -281
  43. package/src/generic/ResolverBase.ts +41 -4
  44. package/src/generic/RestoreContextInput.ts +32 -0
  45. package/src/index.ts +22 -5
  46. package/src/resolvers/FeedbackResolver.ts +940 -0
  47. package/src/resolvers/FileResolver.ts +33 -4
  48. package/src/resolvers/IntegrationDiscoveryResolver.ts +224 -20
  49. package/src/resolvers/MCPResolver.ts +297 -1
  50. package/src/resolvers/RunAIAgentResolver.ts +89 -32
@@ -0,0 +1,940 @@
1
+ import { Resolver, Mutation, Query, Arg, Ctx, Field, InputType, ObjectType } from 'type-graphql';
2
+ import { Octokit } from '@octokit/rest';
3
+ import { createAppAuth } from '@octokit/auth-app';
4
+ import { LogError, LogStatus } from '@memberjunction/core';
5
+ import { AppContext } from '../types.js';
6
+ import { configInfo } from '../config.js';
7
+ import { z } from 'zod';
8
+ import { ChatParams, ChatMessage, ChatMessageRole, GetAIAPIKey, BaseLLM } from '@memberjunction/ai';
9
+ import { AIEngine } from '@memberjunction/aiengine';
10
+ import { MJGlobal } from '@memberjunction/global';
11
+ import { readFileSync } from 'fs';
12
+ import { resolve } from 'path';
13
+ import { homedir } from 'os';
14
+
15
+ // Module-level cache — survives across resolver instances (type-graphql creates a new instance per request)
16
+ let cachedPrivateKey: string | null | undefined; // undefined = not yet read
17
+ let cachedOctokit: Octokit | null = null;
18
+ let cachedAuthType: string | null = null;
19
+
20
+ // ============================================================================
21
+ // GraphQL Input/Output Types
22
+ // ============================================================================
23
+
24
+ /**
25
+ * Input type for feedback submission
26
+ */
27
+ @InputType()
28
+ export class SubmitFeedbackInput {
29
+ @Field()
30
+ Title: string;
31
+
32
+ @Field()
33
+ Description: string;
34
+
35
+ @Field()
36
+ Category: string;
37
+
38
+ @Field({ nullable: true })
39
+ StepsToReproduce?: string;
40
+
41
+ @Field({ nullable: true })
42
+ ExpectedBehavior?: string;
43
+
44
+ @Field({ nullable: true })
45
+ ActualBehavior?: string;
46
+
47
+ @Field({ nullable: true })
48
+ Severity?: string;
49
+
50
+ @Field({ nullable: true })
51
+ UseCase?: string;
52
+
53
+ @Field({ nullable: true })
54
+ ProposedSolution?: string;
55
+
56
+ @Field({ nullable: true })
57
+ Email?: string;
58
+
59
+ @Field({ nullable: true })
60
+ Name?: string;
61
+
62
+ @Field({ nullable: true })
63
+ Environment?: string;
64
+
65
+ @Field({ nullable: true })
66
+ AffectedArea?: string;
67
+
68
+ @Field({ nullable: true })
69
+ CurrentPage?: string;
70
+
71
+ @Field({ nullable: true })
72
+ UserAgent?: string;
73
+
74
+ @Field({ nullable: true })
75
+ ScreenSize?: string;
76
+
77
+ @Field({ nullable: true })
78
+ AppName?: string;
79
+
80
+ @Field({ nullable: true })
81
+ AppVersion?: string;
82
+
83
+ @Field({ nullable: true })
84
+ UserId?: string;
85
+
86
+ @Field({ nullable: true })
87
+ Timestamp?: string;
88
+
89
+ @Field({ nullable: true })
90
+ Metadata?: string; // JSON string for custom metadata
91
+ }
92
+
93
+ /**
94
+ * Response type for feedback submission
95
+ */
96
+ @ObjectType()
97
+ export class FeedbackResponseType {
98
+ @Field()
99
+ Success: boolean;
100
+
101
+ @Field({ nullable: true })
102
+ IssueNumber?: number;
103
+
104
+ @Field({ nullable: true })
105
+ IssueUrl?: string;
106
+
107
+ @Field({ nullable: true })
108
+ Error?: string;
109
+ }
110
+
111
+ /**
112
+ * Input type for feedback classification
113
+ */
114
+ @InputType()
115
+ export class ClassifyFeedbackInput {
116
+ @Field()
117
+ Title: string;
118
+
119
+ @Field()
120
+ Description: string;
121
+ }
122
+
123
+ /**
124
+ * Response type for feedback classification
125
+ */
126
+ @ObjectType()
127
+ export class FeedbackClassificationResult {
128
+ @Field()
129
+ Success: boolean;
130
+
131
+ @Field({ nullable: true })
132
+ Category?: string;
133
+
134
+ @Field({ nullable: true })
135
+ Severity?: string;
136
+
137
+ @Field({ nullable: true })
138
+ Error?: string;
139
+ }
140
+
141
+ // ============================================================================
142
+ // Internal Types
143
+ // ============================================================================
144
+
145
+ interface FeedbackSubmission {
146
+ title: string;
147
+ description: string;
148
+ category: string;
149
+ stepsToReproduce?: string;
150
+ expectedBehavior?: string;
151
+ actualBehavior?: string;
152
+ severity?: string;
153
+ useCase?: string;
154
+ proposedSolution?: string;
155
+ email?: string;
156
+ name?: string;
157
+ environment?: string;
158
+ affectedArea?: string;
159
+ currentPage?: string;
160
+ userAgent?: string;
161
+ screenSize?: string;
162
+ appName?: string;
163
+ appVersion?: string;
164
+ userId?: string;
165
+ timestamp?: string;
166
+ metadata?: Record<string, unknown>;
167
+ }
168
+
169
+ interface FeedbackConfig {
170
+ owner: string;
171
+ repo: string;
172
+ defaultLabels?: string[];
173
+ categoryLabels?: Record<string, string>;
174
+ severityLabels?: Record<string, string>;
175
+ assignees?: string[];
176
+ auth: { type: 'pat'; token: string } | { type: 'app'; appId: number; installationId: number; privateKey: string };
177
+ }
178
+
179
+ // ============================================================================
180
+ // Validation Schema
181
+ // ============================================================================
182
+
183
+ const FeedbackSubmissionSchema = z.object({
184
+ title: z
185
+ .string()
186
+ .min(5, 'Title must be at least 5 characters')
187
+ .max(256, 'Title must not exceed 256 characters'),
188
+ description: z
189
+ .string()
190
+ .min(20, 'Description must be at least 20 characters')
191
+ .max(10000, 'Description must not exceed 10000 characters'),
192
+ category: z.union([
193
+ z.literal('bug'),
194
+ z.literal('feature'),
195
+ z.literal('question'),
196
+ z.literal('other'),
197
+ z.string().max(50),
198
+ ]),
199
+ stepsToReproduce: z.string().max(5000).optional(),
200
+ expectedBehavior: z.string().max(5000).optional(),
201
+ actualBehavior: z.string().max(5000).optional(),
202
+ severity: z.enum(['critical', 'major', 'minor', 'trivial']).optional(),
203
+ useCase: z.string().max(5000).optional(),
204
+ proposedSolution: z.string().max(5000).optional(),
205
+ email: z.string().email().optional().or(z.literal('')),
206
+ name: z.string().max(100).optional(),
207
+ environment: z.enum(['production', 'staging', 'development', 'local']).optional(),
208
+ affectedArea: z.string().max(100).optional(),
209
+ currentPage: z.string().max(200).optional(),
210
+ userAgent: z.string().max(500).optional(),
211
+ screenSize: z.string().max(20).optional(),
212
+ appName: z.string().max(100).optional(),
213
+ appVersion: z.string().max(50).optional(),
214
+ userId: z.string().max(100).optional(),
215
+ timestamp: z.string().optional(),
216
+ metadata: z.record(z.unknown()).optional(),
217
+ });
218
+
219
+ // ============================================================================
220
+ // Display Labels
221
+ // ============================================================================
222
+
223
+ const categoryLabels: Record<string, string> = {
224
+ bug: 'Bug Report',
225
+ feature: 'Feature Request',
226
+ question: 'Question',
227
+ other: 'Other',
228
+ };
229
+
230
+ const severityLabels: Record<string, string> = {
231
+ critical: 'Critical',
232
+ major: 'Major',
233
+ minor: 'Minor',
234
+ trivial: 'Trivial',
235
+ };
236
+
237
+ const environmentLabels: Record<string, string> = {
238
+ production: 'Production',
239
+ staging: 'Staging',
240
+ development: 'Development',
241
+ local: 'Local',
242
+ };
243
+
244
+ // ============================================================================
245
+ // Resolver
246
+ // ============================================================================
247
+
248
+ @Resolver()
249
+ export class FeedbackResolver {
250
+ /**
251
+ * Submit feedback and create a GitHub issue
252
+ */
253
+ /**
254
+ * Check if the feedback feature is enabled for this org.
255
+ */
256
+ @Query(() => Boolean)
257
+ FeedbackEnabled(): boolean {
258
+ return this.isFeedbackEnabled();
259
+ }
260
+
261
+ /**
262
+ * Feedback is considered enabled only when both (a) the org kill switch
263
+ * is not explicitly off and (b) GitHub credentials are configured. Without
264
+ * (b) the feature cannot create issues, so we hide it rather than letting
265
+ * users submit into a broken pipe.
266
+ */
267
+ private isFeedbackEnabled(): boolean {
268
+ if (configInfo.feedbackSettings.enabled === false) {
269
+ return false;
270
+ }
271
+ return this.resolveAuth() !== null;
272
+ }
273
+
274
+ @Mutation(() => FeedbackResponseType)
275
+ async SubmitFeedback(
276
+ @Arg('input') input: SubmitFeedbackInput,
277
+ @Ctx() ctx: AppContext
278
+ ): Promise<FeedbackResponseType> {
279
+ try {
280
+ if (!this.isFeedbackEnabled()) {
281
+ return { Success: false, Error: 'Feedback is disabled for this organization.' };
282
+ }
283
+
284
+ // Get configuration
285
+ const config = this.getConfig();
286
+ if (!config) {
287
+ return {
288
+ Success: false,
289
+ Error: 'Feedback system is not configured. GITHUB_PAT environment variable is required.',
290
+ };
291
+ }
292
+
293
+ // Convert input to internal format
294
+ const submission = this.convertInputToSubmission(input, ctx);
295
+
296
+ // Validate submission
297
+ const validationResult = FeedbackSubmissionSchema.safeParse(submission);
298
+ if (!validationResult.success) {
299
+ const errorMessages = validationResult.error.issues.map(
300
+ (issue) => `${issue.path.join('.')}: ${issue.message}`
301
+ );
302
+ return {
303
+ Success: false,
304
+ Error: `Validation failed: ${errorMessages.join('; ')}`,
305
+ };
306
+ }
307
+
308
+ // Create GitHub issue
309
+ const issue = await this.createGitHubIssue(submission, config);
310
+
311
+ LogStatus(`Feedback submitted: Issue #${issue.number} created`);
312
+
313
+ return {
314
+ Success: true,
315
+ IssueNumber: issue.number,
316
+ IssueUrl: issue.html_url,
317
+ };
318
+ } catch (error) {
319
+ LogError('Error submitting feedback', undefined, error);
320
+ const message = error instanceof Error ? error.message : 'Unknown error occurred';
321
+ return {
322
+ Success: false,
323
+ Error: message,
324
+ };
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Classify feedback using an LLM to suggest category and severity.
330
+ */
331
+ @Mutation(() => FeedbackClassificationResult)
332
+ async ClassifyFeedback(
333
+ @Arg('input') input: ClassifyFeedbackInput,
334
+ @Ctx() ctx: AppContext
335
+ ): Promise<FeedbackClassificationResult> {
336
+ try {
337
+ if (!this.isFeedbackEnabled()) {
338
+ return { Success: false, Error: 'Feedback is disabled for this organization.' };
339
+ }
340
+
341
+ // Get the current context user for AIEngine config
342
+ const contextUser = ctx.userPayload?.userRecord;
343
+ await AIEngine.Instance.Config(false, contextUser);
344
+
345
+ // Get active LLM models with valid API keys (guard against null DriverClass)
346
+ const allModels = AIEngine.Instance.Models.filter(m =>
347
+ m.AIModelType?.trim().toLowerCase() === 'llm' && m.IsActive && m.DriverClass
348
+ );
349
+ const models = allModels.filter(m => {
350
+ try {
351
+ const key = GetAIAPIKey(m.DriverClass);
352
+ return key && key.trim().length > 0;
353
+ } catch {
354
+ return false;
355
+ }
356
+ });
357
+
358
+ if (models.length === 0) {
359
+ return { Success: false, Error: 'No AI models configured' };
360
+ }
361
+
362
+ // Pick a fast, cheap model — prefer Groq, then OpenAI, then any available
363
+ const model = models.find(m => m.DriverClass === 'GroqLLM')
364
+ || models.find(m => m.DriverClass === 'OpenAILLM')
365
+ || models[0];
366
+
367
+ const apiKey = GetAIAPIKey(model.DriverClass);
368
+ const llm = MJGlobal.Instance.ClassFactory.CreateInstance<BaseLLM>(
369
+ BaseLLM, model.DriverClass, apiKey
370
+ );
371
+
372
+ if (!llm) {
373
+ return { Success: false, Error: 'Failed to create LLM instance' };
374
+ }
375
+
376
+ const chatParams = new ChatParams();
377
+ chatParams.model = model.APIName;
378
+ chatParams.messages = [
379
+ {
380
+ role: ChatMessageRole.system,
381
+ content: `Classify this user feedback. Respond with ONLY a JSON object, no other text:
382
+ {"category": "bug"|"feature"|"question"|"other", "severity": "critical"|"major"|"minor"|"trivial"}
383
+
384
+ Step 1 — CATEGORY. Choose ONE. Read the intent, not just keywords:
385
+ - "question" = user is SEEKING HELP or INFORMATION. Clues: "how do I", "where is", "can I", "is it possible", "I can't find", "where did ___ go", "used to see ___ but now", "help me". Even if they sound frustrated, if they're looking for guidance it's a question. A user who can't FIND something is asking a question, not reporting a bug.
386
+ - "feature" = user WANTS something NEW: "it would be nice", "can you add", "I wish", "please add", "would love to see"
387
+ - "bug" = something is OBJECTIVELY BROKEN: "error message", "crash", "500 error", "white screen", "data lost", "button does nothing when clicked", "stops responding". The system is malfunctioning, not just confusing.
388
+ - "other" = doesn't fit the above
389
+
390
+ Step 2 — SEVERITY. Strictly follow these rules:
391
+ - If category is "question" → severity MUST be "trivial"
392
+ - If category is "feature" → severity MUST be "minor"
393
+ - If category is "other" → severity MUST be "minor"
394
+ - If category is "bug":
395
+ - "critical" = data loss, security vulnerability, entire system unusable for ALL users
396
+ - "major" = user EXPLICITLY says words like "blocking me", "preventing me from", "can't complete my work", "unable to do my job". Without these exact phrases, do NOT use major.
397
+ - "trivial" = purely visual: typo, misspelling, alignment, spacing, colors, dark mode, theme, font, styling, cosmetic appearance
398
+ - "minor" = everything else. This is the DEFAULT for bugs. If unsure between trivial and minor, pick trivial. If unsure between minor and major, pick minor.`
399
+ },
400
+ {
401
+ role: ChatMessageRole.user,
402
+ content: `Title: ${input.Title}\nDescription: ${input.Description}`
403
+ }
404
+ ];
405
+
406
+ const result = await llm.ChatCompletion(chatParams);
407
+ const content = result?.data?.choices?.[0]?.message?.content?.trim();
408
+
409
+ if (!content) {
410
+ return { Success: false, Error: 'Empty response from AI' };
411
+ }
412
+
413
+ // Parse JSON from response (handle potential markdown code fences)
414
+ const jsonStr = content.replace(/```json?\s*/g, '').replace(/```/g, '').trim();
415
+ const parsed = JSON.parse(jsonStr);
416
+
417
+ return {
418
+ Success: true,
419
+ Category: parsed.category,
420
+ Severity: parsed.severity
421
+ };
422
+ } catch (error) {
423
+ LogError('Error classifying feedback', undefined, error);
424
+ return { Success: false, Error: 'Classification failed' };
425
+ }
426
+ }
427
+
428
+ // ============================================================================
429
+ // Private Helper Methods
430
+ // ============================================================================
431
+
432
+ /**
433
+ * Get feedback configuration from environment/config.
434
+ * Supports GitHub App auth (preferred) or PAT fallback.
435
+ */
436
+ private getConfig(): FeedbackConfig | null {
437
+ const auth = this.resolveAuth();
438
+ if (!auth) {
439
+ return null;
440
+ }
441
+
442
+ const githubSettings = configInfo.feedbackSettings?.github;
443
+
444
+ return {
445
+ owner: githubSettings?.owner || process.env.GITHUB_FEEDBACK_OWNER || 'MemberJunction',
446
+ repo: githubSettings?.repo || process.env.GITHUB_FEEDBACK_REPO || 'MJ',
447
+ auth,
448
+ defaultLabels: githubSettings?.defaultLabels || ['user-submitted'],
449
+ categoryLabels: githubSettings?.categoryLabels || {
450
+ bug: 'bug',
451
+ feature: 'enhancement',
452
+ question: 'question',
453
+ other: 'triage',
454
+ },
455
+ severityLabels: githubSettings?.severityLabels || {
456
+ critical: 'priority: critical',
457
+ major: 'priority: high',
458
+ minor: 'priority: medium',
459
+ trivial: 'priority: low',
460
+ },
461
+ assignees: githubSettings?.assignees,
462
+ };
463
+ }
464
+
465
+ /**
466
+ * Resolve authentication — prefers GitHub App, falls back to PAT.
467
+ */
468
+ private resolveAuth(): FeedbackConfig['auth'] | null {
469
+ // Try GitHub App first
470
+ const appId = process.env.GITHUB_APP_ID;
471
+ const installationId = process.env.GITHUB_APP_INSTALLATION_ID;
472
+ const privateKey = this.readPrivateKey();
473
+
474
+ if (appId && installationId && privateKey) {
475
+ return {
476
+ type: 'app',
477
+ appId: Number(appId),
478
+ installationId: Number(installationId),
479
+ privateKey
480
+ };
481
+ }
482
+
483
+ // Fall back to PAT
484
+ const token = process.env.GITHUB_PAT;
485
+ if (token) {
486
+ return { type: 'pat', token };
487
+ }
488
+
489
+ return null;
490
+ }
491
+
492
+ /**
493
+ * Read the GitHub App private key from file path or inline env var.
494
+ * Cached at module level — the key file doesn't change at runtime.
495
+ */
496
+ private readPrivateKey(): string | null {
497
+ if (cachedPrivateKey !== undefined) {
498
+ return cachedPrivateKey;
499
+ }
500
+
501
+ // Option 1: File path
502
+ const keyPath = process.env.GITHUB_APP_PRIVATE_KEY_PATH;
503
+ if (keyPath) {
504
+ try {
505
+ const resolved = keyPath.startsWith('~/')
506
+ ? resolve(homedir(), keyPath.slice(2))
507
+ : resolve(keyPath);
508
+ cachedPrivateKey = readFileSync(resolved, 'utf-8');
509
+ return cachedPrivateKey;
510
+ } catch (error) {
511
+ LogError('Failed to read GitHub App private key file', undefined, error);
512
+ cachedPrivateKey = null;
513
+ return null;
514
+ }
515
+ }
516
+
517
+ // Option 2: Inline key (with \n replaced)
518
+ const inlineKey = process.env.GITHUB_APP_PRIVATE_KEY;
519
+ if (inlineKey) {
520
+ cachedPrivateKey = inlineKey.replace(/\\n/g, '\n');
521
+ return cachedPrivateKey;
522
+ }
523
+
524
+ cachedPrivateKey = null;
525
+ return null;
526
+ }
527
+
528
+ /**
529
+ * Convert GraphQL input to internal submission format
530
+ */
531
+ private convertInputToSubmission(input: SubmitFeedbackInput, ctx: AppContext): FeedbackSubmission {
532
+ const submission: FeedbackSubmission = {
533
+ title: input.Title,
534
+ description: input.Description,
535
+ category: input.Category,
536
+ stepsToReproduce: input.StepsToReproduce,
537
+ expectedBehavior: input.ExpectedBehavior,
538
+ actualBehavior: input.ActualBehavior,
539
+ severity: input.Severity,
540
+ useCase: input.UseCase,
541
+ proposedSolution: input.ProposedSolution,
542
+ email: input.Email,
543
+ name: input.Name,
544
+ environment: input.Environment,
545
+ affectedArea: input.AffectedArea,
546
+ currentPage: input.CurrentPage,
547
+ userAgent: input.UserAgent,
548
+ screenSize: input.ScreenSize,
549
+ appName: input.AppName,
550
+ appVersion: input.AppVersion,
551
+ userId: input.UserId || ctx.userPayload?.userRecord?.ID,
552
+ timestamp: input.Timestamp || new Date().toISOString(),
553
+ };
554
+
555
+ // Parse metadata if provided
556
+ if (input.Metadata) {
557
+ try {
558
+ submission.metadata = JSON.parse(input.Metadata);
559
+ } catch {
560
+ // Ignore invalid JSON
561
+ }
562
+ }
563
+
564
+ return submission;
565
+ }
566
+
567
+ /**
568
+ * Get or create an authenticated Octokit instance.
569
+ * Cached at module level — createAppAuth handles token refresh internally.
570
+ */
571
+ private getOctokit(config: FeedbackConfig): Octokit {
572
+ if (cachedOctokit && cachedAuthType === config.auth.type) {
573
+ return cachedOctokit;
574
+ }
575
+
576
+ if (config.auth.type === 'app') {
577
+ cachedOctokit = new Octokit({
578
+ authStrategy: createAppAuth,
579
+ auth: {
580
+ appId: config.auth.appId,
581
+ privateKey: config.auth.privateKey,
582
+ installationId: config.auth.installationId,
583
+ },
584
+ });
585
+ } else {
586
+ cachedOctokit = new Octokit({ auth: config.auth.token });
587
+ }
588
+
589
+ cachedAuthType = config.auth.type;
590
+ return cachedOctokit;
591
+ }
592
+
593
+ /**
594
+ * Create a GitHub issue from the feedback submission.
595
+ * If a screenshot is present, uploads it to the repo and embeds it in the issue.
596
+ */
597
+ private async createGitHubIssue(
598
+ submission: FeedbackSubmission,
599
+ config: FeedbackConfig
600
+ ): Promise<{ number: number; html_url: string }> {
601
+ const octokit = this.getOctokit(config);
602
+
603
+ // Extract screenshot before formatting (formatScreenshotSection removes it from metadata)
604
+ const screenshotDataUrl = submission.metadata?.screenshot as string | undefined;
605
+
606
+ const labels = this.buildLabels(submission, config);
607
+ const body = this.formatIssueBody(submission);
608
+
609
+ // Create the issue first
610
+ const response = await octokit.issues.create({
611
+ owner: config.owner,
612
+ repo: config.repo,
613
+ title: submission.title,
614
+ body,
615
+ labels,
616
+ assignees: config.assignees,
617
+ });
618
+
619
+ const issueNumber = response.data.number;
620
+
621
+ // Upload screenshot and update issue body if present
622
+ if (screenshotDataUrl && typeof screenshotDataUrl === 'string') {
623
+ try {
624
+ const imageUrl = await this.uploadScreenshot(
625
+ octokit, config.owner, config.repo, screenshotDataUrl, issueNumber
626
+ );
627
+ if (imageUrl) {
628
+ const screenshotMarkdown = `\n## Screenshot\n\n![Screenshot](${imageUrl})\n`;
629
+ await octokit.issues.update({
630
+ owner: config.owner,
631
+ repo: config.repo,
632
+ issue_number: issueNumber,
633
+ body: body + screenshotMarkdown,
634
+ });
635
+ }
636
+ } catch (error) {
637
+ // Screenshot upload is best-effort — don't fail the whole submission
638
+ LogError('Failed to upload feedback screenshot', undefined, error);
639
+ }
640
+ }
641
+
642
+ return {
643
+ number: issueNumber,
644
+ html_url: response.data.html_url,
645
+ };
646
+ }
647
+
648
+ /**
649
+ * Upload a base64 screenshot to the repo and return the raw content URL.
650
+ */
651
+ private async uploadScreenshot(
652
+ octokit: Octokit,
653
+ owner: string,
654
+ repo: string,
655
+ dataUrl: string,
656
+ issueNumber: number
657
+ ): Promise<string | null> {
658
+ // Strip the data URL prefix (e.g., "data:image/jpeg;base64,")
659
+ const base64Match = dataUrl.match(/^data:image\/(\w+);base64,(.+)$/);
660
+ if (!base64Match) return null;
661
+
662
+ const extension = base64Match[1] === 'jpeg' ? 'jpg' : base64Match[1];
663
+ const base64Content = base64Match[2];
664
+ const filename = `${issueNumber}-${Date.now()}.${extension}`;
665
+ const path = `.github/feedback-screenshots/${filename}`;
666
+
667
+ await octokit.repos.createOrUpdateFileContents({
668
+ owner,
669
+ repo,
670
+ path,
671
+ message: `feedback: add screenshot for issue #${issueNumber}`,
672
+ content: base64Content,
673
+ });
674
+
675
+ return `https://raw.githubusercontent.com/${owner}/${repo}/next/${path}`;
676
+ }
677
+
678
+ /**
679
+ * Build the labels array for the GitHub issue
680
+ */
681
+ private buildLabels(submission: FeedbackSubmission, config: FeedbackConfig): string[] {
682
+ const labels: string[] = [];
683
+
684
+ if (config.defaultLabels) {
685
+ labels.push(...config.defaultLabels);
686
+ }
687
+
688
+ if (config.categoryLabels && submission.category in config.categoryLabels) {
689
+ labels.push(config.categoryLabels[submission.category]);
690
+ }
691
+
692
+ if (submission.severity && config.severityLabels && submission.severity in config.severityLabels) {
693
+ labels.push(config.severityLabels[submission.severity]);
694
+ }
695
+
696
+ return labels;
697
+ }
698
+
699
+ /**
700
+ * Format the feedback submission as a GitHub issue body in markdown.
701
+ * Orchestrates section-specific formatters and joins them.
702
+ */
703
+ private formatIssueBody(submission: FeedbackSubmission): string {
704
+ const sections: string[] = [
705
+ this.formatDescriptionSection(submission),
706
+ this.formatScreenshotSection(submission),
707
+ '---',
708
+ '',
709
+ this.formatDetailsTable(submission),
710
+ this.formatContactSection(submission),
711
+ this.formatTechnicalDetails(submission),
712
+ this.formatMetadataSection(submission),
713
+ ];
714
+
715
+ return sections.filter(s => s.length > 0).join('\n');
716
+ }
717
+
718
+ /**
719
+ * Format the description and category-specific sections (bug/feature)
720
+ */
721
+ private formatDescriptionSection(submission: FeedbackSubmission): string {
722
+ const lines: string[] = [];
723
+
724
+ lines.push('## Description', '', this.sanitizeMarkdown(submission.description), '');
725
+
726
+ if (submission.category === 'bug') {
727
+ if (submission.stepsToReproduce) {
728
+ lines.push('## Steps to Reproduce', '', this.sanitizeMarkdown(submission.stepsToReproduce), '');
729
+ }
730
+ if (submission.expectedBehavior) {
731
+ lines.push('## Expected Behavior', '', this.sanitizeMarkdown(submission.expectedBehavior), '');
732
+ }
733
+ if (submission.actualBehavior) {
734
+ lines.push('## Actual Behavior', '', this.sanitizeMarkdown(submission.actualBehavior), '');
735
+ }
736
+ }
737
+
738
+ if (submission.category === 'feature') {
739
+ if (submission.useCase) {
740
+ lines.push('## Use Case', '', this.sanitizeMarkdown(submission.useCase), '');
741
+ }
742
+ if (submission.proposedSolution) {
743
+ lines.push('## Proposed Solution', '', this.sanitizeMarkdown(submission.proposedSolution), '');
744
+ }
745
+ }
746
+
747
+ return lines.join('\n');
748
+ }
749
+
750
+ /**
751
+ * Format the submission details markdown table
752
+ */
753
+ private formatDetailsTable(submission: FeedbackSubmission): string {
754
+ const rows: string[] = [];
755
+ rows.push('### Submission Details', '', '| Field | Value |', '|-------|-------|');
756
+
757
+ if (submission.appName) {
758
+ const appInfo = submission.appVersion
759
+ ? `${this.sanitizeMarkdown(submission.appName)} v${this.sanitizeMarkdown(submission.appVersion)}`
760
+ : this.sanitizeMarkdown(submission.appName);
761
+ rows.push(`| **App** | ${appInfo} |`);
762
+ }
763
+
764
+ const categoryDisplay = categoryLabels[submission.category] || submission.category;
765
+ rows.push(`| **Category** | ${this.sanitizeMarkdown(categoryDisplay)} |`);
766
+
767
+ if (submission.severity) {
768
+ const severityDisplay = severityLabels[submission.severity] || submission.severity;
769
+ rows.push(`| **Severity** | ${this.sanitizeMarkdown(severityDisplay)} |`);
770
+ }
771
+
772
+ if (submission.environment) {
773
+ const envDisplay = environmentLabels[submission.environment] || submission.environment;
774
+ rows.push(`| **Environment** | ${this.sanitizeMarkdown(envDisplay)} |`);
775
+ }
776
+
777
+ if (submission.affectedArea) {
778
+ rows.push(`| **Affected Area** | ${this.sanitizeMarkdown(submission.affectedArea)} |`);
779
+ }
780
+
781
+ if (submission.timestamp) {
782
+ rows.push(`| **Submitted** | ${this.sanitizeMarkdown(submission.timestamp)} |`);
783
+ }
784
+
785
+ rows.push('');
786
+ return rows.join('\n');
787
+ }
788
+
789
+ /**
790
+ * Format the contact information section
791
+ */
792
+ private formatContactSection(submission: FeedbackSubmission): string {
793
+ if (!submission.name && !submission.email) {
794
+ return '';
795
+ }
796
+
797
+ const contactParts: string[] = [];
798
+ if (submission.name) {
799
+ contactParts.push(this.sanitizeMarkdown(submission.name));
800
+ }
801
+ if (submission.email) {
802
+ contactParts.push(this.sanitizeMarkdown(submission.email));
803
+ }
804
+
805
+ return ['### Contact', '', contactParts.join(' — '), ''].join('\n');
806
+ }
807
+
808
+ /**
809
+ * Format the technical details table (page, browser, screen, user)
810
+ */
811
+ private formatTechnicalDetails(submission: FeedbackSubmission): string {
812
+ if (!submission.currentPage && !submission.userAgent && !submission.screenSize && !submission.userId) {
813
+ return '';
814
+ }
815
+
816
+ const rows: string[] = [];
817
+ rows.push('### Technical Details', '', '| Field | Value |', '|-------|-------|');
818
+
819
+ if (submission.currentPage) {
820
+ rows.push(`| **Page/View** | ${this.sanitizeMarkdown(submission.currentPage)} |`);
821
+ }
822
+ if (submission.userAgent) {
823
+ const browserInfo = this.parseUserAgent(submission.userAgent);
824
+ rows.push(`| **Browser** | ${this.sanitizeMarkdown(browserInfo)} |`);
825
+ }
826
+ if (submission.screenSize) {
827
+ rows.push(`| **Screen Size** | ${this.sanitizeMarkdown(submission.screenSize)} |`);
828
+ }
829
+ if (submission.userId) {
830
+ rows.push(`| **User ID** | ${this.sanitizeMarkdown(submission.userId)} |`);
831
+ }
832
+
833
+ rows.push('');
834
+ return rows.join('\n');
835
+ }
836
+
837
+ /**
838
+ * Format the screenshot section. Embeds the base64 data in a collapsed block
839
+ * that AI agents can parse, even though GitHub won't render it visually.
840
+ * If the file upload succeeds (in createGitHubIssue), the issue body gets
841
+ * updated with a rendered image above this block.
842
+ */
843
+ private formatScreenshotSection(submission: FeedbackSubmission): string {
844
+ const screenshot = submission.metadata?.screenshot;
845
+ if (!screenshot || typeof screenshot !== 'string') {
846
+ return '';
847
+ }
848
+
849
+ // Remove from metadata so it doesn't also appear in the JSON section
850
+ delete submission.metadata!.screenshot;
851
+
852
+ return [
853
+ '<details>',
854
+ '<summary>📷 Screenshot (for AI agents — not rendered by GitHub)</summary>',
855
+ '',
856
+ `![Screenshot](${screenshot})`,
857
+ '',
858
+ '</details>',
859
+ '',
860
+ ].join('\n');
861
+ }
862
+
863
+ /**
864
+ * Format the collapsible metadata JSON section
865
+ */
866
+ private formatMetadataSection(submission: FeedbackSubmission): string {
867
+ if (!submission.metadata || Object.keys(submission.metadata).length === 0) {
868
+ return '';
869
+ }
870
+
871
+ return [
872
+ '<details>',
873
+ '<summary>Additional Metadata</summary>',
874
+ '',
875
+ '```json',
876
+ JSON.stringify(submission.metadata, null, 2),
877
+ '```',
878
+ '</details>',
879
+ ].join('\n');
880
+ }
881
+
882
+ /**
883
+ * Sanitize a string for safe inclusion in markdown
884
+ */
885
+ private sanitizeMarkdown(text: string | undefined): string {
886
+ if (!text) return '';
887
+
888
+ return text
889
+ .replace(/\\/g, '\\\\') // backslashes first (before other escapes add more)
890
+ .replace(/&/g, '&amp;')
891
+ .replace(/</g, '&lt;')
892
+ .replace(/>/g, '&gt;')
893
+ .replace(/`/g, '\\`') // inline code injection
894
+ .replace(/\[/g, '\\[')
895
+ .replace(/\]/g, '\\]')
896
+ .replace(/!\[/g, '!\\[')
897
+ .replace(/\(/g, '\\(') // link URL injection
898
+ .replace(/\)/g, '\\)')
899
+ .replace(/\|/g, '\\|');
900
+ }
901
+
902
+ /**
903
+ * Parse user agent string into a human-readable format
904
+ */
905
+ private parseUserAgent(userAgent: string): string {
906
+ let browser = 'Unknown Browser';
907
+ let os = 'Unknown OS';
908
+
909
+ if (userAgent.includes('Firefox/')) {
910
+ const match = userAgent.match(/Firefox\/(\d+)/);
911
+ browser = match ? `Firefox ${match[1]}` : 'Firefox';
912
+ } else if (userAgent.includes('Edg/')) {
913
+ const match = userAgent.match(/Edg\/(\d+)/);
914
+ browser = match ? `Edge ${match[1]}` : 'Edge';
915
+ } else if (userAgent.includes('Chrome/')) {
916
+ const match = userAgent.match(/Chrome\/(\d+)/);
917
+ browser = match ? `Chrome ${match[1]}` : 'Chrome';
918
+ } else if (userAgent.includes('Safari/') && !userAgent.includes('Chrome')) {
919
+ const match = userAgent.match(/Version\/(\d+)/);
920
+ browser = match ? `Safari ${match[1]}` : 'Safari';
921
+ }
922
+
923
+ if (userAgent.includes('Windows NT 10')) {
924
+ os = 'Windows 10/11';
925
+ } else if (userAgent.includes('Windows NT')) {
926
+ os = 'Windows';
927
+ } else if (userAgent.includes('Mac OS X')) {
928
+ const match = userAgent.match(/Mac OS X (\d+[._]\d+)/);
929
+ os = match ? `macOS ${match[1].replace('_', '.')}` : 'macOS';
930
+ } else if (userAgent.includes('Linux')) {
931
+ os = 'Linux';
932
+ } else if (userAgent.includes('Android')) {
933
+ os = 'Android';
934
+ } else if (userAgent.includes('iOS') || userAgent.includes('iPhone') || userAgent.includes('iPad')) {
935
+ os = 'iOS';
936
+ }
937
+
938
+ return `${browser} on ${os}`;
939
+ }
940
+ }