duoops 0.2.7 → 0.2.12
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/dist/commands/init.js +23 -30
- package/dist/commands/mcp/deploy.js +3 -1
- package/dist/lib/gcloud.d.ts +1 -0
- package/dist/lib/gcloud.js +28 -0
- package/mcp-server/src/auth.ts +16 -8
- package/mcp-server/src/index.ts +29 -10
- package/mcp-server/src/lib/bigquery.ts +33 -56
- package/mcp-server/src/lib/types.ts +7 -6
- package/mcp-server/src/tools/emissions.ts +40 -38
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
package/dist/commands/init.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { BigQuery } from '@google-cloud/bigquery';
|
|
2
|
-
import { confirm, input, password, select } from '@inquirer/prompts';
|
|
2
|
+
import { checkbox, confirm, input, password, select } from '@inquirer/prompts';
|
|
3
3
|
import { Command } from '@oclif/core';
|
|
4
4
|
import axios from 'axios';
|
|
5
5
|
import { bold, gray, green, yellow } from 'kleur/colors';
|
|
@@ -310,9 +310,16 @@ export default class Init extends Command {
|
|
|
310
310
|
message: 'GitLab Personal Access Token',
|
|
311
311
|
});
|
|
312
312
|
}
|
|
313
|
-
const
|
|
314
|
-
|
|
313
|
+
const features = await checkbox({
|
|
314
|
+
choices: [
|
|
315
|
+
{ checked: true, name: 'Carbon measurement (BigQuery)', value: 'measure' },
|
|
316
|
+
{ checked: true, name: 'Duo agents and flows', value: 'agents' },
|
|
317
|
+
{ checked: true, name: 'MCP server (Cloud Run)', value: 'mcp' },
|
|
318
|
+
{ checked: true, name: 'CI project wiring (variables + runner)', value: 'ci' },
|
|
319
|
+
],
|
|
320
|
+
message: 'What would you like to set up?',
|
|
315
321
|
});
|
|
322
|
+
const enableMeasure = features.includes('measure');
|
|
316
323
|
let bigqueryDataset;
|
|
317
324
|
let bigqueryTable;
|
|
318
325
|
let googleProjectId;
|
|
@@ -384,11 +391,7 @@ export default class Init extends Command {
|
|
|
384
391
|
};
|
|
385
392
|
configManager.set(config);
|
|
386
393
|
this.log(green('Configuration saved.'));
|
|
387
|
-
|
|
388
|
-
default: true,
|
|
389
|
-
message: 'Set up DuoOps agents and flows in this project?',
|
|
390
|
-
});
|
|
391
|
-
if (setupAgents) {
|
|
394
|
+
if (features.includes('agents')) {
|
|
392
395
|
const { scaffoldProject } = await import('../lib/scaffold.js');
|
|
393
396
|
const result = scaffoldProject(process.cwd());
|
|
394
397
|
for (const f of result.created) {
|
|
@@ -399,30 +402,20 @@ export default class Init extends Command {
|
|
|
399
402
|
}
|
|
400
403
|
}
|
|
401
404
|
this.log(`\nTry '${this.config.bin} pipelines:list <project>' to verify GitLab access.\n`);
|
|
402
|
-
if (
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
'--gitlab-url', gitlabUrl,
|
|
414
|
-
]);
|
|
415
|
-
}
|
|
416
|
-
catch (error) {
|
|
417
|
-
this.warn(`MCP Deployment failed: ${error}`);
|
|
418
|
-
}
|
|
405
|
+
if (features.includes('mcp') && googleProjectId && bigqueryDataset && bigqueryTable) {
|
|
406
|
+
try {
|
|
407
|
+
await McpDeploy.run([
|
|
408
|
+
'--gcp-project', googleProjectId,
|
|
409
|
+
'--bq-dataset', bigqueryDataset,
|
|
410
|
+
'--bq-table', bigqueryTable,
|
|
411
|
+
'--gitlab-url', gitlabUrl,
|
|
412
|
+
]);
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
this.warn(`MCP Deployment failed: ${error}`);
|
|
419
416
|
}
|
|
420
417
|
}
|
|
421
|
-
|
|
422
|
-
default: true,
|
|
423
|
-
message: 'Configure a GitLab project (CI variables + optional GCP runner) now?',
|
|
424
|
-
});
|
|
425
|
-
if (!configureProject) {
|
|
418
|
+
if (!features.includes('ci')) {
|
|
426
419
|
return;
|
|
427
420
|
}
|
|
428
421
|
const updatedDefaultProject = await this.configureGitLabProject({ baseUrl: gitlabUrl, token: gitlabToken }, config.defaultProjectId);
|
|
@@ -6,7 +6,7 @@ import fs from 'node:fs';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { configManager } from '../../lib/config.js';
|
|
9
|
-
import { detectGcpProject, enableApis, ensureCloudBuildServiceAccount, getActiveAccount, requireGcloud, validateProjectAccess } from '../../lib/gcloud.js';
|
|
9
|
+
import { detectGcpProject, enableApis, ensureBigQueryAccess, ensureCloudBuildServiceAccount, getActiveAccount, requireGcloud, validateProjectAccess } from '../../lib/gcloud.js';
|
|
10
10
|
export default class McpDeploy extends Command {
|
|
11
11
|
static description = 'Deploy the DuoOps MCP server to Google Cloud Run';
|
|
12
12
|
static flags = {
|
|
@@ -75,6 +75,8 @@ export default class McpDeploy extends Command {
|
|
|
75
75
|
}
|
|
76
76
|
this.log(gray('\nConfiguring Cloud Build service account...'));
|
|
77
77
|
ensureCloudBuildServiceAccount(gcpProject, (msg) => this.log(gray(msg)));
|
|
78
|
+
this.log(gray('\nConfiguring BigQuery access for MCP server...'));
|
|
79
|
+
ensureBigQueryAccess(gcpProject, (msg) => this.log(gray(msg)));
|
|
78
80
|
const mcpServerDir = flags.source || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../mcp-server');
|
|
79
81
|
if (!fs.existsSync(path.join(mcpServerDir, 'Dockerfile'))) {
|
|
80
82
|
this.error(`MCP server source not found at ${mcpServerDir}. Provide --source flag or run from the DuoOps repo.`);
|
package/dist/lib/gcloud.d.ts
CHANGED
|
@@ -4,4 +4,5 @@ export declare function detectGcpProject(): string | undefined;
|
|
|
4
4
|
export declare function validateProjectAccess(project: string): void;
|
|
5
5
|
export declare function enableApis(project: string, apis: string[], log: (msg: string) => void): void;
|
|
6
6
|
export declare function getProjectNumber(project: string): string | undefined;
|
|
7
|
+
export declare function ensureBigQueryAccess(project: string, log: (msg: string) => void): void;
|
|
7
8
|
export declare function ensureCloudBuildServiceAccount(project: string, log: (msg: string) => void): void;
|
package/dist/lib/gcloud.js
CHANGED
|
@@ -75,6 +75,34 @@ export function getProjectNumber(project) {
|
|
|
75
75
|
return undefined;
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
|
+
export function ensureBigQueryAccess(project, log) {
|
|
79
|
+
const projectNumber = getProjectNumber(project);
|
|
80
|
+
if (!projectNumber) {
|
|
81
|
+
log(' ? Could not determine project number, skipping BigQuery permissions');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const computeSa = `${projectNumber}-compute@developer.gserviceaccount.com`;
|
|
85
|
+
const roles = [
|
|
86
|
+
'roles/bigquery.user',
|
|
87
|
+
'roles/bigquery.dataViewer',
|
|
88
|
+
];
|
|
89
|
+
log(` Granting BigQuery 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`);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
log(` ? ${role} — failed: ${stderr.trim().split('\n')[0]}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
78
106
|
export function ensureCloudBuildServiceAccount(project, log) {
|
|
79
107
|
const projectNumber = getProjectNumber(project);
|
|
80
108
|
if (!projectNumber) {
|
package/mcp-server/src/auth.ts
CHANGED
|
@@ -5,17 +5,25 @@ import {NextFunction, Request, Response} from 'express'
|
|
|
5
5
|
export const GITLAB_URL = process.env.GITLAB_URL || 'https://gitlab.com'
|
|
6
6
|
|
|
7
7
|
export async function validateGitLabToken(token: string): Promise<boolean> {
|
|
8
|
+
try {
|
|
9
|
+
const response = await axios.get(`${GITLAB_URL}/oauth/token/info`, {
|
|
10
|
+
headers: {Authorization: `Bearer ${token}`},
|
|
11
|
+
validateStatus: (status) => status < 500,
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
if (response.status === 200) return true
|
|
15
|
+
} catch {/* ignore */}
|
|
16
|
+
|
|
8
17
|
try {
|
|
9
18
|
const response = await axios.get(`${GITLAB_URL}/api/v4/user`, {
|
|
10
|
-
headers: {
|
|
11
|
-
|
|
12
|
-
},
|
|
19
|
+
headers: {Authorization: `Bearer ${token}`},
|
|
20
|
+
validateStatus: (status) => status < 500,
|
|
13
21
|
})
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
22
|
+
|
|
23
|
+
if (response.status === 200) return true
|
|
24
|
+
} catch {/* ignore */}
|
|
25
|
+
|
|
26
|
+
return false
|
|
19
27
|
}
|
|
20
28
|
|
|
21
29
|
export function authMiddleware(req: Request, res: Response, next: NextFunction) {
|
package/mcp-server/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
2
2
|
import {SSEServerTransport} from '@modelcontextprotocol/sdk/server/sse.js'
|
|
3
|
+
import {StreamableHTTPServerTransport} from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
|
3
4
|
import express from 'express'
|
|
4
5
|
|
|
5
6
|
import {authMiddleware, oauthEndpoints} from './auth.js'
|
|
@@ -12,11 +13,11 @@ async function main() {
|
|
|
12
13
|
version: '1.0.0',
|
|
13
14
|
})
|
|
14
15
|
|
|
15
|
-
// Register tools
|
|
16
16
|
registerEmissionsTools(server)
|
|
17
17
|
registerProfileTools(server)
|
|
18
18
|
|
|
19
19
|
const app = express()
|
|
20
|
+
app.use(express.json())
|
|
20
21
|
|
|
21
22
|
app.get('/health', (_req, res) => {
|
|
22
23
|
res.json({service: 'duoops-mcp', status: 'ok', version: '1.0.0'})
|
|
@@ -24,30 +25,48 @@ async function main() {
|
|
|
24
25
|
|
|
25
26
|
oauthEndpoints(app)
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
let transport: SSEServerTransport
|
|
28
|
+
let sseTransport: SSEServerTransport
|
|
29
29
|
|
|
30
30
|
app.get('/sse', authMiddleware, async (req, res) => {
|
|
31
|
-
|
|
32
|
-
await server.connect(
|
|
31
|
+
sseTransport = new SSEServerTransport('/message', res)
|
|
32
|
+
await server.connect(sseTransport)
|
|
33
33
|
})
|
|
34
34
|
|
|
35
35
|
app.post('/message', authMiddleware, async (req, res) => {
|
|
36
|
-
if (!
|
|
36
|
+
if (!sseTransport) {
|
|
37
37
|
res.sendStatus(400)
|
|
38
38
|
return
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
await
|
|
41
|
+
await sseTransport.handlePostMessage(req, res)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
app.post('/mcp', authMiddleware, async (req, res) => {
|
|
45
|
+
const transport = new StreamableHTTPServerTransport({sessionIdGenerator: undefined})
|
|
46
|
+
res.on('close', () => {
|
|
47
|
+
transport.close()
|
|
48
|
+
})
|
|
49
|
+
await server.connect(transport)
|
|
50
|
+
await transport.handleRequest(req, res)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
app.get('/mcp', authMiddleware, async (req, res) => {
|
|
54
|
+
const transport = new StreamableHTTPServerTransport({sessionIdGenerator: undefined})
|
|
55
|
+
res.on('close', () => {
|
|
56
|
+
transport.close()
|
|
57
|
+
})
|
|
58
|
+
await server.connect(transport)
|
|
59
|
+
await transport.handleRequest(req, res)
|
|
42
60
|
})
|
|
43
61
|
|
|
44
|
-
|
|
45
|
-
|
|
62
|
+
app.delete('/mcp', async (_req, res) => {
|
|
63
|
+
res.status(405).send('Session termination not supported')
|
|
64
|
+
})
|
|
46
65
|
|
|
47
66
|
const port = process.env.PORT || 8080
|
|
48
67
|
app.listen(port, () => {
|
|
49
68
|
console.log(`DuoOps MCP Server running on port ${port}`)
|
|
50
|
-
console.log(`
|
|
69
|
+
console.log(`Transports: SSE at /sse, HTTP Streamable at /mcp`)
|
|
51
70
|
})
|
|
52
71
|
}
|
|
53
72
|
|
|
@@ -23,18 +23,9 @@ export class BigQueryService {
|
|
|
23
23
|
|
|
24
24
|
async getJobEmissions(jobId: number, projectId?: number): Promise<EmissionsRecord | null> {
|
|
25
25
|
const query = `
|
|
26
|
-
SELECT
|
|
27
|
-
job_id,
|
|
28
|
-
pipeline_id,
|
|
29
|
-
gitlab_project_id as project_id,
|
|
30
|
-
machine_type,
|
|
31
|
-
region,
|
|
32
|
-
total_emissions,
|
|
33
|
-
(cpu_energy_kwh + ram_energy_kwh) as total_energy_kwh,
|
|
34
|
-
created_at,
|
|
35
|
-
runtime_seconds
|
|
26
|
+
SELECT *
|
|
36
27
|
FROM \`${this.projectId}.${this.datasetId}.${this.tableId}\`
|
|
37
|
-
WHERE
|
|
28
|
+
WHERE gitlab_job_id = @jobId
|
|
38
29
|
${projectId ? 'AND gitlab_project_id = @projectId' : ''}
|
|
39
30
|
LIMIT 1
|
|
40
31
|
`
|
|
@@ -48,75 +39,61 @@ export class BigQueryService {
|
|
|
48
39
|
return rows.length > 0 ? (rows[0] as EmissionsRecord) : null
|
|
49
40
|
}
|
|
50
41
|
|
|
51
|
-
async
|
|
42
|
+
async getProjectEmissionsSummary(projectId: number, limit: number = 10): Promise<EmissionsRecord[]> {
|
|
43
|
+
const query = `
|
|
44
|
+
SELECT *
|
|
45
|
+
FROM \`${this.projectId}.${this.datasetId}.${this.tableId}\`
|
|
46
|
+
WHERE gitlab_project_id = @projectId
|
|
47
|
+
ORDER BY ingested_at DESC
|
|
48
|
+
LIMIT @limit
|
|
49
|
+
`
|
|
50
|
+
|
|
51
|
+
const options = {
|
|
52
|
+
params: {limit, projectId},
|
|
53
|
+
query,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const [rows] = await this.bigquery.query(options)
|
|
57
|
+
return rows as EmissionsRecord[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async getProjectJobEmissions(projectId: number, limit: number = 20): Promise<{
|
|
52
61
|
jobs: Partial<EmissionsRecord>[];
|
|
53
|
-
pipeline_id: number;
|
|
54
62
|
total_emissions_g: number;
|
|
55
63
|
total_energy_kwh: number;
|
|
56
64
|
}> {
|
|
57
65
|
const query = `
|
|
58
|
-
SELECT
|
|
59
|
-
job_id,
|
|
60
|
-
machine_type,
|
|
61
|
-
total_emissions,
|
|
62
|
-
(cpu_energy_kwh + ram_energy_kwh) as total_energy_kwh,
|
|
63
|
-
runtime_seconds
|
|
66
|
+
SELECT *
|
|
64
67
|
FROM \`${this.projectId}.${this.datasetId}.${this.tableId}\`
|
|
65
|
-
WHERE
|
|
66
|
-
|
|
68
|
+
WHERE gitlab_project_id = @projectId
|
|
69
|
+
ORDER BY ingested_at DESC
|
|
70
|
+
LIMIT @limit
|
|
67
71
|
`
|
|
68
72
|
|
|
69
73
|
const options = {
|
|
70
|
-
params: {
|
|
74
|
+
params: {limit, projectId},
|
|
71
75
|
query,
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
const [rows] = await this.bigquery.query(options)
|
|
75
|
-
|
|
79
|
+
|
|
76
80
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
77
|
-
const totalEmissions = rows.reduce((sum: number, row: any) => sum + (row.
|
|
81
|
+
const totalEmissions = rows.reduce((sum: number, row: any) => sum + (row.total_emissions_g || 0), 0)
|
|
78
82
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
79
|
-
const totalEnergy = rows.reduce((sum: number, row: any) => sum + (row.
|
|
83
|
+
const totalEnergy = rows.reduce((sum: number, row: any) => sum + (row.energy_kwh || 0), 0)
|
|
80
84
|
|
|
81
85
|
return {
|
|
82
86
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
83
87
|
jobs: rows.map((r: any) => ({
|
|
84
|
-
|
|
88
|
+
energy_kwh: r.energy_kwh,
|
|
89
|
+
gitlab_job_id: r.gitlab_job_id,
|
|
90
|
+
gitlab_job_name: r.gitlab_job_name,
|
|
85
91
|
machine_type: r.machine_type,
|
|
86
92
|
runtime_seconds: r.runtime_seconds,
|
|
87
|
-
|
|
88
|
-
total_energy_kwh: r.total_energy_kwh
|
|
93
|
+
total_emissions_g: r.total_emissions_g,
|
|
89
94
|
})),
|
|
90
|
-
pipeline_id: pipelineId,
|
|
91
95
|
total_emissions_g: totalEmissions,
|
|
92
96
|
total_energy_kwh: totalEnergy
|
|
93
97
|
}
|
|
94
98
|
}
|
|
95
|
-
|
|
96
|
-
async getProjectEmissionsSummary(projectId: number, limit: number = 10): Promise<EmissionsRecord[]> {
|
|
97
|
-
const query = `
|
|
98
|
-
SELECT
|
|
99
|
-
job_id,
|
|
100
|
-
pipeline_id,
|
|
101
|
-
gitlab_project_id as project_id,
|
|
102
|
-
machine_type,
|
|
103
|
-
region,
|
|
104
|
-
total_emissions,
|
|
105
|
-
(cpu_energy_kwh + ram_energy_kwh) as total_energy_kwh,
|
|
106
|
-
created_at,
|
|
107
|
-
runtime_seconds
|
|
108
|
-
FROM \`${this.projectId}.${this.datasetId}.${this.tableId}\`
|
|
109
|
-
WHERE gitlab_project_id = @projectId
|
|
110
|
-
ORDER BY created_at DESC
|
|
111
|
-
LIMIT @limit
|
|
112
|
-
`
|
|
113
|
-
|
|
114
|
-
const options = {
|
|
115
|
-
params: {limit, projectId},
|
|
116
|
-
query,
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const [rows] = await this.bigquery.query(options)
|
|
120
|
-
return rows as EmissionsRecord[]
|
|
121
|
-
}
|
|
122
99
|
}
|
|
@@ -54,13 +54,14 @@ export interface CarbonResult {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
export interface EmissionsRecord {
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
energy_kwh: number;
|
|
58
|
+
gitlab_job_id: number;
|
|
59
|
+
gitlab_job_name: string;
|
|
60
|
+
gitlab_project_id: number;
|
|
61
|
+
gitlab_user_name: string;
|
|
62
|
+
ingested_at: string;
|
|
59
63
|
machine_type: string;
|
|
60
|
-
pipeline_id: number;
|
|
61
|
-
project_id: number;
|
|
62
64
|
region: string;
|
|
63
65
|
runtime_seconds: number;
|
|
64
|
-
|
|
65
|
-
total_energy_kwh: number;
|
|
66
|
+
total_emissions_g: number;
|
|
66
67
|
}
|
|
@@ -15,7 +15,7 @@ export function registerEmissionsTools(server: McpServer) {
|
|
|
15
15
|
},
|
|
16
16
|
async ({job_id, project_id}) => {
|
|
17
17
|
const emissions = await bq.getJobEmissions(job_id, project_id)
|
|
18
|
-
|
|
18
|
+
|
|
19
19
|
if (!emissions) {
|
|
20
20
|
return {
|
|
21
21
|
content: [{text: `No emissions data found for job ${job_id}`, type: 'text'}],
|
|
@@ -33,74 +33,76 @@ export function registerEmissionsTools(server: McpServer) {
|
|
|
33
33
|
)
|
|
34
34
|
|
|
35
35
|
server.tool(
|
|
36
|
-
'
|
|
36
|
+
'get_project_emissions_summary',
|
|
37
37
|
{
|
|
38
|
-
|
|
39
|
-
project_id: z.number().
|
|
38
|
+
limit: z.number().optional().default(10).describe('Number of recent jobs to return'),
|
|
39
|
+
project_id: z.number().describe('GitLab Project ID'),
|
|
40
40
|
},
|
|
41
|
-
async ({
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
if (
|
|
41
|
+
async ({limit, project_id}) => {
|
|
42
|
+
const records = await bq.getProjectEmissionsSummary(project_id, limit)
|
|
43
|
+
|
|
44
|
+
if (records.length === 0) {
|
|
45
45
|
return {
|
|
46
|
-
content: [{text: `No emissions
|
|
46
|
+
content: [{text: `No emissions history found for project ${project_id}. The measure CI component may not have run yet.`, type: 'text'}],
|
|
47
47
|
isError: true,
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
const totalEmissions = records.reduce((sum, r) => sum + r.total_emissions_g, 0)
|
|
52
|
+
const avgEmissions = totalEmissions / records.length
|
|
53
|
+
const totalEnergy = records.reduce((sum, r) => sum + r.energy_kwh, 0)
|
|
54
|
+
|
|
51
55
|
const summary = `
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
Total
|
|
55
|
-
|
|
56
|
+
Project ${project_id} Emissions Summary (Last ${records.length} jobs):
|
|
57
|
+
Average per job: ${avgEmissions.toFixed(3)} gCO2e
|
|
58
|
+
Total recent: ${totalEmissions.toFixed(3)} gCO2e
|
|
59
|
+
Total energy: ${totalEnergy.toFixed(4)} kWh
|
|
56
60
|
|
|
57
|
-
|
|
58
|
-
${
|
|
59
|
-
.sort((a, b) => (b.total_emissions || 0) - (a.total_emissions || 0))
|
|
60
|
-
.slice(0, 3)
|
|
61
|
-
.map(j => `- Job ${j.job_id} (${j.machine_type}): ${j.total_emissions?.toFixed(3)} gCO2e`)
|
|
62
|
-
.join('\n')}
|
|
61
|
+
Recent Jobs:
|
|
62
|
+
${records.map(r => `- ${r.ingested_at} | ${r.gitlab_job_name} (${r.machine_type}): ${r.total_emissions_g.toFixed(3)} gCO2e, ${r.energy_kwh.toFixed(4)} kWh, ${r.runtime_seconds.toFixed(0)}s`).join('\n')}
|
|
63
63
|
`
|
|
64
64
|
|
|
65
65
|
return {
|
|
66
|
-
content: [
|
|
67
|
-
{text: summary, type: 'text'},
|
|
68
|
-
{text: JSON.stringify(result, null, 2), type: 'text'} // Full JSON also provided
|
|
69
|
-
]
|
|
66
|
+
content: [{text: summary, type: 'text'}]
|
|
70
67
|
}
|
|
71
68
|
}
|
|
72
69
|
)
|
|
73
70
|
|
|
74
71
|
server.tool(
|
|
75
|
-
'
|
|
72
|
+
'get_project_job_emissions',
|
|
76
73
|
{
|
|
77
|
-
limit: z.number().optional().default(
|
|
74
|
+
limit: z.number().optional().default(20).describe('Number of recent jobs to return'),
|
|
78
75
|
project_id: z.number().describe('GitLab Project ID'),
|
|
79
76
|
},
|
|
80
77
|
async ({limit, project_id}) => {
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
if (
|
|
78
|
+
const result = await bq.getProjectJobEmissions(project_id, limit)
|
|
79
|
+
|
|
80
|
+
if (result.jobs.length === 0) {
|
|
84
81
|
return {
|
|
85
|
-
content: [{text: `No emissions
|
|
82
|
+
content: [{text: `No emissions data found for project ${project_id}`, type: 'text'}],
|
|
86
83
|
isError: true,
|
|
87
84
|
}
|
|
88
85
|
}
|
|
89
86
|
|
|
90
|
-
const totalEmissions = records.reduce((sum, r) => sum + r.total_emissions, 0)
|
|
91
|
-
const avgEmissions = totalEmissions / records.length
|
|
92
|
-
|
|
93
87
|
const summary = `
|
|
94
|
-
Project ${project_id}
|
|
95
|
-
|
|
96
|
-
Total
|
|
88
|
+
Project ${project_id} Carbon Report:
|
|
89
|
+
Total Emissions: ${result.total_emissions_g.toFixed(3)} gCO2e
|
|
90
|
+
Total Energy: ${result.total_energy_kwh.toFixed(4)} kWh
|
|
91
|
+
Jobs Recorded: ${result.jobs.length}
|
|
97
92
|
|
|
98
|
-
|
|
99
|
-
${
|
|
93
|
+
Top Emitters:
|
|
94
|
+
${result.jobs
|
|
95
|
+
.sort((a, b) => (b.total_emissions_g || 0) - (a.total_emissions_g || 0))
|
|
96
|
+
.slice(0, 5)
|
|
97
|
+
.map(j => `- Job ${j.gitlab_job_id} ${j.gitlab_job_name} (${j.machine_type}): ${j.total_emissions_g?.toFixed(3)} gCO2e`)
|
|
98
|
+
.join('\n')}
|
|
100
99
|
`
|
|
101
100
|
|
|
102
101
|
return {
|
|
103
|
-
content: [
|
|
102
|
+
content: [
|
|
103
|
+
{text: summary, type: 'text'},
|
|
104
|
+
{text: JSON.stringify(result, null, 2), type: 'text'}
|
|
105
|
+
]
|
|
104
106
|
}
|
|
105
107
|
}
|
|
106
108
|
)
|
package/oclif.manifest.json
CHANGED