gitlab-radiator 5.0.0 → 5.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.
- package/bin/gitlab-radiator.js +1 -1
- package/package.json +1 -1
- package/src/app.js +74 -0
- package/src/auth.js +18 -0
- package/src/config.js +80 -0
- package/src/dev-assets.js +11 -0
- package/src/gitlab/client.js +20 -0
- package/src/gitlab/index.js +49 -0
- package/src/gitlab/pipelines.js +125 -0
- package/src/gitlab/projects.js +65 -0
- package/src/gitlab/runners.js +23 -0
- package/src/less.js +28 -0
- package/src/tsconfig.build.tsbuildinfo +1 -0
- package/src/app.ts +0 -85
- package/src/auth.ts +0 -20
- package/src/common/gitlab-types.d.ts +0 -57
- package/src/config.ts +0 -95
- package/src/gitlab/client.ts +0 -34
- package/src/gitlab/index.ts +0 -59
- package/src/gitlab/pipelines.ts +0 -178
- package/src/gitlab/projects.ts +0 -87
- package/src/gitlab/runners.ts +0 -35
- package/src/less.ts +0 -33
package/bin/gitlab-radiator.js
CHANGED
package/package.json
CHANGED
package/src/app.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { basicAuth } from "./auth.js";
|
|
2
|
+
import compression from 'compression';
|
|
3
|
+
import { config } from "./config.js";
|
|
4
|
+
import express from 'express';
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import { fetchOfflineRunners } from "./gitlab/runners.js";
|
|
7
|
+
import http from 'http';
|
|
8
|
+
import { serveLessAsCss } from "./less.js";
|
|
9
|
+
import { Server } from 'socket.io';
|
|
10
|
+
import { update } from "./gitlab/index.js";
|
|
11
|
+
const app = express();
|
|
12
|
+
const httpServer = new http.Server(app);
|
|
13
|
+
const socketIoServer = new Server(httpServer);
|
|
14
|
+
if (process.env.NODE_ENV !== 'production' && fs.existsSync('./src/dev-assets.ts')) {
|
|
15
|
+
const { bindDevAssets } = await import("./dev-assets.js");
|
|
16
|
+
bindDevAssets(app);
|
|
17
|
+
}
|
|
18
|
+
app.disable('x-powered-by');
|
|
19
|
+
app.get('/client.css', serveLessAsCss);
|
|
20
|
+
app.use(express.static('public'));
|
|
21
|
+
app.use(compression());
|
|
22
|
+
app.use(basicAuth(config.auth));
|
|
23
|
+
httpServer.listen(config.port, () => {
|
|
24
|
+
console.log(`Listening on port *:${config.port}`);
|
|
25
|
+
});
|
|
26
|
+
const globalState = {
|
|
27
|
+
projects: null,
|
|
28
|
+
error: null,
|
|
29
|
+
zoom: config.zoom,
|
|
30
|
+
projectsOrder: config.projectsOrder,
|
|
31
|
+
columns: config.columns,
|
|
32
|
+
horizontal: config.horizontal,
|
|
33
|
+
rotateRunningPipelines: config.rotateRunningPipelines,
|
|
34
|
+
groupSuccessfulProjects: config.groupSuccessfulProjects
|
|
35
|
+
};
|
|
36
|
+
socketIoServer.on('connection', (socket) => {
|
|
37
|
+
socket.emit('state', withDate(globalState));
|
|
38
|
+
});
|
|
39
|
+
async function runUpdate() {
|
|
40
|
+
try {
|
|
41
|
+
globalState.projects = await update(config.gitlabs, config.rotateRunningPipelines > 0);
|
|
42
|
+
globalState.error = await errorIfRunnerOffline();
|
|
43
|
+
socketIoServer.emit('state', withDate(globalState));
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
47
|
+
console.error(message);
|
|
48
|
+
globalState.error = `Failed to communicate with GitLab API: ${message}`;
|
|
49
|
+
socketIoServer.emit('state', withDate(globalState));
|
|
50
|
+
}
|
|
51
|
+
setTimeout(runUpdate, config.interval);
|
|
52
|
+
}
|
|
53
|
+
async function errorIfRunnerOffline() {
|
|
54
|
+
const offlineRunnersPerGitlab = await Promise.all(config.gitlabs.map(fetchOfflineRunners));
|
|
55
|
+
const { offline, totalCount } = offlineRunnersPerGitlab.reduce((acc, runner) => {
|
|
56
|
+
return {
|
|
57
|
+
offline: acc.offline.concat(runner.offline),
|
|
58
|
+
totalCount: acc.totalCount + runner.totalCount
|
|
59
|
+
};
|
|
60
|
+
}, { offline: [], totalCount: 0 });
|
|
61
|
+
if (offline.length > 0) {
|
|
62
|
+
const names = offline.map(r => r.name).sort().join(', ');
|
|
63
|
+
const counts = offline.length === totalCount ? 'All' : `${offline.length}/${totalCount}`;
|
|
64
|
+
return `${counts} runners offline: ${names}`;
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
await runUpdate();
|
|
69
|
+
function withDate(state) {
|
|
70
|
+
return {
|
|
71
|
+
...state,
|
|
72
|
+
now: Date.now()
|
|
73
|
+
};
|
|
74
|
+
}
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import authenticate from 'basic-auth';
|
|
2
|
+
export function basicAuth(auth) {
|
|
3
|
+
if (!auth) {
|
|
4
|
+
console.log('No authentication configured');
|
|
5
|
+
return (req, res, next) => next();
|
|
6
|
+
}
|
|
7
|
+
console.log('HTTP basic auth enabled');
|
|
8
|
+
return (req, res, next) => {
|
|
9
|
+
const { name, pass } = authenticate(req) || {};
|
|
10
|
+
if (auth.username === name && auth.password === pass) {
|
|
11
|
+
next();
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
res.setHeader('WWW-Authenticate', 'Basic realm="gitlab-radiator"');
|
|
15
|
+
res.status(401).end();
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
function expandTilde(path) {
|
|
6
|
+
return path.replace(/^~($|\/|\\)/, `${os.homedir()}$1`);
|
|
7
|
+
}
|
|
8
|
+
const JobStatusSchema = z.literal(['canceled', 'created', 'failed', 'manual', 'pending', 'running', 'skipped', 'success']);
|
|
9
|
+
const GitlabSchema = z.strictObject({
|
|
10
|
+
url: z.string().min(1, 'Mandatory gitlab url missing from configuration file'),
|
|
11
|
+
'access-token': z.string().min(1).optional(),
|
|
12
|
+
ignoreArchived: z.boolean().default(true),
|
|
13
|
+
maxNonFailedJobsVisible: z.coerce.number().int().default(999999),
|
|
14
|
+
branch: z.string().min(1).optional(),
|
|
15
|
+
caFile: z.string().optional(),
|
|
16
|
+
offlineRunners: z.literal(['all', 'default', 'none']).default('default'),
|
|
17
|
+
projects: z.strictObject({
|
|
18
|
+
excludePipelineStatus: z.array(JobStatusSchema).optional(),
|
|
19
|
+
include: z.string().min(1).optional(),
|
|
20
|
+
exclude: z.string().min(1).optional()
|
|
21
|
+
}).optional()
|
|
22
|
+
}).transform(gitlab => {
|
|
23
|
+
const accessToken = gitlab['access-token'] || process.env.GITLAB_ACCESS_TOKEN;
|
|
24
|
+
if (!accessToken) {
|
|
25
|
+
throw new Error('Mandatory gitlab access token missing from configuration (and none present at GITLAB_ACCESS_TOKEN env variable)');
|
|
26
|
+
}
|
|
27
|
+
const { url, ignoreArchived, maxNonFailedJobsVisible, caFile, branch, offlineRunners, projects } = gitlab;
|
|
28
|
+
const ca = caFile && fs.existsSync(caFile) ? fs.readFileSync(caFile, 'utf-8') : undefined;
|
|
29
|
+
return {
|
|
30
|
+
url,
|
|
31
|
+
ignoreArchived,
|
|
32
|
+
maxNonFailedJobsVisible,
|
|
33
|
+
branch,
|
|
34
|
+
ca,
|
|
35
|
+
offlineRunners,
|
|
36
|
+
'access-token': accessToken,
|
|
37
|
+
projects
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
const OrderSchema = z.literal(['status', 'name', 'id', 'nameWithoutNamespace', 'group']);
|
|
41
|
+
const ConfigSchema = z.strictObject({
|
|
42
|
+
interval: z.coerce.number().default(10).transform(sec => sec * 1000),
|
|
43
|
+
port: z.coerce.number().int().default(3000),
|
|
44
|
+
zoom: z.coerce.number().default(1.0),
|
|
45
|
+
columns: z.coerce.number().int().default(1),
|
|
46
|
+
horizontal: z.boolean().default(false),
|
|
47
|
+
rotateRunningPipelines: z.coerce.number().min(0).default(0).transform(sec => sec * 1000),
|
|
48
|
+
groupSuccessfulProjects: z.boolean().default(false),
|
|
49
|
+
projectsOrder: z.array(OrderSchema).default(['name']),
|
|
50
|
+
gitlabs: z.array(GitlabSchema).min(1, { message: 'Mandatory gitlab properties missing from configuration file' }),
|
|
51
|
+
colors: z.strictObject({
|
|
52
|
+
background: z.string(),
|
|
53
|
+
'created-background': z.string(),
|
|
54
|
+
'created-text': z.string(),
|
|
55
|
+
'dark-text': z.string(),
|
|
56
|
+
'error-message-background': z.string(),
|
|
57
|
+
'error-message-text': z.string(),
|
|
58
|
+
'failed-background': z.string(),
|
|
59
|
+
'failed-text': z.string(),
|
|
60
|
+
'group-background': z.string(),
|
|
61
|
+
'light-text': z.string(),
|
|
62
|
+
'pending-background': z.string(),
|
|
63
|
+
'pending-text': z.string(),
|
|
64
|
+
'project-background': z.string(),
|
|
65
|
+
'running-background': z.string(),
|
|
66
|
+
'running-text': z.string(),
|
|
67
|
+
'skipped-background': z.string(),
|
|
68
|
+
'skipped-text': z.string(),
|
|
69
|
+
'success-background': z.string(),
|
|
70
|
+
'success-text': z.string()
|
|
71
|
+
}).partial().optional(),
|
|
72
|
+
auth: z.strictObject({
|
|
73
|
+
username: z.string(),
|
|
74
|
+
password: z.string()
|
|
75
|
+
}).optional()
|
|
76
|
+
});
|
|
77
|
+
const configFile = expandTilde(process.env.GITLAB_RADIATOR_CONFIG || '~/.gitlab-radiator.yml');
|
|
78
|
+
const yamlContent = fs.readFileSync(configFile, 'utf8');
|
|
79
|
+
const rawConfig = yaml.load(yamlContent);
|
|
80
|
+
export const config = ConfigSchema.parse(rawConfig);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createRequire } from 'module';
|
|
2
|
+
import webpack from 'webpack';
|
|
3
|
+
import webpackDevMiddleware from 'webpack-dev-middleware';
|
|
4
|
+
import webpackHotMiddleware from 'webpack-hot-middleware';
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
export function bindDevAssets(app) {
|
|
7
|
+
const config = require('../webpack.dev.cjs');
|
|
8
|
+
const compiler = webpack(config);
|
|
9
|
+
app.use(webpackDevMiddleware(compiler));
|
|
10
|
+
app.use(webpackHotMiddleware(compiler));
|
|
11
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import https from 'https';
|
|
3
|
+
import url from 'url';
|
|
4
|
+
export function gitlabRequest(pathStr, params, gitlab) {
|
|
5
|
+
return lazyClient(gitlab).get(pathStr, { params: params || {} });
|
|
6
|
+
}
|
|
7
|
+
const clients = new Map();
|
|
8
|
+
function lazyClient(gitlab) {
|
|
9
|
+
let client = clients.get(gitlab.url);
|
|
10
|
+
if (!client) {
|
|
11
|
+
client = axios.create({
|
|
12
|
+
baseURL: url.resolve(gitlab.url, '/api/v4/'),
|
|
13
|
+
headers: { 'PRIVATE-TOKEN': gitlab['access-token'] },
|
|
14
|
+
httpsAgent: new https.Agent({ keepAlive: true, ca: gitlab.ca }),
|
|
15
|
+
timeout: 30 * 1000
|
|
16
|
+
});
|
|
17
|
+
clients.set(gitlab.url, client);
|
|
18
|
+
}
|
|
19
|
+
return client;
|
|
20
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { fetchLatestPipelines } from "./pipelines.js";
|
|
2
|
+
import { fetchProjects } from "./projects.js";
|
|
3
|
+
export async function update(gitlabs, prioritizeRunningPipelines) {
|
|
4
|
+
const projectsWithPipelines = await loadProjectsWithPipelines(gitlabs, prioritizeRunningPipelines);
|
|
5
|
+
return projectsWithPipelines
|
|
6
|
+
.filter((project) => project.pipelines.length > 0);
|
|
7
|
+
}
|
|
8
|
+
async function loadProjectsWithPipelines(gitlabs, prioritizeRunningPipelines) {
|
|
9
|
+
const allProjectsWithPipelines = [];
|
|
10
|
+
await Promise.all(gitlabs.map(async (gitlab) => {
|
|
11
|
+
const projects = (await fetchProjects(gitlab))
|
|
12
|
+
.map(project => ({
|
|
13
|
+
...project,
|
|
14
|
+
maxNonFailedJobsVisible: gitlab.maxNonFailedJobsVisible
|
|
15
|
+
}));
|
|
16
|
+
for (const project of projects) {
|
|
17
|
+
allProjectsWithPipelines.push(await projectWithPipelines(project, gitlab, prioritizeRunningPipelines));
|
|
18
|
+
}
|
|
19
|
+
}));
|
|
20
|
+
return allProjectsWithPipelines;
|
|
21
|
+
}
|
|
22
|
+
async function projectWithPipelines(project, gitlab, prioritizeRunningPipelines) {
|
|
23
|
+
const pipelines = filterOutEmpty(await fetchLatestPipelines(project.id, gitlab, prioritizeRunningPipelines))
|
|
24
|
+
.filter(excludePipelineStatusFilter(gitlab));
|
|
25
|
+
const status = defaultBranchStatus(project, pipelines);
|
|
26
|
+
return {
|
|
27
|
+
...project,
|
|
28
|
+
maxNonFailedJobsVisible: gitlab.maxNonFailedJobsVisible,
|
|
29
|
+
pipelines,
|
|
30
|
+
status
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function defaultBranchStatus(project, pipelines) {
|
|
34
|
+
const [head] = pipelines
|
|
35
|
+
.filter(({ ref }) => ref === project.default_branch)
|
|
36
|
+
.map(({ status }) => status);
|
|
37
|
+
return head;
|
|
38
|
+
}
|
|
39
|
+
function filterOutEmpty(pipelines) {
|
|
40
|
+
return pipelines.filter(pipeline => pipeline.stages);
|
|
41
|
+
}
|
|
42
|
+
function excludePipelineStatusFilter(gitlab) {
|
|
43
|
+
return (pipeline) => {
|
|
44
|
+
if (gitlab.projects?.excludePipelineStatus) {
|
|
45
|
+
return !gitlab.projects.excludePipelineStatus.includes(pipeline.status);
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { gitlabRequest } from "./client.js";
|
|
2
|
+
export async function fetchLatestPipelines(projectId, gitlab, prioritizeRunningPipelines) {
|
|
3
|
+
const pipelines = await fetchLatestAndMasterPipeline(projectId, gitlab, prioritizeRunningPipelines);
|
|
4
|
+
const pipelinesWithStages = [];
|
|
5
|
+
for (const { id, ref, status } of pipelines) {
|
|
6
|
+
const { commit, stages } = await fetchJobs(projectId, id, gitlab);
|
|
7
|
+
const downstreamStages = await fetchDownstreamJobs(projectId, id, gitlab);
|
|
8
|
+
pipelinesWithStages.push({
|
|
9
|
+
id,
|
|
10
|
+
ref,
|
|
11
|
+
status,
|
|
12
|
+
commit,
|
|
13
|
+
stages: stages.concat(downstreamStages)
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
return pipelinesWithStages;
|
|
17
|
+
}
|
|
18
|
+
async function fetchLatestAndMasterPipeline(projectId, gitlab, prioritizeRunningPipelines) {
|
|
19
|
+
const options = {
|
|
20
|
+
per_page: 100,
|
|
21
|
+
...(gitlab.branch ? { ref: gitlab.branch } : {})
|
|
22
|
+
};
|
|
23
|
+
const pipelines = await fetchPipelines(projectId, gitlab, options);
|
|
24
|
+
if (pipelines.length === 0) {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
const runningPipelines = pipelines.filter(pipeline => pipeline.status === 'running');
|
|
28
|
+
if (runningPipelines.length > 1 && prioritizeRunningPipelines) {
|
|
29
|
+
return runningPipelines;
|
|
30
|
+
}
|
|
31
|
+
const latestPipeline = pipelines.slice(0, 1);
|
|
32
|
+
if (latestPipeline[0].ref === 'master') {
|
|
33
|
+
return latestPipeline;
|
|
34
|
+
}
|
|
35
|
+
const latestMasterPipeline = pipelines.filter(p => p.ref === 'master').slice(0, 1);
|
|
36
|
+
if (latestMasterPipeline.length > 0) {
|
|
37
|
+
return latestPipeline.concat(latestMasterPipeline);
|
|
38
|
+
}
|
|
39
|
+
const masterPipelines = await fetchPipelines(projectId, gitlab, { per_page: 50, ref: 'master' });
|
|
40
|
+
return latestPipeline.concat(masterPipelines.slice(0, 1));
|
|
41
|
+
}
|
|
42
|
+
async function fetchPipelines(projectId, gitlab, params) {
|
|
43
|
+
const { data: pipelines } = await gitlabRequest(`/projects/${projectId}/pipelines`, params, gitlab);
|
|
44
|
+
return pipelines.filter(pipeline => pipeline.status !== 'skipped');
|
|
45
|
+
}
|
|
46
|
+
async function fetchDownstreamJobs(projectId, pipelineId, gitlab) {
|
|
47
|
+
const { data: gitlabBridgeJobs } = await gitlabRequest(`/projects/${projectId}/pipelines/${pipelineId}/bridges`, { per_page: 100 }, gitlab);
|
|
48
|
+
const childPipelines = gitlabBridgeJobs.filter((bridge) => bridge.downstream_pipeline !== null && bridge.downstream_pipeline.status !== 'skipped');
|
|
49
|
+
const downstreamStages = [];
|
|
50
|
+
for (const childPipeline of childPipelines) {
|
|
51
|
+
const { stages } = await fetchJobs(childPipeline.downstream_pipeline.project_id, childPipeline.downstream_pipeline.id, gitlab);
|
|
52
|
+
downstreamStages.push(stages.map((stage) => ({
|
|
53
|
+
...stage,
|
|
54
|
+
name: `${childPipeline.stage}:${stage.name}`
|
|
55
|
+
})));
|
|
56
|
+
}
|
|
57
|
+
return downstreamStages.flat();
|
|
58
|
+
}
|
|
59
|
+
async function fetchJobs(projectId, pipelineId, gitlab) {
|
|
60
|
+
const { data: gitlabJobs } = await gitlabRequest(`/projects/${projectId}/pipelines/${pipelineId}/jobs?include_retried=true`, { per_page: 100 }, gitlab);
|
|
61
|
+
if (gitlabJobs.length === 0) {
|
|
62
|
+
return { commit: null, stages: [] };
|
|
63
|
+
}
|
|
64
|
+
const commit = findCommit(gitlabJobs);
|
|
65
|
+
// Map jobs and sort by id
|
|
66
|
+
const mappedJobs = gitlabJobs
|
|
67
|
+
.map(job => ({
|
|
68
|
+
id: job.id,
|
|
69
|
+
status: job.status,
|
|
70
|
+
stage: job.stage,
|
|
71
|
+
name: job.name,
|
|
72
|
+
startedAt: job.started_at,
|
|
73
|
+
finishedAt: job.finished_at,
|
|
74
|
+
url: job.web_url
|
|
75
|
+
}))
|
|
76
|
+
.sort((a, b) => a.id - b.id);
|
|
77
|
+
// Group by stage
|
|
78
|
+
const jobsByStage = new Map();
|
|
79
|
+
for (const job of mappedJobs) {
|
|
80
|
+
const stageJobs = jobsByStage.get(job.stage) || [];
|
|
81
|
+
stageJobs.push(job);
|
|
82
|
+
jobsByStage.set(job.stage, stageJobs);
|
|
83
|
+
}
|
|
84
|
+
// Convert to stages array
|
|
85
|
+
const stages = Array.from(jobsByStage.entries()).map(([name, jobs]) => ({
|
|
86
|
+
name,
|
|
87
|
+
jobs: mergeRetriedJobs(removeStageProperty(jobs)).sort(byName)
|
|
88
|
+
}));
|
|
89
|
+
return {
|
|
90
|
+
commit,
|
|
91
|
+
stages
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function byName(a, b) {
|
|
95
|
+
return a.name.localeCompare(b.name);
|
|
96
|
+
}
|
|
97
|
+
function findCommit(jobs) {
|
|
98
|
+
const [job] = jobs.filter(j => j.commit);
|
|
99
|
+
if (!job || !job.commit) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
title: job.commit.title,
|
|
104
|
+
author: job.commit.author_name
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function mergeRetriedJobs(jobs) {
|
|
108
|
+
return jobs.reduce((mergedJobs, job) => {
|
|
109
|
+
const index = mergedJobs.findIndex(mergedJob => mergedJob.name === job.name);
|
|
110
|
+
if (index >= 0) {
|
|
111
|
+
mergedJobs[index] = job;
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
mergedJobs.push(job);
|
|
115
|
+
}
|
|
116
|
+
return mergedJobs;
|
|
117
|
+
}, []);
|
|
118
|
+
}
|
|
119
|
+
function removeStageProperty(jobs) {
|
|
120
|
+
return jobs.map(job => {
|
|
121
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
122
|
+
const { stage, ...rest } = job;
|
|
123
|
+
return rest;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { gitlabRequest } from "./client.js";
|
|
2
|
+
export async function fetchProjects(gitlab) {
|
|
3
|
+
const projects = await fetchOwnProjects(gitlab);
|
|
4
|
+
return projects
|
|
5
|
+
// Ignore projects for which CI/CD is not enabled
|
|
6
|
+
.filter(project => project.jobs_enabled)
|
|
7
|
+
.map(projectMapper)
|
|
8
|
+
.filter(includeRegexFilter(gitlab))
|
|
9
|
+
.filter(excludeRegexFilter(gitlab))
|
|
10
|
+
.filter(archivedFilter(gitlab));
|
|
11
|
+
}
|
|
12
|
+
async function fetchOwnProjects(gitlab) {
|
|
13
|
+
const projects = [];
|
|
14
|
+
const SAFETY_MAX_PAGE = 10;
|
|
15
|
+
for (let page = 1; page <= SAFETY_MAX_PAGE; page += 1) {
|
|
16
|
+
const { data, headers } = await gitlabRequest('/projects', { page, per_page: 100, membership: true }, gitlab);
|
|
17
|
+
projects.push(...data);
|
|
18
|
+
if (data.length === 0 || !headers['x-next-page']) {
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return projects;
|
|
23
|
+
}
|
|
24
|
+
function projectMapper(project) {
|
|
25
|
+
return {
|
|
26
|
+
id: project.id,
|
|
27
|
+
name: project.path_with_namespace,
|
|
28
|
+
nameWithoutNamespace: project.path,
|
|
29
|
+
group: getGroupName(project),
|
|
30
|
+
archived: project.archived,
|
|
31
|
+
default_branch: project.default_branch || 'master',
|
|
32
|
+
url: project.web_url,
|
|
33
|
+
topics: (project.topics || []).map((t) => t.toLowerCase())
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function getGroupName(project) {
|
|
37
|
+
const pathWithNameSpace = project.path_with_namespace;
|
|
38
|
+
return pathWithNameSpace.split('/')[0];
|
|
39
|
+
}
|
|
40
|
+
function includeRegexFilter(gitlab) {
|
|
41
|
+
return (project) => {
|
|
42
|
+
if (gitlab.projects?.include) {
|
|
43
|
+
const includeRegex = new RegExp(gitlab.projects.include, "i");
|
|
44
|
+
return includeRegex.test(project.name);
|
|
45
|
+
}
|
|
46
|
+
return true;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function excludeRegexFilter(gitlab) {
|
|
50
|
+
return (project) => {
|
|
51
|
+
if (gitlab.projects?.exclude) {
|
|
52
|
+
const excludeRegex = new RegExp(gitlab.projects.exclude, "i");
|
|
53
|
+
return !excludeRegex.test(project.name);
|
|
54
|
+
}
|
|
55
|
+
return true;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function archivedFilter(gitlab) {
|
|
59
|
+
return (project) => {
|
|
60
|
+
if (gitlab.ignoreArchived) {
|
|
61
|
+
return !project.archived;
|
|
62
|
+
}
|
|
63
|
+
return true;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { gitlabRequest } from "./client.js";
|
|
2
|
+
export async function fetchOfflineRunners(gitlab) {
|
|
3
|
+
if (gitlab.offlineRunners === 'none') {
|
|
4
|
+
return {
|
|
5
|
+
offline: [],
|
|
6
|
+
totalCount: 0
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
const runners = await fetchRunners(gitlab);
|
|
10
|
+
const offline = runners.filter(r => r.status === 'offline');
|
|
11
|
+
return {
|
|
12
|
+
offline,
|
|
13
|
+
totalCount: runners.length
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
async function fetchRunners(gitlab) {
|
|
17
|
+
const runnersApi = gitlab.offlineRunners === 'all' ? '/runners/all' : '/runners';
|
|
18
|
+
const { data: runners } = await gitlabRequest(runnersApi, null, gitlab);
|
|
19
|
+
return runners.map(r => ({
|
|
20
|
+
name: r.description || r.id.toString(),
|
|
21
|
+
status: r.status
|
|
22
|
+
}));
|
|
23
|
+
}
|
package/src/less.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import less from 'less';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { config } from "./config.js";
|
|
5
|
+
const filename = path.join('public', 'client.less');
|
|
6
|
+
export async function serveLessAsCss(_req, res) {
|
|
7
|
+
try {
|
|
8
|
+
const source = await fs.promises.readFile(filename, 'utf-8');
|
|
9
|
+
const { css } = await less.render(withColorOverrides(source), { filename });
|
|
10
|
+
res.setHeader('content-type', 'text/css');
|
|
11
|
+
res.send(css);
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
console.error('Failed to render client.less', err);
|
|
15
|
+
res.sendStatus(500);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function withColorOverrides(source) {
|
|
19
|
+
const { colors } = config;
|
|
20
|
+
if (!colors) {
|
|
21
|
+
return source;
|
|
22
|
+
}
|
|
23
|
+
let colorLess = '';
|
|
24
|
+
Object.keys(colors).forEach((stateName) => {
|
|
25
|
+
colorLess += `@${stateName}-color:${colors[stateName]};`;
|
|
26
|
+
});
|
|
27
|
+
return source + colorLess;
|
|
28
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"root":["../../src/app.ts","../../src/auth.ts","../../src/config.ts","../../src/dev-assets.ts","../../src/less.ts","../../src/common/gitlab-types.d.ts","../../src/gitlab/client.ts","../../src/gitlab/index.ts","../../src/gitlab/pipelines.ts","../../src/gitlab/projects.ts","../../src/gitlab/runners.ts"],"version":"5.9.3"}
|
package/src/app.ts
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
import {basicAuth} from './auth.ts'
|
|
2
|
-
import compression from 'compression'
|
|
3
|
-
import {config} from './config.ts'
|
|
4
|
-
import express from 'express'
|
|
5
|
-
import fs from 'fs'
|
|
6
|
-
import {fetchOfflineRunners} from './gitlab/runners.ts'
|
|
7
|
-
import http from 'http'
|
|
8
|
-
import {serveLessAsCss} from './less.ts'
|
|
9
|
-
import {Server} from 'socket.io'
|
|
10
|
-
import {update} from './gitlab/index.ts'
|
|
11
|
-
import type {GlobalState} from './common/gitlab-types.d.ts'
|
|
12
|
-
|
|
13
|
-
const app = express()
|
|
14
|
-
const httpServer = new http.Server(app)
|
|
15
|
-
const socketIoServer = new Server(httpServer)
|
|
16
|
-
|
|
17
|
-
if (process.env.NODE_ENV !== 'production' && fs.existsSync('./src/dev-assets.ts')) {
|
|
18
|
-
const {bindDevAssets} = await import('./dev-assets.ts')
|
|
19
|
-
bindDevAssets(app)
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
app.disable('x-powered-by')
|
|
23
|
-
app.get('/client.css', serveLessAsCss)
|
|
24
|
-
app.use(express.static('public'))
|
|
25
|
-
app.use(compression())
|
|
26
|
-
app.use(basicAuth(config.auth))
|
|
27
|
-
|
|
28
|
-
httpServer.listen(config.port, () => {
|
|
29
|
-
console.log(`Listening on port *:${config.port}`)
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
const globalState: Omit<GlobalState, 'now'> = {
|
|
33
|
-
projects: null,
|
|
34
|
-
error: null,
|
|
35
|
-
zoom: config.zoom,
|
|
36
|
-
projectsOrder: config.projectsOrder,
|
|
37
|
-
columns: config.columns,
|
|
38
|
-
horizontal: config.horizontal,
|
|
39
|
-
rotateRunningPipelines: config.rotateRunningPipelines,
|
|
40
|
-
groupSuccessfulProjects: config.groupSuccessfulProjects
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
socketIoServer.on('connection', (socket) => {
|
|
44
|
-
socket.emit('state', withDate(globalState))
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
async function runUpdate() {
|
|
48
|
-
try {
|
|
49
|
-
globalState.projects = await update(config.gitlabs, config.rotateRunningPipelines > 0)
|
|
50
|
-
globalState.error = await errorIfRunnerOffline()
|
|
51
|
-
socketIoServer.emit('state', withDate(globalState))
|
|
52
|
-
} catch (error: unknown) {
|
|
53
|
-
const message = error instanceof Error ? error.message : String(error)
|
|
54
|
-
console.error(message)
|
|
55
|
-
globalState.error = `Failed to communicate with GitLab API: ${message}`
|
|
56
|
-
socketIoServer.emit('state', withDate(globalState))
|
|
57
|
-
}
|
|
58
|
-
setTimeout(runUpdate, config.interval)
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
async function errorIfRunnerOffline() {
|
|
62
|
-
const offlineRunnersPerGitlab = await Promise.all(config.gitlabs.map(fetchOfflineRunners))
|
|
63
|
-
const {offline, totalCount} = offlineRunnersPerGitlab.reduce((acc, runner) => {
|
|
64
|
-
return {
|
|
65
|
-
offline: acc.offline.concat(runner.offline),
|
|
66
|
-
totalCount: acc.totalCount + runner.totalCount
|
|
67
|
-
}
|
|
68
|
-
}, {offline: [], totalCount: 0})
|
|
69
|
-
|
|
70
|
-
if (offline.length > 0) {
|
|
71
|
-
const names = offline.map(r => r.name).sort().join(', ')
|
|
72
|
-
const counts = offline.length === totalCount ? 'All' : `${offline.length}/${totalCount}`
|
|
73
|
-
return `${counts} runners offline: ${names}`
|
|
74
|
-
}
|
|
75
|
-
return null
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
await runUpdate()
|
|
79
|
-
|
|
80
|
-
function withDate(state: Omit<GlobalState, 'now'>): GlobalState {
|
|
81
|
-
return {
|
|
82
|
-
...state,
|
|
83
|
-
now: Date.now()
|
|
84
|
-
}
|
|
85
|
-
}
|
package/src/auth.ts
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import authenticate from 'basic-auth'
|
|
2
|
-
import type {NextFunction, Request, Response} from 'express'
|
|
3
|
-
|
|
4
|
-
export function basicAuth(auth: {username: string; password: string } | undefined) {
|
|
5
|
-
if (!auth) {
|
|
6
|
-
console.log('No authentication configured')
|
|
7
|
-
return (req: Request, res: Response, next: NextFunction) => next()
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
console.log('HTTP basic auth enabled')
|
|
11
|
-
return (req: Request, res: Response, next: NextFunction) => {
|
|
12
|
-
const {name, pass} = authenticate(req) || ({} as {name?: string; pass?: string})
|
|
13
|
-
if (auth.username === name && auth.password === pass) {
|
|
14
|
-
next()
|
|
15
|
-
} else {
|
|
16
|
-
res.setHeader('WWW-Authenticate', 'Basic realm="gitlab-radiator"')
|
|
17
|
-
res.status(401).end()
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
}
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
export interface GlobalState {
|
|
2
|
-
columns: number
|
|
3
|
-
error: string | null
|
|
4
|
-
groupSuccessfulProjects: boolean
|
|
5
|
-
horizontal: boolean
|
|
6
|
-
rotateRunningPipelines: number
|
|
7
|
-
projects: Project[] | null
|
|
8
|
-
projectsOrder: Array<ProjectsOrder>
|
|
9
|
-
zoom: number
|
|
10
|
-
now: number
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface Project {
|
|
14
|
-
archived: boolean
|
|
15
|
-
group: string
|
|
16
|
-
id: number
|
|
17
|
-
name: string
|
|
18
|
-
nameWithoutNamespace: string
|
|
19
|
-
topics: string[]
|
|
20
|
-
url: string
|
|
21
|
-
default_branch: string
|
|
22
|
-
pipelines: Pipeline[]
|
|
23
|
-
maxNonFailedJobsVisible: number
|
|
24
|
-
status: JobStatus
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// Keys that represent either string or number values, and can be compared with < and >
|
|
28
|
-
export type ProjectsOrder = keyof Pick<Project, 'status' | 'name' | 'id' | 'nameWithoutNamespace' | 'group'>
|
|
29
|
-
|
|
30
|
-
export interface Pipeline {
|
|
31
|
-
commit: Commit | null
|
|
32
|
-
id: number
|
|
33
|
-
ref: string
|
|
34
|
-
stages: Stage[]
|
|
35
|
-
status: JobStatus
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export interface Commit {
|
|
39
|
-
title: string
|
|
40
|
-
author: string
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export interface Stage {
|
|
44
|
-
jobs: Job[]
|
|
45
|
-
name: string
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export interface Job {
|
|
49
|
-
finishedAt: string | null
|
|
50
|
-
id: number
|
|
51
|
-
name: string
|
|
52
|
-
startedAt: string | null
|
|
53
|
-
status: JobStatus
|
|
54
|
-
url: string
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export type JobStatus = 'canceled' | 'created' | 'failed' | 'manual' | 'pending' | 'running' | 'skipped' | 'success'
|
package/src/config.ts
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
import fs from 'fs'
|
|
2
|
-
import os from 'os'
|
|
3
|
-
import yaml from 'js-yaml'
|
|
4
|
-
import {z} from 'zod'
|
|
5
|
-
|
|
6
|
-
function expandTilde(path: string) {
|
|
7
|
-
return path.replace(/^~($|\/|\\)/, `${os.homedir()}$1`)
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
const JobStatusSchema = z.literal(['canceled', 'created', 'failed', 'manual', 'pending', 'running', 'skipped', 'success'])
|
|
11
|
-
export type JobStatus = z.infer<typeof JobStatusSchema>
|
|
12
|
-
|
|
13
|
-
const GitlabSchema = z.strictObject({
|
|
14
|
-
url: z.string().min(1, 'Mandatory gitlab url missing from configuration file'),
|
|
15
|
-
'access-token': z.string().min(1).optional(),
|
|
16
|
-
ignoreArchived: z.boolean().default(true),
|
|
17
|
-
maxNonFailedJobsVisible: z.coerce.number().int().default(999999),
|
|
18
|
-
branch: z.string().min(1).optional(),
|
|
19
|
-
caFile: z.string().optional(),
|
|
20
|
-
offlineRunners: z.literal(['all', 'default', 'none']).default('default'),
|
|
21
|
-
projects: z.strictObject({
|
|
22
|
-
excludePipelineStatus: z.array(JobStatusSchema).optional(),
|
|
23
|
-
include: z.string().min(1).optional(),
|
|
24
|
-
exclude: z.string().min(1).optional()
|
|
25
|
-
}).optional()
|
|
26
|
-
}).transform(gitlab => {
|
|
27
|
-
const accessToken = gitlab['access-token'] || process.env.GITLAB_ACCESS_TOKEN
|
|
28
|
-
if (!accessToken) {
|
|
29
|
-
throw new Error('Mandatory gitlab access token missing from configuration (and none present at GITLAB_ACCESS_TOKEN env variable)')
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const {url, ignoreArchived, maxNonFailedJobsVisible, caFile, branch,offlineRunners, projects} = gitlab
|
|
33
|
-
const ca = caFile && fs.existsSync(caFile) ? fs.readFileSync(caFile, 'utf-8') : undefined
|
|
34
|
-
|
|
35
|
-
return {
|
|
36
|
-
url,
|
|
37
|
-
ignoreArchived,
|
|
38
|
-
maxNonFailedJobsVisible,
|
|
39
|
-
branch,
|
|
40
|
-
ca,
|
|
41
|
-
offlineRunners,
|
|
42
|
-
'access-token': accessToken,
|
|
43
|
-
projects
|
|
44
|
-
}
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
export type Gitlab = z.infer<typeof GitlabSchema>
|
|
48
|
-
|
|
49
|
-
const ColorSchema = z.literal([
|
|
50
|
-
'background',
|
|
51
|
-
'created-background',
|
|
52
|
-
'created-text',
|
|
53
|
-
'dark-text',
|
|
54
|
-
'error-message-background',
|
|
55
|
-
'error-message-text',
|
|
56
|
-
'failed-background',
|
|
57
|
-
'failed-text',
|
|
58
|
-
'group-background',
|
|
59
|
-
'light-text',
|
|
60
|
-
'pending-background',
|
|
61
|
-
'pending-text',
|
|
62
|
-
'project-background',
|
|
63
|
-
'running-background',
|
|
64
|
-
'running-text',
|
|
65
|
-
'skipped-background',
|
|
66
|
-
'skipped-text',
|
|
67
|
-
'success-background',
|
|
68
|
-
'success-text'
|
|
69
|
-
])
|
|
70
|
-
|
|
71
|
-
const OrderSchema = z.literal(['status', 'name', 'id', 'nameWithoutNamespace', 'group'])
|
|
72
|
-
|
|
73
|
-
const ConfigSchema = z.strictObject({
|
|
74
|
-
interval: z.coerce.number().default(10).transform(sec => sec * 1000),
|
|
75
|
-
port: z.coerce.number().int().default(3000),
|
|
76
|
-
zoom: z.coerce.number().default(1.0),
|
|
77
|
-
columns: z.coerce.number().int().default(1),
|
|
78
|
-
horizontal: z.boolean().default(false),
|
|
79
|
-
rotateRunningPipelines: z.coerce.number().min(0).default(0).transform(sec => sec * 1000),
|
|
80
|
-
groupSuccessfulProjects: z.boolean().default(false),
|
|
81
|
-
projectsOrder: z.array(OrderSchema).default(['name']),
|
|
82
|
-
gitlabs: z.array(GitlabSchema).min(1, {message: 'Mandatory gitlab properties missing from configuration file'}),
|
|
83
|
-
colors: z.record(ColorSchema, z.string()).optional(),
|
|
84
|
-
auth: z.strictObject({
|
|
85
|
-
username: z.string(),
|
|
86
|
-
password: z.string()
|
|
87
|
-
}).optional()
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
export type Config = z.infer<typeof ConfigSchema>
|
|
91
|
-
|
|
92
|
-
const configFile = expandTilde(process.env.GITLAB_RADIATOR_CONFIG || '~/.gitlab-radiator.yml')
|
|
93
|
-
const yamlContent = fs.readFileSync(configFile, 'utf8')
|
|
94
|
-
const rawConfig = yaml.load(yamlContent)
|
|
95
|
-
export const config = ConfigSchema.parse(rawConfig)
|
package/src/gitlab/client.ts
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import axios from 'axios'
|
|
2
|
-
import https from 'https'
|
|
3
|
-
import url from 'url'
|
|
4
|
-
import type {AxiosInstance} from 'axios'
|
|
5
|
-
import type {Gitlab} from '../config'
|
|
6
|
-
|
|
7
|
-
export type PartialGitlab = Pick<Gitlab, 'url' | 'access-token' | 'ca' | 'branch'>
|
|
8
|
-
|
|
9
|
-
export interface GitlabRequestParams {
|
|
10
|
-
page?: number
|
|
11
|
-
per_page?: number
|
|
12
|
-
membership?: boolean
|
|
13
|
-
ref?: string
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function gitlabRequest<T>(pathStr: string, params: GitlabRequestParams | null, gitlab: PartialGitlab) {
|
|
17
|
-
return lazyClient(gitlab).get<T>(pathStr, {params: params || {}})
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const clients = new Map<string, AxiosInstance>()
|
|
21
|
-
|
|
22
|
-
function lazyClient(gitlab: PartialGitlab) {
|
|
23
|
-
let client = clients.get(gitlab.url)
|
|
24
|
-
if (!client) {
|
|
25
|
-
client = axios.create({
|
|
26
|
-
baseURL: url.resolve(gitlab.url, '/api/v4/'),
|
|
27
|
-
headers: {'PRIVATE-TOKEN': gitlab['access-token']},
|
|
28
|
-
httpsAgent: new https.Agent({keepAlive: true, ca: gitlab.ca}),
|
|
29
|
-
timeout: 30 * 1000
|
|
30
|
-
})
|
|
31
|
-
clients.set(gitlab.url, client)
|
|
32
|
-
}
|
|
33
|
-
return client
|
|
34
|
-
}
|
package/src/gitlab/index.ts
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import {fetchLatestPipelines} from './pipelines.ts'
|
|
2
|
-
import {fetchProjects} from './projects.ts'
|
|
3
|
-
import type {Gitlab} from '../config.ts'
|
|
4
|
-
import type {PartialProject} from './projects.ts'
|
|
5
|
-
import type {Pipeline, Project} from '../common/gitlab-types.d.ts'
|
|
6
|
-
|
|
7
|
-
export async function update(gitlabs: Gitlab[], prioritizeRunningPipelines: boolean): Promise<Project[]> {
|
|
8
|
-
const projectsWithPipelines = await loadProjectsWithPipelines(gitlabs, prioritizeRunningPipelines)
|
|
9
|
-
return projectsWithPipelines
|
|
10
|
-
.filter((project: Project) => project.pipelines.length > 0)
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
async function loadProjectsWithPipelines(gitlabs: Gitlab[], prioritizeRunningPipelines: boolean): Promise<Project[]> {
|
|
14
|
-
const allProjectsWithPipelines: Project[] = []
|
|
15
|
-
await Promise.all(gitlabs.map(async gitlab => {
|
|
16
|
-
const projects = (await fetchProjects(gitlab))
|
|
17
|
-
.map(project => ({
|
|
18
|
-
...project,
|
|
19
|
-
maxNonFailedJobsVisible: gitlab.maxNonFailedJobsVisible
|
|
20
|
-
}))
|
|
21
|
-
|
|
22
|
-
for (const project of projects) {
|
|
23
|
-
allProjectsWithPipelines.push(await projectWithPipelines(project, gitlab, prioritizeRunningPipelines))
|
|
24
|
-
}
|
|
25
|
-
}))
|
|
26
|
-
return allProjectsWithPipelines
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
async function projectWithPipelines(project: PartialProject, gitlab: Gitlab, prioritizeRunningPipelines: boolean): Promise<Project> {
|
|
30
|
-
const pipelines = filterOutEmpty(await fetchLatestPipelines(project.id, gitlab, prioritizeRunningPipelines))
|
|
31
|
-
.filter(excludePipelineStatusFilter(gitlab))
|
|
32
|
-
const status = defaultBranchStatus(project, pipelines)
|
|
33
|
-
return {
|
|
34
|
-
...project,
|
|
35
|
-
maxNonFailedJobsVisible: gitlab.maxNonFailedJobsVisible,
|
|
36
|
-
pipelines,
|
|
37
|
-
status
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function defaultBranchStatus(project: PartialProject, pipelines: Pipeline[]) {
|
|
42
|
-
const [head] = pipelines
|
|
43
|
-
.filter(({ref}) => ref === project.default_branch)
|
|
44
|
-
.map(({status}) => status)
|
|
45
|
-
return head
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function filterOutEmpty(pipelines: Pipeline[]): Pipeline[] {
|
|
49
|
-
return pipelines.filter(pipeline => pipeline.stages)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function excludePipelineStatusFilter(gitlab: Gitlab): (pipeline: Pipeline) => boolean {
|
|
53
|
-
return (pipeline: Pipeline) => {
|
|
54
|
-
if (gitlab.projects?.excludePipelineStatus) {
|
|
55
|
-
return !gitlab.projects.excludePipelineStatus.includes(pipeline.status)
|
|
56
|
-
}
|
|
57
|
-
return true
|
|
58
|
-
}
|
|
59
|
-
}
|
package/src/gitlab/pipelines.ts
DELETED
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
import {gitlabRequest} from './client.ts'
|
|
2
|
-
import type {Commit, Job, JobStatus, Pipeline, Stage} from '../common/gitlab-types.d.ts'
|
|
3
|
-
import type {GitlabRequestParams, PartialGitlab} from './client.ts'
|
|
4
|
-
|
|
5
|
-
interface GitlabPipelineResponse {
|
|
6
|
-
id: number
|
|
7
|
-
ref: string
|
|
8
|
-
status: JobStatus
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
// project_id is undocumented by docs.gitlab.com but is present in the API response
|
|
12
|
-
interface GitlabDownstreamPipeline {
|
|
13
|
-
id: number
|
|
14
|
-
project_id: number
|
|
15
|
-
status: JobStatus
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
interface GitlabPipelineTriggerResponse {
|
|
19
|
-
stage: string
|
|
20
|
-
downstream_pipeline: GitlabDownstreamPipeline | null
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
interface GitlabJobResponse {
|
|
24
|
-
id: number
|
|
25
|
-
name: string
|
|
26
|
-
stage: string
|
|
27
|
-
status: JobStatus
|
|
28
|
-
started_at: string | null
|
|
29
|
-
finished_at: string | null
|
|
30
|
-
web_url: string
|
|
31
|
-
commit: {
|
|
32
|
-
title: string
|
|
33
|
-
author_name: string
|
|
34
|
-
} | null
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export async function fetchLatestPipelines(projectId: number, gitlab: PartialGitlab, prioritizeRunningPipelines: boolean): Promise<Pipeline[]> {
|
|
38
|
-
const pipelines = await fetchLatestAndMasterPipeline(projectId, gitlab, prioritizeRunningPipelines)
|
|
39
|
-
|
|
40
|
-
const pipelinesWithStages: Pipeline[] = []
|
|
41
|
-
for (const {id, ref, status} of pipelines) {
|
|
42
|
-
const {commit, stages} = await fetchJobs(projectId, id, gitlab)
|
|
43
|
-
const downstreamStages = await fetchDownstreamJobs(projectId, id, gitlab)
|
|
44
|
-
pipelinesWithStages.push({
|
|
45
|
-
id,
|
|
46
|
-
ref,
|
|
47
|
-
status,
|
|
48
|
-
commit,
|
|
49
|
-
stages: stages.concat(downstreamStages)
|
|
50
|
-
})
|
|
51
|
-
}
|
|
52
|
-
return pipelinesWithStages
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async function fetchLatestAndMasterPipeline(projectId: number, gitlab: PartialGitlab, prioritizeRunningPipelines: boolean): Promise<GitlabPipelineResponse[]> {
|
|
56
|
-
const options = {
|
|
57
|
-
per_page: 100,
|
|
58
|
-
...(gitlab.branch ? {ref: gitlab.branch} : {})
|
|
59
|
-
}
|
|
60
|
-
const pipelines = await fetchPipelines(projectId, gitlab, options)
|
|
61
|
-
if (pipelines.length === 0) {
|
|
62
|
-
return []
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const runningPipelines = pipelines.filter(pipeline => pipeline.status === 'running')
|
|
66
|
-
if (runningPipelines.length > 1 && prioritizeRunningPipelines) {
|
|
67
|
-
return runningPipelines
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const latestPipeline = pipelines.slice(0, 1)
|
|
71
|
-
if (latestPipeline[0].ref === 'master') {
|
|
72
|
-
return latestPipeline
|
|
73
|
-
}
|
|
74
|
-
const latestMasterPipeline = pipelines.filter(p => p.ref === 'master').slice(0, 1)
|
|
75
|
-
if (latestMasterPipeline.length > 0) {
|
|
76
|
-
return latestPipeline.concat(latestMasterPipeline)
|
|
77
|
-
}
|
|
78
|
-
const masterPipelines = await fetchPipelines(projectId, gitlab, {per_page: 50, ref: 'master'})
|
|
79
|
-
return latestPipeline.concat(masterPipelines.slice(0, 1))
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
async function fetchPipelines(projectId: number, gitlab: PartialGitlab, params: GitlabRequestParams) {
|
|
83
|
-
const {data: pipelines} = await gitlabRequest<GitlabPipelineResponse[]>(`/projects/${projectId}/pipelines`, params, gitlab)
|
|
84
|
-
return pipelines.filter(pipeline => pipeline.status !== 'skipped')
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
async function fetchDownstreamJobs(projectId: number, pipelineId: number, gitlab: PartialGitlab): Promise<Stage[]> {
|
|
88
|
-
const {data: gitlabBridgeJobs} = await gitlabRequest<GitlabPipelineTriggerResponse[]>(`/projects/${projectId}/pipelines/${pipelineId}/bridges`, {per_page: 100}, gitlab)
|
|
89
|
-
const childPipelines = gitlabBridgeJobs.filter((bridge): bridge is GitlabPipelineTriggerResponse & {downstream_pipeline: GitlabDownstreamPipeline} =>
|
|
90
|
-
bridge.downstream_pipeline !== null && bridge.downstream_pipeline.status !== 'skipped'
|
|
91
|
-
)
|
|
92
|
-
|
|
93
|
-
const downstreamStages: Stage[][] = []
|
|
94
|
-
for(const childPipeline of childPipelines) {
|
|
95
|
-
const {stages} = await fetchJobs(childPipeline.downstream_pipeline.project_id, childPipeline.downstream_pipeline.id, gitlab)
|
|
96
|
-
downstreamStages.push(stages.map((stage: Stage) => ({
|
|
97
|
-
...stage,
|
|
98
|
-
name: `${childPipeline.stage}:${stage.name}`
|
|
99
|
-
})))
|
|
100
|
-
}
|
|
101
|
-
return downstreamStages.flat()
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
async function fetchJobs(projectId: number, pipelineId: number, gitlab: PartialGitlab): Promise<{commit: Commit | null, stages: Stage[]}> {
|
|
105
|
-
const {data: gitlabJobs} = await gitlabRequest<GitlabJobResponse[]>(`/projects/${projectId}/pipelines/${pipelineId}/jobs?include_retried=true`, {per_page: 100}, gitlab)
|
|
106
|
-
if (gitlabJobs.length === 0) {
|
|
107
|
-
return {commit: null, stages: []}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const commit = findCommit(gitlabJobs)
|
|
111
|
-
|
|
112
|
-
// Map jobs and sort by id
|
|
113
|
-
const mappedJobs = gitlabJobs
|
|
114
|
-
.map(job => ({
|
|
115
|
-
id: job.id,
|
|
116
|
-
status: job.status,
|
|
117
|
-
stage: job.stage,
|
|
118
|
-
name: job.name,
|
|
119
|
-
startedAt: job.started_at,
|
|
120
|
-
finishedAt: job.finished_at,
|
|
121
|
-
url: job.web_url
|
|
122
|
-
} satisfies Job & {stage: string}))
|
|
123
|
-
.sort((a, b) => a.id - b.id)
|
|
124
|
-
|
|
125
|
-
// Group by stage
|
|
126
|
-
const jobsByStage = new Map<string, Array<Job & {stage: string}>>()
|
|
127
|
-
for (const job of mappedJobs) {
|
|
128
|
-
const stageJobs = jobsByStage.get(job.stage) || []
|
|
129
|
-
stageJobs.push(job)
|
|
130
|
-
jobsByStage.set(job.stage, stageJobs)
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Convert to stages array
|
|
134
|
-
const stages = Array.from(jobsByStage.entries()).map(([name, jobs]) => ({
|
|
135
|
-
name,
|
|
136
|
-
jobs: mergeRetriedJobs(removeStageProperty(jobs)).sort(byName)
|
|
137
|
-
}))
|
|
138
|
-
|
|
139
|
-
return {
|
|
140
|
-
commit,
|
|
141
|
-
stages
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function byName(a: Job, b: Job): number {
|
|
146
|
-
return a.name.localeCompare(b.name)
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function findCommit(jobs: GitlabJobResponse[]): Commit | null {
|
|
150
|
-
const [job] = jobs.filter(j => j.commit)
|
|
151
|
-
if (!job || !job.commit) {
|
|
152
|
-
return null
|
|
153
|
-
}
|
|
154
|
-
return {
|
|
155
|
-
title: job.commit.title,
|
|
156
|
-
author: job.commit.author_name
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function mergeRetriedJobs(jobs: Job[]): Job[] {
|
|
161
|
-
return jobs.reduce((mergedJobs: Job[], job: Job) => {
|
|
162
|
-
const index = mergedJobs.findIndex(mergedJob => mergedJob.name === job.name)
|
|
163
|
-
if (index >= 0) {
|
|
164
|
-
mergedJobs[index] = job
|
|
165
|
-
} else {
|
|
166
|
-
mergedJobs.push(job)
|
|
167
|
-
}
|
|
168
|
-
return mergedJobs
|
|
169
|
-
}, [])
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function removeStageProperty(jobs: Array<Job & {stage: string}>): Job[] {
|
|
173
|
-
return jobs.map(job => {
|
|
174
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
175
|
-
const {stage, ...rest} = job
|
|
176
|
-
return rest
|
|
177
|
-
})
|
|
178
|
-
}
|
package/src/gitlab/projects.ts
DELETED
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
import {gitlabRequest} from './client.ts'
|
|
2
|
-
import type {Gitlab} from '../config.ts'
|
|
3
|
-
import type {Project} from '../common/gitlab-types.d.ts'
|
|
4
|
-
|
|
5
|
-
export type PartialProject = Omit<Project, 'pipelines' | 'maxNonFailedJobsVisible' | 'status'>
|
|
6
|
-
|
|
7
|
-
interface GitlabProjectResponse {
|
|
8
|
-
id: number
|
|
9
|
-
path: string
|
|
10
|
-
path_with_namespace: string
|
|
11
|
-
archived: boolean
|
|
12
|
-
default_branch: string | null
|
|
13
|
-
web_url: string
|
|
14
|
-
topics?: string[]
|
|
15
|
-
jobs_enabled: boolean
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export async function fetchProjects(gitlab: Gitlab): Promise<PartialProject[]> {
|
|
19
|
-
const projects = await fetchOwnProjects(gitlab)
|
|
20
|
-
return projects
|
|
21
|
-
// Ignore projects for which CI/CD is not enabled
|
|
22
|
-
.filter(project => project.jobs_enabled)
|
|
23
|
-
.map(projectMapper)
|
|
24
|
-
.filter(includeRegexFilter(gitlab))
|
|
25
|
-
.filter(excludeRegexFilter(gitlab))
|
|
26
|
-
.filter(archivedFilter(gitlab))
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
async function fetchOwnProjects(gitlab: Gitlab) {
|
|
30
|
-
const projects: GitlabProjectResponse[] = []
|
|
31
|
-
const SAFETY_MAX_PAGE = 10
|
|
32
|
-
for (let page = 1; page <= SAFETY_MAX_PAGE; page += 1) {
|
|
33
|
-
const {data, headers} = await gitlabRequest<GitlabProjectResponse[]>('/projects', {page, per_page: 100, membership: true}, gitlab)
|
|
34
|
-
projects.push(...data)
|
|
35
|
-
if (data.length === 0 || !headers['x-next-page']) {
|
|
36
|
-
break
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
return projects
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function projectMapper(project: GitlabProjectResponse): PartialProject {
|
|
43
|
-
return {
|
|
44
|
-
id: project.id,
|
|
45
|
-
name: project.path_with_namespace,
|
|
46
|
-
nameWithoutNamespace: project.path,
|
|
47
|
-
group: getGroupName(project),
|
|
48
|
-
archived: project.archived,
|
|
49
|
-
default_branch: project.default_branch || 'master',
|
|
50
|
-
url: project.web_url,
|
|
51
|
-
topics: (project.topics || []).map((t: string) => t.toLowerCase())
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function getGroupName(project: GitlabProjectResponse) {
|
|
56
|
-
const pathWithNameSpace = project.path_with_namespace
|
|
57
|
-
return pathWithNameSpace.split('/')[0]
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function includeRegexFilter(gitlab: Gitlab) {
|
|
61
|
-
return (project: PartialProject) => {
|
|
62
|
-
if (gitlab.projects?.include) {
|
|
63
|
-
const includeRegex = new RegExp(gitlab.projects.include, "i")
|
|
64
|
-
return includeRegex.test(project.name)
|
|
65
|
-
}
|
|
66
|
-
return true
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function excludeRegexFilter(gitlab: Gitlab) {
|
|
71
|
-
return (project: PartialProject) => {
|
|
72
|
-
if (gitlab.projects?.exclude) {
|
|
73
|
-
const excludeRegex = new RegExp(gitlab.projects.exclude, "i")
|
|
74
|
-
return !excludeRegex.test(project.name)
|
|
75
|
-
}
|
|
76
|
-
return true
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function archivedFilter(gitlab: Gitlab) {
|
|
81
|
-
return (project: PartialProject) => {
|
|
82
|
-
if (gitlab.ignoreArchived) {
|
|
83
|
-
return !project.archived
|
|
84
|
-
}
|
|
85
|
-
return true
|
|
86
|
-
}
|
|
87
|
-
}
|
package/src/gitlab/runners.ts
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import {gitlabRequest} from './client.ts'
|
|
2
|
-
import type {Gitlab} from '../config.ts'
|
|
3
|
-
|
|
4
|
-
type RunnerStatus = 'online' | 'offline' | 'stale' | 'never_contacted'
|
|
5
|
-
|
|
6
|
-
export async function fetchOfflineRunners(gitlab: Gitlab): Promise<{offline: {name: string, status: RunnerStatus}[], totalCount: number}> {
|
|
7
|
-
if (gitlab.offlineRunners === 'none') {
|
|
8
|
-
return {
|
|
9
|
-
offline: [],
|
|
10
|
-
totalCount: 0
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const runners = await fetchRunners(gitlab)
|
|
15
|
-
const offline = runners.filter(r => r.status === 'offline')
|
|
16
|
-
return {
|
|
17
|
-
offline,
|
|
18
|
-
totalCount: runners.length
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
interface GitlabRunnerResponse {
|
|
23
|
-
id: number
|
|
24
|
-
description?: string
|
|
25
|
-
status: RunnerStatus
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
async function fetchRunners(gitlab: Gitlab) {
|
|
29
|
-
const runnersApi = gitlab.offlineRunners === 'all' ? '/runners/all' : '/runners'
|
|
30
|
-
const {data: runners} = await gitlabRequest<GitlabRunnerResponse[]>(runnersApi, null, gitlab)
|
|
31
|
-
return runners.map(r => ({
|
|
32
|
-
name: r.description || r.id.toString(),
|
|
33
|
-
status: r.status
|
|
34
|
-
}))
|
|
35
|
-
}
|
package/src/less.ts
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import fs from 'fs'
|
|
2
|
-
import less from 'less'
|
|
3
|
-
import path from 'path'
|
|
4
|
-
import {config} from './config.ts'
|
|
5
|
-
import type {Config} from './config.ts'
|
|
6
|
-
import type {Request, Response} from 'express'
|
|
7
|
-
|
|
8
|
-
const filename = path.join('public', 'client.less')
|
|
9
|
-
|
|
10
|
-
export async function serveLessAsCss(_req: Request, res: Response) {
|
|
11
|
-
try {
|
|
12
|
-
const source = await fs.promises.readFile(filename, 'utf-8')
|
|
13
|
-
const {css} = await less.render(withColorOverrides(source), {filename})
|
|
14
|
-
res.setHeader('content-type', 'text/css')
|
|
15
|
-
res.send(css)
|
|
16
|
-
} catch (err) {
|
|
17
|
-
console.error('Failed to render client.less', err)
|
|
18
|
-
res.sendStatus(500)
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function withColorOverrides(source: string) {
|
|
23
|
-
const {colors} = config
|
|
24
|
-
if (!colors) {
|
|
25
|
-
return source
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
let colorLess = ''
|
|
29
|
-
Object.keys(colors).forEach((stateName) => {
|
|
30
|
-
colorLess += `@${stateName}-color:${colors[stateName as keyof Config["colors"]]};`
|
|
31
|
-
})
|
|
32
|
-
return source + colorLess
|
|
33
|
-
}
|