Skip to content

Commit 75c7c68

Browse files
committed
PoC for logger
1 parent 20e83fc commit 75c7c68

File tree

5 files changed

+610
-57
lines changed

5 files changed

+610
-57
lines changed

.github/workflows/ci.yml

Lines changed: 5 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,27 @@
11
name: Continuous Integration
22

3-
on:
4-
pull_request:
5-
branches:
6-
- main
7-
push:
8-
branches:
9-
- main
3+
on: push
104

115
permissions:
126
contents: read
137

148
jobs:
15-
test-typescript:
16-
name: TypeScript Tests
9+
logger:
10+
name: Logger PoC
1711
runs-on: ubuntu-latest
1812

1913
steps:
2014
- name: Checkout
21-
id: checkout
2215
uses: actions/checkout@v4
2316

2417
- name: Setup Node.js
25-
id: setup-node
2618
uses: actions/setup-node@v4
2719
with:
2820
node-version-file: .node-version
2921
cache: npm
3022

3123
- name: Install Dependencies
32-
id: npm-ci
3324
run: npm ci
3425

35-
- name: Check Format
36-
id: npm-format-check
37-
run: npm run format:check
38-
39-
- name: Lint
40-
id: npm-lint
41-
run: npm run lint
42-
43-
- name: Test
44-
id: npm-ci-test
45-
run: npm run ci-test
46-
47-
test-action:
48-
name: GitHub Actions Test
49-
runs-on: ubuntu-latest
50-
51-
permissions:
52-
pull-requests: write
53-
54-
steps:
55-
- name: Checkout
56-
id: checkout
57-
uses: actions/checkout@v4
58-
59-
- name: Setup Node.js
60-
id: setup-node
61-
uses: actions/setup-node@v4
62-
with:
63-
node-version-file: .node-version
64-
cache: npm
65-
66-
- name: Install Dependencies
67-
id: npm-ci
68-
run: npm ci
69-
70-
- name: Test Local Action
71-
id: test-action
72-
uses: ./
73-
env:
74-
CP_SERVER: ${{ secrets.CP_SERVER }}
75-
CP_API_KEY: ${{ secrets.CP_API_KEY }}
76-
77-
- name: Print Output
78-
id: output
79-
run: echo "${{ steps.test-action.outputs.comment-id }}"
26+
- name: Logger (verbose)
27+
run: npx tsx usage.ts --verbose

