@hellopearl/dv-gitlab 0.1.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.
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ import { run } from '../src/cli.mjs';
5
+
6
+ run(process.argv.slice(2));
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@hellopearl/dv-gitlab",
3
+ "version": "0.1.0",
4
+ "description": "Unified GitLab CI tooling -- MR comments, pipeline triggers, preview env management",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "main": "src/index.mjs",
8
+ "exports": {
9
+ ".": "./src/index.mjs"
10
+ },
11
+ "bin": "bin/dv-gitlab.mjs",
12
+ "files": [
13
+ "bin",
14
+ "src"
15
+ ],
16
+ "scripts": {
17
+ "deps": "dv-deps-runner",
18
+ "format": "dv-prettier-runner",
19
+ "format:check": "dv-prettier-runner --check",
20
+ "lint": "dv-lint-runner --server",
21
+ "test:unit": "dv-test-runner --type=unit",
22
+ "test:unit:cover": "dv-test-runner --type=unit --coverage"
23
+ },
24
+ "jest": {
25
+ "coverageThreshold": {
26
+ "global": {
27
+ "statements": 80,
28
+ "branches": 80,
29
+ "functions": 80,
30
+ "lines": 80
31
+ }
32
+ }
33
+ },
34
+ "engines": {
35
+ "node": ">=22",
36
+ "yarn": ">=4.14.1"
37
+ },
38
+ "publishConfig": {
39
+ "registry": "https://registry.npmjs.org/"
40
+ },
41
+ "devDependencies": {
42
+ "@hellopearl/dv-deps": "*",
43
+ "@hellopearl/dv-lint": "*",
44
+ "@hellopearl/dv-prettier": "*",
45
+ "@hellopearl/dv-test": "*"
46
+ },
47
+ "gitHead": "2da7b9fc23900bbaf9c4fcf6042ecec76d538bc2"
48
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,84 @@
1
+ import { cleanupBranch } from './commands/cleanup-branch.mjs';
2
+ import { postComment } from './commands/post-comment.mjs';
3
+ import { postbuild } from './commands/postbuild.mjs';
4
+ import { triggerPipeline } from './commands/trigger-pipeline.mjs';
5
+ import { validateToken } from './commands/validate-token.mjs';
6
+ import { log } from './lib/logger.mjs';
7
+
8
+ const COMMANDS = {
9
+ 'cleanup-branch': cleanupBranch,
10
+ 'post-comment': postComment,
11
+ postbuild,
12
+ 'trigger-pipeline': triggerPipeline,
13
+ 'validate-token': validateToken,
14
+ };
15
+
16
+ /**
17
+ * @param {string[]} args
18
+ * @returns {Record<string, string>}
19
+ */
20
+ function parseFlags(args) {
21
+ const flags = {};
22
+ for (let i = 0; i < args.length; i++) {
23
+ const arg = args[i];
24
+ if (arg.startsWith('--')) {
25
+ const key = arg.slice(2);
26
+ const next = args[i + 1];
27
+ if (next && !next.startsWith('--')) {
28
+ flags[key] = next;
29
+ i++;
30
+ } else {
31
+ flags[key] = 'true';
32
+ }
33
+ }
34
+ }
35
+ return flags;
36
+ }
37
+
38
+ function printUsage() {
39
+ log(`
40
+ dv-gitlab -- Unified GitLab CI tooling
41
+
42
+ Commands:
43
+ postbuild Amplify post-build orchestrator (branch routing + comment + trigger)
44
+ post-comment Post markdown note on a GitLab MR
45
+ trigger-pipeline Trigger a GitLab CI pipeline
46
+ validate-token Check if a GitLab PAT is valid
47
+ cleanup-branch Delete an Amplify preview branch
48
+
49
+ Options:
50
+ --help Show this help message
51
+
52
+ All commands read configuration from environment variables.
53
+ Use --<flag> <value> to override for local testing.
54
+ `);
55
+ }
56
+
57
+ /**
58
+ * @param {string[]} argv
59
+ */
60
+ export async function run(argv) {
61
+ const [command, ...args] = argv;
62
+
63
+ if (!command || command === '--help' || command === '-h') {
64
+ printUsage();
65
+ process.exit(0);
66
+ }
67
+
68
+ const handler = COMMANDS[command];
69
+ if (!handler) {
70
+ log(`Unknown command: ${command}`);
71
+ printUsage();
72
+ process.exit(1);
73
+ }
74
+
75
+ try {
76
+ await handler(parseFlags(args));
77
+ } catch (err) {
78
+ log(`[error] ${err.message}`);
79
+ if (process.env.DV_GITLAB_DEBUG === 'true') {
80
+ console.error(err.stack);
81
+ }
82
+ process.exit(1);
83
+ }
84
+ }
@@ -0,0 +1,40 @@
1
+ import { AmplifyClient } from '../lib/amplify-client.mjs';
2
+ import { debug, log } from '../lib/logger.mjs';
3
+
4
+ /**
5
+ * Deletes an Amplify preview branch environment.
6
+ * Intended for use in a GitLab CI .post stage on MR merge.
7
+ * @param {Record<string, string>} flags
8
+ */
9
+ export async function cleanupBranch(flags) {
10
+ const branch =
11
+ flags.branch ||
12
+ process.env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME ||
13
+ process.env.AWS_BRANCH ||
14
+ '';
15
+ const appId = flags['app-id'] || process.env.AWS_APP_ID || '';
16
+ const region = flags.region || process.env.AWS_DEFAULT_REGION || 'us-east-1';
17
+
18
+ if (!branch) {
19
+ throw new Error(
20
+ 'No branch specified. Set CI_MERGE_REQUEST_SOURCE_BRANCH_NAME or pass --branch',
21
+ );
22
+ }
23
+ if (!appId) {
24
+ throw new Error('No Amplify app ID. Set AWS_APP_ID or pass --app-id');
25
+ }
26
+
27
+ debug(`cleanup-branch branch=${branch} appId=${appId} region=${region}`);
28
+
29
+ const amplifyBranch = branch.toLowerCase().replace(/\//g, '-');
30
+ log(`[cleanup] Deleting Amplify branch: ${amplifyBranch} (app=${appId})`);
31
+
32
+ const client = new AmplifyClient({ appId, region });
33
+ const ok = await client.deleteBranch(amplifyBranch);
34
+
35
+ if (ok) {
36
+ log(`[cleanup] Branch ${amplifyBranch} removed successfully`);
37
+ } else {
38
+ throw new Error(`Failed to delete Amplify branch: ${amplifyBranch}`);
39
+ }
40
+ }
@@ -0,0 +1,85 @@
1
+ import { GitLabClient } from '../lib/gitlab-client.mjs';
2
+ import { debug, log } from '../lib/logger.mjs';
3
+ import { renderTemplate } from '../lib/template.mjs';
4
+
5
+ /**
6
+ * @param {string} str
7
+ * @returns {string}
8
+ */
9
+ function camelCase(str) {
10
+ return str.toLowerCase().replace(/_([a-z])/g, (_, c) => c.toUpperCase());
11
+ }
12
+
13
+ /**
14
+ * Post a markdown note on a GitLab MR.
15
+ * Supports either --body for raw markdown or --template for template rendering.
16
+ * @param {Record<string, string>} flags
17
+ */
18
+ export async function postComment(flags) {
19
+ const token =
20
+ flags.token ||
21
+ process.env.GITLAB_API_TOKEN ||
22
+ process.env.GITLAB_SO_API_TOKEN ||
23
+ '';
24
+ const projectId =
25
+ flags['project-id'] ||
26
+ process.env.GITLAB_PROJECT_ID ||
27
+ process.env.GITLAB_SECOND_OPINION_PROJECT_ID ||
28
+ '';
29
+ const mrIid = flags['mr-iid'] || '';
30
+ const branch = flags.branch || process.env.AWS_BRANCH || '';
31
+
32
+ if (!token) {
33
+ throw new Error('No GitLab token. Set GITLAB_API_TOKEN or pass --token');
34
+ }
35
+ if (!projectId) {
36
+ throw new Error(
37
+ 'No project ID. Set GITLAB_PROJECT_ID or pass --project-id',
38
+ );
39
+ }
40
+
41
+ const client = new GitLabClient({ token });
42
+ let iid = mrIid ? Number(mrIid) : null;
43
+
44
+ if (!iid && branch) {
45
+ debug(`Looking up open MR for branch=${branch}`);
46
+ const mr = await client.findOpenMr(projectId, branch);
47
+ if (!mr) {
48
+ throw new Error(`No open MR found for branch: ${branch}`);
49
+ }
50
+ iid = mr.iid;
51
+ }
52
+
53
+ if (!iid) {
54
+ throw new Error('No MR IID. Pass --mr-iid or --branch to auto-detect');
55
+ }
56
+
57
+ let body = flags.body || '';
58
+
59
+ if (!body && flags.template) {
60
+ const vars = {};
61
+ for (const [key, value] of Object.entries(flags)) {
62
+ if (key !== 'template' && key !== 'token' && key !== 'project-id') {
63
+ vars[key] = value;
64
+ }
65
+ }
66
+ for (const [key, value] of Object.entries(process.env)) {
67
+ const lower = key.toLowerCase().replace(/_/g, '-');
68
+ if (!vars[lower]) {
69
+ vars[camelCase(key)] = value || '';
70
+ }
71
+ }
72
+ body = renderTemplate(flags.template, vars);
73
+ }
74
+
75
+ if (!body) {
76
+ throw new Error('No comment body. Pass --body "..." or --template <name>');
77
+ }
78
+
79
+ const ok = await client.postNote(projectId, iid, body);
80
+ if (ok) {
81
+ log(`Comment posted on MR !${iid}`);
82
+ } else {
83
+ throw new Error(`Failed to post comment on MR !${iid}`);
84
+ }
85
+ }
@@ -0,0 +1,253 @@
1
+ import { GitLabClient } from '../lib/gitlab-client.mjs';
2
+ import { debug, log } from '../lib/logger.mjs';
3
+ import { renderTemplate } from '../lib/template.mjs';
4
+
5
+ const PRODUCTION_BRANCHES = ['main', 'master', 'prod', 'sandbox'];
6
+ const QA_BRANCHES = ['development', 'develop', 'stage'];
7
+
8
+ /**
9
+ * @param {string} name
10
+ * @returns {string}
11
+ */
12
+ function env(name) {
13
+ const val = process.env[name] || '';
14
+ if (!val) {
15
+ debug(`env ${name} is empty`);
16
+ }
17
+ return val;
18
+ }
19
+
20
+ /**
21
+ * @param {string} path
22
+ * @returns {Promise<boolean>}
23
+ */
24
+ async function dirExists(path) {
25
+ try {
26
+ const { stat } = await import('node:fs/promises');
27
+ const s = await stat(path);
28
+ return s.isDirectory();
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ function resolveToken() {
35
+ return process.env.GITLAB_API_TOKEN || process.env.GITLAB_SO_API_TOKEN || '';
36
+ }
37
+
38
+ function resolveProjectId() {
39
+ return (
40
+ process.env.GITLAB_PROJECT_ID ||
41
+ process.env.GITLAB_SECOND_OPINION_PROJECT_ID ||
42
+ ''
43
+ );
44
+ }
45
+
46
+ async function triggerPipelineSimple() {
47
+ const pipelineUrl = env('QA_PIPELINE_URL');
48
+ const token = process.env.QA_PIPELINE_TRIGGER_TOKEN || resolveToken();
49
+
50
+ if (!token || !pipelineUrl) {
51
+ log('[postbuild] missing pipeline trigger config -- skipping');
52
+ return;
53
+ }
54
+
55
+ const payload = {
56
+ inputs: {
57
+ capture: process.env.QA_PIPELINE_CAPTURE || '',
58
+ environment: process.env.QA_PIPELINE_ENVIRONMENT || '',
59
+ project: process.env.QA_PIPELINE_PROJECT || '',
60
+ scope: process.env.QA_PIPELINE_SCOPE || '',
61
+ },
62
+ ref: process.env.QA_PIPELINE_REF || '',
63
+ };
64
+
65
+ debug(`[trigger-simple] payload=${JSON.stringify(payload)}`);
66
+
67
+ const res = await fetch(pipelineUrl, {
68
+ body: JSON.stringify(payload),
69
+ headers: {
70
+ 'Content-Type': 'application/json',
71
+ 'PRIVATE-TOKEN': token,
72
+ },
73
+ method: 'POST',
74
+ });
75
+
76
+ if (res.ok) {
77
+ log(`[postbuild] QA pipeline triggered (http=${res.status})`);
78
+ } else {
79
+ log(`[postbuild] QA pipeline trigger failed (http=${res.status})`);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * @param {{token: string, projectId: string, mrIid: string, branch: string, appId: string}} opts
85
+ * @returns {Promise<{id: number, webUrl: string} | null>}
86
+ */
87
+ async function triggerPipelinePreview({
88
+ token,
89
+ projectId,
90
+ mrIid,
91
+ branch,
92
+ appId,
93
+ }) {
94
+ const pipelineUrl = process.env.QA_PIPELINE_URL;
95
+ const triggerToken = process.env.QA_PIPELINE_TRIGGER_TOKEN || token;
96
+
97
+ if (!pipelineUrl) {
98
+ log('[postbuild] QA_PIPELINE_URL not set -- skipping trigger');
99
+ return null;
100
+ }
101
+
102
+ const payload = {
103
+ inputs: {
104
+ capture: process.env.QA_PIPELINE_CAPTURE || '',
105
+ environment: process.env.QA_PIPELINE_ENVIRONMENT || '',
106
+ project: process.env.QA_PIPELINE_PROJECT || '',
107
+ scope: process.env.QA_PIPELINE_SCOPE || '',
108
+ },
109
+ ref: process.env.QA_PIPELINE_REF || '',
110
+ variables: [
111
+ { key: 'SOURCE_PROJECT_ID', value: projectId },
112
+ { key: 'SOURCE_MR_IID', value: mrIid },
113
+ { key: 'BRANCH', value: branch },
114
+ { key: 'AMPLIFY_APP_ID', value: appId },
115
+ ],
116
+ };
117
+
118
+ debug(`[trigger-preview] payload=${JSON.stringify(payload)}`);
119
+
120
+ const res = await fetch(pipelineUrl, {
121
+ body: JSON.stringify(payload),
122
+ headers: {
123
+ 'Content-Type': 'application/json',
124
+ 'PRIVATE-TOKEN': triggerToken,
125
+ },
126
+ method: 'POST',
127
+ });
128
+
129
+ debug(`[trigger-preview] http=${res.status}`);
130
+
131
+ if (!res.ok) {
132
+ log(`[postbuild] pipeline trigger failed (http=${res.status})`);
133
+ return null;
134
+ }
135
+
136
+ const data = await res.json();
137
+ log(`[postbuild] pipeline triggered id=${data.id} url=${data.web_url}`);
138
+ return { id: data.id, webUrl: data.web_url };
139
+ }
140
+
141
+ /**
142
+ * Full postbuild orchestrator.
143
+ * Routes by branch type: production (exit), QA (trigger only), feature (comment + trigger).
144
+ */
145
+ export async function postbuild() {
146
+ const branch = env('AWS_BRANCH');
147
+ const commitId = env('AWS_COMMIT_ID');
148
+ const appId = env('AWS_APP_ID');
149
+ const jobId = env('AWS_JOB_ID');
150
+ const region = env('AWS_DEFAULT_REGION');
151
+
152
+ log(
153
+ `[postbuild] branch=${branch} commit=${commitId} app=${appId} job=${jobId}`,
154
+ );
155
+
156
+ if (PRODUCTION_BRANCHES.includes(branch)) {
157
+ log('[postbuild] route=production -- skipping');
158
+ return;
159
+ }
160
+
161
+ if (QA_BRANCHES.includes(branch)) {
162
+ log('[postbuild] route=qa');
163
+ if (process.env.QA_POSTBUILD_ENABLED !== 'true') {
164
+ log('[postbuild] QA_POSTBUILD_ENABLED not set -- skipping');
165
+ return;
166
+ }
167
+ await triggerPipelineSimple();
168
+ return;
169
+ }
170
+
171
+ log('[postbuild] route=feature');
172
+
173
+ const token = resolveToken();
174
+ if (!token) {
175
+ log('[postbuild] no GitLab token available -- skipping');
176
+ return;
177
+ }
178
+
179
+ const projectId = resolveProjectId();
180
+ const client = new GitLabClient({ token });
181
+ const mr = await client.findOpenMr(projectId, branch);
182
+
183
+ if (!mr) {
184
+ log(`[postbuild] no open MR for branch=${branch} -- skipping`);
185
+ return;
186
+ }
187
+
188
+ if (mr.description.includes('skip:ci')) {
189
+ log('[postbuild] MR description contains "skip:ci" -- skipping');
190
+ return;
191
+ }
192
+
193
+ const previewBranch = branch.toLowerCase().replace(/\//g, '-');
194
+ const previewDomain = process.env.PREVIEW_DOMAIN || '';
195
+ const previewUrl = previewDomain
196
+ ? `https://${previewBranch}.${previewDomain}`
197
+ : '';
198
+ const shortCommit = commitId.slice(0, 8);
199
+
200
+ const buildDir = process.env.CODEBUILD_SRC_DIR
201
+ ? `${process.env.CODEBUILD_SRC_DIR}/build`
202
+ : 'build';
203
+
204
+ const buildExists = await dirExists(buildDir);
205
+ const templateName = buildExists ? 'preview-ready' : 'preview-failed';
206
+
207
+ const commentBody = renderTemplate(templateName, {
208
+ appId,
209
+ branch,
210
+ commitId: shortCommit,
211
+ jobId,
212
+ previewUrl,
213
+ region,
214
+ });
215
+
216
+ const posted = await client.postNote(projectId, mr.iid, commentBody);
217
+ if (posted) {
218
+ log(`[postbuild] preview comment posted on MR !${mr.iid}`);
219
+ } else {
220
+ log('[postbuild] failed to post preview comment');
221
+ return;
222
+ }
223
+
224
+ if (!buildExists) {
225
+ log('[postbuild] build dir missing -- skipping pipeline trigger');
226
+ return;
227
+ }
228
+
229
+ const triggerResult = await triggerPipelinePreview({
230
+ appId,
231
+ branch,
232
+ mrIid: mr.iid.toString(),
233
+ projectId: projectId.toString(),
234
+ token,
235
+ });
236
+
237
+ if (triggerResult) {
238
+ const triggerBody = renderTemplate('pipeline-triggered', {
239
+ environment: process.env.QA_PIPELINE_ENVIRONMENT || '',
240
+ pipelineId: triggerResult.id.toString(),
241
+ pipelineUrl: triggerResult.webUrl,
242
+ project: process.env.QA_PIPELINE_PROJECT || '',
243
+ ref: process.env.QA_PIPELINE_REF || '',
244
+ });
245
+
246
+ const triggerPosted = await client.postNote(projectId, mr.iid, triggerBody);
247
+ if (triggerPosted) {
248
+ log('[postbuild] automation trigger comment posted');
249
+ } else {
250
+ log('[postbuild] failed to post trigger comment');
251
+ }
252
+ }
253
+ }
@@ -0,0 +1,85 @@
1
+ import { debug, log } from '../lib/logger.mjs';
2
+
3
+ /**
4
+ * Triggers a GitLab CI pipeline via API.
5
+ * @param {Record<string, string>} flags
6
+ */
7
+ export async function triggerPipeline(flags) {
8
+ const url = flags.url || process.env.QA_PIPELINE_URL || '';
9
+ const token =
10
+ flags.token ||
11
+ process.env.QA_PIPELINE_TRIGGER_TOKEN ||
12
+ process.env.GITLAB_API_TOKEN ||
13
+ '';
14
+ const ref = flags.ref || process.env.QA_PIPELINE_REF || '';
15
+
16
+ if (!url) {
17
+ throw new Error('No pipeline URL. Set QA_PIPELINE_URL or pass --url');
18
+ }
19
+ if (!token) {
20
+ throw new Error(
21
+ 'No trigger token. Set QA_PIPELINE_TRIGGER_TOKEN or pass --token',
22
+ );
23
+ }
24
+
25
+ const payload = {
26
+ inputs: {
27
+ capture: flags.capture || process.env.QA_PIPELINE_CAPTURE || '',
28
+ environment:
29
+ flags.environment || process.env.QA_PIPELINE_ENVIRONMENT || '',
30
+ project: flags.project || process.env.QA_PIPELINE_PROJECT || '',
31
+ scope: flags.scope || process.env.QA_PIPELINE_SCOPE || '',
32
+ },
33
+ ref,
34
+ };
35
+
36
+ const variables = [];
37
+ if (flags['source-project-id']) {
38
+ variables.push({
39
+ key: 'SOURCE_PROJECT_ID',
40
+ value: flags['source-project-id'],
41
+ });
42
+ }
43
+ if (flags['source-mr-iid']) {
44
+ variables.push({
45
+ key: 'SOURCE_MR_IID',
46
+ value: flags['source-mr-iid'],
47
+ });
48
+ }
49
+ if (flags.branch || process.env.AWS_BRANCH) {
50
+ variables.push({
51
+ key: 'BRANCH',
52
+ value: flags.branch || process.env.AWS_BRANCH,
53
+ });
54
+ }
55
+ if (flags['app-id'] || process.env.AWS_APP_ID) {
56
+ variables.push({
57
+ key: 'AMPLIFY_APP_ID',
58
+ value: flags['app-id'] || process.env.AWS_APP_ID,
59
+ });
60
+ }
61
+
62
+ if (variables.length) {
63
+ payload.variables = variables;
64
+ }
65
+
66
+ debug(`trigger-pipeline payload=${JSON.stringify(payload)}`);
67
+
68
+ const res = await fetch(url, {
69
+ body: JSON.stringify(payload),
70
+ headers: {
71
+ 'Content-Type': 'application/json',
72
+ 'PRIVATE-TOKEN': token,
73
+ },
74
+ method: 'POST',
75
+ });
76
+
77
+ if (!res.ok) {
78
+ const text = await res.text();
79
+ debug(`trigger-pipeline response=${text}`);
80
+ throw new Error(`Pipeline trigger failed (http=${res.status})`);
81
+ }
82
+
83
+ const data = await res.json();
84
+ log(`Pipeline triggered: id=${data.id} url=${data.web_url}`);
85
+ }
@@ -0,0 +1,30 @@
1
+ import { GitLabClient } from '../lib/gitlab-client.mjs';
2
+ import { log } from '../lib/logger.mjs';
3
+
4
+ /**
5
+ * Validates a GitLab PAT by calling the self-introspection endpoint.
6
+ * Exits 0 if valid, 1 if invalid.
7
+ * @param {Record<string, string>} flags
8
+ */
9
+ export async function validateToken(flags) {
10
+ const token =
11
+ flags.token ||
12
+ process.env.GITLAB_API_TOKEN ||
13
+ process.env.GITLAB_SO_API_TOKEN ||
14
+ '';
15
+
16
+ if (!token) {
17
+ log('No token provided. Set GITLAB_API_TOKEN or pass --token');
18
+ process.exit(1);
19
+ }
20
+
21
+ const client = new GitLabClient({ token });
22
+ const valid = await client.validateToken();
23
+
24
+ if (valid) {
25
+ log('Token is valid');
26
+ } else {
27
+ log('Token is invalid or expired');
28
+ process.exit(1);
29
+ }
30
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,4 @@
1
+ export { run } from './cli.mjs';
2
+ export { GitLabClient } from './lib/gitlab-client.mjs';
3
+ export { AmplifyClient } from './lib/amplify-client.mjs';
4
+ export { log, debug } from './lib/logger.mjs';
@@ -0,0 +1,45 @@
1
+ import { debug, log } from './logger.mjs';
2
+
3
+ const AWS_AMPLIFY_BASE = 'https://amplify.{region}.amazonaws.com';
4
+
5
+ export class AmplifyClient {
6
+ /** @param {{ region: string, appId: string }} opts */
7
+ constructor({ region, appId }) {
8
+ this.region = region;
9
+ this.appId = appId;
10
+ this.baseUrl = AWS_AMPLIFY_BASE.replace('{region}', region);
11
+ }
12
+
13
+ /**
14
+ * Deletes a preview branch from Amplify via AWS SDK-like REST call.
15
+ * Uses AWS Signature v4 from the environment (IAM role / env creds).
16
+ * In practice this should be called from an environment where
17
+ * @aws-sdk/client-amplify is available; this fallback uses fetch.
18
+ * @param {string} branchName
19
+ * @returns {Promise<boolean>}
20
+ */
21
+ async deleteBranch(branchName) {
22
+ debug(`deleteBranch appId=${this.appId} branch=${branchName}`);
23
+
24
+ try {
25
+ const { AmplifyClient: AwsAmplify, DeleteBranchCommand } = await import(
26
+ '@aws-sdk/client-amplify' // eslint-disable-line import/no-unresolved
27
+ );
28
+ const client = new AwsAmplify({ region: this.region });
29
+ const command = new DeleteBranchCommand({
30
+ appId: this.appId,
31
+ branchName,
32
+ });
33
+ await client.send(command);
34
+ log(`Deleted Amplify branch: ${branchName}`);
35
+ return true;
36
+ } catch (err) {
37
+ if (err.name === 'NotFoundException') {
38
+ log(`Branch ${branchName} not found (already deleted)`);
39
+ return true;
40
+ }
41
+ log(`Failed to delete branch ${branchName}: ${err.message}`);
42
+ return false;
43
+ }
44
+ }
45
+ }
@@ -0,0 +1,107 @@
1
+ import { debug } from './logger.mjs';
2
+
3
+ const GITLAB_API_BASE = 'https://gitlab.com/api/v4';
4
+
5
+ export class GitLabClient {
6
+ /** @param {{ token: string, apiBase?: string }} opts */
7
+ constructor({ token, apiBase }) {
8
+ this.token = token;
9
+ this.apiBase = apiBase || GITLAB_API_BASE;
10
+ }
11
+
12
+ /**
13
+ * Validates the PAT by calling the self-introspection endpoint.
14
+ * @returns {Promise<boolean>}
15
+ */
16
+ async validateToken() {
17
+ const res = await this._fetch(
18
+ `${this.apiBase}/personal_access_tokens/self`,
19
+ );
20
+ debug(`validate_token http=${res.status}`);
21
+ return res.ok;
22
+ }
23
+
24
+ /**
25
+ * Finds the first open MR for a given source branch.
26
+ * @param {string} projectId
27
+ * @param {string} branch
28
+ * @returns {Promise<{iid: number, description: string} | null>}
29
+ */
30
+ async findOpenMr(projectId, branch) {
31
+ const encoded = encodeURIComponent(branch);
32
+ const url = `${this.apiBase}/projects/${projectId}/merge_requests?source_branch=${encoded}&state=opened`;
33
+ debug(`findOpenMr GET ${url}`);
34
+
35
+ const res = await this._fetch(url);
36
+ if (!res.ok) {
37
+ debug(`findOpenMr failed http=${res.status}`);
38
+ return null;
39
+ }
40
+
41
+ const mrs = await res.json();
42
+ if (!mrs.length) {
43
+ return null;
44
+ }
45
+ return { description: mrs[0].description || '', iid: mrs[0].iid };
46
+ }
47
+
48
+ /**
49
+ * Posts a markdown note on an MR.
50
+ * @param {string} projectId
51
+ * @param {number} mrIid
52
+ * @param {string} body
53
+ * @returns {Promise<boolean>}
54
+ */
55
+ async postNote(projectId, mrIid, body) {
56
+ const url = `${this.apiBase}/projects/${projectId}/merge_requests/${mrIid}/notes`;
57
+ debug(`postNote POST ${url}`);
58
+
59
+ const res = await this._fetch(url, {
60
+ body: JSON.stringify({ body }),
61
+ headers: { 'Content-Type': 'application/json' },
62
+ method: 'POST',
63
+ });
64
+
65
+ debug(`postNote http=${res.status}`);
66
+ return res.ok;
67
+ }
68
+
69
+ /**
70
+ * Triggers a pipeline via the GitLab API.
71
+ * @param {string} url - full pipeline trigger URL
72
+ * @param {object} payload
73
+ * @returns {Promise<{ok: boolean, id?: number, webUrl?: string}>}
74
+ */
75
+ async triggerPipeline(url, payload) {
76
+ debug(`triggerPipeline POST ${url}`);
77
+
78
+ const res = await fetch(url, {
79
+ body: JSON.stringify(payload),
80
+ headers: {
81
+ 'Content-Type': 'application/json',
82
+ 'PRIVATE-TOKEN': this.token,
83
+ },
84
+ method: 'POST',
85
+ });
86
+
87
+ debug(`triggerPipeline http=${res.status}`);
88
+
89
+ if (!res.ok) {
90
+ return { ok: false };
91
+ }
92
+
93
+ const data = await res.json();
94
+ return { id: data.id, ok: true, webUrl: data.web_url };
95
+ }
96
+
97
+ /** @param {string} url @param {RequestInit} [opts] */
98
+ _fetch(url, opts = {}) {
99
+ return fetch(url, {
100
+ ...opts,
101
+ headers: {
102
+ 'PRIVATE-TOKEN': this.token,
103
+ ...opts.headers,
104
+ },
105
+ });
106
+ }
107
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @param {string} message
3
+ */
4
+ export function log(message) {
5
+ console.log(message);
6
+ }
7
+
8
+ /**
9
+ * Only prints when DV_GITLAB_DEBUG=true.
10
+ * @param {string} message
11
+ */
12
+ export function debug(message) {
13
+ if (process.env.DV_GITLAB_DEBUG === 'true') {
14
+ console.log(`[debug] ${message}`);
15
+ }
16
+ }
@@ -0,0 +1,26 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+ const TEMPLATES_DIR = resolve(__dirname, '../templates');
7
+
8
+ /**
9
+ * Renders a template file with variable interpolation.
10
+ * Template vars use `{{varName}}` syntax.
11
+ * @param {string} templateName - filename without extension (e.g. 'preview-ready')
12
+ * @param {Record<string, string>} vars
13
+ * @returns {string}
14
+ */
15
+ export function renderTemplate(templateName, vars) {
16
+ const filePath = resolve(TEMPLATES_DIR, `${templateName}.md`);
17
+ let content = readFileSync(filePath, 'utf-8');
18
+
19
+ for (const [key, value] of Object.entries(vars)) {
20
+ content = content.replaceAll(`{{${key}}}`, value || '');
21
+ }
22
+
23
+ content = content.replace(/\{\{[^}]+\}\}/g, '');
24
+
25
+ return content;
26
+ }
@@ -0,0 +1,12 @@
1
+ ## 🔄 Automation Pipeline Triggered
2
+
3
+ | | |
4
+ |---|---|
5
+ | **Pipeline** | [#{{pipelineId}}]({{pipelineUrl}}) |
6
+ | **Project** | `{{project}}` |
7
+ | **Ref** | `{{ref}}` |
8
+ | **Environment** | `{{environment}}` |
9
+
10
+ ---
11
+
12
+ Results will be posted here once the pipeline completes.
@@ -0,0 +1,10 @@
1
+ ## ❌ Preview Build Failed
2
+
3
+ | | |
4
+ |---|---|
5
+ | **Branch** | `{{branch}}` |
6
+ | **Commit** | `{{commitId}}` |
7
+
8
+ ---
9
+
10
+ The Amplify preview build did not complete successfully. Check the [Amplify Console](https://{{region}}.console.aws.amazon.com/amplify/home#/{{appId}}/{{branch}}) for details.
@@ -0,0 +1,18 @@
1
+ ## 🚀 Preview Environment Ready
2
+
3
+ | | |
4
+ |---|---|
5
+ | **Branch** | `{{branch}}` |
6
+ | **Commit** | `{{commitId}}` |
7
+ | **Preview URL** | [{{previewUrl}}]({{previewUrl}}) |
8
+
9
+ ---
10
+
11
+ <details>
12
+ <summary>Build details</summary>
13
+
14
+ - **App ID:** `{{appId}}`
15
+ - **Job ID:** `{{jobId}}`
16
+ - **Region:** `{{region}}`
17
+
18
+ </details>