@hellopearl/dv-gitlab 0.4.2 → 0.4.4

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,35 @@
1
+ # Reusable template: Slack deploy notification with release summary.
2
+ # Generates an AI-powered summary of commits (falls back to bullet list).
3
+ # Requires the `hellopearl/devops/cicd/common` slack-notify template.
4
+ #
5
+ # Consumer usage:
6
+ # include:
7
+ # - project: 'hellopearl/pearl-agentic-mono'
8
+ # ref: main
9
+ # file: '/packages/dev/dv-gitlab/ci/slack-deploy.yml'
10
+ # - project: 'hellopearl/devops/cicd/common'
11
+ # file: 'gitlab/slack-notify.yml'
12
+ #
13
+ # notify:deploy:
14
+ # extends: .slack-deploy
15
+ # variables:
16
+ # SLACK_CHANNEL: "#deployments-rcm"
17
+
18
+ .slack-deploy:
19
+ extends: .slack_notify
20
+ image: node:22-alpine
21
+ stage: post-deploy
22
+ needs: []
23
+ before_script:
24
+ - apk add --no-cache git
25
+ - 'export SLACK_MESSAGE=$(npx @hellopearl/dv-gitlab release-summary 2>/dev/null || echo ":rocket: *$CI_PROJECT_NAME* deployed to *$CI_COMMIT_REF_NAME*")'
26
+ variables:
27
+ SLACK_CHANNEL: "#deployments"
28
+ SLACK_MESSAGE: ":rocket: *$CI_PROJECT_NAME* deployed to *$CI_COMMIT_REF_NAME*"
29
+ GIT_DEPTH: 500
30
+ RELEASE_SUMMARY_SOURCE_BRANCH: develop
31
+ rules:
32
+ - if: $CI_COMMIT_BRANCH == "main"
33
+ - if: $CI_COMMIT_BRANCH == "prod"
34
+ - if: $CI_COMMIT_BRANCH == "stage"
35
+ - if: $CI_COMMIT_BRANCH == "sandbox"
@@ -0,0 +1,61 @@
1
+ # Reusable template: auto-bump patch version on develop merge.
2
+ # Increments the patch version in package.json and pushes with [skip ci].
3
+ #
4
+ # Consumer usage:
5
+ # include:
6
+ # - project: 'hellopearl/pearl-agentic-mono'
7
+ # ref: main
8
+ # file: '/packages/dev/dv-gitlab/ci/version-bump.yml'
9
+ #
10
+ # bump-version:
11
+ # extends: .version-bump
12
+
13
+ .version-bump:
14
+ stage: post-deploy
15
+ image: node:22-alpine
16
+ variables:
17
+ GIT_STRATEGY: none
18
+ VERSION_BUMP_BRANCH: develop
19
+ before_script:
20
+ - apk add --no-cache git
21
+ - git config --global user.email "ci@hellopearl.com"
22
+ - git config --global user.name "GitLab CI"
23
+ - git clone --depth=1 -b "$VERSION_BUMP_BRANCH" "https://oauth2:${GITLAB_TOKEN}@gitlab.com/${CI_PROJECT_PATH}.git" repo
24
+ - cd repo
25
+ script:
26
+ - |
27
+ MAX_RETRIES=3
28
+ for i in $(seq 1 $MAX_RETRIES); do
29
+ git fetch origin "$VERSION_BUMP_BRANCH:refs/remotes/origin/$VERSION_BUMP_BRANCH"
30
+ git reset --hard "origin/$VERSION_BUMP_BRANCH"
31
+
32
+ CURRENT=$(node -p "require('./package.json').version")
33
+ MAJOR=$(echo "$CURRENT" | cut -d. -f1)
34
+ MINOR=$(echo "$CURRENT" | cut -d. -f2)
35
+ PATCH=$(echo "$CURRENT" | cut -d. -f3)
36
+ NEXT="${MAJOR}.${MINOR}.$((PATCH + 1))"
37
+
38
+ node -e "
39
+ const fs = require('fs');
40
+ const pkg = JSON.parse(fs.readFileSync('package.json','utf8'));
41
+ pkg.version = '${NEXT}';
42
+ fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
43
+ "
44
+
45
+ git add package.json
46
+ git commit -m "chore: bump version to ${NEXT} [skip ci]"
47
+
48
+ if git push origin "HEAD:$VERSION_BUMP_BRANCH"; then
49
+ echo "Bumped ${CURRENT} -> ${NEXT}"
50
+ exit 0
51
+ fi
52
+
53
+ echo "Push failed (attempt $i/$MAX_RETRIES), retrying..."
54
+ sleep 2
55
+ done
56
+
57
+ echo "Version bump failed after $MAX_RETRIES attempts — skipping"
58
+ exit 0
59
+ rules:
60
+ - if: $CI_COMMIT_BRANCH == $VERSION_BUMP_BRANCH
61
+ when: on_success
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hellopearl/dv-gitlab",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Unified GitLab CI tooling -- MR comments, pipeline triggers, preview env management",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -48,5 +48,5 @@
48
48
  "@hellopearl/dv-prettier": "*",
