@ilya-lesikov/pi-pi 0.1.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/3p/pi-ask-user/index.ts +1835 -0
- package/3p/pi-ask-user/package.json +50 -0
- package/3p/pi-ask-user/single-select-layout.ts +203 -0
- package/3p/pi-lsp/extensions/lsp/client.ts +542 -0
- package/3p/pi-lsp/extensions/lsp/config.ts +135 -0
- package/3p/pi-lsp/extensions/lsp/effects/command.ts +42 -0
- package/3p/pi-lsp/extensions/lsp/effects/filesystem.ts +50 -0
- package/3p/pi-lsp/extensions/lsp/effects/runtime.ts +21 -0
- package/3p/pi-lsp/extensions/lsp/errors.ts +113 -0
- package/3p/pi-lsp/extensions/lsp/formatting.ts +315 -0
- package/3p/pi-lsp/extensions/lsp/index.ts +231 -0
- package/3p/pi-lsp/extensions/lsp/protocol.ts +209 -0
- package/3p/pi-lsp/extensions/lsp/retry.ts +39 -0
- package/3p/pi-lsp/extensions/lsp/tools/programs.ts +293 -0
- package/3p/pi-lsp/extensions/lsp/tools.ts +89 -0
- package/3p/pi-lsp/extensions/lsp/types.ts +243 -0
- package/3p/pi-lsp/package.json +54 -0
- package/3p/pi-plannotator/AGENTS.md +583 -0
- package/3p/pi-plannotator/apps/pi-extension/README.md +228 -0
- package/3p/pi-plannotator/apps/pi-extension/assistant-message.ts +128 -0
- package/3p/pi-plannotator/apps/pi-extension/config.test.ts +166 -0
- package/3p/pi-plannotator/apps/pi-extension/config.ts +318 -0
- package/3p/pi-plannotator/apps/pi-extension/current-pi-session.ts +147 -0
- package/3p/pi-plannotator/apps/pi-extension/index.ts +1279 -0
- package/3p/pi-plannotator/apps/pi-extension/package.json +56 -0
- package/3p/pi-plannotator/apps/pi-extension/plannotator-browser.test.ts +13 -0
- package/3p/pi-plannotator/apps/pi-extension/plannotator-browser.ts +616 -0
- package/3p/pi-plannotator/apps/pi-extension/plannotator-events.ts +342 -0
- package/3p/pi-plannotator/apps/pi-extension/plannotator.json +12 -0
- package/3p/pi-plannotator/apps/pi-extension/server/agent-jobs.ts +515 -0
- package/3p/pi-plannotator/apps/pi-extension/server/ai-runtime.ts +169 -0
- package/3p/pi-plannotator/apps/pi-extension/server/annotations.ts +85 -0
- package/3p/pi-plannotator/apps/pi-extension/server/external-annotations.ts +189 -0
- package/3p/pi-plannotator/apps/pi-extension/server/handlers.ts +210 -0
- package/3p/pi-plannotator/apps/pi-extension/server/helpers.ts +78 -0
- package/3p/pi-plannotator/apps/pi-extension/server/ide.ts +46 -0
- package/3p/pi-plannotator/apps/pi-extension/server/integrations.ts +195 -0
- package/3p/pi-plannotator/apps/pi-extension/server/network.test.ts +158 -0
- package/3p/pi-plannotator/apps/pi-extension/server/network.ts +268 -0
- package/3p/pi-plannotator/apps/pi-extension/server/pr.ts +126 -0
- package/3p/pi-plannotator/apps/pi-extension/server/project.ts +64 -0
- package/3p/pi-plannotator/apps/pi-extension/server/reference.ts +362 -0
- package/3p/pi-plannotator/apps/pi-extension/server/serverAnnotate.ts +199 -0
- package/3p/pi-plannotator/apps/pi-extension/server/serverPlan.ts +505 -0
- package/3p/pi-plannotator/apps/pi-extension/server/serverReview.ts +1229 -0
- package/3p/pi-plannotator/apps/pi-extension/server/vcs.ts +121 -0
- package/3p/pi-plannotator/apps/pi-extension/server.test.ts +966 -0
- package/3p/pi-plannotator/apps/pi-extension/server.ts +44 -0
- package/3p/pi-plannotator/apps/pi-extension/tool-scope.test.ts +88 -0
- package/3p/pi-plannotator/apps/pi-extension/tool-scope.ts +39 -0
- package/3p/pi-plannotator/apps/pi-extension/tsconfig.json +16 -0
- package/3p/pi-plannotator/apps/pi-extension/vendor.sh +48 -0
- package/3p/pi-plannotator/package.json +53 -0
- package/3p/pi-plannotator/packages/ai/ai.test.ts +1407 -0
- package/3p/pi-plannotator/packages/ai/base-session.ts +94 -0
- package/3p/pi-plannotator/packages/ai/context.ts +250 -0
- package/3p/pi-plannotator/packages/ai/endpoints.ts +326 -0
- package/3p/pi-plannotator/packages/ai/index.ts +105 -0
- package/3p/pi-plannotator/packages/ai/package.json +21 -0
- package/3p/pi-plannotator/packages/ai/provider.ts +103 -0
- package/3p/pi-plannotator/packages/ai/providers/claude-agent-sdk.ts +447 -0
- package/3p/pi-plannotator/packages/ai/providers/codex-sdk.ts +430 -0
- package/3p/pi-plannotator/packages/ai/providers/command-path.ts +115 -0
- package/3p/pi-plannotator/packages/ai/providers/opencode-sdk.ts +546 -0
- package/3p/pi-plannotator/packages/ai/providers/pi-events.ts +110 -0
- package/3p/pi-plannotator/packages/ai/providers/pi-sdk-node.ts +425 -0
- package/3p/pi-plannotator/packages/ai/providers/pi-sdk.ts +469 -0
- package/3p/pi-plannotator/packages/ai/session-manager.ts +195 -0
- package/3p/pi-plannotator/packages/ai/tsconfig.json +15 -0
- package/3p/pi-plannotator/packages/ai/types.ts +379 -0
- package/3p/pi-plannotator/packages/server/agent-jobs.ts +538 -0
- package/3p/pi-plannotator/packages/server/agent-review-message.test.ts +135 -0
- package/3p/pi-plannotator/packages/server/agent-review-message.ts +243 -0
- package/3p/pi-plannotator/packages/server/ai-runtime.ts +108 -0
- package/3p/pi-plannotator/packages/server/annotate.ts +401 -0
- package/3p/pi-plannotator/packages/server/browser.test.ts +87 -0
- package/3p/pi-plannotator/packages/server/browser.ts +245 -0
- package/3p/pi-plannotator/packages/server/claude-review.ts +352 -0
- package/3p/pi-plannotator/packages/server/code-nav.ts +73 -0
- package/3p/pi-plannotator/packages/server/codex-review-schema.json +41 -0
- package/3p/pi-plannotator/packages/server/codex-review.ts +330 -0
- package/3p/pi-plannotator/packages/server/config.ts +8 -0
- package/3p/pi-plannotator/packages/server/draft.ts +1 -0
- package/3p/pi-plannotator/packages/server/editor-annotations.ts +76 -0
- package/3p/pi-plannotator/packages/server/external-annotations.test.ts +18 -0
- package/3p/pi-plannotator/packages/server/external-annotations.ts +207 -0
- package/3p/pi-plannotator/packages/server/git.ts +141 -0
- package/3p/pi-plannotator/packages/server/goal-setup.test.ts +55 -0
- package/3p/pi-plannotator/packages/server/goal-setup.ts +248 -0
- package/3p/pi-plannotator/packages/server/ide.ts +43 -0
- package/3p/pi-plannotator/packages/server/image.test.ts +63 -0
- package/3p/pi-plannotator/packages/server/image.ts +66 -0
- package/3p/pi-plannotator/packages/server/index.ts +648 -0
- package/3p/pi-plannotator/packages/server/integrations.test.ts +172 -0
- package/3p/pi-plannotator/packages/server/integrations.ts +214 -0
- package/3p/pi-plannotator/packages/server/jj.test.ts +69 -0
- package/3p/pi-plannotator/packages/server/jj.ts +86 -0
- package/3p/pi-plannotator/packages/server/p4.ts +417 -0
- package/3p/pi-plannotator/packages/server/package.json +40 -0
- package/3p/pi-plannotator/packages/server/path-utils.ts +18 -0
- package/3p/pi-plannotator/packages/server/pr.ts +146 -0
- package/3p/pi-plannotator/packages/server/project.test.ts +115 -0
- package/3p/pi-plannotator/packages/server/project.ts +45 -0
- package/3p/pi-plannotator/packages/server/reference-handlers.ts +404 -0
- package/3p/pi-plannotator/packages/server/remote.test.ts +150 -0
- package/3p/pi-plannotator/packages/server/remote.ts +74 -0
- package/3p/pi-plannotator/packages/server/repo.ts +80 -0
- package/3p/pi-plannotator/packages/server/resolve-file.test.ts +310 -0
- package/3p/pi-plannotator/packages/server/review-workspace.test.ts +1032 -0
- package/3p/pi-plannotator/packages/server/review-workspace.ts +48 -0
- package/3p/pi-plannotator/packages/server/review.ts +1318 -0
- package/3p/pi-plannotator/packages/server/sessions.ts +111 -0
- package/3p/pi-plannotator/packages/server/share-url.ts +53 -0
- package/3p/pi-plannotator/packages/server/shared-handlers.test.ts +43 -0
- package/3p/pi-plannotator/packages/server/shared-handlers.ts +177 -0
- package/3p/pi-plannotator/packages/server/storage.test.ts +176 -0
- package/3p/pi-plannotator/packages/server/storage.ts +17 -0
- package/3p/pi-plannotator/packages/server/tour/tour-review.test.ts +146 -0
- package/3p/pi-plannotator/packages/server/tour/tour-review.ts +604 -0
- package/3p/pi-plannotator/packages/server/tsconfig.json +15 -0
- package/3p/pi-plannotator/packages/server/vcs.test.ts +48 -0
- package/3p/pi-plannotator/packages/server/vcs.ts +80 -0
- package/3p/pi-plannotator/packages/shared/agent-jobs.ts +132 -0
- package/3p/pi-plannotator/packages/shared/agents.ts +53 -0
- package/3p/pi-plannotator/packages/shared/annotate-args.test.ts +386 -0
- package/3p/pi-plannotator/packages/shared/annotate-args.ts +107 -0
- package/3p/pi-plannotator/packages/shared/at-reference.test.ts +99 -0
- package/3p/pi-plannotator/packages/shared/at-reference.ts +52 -0
- package/3p/pi-plannotator/packages/shared/checklist.ts +52 -0
- package/3p/pi-plannotator/packages/shared/code-file.test.ts +112 -0
- package/3p/pi-plannotator/packages/shared/code-file.ts +41 -0
- package/3p/pi-plannotator/packages/shared/code-nav.test.ts +515 -0
- package/3p/pi-plannotator/packages/shared/code-nav.ts +436 -0
- package/3p/pi-plannotator/packages/shared/compress.ts +51 -0
- package/3p/pi-plannotator/packages/shared/config.ts +262 -0
- package/3p/pi-plannotator/packages/shared/crypto.test.ts +172 -0
- package/3p/pi-plannotator/packages/shared/crypto.ts +97 -0
- package/3p/pi-plannotator/packages/shared/data-dir.ts +42 -0
- package/3p/pi-plannotator/packages/shared/diff-paths.test.ts +30 -0
- package/3p/pi-plannotator/packages/shared/diff-paths.ts +137 -0
- package/3p/pi-plannotator/packages/shared/draft.ts +64 -0
- package/3p/pi-plannotator/packages/shared/external-annotation.ts +397 -0
- package/3p/pi-plannotator/packages/shared/extract-code-paths.test.ts +59 -0
- package/3p/pi-plannotator/packages/shared/extract-code-paths.ts +66 -0
- package/3p/pi-plannotator/packages/shared/favicon.ts +5 -0
- package/3p/pi-plannotator/packages/shared/feedback-templates.test.ts +65 -0
- package/3p/pi-plannotator/packages/shared/feedback-templates.ts +29 -0
- package/3p/pi-plannotator/packages/shared/goal-setup.test.ts +231 -0
- package/3p/pi-plannotator/packages/shared/goal-setup.ts +336 -0
- package/3p/pi-plannotator/packages/shared/html-to-markdown.test.ts +62 -0
- package/3p/pi-plannotator/packages/shared/html-to-markdown.ts +32 -0
- package/3p/pi-plannotator/packages/shared/improvement-hooks.test.ts +135 -0
- package/3p/pi-plannotator/packages/shared/improvement-hooks.ts +115 -0
- package/3p/pi-plannotator/packages/shared/integrations-common.ts +243 -0
- package/3p/pi-plannotator/packages/shared/jj-core.test.ts +236 -0
- package/3p/pi-plannotator/packages/shared/jj-core.ts +433 -0
- package/3p/pi-plannotator/packages/shared/package.json +55 -0
- package/3p/pi-plannotator/packages/shared/pfm-reminder.test.ts +88 -0
- package/3p/pi-plannotator/packages/shared/pfm-reminder.ts +80 -0
- package/3p/pi-plannotator/packages/shared/pr-github.ts +661 -0
- package/3p/pi-plannotator/packages/shared/pr-gitlab.test.ts +202 -0
- package/3p/pi-plannotator/packages/shared/pr-gitlab.ts +620 -0
- package/3p/pi-plannotator/packages/shared/pr-provider.test.ts +266 -0
- package/3p/pi-plannotator/packages/shared/pr-provider.ts +123 -0
- package/3p/pi-plannotator/packages/shared/pr-stack.test.ts +104 -0
- package/3p/pi-plannotator/packages/shared/pr-stack.ts +194 -0
- package/3p/pi-plannotator/packages/shared/pr-types.ts +326 -0
- package/3p/pi-plannotator/packages/shared/project.ts +71 -0
- package/3p/pi-plannotator/packages/shared/prompts-integration.test.ts +421 -0
- package/3p/pi-plannotator/packages/shared/prompts.test.ts +504 -0
- package/3p/pi-plannotator/packages/shared/prompts.ts +247 -0
- package/3p/pi-plannotator/packages/shared/reference-common.ts +87 -0
- package/3p/pi-plannotator/packages/shared/repo.ts +71 -0
- package/3p/pi-plannotator/packages/shared/resolve-file.test.ts +113 -0
- package/3p/pi-plannotator/packages/shared/resolve-file.ts +509 -0
- package/3p/pi-plannotator/packages/shared/review-args.test.ts +64 -0
- package/3p/pi-plannotator/packages/shared/review-args.ts +85 -0
- package/3p/pi-plannotator/packages/shared/review-core.test.ts +286 -0
- package/3p/pi-plannotator/packages/shared/review-core.ts +895 -0
- package/3p/pi-plannotator/packages/shared/review-workspace-node.ts +230 -0
- package/3p/pi-plannotator/packages/shared/review-workspace.ts +436 -0
- package/3p/pi-plannotator/packages/shared/semantic-diff-types.ts +76 -0
- package/3p/pi-plannotator/packages/shared/semantic-diff.test.ts +322 -0
- package/3p/pi-plannotator/packages/shared/semantic-diff.ts +520 -0
- package/3p/pi-plannotator/packages/shared/storage.ts +378 -0
- package/3p/pi-plannotator/packages/shared/tour.ts +61 -0
- package/3p/pi-plannotator/packages/shared/tsconfig.json +15 -0
- package/3p/pi-plannotator/packages/shared/types.ts +29 -0
- package/3p/pi-plannotator/packages/shared/url-to-markdown.test.ts +177 -0
- package/3p/pi-plannotator/packages/shared/url-to-markdown.ts +351 -0
- package/3p/pi-plannotator/packages/shared/vcs-core.test.ts +332 -0
- package/3p/pi-plannotator/packages/shared/vcs-core.ts +482 -0
- package/3p/pi-plannotator/packages/shared/worktree-pool.test.ts +162 -0
- package/3p/pi-plannotator/packages/shared/worktree-pool.ts +103 -0
- package/3p/pi-plannotator/packages/shared/worktree.ts +119 -0
- package/3p/pi-subagents/package.json +50 -0
- package/3p/pi-subagents/src/agent-manager.ts +413 -0
- package/3p/pi-subagents/src/agent-runner.ts +502 -0
- package/3p/pi-subagents/src/agent-types.ts +248 -0
- package/3p/pi-subagents/src/context.ts +58 -0
- package/3p/pi-subagents/src/cross-extension-rpc.ts +135 -0
- package/3p/pi-subagents/src/custom-agents.ts +137 -0
- package/3p/pi-subagents/src/default-agents.ts +144 -0
- package/3p/pi-subagents/src/env.ts +33 -0
- package/3p/pi-subagents/src/group-join.ts +141 -0
- package/3p/pi-subagents/src/index.ts +1811 -0
- package/3p/pi-subagents/src/invocation-config.ts +40 -0
- package/3p/pi-subagents/src/memory.ts +165 -0
- package/3p/pi-subagents/src/model-resolver.ts +81 -0
- package/3p/pi-subagents/src/output-file.ts +77 -0
- package/3p/pi-subagents/src/prompts.ts +85 -0
- package/3p/pi-subagents/src/skill-loader.ts +79 -0
- package/3p/pi-subagents/src/types.ts +111 -0
- package/3p/pi-subagents/src/ui/agent-widget.ts +496 -0
- package/3p/pi-subagents/src/ui/conversation-viewer.ts +250 -0
- package/3p/pi-subagents/src/worktree.ts +162 -0
- package/3p/pi-tasks/package.json +52 -0
- package/3p/pi-tasks/src/auto-clear.ts +91 -0
- package/3p/pi-tasks/src/index.ts +1150 -0
- package/3p/pi-tasks/src/process-tracker.ts +140 -0
- package/3p/pi-tasks/src/reminder-cadence.ts +90 -0
- package/3p/pi-tasks/src/task-store.ts +324 -0
- package/3p/pi-tasks/src/tasks-config.ts +27 -0
- package/3p/pi-tasks/src/types.ts +40 -0
- package/3p/pi-tasks/src/ui/settings-menu.ts +152 -0
- package/3p/pi-tasks/src/ui/task-widget.ts +296 -0
- package/AGENTS.md +28 -0
- package/LICENSE +201 -0
- package/extensions/orchestrator/agents/brainstorm-reviewer.ts +77 -0
- package/extensions/orchestrator/agents/code-reviewer.ts +89 -0
- package/extensions/orchestrator/agents/explore.ts +33 -0
- package/extensions/orchestrator/agents/librarian.ts +43 -0
- package/extensions/orchestrator/agents/plan-reviewer.ts +75 -0
- package/extensions/orchestrator/agents/planner.ts +61 -0
- package/extensions/orchestrator/agents/registry.ts +156 -0
- package/extensions/orchestrator/agents/task.ts +50 -0
- package/extensions/orchestrator/agents/tool-routing.ts +84 -0
- package/extensions/orchestrator/ast-search.ts +85 -0
- package/extensions/orchestrator/cbm.ts +330 -0
- package/extensions/orchestrator/command-handlers.test.ts +163 -0
- package/extensions/orchestrator/command-handlers.ts +116 -0
- package/extensions/orchestrator/commands.test.ts +81 -0
- package/extensions/orchestrator/commands.ts +75 -0
- package/extensions/orchestrator/config.test.ts +221 -0
- package/extensions/orchestrator/config.ts +230 -0
- package/extensions/orchestrator/context.test.ts +293 -0
- package/extensions/orchestrator/context.ts +206 -0
- package/extensions/orchestrator/event-handlers.test.ts +190 -0
- package/extensions/orchestrator/event-handlers.ts +1416 -0
- package/extensions/orchestrator/exa.ts +104 -0
- package/extensions/orchestrator/flant-infra.ts +486 -0
- package/extensions/orchestrator/index.ts +80 -0
- package/extensions/orchestrator/integration.test.ts +1214 -0
- package/extensions/orchestrator/orchestrator.test.ts +252 -0
- package/extensions/orchestrator/orchestrator.ts +538 -0
- package/extensions/orchestrator/phases/brainstorm.test.ts +15 -0
- package/extensions/orchestrator/phases/brainstorm.ts +273 -0
- package/extensions/orchestrator/phases/implementation.ts +40 -0
- package/extensions/orchestrator/phases/machine.test.ts +293 -0
- package/extensions/orchestrator/phases/machine.ts +209 -0
- package/extensions/orchestrator/phases/planning.ts +255 -0
- package/extensions/orchestrator/phases/review.ts +193 -0
- package/extensions/orchestrator/plannotator.ts +56 -0
- package/extensions/orchestrator/pp-menu.ts +866 -0
- package/extensions/orchestrator/state.test.ts +343 -0
- package/extensions/orchestrator/state.ts +237 -0
- package/extensions/orchestrator/validate-artifacts.test.ts +88 -0
- package/extensions/orchestrator/validate-artifacts.ts +272 -0
- package/extensions/orchestrator/vendor.d.ts +26 -0
- package/package.json +73 -0
- package/scripts/postinstall.sh +18 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL-to-Markdown conversion.
|
|
3
|
+
*
|
|
4
|
+
* Fetches a URL via Jina Reader (default) or plain fetch + Turndown,
|
|
5
|
+
* returning clean markdown for the annotation pipeline.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { htmlToMarkdown } from "./html-to-markdown";
|
|
9
|
+
|
|
10
|
+
export interface UrlToMarkdownOptions {
|
|
11
|
+
/** Whether to use Jina Reader (true) or plain fetch+Turndown (false). */
|
|
12
|
+
useJina: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface UrlToMarkdownResult {
|
|
16
|
+
markdown: string;
|
|
17
|
+
source: "jina" | "fetch+turndown" | "fetch-raw" | "content-negotiation";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** True when the source indicates the markdown was converted from HTML,
|
|
21
|
+
* not returned as-is from the origin. */
|
|
22
|
+
export const isConvertedSource = (source: UrlToMarkdownResult["source"]): boolean =>
|
|
23
|
+
source === "jina" || source === "fetch+turndown";
|
|
24
|
+
|
|
25
|
+
const FETCH_TIMEOUT_MS = 30_000;
|
|
26
|
+
const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB — matches local HTML file guard
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Skip Jina for local/private URLs — fetch them directly instead.
|
|
30
|
+
*
|
|
31
|
+
* IMPORTANT — IPv6 hostname format (verified empirically in Bun 1.3.11 and Node 22):
|
|
32
|
+
* The WHATWG URL `hostname` getter returns IPv6 addresses WITH brackets.
|
|
33
|
+
* This is why PRIVATE_IPV6 uses `^\[` — it matches the actual runtime output.
|
|
34
|
+
*
|
|
35
|
+
* Verified outputs (both Bun and Node return identical results):
|
|
36
|
+
* new URL("http://[::1]:3000/").hostname → "[::1]"
|
|
37
|
+
* new URL("http://[fe80::1]/").hostname → "[fe80::1]"
|
|
38
|
+
* new URL("http://[fc00::1]/").hostname → "[fc00::1]"
|
|
39
|
+
* new URL("http://[fd12::1]/").hostname → "[fd12::1]"
|
|
40
|
+
* new URL("http://[::ffff:192.168.0.1]/").hostname → "[::ffff:c0a8:1]"
|
|
41
|
+
* new URL("http://[::ffff:169.254.169.254]/").hostname → "[::ffff:a9fe:a9fe]"
|
|
42
|
+
*
|
|
43
|
+
* The unbracketed "::1" check (line below) covers the edge case defensively.
|
|
44
|
+
*/
|
|
45
|
+
const PRIVATE_IPV4 = /^(10\.\d{1,3}|192\.168|172\.(1[6-9]|2\d|3[01])|169\.254)\.\d{1,3}\.\d{1,3}$/;
|
|
46
|
+
// Bracketed IPv6 private/reserved prefixes (matches WHATWG URL hostname getter output).
|
|
47
|
+
// fc00::/7 covers fc00:: through fdff::, so match [fc or [fd prefix.
|
|
48
|
+
const PRIVATE_IPV6 = /^\[(::1|::ffff:|fe80:|fc[0-9a-f]{2}:|fd[0-9a-f]{2}:)/i;
|
|
49
|
+
function isLocalUrl(url: string): boolean {
|
|
50
|
+
try {
|
|
51
|
+
const { hostname } = new URL(url);
|
|
52
|
+
if (
|
|
53
|
+
hostname === "localhost" ||
|
|
54
|
+
hostname === "::1" ||
|
|
55
|
+
hostname === "[::1]" ||
|
|
56
|
+
hostname === "0.0.0.0" ||
|
|
57
|
+
hostname.endsWith(".local") ||
|
|
58
|
+
/^127\./.test(hostname) ||
|
|
59
|
+
PRIVATE_IPV4.test(hostname)
|
|
60
|
+
) {
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
// IPv6 private ranges: link-local (fe80::), unique-local (fc00::/fd00::),
|
|
64
|
+
// and IPv4-mapped (::ffff:) which embeds private IPv4 in hex notation
|
|
65
|
+
if (PRIVATE_IPV6.test(hostname)) return true;
|
|
66
|
+
return false;
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Fetch a URL and return its content as markdown.
|
|
74
|
+
*
|
|
75
|
+
* When `useJina` is true, attempts Jina Reader first (returns markdown
|
|
76
|
+
* directly, handles JS-rendered pages). On failure, warns to stderr
|
|
77
|
+
* and falls back to plain fetch + Turndown.
|
|
78
|
+
*/
|
|
79
|
+
export async function urlToMarkdown(
|
|
80
|
+
url: string,
|
|
81
|
+
options: UrlToMarkdownOptions,
|
|
82
|
+
): Promise<UrlToMarkdownResult> {
|
|
83
|
+
// URLs pointing to markdown files — fetch raw if the server returns plain text.
|
|
84
|
+
// If the server returns HTML (e.g. GitHub's .md viewer), fall through to Jina/Turndown.
|
|
85
|
+
const urlPath = url.split("?")[0].split("#")[0];
|
|
86
|
+
if (/\.mdx?$/i.test(urlPath)) {
|
|
87
|
+
const text = await fetchRawText(url);
|
|
88
|
+
if (text !== null) {
|
|
89
|
+
return { markdown: text, source: "fetch-raw" };
|
|
90
|
+
}
|
|
91
|
+
// Server returned HTML for this .md URL — fall through to normal conversion
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Content negotiation fast path — if the server natively returns markdown
|
|
95
|
+
// (e.g. Cloudflare's Markdown for Agents), skip Jina/Turndown entirely.
|
|
96
|
+
const local = isLocalUrl(url);
|
|
97
|
+
if (!local) {
|
|
98
|
+
const negotiated = await fetchViaContentNegotiation(url);
|
|
99
|
+
if (negotiated !== null) {
|
|
100
|
+
return { markdown: negotiated, source: "content-negotiation" };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (options.useJina && !local) {
|
|
105
|
+
try {
|
|
106
|
+
const markdown = await fetchViaJina(url);
|
|
107
|
+
return { markdown, source: "jina" };
|
|
108
|
+
} catch (err) {
|
|
109
|
+
process.stderr.write(
|
|
110
|
+
`[plannotator] Warning: Jina Reader failed (${err instanceof Error ? err.message : String(err)}), falling back to direct fetch...\n`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const markdown = await fetchViaTurndown(url);
|
|
116
|
+
return { markdown, source: "fetch+turndown" };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Read response body with a size limit. Throws if the body exceeds MAX_BODY_BYTES. */
|
|
120
|
+
async function readBodyWithLimit(res: Response): Promise<string> {
|
|
121
|
+
const contentLength = res.headers.get("content-length");
|
|
122
|
+
if (contentLength) {
|
|
123
|
+
const bytes = parseInt(contentLength, 10);
|
|
124
|
+
if (bytes > MAX_BODY_BYTES) {
|
|
125
|
+
res.body?.cancel();
|
|
126
|
+
throw new Error(`Response too large (${Math.round(bytes / 1024 / 1024)}MB, max 10MB)`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const reader = res.body?.getReader();
|
|
130
|
+
if (!reader) {
|
|
131
|
+
// Null body is rare (e.g. manually constructed Response). Still enforce
|
|
132
|
+
// the size limit via the text result length as a best-effort fallback.
|
|
133
|
+
const text = await res.text();
|
|
134
|
+
if (text.length > MAX_BODY_BYTES) {
|
|
135
|
+
throw new Error(`Response too large (>${Math.round(MAX_BODY_BYTES / 1024 / 1024)}MB, max 10MB)`);
|
|
136
|
+
}
|
|
137
|
+
return text;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const chunks: Uint8Array[] = [];
|
|
141
|
+
let totalBytes = 0;
|
|
142
|
+
while (true) {
|
|
143
|
+
const { done, value } = await reader.read();
|
|
144
|
+
if (done) break;
|
|
145
|
+
totalBytes += value.byteLength;
|
|
146
|
+
if (totalBytes > MAX_BODY_BYTES) {
|
|
147
|
+
reader.cancel();
|
|
148
|
+
throw new Error(`Response too large (>${Math.round(MAX_BODY_BYTES / 1024 / 1024)}MB, max 10MB)`);
|
|
149
|
+
}
|
|
150
|
+
chunks.push(value);
|
|
151
|
+
}
|
|
152
|
+
return new TextDecoder().decode(Buffer.concat(chunks));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Fetch a URL as raw text — for .md/.mdx URLs that are already markdown.
|
|
157
|
+
* Returns null if the server returns HTML (e.g. GitHub's viewer page for
|
|
158
|
+
* a .md file), signaling the caller to fall through to Jina/Turndown.
|
|
159
|
+
*
|
|
160
|
+
* Uses redirect: "manual" with isLocalUrl validation on each hop —
|
|
161
|
+
* same SSRF protection as fetchViaTurndown.
|
|
162
|
+
*/
|
|
163
|
+
async function fetchRawText(url: string): Promise<string | null> {
|
|
164
|
+
const controller = new AbortController();
|
|
165
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
166
|
+
const headers = { "User-Agent": "Mozilla/5.0 (compatible; Plannotator/1.0; +https://plannotator.ai)" };
|
|
167
|
+
try {
|
|
168
|
+
let currentUrl = url;
|
|
169
|
+
let res = await fetch(currentUrl, { headers, redirect: "manual", signal: controller.signal });
|
|
170
|
+
|
|
171
|
+
for (let i = 0; i < MAX_REDIRECTS && REDIRECT_STATUSES.has(res.status); i++) {
|
|
172
|
+
const location = res.headers.get("location");
|
|
173
|
+
if (!location) break;
|
|
174
|
+
currentUrl = new URL(location, currentUrl).href;
|
|
175
|
+
if (isLocalUrl(currentUrl)) {
|
|
176
|
+
throw new Error(`Redirect to private/local URL blocked: ${currentUrl}`);
|
|
177
|
+
}
|
|
178
|
+
res.body?.cancel();
|
|
179
|
+
res = await fetch(currentUrl, { headers, redirect: "manual", signal: controller.signal });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (REDIRECT_STATUSES.has(res.status)) {
|
|
183
|
+
res.body?.cancel();
|
|
184
|
+
throw new Error("Too many redirects");
|
|
185
|
+
}
|
|
186
|
+
if (!res.ok) {
|
|
187
|
+
res.body?.cancel();
|
|
188
|
+
throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
|
189
|
+
}
|
|
190
|
+
// If server returns HTML (e.g. GitHub's .md viewer), signal caller to
|
|
191
|
+
// fall through to Jina/Turndown instead of using raw content
|
|
192
|
+
const ct = res.headers.get("content-type") || "";
|
|
193
|
+
if (ct.includes("text/html") || ct.includes("application/xhtml+xml")) {
|
|
194
|
+
res.body?.cancel();
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
return await readBodyWithLimit(res);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
200
|
+
throw new Error(`Timed out fetching ${url}`);
|
|
201
|
+
}
|
|
202
|
+
throw err;
|
|
203
|
+
} finally {
|
|
204
|
+
clearTimeout(timer);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Content negotiation fast path — request `text/markdown` via the Accept header.
|
|
210
|
+
* Sites that support Cloudflare's "Markdown for Agents" (or similar) will return
|
|
211
|
+
* markdown directly, letting us skip Jina and Turndown entirely.
|
|
212
|
+
* Returns null if the server doesn't serve markdown.
|
|
213
|
+
*/
|
|
214
|
+
const NEGOTIATION_TIMEOUT_MS = 5_000; // Short timeout — this is a best-effort optimization
|
|
215
|
+
|
|
216
|
+
async function fetchViaContentNegotiation(url: string): Promise<string | null> {
|
|
217
|
+
const controller = new AbortController();
|
|
218
|
+
const timer = setTimeout(() => controller.abort(), NEGOTIATION_TIMEOUT_MS);
|
|
219
|
+
const headers = {
|
|
220
|
+
"User-Agent": "Mozilla/5.0 (compatible; Plannotator/1.0; +https://plannotator.ai)",
|
|
221
|
+
Accept: "text/markdown, text/html;q=0.9",
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
let currentUrl = url;
|
|
226
|
+
let res = await fetch(currentUrl, { headers, redirect: "manual", signal: controller.signal });
|
|
227
|
+
|
|
228
|
+
for (let i = 0; i < MAX_REDIRECTS && REDIRECT_STATUSES.has(res.status); i++) {
|
|
229
|
+
const location = res.headers.get("location");
|
|
230
|
+
if (!location) break;
|
|
231
|
+
currentUrl = new URL(location, currentUrl).href;
|
|
232
|
+
if (isLocalUrl(currentUrl)) {
|
|
233
|
+
res.body?.cancel();
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
res.body?.cancel();
|
|
237
|
+
res = await fetch(currentUrl, { headers, redirect: "manual", signal: controller.signal });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (!res.ok) {
|
|
241
|
+
res.body?.cancel();
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const ct = res.headers.get("content-type") || "";
|
|
246
|
+
if (!ct.includes("text/markdown")) {
|
|
247
|
+
res.body?.cancel();
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return await readBodyWithLimit(res);
|
|
252
|
+
} catch {
|
|
253
|
+
return null;
|
|
254
|
+
} finally {
|
|
255
|
+
clearTimeout(timer);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Fetch via Jina Reader — returns markdown directly. */
|
|
260
|
+
async function fetchViaJina(url: string): Promise<string> {
|
|
261
|
+
// Strip fragment (never sent to server) and encode for Jina's path-based API
|
|
262
|
+
const cleanUrl = url.split("#")[0];
|
|
263
|
+
const jinaUrl = `https://r.jina.ai/${cleanUrl}`;
|
|
264
|
+
const headers: Record<string, string> = {
|
|
265
|
+
Accept: "text/plain",
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const apiKey = process.env.JINA_API_KEY;
|
|
269
|
+
if (apiKey) {
|
|
270
|
+
headers.Authorization = `Bearer ${apiKey}`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const controller = new AbortController();
|
|
274
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
const res = await fetch(jinaUrl, { headers, signal: controller.signal });
|
|
278
|
+
if (!res.ok) {
|
|
279
|
+
res.body?.cancel();
|
|
280
|
+
throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
|
281
|
+
}
|
|
282
|
+
return await readBodyWithLimit(res);
|
|
283
|
+
} catch (err) {
|
|
284
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
285
|
+
throw new Error("timed out");
|
|
286
|
+
}
|
|
287
|
+
throw err;
|
|
288
|
+
} finally {
|
|
289
|
+
clearTimeout(timer);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const MAX_REDIRECTS = 10;
|
|
294
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
295
|
+
|
|
296
|
+
/** Fetch raw HTML and convert via Turndown. Follows redirects manually to validate each hop. */
|
|
297
|
+
async function fetchViaTurndown(url: string): Promise<string> {
|
|
298
|
+
const controller = new AbortController();
|
|
299
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
300
|
+
|
|
301
|
+
const headers = {
|
|
302
|
+
"User-Agent":
|
|
303
|
+
"Mozilla/5.0 (compatible; Plannotator/1.0; +https://plannotator.ai)",
|
|
304
|
+
Accept: "text/html,application/xhtml+xml",
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
let currentUrl = url;
|
|
309
|
+
let res = await fetch(currentUrl, { headers, redirect: "manual", signal: controller.signal });
|
|
310
|
+
|
|
311
|
+
for (let i = 0; i < MAX_REDIRECTS && REDIRECT_STATUSES.has(res.status); i++) {
|
|
312
|
+
const location = res.headers.get("location");
|
|
313
|
+
if (!location) break;
|
|
314
|
+
|
|
315
|
+
currentUrl = new URL(location, currentUrl).href;
|
|
316
|
+
if (isLocalUrl(currentUrl)) {
|
|
317
|
+
throw new Error(`Redirect to private/local URL blocked: ${currentUrl}`);
|
|
318
|
+
}
|
|
319
|
+
res.body?.cancel();
|
|
320
|
+
res = await fetch(currentUrl, { headers, redirect: "manual", signal: controller.signal });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (REDIRECT_STATUSES.has(res.status)) {
|
|
324
|
+
res.body?.cancel();
|
|
325
|
+
throw new Error("Too many redirects");
|
|
326
|
+
}
|
|
327
|
+
if (!res.ok) {
|
|
328
|
+
res.body?.cancel();
|
|
329
|
+
throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
|
330
|
+
}
|
|
331
|
+
const contentType = res.headers.get("content-type") || "";
|
|
332
|
+
if (
|
|
333
|
+
!contentType.includes("text/html") &&
|
|
334
|
+
!contentType.includes("application/xhtml+xml")
|
|
335
|
+
) {
|
|
336
|
+
res.body?.cancel();
|
|
337
|
+
throw new Error(
|
|
338
|
+
`Not an HTML page (content-type: ${contentType})`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
const html = await readBodyWithLimit(res);
|
|
342
|
+
return htmlToMarkdown(html);
|
|
343
|
+
} catch (err) {
|
|
344
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
345
|
+
throw new Error(`Timed out fetching ${url}`);
|
|
346
|
+
}
|
|
347
|
+
throw err;
|
|
348
|
+
} finally {
|
|
349
|
+
clearTimeout(timer);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type {
|
|
3
|
+
DiffResult,
|
|
4
|
+
DiffType,
|
|
5
|
+
GitContext,
|
|
6
|
+
ReviewGitRuntime,
|
|
7
|
+
} from "./review-core";
|
|
8
|
+
import {
|
|
9
|
+
type VcsProvider,
|
|
10
|
+
createGitProvider,
|
|
11
|
+
createVcsApi,
|
|
12
|
+
resolveInitialDiffType,
|
|
13
|
+
} from "./vcs-core";
|
|
14
|
+
|
|
15
|
+
function context(overrides: Partial<GitContext>): GitContext {
|
|
16
|
+
return {
|
|
17
|
+
currentBranch: "feature",
|
|
18
|
+
defaultBranch: "main",
|
|
19
|
+
diffOptions: [
|
|
20
|
+
{ id: "uncommitted", label: "Uncommitted changes" },
|
|
21
|
+
{ id: "merge-base", label: "Committed changes" },
|
|
22
|
+
],
|
|
23
|
+
worktrees: [],
|
|
24
|
+
availableBranches: { local: [], remote: [] },
|
|
25
|
+
vcsType: "git",
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function provider(
|
|
31
|
+
id: string,
|
|
32
|
+
detected: boolean,
|
|
33
|
+
ownedTypes: string[],
|
|
34
|
+
contextOverrides: Partial<GitContext> = {},
|
|
35
|
+
root?: string,
|
|
36
|
+
): VcsProvider {
|
|
37
|
+
return {
|
|
38
|
+
id,
|
|
39
|
+
async detect() {
|
|
40
|
+
return detected;
|
|
41
|
+
},
|
|
42
|
+
async getRoot() {
|
|
43
|
+
return detected ? root ?? "/repo" : null;
|
|
44
|
+
},
|
|
45
|
+
ownsDiffType(diffType: string) {
|
|
46
|
+
return ownedTypes.includes(diffType);
|
|
47
|
+
},
|
|
48
|
+
async getContext() {
|
|
49
|
+
return context({ vcsType: id as GitContext["vcsType"], ...contextOverrides });
|
|
50
|
+
},
|
|
51
|
+
async runDiff(diffType: DiffType, defaultBranch: string): Promise<DiffResult> {
|
|
52
|
+
return { patch: `${id}:${diffType}:${defaultBranch}`, label: `${id}:${defaultBranch}` };
|
|
53
|
+
},
|
|
54
|
+
async getFileContents() {
|
|
55
|
+
return { oldContent: id, newContent: id };
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const gitRuntime: ReviewGitRuntime = {
|
|
61
|
+
async runGit() {
|
|
62
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
63
|
+
},
|
|
64
|
+
async readTextFile() {
|
|
65
|
+
return null;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
describe("createVcsApi", () => {
|
|
70
|
+
test("detects the first matching provider so jj wins colocated workspaces", async () => {
|
|
71
|
+
const jj = provider("jj", true, ["jj-current"], {}, "/repo");
|
|
72
|
+
const git = provider("git", true, ["uncommitted"], {}, "/repo");
|
|
73
|
+
const api = createVcsApi([jj, git]);
|
|
74
|
+
|
|
75
|
+
await expect(api.detectVcs("/repo")).resolves.toBe(jj);
|
|
76
|
+
await expect(api.getVcsContext("/repo")).resolves.toMatchObject({ vcsType: "jj" });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("detects the nearest VCS root so nested Git repos beat outer JJ workspaces", async () => {
|
|
80
|
+
const jj = provider("jj", true, ["jj-current"], { cwd: "/repo" }, "/repo");
|
|
81
|
+
const git = provider("git", true, ["uncommitted"], { cwd: "/repo/packages/tool" }, "/repo/packages/tool");
|
|
82
|
+
const api = createVcsApi([jj, git]);
|
|
83
|
+
|
|
84
|
+
await expect(api.detectVcs("/repo/packages/tool")).resolves.toBe(git);
|
|
85
|
+
await expect(api.getVcsContext("/repo/packages/tool")).resolves.toMatchObject({ vcsType: "git" });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("detects the nearest VCS root so nested JJ workspaces beat outer Git repos", async () => {
|
|
89
|
+
const jj = provider("jj", true, ["jj-current"], { cwd: "/repo/packages/tool" }, "/repo/packages/tool");
|
|
90
|
+
const git = provider("git", true, ["uncommitted"], { cwd: "/repo" }, "/repo");
|
|
91
|
+
const api = createVcsApi([jj, git]);
|
|
92
|
+
|
|
93
|
+
await expect(api.detectVcs("/repo/packages/tool")).resolves.toBe(jj);
|
|
94
|
+
await expect(api.getVcsContext("/repo/packages/tool")).resolves.toMatchObject({ vcsType: "jj" });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("continues probing providers when root detection throws", async () => {
|
|
98
|
+
const brokenGit = {
|
|
99
|
+
...provider("git", true, ["uncommitted"]),
|
|
100
|
+
async getRoot() {
|
|
101
|
+
throw new Error("git failed");
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
const p4 = provider("p4", true, ["p4-default"], {}, "/repo");
|
|
105
|
+
const api = createVcsApi([brokenGit, p4]);
|
|
106
|
+
|
|
107
|
+
await expect(api.detectVcs("/repo")).resolves.toBe(p4);
|
|
108
|
+
await expect(api.getVcsContext("/repo")).resolves.toMatchObject({ vcsType: "p4" });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("detectManagedVcs returns null instead of falling back when no provider detects a workspace", async () => {
|
|
112
|
+
const git = provider("git", false, ["uncommitted"]);
|
|
113
|
+
const api = createVcsApi([git]);
|
|
114
|
+
|
|
115
|
+
await expect(api.detectManagedVcs("/not-a-repo")).resolves.toBeNull();
|
|
116
|
+
await expect(api.detectVcs("/not-a-repo")).resolves.toBe(git);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("detectManagedVcs respects forced VCS selection without throwing", async () => {
|
|
120
|
+
const jj = provider("jj", true, ["jj-current"], {}, "/repo");
|
|
121
|
+
const git = provider("git", false, ["uncommitted"]);
|
|
122
|
+
const api = createVcsApi([jj, git]);
|
|
123
|
+
|
|
124
|
+
await expect(api.detectManagedVcs("/repo", "git")).resolves.toBeNull();
|
|
125
|
+
await expect(api.detectManagedVcs("/repo", "jj")).resolves.toBe(jj);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("detectManagedVcs returns null when forced provider detection throws", async () => {
|
|
129
|
+
const git = {
|
|
130
|
+
...provider("git", true, ["uncommitted"]),
|
|
131
|
+
async detect() {
|
|
132
|
+
throw new Error("git failed");
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
const api = createVcsApi([git]);
|
|
136
|
+
|
|
137
|
+
await expect(api.detectManagedVcs("/repo", "git")).resolves.toBeNull();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("routes operations by diff type before falling back to detection", async () => {
|
|
141
|
+
const jj = provider("jj", false, ["jj-current"]);
|
|
142
|
+
const git = provider("git", true, ["uncommitted"]);
|
|
143
|
+
const api = createVcsApi([jj, git]);
|
|
144
|
+
|
|
145
|
+
await expect(api.runVcsDiff("jj-current", "trunk()", "/repo")).resolves.toMatchObject({
|
|
146
|
+
patch: "jj:jj-current:trunk()",
|
|
147
|
+
});
|
|
148
|
+
await expect(api.runVcsDiff("uncommitted", "main", "/repo")).resolves.toMatchObject({
|
|
149
|
+
patch: "git:uncommitted:main",
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("limits Git staging to working-tree diff modes", async () => {
|
|
154
|
+
const git = createVcsApi([createGitProvider(gitRuntime)]);
|
|
155
|
+
|
|
156
|
+
await expect(git.canStageFiles("uncommitted", "/repo")).resolves.toBe(true);
|
|
157
|
+
await expect(git.canStageFiles("unstaged", "/repo")).resolves.toBe(true);
|
|
158
|
+
await expect(git.canStageFiles("worktree:/repo:uncommitted", "/repo")).resolves.toBe(true);
|
|
159
|
+
await expect(git.canStageFiles("staged", "/repo")).resolves.toBe(false);
|
|
160
|
+
await expect(git.canStageFiles("branch", "/repo")).resolves.toBe(false);
|
|
161
|
+
await expect(git.canStageFiles("merge-base", "/repo")).resolves.toBe(false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("requires providers to explicitly opt into staging", async () => {
|
|
165
|
+
const stageFile = async () => {};
|
|
166
|
+
const unstageFile = async () => {};
|
|
167
|
+
const api = createVcsApi([
|
|
168
|
+
{
|
|
169
|
+
...provider("custom", true, ["custom-diff"]),
|
|
170
|
+
stageFile,
|
|
171
|
+
unstageFile,
|
|
172
|
+
},
|
|
173
|
+
]);
|
|
174
|
+
|
|
175
|
+
await expect(api.canStageFiles("custom-diff", "/repo")).resolves.toBe(false);
|
|
176
|
+
await expect(api.stageFile("custom-diff", "tracked.txt", "/repo")).rejects.toThrow(
|
|
177
|
+
"Staging not available for custom",
|
|
178
|
+
);
|
|
179
|
+
await expect(api.unstageFile("custom-diff", "tracked.txt", "/repo")).rejects.toThrow(
|
|
180
|
+
"Unstaging not available for custom",
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("prepares JJ local reviews by ignoring Git-shaped requested options", async () => {
|
|
185
|
+
const jj = provider("jj", true, ["jj-current", "jj-line"], {
|
|
186
|
+
defaultBranch: "trunk()",
|
|
187
|
+
diffOptions: [
|
|
188
|
+
{ id: "jj-current", label: "Current change" },
|
|
189
|
+
{ id: "jj-line", label: "Line of work" },
|
|
190
|
+
],
|
|
191
|
+
vcsType: "jj",
|
|
192
|
+
});
|
|
193
|
+
const git = provider("git", true, ["merge-base", "uncommitted"]);
|
|
194
|
+
const api = createVcsApi([jj, git]);
|
|
195
|
+
|
|
196
|
+
await expect(api.prepareLocalReviewDiff({
|
|
197
|
+
cwd: "/repo",
|
|
198
|
+
requestedDiffType: "merge-base",
|
|
199
|
+
requestedBase: "main",
|
|
200
|
+
configuredDiffType: "unstaged",
|
|
201
|
+
})).resolves.toMatchObject({
|
|
202
|
+
diffType: "jj-current",
|
|
203
|
+
base: "trunk()",
|
|
204
|
+
rawPatch: "jj:jj-current:trunk()",
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("prepares local reviews by preserving valid requested diff types for the detected VCS", async () => {
|
|
209
|
+
const jj = provider("jj", true, ["jj-current", "jj-line"], {
|
|
210
|
+
defaultBranch: "trunk()",
|
|
211
|
+
diffOptions: [
|
|
212
|
+
{ id: "jj-current", label: "Current change" },
|
|
213
|
+
{ id: "jj-line", label: "Line of work" },
|
|
214
|
+
],
|
|
215
|
+
vcsType: "jj",
|
|
216
|
+
});
|
|
217
|
+
const api = createVcsApi([jj]);
|
|
218
|
+
|
|
219
|
+
await expect(api.prepareLocalReviewDiff({
|
|
220
|
+
cwd: "/repo",
|
|
221
|
+
requestedDiffType: "jj-line",
|
|
222
|
+
requestedBase: "feature@origin",
|
|
223
|
+
configuredDiffType: "unstaged",
|
|
224
|
+
})).resolves.toMatchObject({
|
|
225
|
+
diffType: "jj-line",
|
|
226
|
+
base: "feature@origin",
|
|
227
|
+
rawPatch: "jj:jj-line:feature@origin",
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("prepares Git local reviews by honoring valid requested base and ignoring JJ diff modes", async () => {
|
|
232
|
+
const git = provider("git", true, ["uncommitted", "merge-base"]);
|
|
233
|
+
const api = createVcsApi([git]);
|
|
234
|
+
|
|
235
|
+
await expect(api.prepareLocalReviewDiff({
|
|
236
|
+
cwd: "/repo",
|
|
237
|
+
requestedDiffType: "jj-line",
|
|
238
|
+
requestedBase: "develop",
|
|
239
|
+
configuredDiffType: "merge-base",
|
|
240
|
+
})).resolves.toMatchObject({
|
|
241
|
+
diffType: "merge-base",
|
|
242
|
+
base: "develop",
|
|
243
|
+
rawPatch: "git:merge-base:develop",
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("can force Git for local review startup in colocated JJ workspaces", async () => {
|
|
248
|
+
const jj = provider("jj", true, ["jj-current"], {
|
|
249
|
+
defaultBranch: "trunk()",
|
|
250
|
+
diffOptions: [{ id: "jj-current", label: "Current change" }],
|
|
251
|
+
vcsType: "jj",
|
|
252
|
+
});
|
|
253
|
+
const git = provider("git", true, ["uncommitted", "merge-base"]);
|
|
254
|
+
const api = createVcsApi([jj, git]);
|
|
255
|
+
|
|
256
|
+
await expect(api.prepareLocalReviewDiff({
|
|
257
|
+
cwd: "/repo",
|
|
258
|
+
vcsType: "git",
|
|
259
|
+
configuredDiffType: "merge-base",
|
|
260
|
+
})).resolves.toMatchObject({
|
|
261
|
+
gitContext: { vcsType: "git" },
|
|
262
|
+
diffType: "merge-base",
|
|
263
|
+
base: "main",
|
|
264
|
+
rawPatch: "git:merge-base:main",
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("reports a clear error when forced Git is unavailable", async () => {
|
|
269
|
+
const jj = provider("jj", true, ["jj-current"], {
|
|
270
|
+
defaultBranch: "trunk()",
|
|
271
|
+
diffOptions: [{ id: "jj-current", label: "Current change" }],
|
|
272
|
+
vcsType: "jj",
|
|
273
|
+
});
|
|
274
|
+
const git = provider("git", false, ["uncommitted", "merge-base"]);
|
|
275
|
+
const api = createVcsApi([jj, git]);
|
|
276
|
+
|
|
277
|
+
await expect(api.prepareLocalReviewDiff({
|
|
278
|
+
cwd: "/repo",
|
|
279
|
+
vcsType: "git",
|
|
280
|
+
configuredDiffType: "merge-base",
|
|
281
|
+
})).rejects.toThrow("Git workspace not found.");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("refreshes context and remote defaults with the forced VCS", async () => {
|
|
285
|
+
const jj = provider("jj", true, ["jj-current"], {
|
|
286
|
+
defaultBranch: "trunk()",
|
|
287
|
+
diffOptions: [{ id: "jj-current", label: "Current change" }],
|
|
288
|
+
vcsType: "jj",
|
|
289
|
+
});
|
|
290
|
+
const git = {
|
|
291
|
+
...provider("git", true, ["uncommitted", "merge-base"]),
|
|
292
|
+
detectRemoteDefaultCompareTarget: async () => "origin/main",
|
|
293
|
+
};
|
|
294
|
+
const api = createVcsApi([jj, git]);
|
|
295
|
+
|
|
296
|
+
await expect(api.getVcsContext("/repo", "git")).resolves.toMatchObject({
|
|
297
|
+
vcsType: "git",
|
|
298
|
+
defaultBranch: "main",
|
|
299
|
+
});
|
|
300
|
+
await expect(api.detectRemoteDefaultCompareTarget("/repo", "git")).resolves.toBe("origin/main");
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe("resolveInitialDiffType", () => {
|
|
305
|
+
test("preserves configured Git diff modes when available", () => {
|
|
306
|
+
expect(resolveInitialDiffType(context({}), "merge-base")).toBe("merge-base");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("uses p4-default for P4 contexts", () => {
|
|
310
|
+
expect(resolveInitialDiffType(context({ vcsType: "p4" }), "merge-base")).toBe("p4-default");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("ignores saved Git defaults for jj contexts", () => {
|
|
314
|
+
const jjContext = context({
|
|
315
|
+
defaultBranch: "trunk()",
|
|
316
|
+
diffOptions: [
|
|
317
|
+
{ id: "jj-current", label: "Current change" },
|
|
318
|
+
{ id: "jj-line", label: "Line of work" },
|
|
319
|
+
{ id: "jj-all", label: "All files" },
|
|
320
|
+
],
|
|
321
|
+
vcsType: "jj",
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
expect(resolveInitialDiffType(jjContext, "all")).toBe("jj-current");
|
|
325
|
+
expect(resolveInitialDiffType(jjContext, "merge-base")).toBe("jj-current");
|
|
326
|
+
expect(resolveInitialDiffType(jjContext, "unstaged")).toBe("jj-current");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("falls back to the first available option for unknown non-jj modes", () => {
|
|
330
|
+
expect(resolveInitialDiffType(context({}), "jj-current")).toBe("uncommitted");
|
|
331
|
+
});
|
|
332
|
+
});
|