logger.ts

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
import ansis, { type AnsiColors } from 'ansis';
2+
import { platform } from 'node:os';
3+
import ora, { type Ora } from 'ora';
4+
5+
type GroupColor = Extract<AnsiColors, 'cyan' | 'magenta'>;
6+
type CiPlatform = 'GitHub Actions' | 'GitLab CI/CD';
7+
8+
const GROUP_COLOR_ENV_VAR_NAME = 'CP_LOGGER_GROUP_COLOR';
9+
10+
export class Logger {
11+
// TODO: smart boolean parsing
12+
#isVerbose = process.env['CP_VERBOSE'] === 'true';
13+
#isCI = process.env['CI'] === 'true';
14+
#ciPlatform: CiPlatform | undefined =
15+
process.env['GITHUB_ACTIONS'] === 'true'
16+
? 'GitHub Actions'
17+
: process.env['GITLAB_CI'] === 'true'
18+
? 'GitLab CI/CD'
19+
: undefined;
20+
#groupColor: GroupColor | undefined =
21+
process.env[GROUP_COLOR_ENV_VAR_NAME] === 'cyan' ||
22+
process.env[GROUP_COLOR_ENV_VAR_NAME] === 'magenta'
23+
? process.env[GROUP_COLOR_ENV_VAR_NAME]
24+
: undefined;
25+
26+
#groupsCount = 0;
27+
#activeSpinner: Ora | undefined;
28+
#activeSpinnerLogs: string[] = [];
29+
#endsWithBlankLine = false;
30+
31+
#groupSymbols = {
32+
start: '❯',
33+
middle: '│',
34+
end: '└',
35+
};
36+
37+
#sigintListener = () => {
38+
if (this.#activeSpinner != null) {
39+
const text = `${this.#activeSpinner.text} ${ansis.red.bold('[SIGINT]')}`;
40+
if (this.#groupColor) {
41+
this.#activeSpinner.stopAndPersist({
42+
text,
43+
symbol: this.#colorize(this.#groupSymbols.end, this.#groupColor),
44+
});
45+
this.#setGroupColor(undefined);
46+
} else {
47+
this.#activeSpinner.fail(text);
48+
}
49+
this.#activeSpinner = undefined;
50+
}
51+
this.newline();
52+
this.error(ansis.bold('Cancelled by SIGINT'));
53+
process.exit(platform() === 'win32' ? 2 : 130);
54+
};
55+
56+
error(message: string): void {
57+
this.#log(message, 'red');
58+
}
59+
60+
warn(message: string): void {
61+
this.#log(message, 'yellow');
62+
}
63+
64+
info(message: string): void {
65+
this.#log(message);
66+
}
67+
68+
debug(message: string): void {
69+
if (this.#isVerbose) {
70+
this.#log(message, 'gray');
71+
}
72+
}
73+
74+
newline(): void {
75+
this.#log('');
76+
}
77+
78+
isVerbose(): boolean {
79+
return this.#isVerbose;
80+
}
81+
82+
setVerbose(isVerbose: boolean): void {
83+
process.env['CP_VERBOSE'] = `${isVerbose}`;
84+
this.#isVerbose = isVerbose;
85+
}
86+
87+
async group(title: string, worker: () => Promise<string>): Promise<void> {
88+
if (!this.#endsWithBlankLine) {
89+
this.newline();
90+
}
91+
92+
this.#setGroupColor(this.#groupsCount % 2 === 0 ? 'cyan' : 'magenta');
93+
this.#groupsCount++;
94+
95+
const groupMarkers = this.#createGroupMarkers();
96+
97+
console.log(groupMarkers.start(title));
98+
99+
const start = performance.now();
100+
const result = await this.#settlePromise(worker());
101+
const end = performance.now();
102+
103+
if (result.status === 'fulfilled') {
104+
console.log(
105+
[
106+
this.#colorize(this.#groupSymbols.end, this.#groupColor),
107+
this.#colorize(result.value, 'green'),
108+
this.#formatDuration({ start, end }),
109+
].join(' '),
110+
);
111+
} else {
112+
console.log(
113+
[
114+
this.#colorize(this.#groupSymbols.end, this.#groupColor),
115+
this.#colorize(`${result.reason}`, 'red'),
116+
].join(' '),
117+
);
118+
}
119+
120+
const endMarker = groupMarkers.end();
121+
if (endMarker) {
122+
console.log(endMarker);
123+
}
124+
this.#setGroupColor(undefined);
125+
this.newline();
126+
127+
if (result.status === 'rejected') {
128+
throw result.reason;
129+
}
130+
}
131+
132+
#createGroupMarkers(): {
133+
start: (title: string) => string;
134+
end: () => string;
135+
} {
136+
const formatTitle = (title: string) =>
137+
ansis.bold(this.#colorize(title, this.#groupColor));
138+
139+
switch (this.#ciPlatform) {
140+
case 'GitHub Actions':
141+
// https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands#grouping-log-lines
142+
return {
143+
start: title => `::group::${formatTitle(title)}`,
144+
end: () => '::endgroup::',
145+
};
146+
case 'GitLab CI/CD':
147+
// https://docs.gitlab.com/ci/jobs/job_logs/#custom-collapsible-sections
148+
const unixTimestamp = () => Math.round(Date.now() / 1000);
149+
const ansiEscCode = '\x1b[0K'; // '\e' ESC character only works for `echo -e`, Node console must use '\x1b'
150+
const sectionId = () => `code_pushup_logs_group_${this.#groupsCount}`;
151+
return {
152+
start: title =>
153+
`${ansiEscCode}section_start:${unixTimestamp()}:${sectionId()}\r${ansiEscCode}${formatTitle(`${this.#groupSymbols.start} ${title}`)}`,
154+
end: () =>
155+
`${ansiEscCode}section_end:${unixTimestamp()}:${sectionId()}\r${ansiEscCode}`,
156+
};
157+
case undefined:
158+
return {
159+
start: title => formatTitle(`${this.#groupSymbols.start} ${title}`),
160+
end: () => '',
161+
};
162+
}
163+
}
164+
165+
#setGroupColor(groupColor: GroupColor | undefined) {
166+
this.#groupColor = groupColor;
167+
if (groupColor) {
168+
process.env[GROUP_COLOR_ENV_VAR_NAME] = groupColor;
169+
} else {
170+
delete process.env[GROUP_COLOR_ENV_VAR_NAME];
171+
}
172+
}
173+
174+
task(title: string, worker: () => Promise<string>): Promise<void> {
175+
return this.#spinner(worker, {
176+
pending: title,
177+
success: value => value,
178+
failure: error => `${title}${ansis.red(`${error}`)}`,
179+
});
180+
}
181+
182+
command(bin: string, worker: () => Promise<void>): Promise<void> {
183+
return this.#spinner(worker, {
184+
pending: `${ansis.blue('$')} ${bin}`,
185+
success: () => `${ansis.green('$')} ${bin}`,
186+
failure: () => `${ansis.red('$')} ${bin}`,
187+
});
188+
}
189+
190+
async #spinner<T>(
191+
worker: () => Promise<T>,
192+
messages: {
193+
pending: string;
194+
success: (value: T) => string;
195+
failure: (error: unknown) => string;
196+
},
197+
): Promise<void> {
198+
process.removeListener('SIGINT', this.#sigintListener);
199+
process.addListener('SIGINT', this.#sigintListener);
200+
201+
if (this.#groupColor) {
202+
this.#activeSpinner = ora({
203+
text: this.#isCI
204+
? `\r${this.#format(messages.pending, undefined)}`
205+
: messages.pending,
206+
spinner: 'line',
207+
color: this.#groupColor,
208+
});
209+
} else {
210+
this.#activeSpinner = ora(messages.pending);
211+
}
212+
213+
this.#activeSpinner.start();
214+
this.#endsWithBlankLine = false;
215+
216+
const start = performance.now();
217+
const result = await this.#settlePromise(worker());
218+
const end = performance.now();
219+
220+
const text =
221+
result.status === 'fulfilled'
222+
? [
223+
messages.success(result.value),
224+
this.#formatDuration({ start, end }),
225+
].join(' ')
226+
: messages.failure(result.reason);
227+
228+
if (this.#groupColor) {
229+
this.#activeSpinner.stopAndPersist({
230+
text,
231+
symbol: this.#colorize(this.#groupSymbols.middle, this.#groupColor),
232+
});
233+
} else {
234+
if (result.status === 'fulfilled') {
235+
this.#activeSpinner.succeed(text);
236+
} else {
237+
this.#activeSpinner.fail(text);
238+
}
239+
}
240+
this.#endsWithBlankLine = false;
241+
242+
this.#activeSpinner = undefined;
243+
this.#activeSpinnerLogs.forEach(message => {
244+
this.#log(` ${message}`);
245+
});
246+
this.#activeSpinnerLogs = [];
247+
process.removeListener('SIGINT', this.#sigintListener);
248+
249+
if (result.status === 'rejected') {
250+
throw result.reason;
251+
}
252+
}
253+
254+
#log(message: string, color?: AnsiColors): void {
255+
if (this.#activeSpinner) {
256+
if (this.#activeSpinner.isSpinning) {
257+
this.#activeSpinnerLogs.push(this.#format(message, color));
258+
} else {
259+
console.log(this.#format(` ${message}`, color));
260+
}
261+
} else {
262+
console.log(this.#format(message, color));
263+
}
264+
this.#endsWithBlankLine = !message || message.endsWith('\n');
265+
}
266+
267+
#format(message: string, color: AnsiColors | undefined): string {
268+
if (!this.#groupColor || this.#activeSpinner?.isSpinning) {
269+
return this.#colorize(message, color);
270+
}
271+
return message
272+
.split(/\r?\n/)
273+
.map(line =>
274+
[
275+
this.#colorize('│', this.#groupColor),
276+
this.#colorize(line, color),
277+
].join(' '),
278+
)
279+
.join('\n');
280+
}
281+
282+
#colorize(text: string, color: AnsiColors | undefined): string {
283+
if (!color) {
284+
return text;
285+
}
286+
return ansis[color](text);
287+
}
288+
289+
#formatDuration({ start, end }: { start: number; end: number }): string {
290+
const duration = end - start;
291+
const seconds = Math.round(duration / 10) / 100;
292+
return ansis.gray(`(${seconds}s)`);
293+
}
294+
295+
async #settlePromise<T>(
296+
promise: Promise<T>,
297+
): Promise<PromiseSettledResult<T>> {
298+
try {
299+
const value = await promise;
300+
return { status: 'fulfilled', value };
301+
} catch (error) {
302+
return { status: 'rejected', reason: error };
303+
}
304+
}
305+
}

0 commit comments

Comments
 (0)