@moritzwilksch/diffle 0.1.4 → 0.1.6
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 +25 -6
- package/dist/client/assets/{angular-html-DCs801AK.js → angular-html-VfoYZbqR.js} +1 -1
- package/dist/client/assets/{angular-ts-BWhN0w8_.js → angular-ts-E_ds17k3.js} +1 -1
- package/dist/client/assets/{apl-Djz0ErNW.js → apl-DfLg31Yo.js} +1 -1
- package/dist/client/assets/{astro-MWC3rLUN.js → astro-DDZGZEa0.js} +1 -1
- package/dist/client/assets/{blade-B8pycf1w.js → blade-C7j7NUSc.js} +1 -1
- package/dist/client/assets/{c-B-Jcxs--.js → c-B9ldfZRm.js} +1 -1
- package/dist/client/assets/{chapel-CuJCeKLf.js → chapel-D1rbgfGL.js} +1 -1
- package/dist/client/assets/{cobol-DgquORVs.js → cobol-CvvLWZ5H.js} +1 -1
- package/dist/client/assets/{coffee-ECOaIhT5.js → coffee-Cq5EY8Kf.js} +1 -1
- package/dist/client/assets/{cpp-CqnSivHO.js → cpp-KDnKhkos.js} +1 -1
- package/dist/client/assets/{crystal-Cs7ZYBfN.js → crystal-qnDGw82Q.js} +1 -1
- package/dist/client/assets/{css-p0qk9Lci.js → css-DbYR8NW9.js} +1 -1
- package/dist/client/assets/{edge-mMULBvUs.js → edge-BytNLpnp.js} +1 -1
- package/dist/client/assets/{elixir-VIosceup.js → elixir-BOIZTT6O.js} +1 -1
- package/dist/client/assets/{elm-C3kw87lA.js → elm-BLyo2uoF.js} +1 -1
- package/dist/client/assets/{erb-DZjmloke.js → erb-C8RG1ggg.js} +1 -1
- package/dist/client/assets/{git-rebase-qAMhScBw.js → git-rebase-BsJjDHkQ.js} +1 -1
- package/dist/client/assets/{glimmer-js-DJdA7CEB.js → glimmer-js-F0nfRvQ4.js} +1 -1
- package/dist/client/assets/{glimmer-ts-6xtV4JQb.js → glimmer-ts-BaUh_0sm.js} +1 -1
- package/dist/client/assets/{glsl-BRqSJ3dN.js → glsl-r7jKENsf.js} +1 -1
- package/dist/client/assets/{graphql-nm_X3Hg7.js → graphql-DecxGIfy.js} +1 -1
- package/dist/client/assets/{hack-CQ7j-SLC.js → hack-D1QdSbsI.js} +1 -1
- package/dist/client/assets/{haml-nw30ZCFe.js → haml-DZ3Y8IEU.js} +1 -1
- package/dist/client/assets/{handlebars-CbbUPaYB.js → handlebars-9ij-Y0dS.js} +1 -1
- package/dist/client/assets/{html-C-E44L0y.js → html-_813EOsa.js} +1 -1
- package/dist/client/assets/{html-derivative-DTCnXXkR.js → html-derivative-DMoYXYJX.js} +1 -1
- package/dist/client/assets/{http-Cg4Jw-kz.js → http-BHZjBd1F.js} +1 -1
- package/dist/client/assets/{hurl-CITbwYVL.js → hurl-B9C7GLqi.js} +1 -1
- package/dist/client/assets/index-CDwn6vHT.css +2 -0
- package/dist/client/assets/{index-BdA8MWLx.js → index-DDePiSWW.js} +104 -93
- package/dist/client/assets/{java-DNT_EPVr.js → java-BXcKXYSC.js} +1 -1
- package/dist/client/assets/{javascript-DBrMK690.js → javascript-DzK8bvMk.js} +1 -1
- package/dist/client/assets/{jinja-DqPnwJo-.js → jinja-DkPJymJ8.js} +1 -1
- package/dist/client/assets/{jison-A5ieRkCJ.js → jison-BRfwGndr.js} +1 -1
- package/dist/client/assets/{json-BolWLdCd.js → json-CVsgTDXF.js} +1 -1
- package/dist/client/assets/{jsx-CkIysgAJ.js → jsx-CCEGQMKy.js} +1 -1
- package/dist/client/assets/{julia-jBzm267c.js → julia-DB5wqhUl.js} +1 -1
- package/dist/client/assets/{just-cr8UASbW.js → just-saVQlczz.js} +1 -1
- package/dist/client/assets/{latex-DcdC0vak.js → latex-C7pJ0jXy.js} +1 -1
- package/dist/client/assets/{liquid-BBR6eAOA.js → liquid-BzVhxXiW.js} +1 -1
- package/dist/client/assets/{lua-CnUVAA9w.js → lua-B68msXNV.js} +1 -1
- package/dist/client/assets/{marko-BxH5DxB_.js → marko-D0vTAOKD.js} +1 -1
- package/dist/client/assets/{mdc-CVc0_tcy.js → mdc-C5xXBRll.js} +1 -1
- package/dist/client/assets/{nginx-B1DQmN_N.js → nginx-BgUn926n.js} +1 -1
- package/dist/client/assets/{nim-C3rsdIhh.js → nim-BWiAEcU7.js} +1 -1
- package/dist/client/assets/{org-DICsbmv1.js → org-PxncFL6r.js} +1 -1
- package/dist/client/assets/{perl-B-6tgIaz.js → perl-DNCL3XMU.js} +1 -1
- package/dist/client/assets/{php-CSZf1YiM.js → php-B4HLiXa-.js} +1 -1
- package/dist/client/assets/{pug-BvQ97HKe.js → pug-YBRTFp5G.js} +1 -1
- package/dist/client/assets/{qml-B_gSWBeZ.js → qml-Kbw3ykka.js} +1 -1
- package/dist/client/assets/{r-Dbh43Ti8.js → r-e7ntBSQI.js} +1 -1
- package/dist/client/assets/{razor-Dks3_W39.js → razor-C-7kbnr6.js} +1 -1
- package/dist/client/assets/{regexp-cZE9On7Z.js → regexp-BfKQ26Bd.js} +1 -1
- package/dist/client/assets/{rst-Cgn-1Zks.js → rst-Dk61acH4.js} +1 -1
- package/dist/client/assets/{ruby-DkGM-R-6.js → ruby-B-Gsrm_0.js} +1 -1
- package/dist/client/assets/{sas-D6c_PGbn.js → sas-DrwY32Sv.js} +1 -1
- package/dist/client/assets/{scss-BKxWJNFh.js → scss-BQ0tGjl9.js} +1 -1
- package/dist/client/assets/{shellscript-Bcg5lXvK.js → shellscript-70AyB6AW.js} +1 -1
- package/dist/client/assets/{shellsession-Bf400BZo.js → shellsession-C0TRVWC2.js} +1 -1
- package/dist/client/assets/{soy-VDZj_scj.js → soy-ljPZKxfc.js} +1 -1
- package/dist/client/assets/{sql-ushyJdRf.js → sql-CpAh8MTM.js} +1 -1
- package/dist/client/assets/{stata-87RYcwm-.js → stata-U-vhcCzr.js} +1 -1
- package/dist/client/assets/{surrealql-DiJkNDgM.js → surrealql-Cq-W9D43.js} +1 -1
- package/dist/client/assets/{svelte-B6zEQCqX.js → svelte-CsPr3yNN.js} +1 -1
- package/dist/client/assets/{templ-BbUkdxgW.js → templ-DieGJMZV.js} +1 -1
- package/dist/client/assets/{tex-8DUlt0ja.js → tex-D0ERfPEs.js} +1 -1
- package/dist/client/assets/{ts-tags-B9TjJt8W.js → ts-tags-Bw9LC0mV.js} +1 -1
- package/dist/client/assets/{tsx-KtVv-1tC.js → tsx-DIXVqg6z.js} +1 -1
- package/dist/client/assets/{twig-SRZcBkws.js → twig-DbTBKLwc.js} +1 -1
- package/dist/client/assets/{typescript-XxBZhs1X.js → typescript-Bd6Plo3n.js} +1 -1
- package/dist/client/assets/{typst-zdrwuFs3.js → typst-DqDsanSi.js} +1 -1
- package/dist/client/assets/{vue-CIJzu_ou.js → vue-Da_CXJw9.js} +1 -1
- package/dist/client/assets/{vue-html-Cbh4Xclf.js → vue-html-n6FmJEJ_.js} +1 -1
- package/dist/client/assets/{vue-vine-B3rP3Gia.js → vue-vine-BOc7r2gI.js} +1 -1
- package/dist/client/assets/{xml-B8FuGGpH.js → xml-DzU5oF9g.js} +1 -1
- package/dist/client/assets/{xsl-DGb4ZSQs.js → xsl-BTxmEaay.js} +1 -1
- package/dist/client/assets/{yaml-DgFlGzOM.js → yaml-DYMpEUsY.js} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/server/{Watcher-C_X03LRa.js → Watcher-CKwvGuPB.js} +5 -4
- package/dist/server/main.js +343 -145
- package/package.json +1 -1
- package/dist/client/assets/index-CBL0EMlj.css +0 -2
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.6";
|
|
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)");
|
|
@@ -4513,10 +4520,11 @@ var GitRepo = class GitRepo {
|
|
|
4513
4520
|
* Worktree searches include untracked files. The result is
|
|
4514
4521
|
* bounded globally: git is stopped once `limit + 1` records have arrived, so a
|
|
4515
4522
|
* common query in a large repository cannot flood the process. `paths`
|
|
4516
|
-
* restricts
|
|
4523
|
+
* restricts files; `ranges` restricts new-side lines before applying the limit.
|
|
4524
|
+
* An empty path list or range map matches nothing.
|
|
4517
4525
|
*/
|
|
4518
4526
|
async grep(query, rev, limit = 500, opts = {}) {
|
|
4519
|
-
if (!query || opts.paths?.length === 0) return {
|
|
4527
|
+
if (!query || opts.paths?.length === 0 || opts.ranges?.size === 0) return {
|
|
4520
4528
|
matches: [],
|
|
4521
4529
|
truncated: false
|
|
4522
4530
|
};
|
|
@@ -4526,9 +4534,9 @@ var GitRepo = class GitRepo {
|
|
|
4526
4534
|
"--no-column",
|
|
4527
4535
|
"-I",
|
|
4528
4536
|
opts.regex ? "-E" : "-F",
|
|
4529
|
-
"-z"
|
|
4530
|
-
`--max-count=${limit + 1}`
|
|
4537
|
+
"-z"
|
|
4531
4538
|
];
|
|
4539
|
+
if (!opts.ranges) args.push(`--max-count=${limit + 1}`);
|
|
4532
4540
|
if (opts.word) args.push("-w");
|
|
4533
4541
|
if (opts.ignoreCase) args.push("-i");
|
|
4534
4542
|
args.push("-e", query);
|
|
@@ -4536,20 +4544,25 @@ var GitRepo = class GitRepo {
|
|
|
4536
4544
|
else args.push(rev);
|
|
4537
4545
|
args.push("--");
|
|
4538
4546
|
for (const p of opts.paths ?? []) args.push(`:(literal)${p}`);
|
|
4547
|
+
const parse = (rec) => {
|
|
4548
|
+
const [rawPath = "", line = "", ...rest] = rec.split("\0");
|
|
4549
|
+
return {
|
|
4550
|
+
path: rev === "worktree" ? rawPath : rawPath.slice(rawPath.indexOf(":") + 1),
|
|
4551
|
+
line: Number(line),
|
|
4552
|
+
text: rest.join("\0").slice(0, 300)
|
|
4553
|
+
};
|
|
4554
|
+
};
|
|
4539
4555
|
const records = await execGitRecords(this.root, args, limit + 1, {
|
|
4540
4556
|
okCodes: [0, 1],
|
|
4541
|
-
timeoutMs: GREP_TIMEOUT_MS
|
|
4557
|
+
timeoutMs: GREP_TIMEOUT_MS,
|
|
4558
|
+
accept: opts.ranges ? (record) => {
|
|
4559
|
+
const { path, line } = parse(record);
|
|
4560
|
+
return opts.ranges.get(path)?.some(([start, end]) => line >= start && line <= end) ?? false;
|
|
4561
|
+
} : void 0
|
|
4542
4562
|
});
|
|
4543
4563
|
const truncated = records.length > limit;
|
|
4544
4564
|
return {
|
|
4545
|
-
matches: records.slice(0, limit).map(
|
|
4546
|
-
const [rawPath = "", line = "", ...rest] = rec.split("\0");
|
|
4547
|
-
return {
|
|
4548
|
-
path: rev === "worktree" ? rawPath : rawPath.slice(rawPath.indexOf(":") + 1),
|
|
4549
|
-
line: Number(line),
|
|
4550
|
-
text: rest.join("\0").slice(0, 300)
|
|
4551
|
-
};
|
|
4552
|
-
}),
|
|
4565
|
+
matches: records.slice(0, limit).map(parse),
|
|
4553
4566
|
truncated
|
|
4554
4567
|
};
|
|
4555
4568
|
}
|
|
@@ -4720,6 +4733,31 @@ var GitRepo = class GitRepo {
|
|
|
4720
4733
|
await fh?.close();
|
|
4721
4734
|
}
|
|
4722
4735
|
}
|
|
4736
|
+
/**
|
|
4737
|
+
* Boolean value of a gitattribute per path: `attr` or `attr=true` reads
|
|
4738
|
+
* true, `-attr` or `attr=false` false; unspecified paths are absent. A
|
|
4739
|
+
* revision reads `.gitattributes` from that tree, the worktree from the
|
|
4740
|
+
* checkout. `--source` needs git 2.40.
|
|
4741
|
+
*/
|
|
4742
|
+
async attr(name, paths, rev) {
|
|
4743
|
+
const out = /* @__PURE__ */ new Map();
|
|
4744
|
+
if (paths.length === 0) return out;
|
|
4745
|
+
const source = rev === "worktree" ? [] : [`--source=${rev}`];
|
|
4746
|
+
const fields = splitZ(await this.exec([
|
|
4747
|
+
"check-attr",
|
|
4748
|
+
"-z",
|
|
4749
|
+
"--stdin",
|
|
4750
|
+
...source,
|
|
4751
|
+
name,
|
|
4752
|
+
"--"
|
|
4753
|
+
], { input: paths.join("\0") }));
|
|
4754
|
+
for (let i = 0; i + 2 < fields.length; i += 3) {
|
|
4755
|
+
const value = fields[i + 2];
|
|
4756
|
+
if (value === "set" || value === "true") out.set(fields[i], true);
|
|
4757
|
+
else if (value === "unset" || value === "false") out.set(fields[i], false);
|
|
4758
|
+
}
|
|
4759
|
+
return out;
|
|
4760
|
+
}
|
|
4723
4761
|
/** Paths git ignores, from a candidate list. */
|
|
4724
4762
|
async ignored(paths) {
|
|
4725
4763
|
if (paths.length === 0) return /* @__PURE__ */ new Set();
|
|
@@ -4951,9 +4989,10 @@ function execGitRecords(cwd, args, maxRecords, opts = {}) {
|
|
|
4951
4989
|
GIT_OPTIONAL_LOCKS: "0"
|
|
4952
4990
|
}
|
|
4953
4991
|
});
|
|
4954
|
-
const
|
|
4992
|
+
const records = [];
|
|
4993
|
+
let chunks = [];
|
|
4994
|
+
let fields = 0;
|
|
4955
4995
|
const errChunks = [];
|
|
4956
|
-
let seen = 0;
|
|
4957
4996
|
let done = false;
|
|
4958
4997
|
let timedOut = false;
|
|
4959
4998
|
const timer = opts.timeoutMs == null ? null : setTimeout(() => {
|
|
@@ -4962,16 +5001,24 @@ function execGitRecords(cwd, args, maxRecords, opts = {}) {
|
|
|
4962
5001
|
}, opts.timeoutMs);
|
|
4963
5002
|
child.stdout.on("data", (chunk) => {
|
|
4964
5003
|
if (done) return;
|
|
5004
|
+
let start = 0;
|
|
4965
5005
|
for (let i = 0; i < chunk.length; i++) {
|
|
4966
|
-
if (chunk[i]
|
|
4967
|
-
if (
|
|
4968
|
-
|
|
5006
|
+
if (chunk[i] === 0) fields++;
|
|
5007
|
+
if (chunk[i] !== 10 || fields < 2) continue;
|
|
5008
|
+
chunks.push(chunk.subarray(start, i));
|
|
5009
|
+
const record = Buffer.concat(chunks).toString("utf8");
|
|
5010
|
+
chunks = [];
|
|
5011
|
+
fields = 0;
|
|
5012
|
+
start = i + 1;
|
|
5013
|
+
if (opts.accept && !opts.accept(record)) continue;
|
|
5014
|
+
records.push(record);
|
|
5015
|
+
if (records.length === maxRecords) {
|
|
4969
5016
|
done = true;
|
|
4970
5017
|
child.kill();
|
|
4971
5018
|
return;
|
|
4972
5019
|
}
|
|
4973
5020
|
}
|
|
4974
|
-
chunks.push(chunk);
|
|
5021
|
+
if (start < chunk.length) chunks.push(chunk.subarray(start));
|
|
4975
5022
|
});
|
|
4976
5023
|
child.stderr.on("data", (chunk) => errChunks.push(chunk));
|
|
4977
5024
|
child.on("error", (err) => rej(new GitError(`git ${args.join(" ")} failed: ${err.message}`, args, null, "")));
|
|
@@ -4986,8 +5033,6 @@ function execGitRecords(cwd, args, maxRecords, opts = {}) {
|
|
|
4986
5033
|
rej(new GitError(`git ${args.join(" ")} failed: ${stderr.trim()}`, args, code, stderr));
|
|
4987
5034
|
return;
|
|
4988
5035
|
}
|
|
4989
|
-
const records = Buffer.concat(chunks).toString("utf8").split("\n");
|
|
4990
|
-
if (records[records.length - 1] === "") records.pop();
|
|
4991
5036
|
res(records);
|
|
4992
5037
|
});
|
|
4993
5038
|
});
|
|
@@ -5076,11 +5121,15 @@ var GithubError = class extends Error {
|
|
|
5076
5121
|
this.status = status;
|
|
5077
5122
|
}
|
|
5078
5123
|
};
|
|
5124
|
+
function isFileComment(c) {
|
|
5125
|
+
return "subject_type" in c;
|
|
5126
|
+
}
|
|
5079
5127
|
/**
|
|
5080
5128
|
* Threads → the comments of one pending GitHub review, one per thread. Stale threads are skipped:
|
|
5081
5129
|
* their lines no longer sit in the diff, and GitHub would refuse them anyway. With
|
|
5082
5130
|
* `threadIds`, only those (resolved included, the user asked for them by hand);
|
|
5083
|
-
* without, every unresolved thread.
|
|
5131
|
+
* without, every unresolved thread. `review.comments` holds the line comments REST can
|
|
5132
|
+
* create; the file comments are appended afterwards, but `ids` counts both, in review order.
|
|
5084
5133
|
*/
|
|
5085
5134
|
function buildReview(threads, commitId, threadIds) {
|
|
5086
5135
|
const skipped = [];
|
|
@@ -5113,20 +5162,27 @@ function buildReview(threads, commitId, threadIds) {
|
|
|
5113
5162
|
return {
|
|
5114
5163
|
review: {
|
|
5115
5164
|
commit_id: commitId,
|
|
5116
|
-
comments
|
|
5165
|
+
comments: comments.filter((c) => !isFileComment(c))
|
|
5117
5166
|
},
|
|
5167
|
+
comments,
|
|
5118
5168
|
ids,
|
|
5119
5169
|
skipped
|
|
5120
5170
|
};
|
|
5121
5171
|
}
|
|
5122
5172
|
function toReviewComment(t) {
|
|
5123
5173
|
const { anchor } = t;
|
|
5174
|
+
const body = formatBody(t.messages);
|
|
5175
|
+
if (anchor.kind === "file") return {
|
|
5176
|
+
path: anchor.path,
|
|
5177
|
+
subject_type: "file",
|
|
5178
|
+
body
|
|
5179
|
+
};
|
|
5124
5180
|
const side = anchor.side === "old" ? "LEFT" : "RIGHT";
|
|
5125
5181
|
const c = {
|
|
5126
5182
|
path: anchor.path,
|
|
5127
5183
|
line: anchor.endLine,
|
|
5128
5184
|
side,
|
|
5129
|
-
body
|
|
5185
|
+
body
|
|
5130
5186
|
};
|
|
5131
5187
|
if (anchor.startLine !== anchor.endLine) {
|
|
5132
5188
|
c.start_line = anchor.startLine;
|
|
@@ -5171,15 +5227,25 @@ var GithubExporter = class {
|
|
|
5171
5227
|
*/
|
|
5172
5228
|
async function exportToGithub({ snap, pullRequest: pr, threads, threadIds, run = runGh }) {
|
|
5173
5229
|
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
|
|
5230
|
+
const { review, comments, ids, skipped } = buildReview(threads, snap.newSha, threadIds);
|
|
5231
|
+
if (comments.length === 0) throw new GithubError(skipped.length ? `nothing to post: ${describe(skipped)}` : "nothing to post", 400);
|
|
5232
|
+
for (const [i, comment] of comments.entries()) comment.body += `\n\n${threadMarker(ids[i])}`;
|
|
5177
5233
|
const pending = await findPendingReview(run, snap.root, pr);
|
|
5178
5234
|
if (!pending) {
|
|
5179
|
-
await createPendingReview(run, snap.root, pr.repository, pr.number, review);
|
|
5235
|
+
const created = await createPendingReview(run, snap.root, pr.repository, pr.number, review);
|
|
5236
|
+
let appended = 0;
|
|
5237
|
+
try {
|
|
5238
|
+
for (const c of comments.filter(isFileComment)) {
|
|
5239
|
+
await addToPendingReview(run, snap.root, created.id, c);
|
|
5240
|
+
appended++;
|
|
5241
|
+
}
|
|
5242
|
+
} catch (e) {
|
|
5243
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
5244
|
+
throw new GithubError(`created the pending review with ${review.comments.length + appended} comments, then ${detail}`, 502);
|
|
5245
|
+
}
|
|
5180
5246
|
return {
|
|
5181
5247
|
url: pr.url,
|
|
5182
|
-
posted:
|
|
5248
|
+
posted: comments.length,
|
|
5183
5249
|
updated: 0,
|
|
5184
5250
|
review: "created",
|
|
5185
5251
|
skipped
|
|
@@ -5190,7 +5256,7 @@ async function exportToGithub({ snap, pullRequest: pr, threads, threadIds, run =
|
|
|
5190
5256
|
let updated = 0;
|
|
5191
5257
|
let posted = 0;
|
|
5192
5258
|
try {
|
|
5193
|
-
for (const [i, c] of
|
|
5259
|
+
for (const [i, c] of comments.entries()) {
|
|
5194
5260
|
const id = ids[i];
|
|
5195
5261
|
const at = existing.get(anchorKey(c));
|
|
5196
5262
|
const index = at?.findIndex((comment) => comment.body.trimEnd().endsWith(threadMarker(id))) ?? -1;
|
|
@@ -5234,6 +5300,7 @@ function threadMarker(id) {
|
|
|
5234
5300
|
}
|
|
5235
5301
|
/** The anchor must still match before we rewrite an exported thread. */
|
|
5236
5302
|
function anchorKey(c) {
|
|
5303
|
+
if (isFileComment(c)) return [c.path, "FILE"].join("\0");
|
|
5237
5304
|
return [
|
|
5238
5305
|
c.path,
|
|
5239
5306
|
c.side,
|
|
@@ -5264,7 +5331,7 @@ async function findPendingReview(run, cwd, pr) {
|
|
|
5264
5331
|
const THREADS_QUERY = `query($owner:String!,$repo:String!,$number:Int!,$after:String){
|
|
5265
5332
|
repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewThreads(first:100,after:$after){
|
|
5266
5333
|
pageInfo{hasNextPage endCursor}
|
|
5267
|
-
nodes{path line startLine diffSide comments(first:1){nodes{id body pullRequestReview{id}}}}
|
|
5334
|
+
nodes{path line startLine diffSide subjectType comments(first:1){nodes{id body pullRequestReview{id}}}}
|
|
5268
5335
|
}}}
|
|
5269
5336
|
}`;
|
|
5270
5337
|
/**
|
|
@@ -5288,15 +5355,8 @@ async function pendingComments(run, cwd, pr, reviewId) {
|
|
|
5288
5355
|
for (const t of threads.nodes) {
|
|
5289
5356
|
const c = t?.comments?.nodes?.[0];
|
|
5290
5357
|
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
|
-
});
|
|
5358
|
+
const key = pendingKey(t);
|
|
5359
|
+
if (key == null) continue;
|
|
5300
5360
|
const at = by.get(key);
|
|
5301
5361
|
if (at) at.push({
|
|
5302
5362
|
nodeId: c.id,
|
|
@@ -5313,10 +5373,30 @@ async function pendingComments(run, cwd, pr, reviewId) {
|
|
|
5313
5373
|
}
|
|
5314
5374
|
throw new GithubError("pending review exceeds the 1000-thread lookup limit; export stopped without changes", 502);
|
|
5315
5375
|
}
|
|
5376
|
+
/** The anchor key of a thread already in the review, or null for one GitHub reports without a position. */
|
|
5377
|
+
function pendingKey(t) {
|
|
5378
|
+
const path = t.path;
|
|
5379
|
+
if (t.subjectType === "FILE") return anchorKey({
|
|
5380
|
+
path,
|
|
5381
|
+
subject_type: "file",
|
|
5382
|
+
body: ""
|
|
5383
|
+
});
|
|
5384
|
+
if (t.line == null) return null;
|
|
5385
|
+
return anchorKey({
|
|
5386
|
+
path,
|
|
5387
|
+
side: t.diffSide === "LEFT" ? "LEFT" : "RIGHT",
|
|
5388
|
+
line: t.line,
|
|
5389
|
+
start_line: t.startLine ?? void 0,
|
|
5390
|
+
body: ""
|
|
5391
|
+
});
|
|
5392
|
+
}
|
|
5316
5393
|
const UPDATE_COMMENT = `mutation($input:UpdatePullRequestReviewCommentInput!){updatePullRequestReviewComment(input:$input){pullRequestReviewComment{id}}}`;
|
|
5317
|
-
/**
|
|
5394
|
+
/**
|
|
5395
|
+
* No `event` in the body, so GitHub keeps the new review pending with all of its comments.
|
|
5396
|
+
* Resolves the review's node id, for the comments REST cannot carry.
|
|
5397
|
+
*/
|
|
5318
5398
|
async function createPendingReview(run, cwd, repository, number, review) {
|
|
5319
|
-
await run([
|
|
5399
|
+
const out = await run([
|
|
5320
5400
|
"api",
|
|
5321
5401
|
"--method",
|
|
5322
5402
|
"POST",
|
|
@@ -5327,20 +5407,31 @@ async function createPendingReview(run, cwd, repository, number, review) {
|
|
|
5327
5407
|
cwd,
|
|
5328
5408
|
input: JSON.stringify(review)
|
|
5329
5409
|
});
|
|
5410
|
+
let id;
|
|
5411
|
+
try {
|
|
5412
|
+
id = JSON.parse(out).node_id;
|
|
5413
|
+
} catch {
|
|
5414
|
+
throw new GithubError(`unexpected output from gh api: ${out.slice(0, 200)}`, 502);
|
|
5415
|
+
}
|
|
5416
|
+
if (typeof id !== "string") throw new GithubError(`unexpected output from gh api: ${out.slice(0, 200)}`, 502);
|
|
5417
|
+
return { id };
|
|
5330
5418
|
}
|
|
5331
5419
|
const ADD_THREAD = `mutation($input:AddPullRequestReviewThreadInput!){addPullRequestReviewThread(input:$input){thread{id}}}`;
|
|
5332
|
-
/** Appends one thread through GraphQL; REST cannot extend a pending review. */
|
|
5420
|
+
/** Appends one thread through GraphQL; REST cannot extend a pending review, nor place a file comment. */
|
|
5333
5421
|
async function addToPendingReview(run, cwd, reviewId, c) {
|
|
5334
5422
|
const input = {
|
|
5335
5423
|
pullRequestReviewId: reviewId,
|
|
5336
5424
|
path: c.path,
|
|
5337
|
-
line: c.line,
|
|
5338
|
-
side: c.side,
|
|
5339
5425
|
body: c.body
|
|
5340
5426
|
};
|
|
5341
|
-
if (c.
|
|
5342
|
-
|
|
5343
|
-
input.
|
|
5427
|
+
if (isFileComment(c)) input.subjectType = "FILE";
|
|
5428
|
+
else {
|
|
5429
|
+
input.line = c.line;
|
|
5430
|
+
input.side = c.side;
|
|
5431
|
+
if (c.start_line != null) {
|
|
5432
|
+
input.startLine = c.start_line;
|
|
5433
|
+
input.startSide = c.start_side;
|
|
5434
|
+
}
|
|
5344
5435
|
}
|
|
5345
5436
|
await graphql(run, cwd, ADD_THREAD, { input });
|
|
5346
5437
|
}
|
|
@@ -11164,7 +11255,7 @@ const ChangedFileSchema = object({
|
|
|
11164
11255
|
binary: boolean(),
|
|
11165
11256
|
/** New-side blob sha. '' for deletions. Keys the viewed state. */
|
|
11166
11257
|
blob: string(),
|
|
11167
|
-
/**
|
|
11258
|
+
/** The `linguist-generated` gitattribute, a path pattern or a content sniff said this file is generated. Feeds auto-collapse and risk ranking. */
|
|
11168
11259
|
generated: boolean(),
|
|
11169
11260
|
/** A gitlink (mode 160000): `blob` is the recorded commit, the patch shows the commit-id change, and there is no file to open. */
|
|
11170
11261
|
submodule: literal(true).optional()
|
|
@@ -11192,7 +11283,8 @@ object({
|
|
|
11192
11283
|
contents: string(),
|
|
11193
11284
|
binary: boolean()
|
|
11194
11285
|
});
|
|
11195
|
-
const CommentAnchorSchema = object({
|
|
11286
|
+
const CommentAnchorSchema = discriminatedUnion("kind", [object({
|
|
11287
|
+
kind: literal("line"),
|
|
11196
11288
|
path: string(),
|
|
11197
11289
|
/** Column the user selected in a diff; 'new' for file items. */
|
|
11198
11290
|
side: SideSchema,
|
|
@@ -11200,7 +11292,11 @@ const CommentAnchorSchema = object({
|
|
|
11200
11292
|
endLine: number(),
|
|
11201
11293
|
/** Exact text of [startLine, endLine] at creation. */
|
|
11202
11294
|
quoted: string()
|
|
11203
|
-
})
|
|
11295
|
+
}), object({
|
|
11296
|
+
kind: literal("file"),
|
|
11297
|
+
/** The changed file's path (its new path for a rename), or any tree path. */
|
|
11298
|
+
path: string()
|
|
11299
|
+
})]);
|
|
11204
11300
|
const CommentMessageSchema = object({
|
|
11205
11301
|
id: string(),
|
|
11206
11302
|
body: string(),
|
|
@@ -11214,24 +11310,31 @@ object({
|
|
|
11214
11310
|
messages: CommentMessageSchema.array().min(1),
|
|
11215
11311
|
resolved: boolean(),
|
|
11216
11312
|
resolvedAt: number().optional(),
|
|
11217
|
-
/** Relocation failed after a snapshot refresh. */
|
|
11313
|
+
/** Relocation failed after a snapshot refresh: the lines left the diff, or the file left the review. */
|
|
11218
11314
|
stale: boolean(),
|
|
11219
|
-
/** Original startLine, shown in export when stale. */
|
|
11315
|
+
/** Original startLine of a line thread, shown in export when stale. */
|
|
11220
11316
|
staleFromLine: number().optional()
|
|
11221
11317
|
});
|
|
11222
|
-
/**
|
|
11318
|
+
/**
|
|
11319
|
+
* Create payload. Without `startLine` the thread is on the file as a whole; then `side`,
|
|
11320
|
+
* `endLine` and `quoted` are refused rather than ignored, so a payload that meant a line
|
|
11321
|
+
* never lands on the file. `quoted` is optional: the server quotes the range from the snapshot.
|
|
11322
|
+
*/
|
|
11223
11323
|
const ThreadCreateSchema = object({
|
|
11224
11324
|
path: string().min(1),
|
|
11225
11325
|
/** Default 'new'. */
|
|
11226
11326
|
side: SideSchema.optional(),
|
|
11227
|
-
startLine: number().int().positive(),
|
|
11327
|
+
startLine: number().int().positive().optional(),
|
|
11228
11328
|
/** Default startLine. */
|
|
11229
11329
|
endLine: number().int().positive().optional(),
|
|
11230
11330
|
body: string().refine((body) => body.trim().length > 0, "body required"),
|
|
11231
11331
|
quoted: string().optional()
|
|
11232
|
-
}).refine((t) => t.endLine == null || t.endLine >= t.startLine, {
|
|
11332
|
+
}).refine((t) => t.startLine == null || t.endLine == null || t.endLine >= t.startLine, {
|
|
11233
11333
|
path: ["endLine"],
|
|
11234
11334
|
message: "endLine must be ≥ startLine"
|
|
11335
|
+
}).refine((t) => t.startLine != null || t.side == null && t.endLine == null && t.quoted == null, {
|
|
11336
|
+
path: ["startLine"],
|
|
11337
|
+
message: "side, endLine and quoted need a startLine; leave all four out for a comment on the whole file"
|
|
11235
11338
|
});
|
|
11236
11339
|
const ReplyCreateSchema = object({ body: string().refine((body) => body.trim().length > 0, "body required") });
|
|
11237
11340
|
const ThreadStateSchema = _enum([
|
|
@@ -11301,6 +11404,8 @@ const SearchScopeSchema = _enum([
|
|
|
11301
11404
|
"diff",
|
|
11302
11405
|
"repo"
|
|
11303
11406
|
]);
|
|
11407
|
+
/** Whether search includes whole files or only new-side diff hunks and their context. */
|
|
11408
|
+
const SearchContentSchema = _enum(["diff", "full"]);
|
|
11304
11409
|
object({
|
|
11305
11410
|
query: string(),
|
|
11306
11411
|
matches: SearchMatchSchema.array(),
|
|
@@ -11522,6 +11627,7 @@ const flagSchema = _enum(["0", "1"]).optional().transform((value) => value === "
|
|
|
11522
11627
|
const SearchQuerySchema = object({
|
|
11523
11628
|
q: string().default(""),
|
|
11524
11629
|
scope: SearchScopeSchema.default("diff"),
|
|
11630
|
+
content: SearchContentSchema.default("diff"),
|
|
11525
11631
|
path: string().optional(),
|
|
11526
11632
|
word: flagSchema,
|
|
11527
11633
|
i: flagSchema,
|
|
@@ -15780,6 +15886,7 @@ const LOOPBACK = /* @__PURE__ */ new Set([
|
|
|
15780
15886
|
"::1",
|
|
15781
15887
|
"localhost"
|
|
15782
15888
|
]);
|
|
15889
|
+
const HINT = "if a reverse proxy sent it, trust that origin with --allowed-origin";
|
|
15783
15890
|
/**
|
|
15784
15891
|
* Restricts API and WebSocket requests to local hosts and the configured public origin.
|
|
15785
15892
|
* Proxies may preserve the public Host or rewrite it to an allowed upstream Host.
|
|
@@ -15794,20 +15901,24 @@ function requestGuard(bindHost, names = ownNames(), allowedOrigin) {
|
|
|
15794
15901
|
]);
|
|
15795
15902
|
const publicHost = allowedOrigin == null ? void 0 : new URL(allowedOrigin).host;
|
|
15796
15903
|
return ({ host, origin }) => {
|
|
15797
|
-
if (!host) return
|
|
15904
|
+
if (!host) return "missing Host header";
|
|
15798
15905
|
const name = hostnameOf(host).toLowerCase();
|
|
15799
15906
|
const localHost = loopback ? LOOPBACK.has(name) : isIP(name) !== 0 || allowed.has(name);
|
|
15800
15907
|
const proxyHost = publicHost != null && host.toLowerCase() === publicHost;
|
|
15801
|
-
if (!localHost && !proxyHost) return
|
|
15802
|
-
if (origin == null || allowedOrigin != null && origin === allowedOrigin) return
|
|
15803
|
-
if (proxyHost) return
|
|
15804
|
-
|
|
15805
|
-
return new URL(origin).host === host;
|
|
15806
|
-
} catch {
|
|
15807
|
-
return false;
|
|
15808
|
-
}
|
|
15908
|
+
if (!localHost && !proxyHost) return `forbidden Host "${host}"; ${HINT}`;
|
|
15909
|
+
if (origin == null || allowedOrigin != null && origin === allowedOrigin) return null;
|
|
15910
|
+
if (!proxyHost && parseHost(origin) === host) return null;
|
|
15911
|
+
return `forbidden Origin "${origin}" for Host "${host}"; ${HINT}`;
|
|
15809
15912
|
};
|
|
15810
15913
|
}
|
|
15914
|
+
/** The host of a syntactically valid origin; undefined for `null` and other non-URLs. */
|
|
15915
|
+
function parseHost(origin) {
|
|
15916
|
+
try {
|
|
15917
|
+
return new URL(origin).host;
|
|
15918
|
+
} catch {
|
|
15919
|
+
return;
|
|
15920
|
+
}
|
|
15921
|
+
}
|
|
15811
15922
|
/** The machine's hostname, its short form, and its mDNS name, so LAN visitors can use any of them. */
|
|
15812
15923
|
function ownNames() {
|
|
15813
15924
|
const full = hostname();
|
|
@@ -15877,15 +15988,17 @@ function toThreadCreate(entry, where) {
|
|
|
15877
15988
|
}
|
|
15878
15989
|
/** True when `t` duplicates an open thread at the same anchor whose opening message has the same body. */
|
|
15879
15990
|
function isDuplicate(existing, t) {
|
|
15991
|
+
const body = t.body.trim();
|
|
15992
|
+
const open = existing.filter((e) => !e.resolved && e.anchor.path === t.path && e.messages[0]?.body.trim() === body);
|
|
15993
|
+
if (t.startLine == null) return open.some((e) => e.anchor.kind === "file");
|
|
15880
15994
|
const side = t.side ?? "new";
|
|
15881
15995
|
const endLine = t.endLine ?? t.startLine;
|
|
15882
|
-
|
|
15883
|
-
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);
|
|
15996
|
+
return open.some((e) => e.anchor.kind === "line" && e.anchor.side === side && e.anchor.startLine === t.startLine && e.anchor.endLine === endLine);
|
|
15884
15997
|
}
|
|
15885
15998
|
//#endregion
|
|
15886
15999
|
//#region src/server/comments/CommentStore.ts
|
|
15887
16000
|
var NotFoundError = class extends Error {};
|
|
15888
|
-
/**
|
|
16001
|
+
/** An anchor the snapshot cannot place: path absent on that side, or lines past the end. */
|
|
15889
16002
|
var UnquotableError = class extends Error {};
|
|
15890
16003
|
/**
|
|
15891
16004
|
* Owns this mode's set in `<gitDir>/diffle/comments.json`: threads and viewed
|
|
@@ -15902,14 +16015,14 @@ var CommentStore = class CommentStore {
|
|
|
15902
16015
|
this.file = file;
|
|
15903
16016
|
this.key = key;
|
|
15904
16017
|
}
|
|
15905
|
-
/** Opens the store;
|
|
16018
|
+
/** Opens the store; an older file is backed up to `comments.v<n>.bak` and rewritten in the current format first. */
|
|
15906
16019
|
static async open(gitDir, modeKey) {
|
|
15907
16020
|
const file = join(gitDir, "diffle", "comments.json");
|
|
15908
16021
|
await withFileLock(file, async () => {
|
|
15909
16022
|
const all = await readStoreFile(file);
|
|
15910
|
-
if (all.version
|
|
15911
|
-
await copyFile(file, join(gitDir, "diffle",
|
|
15912
|
-
await writeStoreFile(file,
|
|
16023
|
+
if (all.version === 3) return;
|
|
16024
|
+
await copyFile(file, join(gitDir, "diffle", `comments.v${all.version}.bak`));
|
|
16025
|
+
await writeStoreFile(file, upgrade(all));
|
|
15913
16026
|
});
|
|
15914
16027
|
return new CommentStore(file, modeKey);
|
|
15915
16028
|
}
|
|
@@ -15985,12 +16098,13 @@ var CommentStore = class CommentStore {
|
|
|
15985
16098
|
});
|
|
15986
16099
|
}
|
|
15987
16100
|
/**
|
|
15988
|
-
* Adds each import that is not already an open duplicate. Every
|
|
15989
|
-
* checked against the snapshot through `
|
|
15990
|
-
* text the browser showed
|
|
15991
|
-
*
|
|
16101
|
+
* Adds each import that is not already an open duplicate. Every anchor is
|
|
16102
|
+
* checked against the snapshot through `source`: a range is quoted (a payload's
|
|
16103
|
+
* own `quoted`, the text the browser showed, is kept once the range is known to
|
|
16104
|
+
* exist) and a file thread's path must be in the review. Throws UnquotableError,
|
|
16105
|
+
* adding nothing, when any anchor cannot be placed.
|
|
15992
16106
|
*/
|
|
15993
|
-
importThreads(imports,
|
|
16107
|
+
importThreads(imports, source) {
|
|
15994
16108
|
return this.mutate(async (set) => {
|
|
15995
16109
|
const added = [];
|
|
15996
16110
|
const pending = [];
|
|
@@ -16001,18 +16115,7 @@ var CommentStore = class CommentStore {
|
|
|
16001
16115
|
skipped++;
|
|
16002
16116
|
continue;
|
|
16003
16117
|
}
|
|
16004
|
-
const
|
|
16005
|
-
const startLine = t.startLine;
|
|
16006
|
-
const endLine = t.endLine ?? startLine;
|
|
16007
|
-
const fromSnapshot = await quote(t.path, side, startLine, endLine);
|
|
16008
|
-
if (fromSnapshot == null) throw new UnquotableError(`cannot quote ${t.path}:${startLine}${endLine !== startLine ? `-${endLine}` : ""} on the ${side} side`);
|
|
16009
|
-
const anchor = {
|
|
16010
|
-
path: t.path,
|
|
16011
|
-
side,
|
|
16012
|
-
startLine,
|
|
16013
|
-
endLine,
|
|
16014
|
-
quoted: t.quoted ?? fromSnapshot
|
|
16015
|
-
};
|
|
16118
|
+
const anchor = await placeImport(t, source);
|
|
16016
16119
|
pending.push({
|
|
16017
16120
|
anchor,
|
|
16018
16121
|
t
|
|
@@ -16093,17 +16196,18 @@ var CommentStore = class CommentStore {
|
|
|
16093
16196
|
});
|
|
16094
16197
|
}
|
|
16095
16198
|
/**
|
|
16096
|
-
* Re-anchor every thread against the current review. A thread is stale
|
|
16097
|
-
* its side is gone, its text is not found, or the relocated range falls
|
|
16098
|
-
* outside what the review shows
|
|
16199
|
+
* Re-anchor every thread against the current review. A line thread is stale
|
|
16200
|
+
* when its side is gone, its text is not found, or the relocated range falls
|
|
16201
|
+
* outside what the review shows; a file thread when its file left the review.
|
|
16202
|
+
* Returns true if anything changed.
|
|
16099
16203
|
*/
|
|
16100
|
-
async relocateAll(
|
|
16204
|
+
async relocateAll(review) {
|
|
16101
16205
|
const cache = /* @__PURE__ */ new Map();
|
|
16102
16206
|
const load = (path, side) => {
|
|
16103
16207
|
const k = `${side}:${path}`;
|
|
16104
16208
|
let p = cache.get(k);
|
|
16105
16209
|
if (!p) {
|
|
16106
|
-
p =
|
|
16210
|
+
p = review.side(path, side);
|
|
16107
16211
|
cache.set(k, p);
|
|
16108
16212
|
}
|
|
16109
16213
|
return p;
|
|
@@ -16111,6 +16215,14 @@ var CommentStore = class CommentStore {
|
|
|
16111
16215
|
return this.mutate(async (set) => {
|
|
16112
16216
|
let changed = false;
|
|
16113
16217
|
for (const t of set.threads) {
|
|
16218
|
+
if (t.anchor.kind === "file") {
|
|
16219
|
+
const stale = !review.hasFile(t.anchor.path);
|
|
16220
|
+
if (stale !== t.stale) {
|
|
16221
|
+
t.stale = stale;
|
|
16222
|
+
changed = true;
|
|
16223
|
+
}
|
|
16224
|
+
continue;
|
|
16225
|
+
}
|
|
16114
16226
|
const v = await load(t.anchor.path, t.anchor.side);
|
|
16115
16227
|
let moved = v == null ? null : relocate(t.anchor, v.contents);
|
|
16116
16228
|
if (moved && v?.shown && !isShown(v.shown, moved.startLine, moved.endLine)) moved = null;
|
|
@@ -16147,7 +16259,7 @@ var CommentStore = class CommentStore {
|
|
|
16147
16259
|
viewed: []
|
|
16148
16260
|
};
|
|
16149
16261
|
}
|
|
16150
|
-
return ownSet(
|
|
16262
|
+
return ownSet(upgrade(parsed), this.key);
|
|
16151
16263
|
}
|
|
16152
16264
|
/**
|
|
16153
16265
|
* Locked read-modify-write: `fn` edits this key's set as read from disk under
|
|
@@ -16157,12 +16269,11 @@ var CommentStore = class CommentStore {
|
|
|
16157
16269
|
*/
|
|
16158
16270
|
mutate(fn, changed = () => true) {
|
|
16159
16271
|
return withFileLock(this.file, async () => {
|
|
16160
|
-
const
|
|
16161
|
-
const all = onDisk.version === 2 ? onDisk : migrateV1(onDisk);
|
|
16272
|
+
const all = upgrade(await readStoreFile(this.file));
|
|
16162
16273
|
const set = ownSet(all, this.key);
|
|
16163
16274
|
const r = await fn(set);
|
|
16164
16275
|
if (changed(r)) await writeStoreFile(this.file, {
|
|
16165
|
-
version:
|
|
16276
|
+
version: 3,
|
|
16166
16277
|
sets: {
|
|
16167
16278
|
...all.sets,
|
|
16168
16279
|
[this.key]: set
|
|
@@ -16184,7 +16295,33 @@ function findThread(set, id) {
|
|
|
16184
16295
|
if (!t) throw new NotFoundError(id);
|
|
16185
16296
|
return t;
|
|
16186
16297
|
}
|
|
16187
|
-
/**
|
|
16298
|
+
/**
|
|
16299
|
+
* The anchor an import lands on, checked against the snapshot. Throws
|
|
16300
|
+
* UnquotableError for a range that cannot be quoted or a file the review lacks.
|
|
16301
|
+
*/
|
|
16302
|
+
async function placeImport(t, source) {
|
|
16303
|
+
if (t.startLine == null) {
|
|
16304
|
+
if (!await source.hasFile(t.path)) throw new UnquotableError(`${t.path} is not in the review`);
|
|
16305
|
+
return {
|
|
16306
|
+
kind: "file",
|
|
16307
|
+
path: t.path
|
|
16308
|
+
};
|
|
16309
|
+
}
|
|
16310
|
+
const side = t.side ?? "new";
|
|
16311
|
+
const startLine = t.startLine;
|
|
16312
|
+
const endLine = t.endLine ?? startLine;
|
|
16313
|
+
const fromSnapshot = await source.quote(t.path, side, startLine, endLine);
|
|
16314
|
+
if (fromSnapshot == null) throw new UnquotableError(`cannot quote ${t.path}:${startLine}${endLine !== startLine ? `-${endLine}` : ""} on the ${side} side`);
|
|
16315
|
+
return {
|
|
16316
|
+
kind: "line",
|
|
16317
|
+
path: t.path,
|
|
16318
|
+
side,
|
|
16319
|
+
startLine,
|
|
16320
|
+
endLine,
|
|
16321
|
+
quoted: t.quoted ?? fromSnapshot
|
|
16322
|
+
};
|
|
16323
|
+
}
|
|
16324
|
+
/** The file as stored, or an empty store when absent. Malformed JSON propagates. */
|
|
16188
16325
|
async function readStoreFile(file) {
|
|
16189
16326
|
try {
|
|
16190
16327
|
return parseStoreFile(await readFile(file, "utf8"));
|
|
@@ -16192,20 +16329,26 @@ async function readStoreFile(file) {
|
|
|
16192
16329
|
if (e.code !== "ENOENT") throw e;
|
|
16193
16330
|
}
|
|
16194
16331
|
return {
|
|
16195
|
-
version:
|
|
16332
|
+
version: 3,
|
|
16196
16333
|
sets: {}
|
|
16197
16334
|
};
|
|
16198
16335
|
}
|
|
16199
|
-
/** Unknown shapes read as an empty
|
|
16336
|
+
/** Unknown shapes read as an empty store. */
|
|
16200
16337
|
function parseStoreFile(text) {
|
|
16201
16338
|
const parsed = JSON.parse(text);
|
|
16339
|
+
if (parsed.version === 3 && parsed.sets) return parsed;
|
|
16202
16340
|
if (parsed.version === 2 && parsed.sets) return parsed;
|
|
16203
16341
|
if (parsed.version === 1 && parsed.sets) return parsed;
|
|
16204
16342
|
return {
|
|
16205
|
-
version:
|
|
16343
|
+
version: 3,
|
|
16206
16344
|
sets: {}
|
|
16207
16345
|
};
|
|
16208
16346
|
}
|
|
16347
|
+
/** Any stored format as the current one. */
|
|
16348
|
+
function upgrade(all) {
|
|
16349
|
+
if (all.version === 3) return all;
|
|
16350
|
+
return migrateV2(all.version === 2 ? all : migrateV1(all));
|
|
16351
|
+
}
|
|
16209
16352
|
/** Atomic write. Quoted source lines: owner-only. */
|
|
16210
16353
|
function writeStoreFile(file, data) {
|
|
16211
16354
|
return writeFileAtomic(file, JSON.stringify(data, null, 2), 384);
|
|
@@ -16219,6 +16362,24 @@ function newMessage(msg) {
|
|
|
16219
16362
|
updatedAt: now
|
|
16220
16363
|
};
|
|
16221
16364
|
}
|
|
16365
|
+
/** Every v2 anchor is a line range; v3 says so. */
|
|
16366
|
+
function migrateV2(v2) {
|
|
16367
|
+
const sets = {};
|
|
16368
|
+
for (const [key, set] of Object.entries(v2.sets)) sets[key] = {
|
|
16369
|
+
threads: (set.threads ?? []).map((t) => ({
|
|
16370
|
+
...t,
|
|
16371
|
+
anchor: {
|
|
16372
|
+
kind: "line",
|
|
16373
|
+
...t.anchor
|
|
16374
|
+
}
|
|
16375
|
+
})),
|
|
16376
|
+
viewed: set.viewed ?? []
|
|
16377
|
+
};
|
|
16378
|
+
return {
|
|
16379
|
+
version: 3,
|
|
16380
|
+
sets
|
|
16381
|
+
};
|
|
16382
|
+
}
|
|
16222
16383
|
/** Each v1 comment becomes a single-message, unresolved thread. */
|
|
16223
16384
|
function migrateV1(v1) {
|
|
16224
16385
|
const sets = {};
|
|
@@ -16313,12 +16474,22 @@ function createApi(deps) {
|
|
|
16313
16474
|
const query = SearchQuerySchema.parse(c.req.query());
|
|
16314
16475
|
const { q, scope } = query;
|
|
16315
16476
|
const snap = await session.snapshotter.current();
|
|
16316
|
-
|
|
16477
|
+
let paths = scope === "repo" ? void 0 : scope === "file" ? snap.tree.filter((p) => p === query.path) : snap.changed.filter((f) => f.status !== "D").map((f) => f.path);
|
|
16478
|
+
let ranges;
|
|
16479
|
+
if (query.content === "diff") {
|
|
16480
|
+
const entries = await mapLimit(snap.changed.filter((f) => f.status !== "D" && !f.binary && (!paths || paths.includes(f.path))), 8, async (file) => {
|
|
16481
|
+
const patch = await session.repo.patch(snap.oldSha, snap.newSha, file, snap.context);
|
|
16482
|
+
return [file.path, shownRanges(patch, "new")];
|
|
16483
|
+
});
|
|
16484
|
+
ranges = new Map(entries);
|
|
16485
|
+
paths = entries.filter(([, spans]) => spans.length > 0).map(([path]) => path);
|
|
16486
|
+
}
|
|
16317
16487
|
const { matches, truncated } = await session.repo.grep(q, snap.newSha, 500, {
|
|
16318
16488
|
word: query.word,
|
|
16319
16489
|
ignoreCase: query.i,
|
|
16320
16490
|
regex: query.re,
|
|
16321
|
-
paths
|
|
16491
|
+
paths,
|
|
16492
|
+
ranges
|
|
16322
16493
|
});
|
|
16323
16494
|
const body = {
|
|
16324
16495
|
query: q,
|
|
@@ -16349,7 +16520,7 @@ function createApi(deps) {
|
|
|
16349
16520
|
});
|
|
16350
16521
|
app.post("/api/threads", async (c) => {
|
|
16351
16522
|
const imports = parseImports(await readJson(c));
|
|
16352
|
-
const { added } = await session.comments.importThreads(imports, session.
|
|
16523
|
+
const { added } = await session.comments.importThreads(imports, session.anchorSource());
|
|
16353
16524
|
if (added.length) hub.broadcast({ type: "threads" });
|
|
16354
16525
|
return c.json(added, 201);
|
|
16355
16526
|
});
|
|
@@ -16499,10 +16670,11 @@ var Server = class {
|
|
|
16499
16670
|
const app = new Hono();
|
|
16500
16671
|
const guard = requestGuard(this.opts.host, void 0, this.opts.allowedOrigin);
|
|
16501
16672
|
app.use("/api/*", async (c, next) => {
|
|
16502
|
-
|
|
16673
|
+
const rejected = guard({
|
|
16503
16674
|
host: c.req.header("host"),
|
|
16504
16675
|
origin: c.req.header("origin")
|
|
16505
|
-
})
|
|
16676
|
+
});
|
|
16677
|
+
if (rejected != null) return c.json({ error: rejected }, 403);
|
|
16506
16678
|
await next();
|
|
16507
16679
|
});
|
|
16508
16680
|
app.use("/api/*", async (c, next) => {
|
|
@@ -16892,6 +17064,8 @@ function looksGenerated(path, head) {
|
|
|
16892
17064
|
//#endregion
|
|
16893
17065
|
//#region src/server/Snapshotter.ts
|
|
16894
17066
|
const PREWARM = 3;
|
|
17067
|
+
/** The gitattribute GitHub reads to collapse generated files; `=false` opts a file out of the heuristics. */
|
|
17068
|
+
const GENERATED_ATTR = "linguist-generated";
|
|
16895
17069
|
/** Concurrent worktree reads while flagging generated files. */
|
|
16896
17070
|
const SNIFF_CONCURRENCY = 8;
|
|
16897
17071
|
/** Sniff verdicts remembered by blob sha; the map is emptied, not evicted, past this. */
|
|
@@ -17000,14 +17174,23 @@ var Snapshotter = class {
|
|
|
17000
17174
|
return snap;
|
|
17001
17175
|
}
|
|
17002
17176
|
/**
|
|
17003
|
-
* Sets `generated` on each non-binary changed file
|
|
17004
|
-
*
|
|
17005
|
-
*
|
|
17006
|
-
*
|
|
17177
|
+
* Sets `generated` on each non-binary changed file. A `linguist-generated`
|
|
17178
|
+
* gitattribute on the new side decides outright, as on GitHub; otherwise a
|
|
17179
|
+
* path convention, then a SNIFF_BYTES content sniff. Blobs already sniffed
|
|
17180
|
+
* are not read again; commit-side blobs go through one `cat-file --batch`,
|
|
17181
|
+
* worktree files through a bounded read each. Never fails the snapshot: a
|
|
17182
|
+
* failed attribute lookup or read means the next heuristic, then false.
|
|
17007
17183
|
* Submodules have no contents to sniff.
|
|
17008
17184
|
*/
|
|
17009
17185
|
async fillGenerated(files, newSha) {
|
|
17010
|
-
const
|
|
17186
|
+
const textual = files.filter((f) => f.status !== "D" && !f.binary && !f.submodule);
|
|
17187
|
+
const marked = await this.repo.attr(GENERATED_ATTR, textual.map((f) => f.path), newSha).catch(() => /* @__PURE__ */ new Map());
|
|
17188
|
+
const sniff = [];
|
|
17189
|
+
for (const f of textual) {
|
|
17190
|
+
const attr = marked.get(f.path);
|
|
17191
|
+
if (attr != null) f.generated = attr;
|
|
17192
|
+
else if (!(f.generated = looksGenerated(f.path, null))) sniff.push(f);
|
|
17193
|
+
}
|
|
17011
17194
|
const unknown = [];
|
|
17012
17195
|
for (const f of sniff) {
|
|
17013
17196
|
const known = f.blob ? this.sniffed.get(f.blob) : void 0;
|
|
@@ -17232,21 +17415,24 @@ var Session = class {
|
|
|
17232
17415
|
/**
|
|
17233
17416
|
* Re-anchors threads against what `snap` shows. A changed file shows its
|
|
17234
17417
|
* hunks; an unchanged tree file shows its new side whole (the file view) and
|
|
17235
|
-
* nothing of its old side.
|
|
17418
|
+
* nothing of its old side. A file thread only needs its file in the review.
|
|
17236
17419
|
*/
|
|
17237
17420
|
async relocateComments(a, snap) {
|
|
17238
|
-
await a.comments.relocateAll(
|
|
17239
|
-
|
|
17240
|
-
|
|
17241
|
-
|
|
17242
|
-
|
|
17243
|
-
|
|
17244
|
-
|
|
17245
|
-
|
|
17246
|
-
|
|
17247
|
-
|
|
17248
|
-
|
|
17249
|
-
|
|
17421
|
+
await a.comments.relocateAll({
|
|
17422
|
+
side: async (path, side) => {
|
|
17423
|
+
const buf = await this.readSide(snap, path, side);
|
|
17424
|
+
if (buf == null) return null;
|
|
17425
|
+
const contents = buf.toString("utf8");
|
|
17426
|
+
if (!snap.changed.some((f) => f.path === path)) return side === "new" ? {
|
|
17427
|
+
contents,
|
|
17428
|
+
shown: null
|
|
17429
|
+
} : null;
|
|
17430
|
+
return {
|
|
17431
|
+
contents,
|
|
17432
|
+
shown: shownRanges(await a.snapshotter.patch(path) ?? "", side)
|
|
17433
|
+
};
|
|
17434
|
+
},
|
|
17435
|
+
hasFile: (path) => this.hasFile(snap, path)
|
|
17250
17436
|
});
|
|
17251
17437
|
}
|
|
17252
17438
|
/**
|
|
@@ -17265,15 +17451,24 @@ var Session = class {
|
|
|
17265
17451
|
hasSide(snap, path, side) {
|
|
17266
17452
|
return sidePath(this.readablePaths(snap), path, side) != null;
|
|
17267
17453
|
}
|
|
17454
|
+
/** 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. */
|
|
17455
|
+
hasFile(snap, path) {
|
|
17456
|
+
const r = this.readablePaths(snap);
|
|
17457
|
+
return r.tree.has(path) || r.changed.has(path);
|
|
17458
|
+
}
|
|
17268
17459
|
/**
|
|
17269
|
-
*
|
|
17270
|
-
*
|
|
17460
|
+
* Places imported anchors against the current snapshot: quotes a line range for
|
|
17461
|
+
* imports that carry no `quoted`, and says whether a file thread's file is in the
|
|
17462
|
+
* review. Goes through `readSide`, so the allowlist applies.
|
|
17271
17463
|
*/
|
|
17272
|
-
|
|
17273
|
-
return
|
|
17274
|
-
|
|
17275
|
-
|
|
17276
|
-
|
|
17464
|
+
anchorSource() {
|
|
17465
|
+
return {
|
|
17466
|
+
quote: async (path, side, startLine, endLine) => {
|
|
17467
|
+
const snap = await this.snapshotter.current();
|
|
17468
|
+
const buf = await this.readSide(snap, path, side);
|
|
17469
|
+
return buf == null ? null : quoteRange(buf.toString("utf8"), startLine, endLine);
|
|
17470
|
+
},
|
|
17471
|
+
hasFile: async (path) => this.hasFile(await this.snapshotter.current(), path)
|
|
17277
17472
|
};
|
|
17278
17473
|
}
|
|
17279
17474
|
readablePaths(snap) {
|
|
@@ -17325,7 +17520,7 @@ var Session = class {
|
|
|
17325
17520
|
}
|
|
17326
17521
|
};
|
|
17327
17522
|
async function defaultWatcherFactory() {
|
|
17328
|
-
const { Watcher } = await import("./Watcher-
|
|
17523
|
+
const { Watcher } = await import("./Watcher-CKwvGuPB.js");
|
|
17329
17524
|
return (target) => new Watcher(target);
|
|
17330
17525
|
}
|
|
17331
17526
|
function readablePaths(snap) {
|
|
@@ -21013,14 +21208,16 @@ var WsHub = class {
|
|
|
21013
21208
|
maxPayload: MAX_PAYLOAD_BYTES
|
|
21014
21209
|
});
|
|
21015
21210
|
clientListeners = /* @__PURE__ */ new Set();
|
|
21016
|
-
attach(server, guard = () =>
|
|
21211
|
+
attach(server, guard = () => null) {
|
|
21017
21212
|
server.on("upgrade", (req, socket, head) => {
|
|
21018
21213
|
if (new URL(req.url ?? "/", "http://localhost").pathname !== "/ws") return;
|
|
21019
|
-
|
|
21214
|
+
const rejected = guard({
|
|
21020
21215
|
host: req.headers.host,
|
|
21021
21216
|
origin: req.headers.origin
|
|
21022
|
-
})
|
|
21023
|
-
|
|
21217
|
+
});
|
|
21218
|
+
if (rejected != null) {
|
|
21219
|
+
socket.once("finish", () => socket.destroy());
|
|
21220
|
+
socket.end(`HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Type: text/plain\r\n\r\n${rejected}\n`);
|
|
21024
21221
|
return;
|
|
21025
21222
|
}
|
|
21026
21223
|
this.wss.handleUpgrade(req, socket, head, (ws) => {
|
|
@@ -21260,11 +21457,12 @@ Revisions follow git diff, except that a lone one compares from the merge base:
|
|
|
21260
21457
|
diffle main...feat what feat added since it left main
|
|
21261
21458
|
diffle main..worktree main vs the uncommitted tree ("worktree" works on either side)
|
|
21262
21459
|
|
|
21460
|
+
Without revisions, diffle compares HEAD..worktree.
|
|
21461
|
+
|
|
21263
21462
|
Status goes to stderr, so stdout carries only the review: unless --keep-alive is set,
|
|
21264
21463
|
closing the last auto-opened browser tab stops diffle and prints the open comments as a prompt for an agent.
|
|
21265
21464
|
Ctrl+C always stops it.`).action(async (revs, _o, cmd) => {
|
|
21266
|
-
|
|
21267
|
-
await run({
|
|
21465
|
+
await run(revs.length === 0 ? { kind: "working" } : {
|
|
21268
21466
|
kind: "revspec",
|
|
21269
21467
|
args: revs
|
|
21270
21468
|
}, cmd.optsWithGlobals());
|