import path from 'path' import minimatch from 'minimatch' import { AxiosInstance } from 'axios' import { groupBy, max, minBy } from 'lodash' import { Artifact, CustomReportArtifact, createAxios } from './client' import { CustomReportConfig } from '../config/config' import { ArgumentOptions } from '../arg_options' import { Logger } from 'tslog' const DEBUG_PER_PAGE = 10 const FETCH_RECENT_BUILD_API_NUM = 3 export type CircleciStatus = 'retried' | 'canceled' | 'infrastructure_fail' | 'timedout' | 'not_run' | 'running' | 'failed' | 'queued' | 'scheduled' | 'not_running' | 'no_tests' | 'fixed' | 'success' type RecentBuildResponse = { // committer_date: 2020-04-30T08:31:56.000Z, // body: '', // usage_queued_at: '2020-04-29T13:20:15.497Z', reponame: string, build_url: string, // parallel: 1, branch: string, username: string, // author_date: 2020-04-30T08:31:56.000Z, why: string, // user: { // is_user: true, // login: 'Kesin11', // avatar_url: 'https://avatars2.githubusercontent.com/u/1324862?v=4', // name: 'Kenta Kase', // vcs_type: 'github', // id: 1324862 // }, vcs_revision: string, workflows: Workflow, vcs_tag: string | null, build_num: number, // committer_email: [email protected], status: CircleciStatus // committer_name: 'Kenta Kase', // subject: 'refactor: Improve import', // dont_build: null, lifecycle: 'queued' | 'scheduled' | 'not_run' | 'not_running' | 'running' | 'finished' // fleet: 'picard', stop_time: string, build_time_millis: number, start_time: string, // platform: '2.0', // outcome: 'success', // vcs_url: 'https://github.com/Kesin11/CIAnalyzer', // author_name: Kenta Kase, queued_at: string, // author_email: [email protected] } type Workflow = { job_name: string job_id: string workflow_id: string // workspace_id: 'f4e658f6-2eb2-4b34-8da9-e932fc25303f', // upstream_job_ids: [Array], // upstream_concurrency_map: {}, workflow_name: string } type Steps = { name: string actions: { name: string status: CircleciStatus end_time: string | null, // Sometimes step log will be broken and return null start_time: string, step: number, run_time_millis: number | null, // Sometimes step log will be broken and return null background: boolean, }[] } export type SingleBuildResponse = RecentBuildResponse & { steps: Steps[] } export type WorkflowRun = { workflow_name: string workflow_id: string reponame: string username: string vcs_type: string, build_nums: number[] lifecycles: RecentBuildResponse['lifecycle'][] last_build_num: number } export type TestResponse = { exceptions?: unknown[] tests: { classname: string file?: string name: string result: "success" | "failure" | "skipped" run_time: number message?: string source: string source_type: string }[] run_id: number // Add for join workflow } type ArtifactsResponse = { path: string, prettry_path: string, node_index: number, url: string, }[] export class CircleciClient { private axios: AxiosInstance constructor(token: string, logger: Logger, private options: ArgumentOptions, baseUrl?: string) { if (baseUrl && path.basename(baseUrl) !== 'v1.1') { throw new Error(`${CircleciClient.name} accepts only "/api/v1.1/" But your baseUrl is ${baseUrl}`) } const axiosLogger = logger.getChildLogger({ name: CircleciClient.name }) this.axios = createAxios(axiosLogger, options, { baseURL: baseUrl ?? 'https://circleci.com/api/v1.1', auth: { username: token, password: '', }, }) } // https://circleci.com/api/v1.1/project/:vcs-type/:username/:project?circle-token=:token&limit=20&offset=5&filter=completed async fetchWorkflowRuns(owner: string, repo: string, vcsType: string, lastRunId?: number) { const limit = (this.options.debug) ? DEBUG_PER_PAGE : 100 let recentBuilds = [] as RecentBuildResponse[] for (let index = 0; index < FETCH_RECENT_BUILD_API_NUM; index++) { const res = await this.axios.get( `project/${vcsType}/${owner}/${repo}`, { params: { // API default is 30 and max is 100 // ref: https://circleci.com/docs/api/#recent-builds-for-a-single-project limit: limit, // limit: 3, offset: index * limit, // filter: "completed" shallow: true, } }) recentBuilds.push(...res.data) } recentBuilds = (lastRunId) ? recentBuilds.filter((build) => build.build_num > lastRunId) : recentBuilds // Add dummy workflow data if job is not belong to workflow for (const build of recentBuilds) { if (!build.workflows) { build.workflows = this.createDefaultWorkflow(build) } } const groupedBuilds = groupBy(recentBuilds.map((build) => { return { workflow_name: build.workflows.workflow_name, workflow_id: build.workflows.workflow_id, reponame: build.reponame, username: build.username, vcs_type: vcsType, build_num: build.build_num, lifecycle: build.lifecycle, } }), 'workflow_id') const workflowRuns: WorkflowRun[] = Object.values(groupedBuilds).map((builds) => { const build = builds[0] const build_nums = builds.map((build) => build.build_num) return { workflow_id: build.workflow_id, workflow_name: build.workflow_name, reponame: build.reponame, username: build.username, vcs_type: build.vcs_type, build_nums, lifecycles: builds.map((build) => build.lifecycle), last_build_num: max(build_nums)!, } }) return this.filterWorkflowRuns(workflowRuns) } // Filter to: Each workflow's last build number < first running build number filterWorkflowRuns (runs: WorkflowRun[]): WorkflowRun[] { // Ignore not_run workflows that are [ci-skip] commit OR skipped redundant build runs = runs.filter((run) => { return !run.lifecycles.some((lifecycle) => lifecycle === 'not_run') }) const inprogressRuns = runs.filter((run) => { return !run.lifecycles.every((lifecycle) => lifecycle === 'finished') }) const firstInprogress = minBy( inprogressRuns, (run) => run.last_build_num, ) runs = (firstInprogress) ? runs.filter((run) => run.last_build_num < firstInprogress.last_build_num) : runs return runs } async fetchWorkflowJobs(workflowRun: WorkflowRun) { return await Promise.all(workflowRun.build_nums.map((buildNum) => { return this.fetchJobs( workflowRun.username, workflowRun.reponame, workflowRun.vcs_type, buildNum ) })) } // ex: https://circleci.com/api/v1.1/project/github/Kesin11/CIAnalyzer/1021 async fetchJobs(owner: string, repo: string, vcsType: string, runId: number) { const res = await this.axios.get( `project/${vcsType}/${owner}/${repo}/${runId}`, {}) const build = res.data // Add dummy workflow data if job is not belong to workflow if (!build.workflows) { build.workflows = this.createDefaultWorkflow(build) } return build as SingleBuildResponse } // Create default params for old type job that is not using workflow createDefaultWorkflow (data: RecentBuildResponse): Workflow { const startTime = new Date(data.start_time) const repo = `${data.username}/${data.reponame}` return { job_name: 'build', job_id: `${repo}-build-${startTime.getTime()}`, workflow_id: `${repo}-workflow-${startTime.getTime()}`, workflow_name: 'workflow' } } async fetchWorkflowTests(workflowRun: WorkflowRun) { return await Promise.all(workflowRun.build_nums.map((buildNum) => { return this.fetchTests( workflowRun.username, workflowRun.reponame, workflowRun.vcs_type, buildNum ) })) } // ex: https://circleci.com/api/v1.1/project/github/Kesin11/CIAnalyzer/1021/tests async fetchTests(owner: string, repo: string, vcsType: string, runId: number) { const res = await this.axios.get( `project/${vcsType}/${owner}/${repo}/${runId}/tests`) return { ...res.data, run_id: runId } as TestResponse } // ex: https://circleci.com/api/v1.1/project/github/Kesin11/CIAnalyzer/1021/artifacts async fetchArtifactsList(owner: string, repo: string, vcsType: string, runId: number): Promise<ArtifactsResponse> { const res = await this.axios.get( `project/${vcsType}/${owner}/${repo}/${runId}/artifacts` ) return res.data } async fetchArtifacts(artifactsResponse: ArtifactsResponse): Promise<Artifact[]> { const pathResponses = artifactsResponse.map((artifact) => { const response = this.axios.get( artifact.url, { responseType: 'arraybuffer'} ) return { path: artifact.path, response } }) const artifacts = [] for (const { path, response } of pathResponses) { artifacts.push({ path, data: (await response).data as ArrayBuffer }) } return artifacts } async fetchWorkflowCustomReports(workflowRun: WorkflowRun, customReportConfigs: CustomReportConfig[]) { return await Promise.all(workflowRun.build_nums.map((buildNum) => { return this.fetchCustomReports( workflowRun.username, workflowRun.reponame, workflowRun.vcs_type, buildNum, customReportConfigs, ) })) } async fetchCustomReports(owner: string, repo: string, vcsType: string, runId: number, customReportsConfigs: CustomReportConfig[]): Promise<CustomReportArtifact> { // Skip if custom report config are not provided if (customReportsConfigs?.length < 1) return new Map() const artifactsResponse = await this.fetchArtifactsList(owner, repo, vcsType, runId) // Fetch artifacts in parallel const customReports: CustomReportArtifact = new Map<string, Artifact[]>() const nameArtifacts = customReportsConfigs.map((customReportConfig) => { const reportArtifacts = artifactsResponse.filter((artifact) => { return customReportConfig.paths.some((glob) => minimatch(artifact.path, glob)) }) return { name: customReportConfig.name, artifacts: this.fetchArtifacts(reportArtifacts) } }) for (const { name, artifacts } of nameArtifacts) { customReports.set(name, await artifacts) } return customReports } }