Skip to content

Commit da937fe

Browse files
committed
PoC for logger
1 parent 20e83fc commit da937fe

File tree

5 files changed

+575
-57
lines changed

5 files changed

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

0 commit comments

Comments
 (0)