mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-12 23:01:21 +00:00
Prototype of a Roomote tab (#147)
Co-authored-by: cte <cestreich@gmail.com> Co-authored-by: Roomote <chris@roocode.com> Co-authored-by: John Richmond <5629+jr@users.noreply.github.com>
This commit is contained in:
parent
7dcdf6039c
commit
17ce731867
52 changed files with 3507 additions and 578 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -26,4 +26,4 @@ Thumbs.db
|
|||
*.log
|
||||
|
||||
# project mcp config
|
||||
.roo/mcp.json
|
||||
.roo/mcp.json
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
## Testing & Formatting
|
||||
|
||||
- **Test**: `pnpm test` (runs all tests via Turbo)
|
||||
- **Test single file**: `pnpm test path/to/file.test.ts`
|
||||
- **Lint**: `pnpm lint` (ESLint with TypeScript support)
|
||||
- **Type check**: `pnpm check-types`
|
||||
- **Format**: Files are auto-formatted on commit via lint-staged
|
||||
|
|
|
|||
|
|
@ -13,8 +13,6 @@
|
|||
"controller:production": "dotenvx run -f ../../.env.production -- tsx src/lib/controller.ts",
|
||||
"worker": "dotenvx run -f ../../.env.development -- tsx src/lib/worker.ts",
|
||||
"worker:production": "dotenvx run -f ../../.env.production -- tsx src/lib/worker.ts",
|
||||
"cli": "dotenvx run -f ../../.env.development -- tsx src/lib/cli.ts",
|
||||
"cli:production": "dotenvx run -f ../../.env.production -- tsx src/lib/cli.ts",
|
||||
"clean": "rimraf .next .turbo"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -24,7 +22,6 @@
|
|||
"@roo-code-cloud/job-auth": "workspace:^",
|
||||
"@roo-code/types": "^1.30.0",
|
||||
"bullmq": "^5.37.0",
|
||||
"cmd-ts": "^0.13.0",
|
||||
"drizzle-orm": "^0.44.2",
|
||||
"execa": "^9.6.0",
|
||||
"ioredis": "^5.6.1",
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ describe('GitHub Webhook Utils', () => {
|
|||
const mockJob = { id: 123 };
|
||||
const mockEnqueuedJob = { id: 'enqueued-123' };
|
||||
const mockCloudJobs = {};
|
||||
const testOrgId = 'test-org-123';
|
||||
|
||||
beforeEach(() => {
|
||||
mockDb.insert.mockReturnValue({
|
||||
|
|
@ -107,13 +108,14 @@ describe('GitHub Webhook Utils', () => {
|
|||
body: 'Test body',
|
||||
};
|
||||
|
||||
const result = await createAndEnqueueJob(type, payload);
|
||||
const result = await createAndEnqueueJob(type, payload, testOrgId);
|
||||
|
||||
expect(mockDb.insert).toHaveBeenCalledWith(mockCloudJobs);
|
||||
expect(mockEnqueue).toHaveBeenCalledWith({
|
||||
jobId: mockJob.id,
|
||||
type,
|
||||
payload,
|
||||
orgId: testOrgId,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
jobId: mockJob.id,
|
||||
|
|
@ -136,9 +138,9 @@ describe('GitHub Webhook Utils', () => {
|
|||
body: 'Test',
|
||||
};
|
||||
|
||||
await expect(createAndEnqueueJob(type, payload)).rejects.toThrow(
|
||||
'Failed to create `cloudJobs` record.',
|
||||
);
|
||||
await expect(
|
||||
createAndEnqueueJob(type, payload, testOrgId),
|
||||
).rejects.toThrow('Failed to create `cloudJobs` record.');
|
||||
});
|
||||
|
||||
it('should throw error when enqueue fails to return job ID', async () => {
|
||||
|
|
@ -152,9 +154,9 @@ describe('GitHub Webhook Utils', () => {
|
|||
body: 'Test',
|
||||
};
|
||||
|
||||
await expect(createAndEnqueueJob(type, payload)).rejects.toThrow(
|
||||
'Failed to get enqueued job ID.',
|
||||
);
|
||||
await expect(
|
||||
createAndEnqueueJob(type, payload, testOrgId),
|
||||
).rejects.toThrow('Failed to get enqueued job ID.');
|
||||
});
|
||||
|
||||
it('should throw error when enqueue returns undefined', async () => {
|
||||
|
|
@ -168,9 +170,9 @@ describe('GitHub Webhook Utils', () => {
|
|||
body: 'Test',
|
||||
};
|
||||
|
||||
await expect(createAndEnqueueJob(type, payload)).rejects.toThrow(
|
||||
'Failed to get enqueued job ID.',
|
||||
);
|
||||
await expect(
|
||||
createAndEnqueueJob(type, payload, testOrgId),
|
||||
).rejects.toThrow('Failed to get enqueued job ID.');
|
||||
});
|
||||
|
||||
it('should handle different job types', async () => {
|
||||
|
|
@ -189,12 +191,13 @@ describe('GitHub Webhook Utils', () => {
|
|||
commentUrl: 'https://github.com/test/repo/issues/456#issuecomment-789',
|
||||
};
|
||||
|
||||
const result = await createAndEnqueueJob(type, payload);
|
||||
const result = await createAndEnqueueJob(type, payload, testOrgId);
|
||||
|
||||
expect(mockEnqueue).toHaveBeenCalledWith({
|
||||
jobId: mockJob.id,
|
||||
type,
|
||||
payload,
|
||||
orgId: testOrgId,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
jobId: mockJob.id,
|
||||
|
|
@ -213,7 +216,7 @@ describe('GitHub Webhook Utils', () => {
|
|||
body: 'Test',
|
||||
};
|
||||
|
||||
await createAndEnqueueJob(type, payload);
|
||||
await createAndEnqueueJob(type, payload, testOrgId);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
`🔗 Enqueued ${type} job (id: ${mockJob.id}) ->`,
|
||||
|
|
@ -236,9 +239,9 @@ describe('GitHub Webhook Utils', () => {
|
|||
body: 'Test',
|
||||
};
|
||||
|
||||
await expect(createAndEnqueueJob(type, payload)).rejects.toThrow(
|
||||
'Database connection failed',
|
||||
);
|
||||
await expect(
|
||||
createAndEnqueueJob(type, payload, testOrgId),
|
||||
).rejects.toThrow('Database connection failed');
|
||||
});
|
||||
|
||||
it('should handle enqueue service errors', async () => {
|
||||
|
|
@ -252,9 +255,9 @@ describe('GitHub Webhook Utils', () => {
|
|||
body: 'Test',
|
||||
};
|
||||
|
||||
await expect(createAndEnqueueJob(type, payload)).rejects.toThrow(
|
||||
'Queue service unavailable',
|
||||
);
|
||||
await expect(
|
||||
createAndEnqueueJob(type, payload, testOrgId),
|
||||
).rejects.toThrow('Queue service unavailable');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -362,6 +365,7 @@ describe('GitHub Webhook Utils', () => {
|
|||
// Test job creation in a realistic webhook scenario.
|
||||
const mockJob = { id: 456 };
|
||||
const mockEnqueuedJob = { id: 'job-456' };
|
||||
const testOrgId = 'test-org-456';
|
||||
|
||||
mockDb.insert.mockReturnValue({
|
||||
values: vi.fn().mockReturnValue({
|
||||
|
|
@ -371,12 +375,16 @@ describe('GitHub Webhook Utils', () => {
|
|||
mockEnqueue.mockResolvedValue(mockEnqueuedJob);
|
||||
|
||||
// Create job after successful verification.
|
||||
const result = await createAndEnqueueJob('github.issue.fix', {
|
||||
repo: 'test/repo',
|
||||
issue: 123,
|
||||
title: 'Test issue',
|
||||
body: 'Test issue body',
|
||||
});
|
||||
const result = await createAndEnqueueJob(
|
||||
'github.issue.fix',
|
||||
{
|
||||
repo: 'test/repo',
|
||||
issue: 123,
|
||||
title: 'Test issue',
|
||||
body: 'Test issue body',
|
||||
},
|
||||
testOrgId,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
jobId: mockJob.id,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { createHmac } from 'crypto';
|
||||
import { or, eq } from 'drizzle-orm';
|
||||
|
||||
import {
|
||||
type JobType,
|
||||
type JobPayload,
|
||||
db,
|
||||
cloudJobs,
|
||||
orgs,
|
||||
} from '@roo-code-cloud/db/server';
|
||||
|
||||
import { enqueue } from '@/lib';
|
||||
|
|
@ -25,17 +27,38 @@ export function verifySignature(
|
|||
export async function createAndEnqueueJob<T extends JobType>(
|
||||
type: T,
|
||||
payload: JobPayload<T>,
|
||||
orgId?: string,
|
||||
): Promise<{ jobId: number; enqueuedJobId: string }> {
|
||||
// @TODO: Require `orgId` to be specified.
|
||||
const organizationId =
|
||||
orgId ||
|
||||
(
|
||||
await db
|
||||
.select({ id: orgs.id })
|
||||
.from(orgs)
|
||||
.where(or(eq(orgs.name, 'Roo Code'), eq(orgs.name, 'Roo Code / Dev')))
|
||||
.limit(1)
|
||||
)[0]?.id;
|
||||
|
||||
if (!organizationId) {
|
||||
throw new Error('Organization ID is required for job creation.');
|
||||
}
|
||||
|
||||
const [job] = await db
|
||||
.insert(cloudJobs)
|
||||
.values({ type, payload, status: 'pending' })
|
||||
.values({ type, payload, status: 'pending', orgId: organizationId })
|
||||
.returning();
|
||||
|
||||
if (!job) {
|
||||
throw new Error('Failed to create `cloudJobs` record.');
|
||||
}
|
||||
|
||||
const enqueuedJob = await enqueue({ jobId: job.id, type, payload });
|
||||
const enqueuedJob = await enqueue({
|
||||
jobId: job.id,
|
||||
type,
|
||||
payload,
|
||||
orgId: organizationId,
|
||||
});
|
||||
console.log(`🔗 Enqueued ${type} job (id: ${job.id}) ->`, payload);
|
||||
|
||||
if (!enqueuedJob.id) {
|
||||
|
|
|
|||
|
|
@ -1,298 +0,0 @@
|
|||
import {
|
||||
command,
|
||||
run,
|
||||
string,
|
||||
number,
|
||||
option,
|
||||
subcommands,
|
||||
optional,
|
||||
} from 'cmd-ts';
|
||||
|
||||
import type { JobPayload } from '@roo-code-cloud/db';
|
||||
|
||||
import {
|
||||
fixGitHubIssue,
|
||||
processIssueComment,
|
||||
processPullRequestComment,
|
||||
} from '@/lib/jobs';
|
||||
import { runTask } from '@/lib/runTask';
|
||||
|
||||
const fixIssueCommand = command({
|
||||
name: 'fix-issue',
|
||||
description: 'Fix a GitHub issue',
|
||||
args: {
|
||||
repo: option({
|
||||
type: string,
|
||||
long: 'repo',
|
||||
short: 'r',
|
||||
description: 'Repository name (e.g., owner/repo)',
|
||||
}),
|
||||
issue: option({
|
||||
type: number,
|
||||
long: 'issue',
|
||||
short: 'i',
|
||||
description: 'Issue number',
|
||||
}),
|
||||
title: option({
|
||||
type: string,
|
||||
long: 'title',
|
||||
short: 't',
|
||||
description: 'Issue title',
|
||||
}),
|
||||
body: option({
|
||||
type: string,
|
||||
long: 'body',
|
||||
short: 'b',
|
||||
description: 'Issue body/description',
|
||||
}),
|
||||
labels: option({
|
||||
type: optional(string),
|
||||
long: 'labels',
|
||||
short: 'l',
|
||||
description: 'Issue labels (comma-separated: -l "bug,enhancement")',
|
||||
}),
|
||||
},
|
||||
handler: async ({ repo, issue, title, body, labels }) => {
|
||||
try {
|
||||
console.log('🔧 Fixing GitHub issue...');
|
||||
|
||||
const payload: JobPayload<'github.issue.fix'> = {
|
||||
repo,
|
||||
issue,
|
||||
title,
|
||||
body,
|
||||
labels: labels ? labels.split(',').map((l) => l.trim()) : [],
|
||||
};
|
||||
|
||||
const result = await fixGitHubIssue(payload);
|
||||
|
||||
console.log('✅ Issue fix completed successfully!', result);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Error fixing issue:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const respondIssueCommentCommand = command({
|
||||
name: 'respond-issue-comment',
|
||||
description: 'Respond to a GitHub issue comment',
|
||||
args: {
|
||||
repo: option({
|
||||
type: string,
|
||||
long: 'repo',
|
||||
short: 'r',
|
||||
description: 'Repository name (e.g., owner/repo)',
|
||||
}),
|
||||
issueNumber: option({
|
||||
type: number,
|
||||
long: 'issue-number',
|
||||
description: 'Issue number',
|
||||
}),
|
||||
issueTitle: option({
|
||||
type: string,
|
||||
long: 'issue-title',
|
||||
description: 'Issue title',
|
||||
}),
|
||||
issueBody: option({
|
||||
type: string,
|
||||
long: 'issue-body',
|
||||
description: 'Issue body',
|
||||
}),
|
||||
commentId: option({
|
||||
type: number,
|
||||
long: 'comment-id',
|
||||
description: 'Comment ID',
|
||||
}),
|
||||
commentBody: option({
|
||||
type: string,
|
||||
long: 'comment-body',
|
||||
description: 'Comment body',
|
||||
}),
|
||||
commentAuthor: option({
|
||||
type: string,
|
||||
long: 'comment-author',
|
||||
description: 'Comment author',
|
||||
}),
|
||||
commentUrl: option({
|
||||
type: string,
|
||||
long: 'comment-url',
|
||||
description: 'Comment URL',
|
||||
}),
|
||||
},
|
||||
handler: async (args) => {
|
||||
try {
|
||||
console.log('💬 Responding to GitHub issue comment...');
|
||||
|
||||
const payload: JobPayload<'github.issue.comment.respond'> = {
|
||||
repo: args.repo,
|
||||
issueNumber: args.issueNumber,
|
||||
issueTitle: args.issueTitle,
|
||||
issueBody: args.issueBody,
|
||||
commentId: args.commentId,
|
||||
commentBody: args.commentBody,
|
||||
commentAuthor: args.commentAuthor,
|
||||
commentUrl: args.commentUrl,
|
||||
};
|
||||
|
||||
const result = await processIssueComment(payload);
|
||||
console.log('✅ Issue comment response completed successfully!', result);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Error responding to issue comment:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const respondPrCommentCommand = command({
|
||||
name: 'respond-pr-comment',
|
||||
description: 'Respond to a GitHub PR comment',
|
||||
args: {
|
||||
repo: option({
|
||||
type: string,
|
||||
long: 'repo',
|
||||
short: 'r',
|
||||
description: 'Repository name (e.g., owner/repo)',
|
||||
}),
|
||||
prNumber: option({
|
||||
type: number,
|
||||
long: 'pr-number',
|
||||
description: 'PR number',
|
||||
}),
|
||||
prTitle: option({
|
||||
type: string,
|
||||
long: 'pr-title',
|
||||
description: 'PR title',
|
||||
}),
|
||||
prBody: option({
|
||||
type: string,
|
||||
long: 'pr-body',
|
||||
description: 'PR body',
|
||||
}),
|
||||
prBranch: option({
|
||||
type: string,
|
||||
long: 'pr-branch',
|
||||
description: 'PR branch',
|
||||
}),
|
||||
baseRef: option({
|
||||
type: string,
|
||||
long: 'base-ref',
|
||||
description: 'Base reference',
|
||||
}),
|
||||
commentId: option({
|
||||
type: number,
|
||||
long: 'comment-id',
|
||||
description: 'Comment ID',
|
||||
}),
|
||||
commentBody: option({
|
||||
type: string,
|
||||
long: 'comment-body',
|
||||
description: 'Comment body',
|
||||
}),
|
||||
commentAuthor: option({
|
||||
type: string,
|
||||
long: 'comment-author',
|
||||
description: 'Comment author',
|
||||
}),
|
||||
commentType: option({
|
||||
type: string,
|
||||
long: 'comment-type',
|
||||
description: 'Comment type (issue_comment or review_comment)',
|
||||
}),
|
||||
commentUrl: option({
|
||||
type: string,
|
||||
long: 'comment-url',
|
||||
description: 'Comment URL',
|
||||
}),
|
||||
},
|
||||
handler: async (args) => {
|
||||
try {
|
||||
console.log('💬 Responding to GitHub PR comment...');
|
||||
|
||||
if (
|
||||
args.commentType !== 'issue_comment' &&
|
||||
args.commentType !== 'review_comment'
|
||||
) {
|
||||
throw new Error(
|
||||
'Comment type must be either "issue_comment" or "review_comment"',
|
||||
);
|
||||
}
|
||||
|
||||
const payload: JobPayload<'github.pr.comment.respond'> = {
|
||||
repo: args.repo,
|
||||
prNumber: args.prNumber,
|
||||
prTitle: args.prTitle,
|
||||
prBody: args.prBody,
|
||||
prBranch: args.prBranch,
|
||||
baseRef: args.baseRef,
|
||||
commentId: args.commentId,
|
||||
commentBody: args.commentBody,
|
||||
commentAuthor: args.commentAuthor,
|
||||
commentType: args.commentType as 'issue_comment' | 'review_comment',
|
||||
commentUrl: args.commentUrl,
|
||||
};
|
||||
|
||||
const result = await processPullRequestComment(payload);
|
||||
console.log('✅ PR comment response completed successfully!', result);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Error responding to PR comment:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const promptCommand = command({
|
||||
name: 'prompt',
|
||||
description: 'Prompt',
|
||||
args: {
|
||||
text: option({
|
||||
type: string,
|
||||
long: 'text',
|
||||
description: 'Text to test',
|
||||
}),
|
||||
mode: option({
|
||||
type: string,
|
||||
long: 'mode',
|
||||
description: 'Mode to use: code, ask, architect',
|
||||
}),
|
||||
workspacePath: option({
|
||||
type: string,
|
||||
long: 'workspace-path',
|
||||
description: 'Workspace path',
|
||||
}),
|
||||
},
|
||||
handler: async ({ text, mode, workspacePath }) => {
|
||||
if (!['code', 'ask', 'architect'].includes(mode)) {
|
||||
throw new Error('Invalid mode');
|
||||
}
|
||||
|
||||
await runTask({
|
||||
jobType: 'test.prompt',
|
||||
jobPayload: { text },
|
||||
prompt: text,
|
||||
notify: false,
|
||||
workspacePath,
|
||||
settings: { mode },
|
||||
});
|
||||
|
||||
process.exit(0);
|
||||
},
|
||||
});
|
||||
|
||||
// Example:
|
||||
// pnpm --filter @roo-code-cloud/roomote cli prompt --text "What time is it?" --mode ask --workspace-path ~/Documents/Roo-Code
|
||||
const app = subcommands({
|
||||
name: 'roomote-cli',
|
||||
description: 'Roomote CLI - Run jobs directly from the command line',
|
||||
cmds: {
|
||||
[fixIssueCommand.name]: fixIssueCommand,
|
||||
[respondIssueCommentCommand.name]: respondIssueCommentCommand,
|
||||
[respondPrCommentCommand.name]: respondPrCommentCommand,
|
||||
[promptCommand.name]: promptCommand,
|
||||
},
|
||||
});
|
||||
|
||||
run(app, process.argv.slice(2));
|
||||
|
|
@ -10,18 +10,20 @@ import {
|
|||
type UpdateCloudJob,
|
||||
db,
|
||||
cloudJobs,
|
||||
orgSettings,
|
||||
} from '@roo-code-cloud/db/server';
|
||||
|
||||
import { fixGitHubIssue } from './jobs/fixGitHubIssue';
|
||||
import { processPullRequestComment } from './jobs/processPullRequestComment';
|
||||
import { processIssueComment } from './jobs/processIssueComment';
|
||||
import { processSlackMention } from './jobs/processSlackMention';
|
||||
import { processGeneralTask } from './jobs/processGeneralTask';
|
||||
import { SlackNotifier } from './slack';
|
||||
|
||||
const slack = new SlackNotifier();
|
||||
|
||||
export async function processJob<T extends JobType>({
|
||||
data: { type, payload, jobId },
|
||||
data: { type, payload, jobId, orgId },
|
||||
...job
|
||||
}: Job<JobParams<T>>) {
|
||||
console.log(
|
||||
|
|
@ -29,63 +31,37 @@ export async function processJob<T extends JobType>({
|
|||
);
|
||||
|
||||
try {
|
||||
const onTaskStarted = createOnTaskStartedCallback(jobId);
|
||||
const mode = await getConfiguredMode(orgId, type);
|
||||
|
||||
console.log(
|
||||
`[${job.name} | ${job.id}] Using mode '${mode}' for task type '${type}'`,
|
||||
);
|
||||
|
||||
let result: unknown;
|
||||
|
||||
switch (type) {
|
||||
case 'github.issue.fix':
|
||||
result = await fixGitHubIssue(
|
||||
payload as JobPayload<'github.issue.fix'>,
|
||||
{
|
||||
onTaskStarted: (
|
||||
slackThreadTs: string | null | undefined,
|
||||
_rooTaskId: string,
|
||||
) =>
|
||||
updateJobStatus(
|
||||
jobId,
|
||||
'processing',
|
||||
undefined,
|
||||
undefined,
|
||||
slackThreadTs,
|
||||
),
|
||||
},
|
||||
{ onTaskStarted },
|
||||
mode,
|
||||
);
|
||||
|
||||
break;
|
||||
case 'github.issue.comment.respond':
|
||||
result = await processIssueComment(
|
||||
payload as JobPayload<'github.issue.comment.respond'>,
|
||||
{
|
||||
onTaskStarted: (
|
||||
slackThreadTs: string | null | undefined,
|
||||
_rooTaskId: string,
|
||||
) =>
|
||||
updateJobStatus(
|
||||
jobId,
|
||||
'processing',
|
||||
undefined,
|
||||
undefined,
|
||||
slackThreadTs,
|
||||
),
|
||||
},
|
||||
{ onTaskStarted },
|
||||
mode,
|
||||
);
|
||||
|
||||
break;
|
||||
case 'github.pr.comment.respond':
|
||||
result = await processPullRequestComment(
|
||||
payload as JobPayload<'github.pr.comment.respond'>,
|
||||
{
|
||||
onTaskStarted: (
|
||||
slackThreadTs: string | null | undefined,
|
||||
_rooTaskId: string,
|
||||
) =>
|
||||
updateJobStatus(
|
||||
jobId,
|
||||
'processing',
|
||||
undefined,
|
||||
undefined,
|
||||
slackThreadTs,
|
||||
),
|
||||
},
|
||||
{ onTaskStarted },
|
||||
mode,
|
||||
);
|
||||
|
||||
break;
|
||||
|
|
@ -94,17 +70,7 @@ export async function processJob<T extends JobType>({
|
|||
const { channel, thread_ts } = jobPayload;
|
||||
|
||||
result = await processSlackMention(jobPayload, {
|
||||
onTaskStarted: (
|
||||
slackThreadTs: string | null | undefined,
|
||||
_rooTaskId: string,
|
||||
) =>
|
||||
updateJobStatus(
|
||||
jobId,
|
||||
'processing',
|
||||
undefined,
|
||||
undefined,
|
||||
slackThreadTs,
|
||||
),
|
||||
onTaskStarted,
|
||||
onTaskMessage: async (message: ClineMessage) => {
|
||||
console.log(`onTaskMessage (${channel}, ${thread_ts}) ->`, message);
|
||||
|
||||
|
|
@ -120,6 +86,13 @@ export async function processJob<T extends JobType>({
|
|||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'general.task': {
|
||||
const jobPayload = payload as JobPayload<'general.task'>;
|
||||
result = await processGeneralTask(jobPayload, { onTaskStarted });
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown job type: ${type}`);
|
||||
}
|
||||
|
|
@ -134,6 +107,11 @@ export async function processJob<T extends JobType>({
|
|||
}
|
||||
}
|
||||
|
||||
function createOnTaskStartedCallback(jobId: number) {
|
||||
return (slackThreadTs: string | null | undefined, _rooTaskId: string) =>
|
||||
updateJobStatus(jobId, 'processing', undefined, undefined, slackThreadTs);
|
||||
}
|
||||
|
||||
async function updateJobStatus(
|
||||
jobId: number,
|
||||
status: JobStatus,
|
||||
|
|
@ -163,3 +141,49 @@ async function updateJobStatus(
|
|||
|
||||
await db.update(cloudJobs).set(values).where(eq(cloudJobs.id, jobId));
|
||||
}
|
||||
|
||||
const DEFAULT_MODE_MAPPINGS: Record<JobType, string> = {
|
||||
'github.issue.fix': 'issue-fixer',
|
||||
'github.issue.comment.respond': 'ask',
|
||||
'github.pr.comment.respond': 'ask',
|
||||
'slack.app.mention': 'code',
|
||||
'general.task': 'code',
|
||||
};
|
||||
|
||||
async function getConfiguredMode(
|
||||
orgId: string,
|
||||
taskType: JobType,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const settings = await db
|
||||
.select()
|
||||
.from(orgSettings)
|
||||
.where(eq(orgSettings.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
if (settings.length === 0) {
|
||||
return DEFAULT_MODE_MAPPINGS[taskType];
|
||||
}
|
||||
|
||||
const orgSetting = settings[0];
|
||||
|
||||
if (!orgSetting) {
|
||||
return DEFAULT_MODE_MAPPINGS[taskType];
|
||||
}
|
||||
|
||||
const cloudSettings = orgSetting.cloudSettings as Record<string, unknown>;
|
||||
|
||||
const roomoteModeMappings = cloudSettings?.roomoteModeMappings as
|
||||
| Partial<Record<JobType, string>>
|
||||
| undefined;
|
||||
|
||||
if (roomoteModeMappings && roomoteModeMappings[taskType]) {
|
||||
return roomoteModeMappings[taskType];
|
||||
}
|
||||
|
||||
return DEFAULT_MODE_MAPPINGS[taskType];
|
||||
} catch (error) {
|
||||
console.error('Error fetching organization settings:', error);
|
||||
return DEFAULT_MODE_MAPPINGS[taskType];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,16 @@
|
|||
import type { JobType, JobPayload } from '@roo-code-cloud/db';
|
||||
import type { JobPayload } from '@roo-code-cloud/db';
|
||||
|
||||
import { runTask, type RunTaskCallbacks } from '../runTask';
|
||||
import { CRITICAL_COMMAND_RESTRICTIONS, MAIN_BRANCH_PROTECTION } from '../promptConstants';
|
||||
|
||||
const jobType: JobType = 'github.issue.fix';
|
||||
|
||||
type FixGitHubIssueJobPayload = JobPayload<'github.issue.fix'>;
|
||||
import {
|
||||
CRITICAL_COMMAND_RESTRICTIONS,
|
||||
MAIN_BRANCH_PROTECTION,
|
||||
} from '../promptConstants';
|
||||
|
||||
export async function fixGitHubIssue(
|
||||
jobPayload: FixGitHubIssueJobPayload,
|
||||
jobPayload: JobPayload<'github.issue.fix'>,
|
||||
callbacks?: RunTaskCallbacks,
|
||||
): Promise<{
|
||||
repo: string;
|
||||
issue: number;
|
||||
result: unknown;
|
||||
}> {
|
||||
mode?: string,
|
||||
) {
|
||||
const prompt = `
|
||||
Fix the following GitHub issue:
|
||||
|
||||
|
|
@ -29,13 +25,11 @@ ${MAIN_BRANCH_PROTECTION}
|
|||
const { repo, issue } = jobPayload;
|
||||
|
||||
const result = await runTask({
|
||||
jobType,
|
||||
jobType: 'github.issue.fix',
|
||||
jobPayload,
|
||||
prompt,
|
||||
callbacks,
|
||||
settings: {
|
||||
mode: 'issue-fixer',
|
||||
},
|
||||
mode,
|
||||
});
|
||||
|
||||
return { repo, issue, result };
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@ export { fixGitHubIssue } from './fixGitHubIssue';
|
|||
export { processIssueComment } from './processIssueComment';
|
||||
export { processPullRequestComment } from './processPullRequestComment';
|
||||
export { processSlackMention } from './processSlackMention';
|
||||
export { processGeneralTask } from './processGeneralTask';
|
||||
|
|
|
|||
33
apps/roomote/src/lib/jobs/processGeneralTask.ts
Normal file
33
apps/roomote/src/lib/jobs/processGeneralTask.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { JobPayload } from '@roo-code-cloud/db';
|
||||
|
||||
import { runTask, type RunTaskCallbacks } from '../runTask';
|
||||
|
||||
export async function processGeneralTask(
|
||||
jobPayload: JobPayload<'general.task'>,
|
||||
callbacks?: RunTaskCallbacks,
|
||||
mode?: string,
|
||||
) {
|
||||
// Add your workspace root to .env.local to override the default
|
||||
// workspace root that our containers use.
|
||||
const workspaceRoot = process.env.WORKSPACE_ROOT || '/roo/repos';
|
||||
const workspacePath = `${workspaceRoot}/${jobPayload.repo.split('/')[1]}`;
|
||||
|
||||
const prompt = `
|
||||
Repository: ${jobPayload.repo}
|
||||
Task: ${jobPayload.description}
|
||||
|
||||
Please complete this task and create a pull request with your changes when finished.
|
||||
`;
|
||||
|
||||
const result = await runTask({
|
||||
jobType: 'general.task',
|
||||
jobPayload,
|
||||
prompt,
|
||||
callbacks,
|
||||
notify: false,
|
||||
workspacePath,
|
||||
mode,
|
||||
});
|
||||
|
||||
return { result };
|
||||
}
|
||||
|
|
@ -1,21 +1,16 @@
|
|||
import type { JobType, JobPayload } from '@roo-code-cloud/db';
|
||||
import type { JobPayload } from '@roo-code-cloud/db';
|
||||
|
||||
import { runTask, type RunTaskCallbacks } from '../runTask';
|
||||
import { CRITICAL_COMMAND_RESTRICTIONS, MAIN_BRANCH_PROTECTION } from '../promptConstants';
|
||||
|
||||
const jobType: JobType = 'github.issue.comment.respond';
|
||||
|
||||
type ProcessIssueCommentJobPayload = JobPayload<'github.issue.comment.respond'>;
|
||||
import {
|
||||
CRITICAL_COMMAND_RESTRICTIONS,
|
||||
MAIN_BRANCH_PROTECTION,
|
||||
} from '../promptConstants';
|
||||
|
||||
export async function processIssueComment(
|
||||
jobPayload: ProcessIssueCommentJobPayload,
|
||||
jobPayload: JobPayload<'github.issue.comment.respond'>,
|
||||
callbacks?: RunTaskCallbacks,
|
||||
): Promise<{
|
||||
repo: string;
|
||||
issueNumber: number;
|
||||
commentId: number;
|
||||
result: unknown;
|
||||
}> {
|
||||
mode?: string,
|
||||
) {
|
||||
const prompt = `
|
||||
Respond to the following GitHub Issue comment:
|
||||
|
||||
|
|
@ -56,10 +51,11 @@ gh api repos/${jobPayload.repo}/issues/${jobPayload.issueNumber}/comments --meth
|
|||
const { repo, issueNumber, commentId } = jobPayload;
|
||||
|
||||
const result = await runTask({
|
||||
jobType,
|
||||
jobType: 'github.issue.comment.respond',
|
||||
jobPayload,
|
||||
prompt,
|
||||
callbacks,
|
||||
mode,
|
||||
});
|
||||
|
||||
return { repo, issueNumber, commentId, result };
|
||||
|
|
|
|||
|
|
@ -1,22 +1,16 @@
|
|||
import type { JobType, JobPayload } from '@roo-code-cloud/db';
|
||||
import type { JobPayload } from '@roo-code-cloud/db';
|
||||
|
||||
import { runTask, type RunTaskCallbacks } from '../runTask';
|
||||
import { CRITICAL_COMMAND_RESTRICTIONS, MAIN_BRANCH_PROTECTION } from '../promptConstants';
|
||||
|
||||
const jobType: JobType = 'github.pr.comment.respond';
|
||||
|
||||
type ProcessPullRequestCommentJobPayload =
|
||||
JobPayload<'github.pr.comment.respond'>;
|
||||
import {
|
||||
CRITICAL_COMMAND_RESTRICTIONS,
|
||||
MAIN_BRANCH_PROTECTION,
|
||||
} from '../promptConstants';
|
||||
|
||||
export async function processPullRequestComment(
|
||||
jobPayload: ProcessPullRequestCommentJobPayload,
|
||||
jobPayload: JobPayload<'github.pr.comment.respond'>,
|
||||
callbacks?: RunTaskCallbacks,
|
||||
): Promise<{
|
||||
repo: string;
|
||||
prNumber: number;
|
||||
commentId: number;
|
||||
result: unknown;
|
||||
}> {
|
||||
mode?: string,
|
||||
) {
|
||||
const prompt = `
|
||||
Process the following GitHub Pull Request comment:
|
||||
|
||||
|
|
@ -70,10 +64,11 @@ Do not create a new pull request - work directly on the existing PR branch.
|
|||
const { repo, prNumber, commentId } = jobPayload;
|
||||
|
||||
const result = await runTask({
|
||||
jobType,
|
||||
jobType: 'github.pr.comment.respond',
|
||||
jobPayload,
|
||||
prompt,
|
||||
callbacks,
|
||||
mode,
|
||||
});
|
||||
|
||||
return { repo, prNumber, commentId, result };
|
||||
|
|
|
|||
|
|
@ -1,23 +1,19 @@
|
|||
import type { JobType, JobPayload } from '@roo-code-cloud/db';
|
||||
import type { JobPayload } from '@roo-code-cloud/db';
|
||||
|
||||
import { runTask, type RunTaskCallbacks } from '../runTask';
|
||||
import { CRITICAL_COMMAND_RESTRICTIONS, GIT_WORKFLOW_INSTRUCTIONS, MAIN_BRANCH_PROTECTION } from '../promptConstants';
|
||||
|
||||
const jobType: JobType = 'slack.app.mention';
|
||||
|
||||
type ProcessSlackMentionJobPayload = JobPayload<'slack.app.mention'>;
|
||||
import {
|
||||
CRITICAL_COMMAND_RESTRICTIONS,
|
||||
GIT_WORKFLOW_INSTRUCTIONS,
|
||||
MAIN_BRANCH_PROTECTION,
|
||||
} from '../promptConstants';
|
||||
|
||||
export async function processSlackMention(
|
||||
jobPayload: ProcessSlackMentionJobPayload,
|
||||
jobPayload: JobPayload<'slack.app.mention'>,
|
||||
callbacks?: RunTaskCallbacks,
|
||||
): Promise<{
|
||||
channel: string;
|
||||
user: string;
|
||||
result: unknown;
|
||||
}> {
|
||||
mode?: string,
|
||||
) {
|
||||
const { text: originalPrompt, channel, user } = jobPayload;
|
||||
|
||||
// Create a structured prompt following the same pattern as other roomote triggers
|
||||
const prompt = `
|
||||
Process the following Slack mention request:
|
||||
|
||||
|
|
@ -50,12 +46,13 @@ ${GIT_WORKFLOW_INSTRUCTIONS}
|
|||
: `${workspaceRoot}/${jobPayload.workspace}`;
|
||||
|
||||
const result = await runTask({
|
||||
jobType,
|
||||
jobType: 'slack.app.mention',
|
||||
jobPayload,
|
||||
prompt,
|
||||
callbacks,
|
||||
notify: false,
|
||||
workspacePath,
|
||||
mode,
|
||||
});
|
||||
|
||||
return { channel, user, result };
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
/**
|
||||
* Shared constants for roomote job prompts
|
||||
*/
|
||||
|
||||
export const CRITICAL_COMMAND_RESTRICTIONS = `
|
||||
CRITICAL COMMAND RESTRICTIONS:
|
||||
- NEVER execute long-running commands like starting servers (npm run dev, npm start, python -m http.server, etc.)
|
||||
|
|
@ -21,4 +17,4 @@ This ensures all changes are properly tracked and can be reviewed before merging
|
|||
|
||||
export const MAIN_BRANCH_PROTECTION = `
|
||||
NEVER commit directly to the main branch. Always create a feature branch for your changes.
|
||||
`.trim();
|
||||
`.trim();
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ type RunTaskOptions<T extends JobType> = {
|
|||
notify?: boolean;
|
||||
workspacePath?: string;
|
||||
settings?: RooCodeSettings;
|
||||
mode?: string;
|
||||
};
|
||||
|
||||
export const runTask = async <T extends JobType>({
|
||||
|
|
@ -78,6 +79,7 @@ export const runTask = async <T extends JobType>({
|
|||
notify = true,
|
||||
workspacePath = '/roo/repos/Roo-Code',
|
||||
settings = {},
|
||||
mode,
|
||||
}: RunTaskOptions<T>) => {
|
||||
const ipcSocketPath = path.resolve(
|
||||
os.tmpdir(),
|
||||
|
|
@ -90,15 +92,15 @@ export const runTask = async <T extends JobType>({
|
|||
|
||||
let envVars = `ROO_CODE_IPC_SOCKET_PATH=${ipcSocketPath}`;
|
||||
|
||||
// Create JWT token if we have jobId and userId
|
||||
// Create JWT token if we have jobId and userId.
|
||||
if (jobId && userId) {
|
||||
try {
|
||||
// Get user's org info
|
||||
const user = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
const orgId = user[0]?.orgId || null;
|
||||
|
||||
const token = await createJobToken(
|
||||
|
|
@ -109,11 +111,10 @@ export const runTask = async <T extends JobType>({
|
|||
);
|
||||
|
||||
envVars += ` ROO_CODE_CLOUD_TOKEN=${token}`;
|
||||
|
||||
envVars += ` ROO_CODE_CLOUD_ORG_SETTINGS=${Buffer.from(JSON.stringify(ORGANIZATION_DEFAULT)).toString('base64')}`;
|
||||
} catch (error) {
|
||||
logger?.error('Failed to create job token:', error);
|
||||
// Continue without token - job will fall back to no auth
|
||||
// Continue without token - job will fall back to no auth.
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -131,9 +132,10 @@ export const runTask = async <T extends JobType>({
|
|||
|
||||
logger.info(codeCommand);
|
||||
|
||||
// Pull latest changes from git before opening VSCode
|
||||
// Pull latest changes from git before opening VSCode.
|
||||
try {
|
||||
const repoConfig = getRepoConfigByPath(workspacePath);
|
||||
|
||||
if (repoConfig) {
|
||||
logger.info(`Pulling latest changes for repository: ${repoConfig.name}`);
|
||||
await gitPullRepoFromConfig(repoConfig, logger);
|
||||
|
|
@ -148,7 +150,7 @@ export const runTask = async <T extends JobType>({
|
|||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Failed to pull git changes: ${errorMessage}`);
|
||||
// Continue with task execution even if git pull fails
|
||||
// Continue with task execution even if git pull fails.
|
||||
logger.info('Continuing with task execution despite git pull failure');
|
||||
}
|
||||
|
||||
|
|
@ -305,6 +307,7 @@ export const runTask = async <T extends JobType>({
|
|||
openRouterApiKey: process.env.OPENROUTER_API_KEY,
|
||||
lastShownAnnouncementId: 'jun-17-2025-3-21',
|
||||
...settings,
|
||||
mode,
|
||||
},
|
||||
text: prompt,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -135,6 +135,21 @@ export class SlackNotifier {
|
|||
],
|
||||
});
|
||||
}
|
||||
case 'general.task': {
|
||||
const payload = jobPayload as JobPayload<'general.task'>;
|
||||
return await this.postMessage({
|
||||
text: `🚀 Task Started`,
|
||||
blocks: [
|
||||
{
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: `🚀 *Task Started*\nWorking on general task in <https://github.com/${payload.repo}|${payload.repo}>\n*Task:* ${payload.description.slice(0, 200)}${payload.description.length > 200 ? '...' : ''}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown job type: ${jobType}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,3 +9,5 @@ NEXT_PUBLIC_CLERK_SIGN_IN_FORCE_REDIRECT_URL=/authorized
|
|||
NEXT_PUBLIC_CLERK_SIGN_UP_FORCE_REDIRECT_URL=/authorized
|
||||
NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/authorized
|
||||
NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/authorized
|
||||
|
||||
ROOMOTE_API_URL=http://localhost:3001
|
||||
|
|
|
|||
|
|
@ -1,2 +1,4 @@
|
|||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_Y2xlcmsucm9vY29kZS5jb20k
|
||||
NEXT_PUBLIC_CLERK_FRONTEND_API=https://clerk.roocode.com
|
||||
|
||||
ROOMOTE_API_URL=https://roomote-api.fly.dev
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slider": "^1.3.5",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
|
|
|
|||
|
|
@ -8,11 +8,6 @@ import { Env } from '@roo-code-cloud/env';
|
|||
import { type AuthResult, type ApiAuthResult, isOrgRole } from '@/types';
|
||||
import { logger } from '@/lib/server';
|
||||
import { validateJobToken } from '@roo-code-cloud/job-auth';
|
||||
// import {
|
||||
// type AgentTokenPayload,
|
||||
// validateAgentToken,
|
||||
// } from '@/lib/server/agent-auth';
|
||||
// import { updateAgentUsage } from '@/actions/agents';
|
||||
|
||||
export async function authorize(): Promise<AuthResult> {
|
||||
const { userId, orgId, orgRole } = await auth();
|
||||
|
|
@ -21,7 +16,6 @@ export async function authorize(): Promise<AuthResult> {
|
|||
return { success: false, error: 'Unauthorized: User required' };
|
||||
}
|
||||
|
||||
// Personal context is valid - no org required
|
||||
if (!orgId) {
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -41,19 +35,17 @@ export async function authorize(): Promise<AuthResult> {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates authentication and authorization for API endpoints.
|
||||
*/
|
||||
export async function authorizeApi(
|
||||
request: NextRequest,
|
||||
): Promise<ApiAuthResult> {
|
||||
const authHeader = request.headers.get('authorization');
|
||||
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return authorize(); // Fall back to Clerk auth
|
||||
return authorize();
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
success: false,
|
||||
|
|
@ -61,9 +53,9 @@ export async function authorizeApi(
|
|||
};
|
||||
}
|
||||
|
||||
// Try job token first
|
||||
try {
|
||||
const jobContext = await validateJobToken(token);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userType: 'job',
|
||||
|
|
@ -72,17 +64,10 @@ export async function authorizeApi(
|
|||
jobId: jobContext.jobId,
|
||||
};
|
||||
} catch {
|
||||
// If job token validation fails, try agent token
|
||||
// (existing agent token code would go here)
|
||||
|
||||
// If both fail, fall back to Clerk
|
||||
return authorize();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates authentication and authorization for analytics functions.
|
||||
*/
|
||||
export async function authorizeAnalytics({
|
||||
requestedOrgId,
|
||||
requestedUserId,
|
||||
|
|
@ -100,9 +85,7 @@ export async function authorizeAnalytics({
|
|||
throw new Error('Unauthorized: User required');
|
||||
}
|
||||
|
||||
// Handle personal context
|
||||
if (!authOrgId && !requestedOrgId) {
|
||||
// Personal context - user can only access their own data
|
||||
if (requestedUserId && requestedUserId !== authUserId) {
|
||||
throw new Error(
|
||||
'Unauthorized: Personal users can only access their own data',
|
||||
|
|
@ -117,17 +100,14 @@ export async function authorizeAnalytics({
|
|||
};
|
||||
}
|
||||
|
||||
// Ensure user is authenticated and belongs to the organization
|
||||
if (!authOrgId || !authUserId || authOrgId !== requestedOrgId) {
|
||||
throw new Error('Unauthorized: Invalid organization access');
|
||||
}
|
||||
|
||||
// Check if admin access is required
|
||||
if (requireAdmin && orgRole !== 'org:admin') {
|
||||
throw new Error('Unauthorized: Administrator access required');
|
||||
}
|
||||
|
||||
// If user is not an admin and trying to access data other than their own
|
||||
if (
|
||||
orgRole !== 'org:admin' &&
|
||||
requestedUserId &&
|
||||
|
|
@ -136,8 +116,6 @@ export async function authorizeAnalytics({
|
|||
throw new Error('Unauthorized: Members can only access their own data');
|
||||
}
|
||||
|
||||
// For non-admin users, force userId filter to their own ID
|
||||
// Unless allowCrossUserAccess is true and we're checking task sharing permissions
|
||||
const effectiveUserId =
|
||||
orgRole !== 'org:admin' && !allowCrossUserAccess
|
||||
? authUserId
|
||||
|
|
|
|||
238
apps/web/src/actions/roomote.ts
Normal file
238
apps/web/src/actions/roomote.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
'use server';
|
||||
|
||||
import { z } from 'zod';
|
||||
import { desc, eq, and, getTableColumns } from 'drizzle-orm';
|
||||
|
||||
import {
|
||||
type CloudJob,
|
||||
type CreateJob,
|
||||
type User,
|
||||
db,
|
||||
cloudJobs,
|
||||
orgSettings,
|
||||
users,
|
||||
createJobSchema,
|
||||
} from '@roo-code-cloud/db/server';
|
||||
|
||||
import { authorizeRoomotes } from '@/lib/roomotes';
|
||||
|
||||
export type CloudJobWithUser = CloudJob & {
|
||||
user?: User | null;
|
||||
};
|
||||
|
||||
export async function getCloudSettings(): Promise<
|
||||
| { success: true; data: Record<string, unknown> }
|
||||
| { success: false; error: string }
|
||||
> {
|
||||
try {
|
||||
const authResult = await authorizeRoomotes();
|
||||
|
||||
if (!authResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: authResult.error || 'Roomotes feature not enabled',
|
||||
};
|
||||
}
|
||||
|
||||
if (!authResult.orgId) {
|
||||
return { success: false, error: 'Organization membership required' };
|
||||
}
|
||||
|
||||
const { orgId } = authResult;
|
||||
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(orgSettings)
|
||||
.where(eq(orgSettings.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
return { success: true, data: row?.cloudSettings ?? {} };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCloudSettings(
|
||||
newSettings: Record<string, unknown>,
|
||||
): Promise<{ success: true } | { success: false; error: string }> {
|
||||
try {
|
||||
const authResult = await authorizeRoomotes();
|
||||
|
||||
if (!authResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: authResult.error || 'Roomotes feature not enabled',
|
||||
};
|
||||
}
|
||||
|
||||
if (authResult.orgRole !== 'org:admin') {
|
||||
return { success: false, error: 'Admin access required' };
|
||||
}
|
||||
|
||||
const { orgId } = authResult;
|
||||
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(orgSettings)
|
||||
.where(eq(orgSettings.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
const cloudSettings = {
|
||||
...(row?.cloudSettings || {}),
|
||||
...newSettings,
|
||||
};
|
||||
|
||||
if (row) {
|
||||
await db
|
||||
.update(orgSettings)
|
||||
.set({ cloudSettings, updatedAt: new Date() })
|
||||
.where(eq(orgSettings.orgId, orgId));
|
||||
} else {
|
||||
await db.insert(orgSettings).values({ orgId, cloudSettings });
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchRoomoteJobs(
|
||||
userId?: string | null,
|
||||
): Promise<
|
||||
| { success: true; jobs: CloudJobWithUser[] }
|
||||
| { success: false; error: string }
|
||||
> {
|
||||
try {
|
||||
const authResult = await authorizeRoomotes();
|
||||
|
||||
if (!authResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: authResult.error || 'Roomotes feature not enabled',
|
||||
};
|
||||
}
|
||||
|
||||
if (!authResult.orgId) {
|
||||
return { success: false, error: 'Organization membership required' };
|
||||
}
|
||||
|
||||
const { orgId, orgRole } = authResult;
|
||||
|
||||
const conditions = [eq(cloudJobs.orgId, orgId)];
|
||||
|
||||
if (!userId && orgRole !== 'org:admin') {
|
||||
userId = authResult.userId;
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
conditions.push(eq(cloudJobs.userId, userId));
|
||||
}
|
||||
|
||||
const jobs = await db
|
||||
.select({
|
||||
...getTableColumns(cloudJobs),
|
||||
user: getTableColumns(users),
|
||||
})
|
||||
.from(cloudJobs)
|
||||
.leftJoin(users, eq(cloudJobs.userId, users.id))
|
||||
.where(conditions.length > 1 ? and(...conditions) : conditions[0])
|
||||
.orderBy(desc(cloudJobs.createdAt))
|
||||
.limit(50);
|
||||
|
||||
return { success: true, jobs };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function createRoomoteJob(
|
||||
data: CreateJob,
|
||||
): Promise<
|
||||
| { success: true; jobId: number; enqueuedJobId: string }
|
||||
| { success: false; error: string }
|
||||
> {
|
||||
try {
|
||||
const authResult = await authorizeRoomotes();
|
||||
|
||||
if (!authResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: authResult.error || 'Roomotes feature not enabled',
|
||||
};
|
||||
}
|
||||
|
||||
if (!authResult.orgId) {
|
||||
return { success: false, error: 'Organization membership required' };
|
||||
}
|
||||
|
||||
const { orgId, userId } = authResult;
|
||||
|
||||
const response = await fetch(`${process.env.ROOMOTE_API_URL}/api/jobs`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(createJobSchema.parse({ ...data, orgId, userId })),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(response);
|
||||
|
||||
const error =
|
||||
(await response.json().catch(() => undefined)) ??
|
||||
`HTTP ${response.status}: ${response.statusText}`;
|
||||
|
||||
return { success: false, error };
|
||||
}
|
||||
|
||||
const { jobId, enqueuedJobId } = await response.json();
|
||||
return { success: true, jobId, enqueuedJobId };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Validation error: ${error.errors.map((e) => e.message).join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof TypeError) {
|
||||
const { cause } = error;
|
||||
|
||||
if (
|
||||
typeof cause === 'object' &&
|
||||
cause &&
|
||||
'code' in cause &&
|
||||
cause.code === 'ECONNREFUSED'
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Connection refused. Please check if the Roomote API server is running.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'An unknown error occurred.',
|
||||
};
|
||||
}
|
||||
}
|
||||
155
apps/web/src/app/(authenticated)/roomote/ConfigureTasks.tsx
Normal file
155
apps/web/src/app/(authenticated)/roomote/ConfigureTasks.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import type { JobType } from '@roo-code-cloud/db';
|
||||
|
||||
import { QueryKey } from '@/types';
|
||||
import { getCloudSettings, updateCloudSettings } from '@/actions/roomote';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui';
|
||||
|
||||
import { TASK_TYPES } from './constants';
|
||||
|
||||
type ConfigureTasksProps = {
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
export function ConfigureTasks({ onSuccess }: ConfigureTasksProps) {
|
||||
const [localChanges, setLocalChanges] = useState<
|
||||
Partial<Record<JobType, string>>
|
||||
>({});
|
||||
|
||||
const [selectedTaskType, setSelectedTaskType] = useState<JobType | undefined>(
|
||||
TASK_TYPES[0]?.key,
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [QueryKey.GetCloudSettings],
|
||||
queryFn: getCloudSettings,
|
||||
select: (data) =>
|
||||
data.success && data.data?.roomoteModeMappings
|
||||
? (data.data.roomoteModeMappings as Partial<Record<JobType, string>>)
|
||||
: {},
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: updateCloudSettings,
|
||||
onSuccess: (result) => {
|
||||
if (result.success) {
|
||||
toast.success('Task configuration successfully saved.');
|
||||
setLocalChanges({});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [QueryKey.GetCloudSettings],
|
||||
});
|
||||
onSuccess?.();
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
},
|
||||
onError: () => toast.error('An unexpected error occurred.'),
|
||||
});
|
||||
|
||||
const modes = { ...(query.data || {}), ...localChanges };
|
||||
|
||||
return query.isLoading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<Select
|
||||
value={selectedTaskType}
|
||||
onValueChange={(value) => setSelectedTaskType(value as JobType)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Task Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TASK_TYPES.map((taskType) => (
|
||||
<SelectItem key={taskType.key} value={taskType.key}>
|
||||
{taskType.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{selectedTaskType && (
|
||||
<TaskConfiguration
|
||||
jobType={selectedTaskType}
|
||||
modes={modes}
|
||||
onChange={(value) =>
|
||||
setLocalChanges((prev) => ({
|
||||
...prev,
|
||||
[selectedTaskType]: value,
|
||||
}))
|
||||
}
|
||||
disabled={mutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{Object.keys(localChanges).length > 0 && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => mutation.mutate({ roomoteModeMappings: modes })}
|
||||
disabled={mutation.isPending}
|
||||
size="sm"
|
||||
>
|
||||
{mutation.isPending ? <Loader2 className="animate-spin" /> : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TaskConfigurationProps {
|
||||
jobType: JobType;
|
||||
modes: Partial<Record<JobType, string>>;
|
||||
onChange: (value: string) => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
function TaskConfiguration({
|
||||
jobType,
|
||||
modes,
|
||||
onChange,
|
||||
disabled,
|
||||
}: TaskConfigurationProps) {
|
||||
const taskType = TASK_TYPES.find((t) => t.key === jobType);
|
||||
|
||||
return taskType ? (
|
||||
<div className="border rounded p-4 space-y-4">
|
||||
<div>
|
||||
<label htmlFor={taskType.key} className="text-sm font-medium block">
|
||||
{taskType.label}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{taskType.description}
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
id={taskType.key}
|
||||
type="text"
|
||||
value={modes[jobType] || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="Mode"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
141
apps/web/src/app/(authenticated)/roomote/CreateTask.tsx
Normal file
141
apps/web/src/app/(authenticated)/roomote/CreateTask.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useAuth } from '@clerk/nextjs';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { type JobType } from '@roo-code-cloud/db';
|
||||
|
||||
import { QueryKey } from '@/types';
|
||||
import { createRoomoteJob } from '@/actions/roomote';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
FormDescription,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
} from '@/components/ui';
|
||||
|
||||
import { type FormData, formSchema } from './types';
|
||||
import { TASK_TYPES } from './constants';
|
||||
import {
|
||||
GitHubIssueFixFields,
|
||||
GitHubIssueCommentFields,
|
||||
GitHubPRCommentFields,
|
||||
GeneralTaskFields,
|
||||
} from './components';
|
||||
|
||||
type CreateTaskProps = {
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
export function CreateTask({ onSuccess }: CreateTaskProps) {
|
||||
const { orgId, userId } = useAuth();
|
||||
|
||||
const [selectedJobType, setSelectedJobType] =
|
||||
useState<JobType>('general.task');
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
orgId: orgId || '',
|
||||
userId: userId || '',
|
||||
type: 'general.task',
|
||||
},
|
||||
});
|
||||
|
||||
const onJobTypeChange = (jobType: JobType) => {
|
||||
setSelectedJobType(jobType);
|
||||
form.reset();
|
||||
form.setValue('type', jobType);
|
||||
};
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
try {
|
||||
const result = await createRoomoteJob({ ...data });
|
||||
|
||||
if (result.success) {
|
||||
toast.success(`Job created successfully (job_id: ${result.jobId}).`);
|
||||
onJobTypeChange(selectedJobType);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [QueryKey.FetchRoomoteJobs],
|
||||
});
|
||||
onSuccess?.();
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
} catch (_error) {
|
||||
toast.error('An unexpected error occurred.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormItem>
|
||||
<FormLabel>Task Type</FormLabel>
|
||||
<Select onValueChange={onJobTypeChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
selectedJobType
|
||||
? TASK_TYPES.find((type) => type.key === selectedJobType)
|
||||
?.label
|
||||
: 'Select'
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{TASK_TYPES.map(({ key, label }) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Choose the type of task you want to create.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
|
||||
{selectedJobType === 'general.task' && <GeneralTaskFields />}
|
||||
|
||||
{selectedJobType === 'github.issue.fix' && <GitHubIssueFixFields />}
|
||||
|
||||
{selectedJobType === 'github.issue.comment.respond' && (
|
||||
<GitHubIssueCommentFields />
|
||||
)}
|
||||
|
||||
{selectedJobType === 'github.pr.comment.respond' && (
|
||||
<GitHubPRCommentFields />
|
||||
)}
|
||||
|
||||
{selectedJobType && (
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
'Create Task'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
267
apps/web/src/app/(authenticated)/roomote/Jobs.tsx
Normal file
267
apps/web/src/app/(authenticated)/roomote/Jobs.tsx
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { Loader2, RefreshCw, Plus, Cog } from 'lucide-react';
|
||||
|
||||
import { QueryKey } from '@/types';
|
||||
import { cn } from '@/lib';
|
||||
import {
|
||||
Button,
|
||||
Badge,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui';
|
||||
import { fetchRoomoteJobs } from '@/actions/roomote';
|
||||
|
||||
import { STATUSES, LABELS } from './constants';
|
||||
import { getJobTitle } from './utils';
|
||||
import { CreateTask } from './CreateTask';
|
||||
import { ConfigureTasks } from './ConfigureTasks';
|
||||
|
||||
interface JobsProps {
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export function Jobs({ userId }: JobsProps) {
|
||||
const [modal, setModal] = useState<'create' | 'configure'>();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [QueryKey.FetchRoomoteJobs, userId],
|
||||
queryFn: () => fetchRoomoteJobs(userId),
|
||||
refetchInterval: 120_000,
|
||||
});
|
||||
|
||||
const jobs = query.data?.success ? query.data.jobs : [];
|
||||
|
||||
const error =
|
||||
query.data?.success === false ? query.data.error : query.error?.message;
|
||||
|
||||
const lastUpdated = query.dataUpdatedAt
|
||||
? new Date(query.dataUpdatedAt)
|
||||
: null;
|
||||
|
||||
if (query.isLoading && jobs.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-4">
|
||||
<div className="text-destructive">{error}</div>
|
||||
<Button onClick={() => query.refetch()} variant="outline">
|
||||
<RefreshCw />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-xl font-bold tracking-tight">
|
||||
Roomote Tasks
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Create and manage automated tasks using Roomote.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setModal('create')}
|
||||
>
|
||||
<Plus />
|
||||
Create Task
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setModal('configure')}
|
||||
>
|
||||
<Cog />
|
||||
Configure Tasks
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => query.refetch()}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn({ 'animate-spin': query.isLoading })}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
{lastUpdated && (
|
||||
<div className="text-sm text-muted-foreground text-right">
|
||||
Updated {formatDistanceToNow(lastUpdated, { addSuffix: true })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{jobs.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No jobs found. Create your first job using the form above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="bg-muted/50 px-4 py-3 border-b">
|
||||
<div className="grid grid-cols-12 gap-4 text-sm font-medium text-muted-foreground">
|
||||
<div className="col-span-1">ID</div>
|
||||
<div className="col-span-2">Type</div>
|
||||
<div className="col-span-3">Title</div>
|
||||
<div className="col-span-2">Triggered By</div>
|
||||
<div className="col-span-2">Status</div>
|
||||
<div className="col-span-1">Created</div>
|
||||
<div className="col-span-1">Duration</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y">
|
||||
{jobs.map((job) => {
|
||||
const { label, variant, icon: Icon } = STATUSES[job.status];
|
||||
|
||||
const duration =
|
||||
job.completedAt && job.startedAt
|
||||
? Math.round(
|
||||
(new Date(job.completedAt).getTime() -
|
||||
new Date(job.startedAt).getTime()) /
|
||||
1000,
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={job.id}
|
||||
className="px-4 py-3 hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<div className="grid grid-cols-12 gap-4 items-center text-sm">
|
||||
<div className="col-span-1 font-mono text-xs">
|
||||
#{job.id}
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<span className="text-xs bg-muted px-2 py-1 rounded">
|
||||
{LABELS[job.type]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="col-span-3">
|
||||
<div className="truncate" title={getJobTitle(job)}>
|
||||
{getJobTitle(job)}
|
||||
</div>
|
||||
{job.error && (
|
||||
<div
|
||||
className="text-xs text-red-600 truncate mt-1"
|
||||
title={job.error}
|
||||
>
|
||||
Error: {job.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
{job.user ? (
|
||||
<div className="text-xs">
|
||||
<div
|
||||
className="font-medium truncate"
|
||||
title={job.user.name}
|
||||
>
|
||||
{job.user.name}
|
||||
</div>
|
||||
<div
|
||||
className="text-muted-foreground truncate"
|
||||
title={job.user.email}
|
||||
>
|
||||
{job.user.email}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Unknown user
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<Badge
|
||||
variant={variant}
|
||||
className="flex items-center gap-1 w-fit"
|
||||
>
|
||||
<Icon
|
||||
className={`h-3 w-3 ${job.status === 'processing' ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1 text-muted-foreground text-xs">
|
||||
{formatDistanceToNow(new Date(job.createdAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="col-span-1 text-muted-foreground text-xs">
|
||||
{duration ? `${duration}s` : '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={modal === 'create'}
|
||||
onOpenChange={() => setModal(undefined)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Task</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select a task type and fill in the required information to create
|
||||
a new Roomote task.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<CreateTask onSuccess={() => setModal(undefined)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={modal === 'configure'}
|
||||
onOpenChange={() => setModal(undefined)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Configure Tasks</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure which mode Roomote should use for each type of task.
|
||||
Enter the mode slug (e.g., "code",
|
||||
"architect", "ask", "debug",
|
||||
"orchestrator", "designer",
|
||||
"security-review").
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ConfigureTasks onSuccess={() => setModal(undefined)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from '@/components/ui';
|
||||
|
||||
import type { FormData } from '../types';
|
||||
|
||||
export function GeneralTaskFields() {
|
||||
const { control } = useFormContext<FormData>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={control}
|
||||
name="payload.repo"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Repository</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="owner/repository"
|
||||
{...field}
|
||||
value={field.value || ''}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
GitHub repository in format: owner/repository
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="payload.description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Task Description</FormLabel>
|
||||
<FormControl>
|
||||
<textarea
|
||||
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Describe what you want Roomote to do..."
|
||||
{...field}
|
||||
value={field.value || ''}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Provide a detailed description of the task you want Roomote to
|
||||
perform
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from '@/components/ui';
|
||||
|
||||
import type { FormData } from '../types';
|
||||
|
||||
export function GitHubIssueCommentFields() {
|
||||
const { control } = useFormContext<FormData>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={control}
|
||||
name="payload.repo"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Repository</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="owner/repository" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="payload.issueNumber"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Issue Number</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="123"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="payload.issueTitle"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Issue Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Issue title" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="payload.issueBody"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Issue Body</FormLabel>
|
||||
<FormControl>
|
||||
<textarea
|
||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Issue description..."
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="payload.commentId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Comment ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="456"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="payload.commentBody"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Comment Body</FormLabel>
|
||||
<FormControl>
|
||||
<textarea
|
||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Comment content..."
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="payload.commentAuthor"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Comment Author</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="username" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="payload.commentUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Comment URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://github.com/..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from '@/components/ui';
|
||||
|
||||
import type { FormData } from '../types';
|
||||
|
||||
export function GitHubIssueFixFields() {
|
||||
const { control } = useFormContext<FormData>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={control}
|
||||
name="payload.repo"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Repository</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="owner/repository" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
GitHub repository in format: owner/repository
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="payload.issue"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Issue Number</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="123"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="payload.title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Issue Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Issue title" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="payload.body"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Issue Body</FormLabel>
|
||||
<FormControl>
|
||||
<textarea
|
||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Issue description..."
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
import { useFormContext } from 'react-hook-form';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui';
|
||||
|
||||
import type { FormData } from '../types';
|
||||
|
||||
export function GitHubPRCommentFields() {
|
||||
const { control } = useFormContext<FormData>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={control}
|
||||
name="payload.repo"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Repository</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="owner/repository" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="payload.prNumber"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>PR Number</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="123"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="payload.prTitle"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>PR Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Pull request title" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="payload.prBody"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>PR Body</FormLabel>
|
||||
<FormControl>
|
||||
<textarea
|
||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Pull request description..."
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="payload.prBranch"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>PR Branch</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="feature-branch" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="payload.baseRef"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Base Ref</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="main" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="payload.commentId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Comment ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="456"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="payload.commentBody"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Comment Body</FormLabel>
|
||||
<FormControl>
|
||||
<textarea
|
||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Comment content..."
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="payload.commentAuthor"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Comment Author</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="username" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="payload.commentType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Comment Type</FormLabel>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-between">
|
||||
{field.value || 'Select comment type'}
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-full">
|
||||
<DropdownMenuItem
|
||||
onClick={() => field.onChange('issue_comment')}
|
||||
>
|
||||
Issue Comment
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => field.onChange('review_comment')}
|
||||
>
|
||||
Review Comment
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="payload.commentUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Comment URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://github.com/..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export { GitHubIssueFixFields } from './GitHubIssueFixFields';
|
||||
export { GitHubIssueCommentFields } from './GitHubIssueCommentFields';
|
||||
export { GitHubPRCommentFields } from './GitHubPRCommentFields';
|
||||
export { GeneralTaskFields } from './GeneralTaskFields';
|
||||
61
apps/web/src/app/(authenticated)/roomote/constants.ts
Normal file
61
apps/web/src/app/(authenticated)/roomote/constants.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { Clock, CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||
|
||||
import type { JobType } from '@roo-code-cloud/db';
|
||||
|
||||
export const TASK_TYPES: {
|
||||
key: JobType;
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
key: 'general.task',
|
||||
label: 'General Task',
|
||||
description: 'Default mode used for general tasks.',
|
||||
},
|
||||
{
|
||||
key: 'github.issue.fix',
|
||||
label: 'Fix GitHub Issue',
|
||||
description: 'Default mode used when fixing GitHub issues.',
|
||||
},
|
||||
{
|
||||
key: 'github.issue.comment.respond',
|
||||
label: 'Respond to Issue Comment',
|
||||
description: 'Default mode used when responding to issue comments.',
|
||||
},
|
||||
{
|
||||
key: 'github.pr.comment.respond',
|
||||
label: 'Respond to PR Comment',
|
||||
description: 'Default mode used when responding to pull request comments.',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const STATUSES = {
|
||||
pending: {
|
||||
label: 'Pending',
|
||||
variant: 'secondary' as const,
|
||||
icon: Clock,
|
||||
},
|
||||
processing: {
|
||||
label: 'Processing',
|
||||
variant: 'default' as const,
|
||||
icon: Loader2,
|
||||
},
|
||||
completed: {
|
||||
label: 'Completed',
|
||||
variant: 'default' as const,
|
||||
icon: CheckCircle,
|
||||
},
|
||||
failed: {
|
||||
label: 'Failed',
|
||||
variant: 'destructive' as const,
|
||||
icon: XCircle,
|
||||
},
|
||||
};
|
||||
|
||||
export const LABELS: Record<JobType, string> = {
|
||||
'github.issue.fix': 'Fix GitHub Issue',
|
||||
'github.issue.comment.respond': 'Respond to Issue Comment',
|
||||
'github.pr.comment.respond': 'Respond to PR Comment',
|
||||
'slack.app.mention': 'Respond to Slack Mention',
|
||||
'general.task': 'General Task',
|
||||
};
|
||||
28
apps/web/src/app/(authenticated)/roomote/page.tsx
Normal file
28
apps/web/src/app/(authenticated)/roomote/page.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { authorize } from '@/actions/auth';
|
||||
import { authorizeRoomotes } from '@/lib/roomotes';
|
||||
|
||||
import { Jobs } from './Jobs';
|
||||
|
||||
export default async function Page() {
|
||||
const authResult = await authorize();
|
||||
|
||||
if (!authResult.success) {
|
||||
redirect('/select-org');
|
||||
}
|
||||
|
||||
const { success } = await authorizeRoomotes();
|
||||
|
||||
if (!success) {
|
||||
redirect('/usage?error=roomotes_not_enabled');
|
||||
}
|
||||
|
||||
return (
|
||||
<Jobs
|
||||
userId={
|
||||
authResult.orgRole === 'org:admin' ? undefined : authResult.userId
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
19
apps/web/src/app/(authenticated)/roomote/types.ts
Normal file
19
apps/web/src/app/(authenticated)/roomote/types.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
githubIssueFixSchema,
|
||||
githubIssueCommentSchema,
|
||||
githubPullRequestCommentSchema,
|
||||
slackAppMentionSchema,
|
||||
generalTaskSchema,
|
||||
} from '@roo-code-cloud/db';
|
||||
|
||||
export const formSchema = z.discriminatedUnion('type', [
|
||||
githubIssueFixSchema,
|
||||
githubIssueCommentSchema,
|
||||
githubPullRequestCommentSchema,
|
||||
slackAppMentionSchema,
|
||||
generalTaskSchema,
|
||||
]);
|
||||
|
||||
export type FormData = z.infer<typeof formSchema>;
|
||||
58
apps/web/src/app/(authenticated)/roomote/utils.ts
Normal file
58
apps/web/src/app/(authenticated)/roomote/utils.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import type { CloudJobWithUser } from '@/actions/roomote';
|
||||
|
||||
export function getJobTitle(job: CloudJobWithUser): string {
|
||||
switch (job.type) {
|
||||
case 'github.issue.fix': {
|
||||
const payload = job.payload as {
|
||||
repo: string;
|
||||
issue: number;
|
||||
title: string;
|
||||
body: string;
|
||||
labels?: string[];
|
||||
};
|
||||
|
||||
return `Fix issue #${payload.issue} in ${payload.repo}`;
|
||||
}
|
||||
case 'github.issue.comment.respond': {
|
||||
const payload = job.payload as {
|
||||
repo: string;
|
||||
issueNumber: number;
|
||||
issueTitle: string;
|
||||
issueBody: string;
|
||||
commentId: number;
|
||||
commentBody: string;
|
||||
commentAuthor: string;
|
||||
commentUrl: string;
|
||||
};
|
||||
|
||||
return `Respond to comment in ${payload.repo}#${payload.issueNumber}`;
|
||||
}
|
||||
case 'github.pr.comment.respond': {
|
||||
const payload = job.payload as {
|
||||
repo: string;
|
||||
prNumber: number;
|
||||
prTitle: string;
|
||||
prBody: string;
|
||||
prBranch: string;
|
||||
baseRef: string;
|
||||
commentId: number;
|
||||
commentBody: string;
|
||||
commentAuthor: string;
|
||||
commentType: 'issue_comment' | 'review_comment';
|
||||
commentUrl: string;
|
||||
};
|
||||
|
||||
return `Respond to PR comment in ${payload.repo}#${payload.prNumber}`;
|
||||
}
|
||||
case 'general.task': {
|
||||
const payload = job.payload as {
|
||||
repo: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
return `${payload.repo}: ${payload.description.substring(0, 50)}${payload.description.length > 50 ? '...' : ''}`;
|
||||
}
|
||||
default:
|
||||
return 'Unknown job type';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useUser } from '@clerk/nextjs';
|
||||
import { X, AlertCircle } from 'lucide-react';
|
||||
|
||||
import type { TaskWithUser } from '@/actions/analytics';
|
||||
import { Button } from '@/components/ui';
|
||||
|
|
@ -20,14 +21,20 @@ import { UsageFilters } from './UsageFilters';
|
|||
type UsageProps = {
|
||||
userRole?: 'admin' | 'member';
|
||||
currentUserId?: string | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const Usage = ({ userRole = 'admin', currentUserId }: UsageProps) => {
|
||||
export const Usage = ({
|
||||
userRole = 'admin',
|
||||
currentUserId,
|
||||
error,
|
||||
}: UsageProps) => {
|
||||
const { isSignedIn } = useUser();
|
||||
const t = useTranslations('Analytics');
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('tasks');
|
||||
const [filters, setFilters] = useState<Filter[]>([]);
|
||||
const [task, setTask] = useState<TaskWithUser | null>(null);
|
||||
const [showError, setShowError] = useState(!!error);
|
||||
|
||||
const onAddFilter = useCallback((newFilter: Filter) => {
|
||||
setFilters((currentFilters) => {
|
||||
|
|
@ -40,6 +47,22 @@ export const Usage = ({ userRole = 'admin', currentUserId }: UsageProps) => {
|
|||
setViewMode('tasks');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
const timer = setTimeout(() => setShowError(false), 10_000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
const getErrorMessage = (errorCode: string) => {
|
||||
switch (errorCode) {
|
||||
case 'roomotes_not_enabled':
|
||||
return 'The Roomotes feature is not enabled for your account. Please contact your administrator.';
|
||||
default:
|
||||
return 'An error occurred. Please try again.';
|
||||
}
|
||||
};
|
||||
|
||||
const onRemoveFilter = useCallback((filterToRemove: Filter) => {
|
||||
setFilters((currentFilters) =>
|
||||
currentFilters.filter(
|
||||
|
|
@ -69,6 +92,24 @@ export const Usage = ({ userRole = 'admin', currentUserId }: UsageProps) => {
|
|||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-3 sm:gap-4 lg:gap-6">
|
||||
{showError && error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-red-600 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-red-800 text-sm font-medium">
|
||||
{getErrorMessage(error)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowError(false)}
|
||||
className="text-red-600 hover:text-red-800 p-1 h-auto"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<UsageCard
|
||||
userRole={userRole}
|
||||
currentUserId={currentUserId}
|
||||
|
|
|
|||
|
|
@ -2,15 +2,21 @@ import { authorize } from '@/actions/auth';
|
|||
|
||||
import { Usage } from './Usage';
|
||||
|
||||
export default async function Page() {
|
||||
type PageProps = {
|
||||
searchParams: Promise<{ error?: string }>;
|
||||
};
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
const authResult = await authorize();
|
||||
const orgRole = authResult.success ? authResult.orgRole : null;
|
||||
const userId = authResult.success ? authResult.userId : null;
|
||||
const params = await searchParams;
|
||||
|
||||
return (
|
||||
<Usage
|
||||
userRole={orgRole === 'org:admin' ? 'admin' : 'member'}
|
||||
currentUserId={userId}
|
||||
error={params.error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,13 @@ vi.mock('@/actions/organizationSettings', () => ({
|
|||
getOrganizationSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/server/analytics', () => ({
|
||||
analytics: {
|
||||
query: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/providers', () => ({
|
||||
PROVIDERS: {
|
||||
anthropic: {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
|
||||
import { useRoomotes } from '@/hooks/useRoomotes';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/ecosystem';
|
||||
|
||||
import { Section } from './Section';
|
||||
|
|
@ -20,6 +21,7 @@ const tabValues = [
|
|||
'/providers',
|
||||
'/settings',
|
||||
'/org',
|
||||
'/roomote',
|
||||
'/hidden',
|
||||
] as const;
|
||||
|
||||
|
|
@ -35,6 +37,7 @@ export const NavbarMenu = ({
|
|||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [tabValue, setTabValue] = useState<TabValue | undefined>(undefined);
|
||||
const roomotes = useRoomotes();
|
||||
|
||||
useEffect(() => {
|
||||
setTabValue(isTabValue(pathname) ? pathname : '/hidden');
|
||||
|
|
@ -66,6 +69,9 @@ export const NavbarMenu = ({
|
|||
<TabsTrigger value="/org">Organization</TabsTrigger>
|
||||
</>
|
||||
)}
|
||||
{roomotes.isEnabled && (
|
||||
<TabsTrigger value="/roomote">Roomote</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="/hidden" className="hidden" />
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ function DialogContent({
|
|||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
'max-h-[calc(100vh-2rem)] overflow-y-auto',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export * from './pagination';
|
|||
export * from './PaginationControls';
|
||||
export * from './CursorPaginationControls';
|
||||
export * from './popover';
|
||||
export * from './select';
|
||||
export * from './separator';
|
||||
export * from './skeleton';
|
||||
export * from './slider';
|
||||
|
|
|
|||
185
apps/web/src/components/ui/select.tsx
Normal file
185
apps/web/src/components/ui/select.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = 'default',
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: 'sm' | 'default';
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = 'popper',
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
'flex cursor-default items-center justify-center py-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
'flex cursor-default items-center justify-center py-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
23
apps/web/src/hooks/useRoomotes.ts
Normal file
23
apps/web/src/hooks/useRoomotes.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
'use client';
|
||||
|
||||
import { useOrganization } from '@clerk/nextjs';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { QueryKey } from '@/types';
|
||||
import { isRoomoteEnabled } from '@/lib/roomotes';
|
||||
|
||||
export function useRoomotes() {
|
||||
const { organization, isLoaded } = useOrganization();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: [QueryKey.IsRoomoteEnabled, organization?.id],
|
||||
queryFn: () =>
|
||||
organization?.id ? isRoomoteEnabled(organization.id) : false,
|
||||
enabled: isLoaded && !!organization?.id,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
return {
|
||||
isEnabled: data === true,
|
||||
};
|
||||
}
|
||||
159
apps/web/src/lib/__tests__/roomotes.test.ts
Normal file
159
apps/web/src/lib/__tests__/roomotes.test.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { clerkClient } from '@clerk/nextjs/server';
|
||||
|
||||
import { isRoomoteEnabled, authorizeRoomotes } from '../roomotes';
|
||||
|
||||
// Mock Clerk
|
||||
vi.mock('@clerk/nextjs/server', () => ({
|
||||
clerkClient: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock auth action
|
||||
vi.mock('@/actions/auth', () => ({
|
||||
authorize: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('roomotes', () => {
|
||||
const mockClerkClient = {
|
||||
organizations: {
|
||||
getOrganization: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(clerkClient as any).mockResolvedValue(mockClerkClient);
|
||||
});
|
||||
|
||||
describe('isRoomoteEnabled', () => {
|
||||
it('should return true when roomotes_enabled is true in organization private metadata', async () => {
|
||||
mockClerkClient.organizations.getOrganization.mockResolvedValue({
|
||||
privateMetadata: {
|
||||
roomotes_enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await isRoomoteEnabled('org_123');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when roomotes_enabled is false in organization private metadata', async () => {
|
||||
mockClerkClient.organizations.getOrganization.mockResolvedValue({
|
||||
privateMetadata: {
|
||||
roomotes_enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await isRoomoteEnabled('org_123');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when roomotes_enabled is not set', async () => {
|
||||
mockClerkClient.organizations.getOrganization.mockResolvedValue({
|
||||
privateMetadata: {},
|
||||
});
|
||||
|
||||
const result = await isRoomoteEnabled('org_123');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when privateMetadata is not set', async () => {
|
||||
mockClerkClient.organizations.getOrganization.mockResolvedValue({});
|
||||
|
||||
const result = await isRoomoteEnabled('org_123');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when Clerk API throws an error', async () => {
|
||||
mockClerkClient.organizations.getOrganization.mockRejectedValue(
|
||||
new Error('API Error'),
|
||||
);
|
||||
|
||||
const result = await isRoomoteEnabled('org_123');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('authorizeRoomotes', () => {
|
||||
it('should return success when user is authorized and feature is enabled', async () => {
|
||||
const { authorize } = await import('@/actions/auth');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(authorize as any).mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user_123',
|
||||
orgId: 'org_456',
|
||||
orgRole: 'org:admin',
|
||||
});
|
||||
|
||||
mockClerkClient.organizations.getOrganization.mockResolvedValue({
|
||||
privateMetadata: {
|
||||
roomotes_enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await authorizeRoomotes();
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
userId: 'user_123',
|
||||
orgId: 'org_456',
|
||||
orgRole: 'org:admin',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error when feature is not enabled', async () => {
|
||||
const { authorize } = await import('@/actions/auth');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(authorize as any).mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user_123',
|
||||
orgId: 'org_456',
|
||||
orgRole: 'org:admin',
|
||||
});
|
||||
|
||||
mockClerkClient.organizations.getOrganization.mockResolvedValue({
|
||||
privateMetadata: {
|
||||
roomotes_enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await authorizeRoomotes();
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Roomotes feature is not enabled for your organization',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error when user is not authorized', async () => {
|
||||
const { authorize } = await import('@/actions/auth');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(authorize as any).mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
});
|
||||
|
||||
const result = await authorizeRoomotes();
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error when user has no organization', async () => {
|
||||
const { authorize } = await import('@/actions/auth');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(authorize as any).mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user_123',
|
||||
orgId: null,
|
||||
orgRole: null,
|
||||
});
|
||||
|
||||
const result = await authorizeRoomotes();
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Roomotes feature is only available for organization accounts',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
43
apps/web/src/lib/roomotes.ts
Normal file
43
apps/web/src/lib/roomotes.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
'use server';
|
||||
|
||||
import { clerkClient } from '@clerk/nextjs/server';
|
||||
import { authorize } from '@/actions/auth';
|
||||
|
||||
export async function isRoomoteEnabled(organizationId: string) {
|
||||
try {
|
||||
const client = await clerkClient();
|
||||
|
||||
const organization = await client.organizations.getOrganization({
|
||||
organizationId,
|
||||
});
|
||||
|
||||
return organization.privateMetadata?.roomotes_enabled === true;
|
||||
} catch (error) {
|
||||
console.error('Error checking roomotes feature flag:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function authorizeRoomotes() {
|
||||
const authResult = await authorize();
|
||||
|
||||
if (!authResult.success) {
|
||||
return authResult;
|
||||
}
|
||||
|
||||
const { userId, orgId, orgRole } = authResult;
|
||||
|
||||
if (!orgId) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Roomotes feature is only available for organization accounts',
|
||||
};
|
||||
}
|
||||
|
||||
return (await isRoomoteEnabled(orgId))
|
||||
? { success: true, userId, orgId, orgRole }
|
||||
: {
|
||||
success: false,
|
||||
error: 'Roomotes feature is not enabled for your organization',
|
||||
};
|
||||
}
|
||||
|
|
@ -2,4 +2,7 @@ export enum QueryKey {
|
|||
GetOrganizationSettings = 'GetOrganizationSettings',
|
||||
GetDynamicRouterModels = 'GetDynamicRouterModels',
|
||||
CanShareTask = 'canShareTask',
|
||||
IsRoomoteEnabled = 'isRoomoteEnabled',
|
||||
GetCloudSettings = 'getCloudSettings',
|
||||
FetchRoomoteJobs = 'fetchRoomoteJobs',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
|
||||
#
|
||||
# Deploy with: `fly deploy --config fly.roomote-worker.toml --build-arg GH_TOKEN=$(npx dotenvx get GH_TOKEN -f .env.production)`
|
||||
# Test with: `fly machine run $(fly releases --image -a roomote-worker -j 2>/dev/null | jq -r '.[0].ImageRef') --vm-size performance-16x --restart on-fail --rm --shell --command "pnpm cli:production prompt --text 'Tell me a pirate joke.' --mode code --workspace-path /roo/repos/Roo-Code-Cloud" -a roomote-worker`
|
||||
# Test with: `fly machine run $(fly releases --image -a roomote-worker -j 2>/dev/null | jq -r '.[0].ImageRef') --vm-size performance-16x --restart on-fail --rm --shell -a roomote-worker`
|
||||
|
||||
app = "roomote-worker"
|
||||
primary_region = "sjc" # See `fly platform regions`
|
||||
|
|
|
|||
5
packages/db/drizzle/0003_good_sauron.sql
Normal file
5
packages/db/drizzle/0003_good_sauron.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
DELETE FROM "cloud_jobs";--> statement-breakpoint
|
||||
ALTER TABLE "cloud_jobs" ADD COLUMN "organization_id" text NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "cloud_jobs" ADD CONSTRAINT "cloud_jobs_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "cloud_jobs_user_id_idx" ON "cloud_jobs" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "cloud_jobs_user_org_idx" ON "cloud_jobs" USING btree ("user_id","organization_id");
|
||||
1127
packages/db/drizzle/meta/0003_snapshot.json
Normal file
1127
packages/db/drizzle/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -22,6 +22,13 @@
|
|||
"when": 1751397021767,
|
||||
"tag": "0002_slippery_alex_power",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1751564211996,
|
||||
"tag": "0003_good_sauron",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -247,7 +247,7 @@ export const agentsRelations = relations(agents, ({ one, many }) => ({
|
|||
}));
|
||||
|
||||
/**
|
||||
* agent_request_logs
|
||||
* agentRequestLogs
|
||||
*/
|
||||
|
||||
export const agentRequestLogs = pgTable(
|
||||
|
|
@ -293,22 +293,26 @@ export const agentRequestLogsRelations = relations(
|
|||
* cloudJobs
|
||||
*/
|
||||
|
||||
export const cloudJobs = pgTable('cloud_jobs', {
|
||||
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
|
||||
type: text('type').notNull().$type<JobType>(),
|
||||
status: text('status').notNull().default('pending').$type<JobStatus>(),
|
||||
payload: jsonb('payload').notNull().$type<JobPayload>(),
|
||||
result: jsonb('result'),
|
||||
error: text('error'),
|
||||
slackThreadTs: text('slack_thread_ts'),
|
||||
userId: text('user_id').references(() => users.id),
|
||||
startedAt: timestamp('started_at'),
|
||||
completedAt: timestamp('completed_at'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export type CloudJob = typeof cloudJobs.$inferSelect;
|
||||
|
||||
export type InsertCloudJob = typeof cloudJobs.$inferInsert;
|
||||
|
||||
export type UpdateCloudJob = Partial<Omit<CloudJob, 'id' | 'createdAt'>>;
|
||||
export const cloudJobs = pgTable(
|
||||
'cloud_jobs',
|
||||
{
|
||||
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
|
||||
type: text('type').notNull().$type<JobType>(),
|
||||
orgId: text('organization_id')
|
||||
.notNull()
|
||||
.references(() => orgs.id),
|
||||
userId: text('user_id').references(() => users.id),
|
||||
status: text('status').notNull().default('pending').$type<JobStatus>(),
|
||||
payload: jsonb('payload').notNull().$type<JobPayload>(),
|
||||
result: jsonb('result'),
|
||||
error: text('error'),
|
||||
slackThreadTs: text('slack_thread_ts'),
|
||||
startedAt: timestamp('started_at'),
|
||||
completedAt: timestamp('completed_at'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index('cloud_jobs_user_id_idx').on(table.userId),
|
||||
index('cloud_jobs_user_org_idx').on(table.userId, table.orgId),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -20,9 +20,6 @@ export {
|
|||
agentRequestLogs,
|
||||
agentRequestLogsRelations,
|
||||
cloudJobs,
|
||||
type CloudJob,
|
||||
type InsertCloudJob,
|
||||
type UpdateCloudJob,
|
||||
} from './schema';
|
||||
|
||||
export * from './queries';
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
taskShares,
|
||||
agents,
|
||||
agentRequestLogs,
|
||||
cloudJobs,
|
||||
} from './schema';
|
||||
|
||||
type Generated = 'id' | 'createdAt' | 'updatedAt';
|
||||
|
|
@ -78,67 +79,98 @@ export type CreateRequestLog = Omit<
|
|||
Generated
|
||||
>;
|
||||
|
||||
/**
|
||||
* cloudJobs
|
||||
*/
|
||||
|
||||
export type CloudJob = typeof cloudJobs.$inferSelect;
|
||||
|
||||
export type InsertCloudJob = typeof cloudJobs.$inferInsert;
|
||||
|
||||
export type UpdateCloudJob = Partial<Omit<CloudJob, 'id' | 'createdAt'>>;
|
||||
|
||||
/**
|
||||
* CreateJob
|
||||
*/
|
||||
|
||||
export const githubIssueFixSchema = z.object({
|
||||
type: z.literal('github.issue.fix'),
|
||||
orgId: z.string(),
|
||||
userId: z.string(),
|
||||
payload: z.object({
|
||||
repo: z.string(),
|
||||
issue: z.number(),
|
||||
title: z.string(),
|
||||
body: z.string(),
|
||||
labels: z.array(z.string()).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const githubIssueCommentSchema = z.object({
|
||||
type: z.literal('github.issue.comment.respond'),
|
||||
orgId: z.string(),
|
||||
userId: z.string(),
|
||||
payload: z.object({
|
||||
repo: z.string(),
|
||||
issueNumber: z.number(),
|
||||
issueTitle: z.string(),
|
||||
issueBody: z.string(),
|
||||
commentId: z.number(),
|
||||
commentBody: z.string(),
|
||||
commentAuthor: z.string(),
|
||||
commentUrl: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const githubPullRequestCommentSchema = z.object({
|
||||
type: z.literal('github.pr.comment.respond'),
|
||||
orgId: z.string(),
|
||||
userId: z.string(),
|
||||
payload: z.object({
|
||||
repo: z.string(),
|
||||
prNumber: z.number(),
|
||||
prTitle: z.string(),
|
||||
prBody: z.string(),
|
||||
prBranch: z.string(),
|
||||
baseRef: z.string(),
|
||||
commentId: z.number(),
|
||||
commentBody: z.string(),
|
||||
commentAuthor: z.string(),
|
||||
commentType: z.enum(['issue_comment', 'review_comment']),
|
||||
commentUrl: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const slackAppMentionSchema = z.object({
|
||||
type: z.literal('slack.app.mention'),
|
||||
orgId: z.string(),
|
||||
userId: z.string(),
|
||||
payload: z.object({
|
||||
channel: z.string(),
|
||||
user: z.string(),
|
||||
text: z.string(),
|
||||
ts: z.string(),
|
||||
thread_ts: z.string().optional(),
|
||||
workspace: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const generalTaskSchema = z.object({
|
||||
type: z.literal('general.task'),
|
||||
orgId: z.string(),
|
||||
userId: z.string(),
|
||||
payload: z.object({
|
||||
repo: z.string(),
|
||||
description: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const createJobSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('github.issue.fix'),
|
||||
payload: z.object({
|
||||
repo: z.string(),
|
||||
issue: z.number(),
|
||||
title: z.string(),
|
||||
body: z.string(),
|
||||
labels: z.array(z.string()).optional(),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('github.issue.comment.respond'),
|
||||
payload: z.object({
|
||||
repo: z.string(),
|
||||
issueNumber: z.number(),
|
||||
issueTitle: z.string(),
|
||||
issueBody: z.string(),
|
||||
commentId: z.number(),
|
||||
commentBody: z.string(),
|
||||
commentAuthor: z.string(),
|
||||
commentUrl: z.string(),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('github.pr.comment.respond'),
|
||||
payload: z.object({
|
||||
repo: z.string(),
|
||||
prNumber: z.number(),
|
||||
prTitle: z.string(),
|
||||
prBody: z.string(),
|
||||
prBranch: z.string(),
|
||||
baseRef: z.string(),
|
||||
commentId: z.number(),
|
||||
commentBody: z.string(),
|
||||
commentAuthor: z.string(),
|
||||
commentType: z.enum(['issue_comment', 'review_comment']),
|
||||
commentUrl: z.string(),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('slack.app.mention'),
|
||||
payload: z.object({
|
||||
channel: z.string(),
|
||||
user: z.string(),
|
||||
text: z.string(),
|
||||
ts: z.string(),
|
||||
thread_ts: z.string().optional(),
|
||||
workspace: z.string(),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('test.prompt'),
|
||||
payload: z.object({
|
||||
text: z.string(),
|
||||
}),
|
||||
}),
|
||||
githubIssueFixSchema,
|
||||
githubIssueCommentSchema,
|
||||
githubPullRequestCommentSchema,
|
||||
slackAppMentionSchema,
|
||||
generalTaskSchema,
|
||||
]);
|
||||
|
||||
export type CreateJob = z.infer<typeof createJobSchema>;
|
||||
|
|
@ -160,5 +192,6 @@ export type JobPayload<T extends JobType = JobType> = JobTypes[T];
|
|||
export type JobParams<T extends JobType> = {
|
||||
jobId: number;
|
||||
type: T;
|
||||
orgId: string;
|
||||
payload: JobPayload<T>;
|
||||
};
|
||||
|
|
|
|||
65
pnpm-lock.yaml
generated
65
pnpm-lock.yaml
generated
|
|
@ -57,9 +57,6 @@ importers:
|
|||
bullmq:
|
||||
specifier: ^5.37.0
|
||||
version: 5.54.3
|
||||
cmd-ts:
|
||||
specifier: ^0.13.0
|
||||
version: 0.13.0
|
||||
drizzle-orm:
|
||||
specifier: ^0.44.2
|
||||
version: 0.44.2(@electric-sql/pglite@0.3.0)(@libsql/client-wasm@0.15.5)(@opentelemetry/api@1.9.0)(@types/pg@8.15.2)(pg@8.15.6)(postgres@3.4.7)
|
||||
|
|
@ -185,6 +182,9 @@ importers:
|
|||
'@radix-ui/react-popover':
|
||||
specifier: ^1.1.14
|
||||
version: 1.1.14(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-select':
|
||||
specifier: ^2.2.5
|
||||
version: 2.2.5(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-separator':
|
||||
specifier: ^1.1.7
|
||||
version: 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
|
|
@ -1988,6 +1988,19 @@ packages:
|
|||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-select@2.2.5':
|
||||
resolution: {integrity: sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-separator@1.1.7':
|
||||
resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==}
|
||||
peerDependencies:
|
||||
|
|
@ -3400,9 +3413,6 @@ packages:
|
|||
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
cmd-ts@0.13.0:
|
||||
resolution: {integrity: sha512-nsnxf6wNIM/JAS7T/x/1JmbEsjH0a8tezXqqpaL0O6+eV0/aDEnRxwjxpu0VzDdRcaC1ixGSbRlUuf/IU59I4g==}
|
||||
|
||||
cmdk@1.1.1:
|
||||
resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==}
|
||||
peerDependencies:
|
||||
|
|
@ -3774,9 +3784,6 @@ packages:
|
|||
devlop@1.1.0:
|
||||
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
||||
|
||||
didyoumean@1.2.2:
|
||||
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
||||
|
||||
doctrine@2.1.0:
|
||||
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -8438,6 +8445,35 @@ snapshots:
|
|||
'@types/react': 19.1.6
|
||||
'@types/react-dom': 19.1.6(@types/react@19.1.6)
|
||||
|
||||
'@radix-ui/react-select@2.2.5(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@radix-ui/number': 1.1.1
|
||||
'@radix-ui/primitive': 1.1.2
|
||||
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.1.6)(react@19.1.0)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.6)(react@19.1.0)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-focus-guards': 1.1.2(@types/react@19.1.6)(react@19.1.0)
|
||||
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.1.6)(react@19.1.0)
|
||||
'@radix-ui/react-popper': 1.2.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.6)(react@19.1.0)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.6)(react@19.1.0)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.6)(react@19.1.0)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.6)(react@19.1.0)
|
||||
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.6)(react@19.1.0)
|
||||
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
aria-hidden: 1.2.6
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
react-remove-scroll: 2.7.0(@types/react@19.1.6)(react@19.1.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.6
|
||||
'@types/react-dom': 19.1.6(@types/react@19.1.6)
|
||||
|
||||
'@radix-ui/react-separator@1.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
|
|
@ -10002,15 +10038,6 @@ snapshots:
|
|||
|
||||
cluster-key-slot@1.1.2: {}
|
||||
|
||||
cmd-ts@0.13.0:
|
||||
dependencies:
|
||||
chalk: 4.1.2
|
||||
debug: 4.4.1
|
||||
didyoumean: 1.2.2
|
||||
strip-ansi: 6.0.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
cmdk@1.1.1(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0)
|
||||
|
|
@ -10397,8 +10424,6 @@ snapshots:
|
|||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
didyoumean@1.2.2: {}
|
||||
|
||||
doctrine@2.1.0:
|
||||
dependencies:
|
||||
esutils: 2.0.3
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue