feishu-codex-console 1.0.0-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +101 -0
- package/.feishu-codex-policy.example.json +11 -0
- package/.feishu-codex-runbooks.example.json +36 -0
- package/CHANGELOG.md +129 -0
- package/CODE_OF_CONDUCT.md +7 -0
- package/CONTRIBUTING.md +52 -0
- package/LICENSE +21 -0
- package/README.en.md +81 -0
- package/README.md +398 -0
- package/ROADMAP.md +40 -0
- package/SECURITY.md +53 -0
- package/dist/account-quota-card.js +233 -0
- package/dist/account-quota.js +125 -0
- package/dist/app-server-client.js +281 -0
- package/dist/card-session.js +166 -0
- package/dist/codex-events.js +1 -0
- package/dist/codex-runner.js +875 -0
- package/dist/config.js +198 -0
- package/dist/confirmation-card.js +135 -0
- package/dist/control-card.js +345 -0
- package/dist/conversation-turn-session.js +209 -0
- package/dist/data-maintenance.js +71 -0
- package/dist/device-card.js +460 -0
- package/dist/device-health.js +94 -0
- package/dist/diagnostics.js +253 -0
- package/dist/doctor.js +250 -0
- package/dist/fallback-card-session.js +37 -0
- package/dist/health-file.js +75 -0
- package/dist/index.js +4330 -0
- package/dist/lark-cli.js +558 -0
- package/dist/lark-retry.js +34 -0
- package/dist/maintenance.js +140 -0
- package/dist/model-capabilities.js +31 -0
- package/dist/onboarding-card.js +312 -0
- package/dist/permission-lease.js +22 -0
- package/dist/policy.js +506 -0
- package/dist/progress.js +267 -0
- package/dist/project-card.js +303 -0
- package/dist/project-overview-card.js +182 -0
- package/dist/project-overview.js +278 -0
- package/dist/project-policy.js +160 -0
- package/dist/project-registry.js +259 -0
- package/dist/project-status.js +45 -0
- package/dist/project-workspace.js +55 -0
- package/dist/quota-card.js +94 -0
- package/dist/recovery-policy.js +26 -0
- package/dist/redaction.js +67 -0
- package/dist/remote-ready.js +112 -0
- package/dist/response-card.js +139 -0
- package/dist/result-card.js +166 -0
- package/dist/review-card.js +452 -0
- package/dist/runbook-card.js +272 -0
- package/dist/runbooks.js +191 -0
- package/dist/runtime-card.js +337 -0
- package/dist/session-card.js +128 -0
- package/dist/session-naming.js +14 -0
- package/dist/smoke.js +28 -0
- package/dist/state-backup.js +302 -0
- package/dist/state-store.js +874 -0
- package/dist/task-card.js +640 -0
- package/dist/task-center-card.js +176 -0
- package/dist/task-failure.js +43 -0
- package/dist/task-intent.js +76 -0
- package/dist/task-queue.js +187 -0
- package/dist/task-reconciliation.js +80 -0
- package/dist/task-review.js +497 -0
- package/dist/team-card.js +275 -0
- package/dist/team-directory.js +54 -0
- package/dist/team-policy.js +93 -0
- package/dist/types.js +1 -0
- package/dist/version.js +9 -0
- package/dist/workspace-session.js +64 -0
- package/docs/ARCHITECTURE.md +54 -0
- package/docs/COMPATIBILITY.md +55 -0
- package/docs/CONFIGURATION.md +88 -0
- package/docs/DEMO.md +45 -0
- package/docs/GOOD_FIRST_ISSUES.md +23 -0
- package/docs/INSTALLATION.md +207 -0
- package/docs/OPEN_SOURCE_PRODUCT_PLAN.md +113 -0
- package/docs/PRODUCT_REQUIREMENTS_MAP.md +591 -0
- package/docs/RELEASE_CHECKLIST.md +65 -0
- package/docs/TEAM_DEPLOYMENT.md +35 -0
- package/docs/TROUBLESHOOTING.md +130 -0
- package/docs/V4_WORKSPACE_SESSION_FLOW.md +232 -0
- package/docs/requirements/D10_MAINTENANCE_AND_ECOSYSTEM.md +103 -0
- package/docs/requirements/D1_INSTALLATION_AND_FIRST_CONNECTION.md +479 -0
- package/docs/requirements/D2_DEVICE_AND_CONNECTIVITY.md +54 -0
- package/docs/requirements/D3_PROJECTS_AND_SESSIONS.md +107 -0
- package/docs/requirements/D4_REMOTE_TASK_EXECUTION.md +102 -0
- package/docs/requirements/D5_CODEX_NATIVE_INTERACTIONS.md +99 -0
- package/docs/requirements/D6_RESULTS_AND_CODE_REVIEW.md +100 -0
- package/docs/requirements/D7_SECURITY_GOVERNANCE.md +106 -0
- package/docs/requirements/D8_RELIABILITY_AND_RECOVERY.md +182 -0
- package/docs/requirements/D9_TEAM_COLLABORATION.md +129 -0
- package/package.json +76 -0
- package/scripts/capability-probe.mjs +113 -0
- package/scripts/cli.mjs +919 -0
- package/scripts/config-file.mjs +137 -0
- package/scripts/discovery-lib.mjs +78 -0
- package/scripts/install-card.mjs +37 -0
- package/scripts/install-detection.mjs +126 -0
- package/scripts/install-state.mjs +107 -0
- package/scripts/launchd.mjs +161 -0
- package/scripts/migrate-legacy.mjs +97 -0
- package/scripts/package-smoke.mjs +163 -0
- package/scripts/release-dist-tag.mjs +7 -0
- package/scripts/runbook-template.mjs +36 -0
- package/scripts/service-health.mjs +110 -0
- package/scripts/service.mjs +24 -0
- package/scripts/setup-lib.mjs +118 -0
- package/scripts/systemd.mjs +96 -0
- package/scripts/upgrade-lib.mjs +99 -0
- package/scripts/verify-release.mjs +37 -0
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { createReadStream } from "node:fs";
|
|
4
|
+
import { lstat, readFile, readlink } from "node:fs/promises";
|
|
5
|
+
import { basename, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { redactSensitiveText } from "./redaction.js";
|
|
7
|
+
const MAX_GIT_OUTPUT_BYTES = 4 * 1024 * 1024;
|
|
8
|
+
const MAX_REVIEW_FILES = 80;
|
|
9
|
+
const MAX_UNTRACKED_PREVIEW_BYTES = 256 * 1024;
|
|
10
|
+
const MAX_DIFF_CHARS = 500_000;
|
|
11
|
+
export async function captureTaskReviewBaseline(projectPath, isGitRepository) {
|
|
12
|
+
const capturedAt = new Date().toISOString();
|
|
13
|
+
if (!isGitRepository) {
|
|
14
|
+
return {
|
|
15
|
+
capturedAt,
|
|
16
|
+
available: false,
|
|
17
|
+
dirty: false,
|
|
18
|
+
files: [],
|
|
19
|
+
truncated: false,
|
|
20
|
+
error: "当前项目不是 Git 仓库,无法建立可归因的代码基线。",
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const [branch, head, states] = await Promise.all([
|
|
25
|
+
runGit(projectPath, ["branch", "--show-current"]).catch(() => ""),
|
|
26
|
+
runGit(projectPath, ["rev-parse", "HEAD"]).catch(() => ""),
|
|
27
|
+
readGitWorkspaceState(projectPath, "HEAD"),
|
|
28
|
+
]);
|
|
29
|
+
const files = await Promise.all([...states.values()].slice(0, 200).map(async (file) => ({
|
|
30
|
+
path: file.path,
|
|
31
|
+
fingerprint: await fileFingerprint(projectPath, file.path),
|
|
32
|
+
})));
|
|
33
|
+
return {
|
|
34
|
+
capturedAt,
|
|
35
|
+
available: true,
|
|
36
|
+
dirty: states.size > 0,
|
|
37
|
+
...(branch.trim() ? { branch: branch.trim() } : {}),
|
|
38
|
+
...(head.trim() ? { head: head.trim() } : {}),
|
|
39
|
+
files,
|
|
40
|
+
truncated: states.size > files.length,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
return {
|
|
45
|
+
capturedAt,
|
|
46
|
+
available: false,
|
|
47
|
+
dirty: false,
|
|
48
|
+
files: [],
|
|
49
|
+
truncated: false,
|
|
50
|
+
error: reviewError(error),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export async function captureTaskReview(projectPath, baseline, observedChanges = []) {
|
|
55
|
+
const capturedAt = new Date().toISOString();
|
|
56
|
+
if (!baseline?.available) {
|
|
57
|
+
const files = await observedReviewFiles(projectPath, observedChanges, "mixed");
|
|
58
|
+
return {
|
|
59
|
+
capturedAt,
|
|
60
|
+
availability: files.length > 0 ? "ready" : "unavailable",
|
|
61
|
+
attribution: "unknown",
|
|
62
|
+
files,
|
|
63
|
+
totalFiles: files.length,
|
|
64
|
+
totalAdditions: sumKnown(files, "additions"),
|
|
65
|
+
totalDeletions: sumKnown(files, "deletions"),
|
|
66
|
+
binaryFiles: files.filter((file) => file.binary).length,
|
|
67
|
+
preexistingFilesExcluded: 0,
|
|
68
|
+
truncated: false,
|
|
69
|
+
error: baseline?.error ?? "任务开始时没有可用的 Git 基线。",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const baseRef = baseline.head || "HEAD";
|
|
74
|
+
const states = await readGitWorkspaceState(projectPath, baseRef);
|
|
75
|
+
const observed = new Map(observedChanges
|
|
76
|
+
.map((change) => [normalizeObservedPath(projectPath, change.path), change])
|
|
77
|
+
.filter(([path]) => Boolean(path)));
|
|
78
|
+
for (const [path, change] of observed) {
|
|
79
|
+
if (!path || states.has(path))
|
|
80
|
+
continue;
|
|
81
|
+
states.set(path, {
|
|
82
|
+
path,
|
|
83
|
+
kind: change.kind,
|
|
84
|
+
additions: null,
|
|
85
|
+
deletions: null,
|
|
86
|
+
binary: false,
|
|
87
|
+
fingerprint: await fileFingerprint(projectPath, path),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
const baselineFiles = new Map(baseline.files.map((file) => [file.path, file]));
|
|
91
|
+
let preexistingFilesExcluded = 0;
|
|
92
|
+
const reviewFiles = [];
|
|
93
|
+
for (const state of states.values()) {
|
|
94
|
+
const before = baselineFiles.get(state.path);
|
|
95
|
+
const wasObserved = observed.has(state.path);
|
|
96
|
+
if (before && before.fingerprint === state.fingerprint && !wasObserved) {
|
|
97
|
+
preexistingFilesExcluded += 1;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
reviewFiles.push({
|
|
101
|
+
...state,
|
|
102
|
+
sensitive: isSensitiveReviewPath(state.path),
|
|
103
|
+
attribution: before ? "mixed" : "task",
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
reviewFiles.sort((left, right) => left.path.localeCompare(right.path));
|
|
107
|
+
const totalFiles = reviewFiles.length;
|
|
108
|
+
const visibleFiles = reviewFiles.slice(0, MAX_REVIEW_FILES);
|
|
109
|
+
const hasMixedFiles = baseline.truncated === true || reviewFiles.some((file) => file.attribution === "mixed");
|
|
110
|
+
return {
|
|
111
|
+
capturedAt,
|
|
112
|
+
availability: totalFiles > 0 ? "ready" : "no_changes",
|
|
113
|
+
attribution: hasMixedFiles ? "mixed" : "task",
|
|
114
|
+
...(baseline.branch ? { branch: baseline.branch } : {}),
|
|
115
|
+
...(baseline.head ? { baseHead: baseline.head } : {}),
|
|
116
|
+
files: visibleFiles,
|
|
117
|
+
totalFiles,
|
|
118
|
+
totalAdditions: sumKnown(reviewFiles, "additions"),
|
|
119
|
+
totalDeletions: sumKnown(reviewFiles, "deletions"),
|
|
120
|
+
binaryFiles: reviewFiles.filter((file) => file.binary).length,
|
|
121
|
+
preexistingFilesExcluded,
|
|
122
|
+
truncated: totalFiles > visibleFiles.length,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
const files = await observedReviewFiles(projectPath, observedChanges, "mixed");
|
|
127
|
+
return {
|
|
128
|
+
capturedAt,
|
|
129
|
+
availability: files.length > 0 ? "ready" : "unavailable",
|
|
130
|
+
attribution: "unknown",
|
|
131
|
+
files,
|
|
132
|
+
totalFiles: files.length,
|
|
133
|
+
totalAdditions: sumKnown(files, "additions"),
|
|
134
|
+
totalDeletions: sumKnown(files, "deletions"),
|
|
135
|
+
binaryFiles: files.filter((file) => file.binary).length,
|
|
136
|
+
preexistingFilesExcluded: 0,
|
|
137
|
+
truncated: false,
|
|
138
|
+
error: reviewError(error),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export async function readTaskFileDiff(projectPath, baseline, file) {
|
|
143
|
+
const capturedAt = new Date().toISOString();
|
|
144
|
+
const stale = (await fileFingerprint(projectPath, file.path)) !== file.fingerprint;
|
|
145
|
+
if (file.sensitive) {
|
|
146
|
+
return {
|
|
147
|
+
path: file.path,
|
|
148
|
+
content: "该路径可能包含凭据或秘密,飞书端不展示内容。请回到可信本机审阅。",
|
|
149
|
+
stale,
|
|
150
|
+
sensitive: true,
|
|
151
|
+
truncated: false,
|
|
152
|
+
capturedAt,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
if (!baseline?.available) {
|
|
156
|
+
return {
|
|
157
|
+
path: file.path,
|
|
158
|
+
content: "缺少任务开始时的 Git 基线,无法生成可信 Diff。",
|
|
159
|
+
stale,
|
|
160
|
+
sensitive: false,
|
|
161
|
+
truncated: false,
|
|
162
|
+
capturedAt,
|
|
163
|
+
error: "baseline unavailable",
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
let raw = "";
|
|
168
|
+
if (file.kind === "add" && !(await isTracked(projectPath, file.path))) {
|
|
169
|
+
raw = await untrackedFilePreview(projectPath, file.path);
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
raw = await runGit(projectPath, [
|
|
173
|
+
"diff",
|
|
174
|
+
"--relative",
|
|
175
|
+
"--no-ext-diff",
|
|
176
|
+
"--no-color",
|
|
177
|
+
"--unified=3",
|
|
178
|
+
baseline.head || "HEAD",
|
|
179
|
+
"--",
|
|
180
|
+
file.path,
|
|
181
|
+
]);
|
|
182
|
+
}
|
|
183
|
+
const redacted = redactReviewText(raw || "当前文件没有可展示的文本差异。");
|
|
184
|
+
const truncated = redacted.length > MAX_DIFF_CHARS;
|
|
185
|
+
return {
|
|
186
|
+
path: file.path,
|
|
187
|
+
content: truncated ? `${redacted.slice(0, MAX_DIFF_CHARS)}\n\n[Diff 已截断]` : redacted,
|
|
188
|
+
stale,
|
|
189
|
+
sensitive: false,
|
|
190
|
+
truncated,
|
|
191
|
+
capturedAt,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
return {
|
|
196
|
+
path: file.path,
|
|
197
|
+
content: "暂时无法读取这个文件的 Diff,请回到本机查看。",
|
|
198
|
+
stale,
|
|
199
|
+
sensitive: false,
|
|
200
|
+
truncated: false,
|
|
201
|
+
capturedAt,
|
|
202
|
+
error: reviewError(error),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
export function isTestCommand(command) {
|
|
207
|
+
const normalized = command.trim().toLocaleLowerCase();
|
|
208
|
+
return [
|
|
209
|
+
/(?:^|\s)(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test(?:\s|$|:)/,
|
|
210
|
+
/(?:^|\s)(?:vitest|jest|pytest|rspec)(?:\s|$)/,
|
|
211
|
+
/(?:^|\s)go\s+test(?:\s|$)/,
|
|
212
|
+
/(?:^|\s)cargo\s+test(?:\s|$)/,
|
|
213
|
+
/(?:^|\s)dotnet\s+test(?:\s|$)/,
|
|
214
|
+
/(?:^|\s)(?:mvn|mvnw)(?:\s+[^;&|]+)?\s+test(?:\s|$)/,
|
|
215
|
+
/(?:^|\s)(?:gradle|gradlew)(?:\s+[^;&|]+)?\s+test(?:\s|$)/,
|
|
216
|
+
/(?:^|\s)swift\s+test(?:\s|$)/,
|
|
217
|
+
].some((pattern) => pattern.test(normalized));
|
|
218
|
+
}
|
|
219
|
+
export function summarizeCommandOutput(output, maxChars = 1_200) {
|
|
220
|
+
const clean = redactReviewText(output
|
|
221
|
+
.replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, "")
|
|
222
|
+
.split(/\r?\n/)
|
|
223
|
+
.map((line) => line.trimEnd())
|
|
224
|
+
.filter(Boolean)
|
|
225
|
+
.slice(-12)
|
|
226
|
+
.join("\n"));
|
|
227
|
+
if (clean.length <= maxChars)
|
|
228
|
+
return clean;
|
|
229
|
+
return `…${clean.slice(clean.length - maxChars + 1)}`;
|
|
230
|
+
}
|
|
231
|
+
export function redactReviewText(value) {
|
|
232
|
+
return redactSensitiveText(value);
|
|
233
|
+
}
|
|
234
|
+
export function isSensitiveReviewPath(path) {
|
|
235
|
+
const normalized = path.replaceAll("\\", "/").toLocaleLowerCase();
|
|
236
|
+
const name = basename(normalized);
|
|
237
|
+
return (name === ".env" ||
|
|
238
|
+
name.startsWith(".env.") ||
|
|
239
|
+
[".npmrc", ".netrc", "credentials", "id_rsa", "id_ed25519"].includes(name) ||
|
|
240
|
+
/\.(?:pem|key|p12|pfx|jks|keystore)$/.test(name) ||
|
|
241
|
+
/(?:^|\/)\.ssh\//.test(normalized) ||
|
|
242
|
+
/(?:^|\/)(?:secret|secrets|credentials)(?:\/|\.|$)/.test(normalized));
|
|
243
|
+
}
|
|
244
|
+
async function readGitWorkspaceState(projectPath, baseRef) {
|
|
245
|
+
// Keep index-backed reads ordered. Concurrent Git processes can race while
|
|
246
|
+
// refreshing the index/untracked cache and intermittently omit a freshly
|
|
247
|
+
// created untracked file from the task baseline on Linux.
|
|
248
|
+
const namesOutput = await runGit(projectPath, [
|
|
249
|
+
"diff",
|
|
250
|
+
"--relative",
|
|
251
|
+
"--no-ext-diff",
|
|
252
|
+
"--no-renames",
|
|
253
|
+
"--name-status",
|
|
254
|
+
"-z",
|
|
255
|
+
baseRef,
|
|
256
|
+
"--",
|
|
257
|
+
".",
|
|
258
|
+
]);
|
|
259
|
+
const numstatOutput = await runGit(projectPath, [
|
|
260
|
+
"diff",
|
|
261
|
+
"--relative",
|
|
262
|
+
"--no-ext-diff",
|
|
263
|
+
"--no-renames",
|
|
264
|
+
"--numstat",
|
|
265
|
+
"-z",
|
|
266
|
+
baseRef,
|
|
267
|
+
"--",
|
|
268
|
+
".",
|
|
269
|
+
]);
|
|
270
|
+
const untrackedOutput = await runGit(projectPath, [
|
|
271
|
+
"-c",
|
|
272
|
+
"core.untrackedCache=false",
|
|
273
|
+
"ls-files",
|
|
274
|
+
"--others",
|
|
275
|
+
"--exclude-standard",
|
|
276
|
+
"-z",
|
|
277
|
+
"--",
|
|
278
|
+
".",
|
|
279
|
+
]);
|
|
280
|
+
const stats = parseNumstat(numstatOutput);
|
|
281
|
+
const states = new Map();
|
|
282
|
+
const nameParts = namesOutput.split("\0").filter(Boolean);
|
|
283
|
+
for (let index = 0; index + 1 < nameParts.length; index += 2) {
|
|
284
|
+
const status = nameParts[index] ?? "M";
|
|
285
|
+
const path = normalizeGitPath(nameParts[index + 1] ?? "");
|
|
286
|
+
if (!path)
|
|
287
|
+
continue;
|
|
288
|
+
const numbers = stats.get(path);
|
|
289
|
+
states.set(path, {
|
|
290
|
+
path,
|
|
291
|
+
kind: status.startsWith("A") ? "add" : status.startsWith("D") ? "delete" : "update",
|
|
292
|
+
additions: numbers?.additions ?? null,
|
|
293
|
+
deletions: numbers?.deletions ?? null,
|
|
294
|
+
binary: numbers?.binary ?? false,
|
|
295
|
+
fingerprint: await fileFingerprint(projectPath, path),
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
for (const rawPath of untrackedOutput.split("\0").filter(Boolean)) {
|
|
299
|
+
const path = normalizeGitPath(rawPath);
|
|
300
|
+
if (!path || states.has(path))
|
|
301
|
+
continue;
|
|
302
|
+
const stats = await untrackedStats(projectPath, path);
|
|
303
|
+
states.set(path, {
|
|
304
|
+
path,
|
|
305
|
+
kind: "add",
|
|
306
|
+
additions: stats.additions,
|
|
307
|
+
deletions: 0,
|
|
308
|
+
binary: stats.binary,
|
|
309
|
+
fingerprint: await fileFingerprint(projectPath, path),
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
return states;
|
|
313
|
+
}
|
|
314
|
+
function parseNumstat(output) {
|
|
315
|
+
const result = new Map();
|
|
316
|
+
for (const entry of output.split("\0").filter(Boolean)) {
|
|
317
|
+
const [added = "-", deleted = "-", ...pathParts] = entry.split("\t");
|
|
318
|
+
const path = normalizeGitPath(pathParts.join("\t"));
|
|
319
|
+
if (!path)
|
|
320
|
+
continue;
|
|
321
|
+
const binary = added === "-" || deleted === "-";
|
|
322
|
+
result.set(path, {
|
|
323
|
+
additions: binary ? null : Number.parseInt(added, 10),
|
|
324
|
+
deletions: binary ? null : Number.parseInt(deleted, 10),
|
|
325
|
+
binary,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
return result;
|
|
329
|
+
}
|
|
330
|
+
async function observedReviewFiles(projectPath, changes, attribution) {
|
|
331
|
+
const result = [];
|
|
332
|
+
for (const change of changes.slice(0, MAX_REVIEW_FILES)) {
|
|
333
|
+
const path = normalizeObservedPath(projectPath, change.path);
|
|
334
|
+
if (!path)
|
|
335
|
+
continue;
|
|
336
|
+
result.push({
|
|
337
|
+
path,
|
|
338
|
+
kind: change.kind,
|
|
339
|
+
additions: null,
|
|
340
|
+
deletions: null,
|
|
341
|
+
binary: false,
|
|
342
|
+
sensitive: isSensitiveReviewPath(path),
|
|
343
|
+
attribution,
|
|
344
|
+
fingerprint: await fileFingerprint(projectPath, path),
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
return result;
|
|
348
|
+
}
|
|
349
|
+
async function untrackedStats(projectPath, path) {
|
|
350
|
+
const localPath = safeLocalPath(projectPath, path);
|
|
351
|
+
if (!localPath)
|
|
352
|
+
return { additions: null, binary: false };
|
|
353
|
+
try {
|
|
354
|
+
const details = await lstat(localPath);
|
|
355
|
+
if (!details.isFile() || details.size > MAX_UNTRACKED_PREVIEW_BYTES) {
|
|
356
|
+
return { additions: null, binary: details.isFile() && details.size > 0 };
|
|
357
|
+
}
|
|
358
|
+
const content = await readFile(localPath);
|
|
359
|
+
if (content.includes(0))
|
|
360
|
+
return { additions: null, binary: true };
|
|
361
|
+
const text = content.toString("utf8");
|
|
362
|
+
return { additions: text ? text.split(/\r?\n/).length : 0, binary: false };
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
return { additions: null, binary: false };
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
async function untrackedFilePreview(projectPath, path) {
|
|
369
|
+
const localPath = safeLocalPath(projectPath, path);
|
|
370
|
+
if (!localPath)
|
|
371
|
+
throw new Error("unsafe review path");
|
|
372
|
+
const details = await lstat(localPath);
|
|
373
|
+
if (!details.isFile())
|
|
374
|
+
return "该路径不是普通文件,无法生成文本 Diff。";
|
|
375
|
+
if (details.size > MAX_UNTRACKED_PREVIEW_BYTES) {
|
|
376
|
+
return `新增文件大小为 ${details.size} 字节,超过飞书预览上限。`;
|
|
377
|
+
}
|
|
378
|
+
const content = await readFile(localPath);
|
|
379
|
+
if (content.includes(0))
|
|
380
|
+
return "新增文件是二进制内容,飞书端不展示正文。";
|
|
381
|
+
const lines = content.toString("utf8").split(/\r?\n/);
|
|
382
|
+
return [
|
|
383
|
+
`diff --git a/${path} b/${path}`,
|
|
384
|
+
"new file mode 100644",
|
|
385
|
+
"--- /dev/null",
|
|
386
|
+
`+++ b/${path}`,
|
|
387
|
+
`@@ -0,0 +1,${lines.length} @@`,
|
|
388
|
+
...lines.map((line) => `+${line}`),
|
|
389
|
+
].join("\n");
|
|
390
|
+
}
|
|
391
|
+
async function isTracked(projectPath, path) {
|
|
392
|
+
try {
|
|
393
|
+
await runGit(projectPath, ["ls-files", "--error-unmatch", "--", path]);
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
async function fileFingerprint(projectPath, path) {
|
|
401
|
+
const localPath = safeLocalPath(projectPath, path);
|
|
402
|
+
if (!localPath)
|
|
403
|
+
return null;
|
|
404
|
+
try {
|
|
405
|
+
const details = await lstat(localPath);
|
|
406
|
+
if (details.isSymbolicLink()) {
|
|
407
|
+
return `link:${await readlink(localPath)}`;
|
|
408
|
+
}
|
|
409
|
+
if (details.isFile()) {
|
|
410
|
+
const digest = await hashFile(localPath);
|
|
411
|
+
return `file:${details.size}:${details.mode & 0o777}:${digest}`;
|
|
412
|
+
}
|
|
413
|
+
return `other:${details.mode}:${details.size}`;
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
if (error.code === "ENOENT")
|
|
417
|
+
return "deleted";
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
function hashFile(path) {
|
|
422
|
+
return new Promise((resolvePromise, reject) => {
|
|
423
|
+
const hash = createHash("sha256");
|
|
424
|
+
const stream = createReadStream(path);
|
|
425
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
426
|
+
stream.once("error", reject);
|
|
427
|
+
stream.once("end", () => resolvePromise(`sha256:${hash.digest("hex")}`));
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
function normalizeObservedPath(projectPath, path) {
|
|
431
|
+
const absolute = resolve(projectPath, path);
|
|
432
|
+
const root = resolve(projectPath);
|
|
433
|
+
if (absolute !== root && !absolute.startsWith(`${root}${sep}`))
|
|
434
|
+
return "";
|
|
435
|
+
return normalizeGitPath(relative(root, absolute));
|
|
436
|
+
}
|
|
437
|
+
function normalizeGitPath(path) {
|
|
438
|
+
return path.replaceAll("\\", "/").replace(/^\.\//, "").trim();
|
|
439
|
+
}
|
|
440
|
+
function safeLocalPath(projectPath, path) {
|
|
441
|
+
const root = resolve(projectPath);
|
|
442
|
+
const localPath = resolve(root, path);
|
|
443
|
+
return localPath === root || localPath.startsWith(`${root}${sep}`) ? localPath : null;
|
|
444
|
+
}
|
|
445
|
+
function sumKnown(files, key) {
|
|
446
|
+
return files.reduce((total, file) => total + (file[key] ?? 0), 0);
|
|
447
|
+
}
|
|
448
|
+
function reviewError(error) {
|
|
449
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
450
|
+
return redactReviewText(message).slice(0, 500);
|
|
451
|
+
}
|
|
452
|
+
function runGit(cwd, args) {
|
|
453
|
+
return new Promise((resolvePromise, reject) => {
|
|
454
|
+
const child = spawn("git", args, {
|
|
455
|
+
cwd,
|
|
456
|
+
env: {
|
|
457
|
+
...process.env,
|
|
458
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
459
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
460
|
+
},
|
|
461
|
+
shell: false,
|
|
462
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
463
|
+
});
|
|
464
|
+
const stdout = [];
|
|
465
|
+
const stderr = [];
|
|
466
|
+
let bytes = 0;
|
|
467
|
+
let overflow = false;
|
|
468
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), 15_000);
|
|
469
|
+
timer.unref();
|
|
470
|
+
child.stdout.on("data", (chunk) => {
|
|
471
|
+
bytes += chunk.length;
|
|
472
|
+
if (bytes > MAX_GIT_OUTPUT_BYTES) {
|
|
473
|
+
overflow = true;
|
|
474
|
+
child.kill("SIGKILL");
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
stdout.push(chunk);
|
|
478
|
+
});
|
|
479
|
+
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
480
|
+
child.once("error", (error) => {
|
|
481
|
+
clearTimeout(timer);
|
|
482
|
+
reject(error);
|
|
483
|
+
});
|
|
484
|
+
child.once("exit", (code) => {
|
|
485
|
+
clearTimeout(timer);
|
|
486
|
+
if (overflow) {
|
|
487
|
+
reject(new Error("Git review output exceeded the safe local limit"));
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
if (code !== 0) {
|
|
491
|
+
reject(new Error(Buffer.concat(stderr).toString("utf8").trim() || `git exited ${code}`));
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
resolvePromise(Buffer.concat(stdout).toString("utf8"));
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
}
|