gitlab-radiator 4.4.5 → 5.0.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.
@@ -215,6 +215,11 @@ ol.jobs {
215
215
  background-color: @success-background-color;
216
216
  }
217
217
 
218
+ &.manual {
219
+ color: @manual-text-color;
220
+ background-color: @manual-background-color;
221
+ }
222
+
218
223
  &.running {
219
224
  color: @running-text-color;
220
225
  background: repeating-linear-gradient(
@@ -16,6 +16,8 @@
16
16
  @failed-background-color: rgb(204, 208, 0);
17
17
  @running-text-color: @light-text-color;
18
18
  @running-background-color: @success-background-color;
19
+ @manual-text-color: @light-text-color;
20
+ @manual-background-color: @group-background-color;
19
21
 
20
22
  @error-message-text-color: rgb(255, 0, 0);
21
23
  @error-message-background-color: rgb(139, 0, 0);
package/public/index.html CHANGED
@@ -5,7 +5,6 @@
5
5
  <meta http-equiv="x-ua-compatible" content="IE=edge">
6
6
  <meta name="description" content="GitLab build radiator"/>
7
7
  <title>GitLab build radiator</title>
8
- <script src="/socket.io/socket.io.js"></script>
9
8
  <link rel="stylesheet" type="text/css" href="/client.css"/>
10
9
  </head>
11
10
  <body>
@@ -1,21 +1,21 @@
1
- import {basicAuth} from './auth.js'
1
+ import {basicAuth} from './auth.ts'
2
2
  import compression from 'compression'
3
- import {config} from './config.js'
3
+ import {config} from './config.ts'
4
4
  import express from 'express'
5
5
  import fs from 'fs'
6
- import {fetchOfflineRunners} from './gitlab/runners.js'
6
+ import {fetchOfflineRunners} from './gitlab/runners.ts'
7
7
  import http from 'http'
8
- import {serveLessAsCss} from './less.js'
8
+ import {serveLessAsCss} from './less.ts'
9
9
  import {Server} from 'socket.io'
10
- import {update} from './gitlab/index.js'
10
+ import {update} from './gitlab/index.ts'
11
+ import type {GlobalState} from './common/gitlab-types.d.ts'
11
12
 
12
13
  const app = express()
13
- const httpServer = http.Server(app)
14
+ const httpServer = new http.Server(app)
14
15
  const socketIoServer = new Server(httpServer)
15
16
 
16
- if (process.env.NODE_ENV !== 'production' && fs.existsSync('./src/dev-assets.js')) {
17
-
18
- const {bindDevAssets} = await import('./dev-assets.js')
17
+ if (process.env.NODE_ENV !== 'production' && fs.existsSync('./src/dev-assets.ts')) {
18
+ const {bindDevAssets} = await import('./dev-assets.ts')
19
19
  bindDevAssets(app)
20
20
  }
21
21
 
@@ -26,17 +26,17 @@ app.use(compression())
26
26
  app.use(basicAuth(config.auth))
27
27
 
28
28
  httpServer.listen(config.port, () => {
29
-
30
29
  console.log(`Listening on port *:${config.port}`)
31
30
  })
32
31
 
33
- const globalState = {
32
+ const globalState: Omit<GlobalState, 'now'> = {
34
33
  projects: null,
35
34
  error: null,
36
35
  zoom: config.zoom,
37
36
  projectsOrder: config.projectsOrder,
38
37
  columns: config.columns,
39
38
  horizontal: config.horizontal,
39
+ rotateRunningPipelines: config.rotateRunningPipelines,
40
40
  groupSuccessfulProjects: config.groupSuccessfulProjects
41
41
  }
42
42
 
@@ -46,13 +46,13 @@ socketIoServer.on('connection', (socket) => {
46
46
 
47
47
  async function runUpdate() {
48
48
  try {
49
- globalState.projects = await update(config)
49
+ globalState.projects = await update(config.gitlabs, config.rotateRunningPipelines > 0)
50
50
  globalState.error = await errorIfRunnerOffline()
51
51
  socketIoServer.emit('state', withDate(globalState))
52
- } catch (error) {
53
-
54
- console.error(error.message)
55
- globalState.error = `Failed to communicate with GitLab API: ${error.message}`
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
56
  socketIoServer.emit('state', withDate(globalState))
57
57
  }
58
58
  setTimeout(runUpdate, config.interval)
@@ -77,7 +77,7 @@ async function errorIfRunnerOffline() {
77
77
 
78
78
  await runUpdate()
79
79
 
80
- function withDate(state) {
80
+ function withDate(state: Omit<GlobalState, 'now'>): GlobalState {
81
81
  return {
82
82
  ...state,
83
83
  now: Date.now()
package/src/auth.ts ADDED
@@ -0,0 +1,20 @@
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
+ }
@@ -0,0 +1,57 @@
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 ADDED
@@ -0,0 +1,95 @@
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)
@@ -0,0 +1,34 @@
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
+ }
@@ -0,0 +1,59 @@
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
+ }
@@ -0,0 +1,178 @@
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
+ }
@@ -0,0 +1,87 @@
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
+ }
@@ -0,0 +1,35 @@
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
+ }