@in-the-loop-labs/pair-review 1.6.2 → 2.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 (62) hide show
  1. package/README.md +77 -4
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin/skills/review-requests/SKILL.md +4 -1
  5. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  6. package/plugin-code-critic/skills/analyze/SKILL.md +4 -3
  7. package/public/css/pr.css +1930 -114
  8. package/public/js/CONVENTIONS.md +16 -0
  9. package/public/js/components/AIPanel.js +66 -0
  10. package/public/js/components/AnalysisConfigModal.js +2 -2
  11. package/public/js/components/ChatPanel.js +2952 -0
  12. package/public/js/components/CouncilProgressModal.js +12 -16
  13. package/public/js/components/KeyboardShortcuts.js +3 -0
  14. package/public/js/components/PanelGroup.js +723 -0
  15. package/public/js/components/PreviewModal.js +3 -8
  16. package/public/js/index.js +8 -0
  17. package/public/js/local.js +17 -615
  18. package/public/js/modules/analysis-history.js +19 -68
  19. package/public/js/modules/comment-manager.js +57 -19
  20. package/public/js/modules/diff-context.js +176 -0
  21. package/public/js/modules/diff-renderer.js +30 -0
  22. package/public/js/modules/file-comment-manager.js +126 -105
  23. package/public/js/modules/file-list-merger.js +64 -0
  24. package/public/js/modules/panel-resizer.js +25 -6
  25. package/public/js/modules/suggestion-manager.js +40 -125
  26. package/public/js/pr.js +964 -159
  27. package/public/js/repo-settings.js +36 -6
  28. package/public/js/utils/category-emoji.js +44 -0
  29. package/public/js/utils/time.js +32 -0
  30. package/public/local.html +107 -70
  31. package/public/pr.html +107 -70
  32. package/public/repo-settings.html +32 -0
  33. package/src/ai/analyzer.js +5 -1
  34. package/src/ai/copilot-provider.js +39 -9
  35. package/src/ai/cursor-agent-provider.js +36 -7
  36. package/src/ai/gemini-provider.js +17 -4
  37. package/src/ai/prompts/config.js +7 -1
  38. package/src/ai/provider-availability.js +1 -1
  39. package/src/ai/provider.js +25 -37
  40. package/src/chat/CONVENTIONS.md +18 -0
  41. package/src/chat/pi-bridge.js +491 -0
  42. package/src/chat/prompt-builder.js +262 -0
  43. package/src/chat/session-manager.js +619 -0
  44. package/src/config.js +14 -0
  45. package/src/database.js +322 -15
  46. package/src/main.js +4 -17
  47. package/src/routes/analyses.js +721 -0
  48. package/src/routes/chat.js +655 -0
  49. package/src/routes/config.js +29 -8
  50. package/src/routes/context-files.js +223 -0
  51. package/src/routes/local.js +225 -1133
  52. package/src/routes/mcp.js +39 -30
  53. package/src/routes/pr.js +410 -52
  54. package/src/routes/reviews.js +1035 -0
  55. package/src/routes/shared.js +4 -29
  56. package/src/server.js +34 -12
  57. package/src/sse/review-events.js +46 -0
  58. package/src/utils/auto-context.js +88 -0
  59. package/src/utils/category-emoji.js +33 -0
  60. package/src/utils/diff-file-list.js +57 -0
  61. package/src/routes/analysis.js +0 -1600
  62. package/src/routes/comments.js +0 -534
package/src/routes/pr.js CHANGED
@@ -14,15 +14,31 @@
14
14
  */
15
15
 
16
16
  const express = require('express');
17
- const { query, queryOne, run, WorktreeRepository, ReviewRepository, GitHubReviewRepository } = require('../database');
17
+ const { query, queryOne, run, withTransaction, WorktreeRepository, ReviewRepository, GitHubReviewRepository, RepoSettingsRepository, AnalysisRunRepository, PRMetadataRepository, CouncilRepository } = require('../database');
18
18
  const { GitWorktreeManager } = require('../git/worktree');
19
19
  const { GitHubClient } = require('../github/client');
20
20
  const { getGeneratedFilePatterns } = require('../git/gitattributes');
