@tequila99/githuman 0.2.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/dist/cli/commands/export.js +2 -1
- package/dist/cli/server-runtime.js +13 -1
- package/dist/cli/startup-message.js +3 -2
- package/dist/server/adapters/agents/event-mapper.js +140 -0
- package/dist/server/app.js +21 -12
- package/dist/server/config/agents.js +141 -0
- package/dist/server/errors/agents.js +12 -0
- package/dist/server/errors/comments.js +2 -0
- package/dist/server/errors/export.js +2 -0
- package/dist/server/errors/http.js +4 -0
- package/dist/server/errors/reviews.js +3 -0
- package/dist/server/hooks/agents/local-origin.js +33 -0
- package/dist/server/repositories/review.repo.js +1 -3
- package/dist/server/routes/agent.js +135 -0
- package/dist/server/routes/comments.js +17 -67
- package/dist/server/routes/diff.js +40 -7
- package/dist/server/routes/events.js +6 -1
- package/dist/server/routes/export.js +6 -15
- package/dist/server/routes/git.js +12 -21
- package/dist/server/routes/reviews.js +11 -43
- package/dist/server/services/agent/agent-session.js +497 -0
- package/dist/server/services/agent/context-builder.js +137 -0
- package/dist/server/services/agent/file-index.js +58 -0
- package/dist/server/services/agent/session-registry.js +100 -0
- package/dist/server/services/comment.service.js +1 -2
- package/dist/server/services/diff.service.js +121 -2
- package/dist/server/services/export.service.js +1 -2
- package/dist/server/services/git.service.js +110 -10
- package/dist/server/services/review.service.js +3 -3
- package/dist/server/utils/executable.js +26 -0
- package/dist/server/utils/file-name.js +6 -0
- package/dist/server/utils/sse.js +11 -0
- package/dist/server/utils/text.js +29 -0
- package/dist/shared/agents/chat-names.js +17 -0
- package/dist/shared/agents/constants.js +6 -0
- package/dist/shared/agents/context-format.js +17 -0
- package/dist/shared/agents/file-search.js +48 -0
- package/dist/shared/agents/schemas.js +51 -0
- package/dist/shared/agents/types.js +11 -0
- package/dist/shared/app/types.js +1 -0
- package/dist/shared/comments/types.js +1 -0
- package/dist/shared/diff/types.js +4 -0
- package/dist/shared/{types.js → events/types.js} +0 -3
- package/dist/shared/git/types.js +1 -0
- package/dist/shared/http/types.js +1 -0
- package/dist/shared/network/loopback.js +4 -0
- package/dist/shared/reviews/types.js +1 -0
- package/dist/shared/utils/error-message.js +3 -0
- package/dist/shared/utils/guards.js +4 -0
- package/dist/web/assets/AppPage-BNPBe_fi.js +1 -0
- package/dist/web/assets/CreateReviewFabButton-3V9ZYSHd.js +1 -0
- package/dist/web/assets/CreateReviewFabButton-Cufd8hVJ.css +1 -0
- package/dist/web/assets/{ErrorNotFound-BuL12Ld8.js → ErrorNotFound-BAP3n0aC.js} +1 -1
- package/dist/web/assets/IndexPage-CztuM7ha.css +1 -0
- package/dist/web/assets/IndexPage-xqMEFqAN.js +1 -0
- package/dist/web/assets/MainLayout-CMbAu8_d.js +7 -0
- package/dist/web/assets/MainLayout-ChxXEt_Q.css +1 -0
- package/dist/web/assets/QBtn-BDbJU6_B.js +1 -0
- package/dist/web/assets/QMenu-QYPSkHBE.js +1 -0
- package/dist/web/assets/QSelect-D4U0Of4k.js +1 -0
- package/dist/web/assets/ReviewDetailPage-CP6YqXPT.css +1 -0
- package/dist/web/assets/ReviewDetailPage-DSCeLI9D.js +1 -0
- package/dist/web/assets/ReviewsPage-CmHdSBsU.js +1 -0
- package/dist/web/assets/StatusSwitcher-Ca5k9Tjx.css +1 -0
- package/dist/web/assets/StatusSwitcher-CenEW-Lu.js +4 -0
- package/dist/web/assets/abnfDiagram-O67JEVCF-C4PFTrGZ.js +1 -0
- package/dist/web/assets/agent-icon-BUd-vCRS.css +1 -0
- package/dist/web/assets/agent-icon-C1oimBWa.js +17 -0
- package/dist/web/assets/agent-store-D1WRRYIE.js +1 -0
- package/dist/web/assets/app-info-store-xZiQfdgO.js +1 -0
- package/dist/web/assets/arc-CiNErV2i.js +1 -0
- package/dist/web/assets/architecture-7GRP2DOG-DORh0iBM.js +1 -0
- package/dist/web/assets/architectureDiagram-NJMV4G6O-DnAqHYSW.js +36 -0
- package/dist/web/assets/array-BifhSqXX.js +1 -0
- package/dist/web/assets/blockDiagram-BEXU5L5S-CCN9Ja5L.js +140 -0
- package/dist/web/assets/c4Diagram-YGBWAQC7-CPzIw79A.js +38 -0
- package/dist/web/assets/channel-WV0H_O-w.js +1 -0
- package/dist/web/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/dist/web/assets/chunk-3FUC2YCW-BbeiQKv_.js +2 -0
- package/dist/web/assets/chunk-5DYCD2WN-CuzXaWoe.js +1 -0
- package/dist/web/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/dist/web/assets/chunk-742MDFTN-DJUCmQfr.js +1 -0
- package/dist/web/assets/chunk-7INBJB4K-BBd4ghzW.js +62 -0
- package/dist/web/assets/chunk-7M6MHVWA-B0L68u_O.js +213 -0
- package/dist/web/assets/chunk-7PRAP22T-BvEmVPr6.js +1 -0
- package/dist/web/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
- package/dist/web/assets/chunk-FOHPRMQF-CoA4358r.js +161 -0
- package/dist/web/assets/chunk-GTNCS2PH-B_vHEKRO.js +88 -0
- package/dist/web/assets/chunk-GWA4HPMP-DWaW5-VL.js +1 -0
- package/dist/web/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
- package/dist/web/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/dist/web/assets/chunk-MBY4JIJT-Dfo1FM_M.js +72 -0
- package/dist/web/assets/chunk-NETBCI7D-DzjBtXVV.js +1 -0
- package/dist/web/assets/chunk-O7XYJQB3-DY5ZFCJH.js +126 -0
- package/dist/web/assets/chunk-UA2S7LBM-DX_QjVLS.js +1 -0
- package/dist/web/assets/chunk-WEXAMYUT-Cckc2fqg.js +1 -0
- package/dist/web/assets/chunk-XXDRQBXY-DjF0kpJr.js +1 -0
- package/dist/web/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/dist/web/assets/chunk-Z7XXMR3K-Do7oG6KK.js +10 -0
- package/dist/web/assets/chunk-ZIGJFQKS-DTcqsqHq.js +2 -0
- package/dist/web/assets/classDiagram-v2-NBCMYWYE-Z6ZhPv4N.js +217 -0
- package/dist/web/assets/{core-Ruj7F7dx.js → core-tP7fpdit.js} +1 -1
- package/dist/web/assets/cose-bilkent-JH36ORCC-B5DV641c.js +1 -0
- package/dist/web/assets/create-BkZH2Q0U.js +1 -0
- package/dist/web/assets/cynefin-OW5HDTMX-BYrEwheK.js +1 -0
- package/dist/web/assets/cynefinDiagram-VND7K2PF-BXbLnUDP.js +62 -0
- package/dist/web/assets/cytoscape.esm-B-1iW2ft.js +321 -0
- package/dist/web/assets/dagre-6A5THRUB-DJHHAqZU.js +4 -0
- package/dist/web/assets/debounce-CDIE7CV6.js +1 -0
- package/dist/web/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/dist/web/assets/diagram-22UHCM2B-DA9deaTX.js +200 -0
- package/dist/web/assets/diagram-3UASUU5V-C6bo1gN6.js +24 -0
- package/dist/web/assets/diagram-ATOU4E4O-TTQR35E_.js +3 -0
- package/dist/web/assets/diagram-CDSNMT55-ChFXwow0.js +30 -0
- package/dist/web/assets/diagram-MLGK6HIB-D3F8BzhV.js +24 -0
- package/dist/web/assets/diagram-MPIPVDR6-NJ6F45a1.js +41 -0
- package/dist/web/assets/dist-Besl4xjp.js +1 -0
- package/dist/web/assets/ebnfDiagram-ZINNZB2B-u2e1UzYe.js +1 -0
- package/dist/web/assets/elk-276RUBZZ-tCLm9cCU.js +27 -0
- package/dist/web/assets/engine-oniguruma-DaaPGLR3.js +1 -0
- package/dist/web/assets/erDiagram-OPXOYQCR-ChNVjKDe.js +99 -0
- package/dist/web/assets/event-rR76ioXz.js +1 -0
- package/dist/web/assets/eventmodeling-NTZA5JFV-KBTHxbEB.js +1 -0
- package/dist/web/assets/export-file-Di_2jvjP.js +1 -0
- package/dist/web/assets/fira-code-symbols2-400-normal-9X26I2LK.woff2 +0 -0
- package/dist/web/assets/fira-code-symbols2-500-normal-B5s_njw3.woff2 +0 -0
- package/dist/web/assets/fira-code-symbols2-600-normal-Bu3RnO39.woff2 +0 -0
- package/dist/web/assets/flowDiagram-KWPJA3E3-DM-yXypD.js +1 -0
- package/dist/web/assets/ganttDiagram-FUAMR5RP-8mcAm2ao.js +292 -0
- package/dist/web/assets/gitGraph-4MIJSDKK-DtM1RZrd.js +1 -0
- package/dist/web/assets/gitGraphDiagram-X574FWY7-C7KXkLLU.js +106 -0
- package/dist/web/assets/golos-text-cyrillic-400-normal-BwL4n7Pb.woff +0 -0
- package/dist/web/assets/golos-text-cyrillic-400-normal-C7us6pn1.woff2 +0 -0
- package/dist/web/assets/golos-text-cyrillic-500-normal-BSLQUuP1.woff2 +0 -0
- package/dist/web/assets/golos-text-cyrillic-500-normal-hXinzVVQ.woff +0 -0
- package/dist/web/assets/golos-text-cyrillic-ext-400-normal-Bo67NYBq.woff2 +0 -0
- package/dist/web/assets/golos-text-cyrillic-ext-400-normal-D592xEk3.woff +0 -0
- package/dist/web/assets/golos-text-cyrillic-ext-500-normal-CLsu_eDI.woff2 +0 -0
- package/dist/web/assets/golos-text-cyrillic-ext-500-normal-InHNvB7v.woff +0 -0
- package/dist/web/assets/golos-text-latin-400-normal-Coi1FYaD.woff2 +0 -0
- package/dist/web/assets/golos-text-latin-400-normal-DOuJOmdK.woff +0 -0
- package/dist/web/assets/golos-text-latin-500-normal-BQo4s7Kn.woff +0 -0
- package/dist/web/assets/golos-text-latin-500-normal-BznAvurO.woff2 +0 -0
- package/dist/web/assets/golos-text-latin-ext-400-normal-BXgyX_iE.woff +0 -0
- package/dist/web/assets/golos-text-latin-ext-400-normal-BjKcJWKY.woff2 +0 -0
- package/dist/web/assets/golos-text-latin-ext-500-normal-9vZPKadr.woff2 +0 -0
- package/dist/web/assets/golos-text-latin-ext-500-normal-BmtMVntR.woff +0 -0
- package/dist/web/assets/graphlib-DS17s2tU.js +1 -0
- package/dist/web/assets/i18n-BmgFD4Gx.js +1 -0
- package/dist/web/assets/{index-JAYe_SFI.css → index-CgeU6Wuu.css} +1 -1
- package/dist/web/assets/index-CuqF4_ks.js +2 -0
- package/dist/web/assets/info-A6RAGUB7-C5tVl8cQ.js +1 -0
- package/dist/web/assets/infoDiagram-VRGFBTTK-DO4D3mI7.js +2 -0
- package/dist/web/assets/init-C-OQMol4.js +1 -0
- package/dist/web/assets/init-app-C0kkg1Ie.js +1 -0
- package/dist/web/assets/is-BB7rJzdU.js +1 -0
- package/dist/web/assets/ishikawaDiagram-OU5B5YK6-DA2H3Eih.js +70 -0
- package/dist/web/assets/jetbrains-mono-cyrillic-ext-400-normal-Bh0R7Dhr.woff +0 -0
- package/dist/web/assets/jetbrains-mono-cyrillic-ext-400-normal-C_uLvvQ5.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-cyrillic-ext-500-normal-C9ShMxGR.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-cyrillic-ext-500-normal-CEVyPk4Y.woff +0 -0
- package/dist/web/assets/jetbrains-mono-cyrillic-ext-600-normal-DVvC-yKp.woff +0 -0
- package/dist/web/assets/jetbrains-mono-cyrillic-ext-600-normal-Dj6eZy5f.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-vietnamese-400-normal-ByoDsISC.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-vietnamese-500-normal-BvH7FW2L.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-vietnamese-600-normal-Dtizs43-.woff2 +0 -0
- package/dist/web/assets/journeyDiagram-ZHPQQLJL-CdPvR3Sr.js +139 -0
- package/dist/web/assets/kanban-definition-PNTS6WVX-BRIiazVf.js +89 -0
- package/dist/web/assets/katex-ZlcWpGUi.js +257 -0
- package/dist/web/assets/line-CmXhYhmh.js +1 -0
- package/dist/web/assets/linear-Cyx-ptw-.js +1 -0
- package/dist/web/assets/mermaid-parser.core-CZ05RARR.js +7 -0
- package/dist/web/assets/mermaid.core-ZosqiKX-.js +44 -0
- package/dist/web/assets/mindmap-definition-NLK3R4M7-a7nG9rPn.js +96 -0
- package/dist/web/assets/ordinal-BDEzSJ7C.js +1 -0
- package/dist/web/assets/packet-AYTQ26CC-yIJsAali.js +1 -0
- package/dist/web/assets/path-fybaL0A-.js +1 -0
- package/dist/web/assets/pegDiagram-GJSIUBJH-CBdB4i41.js +1 -0
- package/dist/web/assets/pie-WAS4IAKB-ybMEHYPX.js +1 -0
- package/dist/web/assets/pieDiagram-5QR66LMP-BfiG4Lho.js +39 -0
- package/dist/web/assets/pinia-DmTi2T44.js +1 -0
- package/dist/web/assets/pinia-r4XAEwbB.js +1 -0
- package/dist/web/assets/quadrantDiagram-O4NWA36T-7A6x_hoF.js +7 -0
- package/dist/web/assets/radar-RG4KPBEZ-BbeFIczu.js +1 -0
- package/dist/web/assets/railroad-74A4TZTK-CAyDA7Zz.js +1 -0
- package/dist/web/assets/railroad-abnf-HS5TGJTU-BzS6thC5.js +1 -0
- package/dist/web/assets/railroad-ebnf-LZEXJU2U-MS09sAPW.js +1 -0
- package/dist/web/assets/railroad-peg-WCYAUIDC-DEQwqbcE.js +1 -0
- package/dist/web/assets/railroadDiagram-XR7U4H2S-BXiOJ_AA.js +1 -0
- package/dist/web/assets/requirementDiagram-PLB6GJNP-bQNGcpuV.js +84 -0
- package/dist/web/assets/rough.esm-Dy-Kn_BL.js +1 -0
- package/dist/web/assets/runtime-core.esm-bundler-CUpq88z7.js +1 -0
- package/dist/web/assets/safe-storage-CqQMHcn7.js +1 -0
- package/dist/web/assets/sankeyDiagram-IPEJSGJF-CgfGV4TY.js +40 -0
- package/dist/web/assets/sequenceDiagram-PO4LG4MO-BdCuKkDZ.js +169 -0
- package/dist/web/assets/single-flight-r8B7stt1.js +1 -0
- package/dist/web/assets/sizeCapture-INFHLROL-B0uUizjq.js +1 -0
- package/dist/web/assets/src-AvcVSqt3.js +1 -0
- package/dist/web/assets/stateDiagram-v2-GCMORJYK-aj1Op7xi.js +291 -0
- package/dist/web/assets/swimlanes-2SLR337P-DQ_YYhZu.js +1 -0
- package/dist/web/assets/swimlanesDiagram-TC7HE7FX-9frEtrG_.js +8 -0
- package/dist/web/assets/theme-BSjPUW3f.js +1 -0
- package/dist/web/assets/timeline-definition-EJHVYXUP-Bk3Mvy3w.js +120 -0
- package/dist/web/assets/treeView-Q6P3EWNA-BzM7oZcT.js +1 -0
- package/dist/web/assets/treemap-WGGIJYW6-DzgzhZei.js +1 -0
- package/dist/web/assets/use-app-theme-DEiGbVuj.js +1 -0
- package/dist/web/assets/use-panel-width-ujFPWf-l.js +1 -0
- package/dist/web/assets/use-virtual-scroll-BVHTKnoC.js +1 -0
- package/dist/web/assets/usecaseDiagram-POWQR4AR-kriFnh7L.js +328 -0
- package/dist/web/assets/vennDiagram-UO4OBE2U-CDdCMQs7.js +34 -0
- package/dist/web/assets/{vue-i18n.runtime-CxBM6QPV.js → vue-i18n.runtime-D0pkhc7g.js} +2 -2
- package/dist/web/assets/wardley-WFR3VGLG-BUWTYQyf.js +1 -0
- package/dist/web/assets/wardleyDiagram-VNRHLVJA-CW7lMcTj.js +78 -0
- package/dist/web/assets/xychartDiagram-PMCCYNJV-DoIu8J_3.js +7 -0
- package/dist/web/index.html +8 -6
- package/package.json +12 -3
- package/dist/web/assets/AppPage-B9tWPrzn.js +0 -1
- package/dist/web/assets/CreateReviewFabButton-BqWdgfEf.css +0 -1
- package/dist/web/assets/CreateReviewFabButton-DcXpOlBP.js +0 -1
- package/dist/web/assets/IndexPage-CsgdLXZh.css +0 -1
- package/dist/web/assets/IndexPage-y9DcUqW9.js +0 -1
- package/dist/web/assets/MainLayout-BRPoh8y0.css +0 -1
- package/dist/web/assets/MainLayout-fQytypS5.js +0 -1
- package/dist/web/assets/Platform-DDGYwfzl.js +0 -1
- package/dist/web/assets/QBtn-DGiC5uLo.js +0 -1
- package/dist/web/assets/QTooltip-CPN6UvuX.js +0 -1
- package/dist/web/assets/ReviewDetailPage-CL6A5zjH.css +0 -1
- package/dist/web/assets/ReviewDetailPage-aMMuYtax.js +0 -1
- package/dist/web/assets/ReviewsPage-B1CTHU13.js +0 -1
- package/dist/web/assets/StatusSwitcher-Bj-XCwSr.js +0 -3
- package/dist/web/assets/StatusSwitcher-BydQq1LI.css +0 -1
- package/dist/web/assets/app-info-store-Dw8pPU-w.js +0 -1
- package/dist/web/assets/client-De34x_P_.js +0 -1
- package/dist/web/assets/engine-oniguruma-Bon2Icja.js +0 -1
- package/dist/web/assets/event-D0kaqRwq.js +0 -1
- package/dist/web/assets/format-Bb6ahGqi.js +0 -1
- package/dist/web/assets/i18n-Dw0eMaZf.js +0 -1
- package/dist/web/assets/index-i1Dlwasy.js +0 -2
- package/dist/web/assets/init-app-CPTVCTFc.js +0 -1
- package/dist/web/assets/pinia-ClnoFqXt.js +0 -1
- package/dist/web/assets/pinia-jMX0pzvu.js +0 -1
- package/dist/web/assets/runtime-core.esm-bundler-Db5Q9Gsb.js +0 -1
- package/dist/web/assets/theme-CgDsrcvS.js +0 -1
- package/dist/web/assets/use-app-theme-D0Pl-VfI.js +0 -1
- package/dist/web/assets/use-server-events-pJWjsMIC.js +0 -1
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { UnknownAgentPresetError, TooManyAgentSessionsError } from "../../errors/agents.js";
|
|
2
|
+
import { MAX_AGENT_SESSIONS } from "../../../shared/agents/constants.js";
|
|
3
|
+
import { uniqueChatName } from "../../../shared/agents/chat-names.js";
|
|
4
|
+
import { AgentSession } from "./agent-session.js";
|
|
5
|
+
import { describePreset } from "../../config/agents.js";
|
|
6
|
+
export function createSessionRegistry(options) {
|
|
7
|
+
const sessions = new Map();
|
|
8
|
+
const unsubscribeSession = new Map();
|
|
9
|
+
const listeners = new Set();
|
|
10
|
+
let lastEventId = 0;
|
|
11
|
+
function broadcast(message) {
|
|
12
|
+
for (const listener of listeners) {
|
|
13
|
+
listener(message);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function drop(id) {
|
|
17
|
+
unsubscribeSession.get(id)?.();
|
|
18
|
+
unsubscribeSession.delete(id);
|
|
19
|
+
sessions.delete(id);
|
|
20
|
+
}
|
|
21
|
+
function closeAll() {
|
|
22
|
+
for (const session of sessions.values()) {
|
|
23
|
+
session.close();
|
|
24
|
+
}
|
|
25
|
+
for (const id of sessions.keys()) {
|
|
26
|
+
drop(id);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
listPresets: () => options.presets.map(describePreset),
|
|
31
|
+
create(presetId, reviewId, name) {
|
|
32
|
+
const preset = options.presets.find(p => p.id === presetId);
|
|
33
|
+
if (!preset) {
|
|
34
|
+
throw new UnknownAgentPresetError(`Unknown agent "${presetId}"`);
|
|
35
|
+
}
|
|
36
|
+
if (sessions.size >= MAX_AGENT_SESSIONS) {
|
|
37
|
+
throw new TooManyAgentSessionsError(`At most ${MAX_AGENT_SESSIONS} chats can be open at once`);
|
|
38
|
+
}
|
|
39
|
+
const session = new AgentSession({
|
|
40
|
+
preset,
|
|
41
|
+
// Open chats are told apart by name: a taken one gets "(1)", "(2)", …
|
|
42
|
+
name: uniqueChatName(name?.trim() || preset.title, [...sessions.values()].map(s => s.name)),
|
|
43
|
+
cwd: options.repositoryPath,
|
|
44
|
+
reviewId,
|
|
45
|
+
nextEventId: () => ++lastEventId,
|
|
46
|
+
...(options.bufferLimit === undefined
|
|
47
|
+
? {}
|
|
48
|
+
: { bufferLimit: options.bufferLimit })
|
|
49
|
+
});
|
|
50
|
+
sessions.set(session.id, session);
|
|
51
|
+
unsubscribeSession.set(session.id, session.onEvent(envelope => {
|
|
52
|
+
broadcast({
|
|
53
|
+
type: 'event',
|
|
54
|
+
envelope: { ...envelope, sessionId: session.id }
|
|
55
|
+
});
|
|
56
|
+
}));
|
|
57
|
+
broadcast({ type: 'sessions' });
|
|
58
|
+
void session.start();
|
|
59
|
+
return session;
|
|
60
|
+
},
|
|
61
|
+
get: id => sessions.get(id),
|
|
62
|
+
list: () => [...sessions.values()].map(s => s.info()),
|
|
63
|
+
close(id) {
|
|
64
|
+
const session = sessions.get(id);
|
|
65
|
+
if (!session) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
session.close();
|
|
69
|
+
drop(id);
|
|
70
|
+
broadcast({ type: 'sessions' });
|
|
71
|
+
return true;
|
|
72
|
+
},
|
|
73
|
+
closeAll,
|
|
74
|
+
subscribe(listener, afterId = 0) {
|
|
75
|
+
const replays = [...sessions.values()].map(session => ({
|
|
76
|
+
sessionId: session.id,
|
|
77
|
+
...session.replay(afterId)
|
|
78
|
+
}));
|
|
79
|
+
for (const { sessionId, gap } of replays) {
|
|
80
|
+
if (gap) {
|
|
81
|
+
listener({ type: 'gap', sessionId });
|
|
82
|
+
}
|
|
83
|
+
// The buffer starts late for a new client or after a gap, so it may
|
|
84
|
+
// lack the events that set the status, settings and open requests.
|
|
85
|
+
if (gap || afterId === 0) {
|
|
86
|
+
const state = sessions.get(sessionId)?.state();
|
|
87
|
+
if (state) {
|
|
88
|
+
listener({ type: 'state', state });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
replays
|
|
93
|
+
.flatMap(({ sessionId, envelopes }) => envelopes.map(envelope => ({ ...envelope, sessionId })))
|
|
94
|
+
.sort((a, b) => a.id - b.id)
|
|
95
|
+
.forEach(envelope => listener({ type: 'event', envelope }));
|
|
96
|
+
listeners.add(listener);
|
|
97
|
+
return () => listeners.delete(listener);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
+
import { ValidationError } from "../errors/comments.js";
|
|
1
2
|
import { createComment as insertComment, findCommentById, listCommentsByReview, updateComment as updateCommentRepo, deleteComment as deleteCommentRepo, setCommentResolved } from "../repositories/comment.repo.js";
|
|
2
|
-
export class ValidationError extends Error {
|
|
3
|
-
}
|
|
4
3
|
export function createComment(db, reviewId, input, eventBus) {
|
|
5
4
|
if (!input.content) {
|
|
6
5
|
throw new ValidationError('"content" is required');
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { diffLines } from 'diff';
|
|
2
|
-
import {
|
|
2
|
+
import { stat } from 'node:fs/promises';
|
|
3
|
+
import { assertSafeRef, listChangedSummaries, resolveWithinRepo, getFileAtRef, getFilesAtRefBatch, listChangedPaths, listUntrackedPaths } from "./git.service.js";
|
|
4
|
+
/** Unchanged lines kept around each change in a hunk, as in `git diff`. */
|
|
5
|
+
const CONTEXT_LINES = 3;
|
|
6
|
+
/** Stamp of a worktree file that cannot be read. */
|
|
7
|
+
const GONE_STAMP = 'gone';
|
|
8
|
+
/**
|
|
9
|
+
* Line count and binary flag of untracked files, by repository and path. A refetch runs on
|
|
10
|
+
* every save, so an untracked file is read again only when its stamp changes.
|
|
11
|
+
*/
|
|
12
|
+
const untrackedCounts = new Map();
|
|
3
13
|
function splitLines(text) {
|
|
4
14
|
if (text === '')
|
|
5
15
|
return [];
|
|
@@ -8,7 +18,6 @@ function splitLines(text) {
|
|
|
8
18
|
lines.pop();
|
|
9
19
|
return lines;
|
|
10
20
|
}
|
|
11
|
-
const CONTEXT_LINES = 3;
|
|
12
21
|
function windowHunks(lines, contextLines = CONTEXT_LINES) {
|
|
13
22
|
if (lines.length === 0)
|
|
14
23
|
return [];
|
|
@@ -160,3 +169,113 @@ export async function getCommitsDiff(repoPath, from, to) {
|
|
|
160
169
|
assertSafeRef(to);
|
|
161
170
|
return buildDiffFiles(repoPath, [from, to], from, to);
|
|
162
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* `mtime:ctime:inode:size` of a file in the worktree. It costs no read. The inode catches
|
|
174
|
+
* a file replaced by rename. Known limit: an edit of the same size within one timestamp
|
|
175
|
+
* tick of the file system keeps the stamp.
|
|
176
|
+
*/
|
|
177
|
+
async function worktreeStamp(repoPath, path) {
|
|
178
|
+
try {
|
|
179
|
+
const info = await stat(resolveWithinRepo(repoPath, path));
|
|
180
|
+
return `${info.mtimeMs}:${info.ctimeMs}:${info.ino}:${info.size}`;
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return GONE_STAMP;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function countLines(content) {
|
|
187
|
+
return splitLines(content).length;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Builds a signature from both sides of the diff. Equal signatures must mean equal hunks,
|
|
191
|
+
* so the old side counts too: a change of only HEAD or only the index changes the hunks.
|
|
192
|
+
*/
|
|
193
|
+
function signatureOf(change, oldSide, newSide) {
|
|
194
|
+
return [
|
|
195
|
+
change.status,
|
|
196
|
+
change.oldPath,
|
|
197
|
+
change.newPath,
|
|
198
|
+
change.additions,
|
|
199
|
+
change.deletions,
|
|
200
|
+
oldSide,
|
|
201
|
+
newSide
|
|
202
|
+
].join('|');
|
|
203
|
+
}
|
|
204
|
+
/** Counts the lines of an untracked file. The file is read only when its stamp changed. */
|
|
205
|
+
async function untrackedSummary(repoPath, path) {
|
|
206
|
+
const stamp = await worktreeStamp(repoPath, path);
|
|
207
|
+
const key = `${repoPath}\0${path}`;
|
|
208
|
+
let counts = untrackedCounts.get(key);
|
|
209
|
+
if (!counts || counts.stamp !== stamp) {
|
|
210
|
+
const file = await getFileAtRef(repoPath, 'WORKTREE', path);
|
|
211
|
+
counts = {
|
|
212
|
+
stamp,
|
|
213
|
+
additions: file.isBinary ? 0 : countLines(file.content),
|
|
214
|
+
isBinary: file.isBinary
|
|
215
|
+
};
|
|
216
|
+
if (stamp === GONE_STAMP) {
|
|
217
|
+
untrackedCounts.delete(key);
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
untrackedCounts.set(key, counts);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const change = {
|
|
224
|
+
oldPath: path,
|
|
225
|
+
newPath: path,
|
|
226
|
+
status: 'added',
|
|
227
|
+
additions: counts.additions,
|
|
228
|
+
deletions: 0,
|
|
229
|
+
isBinary: counts.isBinary
|
|
230
|
+
};
|
|
231
|
+
return { ...change, signature: signatureOf(change, '', stamp) };
|
|
232
|
+
}
|
|
233
|
+
/** Drops the cached counts of files that are no longer untracked in this repository. */
|
|
234
|
+
function pruneUntrackedCounts(repoPath, untrackedPaths) {
|
|
235
|
+
const prefix = `${repoPath}\0`;
|
|
236
|
+
const alive = new Set(untrackedPaths.map(path => prefix + path));
|
|
237
|
+
for (const key of untrackedCounts.keys()) {
|
|
238
|
+
if (key.startsWith(prefix) && !alive.has(key))
|
|
239
|
+
untrackedCounts.delete(key);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Lists the files of one diff side with counts and a signature, but no hunks (ADR 0033).
|
|
244
|
+
* Counts come from git, the hunks of `getFileDiff` from `diffLines`: they may differ by a few lines.
|
|
245
|
+
*/
|
|
246
|
+
export async function getDiffSummaries(repoPath, source) {
|
|
247
|
+
if (source === 'staged') {
|
|
248
|
+
const changes = await listChangedSummaries(repoPath, ['--cached']);
|
|
249
|
+
return changes.map(({ oldOid, newOid, ...change }) => ({
|
|
250
|
+
...change,
|
|
251
|
+
signature: signatureOf(change, oldOid, newOid)
|
|
252
|
+
}));
|
|
253
|
+
}
|
|
254
|
+
const [changes, untrackedPaths] = await Promise.all([
|
|
255
|
+
listChangedSummaries(repoPath, []),
|
|
256
|
+
listUntrackedPaths(repoPath)
|
|
257
|
+
]);
|
|
258
|
+
// The new side is the worktree: git prints zeros for its blob id, so a stamp stands in.
|
|
259
|
+
const tracked = await Promise.all(changes.map(async ({ oldOid, newOid: _newOid, ...change }) => ({
|
|
260
|
+
...change,
|
|
261
|
+
signature: signatureOf(change, oldOid, await worktreeStamp(repoPath, change.newPath))
|
|
262
|
+
})));
|
|
263
|
+
const untracked = await Promise.all(untrackedPaths.map(path => untrackedSummary(repoPath, path)));
|
|
264
|
+
pruneUntrackedCounts(repoPath, untrackedPaths);
|
|
265
|
+
return [...tracked, ...untracked];
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Builds the diff of one file. The caller passes the summary's paths and status, so no
|
|
269
|
+
* whole-repository listing runs per file. Untracked files have no index entry: their old side is empty.
|
|
270
|
+
*/
|
|
271
|
+
export async function getFileDiff(repoPath, source, meta) {
|
|
272
|
+
const [oldRef, newRef] = source === 'staged' ? ['HEAD', 'INDEX'] : ['INDEX', 'WORKTREE'];
|
|
273
|
+
const [oldFile, newFile] = await Promise.all([
|
|
274
|
+
getFileAtRef(repoPath, oldRef, meta.oldPath),
|
|
275
|
+
getFileAtRef(repoPath, newRef, meta.newPath)
|
|
276
|
+
]);
|
|
277
|
+
return computeFileDiff(oldFile.content, newFile.content, {
|
|
278
|
+
...meta,
|
|
279
|
+
isBinary: oldFile.isBinary || newFile.isBinary
|
|
280
|
+
});
|
|
281
|
+
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
+
import { ExportNotFoundError } from "../errors/export.js";
|
|
1
2
|
import { findReviewById } from "../repositories/review.repo.js";
|
|
2
3
|
import { listCommentsByReview } from "../repositories/comment.repo.js";
|
|
3
4
|
import { getFileAtRef } from "./git.service.js";
|
|
4
|
-
export class ExportNotFoundError extends Error {
|
|
5
|
-
}
|
|
6
5
|
const STATUS_LABELS = {
|
|
7
6
|
in_progress: 'в процессе',
|
|
8
7
|
approved: 'одобрено',
|
|
@@ -3,13 +3,14 @@ import { promisify } from 'node:util';
|
|
|
3
3
|
import { readFile } from 'node:fs/promises';
|
|
4
4
|
import { basename, resolve, sep } from 'node:path';
|
|
5
5
|
import { simpleGit } from 'simple-git';
|
|
6
|
+
import { isBinaryBuffer } from "../utils/text.js";
|
|
6
7
|
const execFileAsync = promisify(execFile);
|
|
7
8
|
/**
|
|
8
9
|
* Resolves `path` against `repoPath` and rejects anything that escapes the
|
|
9
10
|
* repository root (e.g. `../../etc/passwd`), since `path` here is untrusted
|
|
10
11
|
* input coming straight off the HTTP request.
|
|
11
12
|
*/
|
|
12
|
-
function resolveWithinRepo(repoPath, path) {
|
|
13
|
+
export function resolveWithinRepo(repoPath, path) {
|
|
13
14
|
const repoRoot = resolve(repoPath);
|
|
14
15
|
const resolved = resolve(repoRoot, path);
|
|
15
16
|
if (resolved !== repoRoot && !resolved.startsWith(repoRoot + sep)) {
|
|
@@ -62,10 +63,8 @@ export async function getFileAtRef(repoPath, ref, path) {
|
|
|
62
63
|
try {
|
|
63
64
|
const absolutePath = resolveWithinRepo(repoPath, path);
|
|
64
65
|
const buffer = await readFile(absolutePath);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
isBinary: buffer.includes(0)
|
|
68
|
-
};
|
|
66
|
+
const isBinary = isBinaryBuffer(buffer);
|
|
67
|
+
return { content: isBinary ? '' : buffer.toString('utf-8'), isBinary };
|
|
69
68
|
}
|
|
70
69
|
catch {
|
|
71
70
|
return { content: '', isBinary: false };
|
|
@@ -78,7 +77,7 @@ export async function getFileAtRef(repoPath, ref, path) {
|
|
|
78
77
|
encoding: 'buffer',
|
|
79
78
|
maxBuffer: 1024 * 1024 * 100
|
|
80
79
|
});
|
|
81
|
-
const isBinary = stdout
|
|
80
|
+
const isBinary = isBinaryBuffer(stdout);
|
|
82
81
|
return {
|
|
83
82
|
content: isBinary ? '' : stdout.toString('utf-8'),
|
|
84
83
|
isBinary
|
|
@@ -119,7 +118,7 @@ function parseCatFileBatchOutput(buffer, count) {
|
|
|
119
118
|
throw new Error('git cat-file --batch: truncated output (short content)');
|
|
120
119
|
}
|
|
121
120
|
offset += size + 1; // skip content + its trailing LF
|
|
122
|
-
const isBinary = content
|
|
121
|
+
const isBinary = isBinaryBuffer(content);
|
|
123
122
|
results.push({
|
|
124
123
|
content: isBinary ? '' : content.toString('utf-8'),
|
|
125
124
|
isBinary
|
|
@@ -212,6 +211,107 @@ function statusCodeToStatus(code) {
|
|
|
212
211
|
return 'modified';
|
|
213
212
|
}
|
|
214
213
|
}
|
|
214
|
+
/**
|
|
215
|
+
* Removes the extra record of a merge conflict. During a conflict, `git diff` prints an
|
|
216
|
+
* `U` record with zero blob ids and also a normal record for the same path. The normal
|
|
217
|
+
* record has the real blob ids and counts, so it stays.
|
|
218
|
+
*/
|
|
219
|
+
function dedupeUnmerged(records) {
|
|
220
|
+
const normalPaths = new Set(records
|
|
221
|
+
.filter(record => record.code !== 'U')
|
|
222
|
+
.map(record => record.item.newPath));
|
|
223
|
+
return records
|
|
224
|
+
.filter(record => record.code !== 'U' || !normalPaths.has(record.item.newPath))
|
|
225
|
+
.map(record => record.item);
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Parses the output of `git diff --raw --numstat -z`. Git prints all raw records first:
|
|
229
|
+
* a `:<modes> <old oid> <new oid> <status>` token, then one path, or two for a rename or copy.
|
|
230
|
+
* The numstat records follow: `<added>\t<removed>\t<path>` in one token. For a rename, the
|
|
231
|
+
* path part is empty, and two path tokens follow. Paths are read by position, so a path
|
|
232
|
+
* with `:`, a TAB or a line feed does not break the parse.
|
|
233
|
+
*/
|
|
234
|
+
export function parseRawNumstat(stdout) {
|
|
235
|
+
const tokens = stdout.split('\0');
|
|
236
|
+
// With -z, the output ends with NUL, so the last token is empty.
|
|
237
|
+
if (tokens[tokens.length - 1] === '')
|
|
238
|
+
tokens.pop();
|
|
239
|
+
const raw = [];
|
|
240
|
+
const numstat = [];
|
|
241
|
+
let i = 0;
|
|
242
|
+
while (i < tokens.length && tokens[i].startsWith(':')) {
|
|
243
|
+
const fields = tokens[i++].split(' ');
|
|
244
|
+
const code = fields[4] ?? 'M';
|
|
245
|
+
const oldOid = fields[2] ?? '';
|
|
246
|
+
const newOid = fields[3] ?? '';
|
|
247
|
+
const status = statusCodeToStatus(code);
|
|
248
|
+
if (code[0] === 'R' || code[0] === 'C') {
|
|
249
|
+
const oldPath = tokens[i++];
|
|
250
|
+
const newPath = tokens[i++];
|
|
251
|
+
raw.push({ code, oldPath, newPath, status, oldOid, newOid });
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
const path = tokens[i++];
|
|
255
|
+
raw.push({ code, oldPath: path, newPath: path, status, oldOid, newOid });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
while (i < tokens.length) {
|
|
259
|
+
const token = tokens[i++];
|
|
260
|
+
// Only the first two TABs separate fields: a path can hold a TAB too.
|
|
261
|
+
const firstTab = token.indexOf('\t');
|
|
262
|
+
const secondTab = token.indexOf('\t', firstTab + 1);
|
|
263
|
+
const added = token.slice(0, firstTab);
|
|
264
|
+
const removed = token.slice(firstTab + 1, secondTab);
|
|
265
|
+
let path = token.slice(secondTab + 1);
|
|
266
|
+
if (path === '') {
|
|
267
|
+
i++; // the old path of the rename
|
|
268
|
+
path = tokens[i++];
|
|
269
|
+
}
|
|
270
|
+
const isBinary = added === '-';
|
|
271
|
+
numstat.push({
|
|
272
|
+
path,
|
|
273
|
+
additions: isBinary ? 0 : Number(added),
|
|
274
|
+
deletions: isBinary ? 0 : Number(removed),
|
|
275
|
+
isBinary
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
return { raw, numstat };
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Joins raw and numstat records. They come from one git run in the same order, so they
|
|
282
|
+
* line up by position. A path mismatch means the parse went wrong: then the join goes by path.
|
|
283
|
+
*/
|
|
284
|
+
function joinRawNumstat(raw, numstat) {
|
|
285
|
+
const aligned = raw.length === numstat.length &&
|
|
286
|
+
raw.every((record, i) => record.newPath === numstat[i].path);
|
|
287
|
+
if (!aligned) {
|
|
288
|
+
process.emitWarning(`git diff --raw --numstat: ${raw.length} raw and ${numstat.length} numstat records do not line up; joining by path`);
|
|
289
|
+
}
|
|
290
|
+
const byPath = aligned
|
|
291
|
+
? undefined
|
|
292
|
+
: new Map(numstat.map(record => [record.path, record]));
|
|
293
|
+
return raw.map(({ code, ...record }, i) => {
|
|
294
|
+
const counts = aligned ? numstat[i] : byPath?.get(record.newPath);
|
|
295
|
+
return {
|
|
296
|
+
code,
|
|
297
|
+
item: {
|
|
298
|
+
...record,
|
|
299
|
+
additions: counts?.additions ?? 0,
|
|
300
|
+
deletions: counts?.deletions ?? 0,
|
|
301
|
+
isBinary: counts?.isBinary ?? false
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Lists changed paths with line counts and both blob ids, without reading file contents.
|
|
308
|
+
* One git run prints both formats, so the records describe one state of the repository.
|
|
309
|
+
*/
|
|
310
|
+
export async function listChangedSummaries(repoPath, diffArgs) {
|
|
311
|
+
const { stdout } = await execFileAsync('git', ['diff', '--raw', '--numstat', '--no-abbrev', '-M', '-z', ...diffArgs], { cwd: repoPath, encoding: 'utf-8', maxBuffer: 1024 * 1024 * 100 });
|
|
312
|
+
const { raw, numstat } = parseRawNumstat(stdout);
|
|
313
|
+
return dedupeUnmerged(joinRawNumstat(raw, numstat));
|
|
314
|
+
}
|
|
215
315
|
/**
|
|
216
316
|
* Lists changed paths for a diff, given the git ref arguments that would
|
|
217
317
|
* normally follow `git diff --name-status -M` (e.g. `['--cached']` for
|
|
@@ -234,14 +334,14 @@ export async function listChangedPaths(repoPath, diffArgs) {
|
|
|
234
334
|
if (code[0] === 'R' || code[0] === 'C') {
|
|
235
335
|
const oldPath = tokens[i++];
|
|
236
336
|
const newPath = tokens[i++];
|
|
237
|
-
results.push({ oldPath, newPath, status });
|
|
337
|
+
results.push({ code, item: { oldPath, newPath, status } });
|
|
238
338
|
}
|
|
239
339
|
else {
|
|
240
340
|
const path = tokens[i++];
|
|
241
|
-
results.push({ oldPath: path, newPath: path, status });
|
|
341
|
+
results.push({ code, item: { oldPath: path, newPath: path, status } });
|
|
242
342
|
}
|
|
243
343
|
}
|
|
244
|
-
return results;
|
|
344
|
+
return dedupeUnmerged(results);
|
|
245
345
|
}
|
|
246
346
|
/** Lists paths that git has never tracked (respects .gitignore). */
|
|
247
347
|
export async function listUntrackedPaths(repoPath) {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ValidationError } from "../errors/reviews.js";
|
|
2
|
+
import { createReview as insertReview, findReviewById, listReviews, updateReviewStatus as updateReviewStatusRepo, deleteReview as deleteReviewRepo } from "../repositories/review.repo.js";
|
|
3
|
+
import { UniqueNameError } from "../errors/reviews.js";
|
|
2
4
|
import { listCommentsByReview, deleteComment } from "../repositories/comment.repo.js";
|
|
3
5
|
import { insertReviewFiles, deleteReviewFiles } from "../repositories/review-file.repo.js";
|
|
4
6
|
import { getStagedDiff, getUnstagedDiff, getBranchDiff, getCommitsDiff } from "./diff.service.js";
|
|
5
7
|
import { getRepositoryInfo } from "./git.service.js";
|
|
6
|
-
export class ValidationError extends Error {
|
|
7
|
-
}
|
|
8
8
|
const REVIEW_NAME_MONTHS_RU = [
|
|
9
9
|
'янв',
|
|
10
10
|
'февр',
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { accessSync, constants } from 'node:fs';
|
|
2
|
+
import { delimiter, isAbsolute, join } from 'node:path';
|
|
3
|
+
export function isOnPath(binary, pathEnv = process.env.PATH) {
|
|
4
|
+
if (isAbsolute(binary)) {
|
|
5
|
+
try {
|
|
6
|
+
accessSync(binary, constants.X_OK);
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
for (const dir of (pathEnv ?? '').split(delimiter)) {
|
|
14
|
+
if (dir === '') {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
accessSync(join(dir, binary), constants.X_OK);
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// not in this directory
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
/** The client-supplied name must not escape the directory it is written into. */
|
|
3
|
+
export function safeFileName(name) {
|
|
4
|
+
const base = basename(name.replaceAll('\\', '/')).replaceAll('\0', '');
|
|
5
|
+
return base === '' || base === '.' || base === '..' ? 'attachment' : base;
|
|
6
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sends one SSE message and never fails. A client can leave at any time, and
|
|
3
|
+
* `send` then rejects. Nothing awaits that promise, so Node would end the
|
|
4
|
+
* process on the unhandled rejection.
|
|
5
|
+
*/
|
|
6
|
+
export function safeSend(sse, message) {
|
|
7
|
+
if (!sse.isConnected) {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
sse.send(message).catch(() => { });
|
|
11
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Ends a cut text when the caller gives no marker of its own. */
|
|
2
|
+
const DEFAULT_TRUNCATION_MARKER = '\n… (truncated)';
|
|
3
|
+
/**
|
|
4
|
+
* Cuts `text` to `max` characters. `marker` gets the number of characters
|
|
5
|
+
* dropped and returns what is appended, so each caller can tell its reader
|
|
6
|
+
* (browser, agent) what happened.
|
|
7
|
+
*/
|
|
8
|
+
export function truncateText(text, max, marker = () => DEFAULT_TRUNCATION_MARKER) {
|
|
9
|
+
return text.length > max
|
|
10
|
+
? `${text.slice(0, max)}${marker(text.length - max)}`
|
|
11
|
+
: text;
|
|
12
|
+
}
|
|
13
|
+
/** True when the bytes contain a NUL: the quick test git itself uses to call a file binary. */
|
|
14
|
+
export function isBinaryBuffer(bytes) {
|
|
15
|
+
return bytes.includes(0);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The text of `bytes` if they are valid UTF-8 without NUL, else `null`.
|
|
19
|
+
* Stricter than `Buffer#toString`, which turns bad bytes into U+FFFD.
|
|
20
|
+
*/
|
|
21
|
+
export function decodeText(bytes) {
|
|
22
|
+
try {
|
|
23
|
+
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
24
|
+
return text.includes('\0') ? null : text;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const normalize = (name) => name.trim().toLowerCase();
|
|
2
|
+
/**
|
|
3
|
+
* `base` if no chat has that name yet, else `base (1)`, `base (2)`, … — the
|
|
4
|
+
* first free one. Names compare without regard to case or surrounding blanks,
|
|
5
|
+
* so open chats can always be told apart in the tabs and the menu.
|
|
6
|
+
*/
|
|
7
|
+
export function uniqueChatName(base, taken) {
|
|
8
|
+
const used = new Set(taken.map(normalize));
|
|
9
|
+
const wanted = base.trim();
|
|
10
|
+
if (!used.has(normalize(wanted)))
|
|
11
|
+
return wanted;
|
|
12
|
+
for (let n = 1;; n++) {
|
|
13
|
+
const candidate = `${wanted} (${n})`;
|
|
14
|
+
if (!used.has(normalize(candidate)))
|
|
15
|
+
return candidate;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Chats open at once (ADR 0024); the server refuses more, the UI hides "add". */
|
|
2
|
+
export const MAX_AGENT_SESSIONS = 5;
|
|
3
|
+
/** Raw bytes per attachment; shared by the upload UI and the server. */
|
|
4
|
+
export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
|
5
|
+
export const DEFAULT_FILE_SEARCH_LIMIT = 30;
|
|
6
|
+
export const MAX_FILE_SEARCH_LIMIT = 100;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function formatDiffFile(file) {
|
|
2
|
+
const lines = [
|
|
3
|
+
`--- ${file.oldPath ? `a/${file.oldPath}` : '/dev/null'}`,
|
|
4
|
+
`+++ ${file.newPath ? `b/${file.newPath}` : '/dev/null'}`
|
|
5
|
+
];
|
|
6
|
+
if (file.isBinary) {
|
|
7
|
+
lines.push('(binary file)');
|
|
8
|
+
}
|
|
9
|
+
for (const hunk of file.hunks) {
|
|
10
|
+
lines.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`);
|
|
11
|
+
for (const line of hunk.lines) {
|
|
12
|
+
const prefix = line.type === 'added' ? '+' : line.type === 'removed' ? '-' : ' ';
|
|
13
|
+
lines.push(prefix + line.content);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return lines.join('\n');
|
|
17
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
function isSubsequence(needle, haystack) {
|
|
2
|
+
let at = 0;
|
|
3
|
+
for (const char of haystack) {
|
|
4
|
+
if (char === needle[at]) {
|
|
5
|
+
at++;
|
|
6
|
+
}
|
|
7
|
+
if (at === needle.length) {
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
return needle.length === 0;
|
|
12
|
+
}
|
|
13
|
+
/** Lower is better; undefined = no match. */
|
|
14
|
+
function score(path, query) {
|
|
15
|
+
const lower = path.toLowerCase();
|
|
16
|
+
const base = lower.slice(lower.lastIndexOf('/') + 1);
|
|
17
|
+
if (base === query)
|
|
18
|
+
return 0;
|
|
19
|
+
if (base.startsWith(query))
|
|
20
|
+
return 1;
|
|
21
|
+
if (base.includes(query))
|
|
22
|
+
return 2;
|
|
23
|
+
if (lower.includes(query))
|
|
24
|
+
return 3;
|
|
25
|
+
if (isSubsequence(query, lower))
|
|
26
|
+
return 4;
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Orders `files` by how well they match `query`: file name first (exact,
|
|
31
|
+
* prefix, substring), then anywhere in the path, then as scattered letters.
|
|
32
|
+
* Ties go to the shorter path. An empty query matches everything.
|
|
33
|
+
*/
|
|
34
|
+
export function rankFiles(files, query) {
|
|
35
|
+
const needle = query.trim().toLowerCase();
|
|
36
|
+
const scored = [];
|
|
37
|
+
for (const path of files) {
|
|
38
|
+
const rank = needle === '' ? 0 : score(path, needle);
|
|
39
|
+
if (rank !== undefined) {
|
|
40
|
+
scored.push({ path, rank });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return scored
|
|
44
|
+
.sort((a, b) => a.rank - b.rank ||
|
|
45
|
+
a.path.length - b.path.length ||
|
|
46
|
+
a.path.localeCompare(b.path))
|
|
47
|
+
.map(entry => entry.path);
|
|
48
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Type } from '@sinclair/typebox';
|
|
2
|
+
import { MAX_ATTACHMENT_BYTES, MAX_FILE_SEARCH_LIMIT } from "./constants.js";
|
|
3
|
+
export const SessionParams = Type.Object({ id: Type.String() });
|
|
4
|
+
export const PermissionParams = Type.Object({
|
|
5
|
+
id: Type.String(),
|
|
6
|
+
requestId: Type.String()
|
|
7
|
+
});
|
|
8
|
+
export const CreateSessionBody = Type.Object({
|
|
9
|
+
presetId: Type.String({ minLength: 1 }),
|
|
10
|
+
name: Type.Optional(Type.String({ maxLength: 60 })),
|
|
11
|
+
reviewId: Type.Optional(Type.String())
|
|
12
|
+
});
|
|
13
|
+
export const AutoApproveBody = Type.Object({ enabled: Type.Boolean() });
|
|
14
|
+
export const FileSearchQuery = Type.Object({
|
|
15
|
+
q: Type.Optional(Type.String({ maxLength: 200 })),
|
|
16
|
+
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_FILE_SEARCH_LIMIT }))
|
|
17
|
+
});
|
|
18
|
+
export const ContextItem = Type.Union([
|
|
19
|
+
Type.Object({
|
|
20
|
+
kind: Type.Literal('diff'),
|
|
21
|
+
source: Type.Union([Type.Literal('staged'), Type.Literal('unstaged')]),
|
|
22
|
+
path: Type.Optional(Type.String({ minLength: 1 }))
|
|
23
|
+
}),
|
|
24
|
+
Type.Object({
|
|
25
|
+
kind: Type.Literal('file'),
|
|
26
|
+
path: Type.String({ minLength: 1 })
|
|
27
|
+
}),
|
|
28
|
+
Type.Object({ kind: Type.Literal('review'), reviewId: Type.String() }),
|
|
29
|
+
Type.Object({
|
|
30
|
+
kind: Type.Literal('attachment'),
|
|
31
|
+
name: Type.String({ minLength: 1, maxLength: 255 }),
|
|
32
|
+
mimeType: Type.String({ maxLength: 255 }),
|
|
33
|
+
data: Type.String({
|
|
34
|
+
maxLength: Math.ceil(MAX_ATTACHMENT_BYTES / 3) * 4
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
]);
|
|
38
|
+
export const PromptBody = Type.Object({
|
|
39
|
+
text: Type.String({ minLength: 1 }),
|
|
40
|
+
context: Type.Optional(Type.Array(ContextItem, { maxItems: 20 }))
|
|
41
|
+
});
|
|
42
|
+
export const PermissionBody = Type.Object({
|
|
43
|
+
optionId: Type.Optional(Type.String())
|
|
44
|
+
});
|
|
45
|
+
export const ConfigBody = Type.Object({
|
|
46
|
+
configId: Type.String({ minLength: 1 }),
|
|
47
|
+
// No schema type on purpose: a Union/anyOf makes Fastify's Ajv coerce `true`
|
|
48
|
+
// into the string "true", and a `type` array trips Ajv's strictTypes. The
|
|
49
|
+
// session checks the value against the option the agent actually offered.
|
|
50
|
+
value: Type.Unsafe({})
|
|
51
|
+
});
|