@moritzwilksch/diffle 0.1.3 → 0.1.5
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 +32 -9
- package/dist/client/assets/{angular-html-DCs801AK.js → angular-html-DmrG9JtS.js} +1 -1
- package/dist/client/assets/{angular-ts-BWhN0w8_.js → angular-ts-ViWJivwt.js} +1 -1
- package/dist/client/assets/{apl-Djz0ErNW.js → apl-C5EQbFuR.js} +1 -1
- package/dist/client/assets/{astro-MWC3rLUN.js → astro-Dn81Z0Ad.js} +1 -1
- package/dist/client/assets/{blade-B8pycf1w.js → blade-Bw99tw7z.js} +1 -1
- package/dist/client/assets/{c-B-Jcxs--.js → c-BUkR06RC.js} +1 -1
- package/dist/client/assets/{chapel-CuJCeKLf.js → chapel-BkX4HVjh.js} +1 -1
- package/dist/client/assets/{cobol-DgquORVs.js → cobol-DHYNHdYd.js} +1 -1
- package/dist/client/assets/{coffee-ECOaIhT5.js → coffee-CwXH1Vh3.js} +1 -1
- package/dist/client/assets/{cpp-CqnSivHO.js → cpp-CH4TrKz5.js} +1 -1
- package/dist/client/assets/{crystal-Cs7ZYBfN.js → crystal-DKLVzvRx.js} +1 -1
- package/dist/client/assets/{css-p0qk9Lci.js → css-Nq8Uu_QZ.js} +1 -1
- package/dist/client/assets/{edge-mMULBvUs.js → edge-CJRhOJkj.js} +1 -1
- package/dist/client/assets/{elixir-VIosceup.js → elixir-B6z04L_L.js} +1 -1
- package/dist/client/assets/{elm-C3kw87lA.js → elm-CylqdfmR.js} +1 -1
- package/dist/client/assets/{erb-DZjmloke.js → erb--LFUnTAk.js} +1 -1
- package/dist/client/assets/{git-rebase-qAMhScBw.js → git-rebase-CJezoI_U.js} +1 -1
- package/dist/client/assets/{glimmer-js-DJdA7CEB.js → glimmer-js-CVs-5jG6.js} +1 -1
- package/dist/client/assets/{glimmer-ts-6xtV4JQb.js → glimmer-ts-C8GkuLHj.js} +1 -1
- package/dist/client/assets/{glsl-BRqSJ3dN.js → glsl-Cre-e57h.js} +1 -1
- package/dist/client/assets/{graphql-nm_X3Hg7.js → graphql-Bflp-kge.js} +1 -1
- package/dist/client/assets/{hack-CQ7j-SLC.js → hack-BVXbQcT2.js} +1 -1
- package/dist/client/assets/{haml-nw30ZCFe.js → haml-De4W5AvJ.js} +1 -1
- package/dist/client/assets/{handlebars-CbbUPaYB.js → handlebars-DrAV6XbB.js} +1 -1
- package/dist/client/assets/{html-C-E44L0y.js → html-DWrE5ZtW.js} +1 -1
- package/dist/client/assets/{html-derivative-DTCnXXkR.js → html-derivative-4y8Ldk_X.js} +1 -1
- package/dist/client/assets/{http-Cg4Jw-kz.js → http-BlF3sH0P.js} +1 -1
- package/dist/client/assets/{hurl-CITbwYVL.js → hurl-8Yd7qaL-.js} +1 -1
- package/dist/client/assets/{index-BdA8MWLx.js → index-B5j0g-YF.js} +90 -90
- package/dist/client/assets/{java-DNT_EPVr.js → java-Da6TVA-D.js} +1 -1
- package/dist/client/assets/{javascript-DBrMK690.js → javascript-9H4TWodB.js} +1 -1
- package/dist/client/assets/{jinja-DqPnwJo-.js → jinja-CB5h6IWK.js} +1 -1
- package/dist/client/assets/{jison-A5ieRkCJ.js → jison-DdpPjkKm.js} +1 -1
- package/dist/client/assets/{json-BolWLdCd.js → json-lKrYnKUy.js} +1 -1
- package/dist/client/assets/{jsx-CkIysgAJ.js → jsx-Cq_s8bb1.js} +1 -1
- package/dist/client/assets/{julia-jBzm267c.js → julia-B2fUm1la.js} +1 -1
- package/dist/client/assets/{just-cr8UASbW.js → just-EU9poDOS.js} +1 -1
- package/dist/client/assets/{latex-DcdC0vak.js → latex-D94S8ANd.js} +1 -1
- package/dist/client/assets/{liquid-BBR6eAOA.js → liquid-Dq3Y6XOW.js} +1 -1
- package/dist/client/assets/{lua-CnUVAA9w.js → lua-Dl3Y8EmE.js} +1 -1
- package/dist/client/assets/{marko-BxH5DxB_.js → marko-BUvlzVCc.js} +1 -1
- package/dist/client/assets/{mdc-CVc0_tcy.js → mdc-5MteepiP.js} +1 -1
- package/dist/client/assets/{nginx-B1DQmN_N.js → nginx-DPAEohPR.js} +1 -1
- package/dist/client/assets/{nim-C3rsdIhh.js → nim-Dy-zxDpe.js} +1 -1
- package/dist/client/assets/{org-DICsbmv1.js → org-a4b5Lf2G.js} +1 -1
- package/dist/client/assets/{perl-B-6tgIaz.js → perl-Ba0EQGS8.js} +1 -1
- package/dist/client/assets/{php-CSZf1YiM.js → php-BwWPnF9y.js} +1 -1
- package/dist/client/assets/{pug-BvQ97HKe.js → pug-DiduM0zv.js} +1 -1
- package/dist/client/assets/{qml-B_gSWBeZ.js → qml-Zm_NS-qG.js} +1 -1
- package/dist/client/assets/{r-Dbh43Ti8.js → r-Dq0UKcOZ.js} +1 -1
- package/dist/client/assets/{razor-Dks3_W39.js → razor-BOvDDN47.js} +1 -1
- package/dist/client/assets/{regexp-cZE9On7Z.js → regexp-D3YxDQ6s.js} +1 -1
- package/dist/client/assets/{rst-Cgn-1Zks.js → rst-DHbzSb6z.js} +1 -1
- package/dist/client/assets/{ruby-DkGM-R-6.js → ruby-CvI-mn5Q.js} +1 -1
- package/dist/client/assets/{sas-D6c_PGbn.js → sas-CWQFfwiE.js} +1 -1
- package/dist/client/assets/{scss-BKxWJNFh.js → scss-CCpN0AyS.js} +1 -1
- package/dist/client/assets/{shellscript-Bcg5lXvK.js → shellscript-BfpoO4g6.js} +1 -1
- package/dist/client/assets/{shellsession-Bf400BZo.js → shellsession-agjx723h.js} +1 -1
- package/dist/client/assets/{soy-VDZj_scj.js → soy-pEVWulWp.js} +1 -1
- package/dist/client/assets/{sql-ushyJdRf.js → sql-DS0UAPd6.js} +1 -1
- package/dist/client/assets/{stata-87RYcwm-.js → stata-D8L-Tcxs.js} +1 -1
- package/dist/client/assets/{surrealql-DiJkNDgM.js → surrealql-DOkpssDf.js} +1 -1
- package/dist/client/assets/{svelte-B6zEQCqX.js → svelte-B4ieXAtz.js} +1 -1
- package/dist/client/assets/{templ-BbUkdxgW.js → templ-BdVEAAo4.js} +1 -1
- package/dist/client/assets/{tex-8DUlt0ja.js → tex-CYjNIefm.js} +1 -1
- package/dist/client/assets/{ts-tags-B9TjJt8W.js → ts-tags-BFD-4qTD.js} +1 -1
- package/dist/client/assets/{tsx-KtVv-1tC.js → tsx-CZLDU0bf.js} +1 -1
- package/dist/client/assets/{twig-SRZcBkws.js → twig-DYcKmD5x.js} +1 -1
- package/dist/client/assets/{typescript-XxBZhs1X.js → typescript-d_JvksIp.js} +1 -1
- package/dist/client/assets/{typst-zdrwuFs3.js → typst-B0Uo-CKu.js} +1 -1
- package/dist/client/assets/{vue-CIJzu_ou.js → vue-B3Iiop2I.js} +1 -1
- package/dist/client/assets/{vue-html-Cbh4Xclf.js → vue-html-B9s0WJG6.js} +1 -1
- package/dist/client/assets/{vue-vine-B3rP3Gia.js → vue-vine-CIMF412S.js} +1 -1
- package/dist/client/assets/{xml-B8FuGGpH.js → xml-BzXG-icR.js} +1 -1
- package/dist/client/assets/{xsl-DGb4ZSQs.js → xsl-Dp1sXze8.js} +1 -1
- package/dist/client/assets/{yaml-DgFlGzOM.js → yaml-BW2dXlES.js} +1 -1
- package/dist/client/index.html +1 -1
- package/dist/server/{Watcher-C_X03LRa.js → Watcher-CKwvGuPB.js} +5 -4
- package/dist/server/main.js +312 -135
- package/package.json +1 -1
package/dist/server/main.js
CHANGED
|
@@ -3951,7 +3951,7 @@ function generateCompletion(program, { shell, executable = program.name() }) {
|
|
|
3951
3951
|
}
|
|
3952
3952
|
//#endregion
|
|
3953
3953
|
//#region package.json
|
|
3954
|
-
var version$1 = "0.1.
|
|
3954
|
+
var version$1 = "0.1.5";
|
|
3955
3955
|
//#endregion
|
|
3956
3956
|
//#region src/server/comments/anchor.ts
|
|
3957
3957
|
function splitLines(contents) {
|
|
@@ -3996,10 +3996,15 @@ function quoteRange(contents, startLine, endLine) {
|
|
|
3996
3996
|
if (startLine < 1 || endLine < startLine || endLine > lines.length) return null;
|
|
3997
3997
|
return lines.slice(startLine - 1, endLine).join("\n");
|
|
3998
3998
|
}
|
|
3999
|
-
/**
|
|
3999
|
+
/** The line a thread sorts under: a file thread comes before every line of its file. */
|
|
4000
|
+
function anchorLine(anchor) {
|
|
4001
|
+
return anchor.kind === "line" ? anchor.startLine : 0;
|
|
4002
|
+
}
|
|
4003
|
+
/** Review order: by path, then line (file threads first), then when the thread was opened. */
|
|
4000
4004
|
function compareThreads(a, b) {
|
|
4001
4005
|
if (a.anchor.path !== b.anchor.path) return a.anchor.path < b.anchor.path ? -1 : 1;
|
|
4002
|
-
|
|
4006
|
+
const line = anchorLine(a.anchor) - anchorLine(b.anchor);
|
|
4007
|
+
if (line !== 0) return line;
|
|
4003
4008
|
return (a.messages[0]?.createdAt ?? 0) - (b.messages[0]?.createdAt ?? 0);
|
|
4004
4009
|
}
|
|
4005
4010
|
//#endregion
|
|
@@ -4007,21 +4012,23 @@ function compareThreads(a, b) {
|
|
|
4007
4012
|
/**
|
|
4008
4013
|
* Agent-prompt format, one block per thread:
|
|
4009
4014
|
*
|
|
4010
|
-
* path:line (or path:start-end)
|
|
4015
|
+
* path:line (or path:start-end; `(file) path` for a thread on the whole file)
|
|
4011
4016
|
*
|
|
4012
|
-
* > quoted line
|
|
4017
|
+
* > quoted line (absent for a file thread)
|
|
4013
4018
|
*
|
|
4014
4019
|
* body (a thread's messages joined by a blank line)
|
|
4015
4020
|
*
|
|
4016
4021
|
* ---
|
|
4017
4022
|
*
|
|
4018
|
-
* A ```suggestion fence in a body expands to ORIGINAL / SUGGESTED fences
|
|
4023
|
+
* A ```suggestion fence in a body expands to ORIGINAL / SUGGESTED fences; a file
|
|
4024
|
+
* thread has no original, so its fences stay as written.
|
|
4019
4025
|
*/
|
|
4020
4026
|
function formatPrompt(threads) {
|
|
4021
4027
|
return [...threads].sort(compareThreads).map(formatOne).join("\n");
|
|
4022
4028
|
}
|
|
4023
4029
|
function formatOne(t) {
|
|
4024
4030
|
const { anchor } = t;
|
|
4031
|
+
if (anchor.kind === "file") return `${`(file) ${t.stale ? "(stale) " : ""}${anchor.path}`}\n\n${t.messages.map((m) => m.body.trim()).join("\n\n")}\n\n---\n`;
|
|
4025
4032
|
const range = anchor.startLine === anchor.endLine ? `${anchor.startLine}` : `${anchor.startLine}-${anchor.endLine}`;
|
|
4026
4033
|
const prefixes = [];
|
|
4027
4034
|
if (anchor.side === "old") prefixes.push("(removed)");
|
|
@@ -4720,6 +4727,31 @@ var GitRepo = class GitRepo {
|
|
|
4720
4727
|
await fh?.close();
|
|
4721
4728
|
}
|
|
4722
4729
|
}
|
|
4730
|
+
/**
|
|
4731
|
+
* Boolean value of a gitattribute per path: `attr` or `attr=true` reads
|
|
4732
|
+
* true, `-attr` or `attr=false` false; unspecified paths are absent. A
|
|
4733
|
+
* revision reads `.gitattributes` from that tree, the worktree from the
|
|
4734
|
+
* checkout. `--source` needs git 2.40.
|
|
4735
|
+
*/
|
|
4736
|
+
async attr(name, paths, rev) {
|
|
4737
|
+
const out = /* @__PURE__ */ new Map();
|
|
4738
|
+
if (paths.length === 0) return out;
|
|
4739
|
+
const source = rev === "worktree" ? [] : [`--source=${rev}`];
|
|
4740
|
+
const fields = splitZ(await this.exec([
|
|
4741
|
+
"check-attr",
|
|
4742
|
+
"-z",
|
|
4743
|
+
"--stdin",
|
|
4744
|
+
...source,
|
|
4745
|
+
name,
|
|
4746
|
+
"--"
|
|
4747
|
+
], { input: paths.join("\0") }));
|
|
4748
|
+
for (let i = 0; i + 2 < fields.length; i += 3) {
|
|
4749
|
+
const value = fields[i + 2];
|
|
4750
|
+
if (value === "set" || value === "true") out.set(fields[i], true);
|
|
4751
|
+
else if (value === "unset" || value === "false") out.set(fields[i], false);
|
|
4752
|
+
}
|
|
4753
|
+
return out;
|
|
4754
|
+
}
|
|
4723
4755
|
/** Paths git ignores, from a candidate list. */
|
|
4724
4756
|
async ignored(paths) {
|
|
4725
4757
|
if (paths.length === 0) return /* @__PURE__ */ new Set();
|
|
@@ -5076,11 +5108,15 @@ var GithubError = class extends Error {
|
|
|
5076
5108
|
this.status = status;
|
|
5077
5109
|
}
|
|
5078
5110
|
};
|
|
5111
|
+
function isFileComment(c) {
|
|
5112
|
+
return "subject_type" in c;
|
|
5113
|
+
}
|
|
5079
5114
|
/**
|
|
5080
5115
|
* Threads → the comments of one pending GitHub review, one per thread. Stale threads are skipped:
|
|
5081
5116
|
* their lines no longer sit in the diff, and GitHub would refuse them anyway. With
|
|
5082
5117
|
* `threadIds`, only those (resolved included, the user asked for them by hand);
|
|
5083
|
-
* without, every unresolved thread.
|
|
5118
|
+
* without, every unresolved thread. `review.comments` holds the line comments REST can
|
|
5119
|
+
* create; the file comments are appended afterwards, but `ids` counts both, in review order.
|
|
5084
5120
|
*/
|
|
5085
5121
|
function buildReview(threads, commitId, threadIds) {
|
|
5086
5122
|
const skipped = [];
|
|
@@ -5113,20 +5149,27 @@ function buildReview(threads, commitId, threadIds) {
|
|
|
5113
5149
|
return {
|
|
5114
5150
|
review: {
|
|
5115
5151
|
commit_id: commitId,
|
|
5116
|
-
comments
|
|
5152
|
+
comments: comments.filter((c) => !isFileComment(c))
|
|
5117
5153
|
},
|
|
5154
|
+
comments,
|
|
5118
5155
|
ids,
|
|
5119
5156
|
skipped
|
|
5120
5157
|
};
|
|
5121
5158
|
}
|
|
5122
5159
|
function toReviewComment(t) {
|
|
5123
5160
|
const { anchor } = t;
|
|
5161
|
+
const body = formatBody(t.messages);
|
|
5162
|
+
if (anchor.kind === "file") return {
|
|
5163
|
+
path: anchor.path,
|
|
5164
|
+
subject_type: "file",
|
|
5165
|
+
body
|
|
5166
|
+
};
|
|
5124
5167
|
const side = anchor.side === "old" ? "LEFT" : "RIGHT";
|
|
5125
5168
|
const c = {
|
|
5126
5169
|
path: anchor.path,
|
|
5127
5170
|
line: anchor.endLine,
|
|
5128
5171
|
side,
|
|
5129
|
-
body
|
|
5172
|
+
body
|
|
5130
5173
|
};
|
|
5131
5174
|
if (anchor.startLine !== anchor.endLine) {
|
|
5132
5175
|
c.start_line = anchor.startLine;
|
|
@@ -5171,15 +5214,25 @@ var GithubExporter = class {
|
|
|
5171
5214
|
*/
|
|
5172
5215
|
async function exportToGithub({ snap, pullRequest: pr, threads, threadIds, run = runGh }) {
|
|
5173
5216
|
if (snap.newSha === "worktree") throw new GithubError("GitHub cannot anchor comments to the worktree");
|
|
5174
|
-
const { review, ids, skipped } = buildReview(threads, snap.newSha, threadIds);
|
|
5175
|
-
if (
|
|
5176
|
-
for (const [i, comment] of
|
|
5217
|
+
const { review, comments, ids, skipped } = buildReview(threads, snap.newSha, threadIds);
|
|
5218
|
+
if (comments.length === 0) throw new GithubError(skipped.length ? `nothing to post: ${describe(skipped)}` : "nothing to post", 400);
|
|
5219
|
+
for (const [i, comment] of comments.entries()) comment.body += `\n\n${threadMarker(ids[i])}`;
|
|
5177
5220
|
const pending = await findPendingReview(run, snap.root, pr);
|
|
5178
5221
|
if (!pending) {
|
|
5179
|
-
await createPendingReview(run, snap.root, pr.repository, pr.number, review);
|
|
5222
|
+
const created = await createPendingReview(run, snap.root, pr.repository, pr.number, review);
|
|
5223
|
+
let appended = 0;
|
|
5224
|
+
try {
|
|
5225
|
+
for (const c of comments.filter(isFileComment)) {
|
|
5226
|
+
await addToPendingReview(run, snap.root, created.id, c);
|
|
5227
|
+
appended++;
|
|
5228
|
+
}
|
|
5229
|
+
} catch (e) {
|
|
5230
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
5231
|
+
throw new GithubError(`created the pending review with ${review.comments.length + appended} comments, then ${detail}`, 502);
|
|
5232
|
+
}
|
|
5180
5233
|
return {
|
|
5181
5234
|
url: pr.url,
|
|
5182
|
-
posted:
|
|
5235
|
+
posted: comments.length,
|
|
5183
5236
|
updated: 0,
|
|
5184
5237
|
review: "created",
|
|
5185
5238
|
skipped
|
|
@@ -5190,7 +5243,7 @@ async function exportToGithub({ snap, pullRequest: pr, threads, threadIds, run =
|
|
|
5190
5243
|
let updated = 0;
|
|
5191
5244
|
let posted = 0;
|
|
5192
5245
|
try {
|
|
5193
|
-
for (const [i, c] of
|
|
5246
|
+
for (const [i, c] of comments.entries()) {
|
|
5194
5247
|
const id = ids[i];
|
|
5195
5248
|
const at = existing.get(anchorKey(c));
|
|
5196
5249
|
const index = at?.findIndex((comment) => comment.body.trimEnd().endsWith(threadMarker(id))) ?? -1;
|
|
@@ -5234,6 +5287,7 @@ function threadMarker(id) {
|
|
|
5234
5287
|
}
|
|
5235
5288
|
/** The anchor must still match before we rewrite an exported thread. */
|
|
5236
5289
|
function anchorKey(c) {
|
|
5290
|
+
if (isFileComment(c)) return [c.path, "FILE"].join("\0");
|
|
5237
5291
|
return [
|
|
5238
5292
|
c.path,
|
|
5239
5293
|
c.side,
|
|
@@ -5264,7 +5318,7 @@ async function findPendingReview(run, cwd, pr) {
|
|
|
5264
5318
|
const THREADS_QUERY = `query($owner:String!,$repo:String!,$number:Int!,$after:String){
|
|
5265
5319
|
repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewThreads(first:100,after:$after){
|
|
5266
5320
|
pageInfo{hasNextPage endCursor}
|
|
5267
|
-
nodes{path line startLine diffSide comments(first:1){nodes{id body pullRequestReview{id}}}}
|
|
5321
|
+
nodes{path line startLine diffSide subjectType comments(first:1){nodes{id body pullRequestReview{id}}}}
|
|
5268
5322
|
}}}
|
|
5269
5323
|
}`;
|
|
5270
5324
|
/**
|
|
@@ -5288,15 +5342,8 @@ async function pendingComments(run, cwd, pr, reviewId) {
|
|
|
5288
5342
|
for (const t of threads.nodes) {
|
|
5289
5343
|
const c = t?.comments?.nodes?.[0];
|
|
5290
5344
|
if (!t?.path || !c?.id || c.pullRequestReview?.id !== reviewId) continue;
|
|
5291
|
-
const
|
|
5292
|
-
if (
|
|
5293
|
-
const side = t.diffSide === "LEFT" ? "LEFT" : "RIGHT";
|
|
5294
|
-
const key = anchorKey({
|
|
5295
|
-
path: t.path,
|
|
5296
|
-
side,
|
|
5297
|
-
line,
|
|
5298
|
-
start_line: t.startLine ?? void 0
|
|
5299
|
-
});
|
|
5345
|
+
const key = pendingKey(t);
|
|
5346
|
+
if (key == null) continue;
|
|
5300
5347
|
const at = by.get(key);
|
|
5301
5348
|
if (at) at.push({
|
|
5302
5349
|
nodeId: c.id,
|
|
@@ -5313,10 +5360,30 @@ async function pendingComments(run, cwd, pr, reviewId) {
|
|
|
5313
5360
|
}
|
|
5314
5361
|
throw new GithubError("pending review exceeds the 1000-thread lookup limit; export stopped without changes", 502);
|
|
5315
5362
|
}
|
|
5363
|
+
/** The anchor key of a thread already in the review, or null for one GitHub reports without a position. */
|
|
5364
|
+
function pendingKey(t) {
|
|
5365
|
+
const path = t.path;
|
|
5366
|
+
if (t.subjectType === "FILE") return anchorKey({
|
|
5367
|
+
path,
|
|
5368
|
+
subject_type: "file",
|
|
5369
|
+
body: ""
|
|
5370
|
+
});
|
|
5371
|
+
if (t.line == null) return null;
|
|
5372
|
+
return anchorKey({
|
|
5373
|
+
path,
|
|
5374
|
+
side: t.diffSide === "LEFT" ? "LEFT" : "RIGHT",
|
|
5375
|
+
line: t.line,
|
|
5376
|
+
start_line: t.startLine ?? void 0,
|
|
5377
|
+
body: ""
|
|
5378
|
+
});
|
|
5379
|
+
}
|
|
5316
5380
|
const UPDATE_COMMENT = `mutation($input:UpdatePullRequestReviewCommentInput!){updatePullRequestReviewComment(input:$input){pullRequestReviewComment{id}}}`;
|
|
5317
|
-
/**
|
|
5381
|
+
/**
|
|
5382
|
+
* No `event` in the body, so GitHub keeps the new review pending with all of its comments.
|
|
5383
|
+
* Resolves the review's node id, for the comments REST cannot carry.
|
|
5384
|
+
*/
|
|
5318
5385
|
async function createPendingReview(run, cwd, repository, number, review) {
|
|
5319
|
-
await run([
|
|
5386
|
+
const out = await run([
|
|
5320
5387
|
"api",
|
|
5321
5388
|
"--method",
|
|
5322
5389
|
"POST",
|
|
@@ -5327,20 +5394,31 @@ async function createPendingReview(run, cwd, repository, number, review) {
|
|
|
5327
5394
|
cwd,
|
|
5328
5395
|
input: JSON.stringify(review)
|
|
5329
5396
|
});
|
|
5397
|
+
let id;
|
|
5398
|
+
try {
|
|
5399
|
+
id = JSON.parse(out).node_id;
|
|
5400
|
+
} catch {
|
|
5401
|
+
throw new GithubError(`unexpected output from gh api: ${out.slice(0, 200)}`, 502);
|
|
5402
|
+
}
|
|
5403
|
+
if (typeof id !== "string") throw new GithubError(`unexpected output from gh api: ${out.slice(0, 200)}`, 502);
|
|
5404
|
+
return { id };
|
|
5330
5405
|
}
|
|
5331
5406
|
const ADD_THREAD = `mutation($input:AddPullRequestReviewThreadInput!){addPullRequestReviewThread(input:$input){thread{id}}}`;
|
|
5332
|
-
/** Appends one thread through GraphQL; REST cannot extend a pending review. */
|
|
5407
|
+
/** Appends one thread through GraphQL; REST cannot extend a pending review, nor place a file comment. */
|
|
5333
5408
|
async function addToPendingReview(run, cwd, reviewId, c) {
|
|
5334
5409
|
const input = {
|
|
5335
5410
|
pullRequestReviewId: reviewId,
|
|
5336
5411
|
path: c.path,
|
|
5337
|
-
line: c.line,
|
|
5338
|
-
side: c.side,
|
|
5339
5412
|
body: c.body
|
|
5340
5413
|
};
|
|
5341
|
-
if (c.
|
|
5342
|
-
|
|
5343
|
-
input.
|
|
5414
|
+
if (isFileComment(c)) input.subjectType = "FILE";
|
|
5415
|
+
else {
|
|
5416
|
+
input.line = c.line;
|
|
5417
|
+
input.side = c.side;
|
|
5418
|
+
if (c.start_line != null) {
|
|
5419
|
+
input.startLine = c.start_line;
|
|
5420
|
+
input.startSide = c.start_side;
|
|
5421
|
+
}
|
|
5344
5422
|
}
|
|
5345
5423
|
await graphql(run, cwd, ADD_THREAD, { input });
|
|
5346
5424
|
}
|
|
@@ -11164,7 +11242,7 @@ const ChangedFileSchema = object({
|
|
|
11164
11242
|
binary: boolean(),
|
|
11165
11243
|
/** New-side blob sha. '' for deletions. Keys the viewed state. */
|
|
11166
11244
|
blob: string(),
|
|
11167
|
-
/**
|
|
11245
|
+
/** The `linguist-generated` gitattribute, a path pattern or a content sniff said this file is generated. Feeds auto-collapse and risk ranking. */
|
|
11168
11246
|
generated: boolean(),
|
|
11169
11247
|
/** A gitlink (mode 160000): `blob` is the recorded commit, the patch shows the commit-id change, and there is no file to open. */
|
|
11170
11248
|
submodule: literal(true).optional()
|
|
@@ -11192,7 +11270,8 @@ object({
|
|
|
11192
11270
|
contents: string(),
|
|
11193
11271
|
binary: boolean()
|
|
11194
11272
|
});
|
|
11195
|
-
const CommentAnchorSchema = object({
|
|
11273
|
+
const CommentAnchorSchema = discriminatedUnion("kind", [object({
|
|
11274
|
+
kind: literal("line"),
|
|
11196
11275
|
path: string(),
|
|
11197
11276
|
/** Column the user selected in a diff; 'new' for file items. */
|
|
11198
11277
|
side: SideSchema,
|
|
@@ -11200,7 +11279,11 @@ const CommentAnchorSchema = object({
|
|
|
11200
11279
|
endLine: number(),
|
|
11201
11280
|
/** Exact text of [startLine, endLine] at creation. */
|
|
11202
11281
|
quoted: string()
|
|
11203
|
-
})
|
|
11282
|
+
}), object({
|
|
11283
|
+
kind: literal("file"),
|
|
11284
|
+
/** The changed file's path (its new path for a rename), or any tree path. */
|
|
11285
|
+
path: string()
|
|
11286
|
+
})]);
|
|
11204
11287
|
const CommentMessageSchema = object({
|
|
11205
11288
|
id: string(),
|
|
11206
11289
|
body: string(),
|
|
@@ -11214,24 +11297,31 @@ object({
|
|
|
11214
11297
|
messages: CommentMessageSchema.array().min(1),
|
|
11215
11298
|
resolved: boolean(),
|
|
11216
11299
|
resolvedAt: number().optional(),
|
|
11217
|
-
/** Relocation failed after a snapshot refresh. */
|
|
11300
|
+
/** Relocation failed after a snapshot refresh: the lines left the diff, or the file left the review. */
|
|
11218
11301
|
stale: boolean(),
|
|
11219
|
-
/** Original startLine, shown in export when stale. */
|
|
11302
|
+
/** Original startLine of a line thread, shown in export when stale. */
|
|
11220
11303
|
staleFromLine: number().optional()
|
|
11221
11304
|
});
|
|
11222
|
-
/**
|
|
11305
|
+
/**
|
|
11306
|
+
* Create payload. Without `startLine` the thread is on the file as a whole; then `side`,
|
|
11307
|
+
* `endLine` and `quoted` are refused rather than ignored, so a payload that meant a line
|
|
11308
|
+
* never lands on the file. `quoted` is optional: the server quotes the range from the snapshot.
|
|
11309
|
+
*/
|
|
11223
11310
|
const ThreadCreateSchema = object({
|
|
11224
11311
|
path: string().min(1),
|
|
11225
11312
|
/** Default 'new'. */
|
|
11226
11313
|
side: SideSchema.optional(),
|
|
11227
|
-
startLine: number().int().positive(),
|
|
11314
|
+
startLine: number().int().positive().optional(),
|
|
11228
11315
|
/** Default startLine. */
|
|
11229
11316
|
endLine: number().int().positive().optional(),
|
|
11230
11317
|
body: string().refine((body) => body.trim().length > 0, "body required"),
|
|
11231
11318
|
quoted: string().optional()
|
|
11232
|
-
}).refine((t) => t.endLine == null || t.endLine >= t.startLine, {
|
|
11319
|
+
}).refine((t) => t.startLine == null || t.endLine == null || t.endLine >= t.startLine, {
|
|
11233
11320
|
path: ["endLine"],
|
|
11234
11321
|
message: "endLine must be ≥ startLine"
|
|
11322
|
+
}).refine((t) => t.startLine != null || t.side == null && t.endLine == null && t.quoted == null, {
|
|
11323
|
+
path: ["startLine"],
|
|
11324
|
+
message: "side, endLine and quoted need a startLine; leave all four out for a comment on the whole file"
|
|
11235
11325
|
});
|
|
11236
11326
|
const ReplyCreateSchema = object({ body: string().refine((body) => body.trim().length > 0, "body required") });
|
|
11237
11327
|
const ThreadStateSchema = _enum([
|
|
@@ -15780,38 +15870,39 @@ const LOOPBACK = /* @__PURE__ */ new Set([
|
|
|
15780
15870
|
"::1",
|
|
15781
15871
|
"localhost"
|
|
15782
15872
|
]);
|
|
15873
|
+
const HINT = "if a reverse proxy sent it, trust that origin with --allowed-origin";
|
|
15783
15874
|
/**
|
|
15784
|
-
*
|
|
15785
|
-
*
|
|
15786
|
-
*
|
|
15787
|
-
* loopback too. On any other bind, Host must be an IP literal, loopback, the
|
|
15788
|
-
* bind host, or one of `names` (the machine's own hostnames by default). Both
|
|
15789
|
-
* rules block DNS rebinding: an attacker's domain is never an IP literal and
|
|
15790
|
-
* never one of ours.
|
|
15875
|
+
* Restricts API and WebSocket requests to local hosts and the configured public origin.
|
|
15876
|
+
* Proxies may preserve the public Host or rewrite it to an allowed upstream Host.
|
|
15877
|
+
* Forwarded headers never grant trust; a public Origin must be explicitly configured.
|
|
15791
15878
|
*/
|
|
15792
|
-
function requestGuard(bindHost, names = ownNames()) {
|
|
15879
|
+
function requestGuard(bindHost, names = ownNames(), allowedOrigin) {
|
|
15793
15880
|
const loopback = LOOPBACK.has(bindHost);
|
|
15794
15881
|
const allowed = /* @__PURE__ */ new Set([
|
|
15795
15882
|
...LOOPBACK,
|
|
15796
15883
|
bindHost.toLowerCase(),
|
|
15797
15884
|
...names.map((n) => n.toLowerCase())
|
|
15798
15885
|
]);
|
|
15886
|
+
const publicHost = allowedOrigin == null ? void 0 : new URL(allowedOrigin).host;
|
|
15799
15887
|
return ({ host, origin }) => {
|
|
15800
|
-
if (!host) return
|
|
15801
|
-
if (origin != null) {
|
|
15802
|
-
let originHost;
|
|
15803
|
-
try {
|
|
15804
|
-
originHost = new URL(origin).host;
|
|
15805
|
-
} catch {
|
|
15806
|
-
return false;
|
|
15807
|
-
}
|
|
15808
|
-
if (originHost !== host) return false;
|
|
15809
|
-
}
|
|
15888
|
+
if (!host) return "missing Host header";
|
|
15810
15889
|
const name = hostnameOf(host).toLowerCase();
|
|
15811
|
-
|
|
15812
|
-
|
|
15890
|
+
const localHost = loopback ? LOOPBACK.has(name) : isIP(name) !== 0 || allowed.has(name);
|
|
15891
|
+
const proxyHost = publicHost != null && host.toLowerCase() === publicHost;
|
|
15892
|
+
if (!localHost && !proxyHost) return `forbidden Host "${host}"; ${HINT}`;
|
|
15893
|
+
if (origin == null || allowedOrigin != null && origin === allowedOrigin) return null;
|
|
15894
|
+
if (!proxyHost && parseHost(origin) === host) return null;
|
|
15895
|
+
return `forbidden Origin "${origin}" for Host "${host}"; ${HINT}`;
|
|
15813
15896
|
};
|
|
15814
15897
|
}
|
|
15898
|
+
/** The host of a syntactically valid origin; undefined for `null` and other non-URLs. */
|
|
15899
|
+
function parseHost(origin) {
|
|
15900
|
+
try {
|
|
15901
|
+
return new URL(origin).host;
|
|
15902
|
+
} catch {
|
|
15903
|
+
return;
|
|
15904
|
+
}
|
|
15905
|
+
}
|
|
15815
15906
|
/** The machine's hostname, its short form, and its mDNS name, so LAN visitors can use any of them. */
|
|
15816
15907
|
function ownNames() {
|
|
15817
15908
|
const full = hostname();
|
|
@@ -15881,15 +15972,17 @@ function toThreadCreate(entry, where) {
|
|
|
15881
15972
|
}
|
|
15882
15973
|
/** True when `t` duplicates an open thread at the same anchor whose opening message has the same body. */
|
|
15883
15974
|
function isDuplicate(existing, t) {
|
|
15975
|
+
const body = t.body.trim();
|
|
15976
|
+
const open = existing.filter((e) => !e.resolved && e.anchor.path === t.path && e.messages[0]?.body.trim() === body);
|
|
15977
|
+
if (t.startLine == null) return open.some((e) => e.anchor.kind === "file");
|
|
15884
15978
|
const side = t.side ?? "new";
|
|
15885
15979
|
const endLine = t.endLine ?? t.startLine;
|
|
15886
|
-
|
|
15887
|
-
return existing.some((e) => !e.resolved && e.anchor.path === t.path && e.anchor.side === side && e.anchor.startLine === t.startLine && e.anchor.endLine === endLine && e.messages[0]?.body.trim() === body);
|
|
15980
|
+
return open.some((e) => e.anchor.kind === "line" && e.anchor.side === side && e.anchor.startLine === t.startLine && e.anchor.endLine === endLine);
|
|
15888
15981
|
}
|
|
15889
15982
|
//#endregion
|
|
15890
15983
|
//#region src/server/comments/CommentStore.ts
|
|
15891
15984
|
var NotFoundError = class extends Error {};
|
|
15892
|
-
/**
|
|
15985
|
+
/** An anchor the snapshot cannot place: path absent on that side, or lines past the end. */
|
|
15893
15986
|
var UnquotableError = class extends Error {};
|
|
15894
15987
|
/**
|
|
15895
15988
|
* Owns this mode's set in `<gitDir>/diffle/comments.json`: threads and viewed
|
|
@@ -15906,14 +15999,14 @@ var CommentStore = class CommentStore {
|
|
|
15906
15999
|
this.file = file;
|
|
15907
16000
|
this.key = key;
|
|
15908
16001
|
}
|
|
15909
|
-
/** Opens the store;
|
|
16002
|
+
/** Opens the store; an older file is backed up to `comments.v<n>.bak` and rewritten in the current format first. */
|
|
15910
16003
|
static async open(gitDir, modeKey) {
|
|
15911
16004
|
const file = join(gitDir, "diffle", "comments.json");
|
|
15912
16005
|
await withFileLock(file, async () => {
|
|
15913
16006
|
const all = await readStoreFile(file);
|
|
15914
|
-
if (all.version
|
|
15915
|
-
await copyFile(file, join(gitDir, "diffle",
|
|
15916
|
-
await writeStoreFile(file,
|
|
16007
|
+
if (all.version === 3) return;
|
|
16008
|
+
await copyFile(file, join(gitDir, "diffle", `comments.v${all.version}.bak`));
|
|
16009
|
+
await writeStoreFile(file, upgrade(all));
|
|
15917
16010
|
});
|
|
15918
16011
|
return new CommentStore(file, modeKey);
|
|
15919
16012
|
}
|
|
@@ -15989,12 +16082,13 @@ var CommentStore = class CommentStore {
|
|
|
15989
16082
|
});
|
|
15990
16083
|
}
|
|
15991
16084
|
/**
|
|
15992
|
-
* Adds each import that is not already an open duplicate. Every
|
|
15993
|
-
* checked against the snapshot through `
|
|
15994
|
-
* text the browser showed
|
|
15995
|
-
*
|
|
16085
|
+
* Adds each import that is not already an open duplicate. Every anchor is
|
|
16086
|
+
* checked against the snapshot through `source`: a range is quoted (a payload's
|
|
16087
|
+
* own `quoted`, the text the browser showed, is kept once the range is known to
|
|
16088
|
+
* exist) and a file thread's path must be in the review. Throws UnquotableError,
|
|
16089
|
+
* adding nothing, when any anchor cannot be placed.
|
|
15996
16090
|
*/
|
|
15997
|
-
importThreads(imports,
|
|
16091
|
+
importThreads(imports, source) {
|
|
15998
16092
|
return this.mutate(async (set) => {
|
|
15999
16093
|
const added = [];
|
|
16000
16094
|
const pending = [];
|
|
@@ -16005,18 +16099,7 @@ var CommentStore = class CommentStore {
|
|
|
16005
16099
|
skipped++;
|
|
16006
16100
|
continue;
|
|
16007
16101
|
}
|
|
16008
|
-
const
|
|
16009
|
-
const startLine = t.startLine;
|
|
16010
|
-
const endLine = t.endLine ?? startLine;
|
|
16011
|
-
const fromSnapshot = await quote(t.path, side, startLine, endLine);
|
|
16012
|
-
if (fromSnapshot == null) throw new UnquotableError(`cannot quote ${t.path}:${startLine}${endLine !== startLine ? `-${endLine}` : ""} on the ${side} side`);
|
|
16013
|
-
const anchor = {
|
|
16014
|
-
path: t.path,
|
|
16015
|
-
side,
|
|
16016
|
-
startLine,
|
|
16017
|
-
endLine,
|
|
16018
|
-
quoted: t.quoted ?? fromSnapshot
|
|
16019
|
-
};
|
|
16102
|
+
const anchor = await placeImport(t, source);
|
|
16020
16103
|
pending.push({
|
|
16021
16104
|
anchor,
|
|
16022
16105
|
t
|
|
@@ -16097,17 +16180,18 @@ var CommentStore = class CommentStore {
|
|
|
16097
16180
|
});
|
|
16098
16181
|
}
|
|
16099
16182
|
/**
|
|
16100
|
-
* Re-anchor every thread against the current review. A thread is stale
|
|
16101
|
-
* its side is gone, its text is not found, or the relocated range falls
|
|
16102
|
-
* outside what the review shows
|
|
16183
|
+
* Re-anchor every thread against the current review. A line thread is stale
|
|
16184
|
+
* when its side is gone, its text is not found, or the relocated range falls
|
|
16185
|
+
* outside what the review shows; a file thread when its file left the review.
|
|
16186
|
+
* Returns true if anything changed.
|
|
16103
16187
|
*/
|
|
16104
|
-
async relocateAll(
|
|
16188
|
+
async relocateAll(review) {
|
|
16105
16189
|
const cache = /* @__PURE__ */ new Map();
|
|
16106
16190
|
const load = (path, side) => {
|
|
16107
16191
|
const k = `${side}:${path}`;
|
|
16108
16192
|
let p = cache.get(k);
|
|
16109
16193
|
if (!p) {
|
|
16110
|
-
p =
|
|
16194
|
+
p = review.side(path, side);
|
|
16111
16195
|
cache.set(k, p);
|
|
16112
16196
|
}
|
|
16113
16197
|
return p;
|
|
@@ -16115,6 +16199,14 @@ var CommentStore = class CommentStore {
|
|
|
16115
16199
|
return this.mutate(async (set) => {
|
|
16116
16200
|
let changed = false;
|
|
16117
16201
|
for (const t of set.threads) {
|
|
16202
|
+
if (t.anchor.kind === "file") {
|
|
16203
|
+
const stale = !review.hasFile(t.anchor.path);
|
|
16204
|
+
if (stale !== t.stale) {
|
|
16205
|
+
t.stale = stale;
|
|
16206
|
+
changed = true;
|
|
16207
|
+
}
|
|
16208
|
+
continue;
|
|
16209
|
+
}
|
|
16118
16210
|
const v = await load(t.anchor.path, t.anchor.side);
|
|
16119
16211
|
let moved = v == null ? null : relocate(t.anchor, v.contents);
|
|
16120
16212
|
if (moved && v?.shown && !isShown(v.shown, moved.startLine, moved.endLine)) moved = null;
|
|
@@ -16151,7 +16243,7 @@ var CommentStore = class CommentStore {
|
|
|
16151
16243
|
viewed: []
|
|
16152
16244
|
};
|
|
16153
16245
|
}
|
|
16154
|
-
return ownSet(
|
|
16246
|
+
return ownSet(upgrade(parsed), this.key);
|
|
16155
16247
|
}
|
|
16156
16248
|
/**
|
|
16157
16249
|
* Locked read-modify-write: `fn` edits this key's set as read from disk under
|
|
@@ -16161,12 +16253,11 @@ var CommentStore = class CommentStore {
|
|
|
16161
16253
|
*/
|
|
16162
16254
|
mutate(fn, changed = () => true) {
|
|
16163
16255
|
return withFileLock(this.file, async () => {
|
|
16164
|
-
const
|
|
16165
|
-
const all = onDisk.version === 2 ? onDisk : migrateV1(onDisk);
|
|
16256
|
+
const all = upgrade(await readStoreFile(this.file));
|
|
16166
16257
|
const set = ownSet(all, this.key);
|
|
16167
16258
|
const r = await fn(set);
|
|
16168
16259
|
if (changed(r)) await writeStoreFile(this.file, {
|
|
16169
|
-
version:
|
|
16260
|
+
version: 3,
|
|
16170
16261
|
sets: {
|
|
16171
16262
|
...all.sets,
|
|
16172
16263
|
[this.key]: set
|
|
@@ -16188,7 +16279,33 @@ function findThread(set, id) {
|
|
|
16188
16279
|
if (!t) throw new NotFoundError(id);
|
|
16189
16280
|
return t;
|
|
16190
16281
|
}
|
|
16191
|
-
/**
|
|
16282
|
+
/**
|
|
16283
|
+
* The anchor an import lands on, checked against the snapshot. Throws
|
|
16284
|
+
* UnquotableError for a range that cannot be quoted or a file the review lacks.
|
|
16285
|
+
*/
|
|
16286
|
+
async function placeImport(t, source) {
|
|
16287
|
+
if (t.startLine == null) {
|
|
16288
|
+
if (!await source.hasFile(t.path)) throw new UnquotableError(`${t.path} is not in the review`);
|
|
16289
|
+
return {
|
|
16290
|
+
kind: "file",
|
|
16291
|
+
path: t.path
|
|
16292
|
+
};
|
|
16293
|
+
}
|
|
16294
|
+
const side = t.side ?? "new";
|
|
16295
|
+
const startLine = t.startLine;
|
|
16296
|
+
const endLine = t.endLine ?? startLine;
|
|
16297
|
+
const fromSnapshot = await source.quote(t.path, side, startLine, endLine);
|
|
16298
|
+
if (fromSnapshot == null) throw new UnquotableError(`cannot quote ${t.path}:${startLine}${endLine !== startLine ? `-${endLine}` : ""} on the ${side} side`);
|
|
16299
|
+
return {
|
|
16300
|
+
kind: "line",
|
|
16301
|
+
path: t.path,
|
|
16302
|
+
side,
|
|
16303
|
+
startLine,
|
|
16304
|
+
endLine,
|
|
16305
|
+
quoted: t.quoted ?? fromSnapshot
|
|
16306
|
+
};
|
|
16307
|
+
}
|
|
16308
|
+
/** The file as stored, or an empty store when absent. Malformed JSON propagates. */
|
|
16192
16309
|
async function readStoreFile(file) {
|
|
16193
16310
|
try {
|
|
16194
16311
|
return parseStoreFile(await readFile(file, "utf8"));
|
|
@@ -16196,20 +16313,26 @@ async function readStoreFile(file) {
|
|
|
16196
16313
|
if (e.code !== "ENOENT") throw e;
|
|
16197
16314
|
}
|
|
16198
16315
|
return {
|
|
16199
|
-
version:
|
|
16316
|
+
version: 3,
|
|
16200
16317
|
sets: {}
|
|
16201
16318
|
};
|
|
16202
16319
|
}
|
|
16203
|
-
/** Unknown shapes read as an empty
|
|
16320
|
+
/** Unknown shapes read as an empty store. */
|
|
16204
16321
|
function parseStoreFile(text) {
|
|
16205
16322
|
const parsed = JSON.parse(text);
|
|
16323
|
+
if (parsed.version === 3 && parsed.sets) return parsed;
|
|
16206
16324
|
if (parsed.version === 2 && parsed.sets) return parsed;
|
|
16207
16325
|
if (parsed.version === 1 && parsed.sets) return parsed;
|
|
16208
16326
|
return {
|
|
16209
|
-
version:
|
|
16327
|
+
version: 3,
|
|
16210
16328
|
sets: {}
|
|
16211
16329
|
};
|
|
16212
16330
|
}
|
|
16331
|
+
/** Any stored format as the current one. */
|
|
16332
|
+
function upgrade(all) {
|
|
16333
|
+
if (all.version === 3) return all;
|
|
16334
|
+
return migrateV2(all.version === 2 ? all : migrateV1(all));
|
|
16335
|
+
}
|
|
16213
16336
|
/** Atomic write. Quoted source lines: owner-only. */
|
|
16214
16337
|
function writeStoreFile(file, data) {
|
|
16215
16338
|
return writeFileAtomic(file, JSON.stringify(data, null, 2), 384);
|
|
@@ -16223,6 +16346,24 @@ function newMessage(msg) {
|
|
|
16223
16346
|
updatedAt: now
|
|
16224
16347
|
};
|
|
16225
16348
|
}
|
|
16349
|
+
/** Every v2 anchor is a line range; v3 says so. */
|
|
16350
|
+
function migrateV2(v2) {
|
|
16351
|
+
const sets = {};
|
|
16352
|
+
for (const [key, set] of Object.entries(v2.sets)) sets[key] = {
|
|
16353
|
+
threads: (set.threads ?? []).map((t) => ({
|
|
16354
|
+
...t,
|
|
16355
|
+
anchor: {
|
|
16356
|
+
kind: "line",
|
|
16357
|
+
...t.anchor
|
|
16358
|
+
}
|
|
16359
|
+
})),
|
|
16360
|
+
viewed: set.viewed ?? []
|
|
16361
|
+
};
|
|
16362
|
+
return {
|
|
16363
|
+
version: 3,
|
|
16364
|
+
sets
|
|
16365
|
+
};
|
|
16366
|
+
}
|
|
16226
16367
|
/** Each v1 comment becomes a single-message, unresolved thread. */
|
|
16227
16368
|
function migrateV1(v1) {
|
|
16228
16369
|
const sets = {};
|
|
@@ -16353,7 +16494,7 @@ function createApi(deps) {
|
|
|
16353
16494
|
});
|
|
16354
16495
|
app.post("/api/threads", async (c) => {
|
|
16355
16496
|
const imports = parseImports(await readJson(c));
|
|
16356
|
-
const { added } = await session.comments.importThreads(imports, session.
|
|
16497
|
+
const { added } = await session.comments.importThreads(imports, session.anchorSource());
|
|
16357
16498
|
if (added.length) hub.broadcast({ type: "threads" });
|
|
16358
16499
|
return c.json(added, 201);
|
|
16359
16500
|
});
|
|
@@ -16501,12 +16642,13 @@ var Server = class {
|
|
|
16501
16642
|
}
|
|
16502
16643
|
async listen() {
|
|
16503
16644
|
const app = new Hono();
|
|
16504
|
-
const guard = requestGuard(this.opts.host);
|
|
16645
|
+
const guard = requestGuard(this.opts.host, void 0, this.opts.allowedOrigin);
|
|
16505
16646
|
app.use("/api/*", async (c, next) => {
|
|
16506
|
-
|
|
16647
|
+
const rejected = guard({
|
|
16507
16648
|
host: c.req.header("host"),
|
|
16508
16649
|
origin: c.req.header("origin")
|
|
16509
|
-
})
|
|
16650
|
+
});
|
|
16651
|
+
if (rejected != null) return c.json({ error: rejected }, 403);
|
|
16510
16652
|
await next();
|
|
16511
16653
|
});
|
|
16512
16654
|
app.use("/api/*", async (c, next) => {
|
|
@@ -16896,6 +17038,8 @@ function looksGenerated(path, head) {
|
|
|
16896
17038
|
//#endregion
|
|
16897
17039
|
//#region src/server/Snapshotter.ts
|
|
16898
17040
|
const PREWARM = 3;
|
|
17041
|
+
/** The gitattribute GitHub reads to collapse generated files; `=false` opts a file out of the heuristics. */
|
|
17042
|
+
const GENERATED_ATTR = "linguist-generated";
|
|
16899
17043
|
/** Concurrent worktree reads while flagging generated files. */
|
|
16900
17044
|
const SNIFF_CONCURRENCY = 8;
|
|
16901
17045
|
/** Sniff verdicts remembered by blob sha; the map is emptied, not evicted, past this. */
|
|
@@ -17004,14 +17148,23 @@ var Snapshotter = class {
|
|
|
17004
17148
|
return snap;
|
|
17005
17149
|
}
|
|
17006
17150
|
/**
|
|
17007
|
-
* Sets `generated` on each non-binary changed file
|
|
17008
|
-
*
|
|
17009
|
-
*
|
|
17010
|
-
*
|
|
17151
|
+
* Sets `generated` on each non-binary changed file. A `linguist-generated`
|
|
17152
|
+
* gitattribute on the new side decides outright, as on GitHub; otherwise a
|
|
17153
|
+
* path convention, then a SNIFF_BYTES content sniff. Blobs already sniffed
|
|
17154
|
+
* are not read again; commit-side blobs go through one `cat-file --batch`,
|
|
17155
|
+
* worktree files through a bounded read each. Never fails the snapshot: a
|
|
17156
|
+
* failed attribute lookup or read means the next heuristic, then false.
|
|
17011
17157
|
* Submodules have no contents to sniff.
|
|
17012
17158
|
*/
|
|
17013
17159
|
async fillGenerated(files, newSha) {
|
|
17014
|
-
const
|
|
17160
|
+
const textual = files.filter((f) => f.status !== "D" && !f.binary && !f.submodule);
|
|
17161
|
+
const marked = await this.repo.attr(GENERATED_ATTR, textual.map((f) => f.path), newSha).catch(() => /* @__PURE__ */ new Map());
|
|
17162
|
+
const sniff = [];
|
|
17163
|
+
for (const f of textual) {
|
|
17164
|
+
const attr = marked.get(f.path);
|
|
17165
|
+
if (attr != null) f.generated = attr;
|
|
17166
|
+
else if (!(f.generated = looksGenerated(f.path, null))) sniff.push(f);
|
|
17167
|
+
}
|
|
17015
17168
|
const unknown = [];
|
|
17016
17169
|
for (const f of sniff) {
|
|
17017
17170
|
const known = f.blob ? this.sniffed.get(f.blob) : void 0;
|
|
@@ -17236,21 +17389,24 @@ var Session = class {
|
|
|
17236
17389
|
/**
|
|
17237
17390
|
* Re-anchors threads against what `snap` shows. A changed file shows its
|
|
17238
17391
|
* hunks; an unchanged tree file shows its new side whole (the file view) and
|
|
17239
|
-
* nothing of its old side.
|
|
17392
|
+
* nothing of its old side. A file thread only needs its file in the review.
|
|
17240
17393
|
*/
|
|
17241
17394
|
async relocateComments(a, snap) {
|
|
17242
|
-
await a.comments.relocateAll(
|
|
17243
|
-
|
|
17244
|
-
|
|
17245
|
-
|
|
17246
|
-
|
|
17247
|
-
|
|
17248
|
-
|
|
17249
|
-
|
|
17250
|
-
|
|
17251
|
-
|
|
17252
|
-
|
|
17253
|
-
|
|
17395
|
+
await a.comments.relocateAll({
|
|
17396
|
+
side: async (path, side) => {
|
|
17397
|
+
const buf = await this.readSide(snap, path, side);
|
|
17398
|
+
if (buf == null) return null;
|
|
17399
|
+
const contents = buf.toString("utf8");
|
|
17400
|
+
if (!snap.changed.some((f) => f.path === path)) return side === "new" ? {
|
|
17401
|
+
contents,
|
|
17402
|
+
shown: null
|
|
17403
|
+
} : null;
|
|
17404
|
+
return {
|
|
17405
|
+
contents,
|
|
17406
|
+
shown: shownRanges(await a.snapshotter.patch(path) ?? "", side)
|
|
17407
|
+
};
|
|
17408
|
+
},
|
|
17409
|
+
hasFile: (path) => this.hasFile(snap, path)
|
|
17254
17410
|
});
|
|
17255
17411
|
}
|
|
17256
17412
|
/**
|
|
@@ -17269,15 +17425,24 @@ var Session = class {
|
|
|
17269
17425
|
hasSide(snap, path, side) {
|
|
17270
17426
|
return sidePath(this.readablePaths(snap), path, side) != null;
|
|
17271
17427
|
}
|
|
17428
|
+
/** Whether `path` is a file of the review: a changed file (by its new path) or a tree path. A rename's old path is not. */
|
|
17429
|
+
hasFile(snap, path) {
|
|
17430
|
+
const r = this.readablePaths(snap);
|
|
17431
|
+
return r.tree.has(path) || r.changed.has(path);
|
|
17432
|
+
}
|
|
17272
17433
|
/**
|
|
17273
|
-
*
|
|
17274
|
-
*
|
|
17434
|
+
* Places imported anchors against the current snapshot: quotes a line range for
|
|
17435
|
+
* imports that carry no `quoted`, and says whether a file thread's file is in the
|
|
17436
|
+
* review. Goes through `readSide`, so the allowlist applies.
|
|
17275
17437
|
*/
|
|
17276
|
-
|
|
17277
|
-
return
|
|
17278
|
-
|
|
17279
|
-
|
|
17280
|
-
|
|
17438
|
+
anchorSource() {
|
|
17439
|
+
return {
|
|
17440
|
+
quote: async (path, side, startLine, endLine) => {
|
|
17441
|
+
const snap = await this.snapshotter.current();
|
|
17442
|
+
const buf = await this.readSide(snap, path, side);
|
|
17443
|
+
return buf == null ? null : quoteRange(buf.toString("utf8"), startLine, endLine);
|
|
17444
|
+
},
|
|
17445
|
+
hasFile: async (path) => this.hasFile(await this.snapshotter.current(), path)
|
|
17281
17446
|
};
|
|
17282
17447
|
}
|
|
17283
17448
|
readablePaths(snap) {
|
|
@@ -17329,7 +17494,7 @@ var Session = class {
|
|
|
17329
17494
|
}
|
|
17330
17495
|
};
|
|
17331
17496
|
async function defaultWatcherFactory() {
|
|
17332
|
-
const { Watcher } = await import("./Watcher-
|
|
17497
|
+
const { Watcher } = await import("./Watcher-CKwvGuPB.js");
|
|
17333
17498
|
return (target) => new Watcher(target);
|
|
17334
17499
|
}
|
|
17335
17500
|
function readablePaths(snap) {
|
|
@@ -21017,14 +21182,16 @@ var WsHub = class {
|
|
|
21017
21182
|
maxPayload: MAX_PAYLOAD_BYTES
|
|
21018
21183
|
});
|
|
21019
21184
|
clientListeners = /* @__PURE__ */ new Set();
|
|
21020
|
-
attach(server, guard = () =>
|
|
21185
|
+
attach(server, guard = () => null) {
|
|
21021
21186
|
server.on("upgrade", (req, socket, head) => {
|
|
21022
21187
|
if (new URL(req.url ?? "/", "http://localhost").pathname !== "/ws") return;
|
|
21023
|
-
|
|
21188
|
+
const rejected = guard({
|
|
21024
21189
|
host: req.headers.host,
|
|
21025
21190
|
origin: req.headers.origin
|
|
21026
|
-
})
|
|
21027
|
-
|
|
21191
|
+
});
|
|
21192
|
+
if (rejected != null) {
|
|
21193
|
+
socket.once("finish", () => socket.destroy());
|
|
21194
|
+
socket.end(`HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Type: text/plain\r\n\r\n${rejected}\n`);
|
|
21028
21195
|
return;
|
|
21029
21196
|
}
|
|
21030
21197
|
this.wss.handleUpgrade(req, socket, head, (ws) => {
|
|
@@ -21067,6 +21234,16 @@ function intArg(min, max) {
|
|
|
21067
21234
|
}
|
|
21068
21235
|
const parsePort = intArg(0, 65535);
|
|
21069
21236
|
const parseContext = intArg(0, 1e4);
|
|
21237
|
+
/** Accepts an HTTP(S) origin without credentials, a path prefix, query, or fragment. */
|
|
21238
|
+
function parseAllowedOrigin(raw) {
|
|
21239
|
+
try {
|
|
21240
|
+
const url = new URL(raw);
|
|
21241
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.pathname !== "/" || url.search || url.hash || !/^https?:\/\/[^/?#]+\/?$/i.test(raw)) throw new Error("invalid origin");
|
|
21242
|
+
return url.origin;
|
|
21243
|
+
} catch {
|
|
21244
|
+
throw new InvalidArgumentError("expected an HTTP(S) origin, e.g. https://proxy.example (without a path)");
|
|
21245
|
+
}
|
|
21246
|
+
}
|
|
21070
21247
|
/** Commander parser for a language diffle knows; anything else lists the ones it does. */
|
|
21071
21248
|
function parseLanguage(raw) {
|
|
21072
21249
|
const language = raw.trim().toLowerCase();
|
|
@@ -21238,7 +21415,7 @@ const c = {
|
|
|
21238
21415
|
bold: paint("1"),
|
|
21239
21416
|
dim: paint("2")
|
|
21240
21417
|
};
|
|
21241
|
-
const program = new Command().name("diffle").description("Review a git diff in the browser and export line comments as an agent prompt.").version(version$1, "-v, --version", "print the version and exit").addOption(completionHint(new Option("-C <path>", "run as if started in <path> (any directory inside a git worktree)"), { kind: "directory" })).option("-p, --port <port>", `port to listen on (default: ${DEFAULT_PORT}, or the next free one; 0 = random)`, parsePort).option("-H, --host <host>", "address to bind; use 0.0.0.0 to expose on the network", "127.0.0.1").option("--no-open", "do not open a browser").option("--keep-alive", "keep the server running after all browser tabs close").option("--no-watch", "do not watch for changes").addOption(new Option("--auto-viewed <glob>", "mark matching files viewed for this session (repeatable)").argParser(collect).default([], "none")).option("-U, --context <n>", "context lines around changes for this session (default: config, 5)", parseContext).addOption(new Option("--lsp <language=command>", "language server command for one language, started for this run (repeatable); by default the diff's languages are served by whatever is on PATH").argParser(collectLspOverride).default(true, "the diff's languages, resolved on PATH")).option("--no-lsp", "do not start any language server").option("--timing", "print startup phase timings to stderr").option("--dev", "serve the client through Vite (development)", process.env.DIFFLE_DEV === "1" ? true : void 0).addArgument(completionHint(new Argument("[revs...]", "git-diff style revisions: <rev> | <a>..<b> | <a>...<b> | <a> <b>"), {
|
|
21418
|
+
const program = new Command().exitOverride().name("diffle").description("Review a git diff in the browser and export line comments as an agent prompt.").version(version$1, "-v, --version", "print the version and exit").addOption(completionHint(new Option("-C <path>", "run as if started in <path> (any directory inside a git worktree)"), { kind: "directory" })).option("-p, --port <port>", `port to listen on (default: ${DEFAULT_PORT}, or the next free one; 0 = random)`, parsePort).option("-H, --host <host>", "address to bind; use 0.0.0.0 to expose on the network", "127.0.0.1").option("--allowed-origin <origin>", "trusted public HTTP(S) origin behind a reverse proxy (without a path)", parseAllowedOrigin).option("--no-open", "do not open a browser").option("--keep-alive", "keep the server running after all browser tabs close").option("--no-watch", "do not watch for changes").addOption(new Option("--auto-viewed <glob>", "mark matching files viewed for this session (repeatable)").argParser(collect).default([], "none")).option("-U, --context <n>", "context lines around changes for this session (default: config, 5)", parseContext).addOption(new Option("--lsp <language=command>", "language server command for one language, started for this run (repeatable); by default the diff's languages are served by whatever is on PATH").argParser(collectLspOverride).default(true, "the diff's languages, resolved on PATH")).option("--no-lsp", "do not start any language server").option("--timing", "print startup phase timings to stderr").option("--dev", "serve the client through Vite (development)", process.env.DIFFLE_DEV === "1" ? true : void 0).addArgument(completionHint(new Argument("[revs...]", "git-diff style revisions: <rev> | <a>..<b> | <a>...<b> | <a> <b>"), {
|
|
21242
21419
|
kind: "choices",
|
|
21243
21420
|
values: ["working", "pr"]
|
|
21244
21421
|
})).addHelpText("after", `
|
|
@@ -21374,6 +21551,7 @@ async function serve(req, opts, repo, closeRepo, config, timing) {
|
|
|
21374
21551
|
port: opts.port ?? 4966,
|
|
21375
21552
|
probe: opts.port == null,
|
|
21376
21553
|
host: opts.host,
|
|
21554
|
+
allowedOrigin: opts.allowedOrigin,
|
|
21377
21555
|
dev: opts.dev || !hasClientBuild()
|
|
21378
21556
|
});
|
|
21379
21557
|
let stopBrowserWatch = () => {};
|
|
@@ -21471,9 +21649,8 @@ function startLsp(repo, session, hub, overrides, preload) {
|
|
|
21471
21649
|
});
|
|
21472
21650
|
return lsp;
|
|
21473
21651
|
}
|
|
21474
|
-
program.exitOverride();
|
|
21475
21652
|
program.parseAsync(process.argv).catch((e) => {
|
|
21476
|
-
if (e instanceof CommanderError) process.exit(e.exitCode);
|
|
21653
|
+
if (e instanceof CommanderError) process.exit(e.exitCode === 0 ? 0 : 2);
|
|
21477
21654
|
console.error(`${c.red("✖")} ${e instanceof Error ? e.message : e}`);
|
|
21478
21655
|
process.exit(e instanceof RevspecError || e instanceof GitError || e instanceof GithubError ? 2 : 1);
|
|
21479
21656
|
});
|