21
21
  const { normalizeRepository } = require('../utils/paths');
22
+ const { mergeInstructions } = require('../utils/instructions');
23
+ const Analyzer = require('../ai/analyzer');
24
+ const { v4: uuidv4 } = require('uuid');
22
25
  const fs = require('fs').promises;
23
26
  const path = require('path');
24
27
  const logger = require('../utils/logger');
28
+ const { broadcastReviewEvent } = require('../sse/review-events');
25
29
  const simpleGit = require('simple-git');
30
+ const {
31
+ activeAnalyses,
32
+ reviewToAnalysisId,
33
+ getModel,
34
+ determineCompletionInfo,
35
+ broadcastProgress,
36
+ createProgressCallback,
37
+ parseEnabledLevels
38
+ } = require('./shared');
39
+ const { validateCouncilConfig, normalizeCouncilConfig } = require('./councils');
40
+ const { TIERS, TIER_ALIASES, VALID_TIERS, resolveTier } = require('../ai/prompts/config');
41
+ const analysesRouter = require('./analyses');
26
42
 
27
43
  const router = express.Router();
28
44
 
@@ -674,57 +690,6 @@ router.get('/api/pr/:owner/:repo/:number/diff', async (req, res) => {
674
690
  }
675
691
  });
676
692
 
677
- /**
678
- * Get PR comments
679
- */
680
- router.get('/api/pr/:owner/:repo/:number/comments', async (req, res) => {
681
- try {
682
- const { owner, repo, number } = req.params;
683
- const prNumber = parseInt(number);
684
-
685
- if (isNaN(prNumber) || prNumber <= 0) {
686
- return res.status(400).json({
687
- error: 'Invalid pull request number'
688
- });
689
- }
690
-
691
- const repository = normalizeRepository(owner, repo);
692
-
693
- // Get review ID first
694
- const review = await queryOne(req.app.get('db'), `
695
- SELECT id FROM reviews
696
- WHERE pr_number = ? AND repository = ? COLLATE NOCASE
697
- `, [prNumber, repository]);
698
-
699
- if (!review) {
700
- return res.json({ comments: [] });
701
- }
702
-
703
- // Get comments for this review
704
- const comments = await query(req.app.get('db'), `
705
- SELECT
706
- id,
707
- file_path,
708
- line_number,
709
- comment_text,
710
- comment_type,
711
- status,
712
- created_at
713
- FROM comments
714
- WHERE review_id = ?
715
- ORDER BY file_path, line_number, created_at
716
- `, [review.id]);
717
-
718
- res.json({ comments });
719
-
720
- } catch (error) {
721
- console.error('Error fetching PR comments:', error);
722
- res.status(500).json({
723
- error: 'Internal server error while fetching comments'
724
- });
725
- }
726
- });
727
-
728
693
  /**
729
694
  * Get original file content from worktree for context expansion
730
695
  */
@@ -1389,4 +1354,397 @@ router.post('/api/parse-pr-url', (req, res) => {
1389
1354
  });
1390
1355
  });
1391
1356
 