49
49
  "@hellopearl/dv-test": "*"
50
50
  },
51
- "gitHead": "36acb7c5235796449a1425d8a8853f424c1bbb2a"
51
+ "gitHead": "954585e656b4cf6e6b89e294d14a97499c82c874"
52
52
  }
package/src/cli.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { cleanupBranch } from './commands/cleanup-branch.mjs';
2
2
  import { postComment } from './commands/post-comment.mjs';
3
3
  import { postbuild } from './commands/postbuild.mjs';
4
+ import { releaseSummary } from './commands/release-summary.mjs';
4
5
  import { triggerPipeline } from './commands/trigger-pipeline.mjs';
5
6
  import { validateToken } from './commands/validate-token.mjs';
6
7
  import { log } from './lib/logger.mjs';
@@ -9,6 +10,7 @@ const COMMANDS = {
9
10
  'cleanup-branch': cleanupBranch,
10
11
  'post-comment': postComment,
11
12
  postbuild,
13
+ 'release-summary': releaseSummary,
12
14
  'trigger-pipeline': triggerPipeline,
13
15
  'validate-token': validateToken,
14
16
  };
@@ -45,6 +47,7 @@ Commands:
45
47
  trigger-pipeline Trigger a GitLab CI pipeline
46
48
  validate-token Check if a GitLab PAT is valid
47
49
  cleanup-branch Delete an Amplify preview branch
50
+ release-summary Generate Slack deploy summary from git commits (AI-powered with fallback)
48
51
 
49
52
  Options:
50
53
  --help Show this help message
