@in-the-loop-labs/pair-review 1.0.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 (91) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +371 -0
  3. package/bin/git-diff-lines +146 -0
  4. package/bin/pair-review.js +49 -0
  5. package/package.json +71 -0
  6. package/public/css/ai-summary-modal.css +183 -0
  7. package/public/css/pr.css +8698 -0
  8. package/public/css/repo-settings.css +891 -0
  9. package/public/css/styles.css +479 -0
  10. package/public/favicon.png +0 -0
  11. package/public/index.html +1104 -0
  12. package/public/js/components/AIPanel.js +1639 -0
  13. package/public/js/components/AISummaryModal.js +278 -0
  14. package/public/js/components/AnalysisConfigModal.js +684 -0
  15. package/public/js/components/ConfirmDialog.js +227 -0
  16. package/public/js/components/PreviewModal.js +344 -0
  17. package/public/js/components/ProgressModal.js +678 -0
  18. package/public/js/components/ReviewModal.js +531 -0
  19. package/public/js/components/SplitButton.js +382 -0
  20. package/public/js/components/StatusIndicator.js +265 -0
  21. package/public/js/components/SuggestionNavigator.js +489 -0
  22. package/public/js/components/Toast.js +166 -0
  23. package/public/js/local.js +1580 -0
  24. package/public/js/modules/analysis-history.js +940 -0
  25. package/public/js/modules/comment-manager.js +643 -0
  26. package/public/js/modules/diff-renderer.js +585 -0
  27. package/public/js/modules/file-comment-manager.js +1242 -0
  28. package/public/js/modules/gap-coordinates.js +190 -0
  29. package/public/js/modules/hunk-parser.js +358 -0
  30. package/public/js/modules/line-tracker.js +386 -0
  31. package/public/js/modules/panel-resizer.js +228 -0
  32. package/public/js/modules/storage-cleanup.js +36 -0
  33. package/public/js/modules/suggestion-manager.js +692 -0
  34. package/public/js/pr.js +3503 -0
  35. package/public/js/repo-settings.js +691 -0
  36. package/public/js/utils/file-order.js +87 -0
  37. package/public/js/utils/markdown.js +97 -0
  38. package/public/js/utils/suggestion-ui.js +55 -0
  39. package/public/js/utils/tier-icons.js +25 -0
  40. package/public/local.html +460 -0
  41. package/public/pr.html +329 -0
  42. package/public/repo-settings.html +243 -0
  43. package/src/ai/analyzer.js +2592 -0
  44. package/src/ai/claude-cli.js +153 -0
  45. package/src/ai/claude-provider.js +261 -0
  46. package/src/ai/codex-provider.js +361 -0
  47. package/src/ai/copilot-provider.js +345 -0
  48. package/src/ai/gemini-provider.js +375 -0
  49. package/src/ai/index.js +47 -0
  50. package/src/ai/prompts/baseline/_meta.json +14 -0
  51. package/src/ai/prompts/baseline/level1/balanced.js +239 -0
  52. package/src/ai/prompts/baseline/level1/fast.js +194 -0
  53. package/src/ai/prompts/baseline/level1/thorough.js +319 -0
  54. package/src/ai/prompts/baseline/level2/balanced.js +248 -0
  55. package/src/ai/prompts/baseline/level2/fast.js +201 -0
  56. package/src/ai/prompts/baseline/level2/thorough.js +367 -0
  57. package/src/ai/prompts/baseline/level3/balanced.js +280 -0
  58. package/src/ai/prompts/baseline/level3/fast.js +220 -0
  59. package/src/ai/prompts/baseline/level3/thorough.js +459 -0
  60. package/src/ai/prompts/baseline/orchestration/balanced.js +259 -0
  61. package/src/ai/prompts/baseline/orchestration/fast.js +213 -0
  62. package/src/ai/prompts/baseline/orchestration/thorough.js +446 -0
  63. package/src/ai/prompts/config.js +52 -0
  64. package/src/ai/prompts/index.js +267 -0
  65. package/src/ai/prompts/shared/diff-instructions.js +50 -0
  66. package/src/ai/prompts/shared/output-schema.js +179 -0
  67. package/src/ai/prompts/shared/valid-files.js +37 -0
  68. package/src/ai/provider.js +260 -0
  69. package/src/config.js +139 -0
  70. package/src/database.js +2284 -0
  71. package/src/git/gitattributes.js +207 -0
  72. package/src/git/worktree.js +688 -0
  73. package/src/github/client.js +893 -0
  74. package/src/github/parser.js +247 -0
  75. package/src/local-review.js +691 -0
  76. package/src/main.js +987 -0
  77. package/src/routes/analysis.js +897 -0
  78. package/src/routes/comments.js +534 -0
  79. package/src/routes/config.js +250 -0
  80. package/src/routes/local.js +1728 -0
  81. package/src/routes/pr.js +1164 -0
  82. package/src/routes/shared.js +218 -0
  83. package/src/routes/worktrees.js +500 -0
  84. package/src/server.js +295 -0
  85. package/src/utils/diff-annotator.js +414 -0
  86. package/src/utils/instructions.js +33 -0
  87. package/src/utils/json-extractor.js +107 -0
  88. package/src/utils/line-validation.js +183 -0
  89. package/src/utils/logger.js +142 -0
  90. package/src/utils/paths.js +161 -0
  91. package/src/utils/stats-calculator.js +86 -0
