@payconductor/houndsman 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/commands/server-command.ts +7 -1
- package/src/cli/help.ts +1 -1
- package/src/core/commands/findings.ts +6 -1
- package/src/core/defaults.ts +1 -1
- package/src/core/git-runner.ts +25 -13
- package/src/core/github-review.ts +10 -7
- package/src/core/services/gh-client.ts +38 -22
- package/src/server/client/App.tsx +38 -12
- package/src/server/client/CommentsPanel.tsx +255 -0
- package/src/server/client/FindingCard.tsx +12 -4
- package/src/server/client/FindingsList.tsx +4 -1
- package/src/server/client/Sidebar.tsx +397 -0
- package/src/server/client/SubmitModal.tsx +61 -5
- package/src/server/client/icons.tsx +19 -0
- package/src/server/client/index.tsx +1 -1
- package/src/server/client/utils/constants.ts +2 -0
- package/src/server/client/utils/format.ts +47 -2
- package/src/server/client/utils/types.ts +82 -0
- package/src/server/editor.ts +129 -0
- package/src/server/index.ts +112 -3
- package/src/server/pr-comments.ts +256 -0
- package/src/server/pr-inbox.ts +202 -0
- package/src/server/run-repository.ts +33 -1
- package/src/tests/ai-core.test.ts +2 -2
- package/src/tests/cli.test.ts +33 -0
- package/src/tests/github-review.test.ts +9 -0
- package/src/tests/server-editor.test.ts +100 -0
- package/src/tests/server.test.ts +295 -2
- package/src/server/client/RunList.tsx +0 -75
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caixa de entrada de Pull Requests do repositório atual (servidor).
|
|
3
|
+
*
|
|
4
|
+
* Lista as PRs abertas via `gh` e executa o checkout somente quando a árvore
|
|
5
|
+
* de trabalho está limpa. Fail-open: sem `gh` (ou sem git), devolve o motivo e
|
|
6
|
+
* a UI degrada sem quebrar as demais abas.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describeGhError, type GhRunner } from "../core/services/gh-client";
|
|
10
|
+
import type { GitRepository } from "../core/services/git-repository";
|
|
11
|
+
|
|
12
|
+
/** Estado da árvore de trabalho (base do bloqueio do checkout). */
|
|
13
|
+
export type WorktreeState = {
|
|
14
|
+
/** `false` quando o `git status` falhou. */
|
|
15
|
+
available: boolean;
|
|
16
|
+
clean: boolean;
|
|
17
|
+
changes: number;
|
|
18
|
+
/** Amostra dos caminhos alterados (limitada para a UI). */
|
|
19
|
+
sample: string[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** PR aberta exibida na sidebar. */
|
|
23
|
+
export type PrListItem = {
|
|
24
|
+
number: number;
|
|
25
|
+
title: string;
|
|
26
|
+
headRefName: string;
|
|
27
|
+
baseRefName: string;
|
|
28
|
+
author: string;
|
|
29
|
+
updatedAt?: string;
|
|
30
|
+
isDraft: boolean;
|
|
31
|
+
reviewDecision?: string;
|
|
32
|
+
url?: string;
|
|
33
|
+
/** Run local correspondente à branch, quando existir. */
|
|
34
|
+
runSlug?: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Resposta de `GET /api/prs`. */
|
|
38
|
+
export type PrListResponse = {
|
|
39
|
+
available: boolean;
|
|
40
|
+
reason?: string;
|
|
41
|
+
currentBranch: string | null;
|
|
42
|
+
worktree: WorktreeState;
|
|
43
|
+
prs: PrListItem[];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Resultado do checkout de uma PR. */
|
|
47
|
+
export type CheckoutResponse =
|
|
48
|
+
| { ok: true; number: number; branch: string | null }
|
|
49
|
+
| {
|
|
50
|
+
ok: false;
|
|
51
|
+
reason: "dirty" | "git" | "gh";
|
|
52
|
+
detail: string;
|
|
53
|
+
changes?: string[];
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const PR_LIST_FIELDS =
|
|
57
|
+
"number,title,headRefName,baseRefName,author,updatedAt,isDraft,reviewDecision,url";
|
|
58
|
+
|
|
59
|
+
type GhPrListItem = {
|
|
60
|
+
number?: number;
|
|
61
|
+
title?: string;
|
|
62
|
+
headRefName?: string;
|
|
63
|
+
baseRefName?: string;
|
|
64
|
+
author?: { login?: string; name?: string };
|
|
65
|
+
updatedAt?: string;
|
|
66
|
+
isDraft?: boolean;
|
|
67
|
+
reviewDecision?: string;
|
|
68
|
+
url?: string;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function toItem(
|
|
72
|
+
raw: GhPrListItem,
|
|
73
|
+
runSlugs: ReadonlyMap<string, string>,
|
|
74
|
+
): PrListItem | null {
|
|
75
|
+
const number = Number(raw.number);
|
|
76
|
+
const headRefName = raw.headRefName?.trim();
|
|
77
|
+
if (!Number.isFinite(number) || number <= 0 || !headRefName) return null;
|
|
78
|
+
return {
|
|
79
|
+
number,
|
|
80
|
+
title: raw.title?.trim() || `PR #${number}`,
|
|
81
|
+
headRefName,
|
|
82
|
+
baseRefName: raw.baseRefName?.trim() || "",
|
|
83
|
+
author: raw.author?.login ?? raw.author?.name ?? "desconhecido",
|
|
84
|
+
updatedAt: raw.updatedAt,
|
|
85
|
+
isDraft: Boolean(raw.isDraft),
|
|
86
|
+
reviewDecision: raw.reviewDecision?.trim() || undefined,
|
|
87
|
+
url: raw.url,
|
|
88
|
+
runSlug: runSlugs.get(headRefName),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Acesso às PRs do repositório atual. */
|
|
93
|
+
export class PrInbox {
|
|
94
|
+
constructor(
|
|
95
|
+
private readonly ghRunner: GhRunner,
|
|
96
|
+
private readonly git: GitRepository,
|
|
97
|
+
) {}
|
|
98
|
+
|
|
99
|
+
/** Estado da árvore de trabalho (`git status --porcelain`). */
|
|
100
|
+
worktree(): WorktreeState {
|
|
101
|
+
const result = this.git.tryRun(["status", "--porcelain"]);
|
|
102
|
+
if (!result.ok) {
|
|
103
|
+
return { available: false, clean: false, changes: 0, sample: [] };
|
|
104
|
+
}
|
|
105
|
+
const lines = result.stdout
|
|
106
|
+
.split("\n")
|
|
107
|
+
.map((line) => line.trimEnd())
|
|
108
|
+
.filter((line) => line.length > 0);
|
|
109
|
+
return {
|
|
110
|
+
available: true,
|
|
111
|
+
clean: lines.length === 0,
|
|
112
|
+
changes: lines.length,
|
|
113
|
+
sample: lines.slice(0, 20),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Lista as PRs abertas (limite 50) e o estado local do repositório. */
|
|
118
|
+
list(runSlugs: ReadonlyMap<string, string> = new Map()): PrListResponse {
|
|
119
|
+
const currentBranch = this.git.currentBranch();
|
|
120
|
+
const worktree = this.worktree();
|
|
121
|
+
const result = this.ghRunner([
|
|
122
|
+
"pr",
|
|
123
|
+
"list",
|
|
124
|
+
"--state",
|
|
125
|
+
"open",
|
|
126
|
+
"--limit",
|
|
127
|
+
"50",
|
|
128
|
+
"--json",
|
|
129
|
+
PR_LIST_FIELDS,
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
if (!result.ok) {
|
|
133
|
+
return {
|
|
134
|
+
available: false,
|
|
135
|
+
reason: describeGhError(result),
|
|
136
|
+
currentBranch,
|
|
137
|
+
worktree,
|
|
138
|
+
prs: [],
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let parsed: unknown;
|
|
143
|
+
try {
|
|
144
|
+
parsed = JSON.parse(result.stdout);
|
|
145
|
+
} catch {
|
|
146
|
+
return {
|
|
147
|
+
available: false,
|
|
148
|
+
reason: "Resposta inválida do gh (JSON malformado).",
|
|
149
|
+
currentBranch,
|
|
150
|
+
worktree,
|
|
151
|
+
prs: [],
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (!Array.isArray(parsed)) {
|
|
155
|
+
return {
|
|
156
|
+
available: false,
|
|
157
|
+
reason: "Resposta inesperada do gh (esperava uma lista de PRs).",
|
|
158
|
+
currentBranch,
|
|
159
|
+
worktree,
|
|
160
|
+
prs: [],
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const prs = (parsed as GhPrListItem[])
|
|
165
|
+
.map((item) => toItem(item, runSlugs))
|
|
166
|
+
.filter((item): item is PrListItem => item !== null)
|
|
167
|
+
.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
|
|
168
|
+
|
|
169
|
+
return { available: true, currentBranch, worktree, prs };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Faz checkout da PR via `gh pr checkout`.
|
|
174
|
+
*
|
|
175
|
+
* A árvore precisa estar limpa: alterações locais não commitadas bloqueiam a
|
|
176
|
+
* operação para não descartar trabalho do usuário.
|
|
177
|
+
*/
|
|
178
|
+
checkout(number: number): CheckoutResponse {
|
|
179
|
+
const worktree = this.worktree();
|
|
180
|
+
if (!worktree.available) {
|
|
181
|
+
return {
|
|
182
|
+
ok: false,
|
|
183
|
+
reason: "git",
|
|
184
|
+
detail: "Não foi possível ler o estado do git.",
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (!worktree.clean) {
|
|
188
|
+
return {
|
|
189
|
+
ok: false,
|
|
190
|
+
reason: "dirty",
|
|
191
|
+
detail: `${worktree.changes} alteração(ões) não commitada(s).`,
|
|
192
|
+
changes: worktree.sample,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const result = this.ghRunner(["pr", "checkout", String(number)]);
|
|
197
|
+
if (!result.ok) {
|
|
198
|
+
return { ok: false, reason: "gh", detail: describeGhError(result) };
|
|
199
|
+
}
|
|
200
|
+
return { ok: true, number, branch: this.git.currentBranch() };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -34,9 +34,11 @@ import {
|
|
|
34
34
|
} from "../core/github-review";
|
|
35
35
|
import { pingFindingsStatus } from "../core/run-status";
|
|
36
36
|
import { systemClock } from "../core/services/clock";
|
|
37
|
+
import { defaultGhRunner } from "../core/services/gh-client";
|
|
37
38
|
import type { RepoIdentity, ReviewRecord, StorageProvider } from "../core/storage/types";
|
|
38
39
|
import type { Severity } from "../core/types";
|
|
39
40
|
import { isSlug } from "./artifacts";
|
|
41
|
+
import { fetchPrComments, type PrCommentsResponse } from "./pr-comments";
|
|
40
42
|
|
|
41
43
|
/** Item da lista de execuções (sidebar) — já ordenado do mais recente ao antigo. */
|
|
42
44
|
export type RunListItem = {
|
|
@@ -78,10 +80,16 @@ export type ArtifactListItem = {
|
|
|
78
80
|
export class RunRepository {
|
|
79
81
|
private readonly storage: StorageProvider;
|
|
80
82
|
private readonly repo: RepoIdentity;
|
|
83
|
+
private readonly ghRunner: GhRunner;
|
|
81
84
|
|
|
82
|
-
constructor(
|
|
85
|
+
constructor(
|
|
86
|
+
storage: StorageProvider,
|
|
87
|
+
repo: RepoIdentity,
|
|
88
|
+
ghRunner: GhRunner = defaultGhRunner,
|
|
89
|
+
) {
|
|
83
90
|
this.storage = storage;
|
|
84
91
|
this.repo = repo;
|
|
92
|
+
this.ghRunner = ghRunner;
|
|
85
93
|
}
|
|
86
94
|
|
|
87
95
|
/** `true` se o slug aponta para uma run do repositório atual. */
|
|
@@ -105,6 +113,15 @@ export class RunRepository {
|
|
|
105
113
|
});
|
|
106
114
|
}
|
|
107
115
|
|
|
116
|
+
/** Mapa branch → slug das runs (usado para casar PRs com execuções). */
|
|
117
|
+
branchIndex(): Map<string, string> {
|
|
118
|
+
return new Map(
|
|
119
|
+
this.storage
|
|
120
|
+
.listReviews(this.repo.key)
|
|
121
|
+
.map((review) => [review.branch, review.slug]),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
108
125
|
/** Detalhe da run (null se o slug for inválido). */
|
|
109
126
|
detail(slug: string): RunDetail | null {
|
|
110
127
|
const review = this.findBySlug(slug);
|
|
@@ -273,6 +290,21 @@ export class RunRepository {
|
|
|
273
290
|
return { available: true, target: target.target, outcome };
|
|
274
291
|
}
|
|
275
292
|
|
|
293
|
+
/** Comentários da PR da run (null se o slug for inválido). */
|
|
294
|
+
comments(
|
|
295
|
+
slug: string,
|
|
296
|
+
options: { refresh?: boolean } = {},
|
|
297
|
+
): PrCommentsResponse | null {
|
|
298
|
+
const review = this.findBySlug(slug);
|
|
299
|
+
if (!review) return null;
|
|
300
|
+
return fetchPrComments({
|
|
301
|
+
runner: this.ghRunner,
|
|
302
|
+
branch: review.branch,
|
|
303
|
+
repoRoot: review.repoRoot,
|
|
304
|
+
refresh: options.refresh,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
276
308
|
/** Lista os artefatos da run (sem conteúdo). */
|
|
277
309
|
artifacts(slug: string): ArtifactListItem[] | null {
|
|
278
310
|
const review = this.findBySlug(slug);
|
|
@@ -16,8 +16,8 @@ const project = new ProjectConfig(fakeHoundsmanConfig);
|
|
|
16
16
|
|
|
17
17
|
describe("config de IA", () => {
|
|
18
18
|
test("modelo default do DeepSeek é 4.1 flash", () => {
|
|
19
|
-
expect(DEFAULT_AI_MODEL).toBe("deepseek-v4
|
|
20
|
-
expect(project.aiModel).toBe("deepseek-v4
|
|
19
|
+
expect(DEFAULT_AI_MODEL).toBe("deepseek-v4-pro");
|
|
20
|
+
expect(project.aiModel).toBe("deepseek-v4-pro");
|
|
21
21
|
});
|
|
22
22
|
});
|
|
23
23
|
|
package/src/tests/cli.test.ts
CHANGED
|
@@ -152,6 +152,39 @@ describe("cli smoke — imports dinâmicos", () => {
|
|
|
152
152
|
}
|
|
153
153
|
});
|
|
154
154
|
|
|
155
|
+
test("findings submit filtra pela decisão (a executar)", () => {
|
|
156
|
+
const project = makeProject();
|
|
157
|
+
try {
|
|
158
|
+
const pending = runCli(project.cwd, [
|
|
159
|
+
"findings",
|
|
160
|
+
"submit",
|
|
161
|
+
"--dry-run",
|
|
162
|
+
"--json",
|
|
163
|
+
"--branch",
|
|
164
|
+
"main",
|
|
165
|
+
"--decision",
|
|
166
|
+
"pending",
|
|
167
|
+
]);
|
|
168
|
+
expect(pending.code).toBe(0);
|
|
169
|
+
expect(JSON.parse(pending.stdout).totals.candidates).toBe(1);
|
|
170
|
+
|
|
171
|
+
const execute = runCli(project.cwd, [
|
|
172
|
+
"findings",
|
|
173
|
+
"submit",
|
|
174
|
+
"--dry-run",
|
|
175
|
+
"--json",
|
|
176
|
+
"--branch",
|
|
177
|
+
"main",
|
|
178
|
+
"--decision",
|
|
179
|
+
"execute",
|
|
180
|
+
]);
|
|
181
|
+
expect(execute.code).toBe(0);
|
|
182
|
+
expect(JSON.parse(execute.stdout).totals.candidates).toBe(0);
|
|
183
|
+
} finally {
|
|
184
|
+
project.cleanup();
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
155
188
|
test("doctor valida a config (import lazy de registry-check)", () => {
|
|
156
189
|
const project = makeProject();
|
|
157
190
|
try {
|
|
@@ -294,6 +294,15 @@ describe("selectSubmitCandidates", () => {
|
|
|
294
294
|
expect(selectSubmitCandidates(state, { execStates: ["done"] })).toEqual([]);
|
|
295
295
|
});
|
|
296
296
|
|
|
297
|
+
test("filtra pela decisão (a executar vs ainda pendente)", () => {
|
|
298
|
+
const execute = selectSubmitCandidates(state, { decisions: ["execute"] });
|
|
299
|
+
expect(execute.map((c) => c.id)).toEqual(["a"]);
|
|
300
|
+
expect(execute[0].decision).toBe("execute");
|
|
301
|
+
expect(
|
|
302
|
+
selectSubmitCandidates(state, { decisions: ["pending"] }).map((c) => c.id),
|
|
303
|
+
).toEqual(["c"]);
|
|
304
|
+
});
|
|
305
|
+
|
|
297
306
|
test("marca sent/skip e respeita includeSent", () => {
|
|
298
307
|
const sent = stateWith([finding({ id: "a", commentId: 99 })]);
|
|
299
308
|
const [only] = selectSubmitCandidates(sent);
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
editorFileLink,
|
|
4
|
+
jetbrainsFileLink,
|
|
5
|
+
vscodeFileLink,
|
|
6
|
+
} from "../server/client/utils/format";
|
|
7
|
+
import { detectEditor, resolveEditor } from "../server/editor";
|
|
8
|
+
|
|
9
|
+
describe("detecção de editor", () => {
|
|
10
|
+
test("VS Code pelo TERM_PROGRAM", () => {
|
|
11
|
+
expect(detectEditor({ TERM_PROGRAM: "vscode" }, "/work/app")).toMatchObject({
|
|
12
|
+
kind: "vscode",
|
|
13
|
+
project: "app",
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("VS Code pelas variáveis VSCODE_*", () => {
|
|
18
|
+
expect(detectEditor({ VSCODE_PID: "123" }, "/work/app")).toMatchObject({
|
|
19
|
+
kind: "vscode",
|
|
20
|
+
project: "app",
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("JetBrains pelo terminal e produto do bundle", () => {
|
|
25
|
+
expect(
|
|
26
|
+
detectEditor(
|
|
27
|
+
{
|
|
28
|
+
TERMINAL_EMULATOR: "JetBrains-JediTerm",
|
|
29
|
+
__CFBundleIdentifier: "com.jetbrains.pycharm",
|
|
30
|
+
},
|
|
31
|
+
"/work/app",
|
|
32
|
+
),
|
|
33
|
+
).toEqual({ kind: "jetbrains", project: "app", product: "pycharm" });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("JetBrains por *_INITIAL_DIRECTORY", () => {
|
|
37
|
+
expect(
|
|
38
|
+
detectEditor({ WEBSTORM_INITIAL_DIRECTORY: "/work/app" }, "/work/app"),
|
|
39
|
+
).toEqual({ kind: "jetbrains", project: "app", product: "webstorm" });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("produto JetBrains ambíguo cai em idea", () => {
|
|
43
|
+
expect(
|
|
44
|
+
detectEditor({ TERMINAL_EMULATOR: "JetBrains-JediTerm" }, "/work/app"),
|
|
45
|
+
).toEqual({ kind: "jetbrains", project: "app", product: "idea" });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("sem pistas → none", () => {
|
|
49
|
+
expect(detectEditor({}, "/work/app")).toEqual({
|
|
50
|
+
kind: "none",
|
|
51
|
+
project: "app",
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("resolveEditor força vscode/jetbrains/none", () => {
|
|
56
|
+
expect(resolveEditor("vscode", {}, "/work/app")).toMatchObject({
|
|
57
|
+
kind: "vscode",
|
|
58
|
+
});
|
|
59
|
+
expect(resolveEditor("jetbrains", {}, "/work/app")).toMatchObject({
|
|
60
|
+
kind: "jetbrains",
|
|
61
|
+
product: "idea",
|
|
62
|
+
});
|
|
63
|
+
expect(
|
|
64
|
+
resolveEditor("none", { TERM_PROGRAM: "vscode" }, "/work/app"),
|
|
65
|
+
).toMatchObject({ kind: "none" });
|
|
66
|
+
expect(resolveEditor("desconhecido", {}, "/work/app")).toMatchObject({
|
|
67
|
+
kind: "none",
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("links de editor", () => {
|
|
73
|
+
test("VS Code normaliza o drive e mantém a linha", () => {
|
|
74
|
+
expect(vscodeFileLink("C:\\repo\\src\\a.ts", 12)).toBe(
|
|
75
|
+
"vscode://file/c:/repo/src/a.ts:12",
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("JetBrains monta navigate/reference", () => {
|
|
80
|
+
const link = jetbrainsFileLink("C:\\repo\\src\\a.ts", 12, "app", "webstorm");
|
|
81
|
+
expect(link).toContain(
|
|
82
|
+
"jetbrains://webstorm/navigate/reference?project=app&path=",
|
|
83
|
+
);
|
|
84
|
+
expect(link ? decodeURIComponent(link) : "").toContain("C:/repo/src/a.ts:12");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("editorFileLink respeita o tipo detectado", () => {
|
|
88
|
+
expect(editorFileLink("/repo/a.ts", 3, { kind: "vscode", project: "app" })).toBe(
|
|
89
|
+
"vscode://file//repo/a.ts:3",
|
|
90
|
+
);
|
|
91
|
+
expect(
|
|
92
|
+
editorFileLink("/repo/a.ts", 3, {
|
|
93
|
+
kind: "jetbrains",
|
|
94
|
+
project: "app",
|
|
95
|
+
product: "goland",
|
|
96
|
+
}),
|
|
97
|
+
).toContain("jetbrains://goland/");
|
|
98
|
+
expect(editorFileLink("/repo/a.ts", 3, { kind: "none" })).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
});
|