Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@kodus/cli",
"version": "0.4.9",
"version": "0.4.10",
"description": "Kodus CLI - AI-powered code review from your terminal",
"type": "module",
"bin": {
Expand Down Expand Up @@ -47,7 +47,7 @@
"author": "Kodus",
"license": "MIT",
"dependencies": {
"@inquirer/prompts": "^8.3.0",
"@inquirer/prompts": "^8.3.2",
"boxen": "^8.0.1",
"chalk": "^5.6.2",
"clipboardy": "^5.3.1",
Expand All @@ -69,8 +69,8 @@
"@types/gradient-string": "^1.1.6",
"@types/node": "^25.5.0",
"@types/update-notifier": "^6.0.8",
"@typescript-eslint/eslint-plugin": "^8.57.0",
"@typescript-eslint/parser": "^8.57.0",
"@typescript-eslint/eslint-plugin": "^8.57.1",
"@typescript-eslint/parser": "^8.57.1",
"eslint": "^10.0.3",
"prettier": "^3.8.1",
"typescript": "^5.9.3",
Expand All @@ -84,4 +84,4 @@
"dist",
"skills"
]
}
}
72 changes: 26 additions & 46 deletions src/services/api/review.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,39 +21,7 @@ export class RealReviewApi implements IReviewApi {
accessToken: string,
config?: ReviewConfig,
): Promise<ReviewResult> {
const isTeamKey = accessToken.startsWith('kodus_');

if (isTeamKey) {
return this.requester<ReviewResult>('/cli/review', {
method: 'POST',
headers: {
'X-Team-Key': accessToken,
},
body: JSON.stringify({ diff, config }),
});
}

let teamId: string | undefined;
try {
const payload = JSON.parse(
Buffer.from(accessToken.split('.')[1], 'base64').toString(),
);
teamId = payload.organizationId;
} catch {
// Ignore if cannot decode
}

const endpoint = teamId
? `/cli/review?teamId=${encodeURIComponent(teamId)}`
: '/cli/review';

return this.requester<ReviewResult>(endpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ diff, config }),
});
return this.analyzeWithMetrics(diff, accessToken, config);
}