@@ -0,0 +1,500 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ /**
3
+ * Worktree Management Routes
4
+ *
5
+ * Handles all worktree-related endpoints:
6
+ * - Creating worktrees from PR URLs
7
+ * - Getting recent worktrees
8
+ * - Deleting worktrees
9
+ */
10
+
11
+ const express = require('express');
12
+ const { query, queryOne, run, WorktreeRepository, RepoSettingsRepository } = require('../database');
13
+ const { GitWorktreeManager } = require('../git/worktree');
14
+ const { GitHubClient } = require('../github/client');
15
+ const { normalizeRepository } = require('../utils/paths');
16
+ const fs = require('fs').promises;
17
+ const path = require('path');
18
+ const logger = require('../utils/logger');
19
+ const simpleGit = require('simple-git');
20
+
21
+ const router = express.Router();
22
+
23
+ /**
24
+ * Create worktree from PR URL (for web UI start review)
25
+ * Creates worktree, fetches PR data from GitHub, stores in database
26
+ */
27
+ router.post('/api/worktrees/create', async (req, res) => {
28
+ try {
29
+ const { owner, repo, prNumber } = req.body;
30
+
31
+ // Validate required parameters
32
+ if (!owner || !repo || !prNumber) {
33
+ return res.status(400).json({
34
+ success: false,
35
+ error: 'Missing required parameters: owner, repo, prNumber'
36
+ });
37
+ }
38
+
39
+ const parsedPrNumber = parseInt(prNumber, 10);
40
+ if (isNaN(parsedPrNumber) || parsedPrNumber <= 0) {
41
+ return res.status(400).json({
42
+ success: false,
43
+ error: 'Invalid pull request number'
44
+ });
45
+ }
46
+
47
+ const db = req.app.get('db');
48
+ const config = req.app.get('config');
49
+
50
+ // Validate GitHub token
51
+ if (!config || !config.github_token) {
52
+ return res.status(500).json({
53
+ success: false,
54
+ error: 'GitHub token not configured. Please set github_token in ~/.pair-review/config.json'
55
+ });
56
+ }
57
+
58
+ const repository = normalizeRepository(owner, repo);
59
+ logger.section(`Web UI Start Review - PR #${parsedPrNumber}`);
60
+ logger.log('API', `Repository: ${repository}`, 'magenta');
61
+
62
+ // Create GitHub client and validate token
63
+ const githubClient = new GitHubClient(config.github_token);
64
+ const tokenValid = await githubClient.validateToken();
65
+ if (!tokenValid) {
66
+ return res.status(401).json({
67
+ success: false,
68
+ error: 'GitHub authentication failed. Please check your token in ~/.pair-review/config.json'
69
+ });
70
+ }
71
+
72
+ // Check if repository is accessible
73
+ const repoExists = await githubClient.repositoryExists(owner, repo);
74
+ if (!repoExists) {
75
+ return res.status(404).json({
76
+ success: false,
77
+ error: `Repository ${repository} not found or not accessible`
78
+ });
79
+ }
80
+
81
+ // Fetch PR data from GitHub
82
+ logger.info('Fetching pull request data from GitHub...');
83
+ let prData;
84
+ try {
85
+ prData = await githubClient.fetchPullRequest(owner, repo, parsedPrNumber);
86
+ } catch (error) {
87
+ if (error.message && error.message.includes('not found')) {
88
+ return res.status(404).json({
89
+ success: false,
90
+ error: `Pull request #${parsedPrNumber} not found in ${repository}`
91
+ });
92
+ }
93
+ throw error;
94
+ }
95
+
96
+ // Get current working directory for worktree creation
97
+ // Since we're running from the web UI, we need to find a valid git repository
98
+ // The worktree manager will handle this by using its configured base directory
99
+ const worktreeManager = new GitWorktreeManager(db);
100
+
101
+ // We need a source repository to create worktrees from
102
+ // Tier 0: Check known local path from repo_settings (registered by CLI usage)
103
+ let repositoryPath;
104
+ const repoSettingsRepo = new RepoSettingsRepository(db);
105
+ const worktreeRepo = new WorktreeRepository(db);
106
+ const knownPath = await repoSettingsRepo.getLocalPath(repository);
107
+
108
+ if (knownPath && await worktreeManager.pathExists(knownPath)) {
109
+ // Validate it's still a valid git repo
110
+ try {
111
+ const git = simpleGit(knownPath);
112
+ await git.revparse(['--is-inside-work-tree']);
113
+ repositoryPath = knownPath;
114
+ logger.info(`Using known repository location at ${repositoryPath}`);
115
+ } catch {
116
+ // Path exists but isn't a valid git repo anymore, clear it
117
+ logger.warn(`Known path ${knownPath} is no longer a valid git repo, clearing`);
118
+ await repoSettingsRepo.setLocalPath(repository, null);
119
+ }
120
+ }
121
+
122
+ // Tier 1: Check if we have an existing worktree for this repo
123
+ if (!repositoryPath) {
124
+ const existingWorktree = await worktreeRepo.findByPR(parsedPrNumber, repository);
125
+
126
+ if (existingWorktree && await worktreeManager.pathExists(existingWorktree.path)) {
127
+ // Use the existing worktree path to find the parent git repository
128
+ try {
129
+ const git = simpleGit(existingWorktree.path);
130
+ repositoryPath = await git.revparse(['--show-toplevel']);
131
+ repositoryPath = repositoryPath.trim();
132
+ logger.info(`Using repository from existing worktree at ${repositoryPath}`);
133
+ } catch {
134
+ // If we can't get the git root, we'll need to clone
135
+ repositoryPath = null;
136
+ }
137
+ }
138
+ }
139
+
140
+ // Tier 2 & 3: Check cached clone or clone fresh
141
+ if (!repositoryPath) {
142
+ // Check if there's a cached clone for this repository
143
+ const { getConfigDir } = require('../config');
144
+ const cachedRepoPath = path.join(getConfigDir(), 'repos', owner, repo);
145
+
146
+ if (await worktreeManager.pathExists(cachedRepoPath)) {
147
+ repositoryPath = cachedRepoPath;
148
+ logger.info(`Using cached repository at ${repositoryPath}`);
149
+ } else {
150
+ // Clone the repository
151
+ logger.info(`Cloning repository ${repository}...`);
152
+ await fs.mkdir(path.dirname(cachedRepoPath), { recursive: true });
153
+
154
+ const git = simpleGit();
155
+
156
+ // Clone with minimal depth for efficiency
157
+ const cloneUrl = `https://github.com/${owner}/${repo}.git`;
158
+ try {
159
+ await git.clone(cloneUrl, cachedRepoPath, ['--filter=blob:none', '--no-checkout']);
160
+ repositoryPath = cachedRepoPath;
161
+ logger.info(`Cloned repository to ${repositoryPath}`);
162
+ } catch (cloneError) {
163
+ return res.status(500).json({
164
+ success: false,
165
+ error: `Failed to clone repository: ${cloneError.message}`
166
+ });
167
+ }
168
+ }
169
+ }
170
+
171
+ // Setup git worktree
172
+ logger.info('Setting up git worktree...');
173
+ const prInfo = { owner, repo, number: parsedPrNumber };
174
+ const worktreePath = await worktreeManager.createWorktreeForPR(prInfo, prData, repositoryPath);
175
+
176
+ // Generate unified diff
177
+ logger.info('Generating unified diff...');
178
+ const diff = await worktreeManager.generateUnifiedDiff(worktreePath, prData);
179
+ const changedFiles = await worktreeManager.getChangedFiles(worktreePath, prData);
180
+
181
+ // Store PR data in database (similar to storePRData in main.js)
182
+ logger.info('Storing pull request data...');
183
+ await run(db, 'BEGIN TRANSACTION');
184
+
185
+ try {
186
+ // Store or update worktree record
187
+ await worktreeRepo.getOrCreate({
188
+ prNumber: parsedPrNumber,
189
+ repository,
190
+ branch: prData.head_branch,
191
+ path: worktreePath
192
+ });
193
+
194
+ // Prepare extended PR data
195
+ const extendedPRData = {
196
+ ...prData,
197
+ diff: diff,
198
+ changed_files: changedFiles,
199
+ worktree_path: worktreePath,
200
+ fetched_at: new Date().toISOString()
201
+ };
202
+
203
+ // Check if PR metadata exists
204
+ const existingPR = await queryOne(db, `
205
+ SELECT id FROM pr_metadata WHERE pr_number = ? AND repository = ? COLLATE NOCASE
206
+ `, [parsedPrNumber, repository]);
207
+
208
+ if (existingPR) {
209
+ // Update existing PR metadata
210
+ await run(db, `
211
+ UPDATE pr_metadata
212
+ SET title = ?, description = ?, author = ?,
213
+ base_branch = ?, head_branch = ?, pr_data = ?,
214
+ updated_at = CURRENT_TIMESTAMP
215
+ WHERE id = ?
216
+ `, [
217
+ prData.title,
218
+ prData.body,
219
+ prData.author,
220
+ prData.base_branch,
221
+ prData.head_branch,
222
+ JSON.stringify(extendedPRData),
223
+ existingPR.id
224
+ ]);
225
+ logger.info(`Updated existing PR metadata (ID: ${existingPR.id})`);
226
+ } else {
227
+ // Insert new PR metadata
228
+ const result = await run(db, `
229
+ INSERT INTO pr_metadata
230
+ (pr_number, repository, title, description, author, base_branch, head_branch, pr_data)
231
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
232
+ `, [
233
+ parsedPrNumber,
234
+ repository,
235
+ prData.title,
236
+ prData.body,
237
+ prData.author,
238
+ prData.base_branch,
239
+ prData.head_branch,
240
+ JSON.stringify(extendedPRData)
241
+ ]);
242
+ logger.info(`Created new PR metadata (ID: ${result.lastID})`);
243
+ }
244
+
245
+ // Create or update review record
246
+ const existingReview = await queryOne(db, `
247
+ SELECT id FROM reviews WHERE pr_number = ? AND repository = ? COLLATE NOCASE
248
+ `, [parsedPrNumber, repository]);
249
+
250
+ if (existingReview) {
251
+ await run(db, `
252
+ UPDATE reviews
253
+ SET review_data = ?, updated_at = CURRENT_TIMESTAMP
254
+ WHERE id = ?
255
+ `, [
256
+ JSON.stringify({
257
+ worktree_path: worktreePath,
258
+ created_at: new Date().toISOString()
259
+ }),
260
+ existingReview.id
261
+ ]);
262
+ } else {
263
+ await run(db, `
264
+ INSERT INTO reviews
265
+ (pr_number, repository, status, review_data)
266
+ VALUES (?, ?, 'draft', ?)
267
+ `, [
268
+ parsedPrNumber,
269
+ repository,
270
+ JSON.stringify({
271
+ worktree_path: worktreePath,
272
+ created_at: new Date().toISOString()
273
+ })
274
+ ]);
275
+ }
276
+
277
+ await run(db, 'COMMIT');
278
+ logger.success(`Stored PR data for ${repository} #${parsedPrNumber}`);
279
+
280
+ // Register the repository path for future use if it wasn't already known
281
+ // This ensures web UI discoveries also benefit future sessions
282
+ // Skip registration if: (1) knownPath was used (path === knownPath), or
283
+ // (2) we have a knownPath but it failed validation (already cleared above)
284
+ // Only register when we discovered a genuinely new path
285
+ if (repositoryPath && knownPath === null) {
286
+ // Only register if this path isn't already stored (avoid redundant writes)
287
+ const currentPath = await repoSettingsRepo.getLocalPath(repository);
288
+ if (path.resolve(currentPath || '') !== path.resolve(repositoryPath)) {
289
+ await repoSettingsRepo.setLocalPath(repository, repositoryPath);
290
+ logger.info(`Registered repository location: ${repositoryPath}`);
291
+ }
292
+ }
293
+
294
+ } catch (dbError) {
295
+ await run(db, 'ROLLBACK');
296
+ throw new Error(`Failed to store PR data: ${dbError.message}`);
297
+ }
298
+
299
+ // Return success with review URL
300
+ const reviewUrl = `/pr/${owner}/${repo}/${parsedPrNumber}`;
301
+
302
+ logger.success(`Review ready at ${reviewUrl}`);
303
+
304
+ res.json({
305
+ success: true,
306
+ reviewUrl,
307
+ prNumber: parsedPrNumber,
308
+ repository,
309
+ title: prData.title
310
+ });
311
+
312
+ } catch (error) {
313
+ logger.error('Error creating worktree from web UI:', error);
314
+
315
+ // Provide user-friendly error messages
316
+ if (error.message && error.message.includes('authentication failed')) {
317
+ return res.status(401).json({
318
+ success: false,
319
+ error: 'GitHub authentication failed. Please check your token.'
320
+ });
321
+ } else if (error.message && error.message.includes('rate limit')) {
322
+ return res.status(429).json({
323
+ success: false,
324
+ error: 'GitHub API rate limit exceeded. Please try again later.'
325
+ });
326
+ } else if (error.message && error.message.includes('Network error')) {
327
+ return res.status(503).json({
328
+ success: false,
329
+ error: 'Network error. Please check your internet connection.'
330
+ });
331
+ }
332
+
333
+ res.status(500).json({
334
+ success: false,
335
+ error: error.message || 'Failed to create worktree'
336
+ });
337
+ }
338
+ });
339
+
340
+ /**
341
+ * Get recently accessed worktrees
342
+ * Returns list of recently reviewed PRs with metadata
343
+ * Filters out stale worktrees where the directory no longer exists
344
+ */
345
+ router.get('/api/worktrees/recent', async (req, res) => {
346
+ try {
347
+ const limit = Math.min(parseInt(req.query.limit) || 10, 50); // Default 10, max 50
348
+ const db = req.app.get('db');
349
+
350
+ // Get more worktrees than requested to account for stale ones we'll filter out
351
+ const enrichedWorktrees = await query(db, `
352
+ SELECT
353
+ w.id,
354
+ w.repository,
355
+ w.pr_number,
356
+ w.branch,
357
+ w.path,
358
+ w.last_accessed_at,
359
+ w.created_at,
360
+ pm.title as pr_title,
361
+ pm.author,
362
+ pm.head_branch
363
+ FROM worktrees w
364
+ LEFT JOIN pr_metadata pm ON w.pr_number = pm.pr_number AND w.repository = pm.repository COLLATE NOCASE
365
+ ORDER BY w.last_accessed_at DESC
366
+ LIMIT ?
367
+ `, [limit * 2]); // Fetch extra to account for stale entries
368
+
369
+ // Filter out worktrees where:
370
+ // 1. The directory no longer exists
371
+ // 2. The data is incomplete/corrupted (no author, unknown branch)
372
+ const staleIds = [];
373
+ const validWorktrees = [];
374
+
375
+ for (const w of enrichedWorktrees) {
376
+ // Check for corrupted/incomplete data
377
+ if (w.branch === 'unknown' || !w.pr_title || w.pr_title === `PR #${w.pr_number}`) {
378
+ staleIds.push(w.id);
379
+ continue;
380
+ }
381
+
382
+ // Check if path still exists
383
+ try {
384
+ await fs.access(w.path);
385
+ validWorktrees.push(w);
386
+ } catch {
387
+ // Path doesn't exist - mark for cleanup
388
+ staleIds.push(w.id);
389
+ }
390
+ }
391
+
392
+ // Cleanup stale worktree records in background (don't block response)
393
+ if (staleIds.length > 0) {
394
+ setImmediate(async () => {
395
+ try {
396
+ const placeholders = staleIds.map(() => '?').join(',');
397
+ await run(db, `DELETE FROM worktrees WHERE id IN (${placeholders})`, staleIds);
398
+ logger.info(`Cleaned up ${staleIds.length} stale worktree records`);
399
+ } catch (err) {
400
+ logger.warn(`Failed to cleanup stale worktrees: ${err.message}`);
401
+ }
402
+ });
403
+ }
404
+
405
+ // Format the results with fallback values, limited to requested count
406
+ const formattedWorktrees = validWorktrees.slice(0, limit).map(w => ({
407
+ id: w.id,
408
+ repository: w.repository,
409
+ pr_number: w.pr_number,
410
+ pr_title: w.pr_title || `PR #${w.pr_number}`,
411
+ author: w.author || null,
412
+ branch: w.branch,
413
+ head_branch: w.head_branch || w.branch,
414
+ last_accessed_at: w.last_accessed_at,
415
+ created_at: w.created_at
416
+ }));
417
+
418
+ res.json({
419
+ success: true,
420
+ worktrees: formattedWorktrees
421
+ });
422
+
423
+ } catch (error) {
424
+ console.error('Error fetching recent worktrees:', error);
425
+ res.status(500).json({
426
+ error: 'Failed to fetch recent worktrees'
427
+ });
428
+ }
429
+ });
430
+
431
+ /**
432
+ * Delete a worktree
433
+ * Removes the worktree record from the database and optionally deletes the directory
434
+ */
435
+ router.delete('/api/worktrees/:id', async (req, res) => {
436
+ try {
437
+ const worktreeId = req.params.id;
438
+
439
+ if (!worktreeId) {
440
+ return res.status(400).json({
441
+ success: false,
442
+ error: 'Invalid worktree ID'
443
+ });
444
+ }
445
+
446
+ const db = req.app.get('db');
447
+ const worktreeRepo = new WorktreeRepository(db);
448
+
449
+ // Get worktree info before deletion
450
+ const worktree = await queryOne(db, `
451
+ SELECT id, path, pr_number, repository FROM worktrees WHERE id = ?
452
+ `, [worktreeId]);
453
+
454
+ if (!worktree) {
455
+ return res.status(404).json({
456
+ success: false,
457
+ error: 'Worktree not found'
458
+ });
459
+ }
460
+
461
+ logger.info(`Deleting worktree ID ${worktreeId} for ${worktree.repository} #${worktree.pr_number}`);
462
+
463
+ // Delete the worktree directory if it exists
464
+ if (worktree.path) {
465
+ try {
466
+ await fs.access(worktree.path);
467
+ // Directory exists, try to remove it
468
+ await fs.rm(worktree.path, { recursive: true, force: true });
469
+ logger.info(`Deleted worktree directory: ${worktree.path}`);
470
+ } catch (pathError) {
471
+ // Directory doesn't exist or can't be accessed - that's okay
472
+ logger.warn(`Could not delete worktree directory (may not exist): ${worktree.path}`);
473
+ }
474
+ }
475
+
476
+ // Delete the worktree record from the database
477
+ await run(db, `DELETE FROM worktrees WHERE id = ?`, [worktreeId]);
478
+
479
+ // Also delete associated PR metadata and comments (optional cleanup)
480
+ // Keep PR metadata for now as user might want to reload the PR later
481
+ // await run(db, `DELETE FROM pr_metadata WHERE pr_number = ? AND repository = ?`,
482
+ // [worktree.pr_number, worktree.repository]);
483
+
484
+ logger.success(`Deleted worktree ID ${worktreeId}`);
485
+
486
+ res.json({
487
+ success: true,
488
+ message: `Worktree for ${worktree.repository} #${worktree.pr_number} deleted`
489
+ });
490
+
491
+ } catch (error) {
492
+ logger.error('Error deleting worktree:', error);
493
+ res.status(500).json({
494
+ success: false,
495
+ error: 'Failed to delete worktree: ' + error.message
496
+ });
497
+ }
498
+ });
499
+
500
+ module.exports = router;