@twistezo/ai-review-council 1.2.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/cli-options.js +7 -2
- package/dist/domain/review-session.js +4 -1
- package/dist/git/git-repository.js +5 -0
- package/dist/review/review-messages.js +12 -4
- package/dist/review/review-workspace.js +1 -1
- package/dist/scope/build-review-scope.js +19 -9
- package/dist/ui/describe-scope.js +12 -5
- package/dist/ui/steps/scope-step.js +5 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ npx @twistezo/ai-review-council
|
|
|
26
26
|
|---|---|---|
|
|
27
27
|
| `-d, --dir` | folder inside a Git repository | asked |
|
|
28
28
|
| `-l, --lang` | `en`, `pl` | asked, `en` |
|
|
29
|
-
| `-s, --scope` | `uncommitted
|
|
29
|
+
| `-s, --scope` | `uncommitted`; `full`: the entire codebase, every file as new; or a commit hash: changes after it, uncommitted included | asked |
|
|
30
30
|
| `-r, --reviewers` | 1 to 6 | asked |
|
|
31
31
|
| `-t, --timeout` | minutes per agent, 1 to 120 | `10` |
|
|
32
32
|
|
package/dist/cli-options.js
CHANGED
|
@@ -7,7 +7,8 @@ export const usage = `Usage: ai-review-council [options]
|
|
|
7
7
|
Options:
|
|
8
8
|
-d, --dir <path> Project folder to review; skips the folder step
|
|
9
9
|
-l, --lang <code> Report language: ${reportLanguages.join(', ')}; skips the language step
|
|
10
|
-
-s, --scope <value> uncommitted, or a commit hash to review the changes after it;
|
|
10
|
+
-s, --scope <value> uncommitted, full for the entire codebase, or a commit hash to review the changes after it;
|
|
11
|
+
skips the scope step
|
|
11
12
|
-r, --reviewers <n> Number of reviewers, 1 to ${maxReviewers}; skips the count question
|
|
12
13
|
-t, --timeout <min> Minutes each agent may run before it fails (default ${defaultTimeoutMinutes}, max ${maxTimeoutMinutes})
|
|
13
14
|
-h, --help Show this help`;
|
|
@@ -25,6 +26,7 @@ const readRawOptions = (args) => parseArgs({
|
|
|
25
26
|
allowPositionals: false,
|
|
26
27
|
}).values;
|
|
27
28
|
const uncommittedScope = 'uncommitted';
|
|
29
|
+
const fullScope = 'full';
|
|
28
30
|
const reviewerCountSchema = z.number().int().min(1).max(maxReviewers);
|
|
29
31
|
const parseScope = (value) => {
|
|
30
32
|
if (value === undefined) {
|
|
@@ -34,6 +36,9 @@ const parseScope = (value) => {
|
|
|
34
36
|
if (trimmed.toLowerCase() === uncommittedScope) {
|
|
35
37
|
return { kind: 'uncommitted' };
|
|
36
38
|
}
|
|
39
|
+
if (trimmed.toLowerCase() === fullScope) {
|
|
40
|
+
return { kind: 'full' };
|
|
41
|
+
}
|
|
37
42
|
return isCommitHashFormat(trimmed) ? { kind: 'since-commit', commitRef: trimmed } : 'invalid';
|
|
38
43
|
};
|
|
39
44
|
export const parseCliOptions = (args) => {
|
|
@@ -53,7 +58,7 @@ export const parseCliOptions = (args) => {
|
|
|
53
58
|
}
|
|
54
59
|
const scope = parseScope(values.scope);
|
|
55
60
|
if (scope === 'invalid') {
|
|
56
|
-
return { ok: false, error: 'The --scope option needs `uncommitted` or a commit hash (4 to 40 hexadecimal characters).' };
|
|
61
|
+
return { ok: false, error: 'The --scope option needs `uncommitted`, `full` or a commit hash (4 to 40 hexadecimal characters).' };
|
|
57
62
|
}
|
|
58
63
|
const reviewerCount = values.reviewers === undefined ? null : reviewerCountSchema.safeParse(Number(values.reviewers));
|
|
59
64
|
if (reviewerCount !== null && !reviewerCount.success) {
|
|
@@ -37,10 +37,13 @@ const commitInfoSchema = z.object({
|
|
|
37
37
|
hash: z.string().length(40),
|
|
38
38
|
subject: z.string(),
|
|
39
39
|
});
|
|
40
|
-
const reviewScopeKinds = ['uncommitted', 'since-commit'];
|
|
40
|
+
const reviewScopeKinds = ['uncommitted', 'since-commit', 'full'];
|
|
41
41
|
const reviewScopeSchema = z.object({
|
|
42
42
|
kind: z.enum(reviewScopeKinds),
|
|
43
|
+
// the commit the review starts from; HEAD for `full`
|
|
43
44
|
base: commitInfoSchema,
|
|
45
|
+
// what the diff is taken against: the base commit, or Git's empty tree for `full`, so every file shows as added
|
|
46
|
+
diffBase: z.string().min(1),
|
|
44
47
|
changedFiles: z.array(changedFileSchema).min(1),
|
|
45
48
|
// paths of likely secret files left out of the review
|
|
46
49
|
excludedFiles: z.array(z.string().min(1)),
|
|
@@ -44,6 +44,11 @@ export const createGitRepository = (run) => {
|
|
|
44
44
|
},
|
|
45
45
|
resolveCommit,
|
|
46
46
|
isInHeadHistory: async (root, hash) => (await git(root, ['merge-base', '--is-ancestor', hash, 'HEAD'])) !== null,
|
|
47
|
+
readEmptyTreeHash: async (root) => {
|
|
48
|
+
// Hashing nothing as a tree gives the empty tree, also in SHA-256 repositories.
|
|
49
|
+
const output = await git(root, ['hash-object', '-t', 'tree', '/dev/null']);
|
|
50
|
+
return output === null ? null : output.trim();
|
|
51
|
+
},
|
|
47
52
|
listChangedFiles: async (root, baseHash) => {
|
|
48
53
|
// `git diff <base>` compares the base with the working tree: staged and unstaged changes.
|
|
49
54
|
const [diffOutput, untrackedOutput] = await Promise.all([
|
|
@@ -9,15 +9,23 @@ const changeLabels = {
|
|
|
9
9
|
untracked: 'new, not in the diff: read it in full',
|
|
10
10
|
};
|
|
11
11
|
const describeFile = (file) => `- ${file.previousPath === null ? file.path : `${file.previousPath} -> ${file.path}`} (${changeLabels[file.change]})`;
|
|
12
|
+
const describeRange = (scope) => {
|
|
13
|
+
const base = `${scope.base.hash.slice(0, 7)} ${scope.base.subject}`.trim();
|
|
14
|
+
switch (scope.kind) {
|
|
15
|
+
case 'uncommitted':
|
|
16
|
+
return `Uncommitted changes against ${base}.`;
|
|
17
|
+
case 'since-commit':
|
|
18
|
+
return `Everything changed after ${base}, up to the current files with uncommitted changes. The base commit itself is not part of the change.`;
|
|
19
|
+
case 'full':
|
|
20
|
+
return `The entire codebase at ${base}, uncommitted changes included. Every file is part of the change and shows as added in the diff.`;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
12
23
|
const scopeSection = (session, diffPath) => {
|
|
13
24
|
const { repository, scope } = session;
|
|
14
|
-
const base = `${scope.base.hash.slice(0, 7)} ${scope.base.subject}`.trim();
|
|
15
25
|
const lines = [
|
|
16
26
|
'## Scope',
|
|
17
27
|
`Repository: ${repository.root} (${repository.branch === null ? 'detached HEAD' : `branch ${repository.branch}`})`,
|
|
18
|
-
scope
|
|
19
|
-
? `Uncommitted changes against ${base}.`
|
|
20
|
-
: `Everything changed after ${base}, up to the current files with uncommitted changes. The base commit itself is not part of the change.`,
|
|
28
|
+
describeRange(scope),
|
|
21
29
|
`Diff of tracked changes: ${diffPath}`,
|
|
22
30
|
`Changed files (${scope.changedFiles.length}):`,
|
|
23
31
|
...scope.changedFiles.map(describeFile),
|
|
@@ -35,7 +35,7 @@ export const createWorkspacePreparer = (git) => async (repositoryRoot, scope) =>
|
|
|
35
35
|
activeDirectories.add(directory);
|
|
36
36
|
const diffPath = path.join(directory, 'change.diff');
|
|
37
37
|
try {
|
|
38
|
-
await writeFile(diffPath, await git.readDiff(repositoryRoot, scope.
|
|
38
|
+
await writeFile(diffPath, await git.readDiff(repositoryRoot, scope.diffBase, trackedDiffPaths(scope)));
|
|
39
39
|
}
|
|
40
40
|
catch (error) {
|
|
41
41
|
await removeDirectory(directory);
|
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import { isCommitHashFormat } from '../git/git-repository.js';
|
|
2
2
|
import { partitionReviewFiles } from './excluded-files.js';
|
|
3
|
+
const nothingToReview = (request, base) => {
|
|
4
|
+
switch (request.kind) {
|
|
5
|
+
case 'uncommitted':
|
|
6
|
+
return 'There are no uncommitted changes to review.';
|
|
7
|
+
case 'since-commit':
|
|
8
|
+
return `There are no changes after ${base.hash.slice(0, 7)} to review.`;
|
|
9
|
+
case 'full':
|
|
10
|
+
return 'There are no files to review.';
|
|
11
|
+
}
|
|
12
|
+
};
|
|
3
13
|
export const buildReviewScope = async (git, root, request) => {
|
|
4
|
-
const ref = request.kind === '
|
|
14
|
+
const ref = request.kind === 'since-commit' ? request.commitRef.trim() : 'HEAD';
|
|
5
15
|
if (request.kind === 'since-commit' && !isCommitHashFormat(ref)) {
|
|
6
16
|
return { ok: false, error: 'Enter a commit hash: 4 to 40 hexadecimal characters.' };
|
|
7
17
|
}
|
|
@@ -16,14 +26,14 @@ export const buildReviewScope = async (git, root, request) => {
|
|
|
16
26
|
error: `Commit ${base.hash.slice(0, 7)} is not in the history of the current branch. Pick one from \`git log\`.`,
|
|
17
27
|
};
|
|
18
28
|
}
|
|
19
|
-
|
|
29
|
+
// The entire codebase is diffed against no files at all, so every file is new.
|
|
30
|
+
const diffBase = request.kind === 'full' ? await git.readEmptyTreeHash(root) : base.hash;
|
|
31
|
+
if (diffBase === null) {
|
|
32
|
+
return { ok: false, error: 'Git could not read the empty tree to compare the codebase with.' };
|
|
33
|
+
}
|
|
34
|
+
const { included, excluded } = partitionReviewFiles(await git.listChangedFiles(root, diffBase));
|
|
20
35
|
if (included.length === 0) {
|
|
21
|
-
return {
|
|
22
|
-
ok: false,
|
|
23
|
-
error: request.kind === 'uncommitted'
|
|
24
|
-
? 'There are no uncommitted changes to review.'
|
|
25
|
-
: `There are no changes after ${base.hash.slice(0, 7)} to review.`,
|
|
26
|
-
};
|
|
36
|
+
return { ok: false, error: nothingToReview(request, base) };
|
|
27
37
|
}
|
|
28
|
-
return { ok: true, scope: { kind: request.kind, base, changedFiles: included, excludedFiles: excluded } };
|
|
38
|
+
return { ok: true, scope: { kind: request.kind, base, diffBase, changedFiles: included, excludedFiles: excluded } };
|
|
29
39
|
};
|
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
|
|
1
|
+
const describeRange = (scope) => {
|
|
2
2
|
const base = scope.base.hash.slice(0, 7);
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
switch (scope.kind) {
|
|
4
|
+
case 'uncommitted':
|
|
5
|
+
return `uncommitted changes on ${base}`;
|
|
6
|
+
case 'since-commit':
|
|
7
|
+
return `changes after ${base} ${scope.base.subject}, uncommitted included`;
|
|
8
|
+
case 'full':
|
|
9
|
+
return `entire codebase on ${base}, uncommitted included`;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
export const describeScope = (scope) => {
|
|
6
13
|
const excluded = scope.excludedFiles.length === 0 ? '' : `, ${scope.excludedFiles.length} likely secret file(s) excluded`;
|
|
7
|
-
return `${
|
|
14
|
+
return `${describeRange(scope)} (${scope.changedFiles.length} files${excluded})`;
|
|
8
15
|
};
|
|
@@ -9,6 +9,7 @@ import { theme } from '../theme.js';
|
|
|
9
9
|
const kindLabels = {
|
|
10
10
|
uncommitted: 'Uncommitted only',
|
|
11
11
|
'since-commit': 'Since commit',
|
|
12
|
+
full: 'Entire codebase',
|
|
12
13
|
};
|
|
13
14
|
const hints = {
|
|
14
15
|
kind: '↑↓ row · Enter confirm',
|
|
@@ -20,7 +21,7 @@ export const ScopeStep = ({ git, repository, initialKind, preset, onSubmit, onPr
|
|
|
20
21
|
const [commitRef, setCommitRef] = useState(preset?.kind === 'since-commit' ? preset.commitRef : '');
|
|
21
22
|
const [error, setError] = useState(null);
|
|
22
23
|
const load = async (request) => {
|
|
23
|
-
const previousView = request.kind === '
|
|
24
|
+
const previousView = request.kind === 'since-commit' ? 'commit' : 'kind';
|
|
24
25
|
setView('loading');
|
|
25
26
|
setError(null);
|
|
26
27
|
try {
|
|
@@ -56,9 +57,10 @@ export const ScopeStep = ({ git, repository, initialKind, preset, onSubmit, onPr
|
|
|
56
57
|
return (_jsxs(StepFrame, { title: "Review scope", hints: hints[view], onBack: goBack, children: [view === 'kind' && (_jsx(SelectList, { items: [
|
|
57
58
|
{ key: 'uncommitted', label: kindLabels.uncommitted },
|
|
58
59
|
{ key: 'since-commit', label: kindLabels['since-commit'] },
|
|
60
|
+
{ key: 'full', label: kindLabels.full },
|
|
59
61
|
], initialKey: initialKind, onSelect: (key) => {
|
|
60
|
-
if (key === 'uncommitted') {
|
|
61
|
-
void load({ kind:
|
|
62
|
+
if (key === 'uncommitted' || key === 'full') {
|
|
63
|
+
void load({ kind: key });
|
|
62
64
|
}
|
|
63
65
|
else {
|
|
64
66
|
setError(null);
|