@in-the-loop-labs/pair-review 3.5.2 → 3.7.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.
- package/README.md +4 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/analysis-config.css +1807 -0
- package/public/css/pr.css +1029 -2169
- package/public/index.html +11 -0
- package/public/js/components/AIPanel.js +39 -23
- package/public/js/components/AdvancedConfigTab.js +56 -4
- package/public/js/components/AnalysisConfigModal.js +41 -25
- package/public/js/components/ChatPanel.js +163 -3
- package/public/js/components/KeyboardShortcuts.js +10 -26
- package/public/js/components/ReviewModal.js +135 -13
- package/public/js/components/TourBar.js +248 -0
- package/public/js/components/VoiceCentricConfigTab.js +36 -0
- package/public/js/index.js +175 -16
- package/public/js/local.js +64 -8
- package/public/js/modules/cancel-background-job.js +183 -0
- package/public/js/modules/hunk-summary-renderer.js +116 -0
- package/public/js/modules/storage-cleanup.js +16 -0
- package/public/js/modules/suggestion-manager.js +25 -1
- package/public/js/modules/tour-renderer.js +755 -0
- package/public/js/pr.js +1826 -56
- package/public/js/repo-links.js +328 -0
- package/public/js/utils/modal-detection.js +77 -0
- package/public/js/utils/provider-model.js +88 -0
- package/public/js/utils/storage-keys.js +50 -0
- package/public/local.html +24 -0
- package/public/pr.html +24 -0
- package/public/repo-settings.html +1 -0
- package/public/setup.html +2 -0
- package/src/ai/abort-signal-wiring.js +130 -0
- package/src/ai/analyzer.js +125 -18
- package/src/ai/background-queue.js +290 -0
- package/src/ai/claude-cli.js +1 -1
- package/src/ai/claude-provider.js +50 -7
- package/src/ai/codex-provider.js +28 -5
- package/src/ai/copilot-provider.js +22 -3
- package/src/ai/cursor-agent-provider.js +22 -6
- package/src/ai/executable-provider.js +4 -19
- package/src/ai/gemini-provider.js +22 -5
- package/src/ai/hunk-hashing.js +161 -0
- package/src/ai/index.js +2 -0
- package/src/ai/opencode-provider.js +21 -5
- package/src/ai/pi-provider.js +21 -5
- package/src/ai/prompts/hunk-summary.js +199 -0
- package/src/ai/prompts/tour.js +232 -0
- package/src/ai/provider.js +21 -1
- package/src/ai/summary-generator.js +469 -0
- package/src/ai/tour-generator.js +568 -0
- package/src/config.js +778 -10
- package/src/database.js +282 -1
- package/src/external/github-adapter.js +114 -25
- package/src/git/base-branch.js +11 -4
- package/src/github/client.js +482 -588
- package/src/github/errors.js +55 -0
- package/src/github/impl/graphql/pending-review-comments.js +230 -0
- package/src/github/impl/graphql/pending-review.js +153 -0
- package/src/github/impl/graphql/review-lifecycle.js +161 -0
- package/src/github/impl/graphql/stack-walker.js +210 -0
- package/src/github/impl/host/pending-review-comments.js +338 -0
- package/src/github/impl/rest/pending-review.js +251 -0
- package/src/github/impl/rest/review-lifecycle.js +226 -0
- package/src/github/impl/rest/stack-walker.js +309 -0
- package/src/github/operations/pending-review-comments.js +79 -0
- package/src/github/operations/pending-review.js +89 -0
- package/src/github/operations/review-lifecycle.js +126 -0
- package/src/github/operations/stack-walker.js +87 -0
- package/src/github/parser.js +230 -4
- package/src/github/stack-walker.js +14 -189
- package/src/links/repo-links.js +230 -0
- package/src/local-review.js +201 -172
- package/src/main.js +133 -30
- package/src/routes/analyses.js +30 -7
- package/src/routes/bulk-analysis-configs.js +295 -0
- package/src/routes/config.js +118 -3
- package/src/routes/context-files.js +2 -29
- package/src/routes/external-comments.js +20 -10
- package/src/routes/github-collections.js +3 -1
- package/src/routes/local.js +410 -13
- package/src/routes/mcp.js +47 -4
- package/src/routes/middleware/validate-review-id.js +53 -0
- package/src/routes/pr.js +556 -71
- package/src/routes/reviews.js +145 -29
- package/src/routes/setup.js +8 -3
- package/src/routes/stack-analysis.js +33 -9
- package/src/routes/worktrees.js +3 -2
- package/src/server.js +2 -0
- package/src/setup/pr-setup.js +37 -11
- package/src/setup/stack-setup.js +13 -3
- package/src/single-port.js +6 -3
- package/src/utils/diff-hunks.js +65 -0
- package/src/utils/json-extractor.js +5 -2
package/src/database.js
CHANGED
|
@@ -21,7 +21,7 @@ function getDbPath() {
|
|
|
21
21
|
/**
|
|
22
22
|
* Current schema version - increment this when adding new migrations
|
|
23
23
|
*/
|
|
24
|
-
const CURRENT_SCHEMA_VERSION =
|
|
24
|
+
const CURRENT_SCHEMA_VERSION = 48;
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* Database schema SQL statements
|
|
@@ -270,6 +270,36 @@ const SCHEMA_SQL = {
|
|
|
270
270
|
)
|
|
271
271
|
`,
|
|
272
272
|
|
|
273
|
+
hunk_summaries: `
|
|
274
|
+
CREATE TABLE IF NOT EXISTS hunk_summaries (
|
|
275
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
276
|
+
review_id INTEGER NOT NULL,
|
|
277
|
+
file_path TEXT NOT NULL,
|
|
278
|
+
content_hash TEXT NOT NULL,
|
|
279
|
+
summary_text TEXT,
|
|
280
|
+
trivial_reason TEXT,
|
|
281
|
+
provider TEXT,
|
|
282
|
+
model TEXT,
|
|
283
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
284
|
+
FOREIGN KEY (review_id) REFERENCES reviews(id) ON DELETE CASCADE,
|
|
285
|
+
CHECK (summary_text IS NOT NULL OR trivial_reason IS NOT NULL),
|
|
286
|
+
UNIQUE (review_id, content_hash)
|
|
287
|
+
)
|
|
288
|
+
`,
|
|
289
|
+
|
|
290
|
+
tours: `
|
|
291
|
+
CREATE TABLE IF NOT EXISTS tours (
|
|
292
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
293
|
+
review_id INTEGER NOT NULL UNIQUE,
|
|
294
|
+
stops TEXT NOT NULL,
|
|
295
|
+
diff_hash TEXT NOT NULL,
|
|
296
|
+
provider TEXT,
|
|
297
|
+
model TEXT,
|
|
298
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
299
|
+
FOREIGN KEY (review_id) REFERENCES reviews(id) ON DELETE CASCADE
|
|
300
|
+
)
|
|
301
|
+
`,
|
|
302
|
+
|
|
273
303
|
github_pr_cache: `
|
|
274
304
|
CREATE TABLE IF NOT EXISTS github_pr_cache (
|
|
275
305
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -365,6 +395,8 @@ const INDEX_SQL = [
|
|
|
365
395
|
'CREATE INDEX IF NOT EXISTS idx_chat_messages_session ON chat_messages(session_id)',
|
|
366
396
|
// Context files indexes
|
|
367
397
|
'CREATE INDEX IF NOT EXISTS idx_context_files_review ON context_files(review_id)',
|
|
398
|
+
// Hunk summaries indexes
|
|
399
|
+
'CREATE INDEX IF NOT EXISTS idx_hunk_summaries_review ON hunk_summaries(review_id)',
|
|
368
400
|
// GitHub PR cache indexes
|
|
369
401
|
'CREATE UNIQUE INDEX IF NOT EXISTS idx_github_pr_cache_unique ON github_pr_cache(collection, owner, repo, number)',
|
|
370
402
|
// Worktree pool indexes
|
|
@@ -2078,6 +2110,55 @@ const MIGRATIONS = {
|
|
|
2078
2110
|
|
|
2079
2111
|
console.log(' Rebuilt external_comments with ON DELETE SET NULL on parent_id');
|
|
2080
2112
|
console.log('Migration to schema version 46 complete');
|
|
2113
|
+
},
|
|
2114
|
+
|
|
2115
|
+
47: (db) => {
|
|
2116
|
+
console.log('Running migration to schema version 47: Add hunk_summaries table...');
|
|
2117
|
+
if (!tableExists(db, 'hunk_summaries')) {
|
|
2118
|
+
db.exec(`
|
|
2119
|
+
CREATE TABLE IF NOT EXISTS hunk_summaries (
|
|
2120
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2121
|
+
review_id INTEGER NOT NULL,
|
|
2122
|
+
file_path TEXT NOT NULL,
|
|
2123
|
+
content_hash TEXT NOT NULL,
|
|
2124
|
+
summary_text TEXT,
|
|
2125
|
+
trivial_reason TEXT,
|
|
2126
|
+
provider TEXT,
|
|
2127
|
+
model TEXT,
|
|
2128
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
2129
|
+
FOREIGN KEY (review_id) REFERENCES reviews(id) ON DELETE CASCADE,
|
|
2130
|
+
CHECK (summary_text IS NOT NULL OR trivial_reason IS NOT NULL),
|
|
2131
|
+
UNIQUE (review_id, content_hash)
|
|
2132
|
+
)
|
|
2133
|
+
`);
|
|
2134
|
+
console.log(' Created hunk_summaries table');
|
|
2135
|
+
} else {
|
|
2136
|
+
console.log(' Table hunk_summaries already exists');
|
|
2137
|
+
}
|
|
2138
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_hunk_summaries_review ON hunk_summaries(review_id)');
|
|
2139
|
+
console.log('Migration to schema version 47 complete');
|
|
2140
|
+
},
|
|
2141
|
+
|
|
2142
|
+
48: (db) => {
|
|
2143
|
+
console.log('Running migration to schema version 48: Add tours table...');
|
|
2144
|
+
if (!tableExists(db, 'tours')) {
|
|
2145
|
+
db.exec(`
|
|
2146
|
+
CREATE TABLE IF NOT EXISTS tours (
|
|
2147
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2148
|
+
review_id INTEGER NOT NULL UNIQUE,
|
|
2149
|
+
stops TEXT NOT NULL,
|
|
2150
|
+
diff_hash TEXT NOT NULL,
|
|
2151
|
+
provider TEXT,
|
|
2152
|
+
model TEXT,
|
|
2153
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
2154
|
+
FOREIGN KEY (review_id) REFERENCES reviews(id) ON DELETE CASCADE
|
|
2155
|
+
)
|
|
2156
|
+
`);
|
|
2157
|
+
console.log(' Created tours table');
|
|
2158
|
+
} else {
|
|
2159
|
+
console.log(' Table tours already exists');
|
|
2160
|
+
}
|
|
2161
|
+
console.log('Migration to schema version 48 complete');
|
|
2081
2162
|
}
|
|
2082
2163
|
};
|
|
2083
2164
|
|
|
@@ -5546,6 +5627,204 @@ class ContextFileRepository {
|
|
|
5546
5627
|
}
|
|
5547
5628
|
}
|
|
5548
5629
|
|
|
5630
|
+
/**
|
|
5631
|
+
* HunkSummaryRepository class for managing per-hunk natural-language summary
|
|
5632
|
+
* records. Summaries are generated by background AI jobs and keyed by content
|
|
5633
|
+
* hash so that unchanged hunks don't get re-enqueued across reloads.
|
|
5634
|
+
*
|
|
5635
|
+
* Trivial hunks (whitespace, imports, version bumps, etc.) are persisted with
|
|
5636
|
+
* `summary_text = NULL` and a non-null `trivial_reason` so the missing-hash
|
|
5637
|
+
* lookup treats them as "already known, don't re-enqueue". The frontend hides
|
|
5638
|
+
* trivial rows.
|
|
5639
|
+
*/
|
|
5640
|
+
class HunkSummaryRepository {
|
|
5641
|
+
/**
|
|
5642
|
+
* Create a new HunkSummaryRepository instance
|
|
5643
|
+
* @param {Database} db - Database instance
|
|
5644
|
+
*/
|
|
5645
|
+
constructor(db) {
|
|
5646
|
+
this.db = db;
|
|
5647
|
+
}
|
|
5648
|
+
|
|
5649
|
+
/**
|
|
5650
|
+
* Get all hunk summary rows for a review, ordered by file_path then id
|
|
5651
|
+
* @param {number} reviewId - Review ID
|
|
5652
|
+
* @returns {Promise<Array<Object>>} Array of hunk summary rows
|
|
5653
|
+
*/
|
|
5654
|
+
async getByReview(reviewId) {
|
|
5655
|
+
return query(
|
|
5656
|
+
this.db,
|
|
5657
|
+
'SELECT * FROM hunk_summaries WHERE review_id = ? ORDER BY file_path, id',
|
|
5658
|
+
[reviewId]
|
|
5659
|
+
);
|
|
5660
|
+
}
|
|
5661
|
+
|
|
5662
|
+
/**
|
|
5663
|
+
* Get all hunk summary rows for a review scoped to a single file path,
|
|
5664
|
+
* ordered by id.
|
|
5665
|
+
* @param {number} reviewId - Review ID
|
|
5666
|
+
* @param {string} filePath - File path to scope to
|
|
5667
|
+
* @returns {Promise<Array<Object>>} Array of hunk summary rows
|
|
5668
|
+
*/
|
|
5669
|
+
async getByReviewAndFile(reviewId, filePath) {
|
|
5670
|
+
return query(
|
|
5671
|
+
this.db,
|
|
5672
|
+
'SELECT * FROM hunk_summaries WHERE review_id = ? AND file_path = ? ORDER BY id',
|
|
5673
|
+
[reviewId, filePath]
|
|
5674
|
+
);
|
|
5675
|
+
}
|
|
5676
|
+
|
|
5677
|
+
/**
|
|
5678
|
+
* Get hunk summary rows for a specific set of content hashes within a review.
|
|
5679
|
+
* Used to identify which hashes already have persisted summaries and which
|
|
5680
|
+
* still need to be enqueued.
|
|
5681
|
+
* @param {number} reviewId - Review ID
|
|
5682
|
+
* @param {Array<string>} hashes - Content hashes to look up
|
|
5683
|
+
* @returns {Promise<Array<Object>>} Array of matching hunk summary rows
|
|
5684
|
+
*/
|
|
5685
|
+
async getByHashes(reviewId, hashes) {
|
|
5686
|
+
if (!Array.isArray(hashes) || hashes.length === 0) {
|
|
5687
|
+
return [];
|
|
5688
|
+
}
|
|
5689
|
+
const placeholders = hashes.map(() => '?').join(',');
|
|
5690
|
+
return query(
|
|
5691
|
+
this.db,
|
|
5692
|
+
`SELECT * FROM hunk_summaries WHERE review_id = ? AND content_hash IN (${placeholders})`,
|
|
5693
|
+
[reviewId, ...hashes]
|
|
5694
|
+
);
|
|
5695
|
+
}
|
|
5696
|
+
|
|
5697
|
+
/**
|
|
5698
|
+
* Insert or update many hunk summary rows in a single transaction.
|
|
5699
|
+
* On conflict by (review_id, content_hash), the existing row's mutable
|
|
5700
|
+
* fields are overwritten.
|
|
5701
|
+
* @param {Array<Object>} rows - Rows to upsert. Each row should have
|
|
5702
|
+
* {review_id, file_path, content_hash, summary_text?, trivial_reason?, provider?, model?}
|
|
5703
|
+
* @returns {Promise<number>} Number of rows processed
|
|
5704
|
+
*/
|
|
5705
|
+
async upsertMany(rows) {
|
|
5706
|
+
if (!Array.isArray(rows) || rows.length === 0) {
|
|
5707
|
+
return 0;
|
|
5708
|
+
}
|
|
5709
|
+
return withTransaction(this.db, () => {
|
|
5710
|
+
const stmt = this.db.prepare(`
|
|
5711
|
+
INSERT INTO hunk_summaries (
|
|
5712
|
+
review_id, file_path, content_hash, summary_text, trivial_reason, provider, model
|
|
5713
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
5714
|
+
ON CONFLICT(review_id, content_hash) DO UPDATE SET
|
|
5715
|
+
file_path = excluded.file_path,
|
|
5716
|
+
summary_text = excluded.summary_text,
|
|
5717
|
+
trivial_reason = excluded.trivial_reason,
|
|
5718
|
+
provider = excluded.provider,
|
|
5719
|
+
model = excluded.model
|
|
5720
|
+
`);
|
|
5721
|
+
let count = 0;
|
|
5722
|
+
for (const row of rows) {
|
|
5723
|
+
if (row.summary_text == null && row.trivial_reason == null) {
|
|
5724
|
+
throw new Error(
|
|
5725
|
+
`HunkSummaryRepository.upsertMany: row must set summary_text or trivial_reason ` +
|
|
5726
|
+
`(review_id=${row.review_id}, content_hash=${row.content_hash})`
|
|
5727
|
+
);
|
|
5728
|
+
}
|
|
5729
|
+
stmt.run(
|
|
5730
|
+
row.review_id,
|
|
5731
|
+
row.file_path,
|
|
5732
|
+
row.content_hash,
|
|
5733
|
+
row.summary_text ?? null,
|
|
5734
|
+
row.trivial_reason ?? null,
|
|
5735
|
+
row.provider ?? null,
|
|
5736
|
+
row.model ?? null
|
|
5737
|
+
);
|
|
5738
|
+
count++;
|
|
5739
|
+
}
|
|
5740
|
+
return count;
|
|
5741
|
+
});
|
|
5742
|
+
}
|
|
5743
|
+
|
|
5744
|
+
/**
|
|
5745
|
+
* Delete all hunk summary rows for a review
|
|
5746
|
+
* @param {number} reviewId - Review ID
|
|
5747
|
+
* @returns {Promise<Object>} Run result with `changes` count
|
|
5748
|
+
*/
|
|
5749
|
+
async deleteByReview(reviewId) {
|
|
5750
|
+
return run(this.db, 'DELETE FROM hunk_summaries WHERE review_id = ?', [reviewId]);
|
|
5751
|
+
}
|
|
5752
|
+
}
|
|
5753
|
+
|
|
5754
|
+
/**
|
|
5755
|
+
* TourRepository class for managing per-review guided-tour records. A tour is
|
|
5756
|
+
* a single ordered narrative walkthrough (a JSON array of stops). `diff_hash`
|
|
5757
|
+
* is a 16-char SHA-256 prefix of the diff text; the tour-generator uses it to
|
|
5758
|
+
* detect staleness (when the diff changes, the tour is regenerated).
|
|
5759
|
+
*
|
|
5760
|
+
* One row per review (review_id is UNIQUE). On regeneration, upsert replaces
|
|
5761
|
+
* the existing row.
|
|
5762
|
+
*/
|
|
5763
|
+
class TourRepository {
|
|
5764
|
+
/**
|
|
5765
|
+
* Create a new TourRepository instance
|
|
5766
|
+
* @param {Database} db - Database instance
|
|
5767
|
+
*/
|
|
5768
|
+
constructor(db) {
|
|
5769
|
+
this.db = db;
|
|
5770
|
+
}
|
|
5771
|
+
|
|
5772
|
+
/**
|
|
5773
|
+
* Get the tour row for a review.
|
|
5774
|
+
* `stops` is returned as a raw JSON string; the caller parses.
|
|
5775
|
+
* @param {number} reviewId - Review ID
|
|
5776
|
+
* @returns {Promise<Object|undefined>} The tour row or undefined if none
|
|
5777
|
+
*/
|
|
5778
|
+
async get(reviewId) {
|
|
5779
|
+
return queryOne(
|
|
5780
|
+
this.db,
|
|
5781
|
+
'SELECT * FROM tours WHERE review_id = ?',
|
|
5782
|
+
[reviewId]
|
|
5783
|
+
);
|
|
5784
|
+
}
|
|
5785
|
+
|
|
5786
|
+
/**
|
|
5787
|
+
* Insert or replace the tour row for a review. `stops` is stored verbatim;
|
|
5788
|
+
* the caller is responsible for JSON.stringify.
|
|
5789
|
+
* @param {Object} row - { review_id, stops, diff_hash, provider?, model? }
|
|
5790
|
+
* @returns {Promise<Object>} Run result with `changes` count
|
|
5791
|
+
*/
|
|
5792
|
+
async upsert(row) {
|
|
5793
|
+
if (row.review_id == null) {
|
|
5794
|
+
throw new Error('TourRepository.upsert: row.review_id is required');
|
|
5795
|
+
}
|
|
5796
|
+
return run(
|
|
5797
|
+
this.db,
|
|
5798
|
+
`
|
|
5799
|
+
INSERT INTO tours (review_id, stops, diff_hash, provider, model)
|
|
5800
|
+
VALUES (?, ?, ?, ?, ?)
|
|
5801
|
+
ON CONFLICT(review_id) DO UPDATE SET
|
|
5802
|
+
stops = excluded.stops,
|
|
5803
|
+
diff_hash = excluded.diff_hash,
|
|
5804
|
+
provider = excluded.provider,
|
|
5805
|
+
model = excluded.model,
|
|
5806
|
+
created_at = CURRENT_TIMESTAMP
|
|
5807
|
+
`,
|
|
5808
|
+
[
|
|
5809
|
+
row.review_id,
|
|
5810
|
+
row.stops,
|
|
5811
|
+
row.diff_hash,
|
|
5812
|
+
row.provider ?? null,
|
|
5813
|
+
row.model ?? null,
|
|
5814
|
+
]
|
|
5815
|
+
);
|
|
5816
|
+
}
|
|
5817
|
+
|
|
5818
|
+
/**
|
|
5819
|
+
* Delete the tour row for a review.
|
|
5820
|
+
* @param {number} reviewId - Review ID
|
|
5821
|
+
* @returns {Promise<Object>} Run result with `changes` count
|
|
5822
|
+
*/
|
|
5823
|
+
async deleteByReview(reviewId) {
|
|
5824
|
+
return run(this.db, 'DELETE FROM tours WHERE review_id = ?', [reviewId]);
|
|
5825
|
+
}
|
|
5826
|
+
}
|
|
5827
|
+
|
|
5549
5828
|
module.exports = {
|
|
5550
5829
|
initializeDatabase,
|
|
5551
5830
|
closeDatabase,
|
|
@@ -5571,6 +5850,8 @@ module.exports = {
|
|
|
5571
5850
|
GitHubReviewRepository,
|
|
5572
5851
|
CouncilRepository,
|
|
5573
5852
|
ContextFileRepository,
|
|
5853
|
+
HunkSummaryRepository,
|
|
5854
|
+
TourRepository,
|
|
5574
5855
|
generateWorktreeId,
|
|
5575
5856
|
migrateExistingWorktrees,
|
|
5576
5857
|
// Exported for testing only
|
|
@@ -19,7 +19,11 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
const { GitHubClient, GitHubApiError } = require('../github/client');
|
|
22
|
-
const {
|
|
22
|
+
const {
|
|
23
|
+
getGitHubToken,
|
|
24
|
+
resolveHostBinding,
|
|
25
|
+
resolveBindingRepositoryFromPR
|
|
26
|
+
} = require('../config');
|
|
23
27
|
|
|
24
28
|
const name = 'github';
|
|
25
29
|
|
|
@@ -38,25 +42,74 @@ const credentialEnvVar = 'GITHUB_TOKEN';
|
|
|
38
42
|
* `GitHubApiError(status: 401)` keeps the existing 401 mapping at the
|
|
39
43
|
* route layer.
|
|
40
44
|
*
|
|
45
|
+
* Binding-aware: when `repository` (`owner/repo`) is supplied, credential
|
|
46
|
+
* resolution mirrors `resolveBindingForRequest` in `src/routes/pr.js` —
|
|
47
|
+
* the repo is resolved to its binding key via `resolveBindingRepositoryFromPR`
|
|
48
|
+
* and then to a host binding via `resolveHostBinding`, so per-repo
|
|
49
|
+
* `api_host` / `token` / `token_command` / `features` apply. The
|
|
50
|
+
* `GitHubClient` is constructed from the FULL binding (not a bare token),
|
|
51
|
+
* so an alt-host repo routes Octokit's `baseUrl` to its `api_host` instead
|
|
52
|
+
* of always hitting `api.github.com`.
|
|
53
|
+
*
|
|
54
|
+
* When `repository` is absent/empty the no-repo fallback is preserved
|
|
55
|
+
* exactly: `getGitHubToken(config)` (top-level/env token) + a bare-token
|
|
56
|
+
* `GitHubClient` (→ `api.github.com`). This keeps any caller without repo
|
|
57
|
+
* context working unchanged.
|
|
58
|
+
*
|
|
59
|
+
* The returned shape also carries `isAltHost` so the route can drive
|
|
60
|
+
* host-aware comment mapping. On the repo path it reflects the resolved
|
|
61
|
+
* binding (`Boolean(binding.apiHost)`); on the no-repo fallback it is always
|
|
62
|
+
* `false` (api.github.com). Alt-hosts don't implement GitHub's deprecated
|
|
63
|
+
* diff-relative `position` field, so `mapComment` must anchor by `line`
|
|
64
|
+
* instead — see its docstring.
|
|
65
|
+
*
|
|
41
66
|
* @param {Object} config - Server config (see `loadConfig()`)
|
|
42
|
-
* @param {
|
|
43
|
-
* @
|
|
67
|
+
* @param {string} [repository] - "owner/repo" identifier for binding-aware resolution
|
|
68
|
+
* @param {Object} [_deps] - Test overrides for
|
|
69
|
+
* { GitHubClient, getGitHubToken, resolveHostBinding, resolveBindingRepositoryFromPR }
|
|
70
|
+
* @returns {{ client: Object, isAltHost: boolean }}
|
|
44
71
|
* @throws {GitHubApiError} with status 401 when no token is configured
|
|
45
72
|
*/
|
|
46
|
-
function resolveCredentials(config, _deps) {
|
|
73
|
+
function resolveCredentials(config, repository, _deps) {
|
|
47
74
|
const deps = {
|
|
48
75
|
GitHubClient,
|
|
49
76
|
getGitHubToken,
|
|
77
|
+
resolveHostBinding,
|
|
78
|
+
resolveBindingRepositoryFromPR,
|
|
50
79
|
..._deps
|
|
51
80
|
};
|
|
52
|
-
const
|
|
81
|
+
const safeConfig = config || {};
|
|
82
|
+
|
|
83
|
+
if (repository) {
|
|
84
|
+
// Binding-aware path. Mirrors resolveBindingForRequest in routes/pr.js:
|
|
85
|
+
// resolve the PR identity to a binding key, then to a host binding.
|
|
86
|
+
const [owner, repo] = String(repository).split('/');
|
|
87
|
+
const bindingRepository = deps.resolveBindingRepositoryFromPR(owner, repo, safeConfig);
|
|
88
|
+
const binding = deps.resolveHostBinding(bindingRepository, safeConfig);
|
|
89
|
+
if (!binding || !binding.token) {
|
|
90
|
+
throw new GitHubApiError(
|
|
91
|
+
`GitHub token not configured for ${repository}. Set ${credentialEnvVar}, add github_token to ~/.pair-review/config.json, or configure repos["${bindingRepository}"].token / token_command (required for alt-host repos)`,
|
|
92
|
+
401
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
// An api_host on the binding means this repo lives on an alternate Git
|
|
96
|
+
// host. Surface that so the route can switch mapComment to line-based
|
|
97
|
+
// anchoring (alt-hosts return position:null with a valid `line`).
|
|
98
|
+
return { client: new deps.GitHubClient(binding), isAltHost: Boolean(binding.apiHost) };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// No-repo fallback — preserved exactly as before so callers without repo
|
|
102
|
+
// context (top-level/env token → api.github.com) continue to work.
|
|
103
|
+
const token = deps.getGitHubToken(safeConfig);
|
|
53
104
|
if (!token) {
|
|
54
105
|
throw new GitHubApiError(
|
|
55
106
|
`GitHub token not configured. Set ${credentialEnvVar} or add github_token to ~/.pair-review/config.json`,
|
|
56
107
|
401
|
|
57
108
|
);
|
|
58
109
|
}
|
|
59
|
-
|
|
110
|
+
// The no-repo fallback always targets api.github.com, so it is never an
|
|
111
|
+
// alt-host — keep the github.com position-based mapping.
|
|
112
|
+
return { client: new deps.GitHubClient(token), isAltHost: false };
|
|
60
113
|
}
|
|
61
114
|
|
|
62
115
|
/**
|
|
@@ -76,23 +129,40 @@ async function fetchComments({ client, owner, repo, pull_number }) {
|
|
|
76
129
|
/**
|
|
77
130
|
* Map a raw GitHub review-comment API row to an `external_comments` row.
|
|
78
131
|
*
|
|
132
|
+
* Host-aware anchoring (the `options.isAltHost` switch):
|
|
133
|
+
* - **github.com (default, `isAltHost` falsy)**: `position` is the signal
|
|
134
|
+
* for "outdated". A null `position` means the comment no longer maps to
|
|
135
|
+
* the current diff, so the current-anchor fields are nulled and
|
|
136
|
+
* `original_*` becomes the only authoritative anchor. This path is
|
|
137
|
+
* byte-identical to the long-standing behaviour and must not change.
|
|
138
|
+
* - **alt-host (`isAltHost === true`)**: alternate Git hosts do NOT
|
|
139
|
+
* implement GitHub's DEPRECATED diff-relative `position` field — they
|
|
140
|
+
* return `position: null` even for perfectly current comments while
|
|
141
|
+
* supplying a valid modern `line`. Keying "outdated" off `position`
|
|
142
|
+
* there would discard a good `line` and mis-flag live comments as lost
|
|
143
|
+
* anchors. So we anchor uniformly by `line`: a current comment has a
|
|
144
|
+
* non-null `line` (`is_outdated = 0`); a genuinely outdated one has
|
|
145
|
+
* `line == null` (`is_outdated = 1`, anchored via `original_*`). This
|
|
146
|
+
* works whether or not the host also happens to return `position`.
|
|
147
|
+
*
|
|
79
148
|
* Edge cases handled here (per the phase spec):
|
|
80
149
|
* - `apiRow.user` is null (deleted account): `author` and `author_url`
|
|
81
150
|
* both become null. No throw.
|
|
82
|
-
* -
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* (force-push lost anchor): still produces a row — the sync route
|
|
86
|
-
* decides whether to count or skip. We do NOT throw here.
|
|
151
|
+
* - both current AND original anchors null (force-push lost anchor):
|
|
152
|
+
* still produces a row — the sync route decides whether to count or
|
|
153
|
+
* skip. We do NOT throw here.
|
|
87
154
|
* - `apiRow.path` missing: throws — `file` is NOT NULL in the schema
|
|
88
155
|
* and a missing path means upstream gave us something malformed.
|
|
89
156
|
* Failing early in the mapper is far easier to debug than a SQL
|
|
90
157
|
* constraint violation deep in an upsert loop.
|
|
91
158
|
*
|
|
92
159
|
* @param {Object} apiRow
|
|
160
|
+
* @param {Object} [options]
|
|
161
|
+
* @param {boolean} [options.isAltHost=false] - When true, use line-based
|
|
162
|
+
* anchoring (alt-host); when false/omitted, github.com position-based.
|
|
93
163
|
* @returns {Object} A row matching the `external_comments` column names
|
|
94
164
|
*/
|
|
95
|
-
function mapComment(apiRow) {
|
|
165
|
+
function mapComment(apiRow, options = {}) {
|
|
96
166
|
if (!apiRow || apiRow.path == null) {
|
|
97
167
|
throw new Error('GitHub adapter: comment missing required field "path"');
|
|
98
168
|
}
|
|
@@ -106,19 +176,38 @@ function mapComment(apiRow) {
|
|
|
106
176
|
}
|
|
107
177
|
|
|
108
178
|
const user = apiRow.user || null;
|
|
109
|
-
const
|
|
179
|
+
const isAltHost = options.isAltHost === true;
|
|
110
180
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
181
|
+
let line_start;
|
|
182
|
+
let line_end;
|
|
183
|
+
let diff_position;
|
|
184
|
+
let is_outdated;
|
|
185
|
+
|
|
186
|
+
if (isAltHost) {
|
|
187
|
+
// Alt-host: `line` is the authoritative signal. `position` is unreliable
|
|
188
|
+
// (alt-hosts leave it null even for current comments), so we never null
|
|
189
|
+
// out a good `line` based on it. `diff_position` is carried through —
|
|
190
|
+
// legitimately null on most alt-hosts; the frontend renders by
|
|
191
|
+
// line_start/line_end/side, not by diff_position.
|
|
192
|
+
const lineIsNull = apiRow.line == null;
|
|
193
|
+
line_start = lineIsNull ? null : apiRow.start_line ?? apiRow.line ?? null;
|
|
194
|
+
line_end = lineIsNull ? null : apiRow.line ?? null;
|
|
195
|
+
diff_position = apiRow.position ?? null;
|
|
196
|
+
is_outdated = lineIsNull ? 1 : 0;
|
|
197
|
+
} else {
|
|
198
|
+
// github.com (unchanged): when position is null the comment is outdated.
|
|
199
|
+
// GitHub still populates `line` in many of these responses, but the line
|
|
200
|
+
// number does NOT correspond to a position in the current diff — using
|
|
201
|
+
// it would create two conflicting truths (line_end set AND is_outdated=1)
|
|
202
|
+
// and would make the lost-anchor filter under-count. Force the
|
|
203
|
+
// current-anchor fields to null so `original_*` is the only
|
|
204
|
+
// authoritative anchor.
|
|
205
|
+
const positionIsNull = apiRow.position == null;
|
|
206
|
+
line_start = positionIsNull ? null : apiRow.start_line ?? apiRow.line ?? null;
|
|
207
|
+
line_end = positionIsNull ? null : apiRow.line ?? null;
|
|
208
|
+
diff_position = positionIsNull ? null : apiRow.position ?? null;
|
|
209
|
+
is_outdated = positionIsNull ? 1 : 0;
|
|
210
|
+
}
|
|
122
211
|
|
|
123
212
|
return {
|
|
124
213
|
external_id: String(apiRow.id),
|
|
@@ -133,7 +222,7 @@ function mapComment(apiRow) {
|
|
|
133
222
|
line_end,
|
|
134
223
|
diff_position,
|
|
135
224
|
commit_sha: apiRow.commit_id ?? null,
|
|
136
|
-
is_outdated
|
|
225
|
+
is_outdated,
|
|
137
226
|
original_line_start:
|
|
138
227
|
apiRow.original_start_line ?? apiRow.original_line ?? null,
|
|
139
228
|
original_line_end: apiRow.original_line ?? null,
|
package/src/git/base-branch.js
CHANGED
|
@@ -8,9 +8,13 @@ const defaults = {
|
|
|
8
8
|
// This default returns empty so GitHub lookup is silently skipped
|
|
9
9
|
// when no token is provided — never re-resolve config internally.
|
|
10
10
|
getGitHubToken: () => '',
|
|
11
|
-
|
|
11
|
+
// Callers may optionally pass a binding via _deps.getHostBinding so
|
|
12
|
+
// alt-host repos route to the configured api_host. The default
|
|
13
|
+
// returns null which makes the client default to github.com.
|
|
14
|
+
getHostBinding: () => null,
|
|
15
|
+
createGitHubClient: (tokenOrBinding) => {
|
|
12
16
|
const { GitHubClient } = require('../github/client');
|
|
13
|
-
return new GitHubClient(
|
|
17
|
+
return new GitHubClient(tokenOrBinding);
|
|
14
18
|
}
|
|
15
19
|
};
|
|
16
20
|
|
|
@@ -139,11 +143,14 @@ async function tryGitHubPR(repoPath, currentBranch, repository, deps) {
|
|
|
139
143
|
if (!repository || !repository.includes('/')) return null;
|
|
140
144
|
|
|
141
145
|
try {
|
|
146
|
+
// Prefer the host binding (alt-host aware) when callers provide one.
|
|
147
|
+
// Falls back to the bare token for legacy callers.
|
|
148
|
+
const binding = deps.getHostBinding();
|
|
142
149
|
const token = deps.getGitHubToken();
|
|
143
|
-
if (!token) return null;
|
|
150
|
+
if (!binding && !token) return null;
|
|
144
151
|
|
|
145
152
|
const [owner, repo] = repository.split('/');
|
|
146
|
-
const client = deps.createGitHubClient(token);
|
|
153
|
+
const client = deps.createGitHubClient(binding || token);
|
|
147
154
|
const result = await client.findPRByBranch(owner, repo, currentBranch);
|
|
148
155
|
|
|
149
156
|
if (result) {
|