gitfamiliar 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/LICENSE +21 -0
- package/README.md +168 -0
- package/dist/bin/gitfamiliar.js +514 -0
- package/dist/bin/gitfamiliar.js.map +1 -0
- package/dist/chunk-DW2PHZVZ.js +867 -0
- package/dist/chunk-DW2PHZVZ.js.map +1 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/package.json +61 -0
- package/templates/gitfamiliarignore.default +24 -0
|
@@ -0,0 +1,867 @@
|
|
|
1
|
+
// src/git/client.ts
|
|
2
|
+
import simpleGit from "simple-git";
|
|
3
|
+
var GitClient = class {
|
|
4
|
+
git;
|
|
5
|
+
repoPath;
|
|
6
|
+
constructor(repoPath) {
|
|
7
|
+
this.repoPath = repoPath;
|
|
8
|
+
this.git = simpleGit(repoPath);
|
|
9
|
+
}
|
|
10
|
+
async isRepo() {
|
|
11
|
+
return this.git.checkIsRepo();
|
|
12
|
+
}
|
|
13
|
+
async getRepoRoot() {
|
|
14
|
+
return (await this.git.revparse(["--show-toplevel"])).trim();
|
|
15
|
+
}
|
|
16
|
+
async getRepoName() {
|
|
17
|
+
const root = await this.getRepoRoot();
|
|
18
|
+
return root.split("/").pop() || "unknown";
|
|
19
|
+
}
|
|
20
|
+
async listFiles() {
|
|
21
|
+
const result = await this.git.raw(["ls-files"]);
|
|
22
|
+
return result.trim().split("\n").filter(Boolean);
|
|
23
|
+
}
|
|
24
|
+
async getUserName() {
|
|
25
|
+
return (await this.git.raw(["config", "user.name"])).trim();
|
|
26
|
+
}
|
|
27
|
+
async getUserEmail() {
|
|
28
|
+
return (await this.git.raw(["config", "user.email"])).trim();
|
|
29
|
+
}
|
|
30
|
+
async getLog(args) {
|
|
31
|
+
return this.git.raw(["log", ...args]);
|
|
32
|
+
}
|
|
33
|
+
async blame(filePath, options = []) {
|
|
34
|
+
return this.git.raw(["blame", ...options, "--", filePath]);
|
|
35
|
+
}
|
|
36
|
+
async diff(args) {
|
|
37
|
+
return this.git.raw(["diff", ...args]);
|
|
38
|
+
}
|
|
39
|
+
async show(args) {
|
|
40
|
+
return this.git.raw(["show", ...args]);
|
|
41
|
+
}
|
|
42
|
+
async raw(args) {
|
|
43
|
+
return this.git.raw(args);
|
|
44
|
+
}
|
|
45
|
+
async getRemoteUrl() {
|
|
46
|
+
try {
|
|
47
|
+
const result = await this.git.raw(["remote", "get-url", "origin"]);
|
|
48
|
+
return result.trim();
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// src/git/identity.ts
|
|
56
|
+
async function resolveUser(gitClient, userFlag) {
|
|
57
|
+
if (userFlag) {
|
|
58
|
+
if (userFlag.includes("@")) {
|
|
59
|
+
return { name: userFlag, email: userFlag };
|
|
60
|
+
}
|
|
61
|
+
return { name: userFlag, email: userFlag };
|
|
62
|
+
}
|
|
63
|
+
const name = await gitClient.getUserName();
|
|
64
|
+
const email = await gitClient.getUserEmail();
|
|
65
|
+
if (!name && !email) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
"Could not determine git user. Set git config user.name/user.email or use --user flag."
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return { name, email };
|
|
71
|
+
}
|
|
72
|
+
function getAuthorArgs(user) {
|
|
73
|
+
return ["--author", user.email || user.name];
|
|
74
|
+
}
|
|
75
|
+
function matchesUser(authorName, authorEmail, user) {
|
|
76
|
+
if (user.email && authorEmail) {
|
|
77
|
+
if (authorEmail.toLowerCase() === user.email.toLowerCase()) return true;
|
|
78
|
+
}
|
|
79
|
+
if (user.name && authorName) {
|
|
80
|
+
if (authorName.toLowerCase() === user.name.toLowerCase()) return true;
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// src/filter/ignore.ts
|
|
86
|
+
import ignore from "ignore";
|
|
87
|
+
import { readFileSync, existsSync } from "fs";
|
|
88
|
+
import { join } from "path";
|
|
89
|
+
|
|
90
|
+
// src/filter/defaults.ts
|
|
91
|
+
var DEFAULT_IGNORE_PATTERNS = `# Lock files
|
|
92
|
+
package-lock.json
|
|
93
|
+
yarn.lock
|
|
94
|
+
pnpm-lock.yaml
|
|
95
|
+
Gemfile.lock
|
|
96
|
+
poetry.lock
|
|
97
|
+
Cargo.lock
|
|
98
|
+
composer.lock
|
|
99
|
+
|
|
100
|
+
# Auto-generated
|
|
101
|
+
*.generated.*
|
|
102
|
+
*.min.js
|
|
103
|
+
*.min.css
|
|
104
|
+
*.map
|
|
105
|
+
|
|
106
|
+
# Build outputs (if git-tracked)
|
|
107
|
+
dist/
|
|
108
|
+
build/
|
|
109
|
+
.next/
|
|
110
|
+
|
|
111
|
+
# Config that rarely needs understanding
|
|
112
|
+
.eslintrc*
|
|
113
|
+
.prettierrc*
|
|
114
|
+
tsconfig.json
|
|
115
|
+
`;
|
|
116
|
+
|
|
117
|
+
// src/filter/ignore.ts
|
|
118
|
+
function createFilter(repoRoot) {
|
|
119
|
+
const ig = ignore();
|
|
120
|
+
const ignorePath = join(repoRoot, ".gitfamiliarignore");
|
|
121
|
+
if (existsSync(ignorePath)) {
|
|
122
|
+
const content = readFileSync(ignorePath, "utf-8");
|
|
123
|
+
ig.add(content);
|
|
124
|
+
} else {
|
|
125
|
+
ig.add(DEFAULT_IGNORE_PATTERNS);
|
|
126
|
+
}
|
|
127
|
+
return (filePath) => !ig.ignores(filePath);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// src/utils/line-count.ts
|
|
131
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
132
|
+
import { join as join2 } from "path";
|
|
133
|
+
function countLines(repoRoot, filePath) {
|
|
134
|
+
try {
|
|
135
|
+
const fullPath = join2(repoRoot, filePath);
|
|
136
|
+
const content = readFileSync2(fullPath, "utf-8");
|
|
137
|
+
if (content.length === 0) return 0;
|
|
138
|
+
return content.split("\n").length;
|
|
139
|
+
} catch {
|
|
140
|
+
return 0;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// src/core/file-tree.ts
|
|
145
|
+
async function buildFileTree(gitClient, filter) {
|
|
146
|
+
const repoRoot = await gitClient.getRepoRoot();
|
|
147
|
+
const allFiles = await gitClient.listFiles();
|
|
148
|
+
const filteredFiles = allFiles.filter(filter);
|
|
149
|
+
const fileScores = filteredFiles.map((filePath) => ({
|
|
150
|
+
type: "file",
|
|
151
|
+
path: filePath,
|
|
152
|
+
lines: countLines(repoRoot, filePath),
|
|
153
|
+
score: 0
|
|
154
|
+
}));
|
|
155
|
+
return buildTreeFromFiles(fileScores);
|
|
156
|
+
}
|
|
157
|
+
function buildTreeFromFiles(files) {
|
|
158
|
+
const root = {
|
|
159
|
+
type: "folder",
|
|
160
|
+
path: "",
|
|
161
|
+
lines: 0,
|
|
162
|
+
score: 0,
|
|
163
|
+
fileCount: 0,
|
|
164
|
+
children: []
|
|
165
|
+
};
|
|
166
|
+
const folderMap = /* @__PURE__ */ new Map();
|
|
167
|
+
folderMap.set("", root);
|
|
168
|
+
for (const file of files) {
|
|
169
|
+
const parts = file.path.split("/");
|
|
170
|
+
let currentPath = "";
|
|
171
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
172
|
+
const parentPath2 = currentPath;
|
|
173
|
+
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];
|
|
174
|
+
if (!folderMap.has(currentPath)) {
|
|
175
|
+
const folder = {
|
|
176
|
+
type: "folder",
|
|
177
|
+
path: currentPath,
|
|
178
|
+
lines: 0,
|
|
179
|
+
score: 0,
|
|
180
|
+
fileCount: 0,
|
|
181
|
+
children: []
|
|
182
|
+
};
|
|
183
|
+
folderMap.set(currentPath, folder);
|
|
184
|
+
const parent2 = folderMap.get(parentPath2);
|
|
185
|
+
parent2.children.push(folder);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const parentPath = parts.length > 1 ? parts.slice(0, -1).join("/") : "";
|
|
189
|
+
const parent = folderMap.get(parentPath);
|
|
190
|
+
parent.children.push(file);
|
|
191
|
+
}
|
|
192
|
+
computeAggregates(root);
|
|
193
|
+
return root;
|
|
194
|
+
}
|
|
195
|
+
function computeAggregates(node) {
|
|
196
|
+
let totalLines = 0;
|
|
197
|
+
let totalFiles = 0;
|
|
198
|
+
for (const child of node.children) {
|
|
199
|
+
if (child.type === "file") {
|
|
200
|
+
totalLines += child.lines;
|
|
201
|
+
totalFiles += 1;
|
|
202
|
+
} else {
|
|
203
|
+
computeAggregates(child);
|
|
204
|
+
totalLines += child.lines;
|
|
205
|
+
totalFiles += child.fileCount;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
node.lines = totalLines;
|
|
209
|
+
node.fileCount = totalFiles;
|
|
210
|
+
}
|
|
211
|
+
function walkFiles(node, visitor) {
|
|
212
|
+
if (node.type === "file") {
|
|
213
|
+
visitor(node);
|
|
214
|
+
} else {
|
|
215
|
+
for (const child of node.children) {
|
|
216
|
+
walkFiles(child, visitor);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function recomputeFolderScores(node, mode) {
|
|
221
|
+
let readCount = 0;
|
|
222
|
+
let totalFiles = 0;
|
|
223
|
+
let weightedScore = 0;
|
|
224
|
+
let totalLines = 0;
|
|
225
|
+
for (const child of node.children) {
|
|
226
|
+
if (child.type === "file") {
|
|
227
|
+
totalFiles += 1;
|
|
228
|
+
totalLines += child.lines;
|
|
229
|
+
weightedScore += child.score * child.lines;
|
|
230
|
+
if (child.score > 0) readCount += 1;
|
|
231
|
+
} else {
|
|
232
|
+
recomputeFolderScores(child, mode);
|
|
233
|
+
totalFiles += child.fileCount;
|
|
234
|
+
totalLines += child.lines;
|
|
235
|
+
weightedScore += child.score * child.lines;
|
|
236
|
+
readCount += child.readCount || 0;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
node.fileCount = totalFiles;
|
|
240
|
+
node.readCount = readCount;
|
|
241
|
+
if (mode === "binary") {
|
|
242
|
+
node.score = totalFiles > 0 ? readCount / totalFiles : 0;
|
|
243
|
+
} else {
|
|
244
|
+
node.score = totalLines > 0 ? weightedScore / totalLines : 0;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// src/git/log.ts
|
|
249
|
+
async function getFilesCommittedByUser(gitClient, user) {
|
|
250
|
+
const files = /* @__PURE__ */ new Set();
|
|
251
|
+
const queries = [];
|
|
252
|
+
if (user.email) {
|
|
253
|
+
queries.push(["--author", user.email]);
|
|
254
|
+
}
|
|
255
|
+
if (user.name && user.name !== user.email) {
|
|
256
|
+
queries.push(["--author", user.name]);
|
|
257
|
+
}
|
|
258
|
+
for (const authorArgs of queries) {
|
|
259
|
+
try {
|
|
260
|
+
const output = await gitClient.getLog([
|
|
261
|
+
...authorArgs,
|
|
262
|
+
"--name-only",
|
|
263
|
+
"--pretty=format:",
|
|
264
|
+
"--all"
|
|
265
|
+
]);
|
|
266
|
+
for (const line of output.split("\n")) {
|
|
267
|
+
const trimmed = line.trim();
|
|
268
|
+
if (trimmed) {
|
|
269
|
+
files.add(trimmed);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
} catch {
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return files;
|
|
276
|
+
}
|
|
277
|
+
async function getDetailedCommits(gitClient, user, filePath) {
|
|
278
|
+
const commits = [];
|
|
279
|
+
try {
|
|
280
|
+
const output = await gitClient.getLog([
|
|
281
|
+
...getAuthorArgs(user),
|
|
282
|
+
"--numstat",
|
|
283
|
+
"--pretty=format:%H|%aI",
|
|
284
|
+
"--",
|
|
285
|
+
filePath
|
|
286
|
+
]);
|
|
287
|
+
const lines = output.trim().split("\n");
|
|
288
|
+
let currentHash = "";
|
|
289
|
+
let currentDate = /* @__PURE__ */ new Date();
|
|
290
|
+
for (const line of lines) {
|
|
291
|
+
const trimmed = line.trim();
|
|
292
|
+
if (!trimmed) continue;
|
|
293
|
+
if (trimmed.includes("|")) {
|
|
294
|
+
const parts = trimmed.split("|");
|
|
295
|
+
currentHash = parts[0];
|
|
296
|
+
currentDate = new Date(parts[1]);
|
|
297
|
+
} else {
|
|
298
|
+
const statMatch = trimmed.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
|
|
299
|
+
if (statMatch && statMatch[3] === filePath) {
|
|
300
|
+
const added = statMatch[1] === "-" ? 0 : parseInt(statMatch[1], 10);
|
|
301
|
+
const deleted = statMatch[2] === "-" ? 0 : parseInt(statMatch[2], 10);
|
|
302
|
+
let fileSizeAtCommit = 1;
|
|
303
|
+
try {
|
|
304
|
+
const content = await gitClient.show([
|
|
305
|
+
`${currentHash}:${filePath}`
|
|
306
|
+
]);
|
|
307
|
+
fileSizeAtCommit = Math.max(1, content.split("\n").length);
|
|
308
|
+
} catch {
|
|
309
|
+
fileSizeAtCommit = Math.max(1, added);
|
|
310
|
+
}
|
|
311
|
+
commits.push({
|
|
312
|
+
hash: currentHash,
|
|
313
|
+
date: currentDate,
|
|
314
|
+
addedLines: added,
|
|
315
|
+
deletedLines: deleted,
|
|
316
|
+
fileSizeAtCommit
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
} catch {
|
|
322
|
+
}
|
|
323
|
+
return commits;
|
|
324
|
+
}
|
|
325
|
+
async function getLastCommitDate(gitClient, user, filePath) {
|
|
326
|
+
try {
|
|
327
|
+
const output = await gitClient.getLog([
|
|
328
|
+
...getAuthorArgs(user),
|
|
329
|
+
"-1",
|
|
330
|
+
"--pretty=format:%aI",
|
|
331
|
+
"--",
|
|
332
|
+
filePath
|
|
333
|
+
]);
|
|
334
|
+
const trimmed = output.trim();
|
|
335
|
+
if (trimmed) {
|
|
336
|
+
return new Date(trimmed);
|
|
337
|
+
}
|
|
338
|
+
} catch {
|
|
339
|
+
}
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// src/scoring/binary.ts
|
|
344
|
+
function scoreBinary(tree, writtenFiles, reviewedFiles, filterMode, expiredFiles) {
|
|
345
|
+
walkFiles(tree, (file) => {
|
|
346
|
+
const isWritten = writtenFiles.has(file.path);
|
|
347
|
+
const isReviewed = reviewedFiles.has(file.path) && !writtenFiles.has(file.path);
|
|
348
|
+
const isExpired2 = expiredFiles?.has(file.path) ?? false;
|
|
349
|
+
file.isWritten = isWritten;
|
|
350
|
+
file.isReviewed = isReviewed;
|
|
351
|
+
file.isExpired = isExpired2;
|
|
352
|
+
if (isExpired2) {
|
|
353
|
+
file.score = 0;
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
switch (filterMode) {
|
|
357
|
+
case "written":
|
|
358
|
+
file.score = isWritten ? 1 : 0;
|
|
359
|
+
break;
|
|
360
|
+
case "reviewed":
|
|
361
|
+
file.score = isReviewed ? 1 : 0;
|
|
362
|
+
break;
|
|
363
|
+
case "all":
|
|
364
|
+
default:
|
|
365
|
+
file.score = isWritten || isReviewed ? 1 : 0;
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
recomputeFolderScores(tree, "binary");
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// src/git/blame.ts
|
|
373
|
+
async function getBlameData(gitClient, filePath) {
|
|
374
|
+
const authorMap = /* @__PURE__ */ new Map();
|
|
375
|
+
let totalLines = 0;
|
|
376
|
+
try {
|
|
377
|
+
const output = await gitClient.blame(filePath, ["-w", "--porcelain"]);
|
|
378
|
+
const lines = output.split("\n");
|
|
379
|
+
let currentName = "";
|
|
380
|
+
let currentEmail = "";
|
|
381
|
+
for (const line of lines) {
|
|
382
|
+
if (line.startsWith("author ")) {
|
|
383
|
+
currentName = line.slice("author ".length).trim();
|
|
384
|
+
} else if (line.startsWith("author-mail ")) {
|
|
385
|
+
currentEmail = line.slice("author-mail ".length).replace(/[<>]/g, "").trim();
|
|
386
|
+
} else if (line.startsWith(" ")) {
|
|
387
|
+
if (currentEmail || currentName) {
|
|
388
|
+
totalLines++;
|
|
389
|
+
const key = `${currentEmail}|${currentName}`;
|
|
390
|
+
const existing = authorMap.get(key);
|
|
391
|
+
if (existing) {
|
|
392
|
+
existing.lines++;
|
|
393
|
+
} else {
|
|
394
|
+
authorMap.set(key, {
|
|
395
|
+
email: currentEmail,
|
|
396
|
+
name: currentName,
|
|
397
|
+
lines: 1
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
} catch {
|
|
404
|
+
}
|
|
405
|
+
return { entries: Array.from(authorMap.values()), totalLines };
|
|
406
|
+
}
|
|
407
|
+
async function getUserBlameLines(gitClient, filePath, user) {
|
|
408
|
+
const { entries, totalLines } = await getBlameData(gitClient, filePath);
|
|
409
|
+
let userLines = 0;
|
|
410
|
+
for (const entry of entries) {
|
|
411
|
+
if (matchesUser(entry.name, entry.email, user)) {
|
|
412
|
+
userLines += entry.lines;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return { userLines, totalLines };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// src/utils/batch.ts
|
|
419
|
+
var DEFAULT_BATCH_SIZE = 10;
|
|
420
|
+
async function processBatch(items, fn, batchSize = DEFAULT_BATCH_SIZE) {
|
|
421
|
+
for (let i = 0; i < items.length; i += batchSize) {
|
|
422
|
+
const batch = items.slice(i, i + batchSize);
|
|
423
|
+
await Promise.all(batch.map(fn));
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// src/scoring/authorship.ts
|
|
428
|
+
async function scoreAuthorship(tree, gitClient, user) {
|
|
429
|
+
const files = [];
|
|
430
|
+
walkFiles(tree, (file) => {
|
|
431
|
+
files.push({
|
|
432
|
+
path: file.path,
|
|
433
|
+
setScore: (s) => {
|
|
434
|
+
file.score = s;
|
|
435
|
+
file.blameScore = s;
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
await processBatch(files, async ({ path, setScore }) => {
|
|
440
|
+
const { userLines, totalLines } = await getUserBlameLines(
|
|
441
|
+
gitClient,
|
|
442
|
+
path,
|
|
443
|
+
user
|
|
444
|
+
);
|
|
445
|
+
setScore(totalLines > 0 ? userLines / totalLines : 0);
|
|
446
|
+
});
|
|
447
|
+
recomputeFolderScores(tree, "continuous");
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// src/scoring/review-coverage.ts
|
|
451
|
+
function scoreReviewCoverage(tree, reviewedFiles) {
|
|
452
|
+
walkFiles(tree, (file) => {
|
|
453
|
+
file.isReviewed = reviewedFiles.has(file.path);
|
|
454
|
+
file.score = file.isReviewed ? 1 : 0;
|
|
455
|
+
});
|
|
456
|
+
recomputeFolderScores(tree, "binary");
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// src/utils/math.ts
|
|
460
|
+
function sigmoid(x, k = 0.3) {
|
|
461
|
+
if (x <= 0) return 0;
|
|
462
|
+
return x / (x + k);
|
|
463
|
+
}
|
|
464
|
+
function recencyDecay(days, halfLife = 180) {
|
|
465
|
+
if (days <= 0) return 1;
|
|
466
|
+
const lambda = Math.LN2 / halfLife;
|
|
467
|
+
return Math.exp(-lambda * days);
|
|
468
|
+
}
|
|
469
|
+
function scopeFactor(filesInPR, attentionThreshold = 20) {
|
|
470
|
+
if (filesInPR <= 0) return 1;
|
|
471
|
+
return Math.min(1, attentionThreshold / filesInPR);
|
|
472
|
+
}
|
|
473
|
+
function normalizedDiff(added, deleted, fileSize) {
|
|
474
|
+
if (fileSize <= 0) return 0;
|
|
475
|
+
return (added + 0.5 * deleted) / fileSize;
|
|
476
|
+
}
|
|
477
|
+
function daysBetween(a, b) {
|
|
478
|
+
const ms = Math.abs(b.getTime() - a.getTime());
|
|
479
|
+
return ms / (1e3 * 60 * 60 * 24);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// src/scoring/weighted.ts
|
|
483
|
+
var REVIEW_BASE_WEIGHTS = {
|
|
484
|
+
approved: 0.3,
|
|
485
|
+
commented: 0.15,
|
|
486
|
+
changes_requested: 0.35
|
|
487
|
+
};
|
|
488
|
+
function calculateCommitScore(commits, now) {
|
|
489
|
+
let raw = 0;
|
|
490
|
+
for (const c of commits) {
|
|
491
|
+
const nd = normalizedDiff(c.addedLines, c.deletedLines, c.fileSizeAtCommit);
|
|
492
|
+
raw += sigmoid(nd) * recencyDecay(daysBetween(now, c.date));
|
|
493
|
+
}
|
|
494
|
+
return Math.min(1, raw);
|
|
495
|
+
}
|
|
496
|
+
function calculateReviewScore(reviews, now) {
|
|
497
|
+
if (!reviews) return 0;
|
|
498
|
+
let raw = 0;
|
|
499
|
+
for (const r of reviews) {
|
|
500
|
+
const baseWeight = REVIEW_BASE_WEIGHTS[r.type] || 0.15;
|
|
501
|
+
raw += baseWeight * scopeFactor(r.filesInPR) * recencyDecay(daysBetween(now, r.date));
|
|
502
|
+
}
|
|
503
|
+
return Math.min(1, raw);
|
|
504
|
+
}
|
|
505
|
+
async function scoreWeighted(tree, gitClient, user, weights, reviewData, now) {
|
|
506
|
+
const currentDate = now || /* @__PURE__ */ new Date();
|
|
507
|
+
const files = [];
|
|
508
|
+
walkFiles(tree, (file) => {
|
|
509
|
+
files.push({
|
|
510
|
+
path: file.path,
|
|
511
|
+
setScores: (b, c, r, total) => {
|
|
512
|
+
file.blameScore = b;
|
|
513
|
+
file.commitScore = c;
|
|
514
|
+
file.reviewScore = r;
|
|
515
|
+
file.score = total;
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
await processBatch(files, async ({ path, setScores }) => {
|
|
520
|
+
const { userLines, totalLines } = await getUserBlameLines(
|
|
521
|
+
gitClient,
|
|
522
|
+
path,
|
|
523
|
+
user
|
|
524
|
+
);
|
|
525
|
+
const blameScore = totalLines > 0 ? userLines / totalLines : 0;
|
|
526
|
+
const commitScore = calculateCommitScore(
|
|
527
|
+
await getDetailedCommits(gitClient, user, path),
|
|
528
|
+
currentDate
|
|
529
|
+
);
|
|
530
|
+
const reviewScore = calculateReviewScore(
|
|
531
|
+
reviewData?.get(path),
|
|
532
|
+
currentDate
|
|
533
|
+
);
|
|
534
|
+
const total = weights.blame * blameScore + weights.commit * commitScore + weights.review * reviewScore;
|
|
535
|
+
setScores(blameScore, commitScore, reviewScore, total);
|
|
536
|
+
});
|
|
537
|
+
recomputeFolderScores(tree, "continuous");
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// src/git/diff.ts
|
|
541
|
+
async function getChangeRatio(gitClient, filePath, sinceCommit) {
|
|
542
|
+
try {
|
|
543
|
+
const output = await gitClient.diff([
|
|
544
|
+
"--numstat",
|
|
545
|
+
`${sinceCommit}..HEAD`,
|
|
546
|
+
"--",
|
|
547
|
+
filePath
|
|
548
|
+
]);
|
|
549
|
+
const trimmed = output.trim();
|
|
550
|
+
if (!trimmed) return 0;
|
|
551
|
+
const match = trimmed.match(/^(\d+|-)\t(\d+|-)\t/);
|
|
552
|
+
if (!match) return 0;
|
|
553
|
+
const added = match[1] === "-" ? 0 : parseInt(match[1], 10);
|
|
554
|
+
const deleted = match[2] === "-" ? 0 : parseInt(match[2], 10);
|
|
555
|
+
const changedLines = added + deleted;
|
|
556
|
+
const currentContent = await gitClient.show([`HEAD:${filePath}`]);
|
|
557
|
+
const currentLines = Math.max(1, currentContent.split("\n").length);
|
|
558
|
+
return changedLines / currentLines;
|
|
559
|
+
} catch {
|
|
560
|
+
return 0;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
async function getLastTouchCommit(gitClient, filePath, userEmail) {
|
|
564
|
+
try {
|
|
565
|
+
const output = await gitClient.getLog([
|
|
566
|
+
"--author",
|
|
567
|
+
userEmail,
|
|
568
|
+
"-1",
|
|
569
|
+
"--pretty=format:%H",
|
|
570
|
+
"--",
|
|
571
|
+
filePath
|
|
572
|
+
]);
|
|
573
|
+
const hash = output.trim();
|
|
574
|
+
return hash || null;
|
|
575
|
+
} catch {
|
|
576
|
+
return null;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// src/scoring/expiration.ts
|
|
581
|
+
function parseExpirationConfig(input) {
|
|
582
|
+
if (!input || input === "never") {
|
|
583
|
+
return { policy: "never" };
|
|
584
|
+
}
|
|
585
|
+
if (input.startsWith("time:")) {
|
|
586
|
+
const duration = parseDays(input.slice("time:".length));
|
|
587
|
+
return { policy: "time", duration };
|
|
588
|
+
}
|
|
589
|
+
if (input.startsWith("change:")) {
|
|
590
|
+
const threshold = parsePercentage(input.slice("change:".length));
|
|
591
|
+
return { policy: "change", threshold };
|
|
592
|
+
}
|
|
593
|
+
if (input.startsWith("combined:")) {
|
|
594
|
+
const parts = input.slice("combined:".length).split(":");
|
|
595
|
+
const duration = parseDays(parts[0]);
|
|
596
|
+
const threshold = parts[1] ? parsePercentage(parts[1]) : 0.5;
|
|
597
|
+
return { policy: "combined", duration, threshold };
|
|
598
|
+
}
|
|
599
|
+
return { policy: "never" };
|
|
600
|
+
}
|
|
601
|
+
function parseDays(s) {
|
|
602
|
+
const match = s.match(/^(\d+)d$/);
|
|
603
|
+
if (!match)
|
|
604
|
+
throw new Error(
|
|
605
|
+
`Invalid duration format: "${s}". Expected format like "180d".`
|
|
606
|
+
);
|
|
607
|
+
return parseInt(match[1], 10);
|
|
608
|
+
}
|
|
609
|
+
function parsePercentage(s) {
|
|
610
|
+
const match = s.match(/^(\d+)%$/);
|
|
611
|
+
if (!match)
|
|
612
|
+
throw new Error(
|
|
613
|
+
`Invalid percentage format: "${s}". Expected format like "50%".`
|
|
614
|
+
);
|
|
615
|
+
return parseInt(match[1], 10) / 100;
|
|
616
|
+
}
|
|
617
|
+
async function isExpired(gitClient, filePath, user, config, now) {
|
|
618
|
+
if (config.policy === "never") return false;
|
|
619
|
+
const currentDate = now || /* @__PURE__ */ new Date();
|
|
620
|
+
const email = user.email || user.name;
|
|
621
|
+
if (config.policy === "time" || config.policy === "combined") {
|
|
622
|
+
const lastTouch = await getLastCommitDate(gitClient, user, filePath);
|
|
623
|
+
if (lastTouch && config.duration) {
|
|
624
|
+
const daysSince = (currentDate.getTime() - lastTouch.getTime()) / (1e3 * 60 * 60 * 24);
|
|
625
|
+
if (daysSince > config.duration) return true;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
if (config.policy === "change" || config.policy === "combined") {
|
|
629
|
+
const lastCommit = await getLastTouchCommit(gitClient, filePath, email);
|
|
630
|
+
if (lastCommit && config.threshold) {
|
|
631
|
+
const ratio = await getChangeRatio(gitClient, filePath, lastCommit);
|
|
632
|
+
if (ratio > config.threshold) return true;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
return false;
|
|
636
|
+
}
|
|
637
|
+
async function getExpiredFiles(gitClient, files, user, config) {
|
|
638
|
+
if (config.policy === "never") return /* @__PURE__ */ new Set();
|
|
639
|
+
const expiredSet = /* @__PURE__ */ new Set();
|
|
640
|
+
await processBatch(files, async (filePath) => {
|
|
641
|
+
if (await isExpired(gitClient, filePath, user, config)) {
|
|
642
|
+
expiredSet.add(filePath);
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
return expiredSet;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// src/github/client.ts
|
|
649
|
+
var GitHubClient = class {
|
|
650
|
+
token;
|
|
651
|
+
baseUrl = "https://api.github.com";
|
|
652
|
+
constructor(token) {
|
|
653
|
+
this.token = token;
|
|
654
|
+
}
|
|
655
|
+
async fetch(path) {
|
|
656
|
+
const url = `${this.baseUrl}${path}`;
|
|
657
|
+
const response = await fetch(url, {
|
|
658
|
+
headers: {
|
|
659
|
+
Authorization: `Bearer ${this.token}`,
|
|
660
|
+
Accept: "application/vnd.github.v3+json",
|
|
661
|
+
"User-Agent": "gitfamiliar"
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
if (!response.ok) {
|
|
665
|
+
if (response.status === 403) {
|
|
666
|
+
throw new Error("GitHub API rate limit exceeded. Please wait or use a token with higher limits.");
|
|
667
|
+
}
|
|
668
|
+
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
|
669
|
+
}
|
|
670
|
+
return response.json();
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Parse owner/repo from a git remote URL.
|
|
674
|
+
*/
|
|
675
|
+
static parseRemoteUrl(url) {
|
|
676
|
+
let match = url.match(/github\.com[:/]([^/]+)\/([^/.]+)(\.git)?$/);
|
|
677
|
+
if (match) {
|
|
678
|
+
return { owner: match[1], repo: match[2] };
|
|
679
|
+
}
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Get all files reviewed by a user across all PRs they reviewed.
|
|
684
|
+
*/
|
|
685
|
+
async getReviewedFiles(owner, repo, username) {
|
|
686
|
+
const reviewedFiles = /* @__PURE__ */ new Map();
|
|
687
|
+
let page = 1;
|
|
688
|
+
const perPage = 100;
|
|
689
|
+
while (true) {
|
|
690
|
+
const searchResult = await this.fetch(
|
|
691
|
+
`/search/issues?q=type:pr+repo:${owner}/${repo}+reviewed-by:${username}&per_page=${perPage}&page=${page}`
|
|
692
|
+
);
|
|
693
|
+
if (!searchResult.items || searchResult.items.length === 0) break;
|
|
694
|
+
for (const item of searchResult.items) {
|
|
695
|
+
const prNumber = item.number;
|
|
696
|
+
const reviews = await this.fetch(
|
|
697
|
+
`/repos/${owner}/${repo}/pulls/${prNumber}/reviews`
|
|
698
|
+
);
|
|
699
|
+
const userReviews = reviews.filter(
|
|
700
|
+
(r) => r.user?.login?.toLowerCase() === username.toLowerCase()
|
|
701
|
+
);
|
|
702
|
+
if (userReviews.length === 0) continue;
|
|
703
|
+
const prFiles = await this.fetch(
|
|
704
|
+
`/repos/${owner}/${repo}/pulls/${prNumber}/files?per_page=100`
|
|
705
|
+
);
|
|
706
|
+
const fileCount = prFiles.length;
|
|
707
|
+
for (const review of userReviews) {
|
|
708
|
+
const reviewType = mapReviewState(review.state);
|
|
709
|
+
const reviewDate = new Date(review.submitted_at);
|
|
710
|
+
for (const prFile of prFiles) {
|
|
711
|
+
const filePath = prFile.filename;
|
|
712
|
+
const info = {
|
|
713
|
+
date: reviewDate,
|
|
714
|
+
type: reviewType,
|
|
715
|
+
filesInPR: fileCount
|
|
716
|
+
};
|
|
717
|
+
if (reviewedFiles.has(filePath)) {
|
|
718
|
+
reviewedFiles.get(filePath).push(info);
|
|
719
|
+
} else {
|
|
720
|
+
reviewedFiles.set(filePath, [info]);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
if (searchResult.items.length < perPage) break;
|
|
726
|
+
page++;
|
|
727
|
+
}
|
|
728
|
+
return reviewedFiles;
|
|
729
|
+
}
|
|
730
|
+
};
|
|
731
|
+
function mapReviewState(state) {
|
|
732
|
+
switch (state.toUpperCase()) {
|
|
733
|
+
case "APPROVED":
|
|
734
|
+
return "approved";
|
|
735
|
+
case "CHANGES_REQUESTED":
|
|
736
|
+
return "changes_requested";
|
|
737
|
+
default:
|
|
738
|
+
return "commented";
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// src/github/auth.ts
|
|
743
|
+
import { execSync } from "child_process";
|
|
744
|
+
function resolveGitHubToken() {
|
|
745
|
+
if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN;
|
|
746
|
+
if (process.env.GH_TOKEN) return process.env.GH_TOKEN;
|
|
747
|
+
try {
|
|
748
|
+
const token = execSync("gh auth token", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
749
|
+
if (token) return token;
|
|
750
|
+
} catch {
|
|
751
|
+
}
|
|
752
|
+
return null;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// src/github/reviews.ts
|
|
756
|
+
async function fetchReviewData(gitClient, username) {
|
|
757
|
+
const token = resolveGitHubToken();
|
|
758
|
+
if (!token) return null;
|
|
759
|
+
const remoteUrl = await gitClient.getRemoteUrl();
|
|
760
|
+
if (!remoteUrl) return null;
|
|
761
|
+
const parsed = GitHubClient.parseRemoteUrl(remoteUrl);
|
|
762
|
+
if (!parsed) return null;
|
|
763
|
+
if (!username) return null;
|
|
764
|
+
const ghUsername = username;
|
|
765
|
+
try {
|
|
766
|
+
const githubClient = new GitHubClient(token);
|
|
767
|
+
const reviewedFiles = await githubClient.getReviewedFiles(
|
|
768
|
+
parsed.owner,
|
|
769
|
+
parsed.repo,
|
|
770
|
+
ghUsername
|
|
771
|
+
);
|
|
772
|
+
const reviewedFileSet = new Set(reviewedFiles.keys());
|
|
773
|
+
return { reviewedFiles, reviewedFileSet };
|
|
774
|
+
} catch {
|
|
775
|
+
return null;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// src/core/familiarity.ts
|
|
780
|
+
async function computeFamiliarity(options) {
|
|
781
|
+
const gitClient = new GitClient(options.repoPath);
|
|
782
|
+
if (!await gitClient.isRepo()) {
|
|
783
|
+
throw new Error(`"${options.repoPath}" is not a git repository.`);
|
|
784
|
+
}
|
|
785
|
+
const repoRoot = await gitClient.getRepoRoot();
|
|
786
|
+
const repoName = await gitClient.getRepoName();
|
|
787
|
+
const user = await resolveUser(gitClient, options.user);
|
|
788
|
+
const filter = createFilter(repoRoot);
|
|
789
|
+
const tree = await buildFileTree(gitClient, filter);
|
|
790
|
+
const writtenFiles = await getFilesCommittedByUser(gitClient, user);
|
|
791
|
+
let reviewData;
|
|
792
|
+
let reviewedFileSet = /* @__PURE__ */ new Set();
|
|
793
|
+
if (options.mode !== "authorship") {
|
|
794
|
+
const reviewResult = await fetchReviewData(gitClient, options.user);
|
|
795
|
+
if (reviewResult) {
|
|
796
|
+
reviewData = reviewResult.reviewedFiles;
|
|
797
|
+
reviewedFileSet = reviewResult.reviewedFileSet;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
let expiredFiles;
|
|
801
|
+
if (options.expiration.policy !== "never") {
|
|
802
|
+
const allFiles = [];
|
|
803
|
+
walkFiles(tree, (f) => allFiles.push(f.path));
|
|
804
|
+
expiredFiles = await getExpiredFiles(
|
|
805
|
+
gitClient,
|
|
806
|
+
allFiles,
|
|
807
|
+
user,
|
|
808
|
+
options.expiration
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
switch (options.mode) {
|
|
812
|
+
case "binary":
|
|
813
|
+
scoreBinary(
|
|
814
|
+
tree,
|
|
815
|
+
writtenFiles,
|
|
816
|
+
reviewedFileSet,
|
|
817
|
+
options.filter,
|
|
818
|
+
expiredFiles
|
|
819
|
+
);
|
|
820
|
+
break;
|
|
821
|
+
case "authorship":
|
|
822
|
+
await scoreAuthorship(tree, gitClient, user);
|
|
823
|
+
break;
|
|
824
|
+
case "review-coverage":
|
|
825
|
+
if (reviewedFileSet.size === 0) {
|
|
826
|
+
console.error(
|
|
827
|
+
"Warning: No review data available. Set GITHUB_TOKEN or use --user with your GitHub username."
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
scoreReviewCoverage(tree, reviewedFileSet);
|
|
831
|
+
break;
|
|
832
|
+
case "weighted":
|
|
833
|
+
await scoreWeighted(tree, gitClient, user, options.weights, reviewData);
|
|
834
|
+
break;
|
|
835
|
+
}
|
|
836
|
+
return {
|
|
837
|
+
tree,
|
|
838
|
+
repoName,
|
|
839
|
+
userName: user.name || user.email,
|
|
840
|
+
mode: options.mode,
|
|
841
|
+
...computeSummary(tree, writtenFiles, reviewedFileSet),
|
|
842
|
+
totalFiles: tree.fileCount
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
function computeSummary(tree, writtenFiles, reviewedFileSet) {
|
|
846
|
+
let writtenOnly = 0;
|
|
847
|
+
let reviewedOnly = 0;
|
|
848
|
+
let both = 0;
|
|
849
|
+
walkFiles(tree, (file) => {
|
|
850
|
+
const w = writtenFiles.has(file.path);
|
|
851
|
+
const r = reviewedFileSet.has(file.path);
|
|
852
|
+
if (w && r) both++;
|
|
853
|
+
else if (w) writtenOnly++;
|
|
854
|
+
else if (r) reviewedOnly++;
|
|
855
|
+
});
|
|
856
|
+
return {
|
|
857
|
+
writtenCount: writtenOnly + both,
|
|
858
|
+
reviewedCount: reviewedOnly + both,
|
|
859
|
+
bothCount: both
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
export {
|
|
864
|
+
parseExpirationConfig,
|
|
865
|
+
computeFamiliarity
|
|
866
|
+
};
|
|
867
|
+
//# sourceMappingURL=chunk-DW2PHZVZ.js.map
|