duoops 0.0.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.
Files changed (79) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +181 -0
  3. package/bin/dev.cmd +3 -0
  4. package/bin/dev.js +7 -0
  5. package/bin/run.cmd +3 -0
  6. package/bin/run.js +8 -0
  7. package/dist/commands/act.d.ts +12 -0
  8. package/dist/commands/act.js +61 -0
  9. package/dist/commands/ask.d.ts +8 -0
  10. package/dist/commands/ask.js +22 -0
  11. package/dist/commands/init.d.ts +5 -0
  12. package/dist/commands/init.js +97 -0
  13. package/dist/commands/job/logs.d.ts +13 -0
  14. package/dist/commands/job/logs.js +26 -0
  15. package/dist/commands/measure/calculate.d.ts +19 -0
  16. package/dist/commands/measure/calculate.js +208 -0
  17. package/dist/commands/measure/component.d.ts +5 -0
  18. package/dist/commands/measure/component.js +23 -0
  19. package/dist/commands/measure/seed.d.ts +5 -0
  20. package/dist/commands/measure/seed.js +62 -0
  21. package/dist/commands/pipelines/list.d.ts +14 -0
  22. package/dist/commands/pipelines/list.js +62 -0
  23. package/dist/commands/pipelines/show.d.ts +13 -0
  24. package/dist/commands/pipelines/show.js +68 -0
  25. package/dist/commands/portal.d.ts +8 -0
  26. package/dist/commands/portal.js +139 -0
  27. package/dist/commands/undo.d.ts +5 -0
  28. package/dist/commands/undo.js +35 -0
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.js +1 -0
  31. package/dist/lib/ai/agent.d.ts +6 -0
  32. package/dist/lib/ai/agent.js +139 -0
  33. package/dist/lib/ai/model.d.ts +2 -0
  34. package/dist/lib/ai/model.js +22 -0
  35. package/dist/lib/ai/tools/editing.d.ts +3 -0
  36. package/dist/lib/ai/tools/editing.js +61 -0
  37. package/dist/lib/ai/tools/filesystem.d.ts +4 -0
  38. package/dist/lib/ai/tools/filesystem.js +44 -0
  39. package/dist/lib/ai/tools/gitlab.d.ts +4 -0
  40. package/dist/lib/ai/tools/gitlab.js +81 -0
  41. package/dist/lib/ai/tools/measure.d.ts +3 -0
  42. package/dist/lib/ai/tools/measure.js +26 -0
  43. package/dist/lib/config.d.ts +18 -0
  44. package/dist/lib/config.js +72 -0
  45. package/dist/lib/gitlab/client.d.ts +6 -0
  46. package/dist/lib/gitlab/client.js +18 -0
  47. package/dist/lib/gitlab/index.d.ts +6 -0
  48. package/dist/lib/gitlab/index.js +49 -0
  49. package/dist/lib/gitlab/provider.d.ts +14 -0
  50. package/dist/lib/gitlab/provider.js +72 -0
  51. package/dist/lib/gitlab/types.d.ts +34 -0
  52. package/dist/lib/gitlab/types.js +5 -0
  53. package/dist/lib/integrations/bigquery-sink.d.ts +12 -0
  54. package/dist/lib/integrations/bigquery-sink.js +47 -0
  55. package/dist/lib/logger.d.ts +2 -0
  56. package/dist/lib/logger.js +11 -0
  57. package/dist/lib/measure/bigquery-service.d.ts +2 -0
  58. package/dist/lib/measure/bigquery-service.js +54 -0
  59. package/dist/lib/measure/carbon-calculator.d.ts +13 -0
  60. package/dist/lib/measure/carbon-calculator.js +125 -0
  61. package/dist/lib/measure/cli-utils.d.ts +2 -0
  62. package/dist/lib/measure/cli-utils.js +107 -0
  63. package/dist/lib/measure/intensity-provider.d.ts +6 -0
  64. package/dist/lib/measure/intensity-provider.js +34 -0
  65. package/dist/lib/measure/power-profile-repository.d.ts +19 -0
  66. package/dist/lib/measure/power-profile-repository.js +129 -0
  67. package/dist/lib/measure/types.d.ts +137 -0
  68. package/dist/lib/measure/types.js +1 -0
  69. package/dist/lib/measure/zone-mapper.d.ts +16 -0
  70. package/dist/lib/measure/zone-mapper.js +104 -0
  71. package/dist/lib/state.d.ts +4 -0
  72. package/dist/lib/state.js +21 -0
  73. package/dist/portal/assets/index-BP8FwWqA.css +1 -0
  74. package/dist/portal/assets/index-MU6EBerh.js +188 -0
  75. package/dist/portal/duoops.svg +4 -0
  76. package/dist/portal/index.html +24 -0
  77. package/dist/portal/vite.svg +1 -0
  78. package/oclif.manifest.json +415 -0
  79. package/package.json +103 -0