async analyzeWithMetrics(
Expand All @@ -64,21 +32,33 @@ export class RealReviewApi implements IReviewApi {
): Promise<ReviewResult> {
const isTeamKey = accessToken.startsWith('kodus_');

if (isTeamKey) {
return this.requester<ReviewResult>('/cli/review', {
method: 'POST',
headers: {
'X-Team-Key': accessToken,
},
body: JSON.stringify({
diff,
config,
...metrics,
}),
});
const headers: Record<string, string> = isTeamKey
? { 'X-Team-Key': accessToken }
: { Authorization: `Bearer ${accessToken}` };

let endpoint = '/cli/review';
if (!isTeamKey) {
try {
const payload = JSON.parse(
Buffer.from(accessToken.split('.')[1], 'base64').toString(),
);
if (payload.organizationId) {
endpoint = `/cli/review?teamId=${encodeURIComponent(payload.organizationId)}`;
}
} catch {
// Ignore if cannot decode
}
}

return this.analyze(diff, accessToken, config);
return this.requester<ReviewResult>(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({
diff,
config,
...metrics,
}),
});
}

async getPullRequestSuggestions(
Expand Down
24 changes: 23 additions & 1 deletion src/services/review.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,35 @@ class ReviewService {
return result;
}

// Personal token: also send git context for repository-scoped rules
const gitInfo = await gitService.getGitInfo();
const inferredPlatform = gitInfo.remote
? gitService.inferPlatform(gitInfo.remote)
: undefined;

createAnalyzeApiRequestVerboseMessages({
diff,
reviewConfig,
mode: 'personal-token',
gitInfo: {
branch: gitInfo.branch,
remote: gitInfo.remote,
},
}).forEach((message) => this.logVerbose(message));

const result = await api.review.analyze(diff, token, reviewConfig);
const result = await api.review.analyzeWithMetrics(
diff,
token,
reviewConfig,
{
userEmail: gitInfo.userEmail,
gitRemote: gitInfo.remote || undefined,
branch: gitInfo.branch,
commitSha: gitInfo.commitSha,
inferredPlatform,
cliVersion: CLI_VERSION,
},
);

createAnalyzeApiResponseVerboseMessages({
summary: result.summary,
Expand Down
2 changes: 0 additions & 2 deletions src/ui/interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,6 @@ class InteractiveUI {
console.log('');
}

// Show menu again
await this.reviewFileIssues(file, issues, fixedIssues);
return;
}

Expand Down
159 changes: 146 additions & 13 deletions src/utils/__tests__/git-remote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,30 @@ describe('inferPlatformFromRemote', () => {
).toBe('GITHUB');
});

it('detects GitHub from SSH URL', () => {
expect(
inferPlatformFromRemote('git@github.com:org/repo.git'),
).toBe('GITHUB');
});

it('detects GitLab from HTTPS URL', () => {
expect(
inferPlatformFromRemote('https://gitlab.com/org/repo.git'),
).toBe('GITLAB');
});

it('detects Bitbucket from SSH URL', () => {
expect(
inferPlatformFromRemote('git@bitbucket.org:org/repo.git'),
).toBe('BITBUCKET');
});

it('detects Azure DevOps from dev.azure.com URL', () => {
expect(
inferPlatformFromRemote('https://dev.azure.com/org/project/_git/repo'),
).toBe('AZURE_REPOS');
});

it('detects Azure DevOps from visualstudio.com URL', () => {
expect(
inferPlatformFromRemote(
Expand All @@ -19,6 +43,18 @@ describe('inferPlatformFromRemote', () => {
).toBe('AZURE_REPOS');
});

it('detects Azure DevOps from SSH URL', () => {
expect(
inferPlatformFromRemote('git@ssh.dev.azure.com:v3/org/project/repo'),
).toBe('AZURE_REPOS');
});

it('returns undefined for self-hosted instances', () => {
expect(
inferPlatformFromRemote('https://gitlab.company.com/org/repo.git'),
).toBeUndefined();
});

it('returns undefined for deceptive subdomain hosts', () => {
expect(
inferPlatformFromRemote(
Expand All @@ -29,29 +65,126 @@ describe('inferPlatformFromRemote', () => {
});

describe('extractOrgRepoFromRemote', () => {
it('extracts owner and repo from GitHub SSH URL', () => {
// ── GitHub ──────────────────────────────────────────────────────────
it('extracts from GitHub SSH URL', () => {
expect(extractOrgRepoFromRemote('git@github.com:org/repo.git')).toEqual(
{
org: 'org',
repo: 'repo',
},
{ org: 'org', repo: 'repo' },
);
});

it('extracts owner and repo from GitLab HTTPS URL', () => {
it('extracts from GitHub HTTPS URL', () => {
expect(
extractOrgRepoFromRemote('https://github.com/org/repo.git'),
).toEqual({ org: 'org', repo: 'repo' });
});

// ── GitLab ──────────────────────────────────────────────────────────
it('extracts from GitLab SSH URL', () => {
expect(
extractOrgRepoFromRemote('git@gitlab.com:group/project.git'),
).toEqual({ org: 'group', repo: 'project' });
});

it('extracts from GitLab HTTPS URL', () => {
expect(
extractOrgRepoFromRemote('https://gitlab.com/group/project.git'),
).toEqual({
org: 'group',
repo: 'project',
});
).toEqual({ org: 'group', repo: 'project' });
});

it('extracts from GitLab SSH with subgroups', () => {
expect(
extractOrgRepoFromRemote(
'git@gitlab.com:group/subgroup/repo.git',
),
).toEqual({ org: 'group', repo: 'repo' });
});

it('extracts from self-hosted GitLab SSH', () => {
expect(
extractOrgRepoFromRemote('git@gitlab.company.com:org/repo.git'),
).toEqual({ org: 'org', repo: 'repo' });
});

it('extracts from self-hosted GitLab HTTPS', () => {
expect(
extractOrgRepoFromRemote(
'https://gitlab.company.com/org/repo.git',
),
).toEqual({ org: 'org', repo: 'repo' });
});

// ── Bitbucket ───────────────────────────────────────────────────────
it('extracts from Bitbucket SSH URL', () => {
expect(
extractOrgRepoFromRemote('git@bitbucket.org:org/repo.git'),
).toEqual({ org: 'org', repo: 'repo' });
});

it('extracts from Bitbucket HTTPS URL', () => {
expect(
extractOrgRepoFromRemote('https://bitbucket.org/org/repo.git'),
).toEqual({ org: 'org', repo: 'repo' });
});

it('extracts from Bitbucket Server (self-hosted) HTTPS', () => {
expect(
extractOrgRepoFromRemote(
'https://bitbucket.company.com/scm/proj/repo.git',
),
).toEqual({ org: 'proj', repo: 'repo' });
});

it('extracts from Bitbucket Server SSH with port', () => {
expect(
extractOrgRepoFromRemote(
'ssh://git@bitbucket.company.com:7999/proj/repo.git',
),
).toEqual({ org: 'proj', repo: 'repo' });
});

it('returns null for unsupported remotes', () => {
// ── Azure DevOps ────────────────────────────────────────────────────
it('extracts from Azure DevOps HTTPS (new)', () => {
expect(
extractOrgRepoFromRemote(
'https://selfhosted.example.com/org/repo.git',
'https://dev.azure.com/myorg/myproject/_git/myrepo',
),
).toBeNull();
).toEqual({ org: 'myorg', repo: 'myrepo' });
});

it('extracts from Azure DevOps HTTPS (old visualstudio.com)', () => {
expect(
extractOrgRepoFromRemote(
'https://myorg.visualstudio.com/myproject/_git/myrepo',
),
).toEqual({ org: 'myorg', repo: 'myrepo' });
});

it('extracts from Azure DevOps SSH (new)', () => {
expect(
extractOrgRepoFromRemote(
'git@ssh.dev.azure.com:v3/myorg/myproject/myrepo',
),
).toEqual({ org: 'myorg', repo: 'myrepo' });
});

it('extracts from Azure DevOps SSH (old vs-ssh)', () => {
expect(
extractOrgRepoFromRemote(
'git@vs-ssh.visualstudio.com:v3/myorg/myproject/myrepo',
),
).toEqual({ org: 'myorg', repo: 'myrepo' });
});

// ── Edge cases ──────────────────────────────────────────────────────
it('returns null for null input', () => {
expect(extractOrgRepoFromRemote(null)).toBeNull();
});

it('returns null for empty string', () => {
expect(extractOrgRepoFromRemote('')).toBeNull();
});

it('returns null for URL with no path segments', () => {
expect(extractOrgRepoFromRemote('https://github.com')).toBeNull();
});
});
Loading
Loading