duoops 0.1.7 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +151 -63
- package/data/aws_machine_power_profiles.json +54 -0
- package/data/cpu_physical_specs.json +105 -0
- package/data/cpu_power_profiles.json +275 -0
- package/data/gcp_machine_power_profiles.json +1802 -0
- package/data/runtime-pue-mappings.json +183 -0
- package/dist/commands/ask.d.ts +3 -0
- package/dist/commands/ask.js +9 -3
- package/dist/commands/autofix-ci.d.ts +13 -0
- package/dist/commands/autofix-ci.js +114 -0
- package/dist/commands/autofix.d.ts +5 -0
- package/dist/commands/autofix.js +11 -0
- package/dist/commands/config.d.ts +10 -0
- package/dist/commands/config.js +47 -0
- package/dist/commands/init.js +50 -27
- package/dist/commands/mcp/deploy.d.ts +13 -0
- package/dist/commands/mcp/deploy.js +139 -0
- package/dist/commands/measure/calculate.js +2 -2
- package/dist/commands/portal.js +428 -11
- package/dist/lib/ai/agent.d.ts +6 -2
- package/dist/lib/ai/agent.js +51 -57
- package/dist/lib/ai/tools/editing.js +28 -13
- package/dist/lib/ai/tools/gitlab.d.ts +4 -0
- package/dist/lib/ai/tools/gitlab.js +166 -11
- package/dist/lib/ai/tools/measure.js +7 -3
- package/dist/lib/ai/tools/types.d.ts +3 -0
- package/dist/lib/ai/tools/types.js +1 -0
- package/dist/lib/config.d.ts +10 -0
- package/dist/lib/gcloud.d.ts +7 -0
- package/dist/lib/gcloud.js +105 -0
- package/dist/lib/gitlab/pipelines-service.d.ts +23 -0
- package/dist/lib/gitlab/pipelines-service.js +146 -0
- package/dist/lib/gitlab/runner-service.d.ts +11 -0
- package/dist/lib/gitlab/runner-service.js +15 -0
- package/dist/lib/portal/settings.d.ts +3 -0
- package/dist/lib/portal/settings.js +48 -0
- package/dist/lib/scaffold.d.ts +5 -0
- package/dist/lib/scaffold.js +32 -0
- package/dist/portal/assets/HomeDashboard-DlkwSyKx.js +1 -0
- package/dist/portal/assets/JobDetailsDrawer-7kXXMSH8.js +1 -0
- package/dist/portal/assets/JobsDashboard-D4pNc9TM.js +1 -0
- package/dist/portal/assets/MetricsDashboard-BcgzvzBz.js +1 -0
- package/dist/portal/assets/PipelinesDashboard-BNrSM9GB.js +1 -0
- package/dist/portal/assets/allPaths-CXDKahbk.js +1 -0
- package/dist/portal/assets/allPathsLoader-BF5PAx2c.js +2 -0
- package/dist/portal/assets/cache-YerT0Slh.js +6 -0
- package/dist/portal/assets/core-Cz8f3oSB.js +19 -0
- package/dist/portal/assets/{index-B6bzT1Vv.js → index-B9sNUqEC.js} +1 -1
- package/dist/portal/assets/index-BWa_E8Y7.css +1 -0
- package/dist/portal/assets/index-Bp4RqK05.js +1 -0
- package/dist/portal/assets/index-DW6Qp0d6.js +64 -0
- package/dist/portal/assets/index-Uc4Xhv31.js +1 -0
- package/dist/portal/assets/progressBar-C4SmnGeZ.js +1 -0
- package/dist/portal/assets/splitPathsBySizeLoader-C-T9_API.js +1 -0
- package/dist/portal/index.html +2 -2
- package/oclif.manifest.json +282 -93
- package/package.json +2 -1
- package/templates/.gitlab/duo/flows/duoops.yaml +114 -0
- package/templates/agents/agent.yml +45 -0
- package/templates/duoops-autofix-component.yml +52 -0
- package/templates/flows/flow.yml +283 -0
- package/dist/portal/assets/MetricsDashboard-DIsoz4Sl.js +0 -71
- package/dist/portal/assets/index-BP8FwWqA.css +0 -1
- package/dist/portal/assets/index-DkVG3jel.js +0 -70
package/dist/lib/ai/agent.js
CHANGED
|
@@ -6,6 +6,49 @@ import { editingTools } from './tools/editing.js';
|
|
|
6
6
|
import { fileTools } from './tools/filesystem.js';
|
|
7
7
|
import { gitlabTools } from './tools/gitlab.js';
|
|
8
8
|
import { measureTools } from './tools/measure.js';
|
|
9
|
+
const baseTools = {
|
|
10
|
+
...editingTools,
|
|
11
|
+
...fileTools,
|
|
12
|
+
...gitlabTools,
|
|
13
|
+
...measureTools,
|
|
14
|
+
};
|
|
15
|
+
function createSystemPrompt(hasProjectId) {
|
|
16
|
+
const projectLine = hasProjectId
|
|
17
|
+
? 'The portal already selected the GitLab project for you. All GitLab tools automatically target that project. Never attempt to guess, request, or change the project ID.'
|
|
18
|
+
: 'If a GitLab project has not been configured yet, ask the user to select one before running GitLab tools.';
|
|
19
|
+
return `You are a DevOps assistant.
|
|
20
|
+
${projectLine}
|
|
21
|
+
Before changing CI configs or runner scripts, inspect the DuoOps CLI source (e.g. src/commands/measure/*) and confirm whether the tool already bundles required data under the package's data/ directory; avoid inventing placeholder directories or files when the CLI provides them.
|
|
22
|
+
Use the available tools to answer questions about pipelines, jobs, and sustainability.`;
|
|
23
|
+
}
|
|
24
|
+
function createTools(projectId) {
|
|
25
|
+
return Object.fromEntries(Object.entries(baseTools).map(([name, t]) => [
|
|
26
|
+
name,
|
|
27
|
+
{
|
|
28
|
+
...t,
|
|
29
|
+
async execute(args, options) {
|
|
30
|
+
const toolOptions = {
|
|
31
|
+
...(typeof options === 'object' && options !== null ? options : {}),
|
|
32
|
+
projectId,
|
|
33
|
+
};
|
|
34
|
+
const logArgs = typeof args === 'object' && args !== null
|
|
35
|
+
? Object.fromEntries(Object.entries(args).filter(([key]) => key !== 'projectId' && key !== 'project_id'))
|
|
36
|
+
: args;
|
|
37
|
+
logger.info({ args: logArgs, tool: name }, 'tool call');
|
|
38
|
+
try {
|
|
39
|
+
const toolInstance = t;
|
|
40
|
+
const result = await toolInstance.execute(args, toolOptions);
|
|
41
|
+
logger.info({ result, tool: name }, 'tool result');
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
logger.warn({ error: String(error), tool: name }, 'tool error');
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
]));
|
|
51
|
+
}
|
|
9
52
|
/** Ensure no message has empty text content (Anthropic rejects "text content blocks must be non-empty"). */
|
|
10
53
|
function sanitizeMessages(messages) {
|
|
11
54
|
return messages.map((m) => {
|
|
@@ -38,37 +81,12 @@ function sanitizeMessages(messages) {
|
|
|
38
81
|
return m;
|
|
39
82
|
});
|
|
40
83
|
}
|
|
41
|
-
export const runAgent = async (input) => {
|
|
84
|
+
export const runAgent = async (input, context) => {
|
|
42
85
|
const model = createModel();
|
|
43
86
|
const config = configManager.get();
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const rawTools = {
|
|
48
|
-
...editingTools,
|
|
49
|
-
...fileTools,
|
|
50
|
-
...gitlabTools,
|
|
51
|
-
...measureTools,
|
|
52
|
-
};
|
|
53
|
-
/** Wrap each tool's execute to log invocations and results (for debugging tool round-trips). */
|
|
54
|
-
const tools = Object.fromEntries(Object.entries(rawTools).map(([name, t]) => [
|
|
55
|
-
name,
|
|
56
|
-
{
|
|
57
|
-
...t,
|
|
58
|
-
async execute(args, options) {
|
|
59
|
-
logger.info({ args, tool: name }, 'tool call');
|
|
60
|
-
try {
|
|
61
|
-
const result = await t.execute(args, options);
|
|
62
|
-
logger.info({ result, tool: name }, 'tool result');
|
|
63
|
-
return result;
|
|
64
|
-
}
|
|
65
|
-
catch (error) {
|
|
66
|
-
logger.warn({ error: String(error), tool: name }, 'tool error');
|
|
67
|
-
throw error;
|
|
68
|
-
}
|
|
69
|
-
},
|
|
70
|
-
},
|
|
71
|
-
]));
|
|
87
|
+
const projectId = context?.projectId || config.defaultProjectId;
|
|
88
|
+
const system = createSystemPrompt(Boolean(projectId));
|
|
89
|
+
const tools = createTools(projectId);
|
|
72
90
|
const rawMessages = Array.isArray(input)
|
|
73
91
|
? input
|
|
74
92
|
: [{ content: input.trim() || ' ', role: 'user' }];
|
|
@@ -91,36 +109,12 @@ Use the available tools to answer questions about pipelines, jobs, and sustainab
|
|
|
91
109
|
return result.text ?? '';
|
|
92
110
|
};
|
|
93
111
|
/** Same as runAgent but returns a streamText result for UI streaming (tool steps, etc.). */
|
|
94
|
-
export function streamAgent(messages) {
|
|
112
|
+
export function streamAgent(messages, context) {
|
|
95
113
|
const model = createModel();
|
|
96
114
|
const config = configManager.get();
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const rawTools = {
|
|
101
|
-
...editingTools,
|
|
102
|
-
...fileTools,
|
|
103
|
-
...gitlabTools,
|
|
104
|
-
...measureTools,
|
|
105
|
-
};
|
|
106
|
-
const tools = Object.fromEntries(Object.entries(rawTools).map(([name, t]) => [
|
|
107
|
-
name,
|
|
108
|
-
{
|
|
109
|
-
...t,
|
|
110
|
-
async execute(args, options) {
|
|
111
|
-
logger.info({ args, tool: name }, 'tool call');
|
|
112
|
-
try {
|
|
113
|
-
const result = await t.execute(args, options);
|
|
114
|
-
logger.info({ result, tool: name }, 'tool result');
|
|
115
|
-
return result;
|
|
116
|
-
}
|
|
117
|
-
catch (error) {
|
|
118
|
-
logger.warn({ error: String(error), tool: name }, 'tool error');
|
|
119
|
-
throw error;
|
|
120
|
-
}
|
|
121
|
-
},
|
|
122
|
-
},
|
|
123
|
-
]));
|
|
115
|
+
const projectId = context?.projectId || config.defaultProjectId;
|
|
116
|
+
const system = createSystemPrompt(Boolean(projectId));
|
|
117
|
+
const tools = createTools(projectId);
|
|
124
118
|
const sanitized = sanitizeMessages(messages);
|
|
125
119
|
if (sanitized.length === 0) {
|
|
126
120
|
throw new Error('Please send a non-empty message.');
|
|
@@ -8,34 +8,49 @@ import { z } from 'zod';
|
|
|
8
8
|
import { ensureDuoOpsDir, PATCHES_DIR } from '../../state.js';
|
|
9
9
|
const execAsync = promisify(exec);
|
|
10
10
|
/* eslint-disable camelcase */
|
|
11
|
-
const patchFileSchema = z
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
const patchFileSchema = z
|
|
12
|
+
.object({
|
|
13
|
+
filePath: z.string().describe('Relative path to the file to edit').optional(),
|
|
14
|
+
newString: z.string().describe('The new content to replace with').optional(),
|
|
15
|
+
oldString: z.string().describe('The exact existing content to search for and replace').optional(),
|
|
16
|
+
original_content: z.string().describe('Alias for oldString used by some agents').optional(),
|
|
17
|
+
patched_content: z.string().describe('Alias for newString used by some agents').optional(),
|
|
18
|
+
path: z.string().describe('Alias for filePath used by some agents').optional(),
|
|
19
|
+
})
|
|
20
|
+
.refine((args) => Boolean((args.filePath ?? args.path) &&
|
|
21
|
+
(args.oldString ?? args.original_content) &&
|
|
22
|
+
(args.newString ?? args.patched_content)), {
|
|
23
|
+
message: 'filePath/path, oldString/original_content, and newString/patched_content are required',
|
|
15
24
|
});
|
|
16
25
|
export const editingTools = {
|
|
17
26
|
patch_file: tool({
|
|
18
27
|
description: 'Safely edit a file by creating and applying a reversible patch',
|
|
19
28
|
async execute(args) {
|
|
20
29
|
try {
|
|
21
|
-
const
|
|
30
|
+
const filePath = args.filePath ?? args.path;
|
|
31
|
+
const oldString = args.oldString ?? args.original_content;
|
|
32
|
+
const newString = args.newString ?? args.patched_content;
|
|
33
|
+
if (!filePath || !oldString || !newString) {
|
|
34
|
+
return { error: 'filePath/path, oldString/original_content, and newString/patched_content are required' };
|
|
35
|
+
}
|
|
36
|
+
const fullPath = path.resolve(process.cwd(), filePath);
|
|
22
37
|
if (!fs.existsSync(fullPath)) {
|
|
23
|
-
return { error: `File not found: ${
|
|
38
|
+
return { error: `File not found: ${filePath}` };
|
|
24
39
|
}
|
|
25
40
|
const content = fs.readFileSync(fullPath, 'utf8');
|
|
26
41
|
// Safety check: Ensure oldString exists exactly once to avoid ambiguity
|
|
27
42
|
// Or at least exists.
|
|
28
|
-
if (!content.includes(
|
|
29
|
-
return { error: `Could not find exact match for oldString in ${
|
|
43
|
+
if (!content.includes(oldString)) {
|
|
44
|
+
return { error: `Could not find exact match for oldString in ${filePath}. Please read the file again to ensure accuracy.` };
|
|
30
45
|
}
|
|
31
|
-
const newContent = content.replaceAll(
|
|
46
|
+
const newContent = content.replaceAll(oldString, newString);
|
|
32
47
|
// Generate Unified Diff
|
|
33
48
|
// createPatch(fileName, oldStr, newStr, oldHeader, newHeader)
|
|
34
|
-
const patchContent = createPatch(
|
|
49
|
+
const patchContent = createPatch(filePath, content, newContent);
|
|
35
50
|
// Save patch
|
|
36
51
|
ensureDuoOpsDir();
|
|
37
52
|
const timestamp = new Date().toISOString().replaceAll(/[:.]/g, '-');
|
|
38
|
-
const patchName = `${path.basename(
|
|
53
|
+
const patchName = `${path.basename(filePath)}-${timestamp}.patch`;
|
|
39
54
|
const patchPath = path.join(PATCHES_DIR, patchName);
|
|
40
55
|
fs.writeFileSync(patchPath, patchContent);
|
|
41
56
|
// Apply patch using git apply
|
|
@@ -43,7 +58,7 @@ export const editingTools = {
|
|
|
43
58
|
// --ignore-space-change might be useful, but let's be strict first
|
|
44
59
|
await execAsync(`git apply "${patchPath}"`);
|
|
45
60
|
return {
|
|
46
|
-
message: `Successfully applied patch to ${
|
|
61
|
+
message: `Successfully applied patch to ${filePath}`,
|
|
47
62
|
patchPath,
|
|
48
63
|
status: 'success',
|
|
49
64
|
};
|
|
@@ -58,4 +73,4 @@ export const editingTools = {
|
|
|
58
73
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
59
74
|
}),
|
|
60
75
|
};
|
|
61
|
-
/* eslint-enable camelcase */
|
|
76
|
+
/* eslint-enable camelcase */
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
export declare const gitlabTools: {
|
|
2
2
|
get_job_logs: import("ai").Tool<unknown, unknown>;
|
|
3
|
+
get_pipeline_details: import("ai").Tool<unknown, unknown>;
|
|
4
|
+
get_project: import("ai").Tool<unknown, unknown>;
|
|
5
|
+
list_jobs: import("ai").Tool<unknown, unknown>;
|
|
3
6
|
list_pipelines: import("ai").Tool<unknown, unknown>;
|
|
7
|
+
search_projects: import("ai").Tool<unknown, unknown>;
|
|
4
8
|
};
|
|
@@ -1,25 +1,44 @@
|
|
|
1
1
|
import { generateText, tool } from 'ai';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
+
import { configManager } from '../../config.js';
|
|
3
4
|
import { createGitlabClient } from '../../gitlab/client.js';
|
|
4
5
|
import { createModel } from '../model.js';
|
|
5
6
|
/* eslint-disable camelcase */
|
|
7
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
6
8
|
const getJobLogsSchema = z.object({
|
|
7
9
|
jobId: z.number().describe('GitLab Job ID'),
|
|
8
|
-
projectId: z.string().describe('GitLab Project ID'),
|
|
9
10
|
});
|
|
10
11
|
const listPipelinesSchema = z.object({
|
|
11
12
|
limit: z.number().default(5).optional(),
|
|
12
|
-
|
|
13
|
+
status: z.string().optional().describe('Filter by status (e.g. success, failed, running)'),
|
|
14
|
+
});
|
|
15
|
+
const getPipelineDetailsSchema = z.object({
|
|
16
|
+
pipelineId: z.number().describe('GitLab Pipeline ID'),
|
|
17
|
+
});
|
|
18
|
+
const listJobsSchema = z.object({
|
|
19
|
+
limit: z.number().default(20).optional(),
|
|
20
|
+
pipelineId: z.number().optional().describe('Filter by Pipeline ID'),
|
|
21
|
+
scope: z.enum(['created', 'pending', 'running', 'failed', 'success', 'canceled', 'skipped', 'manual']).optional(),
|
|
22
|
+
});
|
|
23
|
+
const searchProjectsSchema = z.object({
|
|
24
|
+
query: z.string().describe('Search query (project name or path)'),
|
|
25
|
+
});
|
|
26
|
+
const getProjectSchema = z.object({
|
|
27
|
+
projectPathOrId: z.string().optional().describe('GitLab Project Path (group/project) or ID'),
|
|
13
28
|
});
|
|
14
29
|
export const gitlabTools = {
|
|
15
30
|
get_job_logs: tool({
|
|
16
31
|
description: 'Get the logs of a specific job to debug failures. If logs are long, it uses a subagent to summarize them.',
|
|
17
|
-
|
|
18
|
-
async execute(args) {
|
|
32
|
+
async execute(args, options) {
|
|
19
33
|
try {
|
|
34
|
+
const projectId = options?.projectId || configManager.get().defaultProjectId;
|
|
35
|
+
const jobId = args.jobId || args.job_id;
|
|
36
|
+
if (!projectId)
|
|
37
|
+
throw new Error('Project ID is required (argument or default config)');
|
|
38
|
+
if (!jobId)
|
|
39
|
+
throw new Error('Job ID is required');
|
|
20
40
|
const client = createGitlabClient();
|
|
21
|
-
|
|
22
|
-
const trace = await client.Jobs.showTrace(args.projectId, args.jobId);
|
|
41
|
+
const trace = await client.Jobs.showLog(projectId, jobId);
|
|
23
42
|
const logText = typeof trace === 'string' ? trace : JSON.stringify(trace);
|
|
24
43
|
// If logs are short, return directly
|
|
25
44
|
if (logText.length < 5000) {
|
|
@@ -51,15 +70,117 @@ ${content}`
|
|
|
51
70
|
}
|
|
52
71
|
},
|
|
53
72
|
parameters: getJobLogsSchema,
|
|
54
|
-
|
|
73
|
+
}),
|
|
74
|
+
get_pipeline_details: tool({
|
|
75
|
+
description: 'Get detailed information about a specific pipeline, including its jobs and status.',
|
|
76
|
+
async execute(args, options) {
|
|
77
|
+
try {
|
|
78
|
+
const projectId = options?.projectId || configManager.get().defaultProjectId;
|
|
79
|
+
const pipelineId = args.pipelineId || args.pipeline_id;
|
|
80
|
+
if (!projectId)
|
|
81
|
+
throw new Error('Project ID is required (argument or default config)');
|
|
82
|
+
if (!pipelineId)
|
|
83
|
+
throw new Error('Pipeline ID is required');
|
|
84
|
+
const client = createGitlabClient();
|
|
85
|
+
const pipeline = await client.Pipelines.show(projectId, pipelineId);
|
|
86
|
+
const jobs = await client.Jobs.all(projectId, { pipelineId });
|
|
87
|
+
return {
|
|
88
|
+
jobs: jobs.map((j) => ({
|
|
89
|
+
id: j.id,
|
|
90
|
+
name: j.name,
|
|
91
|
+
stage: j.stage,
|
|
92
|
+
status: j.status,
|
|
93
|
+
})),
|
|
94
|
+
pipeline: {
|
|
95
|
+
created_at: pipeline.created_at,
|
|
96
|
+
duration: pipeline.duration,
|
|
97
|
+
id: pipeline.id,
|
|
98
|
+
ref: pipeline.ref,
|
|
99
|
+
status: pipeline.status,
|
|
100
|
+
web_url: pipeline.web_url,
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
return { error: String(error) };
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
parameters: getPipelineDetailsSchema,
|
|
109
|
+
}),
|
|
110
|
+
get_project: tool({
|
|
111
|
+
description: 'Get details of a specific project by its path (e.g. "group/project") or ID to find its numeric ID.',
|
|
112
|
+
async execute(args, options) {
|
|
113
|
+
try {
|
|
114
|
+
const projectPathOrId = options?.projectId || args.projectPathOrId || configManager.get().defaultProjectId;
|
|
115
|
+
if (!projectPathOrId)
|
|
116
|
+
throw new Error('Project Path or ID is required (argument or default config)');
|
|
117
|
+
const client = createGitlabClient();
|
|
118
|
+
const project = await client.Projects.show(projectPathOrId);
|
|
119
|
+
return {
|
|
120
|
+
project: {
|
|
121
|
+
description: project.description,
|
|
122
|
+
id: project.id,
|
|
123
|
+
name: project.name,
|
|
124
|
+
path_with_namespace: project.path_with_namespace,
|
|
125
|
+
web_url: project.web_url,
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
return { error: `Failed to find project "${args.projectPathOrId || options?.projectId || 'unknown'}": ${String(error)}` };
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
parameters: getProjectSchema,
|
|
134
|
+
}),
|
|
135
|
+
list_jobs: tool({
|
|
136
|
+
description: 'List jobs for a project, optionally filtered by pipeline or status scope.',
|
|
137
|
+
async execute(args, options) {
|
|
138
|
+
try {
|
|
139
|
+
const projectId = options?.projectId || configManager.get().defaultProjectId;
|
|
140
|
+
const pipelineId = args.pipelineId || args.pipeline_id;
|
|
141
|
+
if (!projectId)
|
|
142
|
+
throw new Error('Project ID is required (argument or default config)');
|
|
143
|
+
const client = createGitlabClient();
|
|
144
|
+
const queryOptions = {};
|
|
145
|
+
if (typeof args.limit === 'number') {
|
|
146
|
+
queryOptions.perPage = args.limit;
|
|
147
|
+
}
|
|
148
|
+
if (args.scope) {
|
|
149
|
+
queryOptions.scope = args.scope;
|
|
150
|
+
}
|
|
151
|
+
const jobs = pipelineId
|
|
152
|
+
? await client.Jobs.all(projectId, { ...queryOptions, pipelineId })
|
|
153
|
+
: await client.Jobs.all(projectId, queryOptions);
|
|
154
|
+
return {
|
|
155
|
+
jobs: jobs.map((j) => ({
|
|
156
|
+
created_at: j.created_at,
|
|
157
|
+
id: j.id,
|
|
158
|
+
name: j.name,
|
|
159
|
+
pipeline: { id: j.pipeline.id },
|
|
160
|
+
stage: j.stage,
|
|
161
|
+
status: j.status,
|
|
162
|
+
web_url: j.web_url,
|
|
163
|
+
})),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
return { error: String(error) };
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
parameters: listJobsSchema,
|
|
55
171
|
}),
|
|
56
172
|
list_pipelines: tool({
|
|
57
173
|
description: 'List recent pipelines for a project to check status',
|
|
58
|
-
|
|
59
|
-
async execute(args) {
|
|
174
|
+
async execute(args, options) {
|
|
60
175
|
try {
|
|
176
|
+
const projectId = options?.projectId || configManager.get().defaultProjectId;
|
|
177
|
+
if (!projectId)
|
|
178
|
+
throw new Error('Project ID is required (argument or default config)');
|
|
61
179
|
const client = createGitlabClient();
|
|
62
|
-
const
|
|
180
|
+
const queryOptions = { perPage: args.limit };
|
|
181
|
+
if (args.status)
|
|
182
|
+
queryOptions.status = args.status;
|
|
183
|
+
const pipelines = await client.Pipelines.all(projectId, queryOptions);
|
|
63
184
|
return {
|
|
64
185
|
pipelines: pipelines.map((p) => ({
|
|
65
186
|
created_at: p.created_at,
|
|
@@ -75,7 +196,41 @@ ${content}`
|
|
|
75
196
|
}
|
|
76
197
|
},
|
|
77
198
|
parameters: listPipelinesSchema,
|
|
78
|
-
|
|
199
|
+
}),
|
|
200
|
+
search_projects: tool({
|
|
201
|
+
description: 'Search for a GitLab project by name or path to find its ID.',
|
|
202
|
+
async execute(args) {
|
|
203
|
+
try {
|
|
204
|
+
const client = createGitlabClient();
|
|
205
|
+
// search() is good for name but less precise than path.
|
|
206
|
+
// It's often useful for discovering projects.
|
|
207
|
+
// In some GitBeaker versions, this might be Projects.search(query) or Projects.all({search: query})
|
|
208
|
+
// We will try Projects.search(query) first, fallback to all({search}) if fails
|
|
209
|
+
let projects;
|
|
210
|
+
try {
|
|
211
|
+
projects = await client.Projects.search(args.query);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
projects = await client.Projects.all({ search: args.query });
|
|
215
|
+
}
|
|
216
|
+
if (!Array.isArray(projects)) {
|
|
217
|
+
projects = [projects];
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
projects: projects.map((p) => ({
|
|
221
|
+
description: p.description,
|
|
222
|
+
id: p.id,
|
|
223
|
+
name: p.name,
|
|
224
|
+
path_with_namespace: p.path_with_namespace,
|
|
225
|
+
web_url: p.web_url,
|
|
226
|
+
})),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
return { error: String(error) };
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
parameters: searchProjectsSchema,
|
|
79
234
|
}),
|
|
80
235
|
};
|
|
81
236
|
/* eslint-enable camelcase */
|
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
import { tool } from 'ai';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
+
import { configManager } from '../../config.js';
|
|
3
4
|
import { fetchCarbonMetrics } from '../../measure/bigquery-service.js';
|
|
4
5
|
/* eslint-disable camelcase */
|
|
5
6
|
const getCarbonMetricsSchema = z.object({
|
|
6
7
|
limit: z.number().default(10).optional(),
|
|
7
|
-
projectId: z.string().describe('GitLab Project ID (numeric)'),
|
|
8
8
|
});
|
|
9
9
|
export const measureTools = {
|
|
10
10
|
get_carbon_metrics: tool({
|
|
11
11
|
description: 'Query carbon emissions for a project from BigQuery',
|
|
12
12
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
13
|
-
async execute(args) {
|
|
13
|
+
async execute(args, options) {
|
|
14
14
|
try {
|
|
15
|
-
const
|
|
15
|
+
const projectId = options?.projectId || configManager.get().defaultProjectId;
|
|
16
|
+
if (!projectId) {
|
|
17
|
+
return { error: 'Project ID is required. Please select a project in the portal or configure a default project.' };
|
|
18
|
+
}
|
|
19
|
+
const metrics = await fetchCarbonMetrics(projectId, args.limit);
|
|
16
20
|
return { metrics };
|
|
17
21
|
}
|
|
18
22
|
catch (error) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -8,6 +8,9 @@ export interface DuoOpsConfig {
|
|
|
8
8
|
bigqueryTable?: string;
|
|
9
9
|
googleProjectId?: string;
|
|
10
10
|
};
|
|
11
|
+
portal?: {
|
|
12
|
+
budgets?: PortalBudgetConfig[];
|
|
13
|
+
};
|
|
11
14
|
runner?: {
|
|
12
15
|
gcpInstanceId?: string;
|
|
13
16
|
gcpProjectId?: string;
|
|
@@ -16,6 +19,13 @@ export interface DuoOpsConfig {
|
|
|
16
19
|
machineType?: string;
|
|
17
20
|
};
|
|
18
21
|
}
|
|
22
|
+
export interface PortalBudgetConfig {
|
|
23
|
+
id: string;
|
|
24
|
+
limit: number;
|
|
25
|
+
name: string;
|
|
26
|
+
period: string;
|
|
27
|
+
unit: string;
|
|
28
|
+
}
|
|
19
29
|
export declare class ConfigManager {
|
|
20
30
|
private configPath;
|
|
21
31
|
constructor();
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare function requireGcloud(): void;
|
|
2
|
+
export declare function getActiveAccount(): string;
|
|
3
|
+
export declare function detectGcpProject(): string | undefined;
|
|
4
|
+
export declare function validateProjectAccess(project: string): void;
|
|
5
|
+
export declare function enableApis(project: string, apis: string[], log: (msg: string) => void): void;
|
|
6
|
+
export declare function getProjectNumber(project: string): string | undefined;
|
|
7
|
+
export declare function ensureCloudBuildServiceAccount(project: string, log: (msg: string) => void): void;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
export function requireGcloud() {
|
|
3
|
+
try {
|
|
4
|
+
execSync('gcloud --version', { stdio: 'ignore' });
|
|
5
|
+
}
|
|
6
|
+
catch {
|
|
7
|
+
throw new Error('gcloud CLI is not installed or not found in PATH. Install it from https://cloud.google.com/sdk');
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function getActiveAccount() {
|
|
11
|
+
try {
|
|
12
|
+
return execSync('gcloud config get-value account', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim();
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return 'unknown';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function detectGcpProject() {
|
|
19
|
+
try {
|
|
20
|
+
const value = execSync('gcloud config get-value project', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim();
|
|
21
|
+
return value && value !== '(unset)' ? value : undefined;
|
|
22
|
+
}
|
|
23
|
+
catch { }
|
|
24
|
+
}
|
|
25
|
+
export function validateProjectAccess(project) {
|
|
26
|
+
const account = getActiveAccount();
|
|
27
|
+
try {
|
|
28
|
+
execSync(`gcloud projects describe ${project} --format="value(projectId)"`, {
|
|
29
|
+
encoding: 'utf8',
|
|
30
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
const stderr = error.stderr || '';
|
|
35
|
+
if (stderr.includes('does not have permission') || stderr.includes('PERMISSION_DENIED')) {
|
|
36
|
+
throw new Error(`Account "${account}" does not have access to project "${project}".\n\n` +
|
|
37
|
+
`Either:\n` +
|
|
38
|
+
` 1. Switch account: gcloud config set account <account-with-access>\n` +
|
|
39
|
+
` 2. Grant access: Grant Owner/Editor role to ${account} on project ${project}\n` +
|
|
40
|
+
` 3. Use another project: --gcp-project <project-id>`);
|
|
41
|
+
}
|
|
42
|
+
if (stderr.includes('not exist')) {
|
|
43
|
+
throw new Error(`GCP project "${project}" does not exist. Check the project ID.`);
|
|
44
|
+
}
|
|
45
|
+
throw new Error(`Cannot access GCP project "${project}": ${stderr.trim()}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function enableApis(project, apis, log) {
|
|
49
|
+
for (const api of apis) {
|
|
50
|
+
try {
|
|
51
|
+
execSync(`gcloud services enable ${api} --project=${project} --quiet`, {
|
|
52
|
+
encoding: 'utf8',
|
|
53
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
54
|
+
});
|
|
55
|
+
log(` ✓ ${api}`);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
const stderr = error.stderr || '';
|
|
59
|
+
if (stderr.includes('PERMISSION_DENIED') || stderr.includes('AUTH_PERMISSION_DENIED')) {
|
|
60
|
+
log(` ? ${api} — no permission to enable, will attempt to proceed`);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
throw new Error(`Failed to enable ${api}. Enable it manually:\n` +
|
|
64
|
+
` https://console.developers.google.com/apis/api/${api}/overview?project=${project}\n\n` +
|
|
65
|
+
`Then retry.`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export function getProjectNumber(project) {
|
|
71
|
+
try {
|
|
72
|
+
return execSync(`gcloud projects describe ${project} --format="value(projectNumber)"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim() || undefined;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export function ensureCloudBuildServiceAccount(project, log) {
|
|
79
|
+
const projectNumber = getProjectNumber(project);
|
|
80
|
+
if (!projectNumber) {
|
|
81
|
+
log(' ? Could not determine project number, skipping service account setup');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const computeSa = `${projectNumber}-compute@developer.gserviceaccount.com`;
|
|
85
|
+
const roles = [
|
|
86
|
+
'roles/cloudbuild.builds.builder',
|
|
87
|
+
'roles/storage.admin',
|
|
88
|
+
];
|
|
89
|
+
log(` Granting Cloud Build permissions to ${computeSa}...`);
|
|
90
|
+
for (const role of roles) {
|
|
91
|
+
try {
|
|
92
|
+
execSync(`gcloud projects add-iam-policy-binding ${project} --member="serviceAccount:${computeSa}" --role="${role}" --quiet`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
93
|
+
log(` ✓ ${role}`);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
const stderr = error.stderr || '';
|
|
97
|
+
if (stderr.includes('PERMISSION_DENIED')) {
|
|
98
|
+
log(` ? ${role} — no permission to grant, will attempt deploy anyway`);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
log(` ? ${role} — failed: ${stderr.trim().split('\n')[0]}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface PortalPipeline {
|
|
2
|
+
branch: string;
|
|
3
|
+
commit_message?: string;
|
|
4
|
+
created_at: string;
|
|
5
|
+
duration_seconds?: number;
|
|
6
|
+
id: number;
|
|
7
|
+
sha: string;
|
|
8
|
+
status: string;
|
|
9
|
+
triggered_by?: string;
|
|
10
|
+
updated_at?: string;
|
|
11
|
+
web_url?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface PortalPipelinePage {
|
|
14
|
+
hasNextPage: boolean;
|
|
15
|
+
hasPrevPage: boolean;
|
|
16
|
+
page: number;
|
|
17
|
+
perPage: number;
|
|
18
|
+
pipelines: PortalPipeline[];
|
|
19
|
+
}
|
|
20
|
+
export declare function fetchPortalPipelines(projectId: string, options?: {
|
|
21
|
+
limit?: number;
|
|
22
|
+
page?: number;
|
|
23
|
+
}): Promise<PortalPipelinePage>;
|