@@ -202,8 +202,11 @@ export async function postbuild() {
202
202
 
203
203
  const buildCandidates = [
204
204
  process.env.CODEBUILD_SRC_DIR && `${process.env.CODEBUILD_SRC_DIR}/build`,
205
+ process.env.CODEBUILD_SRC_DIR && `${process.env.CODEBUILD_SRC_DIR}/out`,
205
206
  'build',
207
+ 'out',
206
208
  'dist',
209
+ '.next',
207
210
  ].filter(Boolean);
208
211
 
209
212
  let buildExists = false;
@@ -0,0 +1,248 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { createSign } from 'node:crypto';
3
+ import { log } from '../lib/logger.mjs';
4
+
5
+ const MAX_COMMITS = 30;
6
+ const NOISE_COMMIT_RE =
7
+ /^(release:|merge branch |merge remote-tracking |chore\(merge\)|hotfix\/)/i;
8
+
9
+ function env(key, fallback = '') {
10
+ return process.env[key] || fallback;
11
+ }
12
+
13
+ function shellQuote(value) {
14
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
15
+ }
16
+
17
+ function git(command) {
18
+ return execSync(`git ${command}`, {
19
+ encoding: 'utf8',
20
+ timeout: 60_000,
21
+ stdio: ['ignore', 'pipe', 'pipe'],
22
+ });
23
+ }
24
+
25
+ export function escapeSlack(text) {
26
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
27
+ }
28
+
29
+ export function isNoiseCommit(subject) {
30
+ return NOISE_COMMIT_RE.test(subject.trim());
31
+ }
32
+
33
+ export function filterNoiseCommits(commits) {
34
+ return commits.filter((c) => !isNoiseCommit(c));
35
+ }
36
+
37
+ export function shouldResolveFromSourceBranch(commits) {
38
+ if (!commits.length || commits.length > 3) return false;
39
+ const nonMerge = commits.filter((c) => !/^merge\b/i.test(c.trim()));
40
+ if (!nonMerge.length) return false;
41
+ return nonMerge.every((c) => /^release:\s*/i.test(c.trim()));
42
+ }
43
+
44
+ export function getCommitsInRange(from, to) {
45
+ if (!from || from === '0000000000000000000000000000000000000000') return [];
46
+ try {
47
+ const raw = git(`log ${from}..${to} --no-merges --format="%s"`);
48
+ return raw.trim().split('\n').filter(Boolean);
49
+ } catch (err) {
50
+ log(`[release-summary] git log ${from}..${to} failed: ${err.message}`);
51
+ return [];
52
+ }
53
+ }
54
+
55
+ export function getCommitsFromSourceBranch(from, to, sourceBranch) {
56
+ if (!from || from === '0000000000000000000000000000000000000000') return [];
57
+ try {
58
+ git(`fetch origin ${sourceBranch} --depth=500`);
59
+ } catch (err) {
60
+ log(`[release-summary] fetch origin/${sourceBranch} failed: ${err.message}`);
61
+ return [];
62
+ }
63
+
64
+ let untilArgs = '';
65
+ try {
66
+ const iso = git(`show -s --format=%cI ${to}`).trim();
67
+ if (iso) untilArgs = ` --until=${shellQuote(iso)}`;
68
+ } catch {
69
+ // optional
70
+ }
71
+
72
+ try {
73
+ const raw = git(
74
+ `log ${from}..origin/${sourceBranch} --no-merges --format="%s"${untilArgs}`,
75
+ );
76
+ return raw.trim().split('\n').filter(Boolean);
77
+ } catch (err) {
78
+ log(`[release-summary] git log ${from}..origin/${sourceBranch} failed: ${err.message}`);
79
+ return [];
80
+ }
81
+ }
82
+
83
+ export function getChangedPathsSummary(from, to) {
84
+ if (!from || from === '0000000000000000000000000000000000000000') return '';
85
+ try {
86
+ const raw = git(`diff --name-status --diff-filter=ACMR ${from}...${to}`);
87
+ const lines = raw.trim().split('\n').filter(Boolean);
88
+ if (!lines.length) return '';
89
+ const shown = lines.slice(0, 80);
90
+ const suffix = lines.length > 80 ? `\n…and ${lines.length - 80} more paths` : '';
91
+ return `${shown.join('\n')}${suffix}`;
92
+ } catch {
93
+ return '';
94
+ }
95
+ }
96
+
97
+ function fallbackMessage(project, branch, commits) {
98
+ const header = `:rocket: *${project}* deployed to *${branch}*`;
99
+ const useful = filterNoiseCommits(commits);
100
+ const list = useful.length ? useful : commits;
101
+ if (!list.length) return header;
102
+ const bullets = list.slice(0, 10).map((c) => `• ${escapeSlack(c)}`).join('\n');
103
+ const suffix = list.length > 10 ? `\n_…and ${list.length - 10} more_` : '';
104
+ return `${header}\n\n${bullets}${suffix}`;
105
+ }
106
+
107
+ async function getAccessToken(credentials) {
108
+ const now = Math.floor(Date.now() / 1000);
109
+ const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
110
+ const payload = Buffer.from(JSON.stringify({
111
+ iss: credentials.client_email,
112
+ sub: credentials.client_email,
113
+ aud: 'https://oauth2.googleapis.com/token',
114
+ iat: now,
115
+ exp: now + 3600,
116
+ scope: 'https://www.googleapis.com/auth/cloud-platform',
117
+ })).toString('base64url');
118
+
119
+ const sign = createSign('RSA-SHA256');
120
+ sign.update(`${header}.${payload}`);
121
+ const signature = sign.sign(credentials.private_key, 'base64url');
122
+ const jwt = `${header}.${payload}.${signature}`;
123
+
124
+ const res = await fetch('https://oauth2.googleapis.com/token', {
125
+ method: 'POST',
126
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
127
+ body: `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${jwt}`,
128
+ });
129
+ if (!res.ok) throw new Error(`Token exchange failed: ${res.status}`);
130
+ const data = await res.json();
131
+ return data.access_token;
132
+ }
133
+
134
+ async function callGemini(accessToken, project, commits, changedPaths = '') {
135
+ const gcpProject = env('GOOGLE_CLOUD_PROJECT', 'voice-project-497618');
136
+ const gcpLocation = env('GOOGLE_CLOUD_LOCATION', 'us-central1');
137
+ const model = env('RELEASE_SUMMARY_MODEL', 'gemini-2.5-flash');
138
+ const endpoint = `https://${gcpLocation}-aiplatform.googleapis.com/v1/projects/${gcpProject}/locations/${gcpLocation}/publishers/google/models/${model}:generateContent`;
139
+
140
+ const commitList = commits.slice(0, MAX_COMMITS).join('\n');
141
+ const pathsBlock = changedPaths
142
+ ? `\n\nChanged paths (for context; do not list every file):\n${changedPaths}`
143
+ : '';
144
+
145
+ const prompt = `You are summarizing git commits for a ${project} deploy. Stay factual and close to what was actually done.
146
+
147
+ Rules:
148
+ - Group related commits into a single bullet point
149
+ - Stay close to the actual change — don't embellish or generalize too much
150
+ - Remove Jira ticket IDs (like SCRIBE-123, INS-456) but keep the substance
151
+ - Remove prefixes like "fix:", "feat:", "refactor:" — just describe what was done
152
+ - Skip commits that are purely internal (CI config, test infrastructure, dependency bumps, pure refactors with no behavior change)
153
+ - Maximum 8 bullet points, each starting with •
154
+ - Each bullet should be one concise line
155
+ - If all commits are internal-only, write: "• Internal improvements and maintenance"
156
+ - Do NOT include a header or footer — just the bullet points
157
+ - Do NOT use Slack formatting like *bold* — plain text only
158
+ - Ignore generic release titles like "release: deploy develop to production"
159
+
160
+ Commits:
161
+ ${commitList}${pathsBlock}`;
162
+
163
+ const body = {
164
+ contents: [{ role: 'user', parts: [{ text: prompt }] }],
165
+ generationConfig: {
166
+ temperature: 0.3,
167
+ maxOutputTokens: 1024,
168
+ topP: 0.8,
169
+ thinkingConfig: { thinkingBudget: 0 },
170
+ },
171
+ };
172
+
173
+ const res = await fetch(endpoint, {
174
+ method: 'POST',
175
+ headers: {
176
+ Authorization: `Bearer ${accessToken}`,
177
+ 'Content-Type': 'application/json',
178
+ },
179
+ body: JSON.stringify(body),
180
+ signal: AbortSignal.timeout(15_000),
181
+ });
182
+
183
+ if (!res.ok) throw new Error(`Gemini API error: ${res.status}`);
184
+ const data = await res.json();
185
+ const text = data?.candidates?.[0]?.content?.parts?.[0]?.text;
186
+ if (!text) throw new Error('Empty Gemini response');
187
+ return text.trim();
188
+ }
189
+
190
+ /**
191
+ * Generates a release summary for Slack deploy notifications.
192
+ * Reads commit range from GitLab CI env vars, optionally calls Gemini for
193
+ * AI-powered summary, falls back to a bullet list of commits.
194
+ * Outputs Slack mrkdwn to stdout.
195
+ */
196
+ export async function releaseSummary() {
197
+ const fromSha = env('CI_COMMIT_BEFORE_SHA');
198
+ const toSha = env('CI_COMMIT_SHA', 'HEAD');
199
+ const branch = env('CI_COMMIT_REF_NAME', 'unknown');
200
+ const project = env('CI_PROJECT_NAME', 'app');
201
+ const sourceBranch = env('RELEASE_SUMMARY_SOURCE_BRANCH', 'develop');
202
+ const googleCreds = env('GOOGLE_CREDENTIALS_JSON');
203
+
204
+ let commits = getCommitsInRange(fromSha, toSha);
205
+
206
+ if (shouldResolveFromSourceBranch(commits)) {
207
+ log(`[release-summary] squash release detected; resolving via origin/${sourceBranch}`);
208
+ const fromSource = getCommitsFromSourceBranch(fromSha, toSha, sourceBranch);
209
+ if (fromSource.length) commits = fromSource;
210
+ }
211
+
212
+ const filtered = filterNoiseCommits(commits);
213
+ if (filtered.length) commits = filtered;
214
+
215
+ log(`[release-summary] resolved ${commits.length} commit(s)`);
216
+
217
+ if (!commits.length) {
218
+ process.stdout.write(fallbackMessage(project, branch, commits));
219
+ return;
220
+ }
221
+
222
+ if (!googleCreds) {
223
+ log('[release-summary] GOOGLE_CREDENTIALS_JSON missing; using fallback');
224
+ process.stdout.write(fallbackMessage(project, branch, commits));
225
+ return;
226
+ }
227
+
228
+ let credentials;
229
+ try {
230
+ credentials = JSON.parse(googleCreds);
231
+ } catch {
232
+ log('[release-summary] invalid GOOGLE_CREDENTIALS_JSON; using fallback');
233
+ process.stdout.write(fallbackMessage(project, branch, commits));
234
+ return;
235
+ }
236
+
237
+ const changedPaths = getChangedPathsSummary(fromSha, toSha);
238
+
239
+ try {
240
+ const token = await getAccessToken(credentials);
241
+ const summary = await callGemini(token, project, commits, changedPaths);
242
+ const header = `:rocket: *${project}* deployed to *${branch}*`;
243
+ process.stdout.write(`${header}\n\n${escapeSlack(summary)}`);
244
+ } catch (err) {
245
+ log(`[release-summary] Gemini failed; using fallback: ${err.message}`);
246
+ process.stdout.write(fallbackMessage(project, branch, commits));
247
+ }
248
+ }