@@ -0,0 +1,6 @@
1
+ import type { ModelMessage } from 'ai';
2
+ export declare const runAgent: (input: ModelMessage[] | string) => Promise<string>;
3
+ /** Same as runAgent but returns a streamText result for UI streaming (tool steps, etc.). */
4
+ export declare function streamAgent(messages: ModelMessage[]): {
5
+ toUIMessageStreamResponse(): Response;
6
+ };
@@ -0,0 +1,139 @@
1
+ import { generateText, streamText } from 'ai';
2
+ import { configManager } from '../config.js';
3
+ import { logger } from '../logger.js';
4
+ import { createModel } from './model.js';
5
+ import { editingTools } from './tools/editing.js';
6
+ import { fileTools } from './tools/filesystem.js';
7
+ import { gitlabTools } from './tools/gitlab.js';
8
+ import { measureTools } from './tools/measure.js';
9
+ /** Ensure no message has empty text content (Anthropic rejects "text content blocks must be non-empty"). */
10
+ function sanitizeMessages(messages) {
11
+ return messages.map((m) => {
12
+ if (m.role === 'user') {
13
+ const { content } = m;
14
+ if (typeof content === 'string') {
15
+ return { ...m, content: content.trim() || ' ' };
16
+ }
17
+ // Array content: fix empty text parts in place; leave type as-is for API
18
+ if (Array.isArray(content)) {
19
+ const fixed = content.map((part) => part.type === 'text' && (part.text === '' || part.text === null || part.text === undefined)
20
+ ? { text: ' ', type: 'text' }
21
+ : part);
22
+ return { ...m, content: fixed };
23
+ }
24
+ }
25
+ if (m.role === 'assistant') {
26
+ const msg = m;
27
+ const { content } = msg;
28
+ if (typeof content === 'string' && !content.trim()) {
29
+ return { ...m, content: ' ' };
30
+ }
31
+ if (Array.isArray(content)) {
32
+ const fixed = content.map((part) => part.type === 'text' && (part.text === '' || part.text === null || part.text === undefined)
33
+ ? { text: ' ', type: 'text' }
34
+ : part);
35
+ return { ...m, content: fixed };
36
+ }
37
+ }
38
+ return m;
39
+ });
40
+ }
41
+ export const runAgent = async (input) => {
42
+ const model = createModel();
43
+ const config = configManager.get();
44
+ const system = `You are a DevOps assistant.
45
+ ${config.defaultProjectId ? `The default GitLab Project ID is: ${config.defaultProjectId}. If the user doesn't specify a project, use this one.` : ''}
46
+ Use the available tools to answer questions about pipelines, jobs, and sustainability.`;
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
+ ]));
72
+ const rawMessages = Array.isArray(input)
73
+ ? input
74
+ : [{ content: input.trim() || ' ', role: 'user' }];
75
+ const messages = sanitizeMessages(rawMessages);
76
+ if (messages.length === 0) {
77
+ return 'Please send a non-empty message.';
78
+ }
79
+ const result = await generateText({
80
+ messages,
81
+ model,
82
+ // Stop when the model responds with text (no tool calls) so tool results are fed back
83
+ // until the model is done. Safety cap at 50 steps to avoid runaway loops.
84
+ stopWhen: [
85
+ ({ steps }) => steps.length > 0 && steps.at(-1).toolCalls.length === 0,
86
+ ({ steps }) => steps.length >= 50,
87
+ ],
88
+ system,
89
+ tools,
90
+ });
91
+ return result.text ?? '';
92
+ };
93
+ /** Same as runAgent but returns a streamText result for UI streaming (tool steps, etc.). */
94
+ export function streamAgent(messages) {
95
+ const model = createModel();
96
+ const config = configManager.get();
97
+ const system = `You are a DevOps assistant.
98
+ ${config.defaultProjectId ? `The default GitLab Project ID is: ${config.defaultProjectId}. If the user doesn't specify a project, use this one.` : ''}
99
+ Use the available tools to answer questions about pipelines, jobs, and sustainability.`;
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
+ ]));
124
+ const sanitized = sanitizeMessages(messages);
125
+ if (sanitized.length === 0) {
126
+ throw new Error('Please send a non-empty message.');
127
+ }
128
+ const result = streamText({
129
+ messages: sanitized,
130
+ model,
131
+ stopWhen: [
132
+ ({ steps }) => steps.length > 0 && steps.at(-1).toolCalls.length === 0,
133
+ ({ steps }) => steps.length >= 50,
134
+ ],
135
+ system,
136
+ tools,
137
+ });
138
+ return result;
139
+ }
@@ -0,0 +1,2 @@
1
+ import { LanguageModel } from 'ai';
2
+ export declare const createModel: () => LanguageModel;
@@ -0,0 +1,22 @@
1
+ import { createVertex } from '@ai-sdk/google-vertex';
2
+ import { createGitLab } from '@gitlab/gitlab-ai-provider';
3
+ import { configManager } from '../config.js';
4
+ export const createModel = () => {
5
+ const providerType = process.env.DUOOPS_AI_PROVIDER || 'gitlab';
6
+ const config = configManager.get();
7
+ if (providerType === 'gitlab') {
8
+ // GitLab Duo Provider (Claude 3.5 Sonnet)
9
+ const gitlab = createGitLab({
10
+ apiKey: process.env.GITLAB_TOKEN || config.gitlabToken,
11
+ instanceUrl: config.gitlabUrl || 'https://gitlab.com',
12
+ });
13
+ // Use Agentic Chat model for tool support
14
+ return gitlab.agenticChat('duo-chat-sonnet-4-5');
15
+ }
16
+ // Vertex AI Provider (Gemini 3 Pro)
17
+ const vertex = createVertex({
18
+ location: process.env.GCP_LOCATION || 'us-central1',
19
+ project: process.env.GCP_PROJECT_ID,
20
+ });
21
+ return vertex('gemini-3-pro-preview');
22
+ };
@@ -0,0 +1,3 @@
1
+ export declare const editingTools: {
2
+ patch_file: import("ai").Tool<unknown, unknown>;
3
+ };
@@ -0,0 +1,61 @@
1
+ import { tool } from 'ai';
2
+ import { createPatch } from 'diff';
3
+ import { exec } from 'node:child_process';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { promisify } from 'node:util';
7
+ import { z } from 'zod';
8
+ import { ensureDuoOpsDir, PATCHES_DIR } from '../../state.js';
9
+ const execAsync = promisify(exec);
10
+ /* eslint-disable camelcase */
11
+ const patchFileSchema = z.object({
12
+ filePath: z.string().describe('Relative path to the file to edit'),
13
+ newString: z.string().describe('The new content to replace with'),
14
+ oldString: z.string().describe('The exact existing content to search for and replace'),
15
+ });
16
+ export const editingTools = {
17
+ patch_file: tool({
18
+ description: 'Safely edit a file by creating and applying a reversible patch',
19
+ async execute(args) {
20
+ try {
21
+ const fullPath = path.resolve(process.cwd(), args.filePath);
22
+ if (!fs.existsSync(fullPath)) {
23
+ return { error: `File not found: ${args.filePath}` };
24
+ }
25
+ const content = fs.readFileSync(fullPath, 'utf8');
26
+ // Safety check: Ensure oldString exists exactly once to avoid ambiguity
27
+ // Or at least exists.
28
+ if (!content.includes(args.oldString)) {
29
+ return { error: `Could not find exact match for oldString in ${args.filePath}. Please read the file again to ensure accuracy.` };
30
+ }
31
+ const newContent = content.replaceAll(args.oldString, args.newString);
32
+ // Generate Unified Diff
33
+ // createPatch(fileName, oldStr, newStr, oldHeader, newHeader)
34
+ const patchContent = createPatch(args.filePath, content, newContent);
35
+ // Save patch
36
+ ensureDuoOpsDir();
37
+ const timestamp = new Date().toISOString().replaceAll(/[:.]/g, '-');
38
+ const patchName = `${path.basename(args.filePath)}-${timestamp}.patch`;
39
+ const patchPath = path.join(PATCHES_DIR, patchName);
40
+ fs.writeFileSync(patchPath, patchContent);
41
+ // Apply patch using git apply
42
+ // We use git apply because it's safer and handles whitespace better than 'patch'
43
+ // --ignore-space-change might be useful, but let's be strict first
44
+ await execAsync(`git apply "${patchPath}"`);
45
+ return {
46
+ message: `Successfully applied patch to ${args.filePath}`,
47
+ patchPath,
48
+ status: 'success',
49
+ };
50
+ }
51
+ catch (error) {
52
+ return {
53
+ error: `Failed to apply patch: ${error instanceof Error ? error.message : String(error)}`,
54
+ };
55
+ }
56
+ },
57
+ parameters: patchFileSchema,
58
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
59
+ }),
60
+ };
61
+ /* eslint-enable camelcase */
@@ -0,0 +1,4 @@
1
+ export declare const fileTools: {
2
+ list_files: import("ai").Tool<unknown, unknown>;
3
+ read_file: import("ai").Tool<unknown, unknown>;
4
+ };
@@ -0,0 +1,44 @@
1
+ import { tool } from 'ai';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { z } from 'zod';
5
+ /* eslint-disable camelcase */
6
+ const listFilesSchema = z.object({
7
+ path: z.string().describe('Relative path to directory (default: current directory)').optional(),
8
+ });
9
+ const readFileSchema = z.object({
10
+ path: z.string().describe('Relative path to the file'),
11
+ });
12
+ export const fileTools = {
13
+ list_files: tool({
14
+ description: 'List files in a directory to understand project structure',
15
+ async execute(args) {
16
+ try {
17
+ const targetPath = path.resolve(process.cwd(), args.path || '.');
18
+ const files = await fs.readdir(targetPath);
19
+ return { files: files.slice(0, 50) }; // Limit for context window
20
+ }
21
+ catch (error) {
22
+ return { error: String(error) };
23
+ }
24
+ },
25
+ parameters: listFilesSchema,
26
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
27
+ }),
28
+ read_file: tool({
29
+ description: 'Read the contents of a file',
30
+ async execute(args) {
31
+ try {
32
+ const targetPath = path.resolve(process.cwd(), args.path);
33
+ const content = await fs.readFile(targetPath, 'utf8');
34
+ return { content: content.slice(0, 10_000) }; // Safety limit
35
+ }
36
+ catch (error) {
37
+ return { error: String(error) };
38
+ }
39
+ },
40
+ parameters: readFileSchema,
41
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
42
+ }),
43
+ };
44
+ /* eslint-enable camelcase */
@@ -0,0 +1,4 @@
1
+ export declare const gitlabTools: {
2
+ get_job_logs: import("ai").Tool<unknown, unknown>;
3
+ list_pipelines: import("ai").Tool<unknown, unknown>;
4
+ };
@@ -0,0 +1,81 @@
1
+ import { generateText, tool } from 'ai';
2
+ import { z } from 'zod';
3
+ import { createGitlabClient } from '../../gitlab/client.js';
4
+ import { createModel } from '../model.js';
5
+ /* eslint-disable camelcase */
6
+ const getJobLogsSchema = z.object({
7
+ jobId: z.number().describe('GitLab Job ID'),
8
+ projectId: z.string().describe('GitLab Project ID'),
9
+ });
10
+ const listPipelinesSchema = z.object({
11
+ limit: z.number().default(5).optional(),
12
+ projectId: z.string().describe('GitLab Project ID'),
13
+ });
14
+ export const gitlabTools = {
15
+ get_job_logs: tool({
16
+ description: 'Get the logs of a specific job to debug failures. If logs are long, it uses a subagent to summarize them.',
17
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
18
+ async execute(args) {
19
+ try {
20
+ const client = createGitlabClient();
21
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
22
+ const trace = await client.Jobs.showTrace(args.projectId, args.jobId);
23
+ const logText = typeof trace === 'string' ? trace : JSON.stringify(trace);
24
+ // If logs are short, return directly
25
+ if (logText.length < 5000) {
26
+ return { logs: logText };
27
+ }
28
+ // Subagent Strategy: Summarize heavy logs
29
+ // We take the last 50,000 characters which usually contain the failure context
30
+ const content = logText.slice(-50_000);
31
+ const model = createModel();
32
+ const { text: summary } = await generateText({
33
+ model,
34
+ prompt: `You are an expert CI/CD engineer. Analyze the following job logs and provide a concise summary.
35
+
36
+ Focus on:
37
+ 1. The root cause of the failure.
38
+ 2. The specific error message.
39
+ 3. Suggested fix.
40
+
41
+ LOGS (truncated to last 50k chars):
42
+ ${content}`
43
+ });
44
+ return {
45
+ note: 'Logs were summarized by a subagent due to length.',
46
+ summary
47
+ };
48
+ }
49
+ catch (error) {
50
+ return { error: String(error) };
51
+ }
52
+ },
53
+ parameters: getJobLogsSchema,
54
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
55
+ }),
56
+ list_pipelines: tool({
57
+ description: 'List recent pipelines for a project to check status',
58
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
59
+ async execute(args) {
60
+ try {
61
+ const client = createGitlabClient();
62
+ const pipelines = await client.Pipelines.all(args.projectId, { perPage: args.limit });
63
+ return {
64
+ pipelines: pipelines.map((p) => ({
65
+ created_at: p.created_at,
66
+ id: p.id,
67
+ ref: p.ref,
68
+ status: p.status,
69
+ web_url: p.web_url,
70
+ })),
71
+ };
72
+ }
73
+ catch (error) {
74
+ return { error: String(error) };
75
+ }
76
+ },
77
+ parameters: listPipelinesSchema,
78
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
79
+ }),
80
+ };
81
+ /* eslint-enable camelcase */
@@ -0,0 +1,3 @@
1
+ export declare const measureTools: {
2
+ get_carbon_metrics: import("ai").Tool<unknown, unknown>;
3
+ };
@@ -0,0 +1,26 @@
1
+ import { tool } from 'ai';
2
+ import { z } from 'zod';
3
+ import { fetchCarbonMetrics } from '../../measure/bigquery-service.js';
4
+ /* eslint-disable camelcase */
5
+ const getCarbonMetricsSchema = z.object({
6
+ limit: z.number().default(10).optional(),
7
+ projectId: z.string().describe('GitLab Project ID (numeric)'),
8
+ });
9
+ export const measureTools = {
10
+ get_carbon_metrics: tool({
11
+ description: 'Query carbon emissions for a project from BigQuery',
12
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
13
+ async execute(args) {
14
+ try {
15
+ const metrics = await fetchCarbonMetrics(args.projectId, args.limit);
16
+ return { metrics };
17
+ }
18
+ catch (error) {
19
+ return { error: String(error) };
20
+ }
21
+ },
22
+ parameters: getCarbonMetricsSchema,
23
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
24
+ }),
25
+ };
26
+ /* eslint-enable camelcase */
@@ -0,0 +1,18 @@
1
+ export interface DuoOpsConfig {
2
+ defaultProjectId?: string;
3
+ gitlabToken?: string;
4
+ gitlabUrl?: string;
5
+ measure?: {
6
+ bigqueryDataset?: string;
7
+ bigqueryTable?: string;
8
+ googleProjectId?: string;
9
+ };
10
+ }
11
+ export declare class ConfigManager {
12
+ private configPath;
13
+ constructor();
14
+ get(): DuoOpsConfig;
15
+ set(config: DuoOpsConfig): void;
16
+ private getConfigPath;
17
+ }
18
+ export declare const configManager: ConfigManager;
@@ -0,0 +1,72 @@
1
+ import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import path from 'node:path';
4
+ export class ConfigManager {
5
+ configPath;
6
+ constructor() {
7
+ this.configPath = this.getConfigPath();
8
+ }
9
+ get() {
10
+ const config = {};
11
+ if (fs.existsSync(this.configPath)) {
12
+ try {
13
+ const content = fs.readFileSync(this.configPath, 'utf8');
14
+ Object.assign(config, JSON.parse(content));
15
+ }
16
+ catch {
17
+ // Ignore parsing errors, return defaults/env
18
+ }
19
+ }
20
+ // Merge with environment variables (Env vars take precedence)
21
+ if (process.env.GITLAB_TOKEN || process.env.GL_TOKEN) {
22
+ config.gitlabToken = process.env.GITLAB_TOKEN || process.env.GL_TOKEN;
23
+ }
24
+ if (process.env.GITLAB_URL) {
25
+ config.gitlabUrl = process.env.GITLAB_URL;
26
+ }
27
+ if (process.env.DUOOPS_TEST_PROJECT_ID) {
28
+ config.defaultProjectId = process.env.DUOOPS_TEST_PROJECT_ID;
29
+ }
30
+ if (process.env.DUOOPS_BQ_DATASET || process.env.DUOOPS_BQ_TABLE || process.env.GCP_PROJECT_ID) {
31
+ config.measure = {
32
+ ...config.measure,
33
+ bigqueryDataset: process.env.DUOOPS_BQ_DATASET || config.measure?.bigqueryDataset,
34
+ bigqueryTable: process.env.DUOOPS_BQ_TABLE || config.measure?.bigqueryTable,
35
+ googleProjectId: process.env.GCP_PROJECT_ID || config.measure?.googleProjectId,
36
+ };
37
+ }
38
+ return config;
39
+ }
40
+ set(config) {
41
+ const dir = path.dirname(this.configPath);
42
+ if (!fs.existsSync(dir)) {
43
+ fs.mkdirSync(dir, { recursive: true });
44
+ }
45
+ // When saving, we don't want to save env vars back to file necessarily,
46
+ // but typically `set` is called with explicit new values from `init`.
47
+ // So we just write what is passed.
48
+ fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2), {
49
+ mode: 0o600, // Read/write only by owner
50
+ });
51
+ }
52
+ getConfigPath() {
53
+ const homedir = os.homedir();
54
+ // Check for XDG_CONFIG_HOME
55
+ if (process.env.XDG_CONFIG_HOME) {
56
+ return path.join(process.env.XDG_CONFIG_HOME, 'duoops', 'config.json');
57
+ }
58
+ // Platform specific defaults
59
+ switch (process.platform) {
60
+ case 'darwin': {
61
+ return path.join(homedir, 'Library', 'Preferences', 'duoops', 'config.json');
62
+ }
63
+ case 'win32': {
64
+ return path.join(process.env.APPDATA || path.join(homedir, 'AppData', 'Roaming'), 'duoops', 'config.json');
65
+ }
66
+ default: { // linux, freebsd, openbsd, sunos, aix, android
67
+ return path.join(homedir, '.config', 'duoops', 'config.json');
68
+ }
69
+ }
70
+ }
71
+ }
72
+ export const configManager = new ConfigManager();
@@ -0,0 +1,6 @@
1
+ import { Gitlab } from '@gitbeaker/rest';
2
+ /**
3
+ * Creates a GitLab API client from environment variables or configuration.
4
+ * @throws Error if GITLAB_TOKEN or GL_TOKEN is not set
5
+ */
6
+ export declare function createGitlabClient(): InstanceType<typeof Gitlab>;
@@ -0,0 +1,18 @@
1
+ import { Gitlab } from '@gitbeaker/rest';
2
+ import { configManager } from '../config.js';
3
+ /**
4
+ * Creates a GitLab API client from environment variables or configuration.
5
+ * @throws Error if GITLAB_TOKEN or GL_TOKEN is not set
6
+ */
7
+ export function createGitlabClient() {
8
+ const config = configManager.get();
9
+ const token = process.env.GITLAB_TOKEN || process.env.GL_TOKEN || config.gitlabToken;
10
+ const host = process.env.GITLAB_HOST || config.gitlabUrl || 'https://gitlab.com';
11
+ if (!token) {
12
+ throw new Error('GITLAB_TOKEN or GL_TOKEN environment variable is required, or run "duoops init" to configure it.');
13
+ }
14
+ return new Gitlab({
15
+ host,
16
+ token,
17
+ });
18
+ }
@@ -0,0 +1,6 @@
1
+ import type { PipelineProvider } from './types.js';
2
+ export { createGitlabClient } from './client.js';
3
+ export { GitLabPipelineProvider } from './provider.js';
4
+ export type { Job, ListOptions, Pipeline, PipelineProvider } from './types.js';
5
+ export declare function setPipelineProvider(provider: null | PipelineProvider): void;
6
+ export declare function getPipelineProvider(): PipelineProvider;
@@ -0,0 +1,49 @@
1
+ import { GitLabPipelineProvider } from './provider.js';
2
+ export { createGitlabClient } from './client.js';
3
+ export { GitLabPipelineProvider } from './provider.js';
4
+ /**
5
+ * Returns a PipelineProvider. In production, returns the real GitLab provider.
6
+ * Call setPipelineProvider in tests to inject a mock.
7
+ * When DUOOPS_USE_MOCK=1, returns a built-in mock with fixture data (no HTTP).
8
+ */
9
+ let providerOverride = null;
10
+ export function setPipelineProvider(provider) {
11
+ providerOverride = provider;
12
+ }
13
+ export function getPipelineProvider() {
14
+ if (providerOverride) {
15
+ return providerOverride;
16
+ }
17
+ if (process.env.DUOOPS_USE_MOCK === '1') {
18
+ return new MockPipelineProvider();
19
+ }
20
+ return new GitLabPipelineProvider();
21
+ }
22
+ /** Mock provider for tests. Returns fixture data without making HTTP requests. */
23
+ class MockPipelineProvider {
24
+ async getJobTrace() {
25
+ return 'Running with mock data...\n$ npm test\n✓ tests passed\n';
26
+ }
27
+ async getPipeline(_projectId, pipelineId) {
28
+ return {
29
+ createdAt: '2024-01-15T10:00:00Z',
30
+ duration: 120,
31
+ id: pipelineId,
32
+ ref: 'main',
33
+ sha: 'abc12345',
34
+ status: 'success',
35
+ };
36
+ }
37
+ async listJobs() {
38
+ return [
39
+ { duration: 30, id: 101, name: 'build', stage: 'build', status: 'success' },
40
+ { duration: 45, id: 102, name: 'test', stage: 'test', status: 'success' },
41
+ ];
42
+ }
43
+ async listPipelines() {
44
+ return [
45
+ { createdAt: '2024-01-15T10:00:00Z', id: 1, ref: 'main', sha: 'abc12345', status: 'success' },
46
+ { createdAt: '2024-01-14T09:00:00Z', id: 2, ref: 'main', sha: 'def67890', status: 'failed' },
47
+ ];
48
+ }
49
+ }
@@ -0,0 +1,14 @@
1
+ import type { Job, ListOptions, Pipeline, PipelineProvider } from './types.js';
2
+ import { createGitlabClient } from './client.js';
3
+ /**
4
+ * GitLab implementation of PipelineProvider.
5
+ * Wraps @gitbeaker/rest and normalizes responses to our domain types.
6
+ */
7
+ export declare class GitLabPipelineProvider implements PipelineProvider {
8
+ #private;
9
+ constructor(client?: ReturnType<typeof createGitlabClient>);
10
+ getJobTrace(projectId: string, jobId: number): Promise<string>;
11
+ getPipeline(projectId: string, pipelineId: number): Promise<Pipeline>;
12
+ listJobs(projectId: string, pipelineId: number): Promise<Job[]>;
13
+ listPipelines(projectId: string, options?: ListOptions): Promise<Pipeline[]>;
14
+ }