1357
+ // ==========================================================================
1358
+ // PR Analysis Routes
1359
+ // ==========================================================================
1360
+
1361
+ /**
1362
+ * Trigger AI analysis for a PR
1363
+ */
1364
+ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
1365
+ try {
1366
+ const { owner, repo, number } = req.params;
1367
+ const prNumber = parseInt(number);
1368
+
1369
+ const { provider: requestProvider, model: requestModel, tier: requestTier, customInstructions: rawInstructions, skipLevel3: requestSkipLevel3, enabledLevels: requestEnabledLevels } = req.body || {};
1370
+
1371
+ const MAX_INSTRUCTIONS_LENGTH = 5000;
1372
+ let requestInstructions = rawInstructions?.trim() || null;
1373
+ if (requestInstructions && requestInstructions.length > MAX_INSTRUCTIONS_LENGTH) {
1374
+ return res.status(400).json({
1375
+ error: `Custom instructions exceed maximum length of ${MAX_INSTRUCTIONS_LENGTH} characters`
1376
+ });
1377
+ }
1378
+
1379
+ if (requestTier && !VALID_TIERS.includes(requestTier)) {
1380
+ return res.status(400).json({
1381
+ error: `Invalid tier: "${requestTier}". Valid tiers: ${VALID_TIERS.join(', ')}`
1382
+ });
1383
+ }
1384
+
1385
+ if (isNaN(prNumber) || prNumber <= 0) {
1386
+ return res.status(400).json({
1387
+ error: 'Invalid pull request number'
1388
+ });
1389
+ }
1390
+
1391
+ const repository = normalizeRepository(owner, repo);
1392
+
1393
+ const db = req.app.get('db');
1394
+ const reviewRepo = new ReviewRepository(db);
1395
+ const prMetadataRepo = new PRMetadataRepository(db);
1396
+ const prMetadata = await prMetadataRepo.getByPR(prNumber, repository);
1397
+
1398
+ if (!prMetadata) {
1399
+ return res.status(404).json({
1400
+ error: `Pull request #${prNumber} not found. Please load the PR first.`
1401
+ });
1402
+ }
1403
+
1404
+ const worktreeManager = new GitWorktreeManager(db);
1405
+ const worktreePath = await worktreeManager.getWorktreePath({ owner, repo, number: prNumber });
1406
+
1407
+ if (!await worktreeManager.worktreeExists({ owner, repo, number: prNumber })) {
1408
+ return res.status(404).json({
1409
+ error: 'Worktree not found for this PR. Please reload the PR.'
1410
+ });
1411
+ }
1412
+
1413
+ const { provider, model, repoInstructions, combinedInstructions } = await withTransaction(db, async () => {
1414
+ const repoSettingsRepo = new RepoSettingsRepository(db);
1415
+ const fetchedRepoSettings = await repoSettingsRepo.getRepoSettings(repository);
1416
+
1417
+ let selectedProvider;
1418
+ if (requestProvider) {
1419
+ selectedProvider = requestProvider;
1420
+ } else if (fetchedRepoSettings && fetchedRepoSettings.default_provider) {
1421
+ selectedProvider = fetchedRepoSettings.default_provider;
1422
+ } else {
1423
+ const config = req.app.get('config') || {};
1424
+ selectedProvider = config.default_provider || config.provider || 'claude';
1425
+ }
1426
+
1427
+ let selectedModel;
1428
+ if (requestModel) {
1429
+ selectedModel = requestModel;
1430
+ } else if (fetchedRepoSettings && fetchedRepoSettings.default_model) {
1431
+ selectedModel = fetchedRepoSettings.default_model;
1432
+ } else {
1433
+ selectedModel = getModel(req);
1434
+ }
1435
+
1436
+ const fetchedRepoInstructions = fetchedRepoSettings?.default_instructions || null;
1437
+ const mergedInstructions = mergeInstructions(fetchedRepoInstructions, requestInstructions);
1438
+
1439
+ if (requestInstructions) {
1440
+ await reviewRepo.upsertCustomInstructions(prNumber, repository, requestInstructions);
1441
+ }
1442
+
1443
+ return {
1444
+ provider: selectedProvider,
1445
+ model: selectedModel,
1446
+ repoInstructions: fetchedRepoInstructions,
1447
+ combinedInstructions: mergedInstructions
1448
+ };
1449
+ });
1450
+
1451
+ const runId = uuidv4();
1452
+ const analysisId = runId;
1453
+
1454
+ const review = await reviewRepo.getOrCreate({ prNumber, repository });
1455
+
1456
+ const analysisRunRepo = new AnalysisRunRepository(db);
1457
+ const levelsConfig = parseEnabledLevels(requestEnabledLevels, requestSkipLevel3);
1458
+ const tier = requestTier ? resolveTier(requestTier) : 'balanced';
1459
+ await analysisRunRepo.create({
1460
+ id: runId,
1461
+ reviewId: review.id,
1462
+ provider,
1463
+ model,
1464
+ tier,
1465
+ repoInstructions,
1466
+ requestInstructions,
1467
+ headSha: prMetadata.head_sha || null,
1468
+ configType: 'single',
1469
+ levelsConfig
1470
+ });
1471
+
1472
+ const initialStatus = {
1473
+ id: analysisId,
1474
+ runId,
1475
+ reviewId: review.id,
1476
+ prNumber,
1477
+ repository,
1478
+ reviewType: 'pr',
1479
+ status: 'running',
1480
+ startedAt: new Date().toISOString(),
1481
+ progress: 'Starting analysis...',
1482
+ levels: {
1483
+ 1: levelsConfig[1] ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
1484
+ 2: levelsConfig[2] ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
1485
+ 3: levelsConfig[3] ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
1486
+ 4: { status: 'pending', progress: 'Pending' }
1487
+ },
1488
+ filesAnalyzed: 0,
1489
+ filesRemaining: 0
1490
+ };
1491
+ activeAnalyses.set(analysisId, initialStatus);
1492
+
1493
+ // Store review to analysis ID mapping (unified map using integer reviewId)
1494
+ reviewToAnalysisId.set(review.id, analysisId);
1495
+
1496
+ broadcastProgress(analysisId, initialStatus);
1497
+ broadcastReviewEvent(review.id, { type: 'review:analysis_started', analysisId });
1498
+
1499
+ const analyzer = new Analyzer(req.app.get('db'), model, provider);
1500
+
1501
+ logger.section(`AI Analysis Request - PR #${prNumber}`);
1502
+ logger.log('API', `Repository: ${repository}`, 'magenta');
1503
+ logger.log('API', `Worktree: ${worktreePath}`, 'magenta');
1504
+ logger.log('API', `Analysis ID: ${analysisId}`, 'magenta');
1505
+ logger.log('API', `Review ID: ${review.id}`, 'magenta');
1506
+ logger.log('API', `Provider: ${provider}`, 'cyan');
1507
+ logger.log('API', `Model: ${model}`, 'cyan');
1508
+ logger.log('API', `Tier: ${tier}`, 'cyan');
1509
+ if (combinedInstructions) {
1510
+ logger.log('API', `Custom instructions: ${combinedInstructions.length} chars`, 'cyan');
1511
+ }
1512
+
1513
+ const progressCallback = createProgressCallback(analysisId);
1514
+
1515
+ analyzer.analyzeLevel1(review.id, worktreePath, prMetadata, progressCallback, { repoInstructions, requestInstructions }, null, { analysisId, runId, skipRunCreation: true, tier, skipLevel3: requestSkipLevel3, enabledLevels: levelsConfig })
1516
+ .then(async result => {
1517
+ logger.section('Analysis Results');
1518
+ logger.success(`Analysis complete for PR #${prNumber}`);
1519
+ logger.success(`Found ${result.suggestions.length} suggestions:`);
1520
+
1521
+ try {
1522
+ await prMetadataRepo.updateLastAiRunId(prMetadata.id, result.runId);
1523
+ logger.info(`Updated pr_metadata with last_ai_run_id: ${result.runId}`);
1524
+ } catch (updateError) {
1525
+ logger.warn(`Failed to update pr_metadata with last_ai_run_id: ${updateError.message}`);
1526
+ }
1527
+
1528
+ if (result.summary) {
1529
+ try {
1530
+ await reviewRepo.upsertSummary(prNumber, repository, result.summary);
1531
+ logger.info(`Saved analysis summary to review record`);
1532
+ logger.section('Analysis Summary');
1533
+ logger.info(result.summary);
1534
+ } catch (summaryError) {
1535
+ logger.warn(`Failed to save analysis summary: ${summaryError.message}`);
1536
+ }
1537
+ }
1538
+ result.suggestions.forEach(s => {
1539
+ const icon = s.type === 'bug' ? '\uD83D\uDC1B' :
1540
+ s.type === 'praise' ? '\u2B50' :
1541
+ s.type === 'improvement' ? '\uD83D\uDCA1' :
1542
+ s.type === 'security' ? '\uD83D\uDD12' :
1543
+ s.type === 'performance' ? '\u26A1' :
1544
+ s.type === 'design' ? '\uD83D\uDCD0' :
1545
+ s.type === 'suggestion' ? '\uD83D\uDCAC' :
1546
+ s.type === 'code-style' || s.type === 'style' ? '\uD83E\uDDF9' : '\uD83D\uDCDD';
1547
+ logger.log('Result', `${icon} ${s.type}: ${s.title} (${s.file}:${s.line_start})`, 'green');
1548
+ });
1549
+
1550
+ const completionInfo = determineCompletionInfo(result);
1551
+
1552
+ const currentStatus = activeAnalyses.get(analysisId);
1553
+ if (!currentStatus) {
1554
+ logger.warn('Analysis already completed or removed:', analysisId);
1555
+ return;
1556
+ }
1557
+
1558
+ if (currentStatus.status === 'cancelled') {
1559
+ logger.info(`Analysis ${analysisId} was cancelled, skipping completion update`);
1560
+ return;
1561
+ }
1562
+
1563
+ for (let i = 1; i <= completionInfo.completedLevel; i++) {
1564
+ currentStatus.levels[i] = {
1565
+ status: 'completed',
1566
+ progress: `Level ${i} complete`
1567
+ };
1568
+ }
1569
+
1570
+ currentStatus.levels[4] = {
1571
+ status: 'completed',
1572
+ progress: 'Results finalized'
1573
+ };
1574
+
1575
+ const completedStatus = {
1576
+ ...currentStatus,
1577
+ status: 'completed',
1578
+ level: completionInfo.completedLevel,
1579
+ completedLevel: completionInfo.completedLevel,
1580
+ completedAt: new Date().toISOString(),
1581
+ result,
1582
+ progress: completionInfo.progressMessage,
1583
+ suggestionsCount: completionInfo.totalSuggestions,
1584
+ filesAnalyzed: currentStatus?.filesAnalyzed || 0,
1585
+ filesRemaining: 0,
1586
+ currentFile: currentStatus?.totalFiles || 0,
1587
+ totalFiles: currentStatus?.totalFiles || 0
1588
+ };
1589
+ activeAnalyses.set(analysisId, completedStatus);
1590
+
1591
+ broadcastProgress(analysisId, completedStatus);
1592
+ broadcastReviewEvent(review.id, { type: 'review:analysis_completed' });
1593
+ })
1594
+ .catch(error => {
1595
+ const currentStatus = activeAnalyses.get(analysisId);
1596
+ if (!currentStatus) {
1597
+ logger.warn('Analysis status not found during error handling:', analysisId);
1598
+ return;
1599
+ }
1600
+
1601
+ if (error.isCancellation) {
1602
+ logger.info(`Analysis cancelled for PR #${prNumber}`);
1603
+ return;
1604
+ }
1605
+
1606
+ logger.error(`Analysis failed for PR #${prNumber}: ${error.message}`);
1607
+
1608
+ for (let i = 1; i <= 4; i++) {
1609
+ currentStatus.levels[i] = {
1610
+ status: 'failed',
1611
+ progress: 'Failed'
1612
+ };
1613
+ }
1614
+
1615
+ const failedStatus = {
1616
+ ...currentStatus,
1617
+ status: 'failed',
1618
+ level: 1,
1619
+ completedAt: new Date().toISOString(),
1620
+ error: error.message,
1621
+ progress: 'Analysis failed'
1622
+ };
1623
+ activeAnalyses.set(analysisId, failedStatus);
1624
+
1625
+ broadcastProgress(analysisId, failedStatus);
1626
+ })
1627
+ .finally(() => {
1628
+ // Clean up review to analysis ID mapping (unified map)
1629
+ reviewToAnalysisId.delete(review.id);
1630
+ });
1631
+
1632
+ res.json({
1633
+ analysisId,
1634
+ runId,
1635
+ status: 'started',
1636
+ message: 'AI analysis started in background'
1637
+ });
1638
+
1639
+ } catch (error) {
1640
+ logger.error('Error starting AI analysis:', error);
1641
+ res.status(500).json({
1642
+ error: 'Failed to start AI analysis'
1643
+ });
1644
+ }
1645
+ });
1646
+
1647
+ /**
1648
+ * Trigger council analysis for a PR
1649
+ */
1650
+ router.post('/api/pr/:owner/:repo/:number/analyses/council', async (req, res) => {
1651
+ try {
1652
+ const { owner, repo, number } = req.params;
1653
+ const prNumber = parseInt(number);
1654
+ const { councilId, councilConfig: inlineConfig, customInstructions: rawInstructions, configType: requestConfigType } = req.body || {};
1655
+
1656
+ if (isNaN(prNumber) || prNumber <= 0) {
1657
+ return res.status(400).json({ error: 'Invalid pull request number' });
1658
+ }
1659
+
1660
+ if (!councilId && !inlineConfig) {
1661
+ return res.status(400).json({ error: 'Either councilId or councilConfig is required' });
1662
+ }
1663
+
1664
+ const repository = normalizeRepository(owner, repo);
1665
+ const db = req.app.get('db');
1666
+
1667
+ let councilConfig;
1668
+ let configType;
1669
+ if (councilId) {
1670
+ const councilRepo = new CouncilRepository(db);
1671
+ const council = await councilRepo.getById(councilId);
1672
+ if (!council) {
1673
+ return res.status(404).json({ error: 'Council not found' });
1674
+ }
1675
+ councilConfig = council.config;
1676
+ configType = requestConfigType || council.type || 'advanced';
1677
+ } else {
1678
+ councilConfig = inlineConfig;
1679
+ configType = requestConfigType || 'advanced';
1680
+ }
1681
+
1682
+ councilConfig = normalizeCouncilConfig(councilConfig, configType);
1683
+
1684
+ const configError = validateCouncilConfig(councilConfig, configType);
1685
+ if (configError) {
1686
+ return res.status(400).json({ error: `Invalid council config: ${configError}` });
1687
+ }
1688
+
1689
+ const prMetadataRepo = new PRMetadataRepository(db);
1690
+ const prMetadata = await prMetadataRepo.getByPR(prNumber, repository);
1691
+ if (!prMetadata) {
1692
+ return res.status(404).json({ error: `Pull request #${prNumber} not found. Please load the PR first.` });
1693
+ }
1694
+
1695
+ const worktreeManager = new GitWorktreeManager(db);
1696
+ const worktreePath = await worktreeManager.getWorktreePath({ owner, repo, number: prNumber });
1697
+ if (!await worktreeManager.worktreeExists({ owner, repo, number: prNumber })) {
1698
+ return res.status(404).json({ error: 'Worktree not found for this PR. Please reload the PR.' });
1699
+ }
1700
+
1701
+ const reviewRepo = new ReviewRepository(db);
1702
+ const repoSettingsRepo = new RepoSettingsRepository(db);
1703
+ const repoSettings = await repoSettingsRepo.getRepoSettings(repository);
1704
+ const repoInstructions = repoSettings?.default_instructions || null;
1705
+ const requestInstructions = rawInstructions?.trim() || null;
1706
+
1707
+ const review = await reviewRepo.getOrCreate({ prNumber, repository });
1708
+
1709
+ if (requestInstructions) {
1710
+ await reviewRepo.upsertCustomInstructions(prNumber, repository, requestInstructions);
1711
+ }
1712
+
1713
+ const { analysisId, runId } = await analysesRouter.launchCouncilAnalysis(
1714
+ db,
1715
+ {
1716
+ reviewId: review.id,
1717
+ worktreePath,
1718
+ prMetadata,
1719
+ changedFiles: null,
1720
+ repository,
1721
+ headSha: prMetadata.head_sha,
1722
+ logLabel: `PR #${prNumber}`,
1723
+ initialStatusExtra: { prNumber, reviewType: 'pr' },
1724
+ extraBroadcastKeys: null,
1725
+ onSuccess: async (result) => {
1726
+ if (result.summary) {
1727
+ await reviewRepo.upsertSummary(prNumber, repository, result.summary);
1728
+ }
1729
+ }
1730
+ },
1731
+ councilConfig,
1732
+ councilId,
1733
+ { repoInstructions, requestInstructions },
1734
+ configType
1735
+ );
1736
+
1737
+ res.json({
1738
+ analysisId,
1739
+ runId,
1740
+ status: 'started',
1741
+ message: 'Council analysis started in background',
1742
+ isCouncil: true
1743
+ });
1744
+ } catch (error) {
1745
+ logger.error('Error starting council analysis:', error);
1746
+ res.status(500).json({ error: 'Failed to start council analysis' });
1747
+ }
1748
+ });
1749
+
1392
1750
  module.exports = router;