diff --git a/README.md b/README.md index 0390bf5..56deecf 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,11 @@ Track gitlab pipelines state of your project. ![Screenshot](https://user-images.githubusercontent.com/1149069/28337289-7973ebcc-6c05-11e7-844d-c7a1e106317c.png) # Configuration + - This fork can be installed from + ``` + apm install https://github.com/azachar/gitlab-integration + ``` + Please install additional forks to benefit from some extra functionality. - Once installed, fill your Gitlab API token in the package's settings page - If you don't know what they are, please refer to https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html, @@ -21,6 +26,16 @@ Track gitlab pipelines state of your project. - `gitlab-integration` should display pipeline statuses in the status bar if it can correctly determine and reach the gitlab server where your project is hosted like shown above. - *In case any errors occurs, a message should be logged in Atom developer console.* +## Gitlab Build Log Filtering Support +- In order to benefit from extra log filtering to narrow down your error from your failed GitLab job's trace log, you need to install 2 additional packages to your atom. Currently, these packages are in experimental mode and still under the development (basically I forked them to gain quick functionality) +- Please install forks `language-ansi-styles` from `azachar` repo the same applies to `language-log` package. + ``` + apm install https://github.com/azachar/language-log + apm install https://github.com/azachar/language-ansi-styles + ``` + +- In order to open your job's reports directly in your browser instead of downloading them, please set up your GitLab installation or modify your browser by following https://gitlab.com/gitlab-org/gitlab-ce/issues/10982#note_50291868 + # Contributing Reporting issues and pull requests are more than welcome on this project. diff --git a/icons/download.svg b/icons/download.svg new file mode 100644 index 0000000..641c384 --- /dev/null +++ b/icons/download.svg @@ -0,0 +1,3 @@ + + + diff --git a/keymaps/gitlab-integration.json b/keymaps/gitlab-integration.json new file mode 100644 index 0000000..5559504 --- /dev/null +++ b/keymaps/gitlab-integration.json @@ -0,0 +1,7 @@ +{ + "atom-workspace": { + "shift-ctrl-alt-g": "gitlab-integration:open-gitlab-ci-cd", + "ctrl-alt-cmd-g": "gitlab-integration:reload", + "ctrl-alt-g": "gitlab-integration:open-failed-job-selector" + } +} diff --git a/lib/all-pipeline-selector-view.coffee b/lib/all-pipeline-selector-view.coffee new file mode 100644 index 0000000..07bfcfe --- /dev/null +++ b/lib/all-pipeline-selector-view.coffee @@ -0,0 +1,172 @@ +{$$, SelectListView} = require 'atom-space-pen-views' +percentile = require 'percentile' +moment = require 'moment' + +class AllPipelineSelectorView extends SelectListView + initialize: (pipelines, controller, projectPath) -> + super + + @pipelines = pipelines + @controller = controller + @projectPath = projectPath + + @addClass('overlay from-top') + @globalCalculate pipelines + @calculate pipelines + @setItems pipelines + @panel ?= atom.workspace.addModalPanel(item: this) + @focusFilterEditor() + $$(@extraContent(@)).insertBefore(@error) + @handleEvents() + @panel.show() + + getFilterKey: -> 'search' + + extraContent: (thiz) -> + return -> + @div class: 'block', => + @button outlet: 'allButton', class: 'btn btn-info', ' All', => + @span class: 'badge badge-small', thiz.jobs?.length + @div class: 'btn-group', => + @button outlet: 'successButton', class: 'btn btn-success', ' Success', => + @span class: 'badge badge-small', thiz.success?.length + @button outlet: 'unstableButton', class: 'btn btn-warning', ' Unstable', => + @span class: 'badge badge-small', thiz.unstable?.length + @button outlet: 'failedButton', class: 'btn btn-error', ' Failed', => + @span class: 'badge badge-small', thiz.failed?.length + + @div class: 'block', => + @div class: 'btn-group', => + @button outlet: 'sortById', class: 'btn', ' Sort by id' + @button outlet: 'sortBySha', class: 'btn', ' Sort by sha' + @button outlet: 'sortByDate', class: 'btn', ' Sort by date' + @button outlet: 'sortByDuration', class: 'btn', ' Sort by duration' + @button outlet: 'sortByBranch', class: 'btn', ' Sort by branch' + + handleEvents: -> + @wireOutlets(@) + + @successButton.on 'mouseover', (e) => + @setItems @success + @calculate @items + + @unstableButton.on 'mouseover', (e) => + @setItems @unstable + @calculate @items + + @failedButton.on 'mouseover', (e) => + @setItems @failed + @calculate @items + + @sortById.on 'mouseover', (e) => + @setItems @items.sort (a, b) -> + return a.id - b.id + + @sortBySha.on 'mouseover', (e) => + @setItems @items.sort (a, b) -> + return -1 if a.sha < b.sha + return 1 if a.sha > b.sha + return 0 + + @sortByDate.on 'mouseover', (e) => + @setItems @items.sort (a, b) -> + if a.created_at and b.created_at + return moment(b.created_at).diff(moment(a.created_at)) + else + return 0 + + @sortByDuration.on 'mouseover', (e) => + @setItems @items.sort (a, b) -> + return b.duration - a.duration + + @sortByBranch.on 'mouseover', (e) => + @setItems @items.sort (a, b) -> + return a.ref - b.ref + + calculate: (items) -> + if items?.length > 0 + @maxDuration = items.reduce( ((max, p) -> + Math.max(max, p.duration || 0) + ), 0 ) + + @averageDuration = percentile(50, items, (item) -> item.duration).duration + + success = items.filter( (p) -> p.status is 'success') + if success?.length > 0 + @maxDurationSuccess = success?.reduce( ((max, p) -> + Math.max(max, p.durationSuccess || 0) + ), 0 ) + @averageDurationSuccess = percentile(50, success, (item) -> item.durationSuccess).durationSuccess + + globalCalculate: (items) -> + if items?.length > 0 + @success = items.filter( (p) -> p.status is 'success') + @failed = items.filter( (p) -> p.status is 'failed') + @unstable = items.filter( (p) -> p.status is 'success' and p.loadedJobs?.filter( (j) => j.status is 'failed')?.length > 0) + + asUniqueNames: (jobs) => + return jobs.map((j) => j.name).unique() + + viewForItem: (pipeline) -> + pipeline.elapsed = moment(pipeline.finished_at).diff(moment(pipeline.created_at), 'seconds') + + if pipeline.loadedJobs?.length > 0 + {alwaysSuccess, unstable, alwaysFailed, total} = @controller.statistics(pipeline.loadedJobs) + + type = @controller.toType(pipeline, @averageDuration) + + "
  • +
    +
    + #{pipeline.id} + #{moment(pipeline.created_at).format('lll')} / #{moment(pipeline.created_at).fromNow()} + + #{pipeline.commit?.short_id} + #{pipeline.ref} + +
    +
    +
    + #{pipeline.commit?.title} +
    +
    + + ♨︎ #{@controller.toHHMMSS(pipeline.duration)} + / ABS #{@controller.toHHMMSS(pipeline.elapsed)} +
    + #{@asUniqueNames(alwaysSuccess)} +
    + #{@asUniqueNames(unstable)} +
    +
    + #{@asUniqueNames(alwaysFailed)} +
    + #{total.length} + #{alwaysSuccess.length} + #{unstable.length} + #{alwaysFailed.length} + + #{pipeline.user?.name} + +
  • " + else + "
  • +
    +
    + #{pipeline.id} + #{pipeline.sha} + #{pipeline.commit?.message} +
    +
    + +
    +
  • " + + confirmed: (pipeline) => + @cancel() + @controller.updatePipeline(pipeline, @projectPath); + + cancelled: -> + @panel.hide() + +module.exports = AllPipelineSelectorView diff --git a/lib/gitlab-integration.coffee b/lib/gitlab-integration.coffee index c203789..57a49d1 100644 --- a/lib/gitlab-integration.coffee +++ b/lib/gitlab-integration.coffee @@ -4,6 +4,8 @@ GitUrlParse = require 'git-url-parse' StatusBarView = require './status-bar-view' GitlabStatus = require './gitlab' log = require './log' +app = require('electron').app; +moment = require 'moment' class GitlabIntegration config: @@ -12,6 +14,11 @@ class GitlabIntegration description: 'Token to access your Gitlab API' type: 'string' default: '' + artifactReportPath: + title: 'Artifact report path' + description: 'Usefull to open your report such as protractor-screenshoter' + type: 'string' + default: 'storage//index.html' period: title: 'Polling period (ms)' description: 'The interval at which gitlab will be polled' @@ -118,6 +125,8 @@ class GitlabIntegration ) activate: (state) -> + if app + moment.locale(app.getLocale()) @subscriptions = new CompositeDisposable @statusBarView = new StatusBarView @statusBarView.init() @@ -128,6 +137,28 @@ class GitlabIntegration "You likely forgot to configure your gitlab token", {dismissable: true} ) + if not atom.config.get('gitlab-integration.artifactReportPath') + atom.notifications.addInfo( + "You likely forgot to configure your gitlab artifact report path", + {dismissable: true} + ) + atom.commands.add 'atom-workspace', + 'gitlab-integration:open-gitlab-ci-cd': () => + if @statusBarView?.currentProject isnt "" + @gitlab.openGitlabCICD(@statusBarView.currentProject) + + atom.commands.add 'atom-workspace', + 'gitlab-integration:open-failed-job-selector': () => + if @statusBarView?.currentProject isnt "" + currentStages = @statusBarView?.stages[@statusBarView.currentProject] + failedStages = currentStages?.filter (stage) -> stage.status is 'failed' + if @statusBarView.currentProject and failedStages?.length > 0 + @gitlab.openJobSelector(@statusBarView.currentProject, failedStages[0]) + + atom.commands.add 'atom-workspace', + 'gitlab-integration:reload': () => + @gitlab.update() + @handleProjects(atom.project.getDirectories()) @subscriptions.add atom.project.onDidChangePaths (paths) => @handleProjects(paths.map((path) => new Directory(path))) diff --git a/lib/gitlab.coffee b/lib/gitlab.coffee index 34b8b9b..3d94192 100644 --- a/lib/gitlab.coffee +++ b/lib/gitlab.coffee @@ -1,196 +1,441 @@ fetch = require 'isomorphic-fetch' log = require './log' +shell = require('electron').shell; +JobSelectorView = require './job-selector-view' +PipelineSelectorView = require './pipeline-selector-view' +AllPipelineSelectorView = require './all-pipeline-selector-view' class GitlabStatus - constructor: (@view, @timeout=null, @projects={}, @pending=[], @jobs={}) -> - @token = atom.config.get('gitlab-integration.token') - @period = atom.config.get('gitlab-integration.period') - @updating = {} - @watchTimeout = null - - fetch: (host, q, paging=false) -> - log " -> fetch '#{q}' from '#{host}" - fetch( - "https://#{host}/api/v4/#{q}", { - headers: { - "PRIVATE-TOKEN": @token, - } - } - ).then((res) => - log " <- ", res - if res.headers.get('X-Next-Page') - if paging - log " -> retrieving #{res.headers.get('X-Total-Pages')} pages" - Promise.all( - [res.json()].concat( - new Array( - parseInt(res.headers.get('X-Total-Pages')) - 1, - ).fill(0).map( - (dum, i) => - log " -> page #{i + 2}" - fetch( - "https://#{host}/api/v4/#{q}" + - (if q.includes('?') then '&' else '?') + - "per_page=" + res.headers.get('X-Per-Page') + - "&page=#{i+2}", { - headers: { - 'PRIVATE-TOKEN': @token - } - } - ).then((page) => - log " <- page #{i + 2}", page - page.json() - ).catch((error) => - console.error "cannot fetch page #{i + 2}", error - Promise.resolve([]) - ) - ) - ) - ).then((all) => - Promise.resolve(all.reduce( - (all, one) => - all.concat(one) - , []) - ) - ) - else - log " -> ignoring paged output for #{q}" - res.json() - else - res.json() - ) + constructor: (@view, @timeout=null, @projects={}, @pending=[], @jobs={}) -> + @token = atom.config.get('gitlab-integration.token') + @artifactReportPath = atom.config.get('gitlab-integration.artifactReportPath') + @period = atom.config.get('gitlab-integration.period') + @updating = {} + @watchTimeout = null + @view.setController(@) - watch: (host, projectPath, repos) -> - projectPath = projectPath.toLowerCase() - if not @projects[projectPath]? and not @updating[projectPath]? - @updating[projectPath] = false - @view.loading projectPath, "loading project..." - @fetch(host, "projects?membership=yes", true).then( - (projects) => - log "received projects from #{host}", projects - if projects? - project = projects.filter( - (project) => - project.path_with_namespace.toLowerCase() is - projectPath - )[0] - if project? - @projects[projectPath] = { host, project, repos } - @update() - else - @view.unknown(projectPath) - else - @view.unknown(projectPath) - ).catch((error) => - @updating[projectPath] = undefined - console.error "cannot fetch projects from #{host}", error - @view.unknown(projectPath) + fetch: (host, q, paging=false) -> + @load(host,q).then((res) => + log " <- ", res + if res.headers.get('X-Next-Page') + if paging + log " -> retrieving #{res.headers.get('X-Total-Pages')} pages" + Promise.all( + [res.json()].concat( + new Array( + parseInt(res.headers.get('X-Total-Pages')) - 1, + ).fill(0).map( + (dum, i) => + log " -> page #{i + 2}" + fetch( + "https://#{host}/api/v4/#{q}" + + (if q.includes('?') then '&' else '?') + + "per_page=" + res.headers.get('X-Per-Page') + + "&page=#{i+2}", { + headers: { + 'PRIVATE-TOKEN': @token + } + } + ).then((page) => + log " <- page #{i + 2}", page + page.json() + ).catch((error) => + console.error "cannot fetch page #{i + 2}", error + Promise.resolve([]) + ) + ) + ) + ).then((all) => + Promise.resolve(all.reduce( + (all, one) => + all.concat(one) + , []) ) + ) + else + log " -> ignoring paged output for #{q}" + res.json() + else + res.json() + ) + .then( (result) => + if result?.error + throw result + else + return result + ) + + load: (host, q) -> + log " -> fetch '#{q}' from '#{host}" + fetch( + "https://#{host}/api/v4/#{q}", { + headers: { + "PRIVATE-TOKEN": @token, + } + } + ) + + watch: (host, projectPath, repos) -> + projectPath = projectPath.toLowerCase() + if not @projects[projectPath]? and not @updating[projectPath]? + @updating[projectPath] = false + @view.loading projectPath, "loading project..." + @fetch(host, "projects?membership=yes", true) + .then( (projects) => + log "received projects from #{host}", projects + if projects? + project = projects.filter( + (project) => + project.path_with_namespace.toLowerCase() is + projectPath + )[0] + if project? + @projects[projectPath] = { host, project, repos } + @update() + else + @view.unknown(projectPath) + else + @view.unknown(projectPath) + ).catch((error) => + if error.error_description + atom.notifications.addWarning( + "Gitlab-Integration: #{error.error}: #{error.error_description}", + {dismissable: true} + ) + console.error "cannot fetch projects from #{host}", error + @updating[projectPath] = undefined + @view.unknown(projectPath) + ) + + printHeader: (job) -> + "[0K Log from branch #{job.ref} | job #{job.name} | # #{job.id} | pipeline #{job.pipeline.id} [0;m" - schedule: -> - @timeout = setTimeout @update.bind(@), @period - - update: -> - @pending = Object.keys(@projects).slice() - @updatePipelines() - - updatePipelines: -> - Object.keys(@projects).map( - (projectPath) => - { host, project, repos } = @projects[projectPath] - if project? and project.id? and not @updating[projectPath] - @updating[projectPath] = true - ref = repos?.getShortHead?() - if ref? - log "project #{project} ref is #{ref}" - ref = "?ref=#{ref}" - else - ref = "" - if not @jobs[projectPath]? - @view.loading(projectPath, "loading pipelines...") - @fetch(host, "projects/#{project.id}/pipelines#{ref}").then( - (pipelines) => - log "received pipelines from #{host}/#{project.id}", pipelines - if pipelines.length > 0 - @updateJobs(host, project, pipelines[0]) - else - @onJobs(project, []) - ).catch((error) => - console.error "cannot fetch pipelines for project #{projectPath}", error - @endUpdate(project) - ) + loadJob: (host, project, job) -> + atom.notifications.addInfo( + "Downloading build log for job #{job.name} #{job.id}", + {dismissable: true} + ) + @load(host, "projects/#{project.id}/jobs/#{job.id}/trace", false) + .then( (res) -> + return res.text() + ) + .then( (text) -> + return text: text, job: job + ) + .catch((error) -> + atom.notifications.addWarning( + "Unable to load the build log due to #{error}", + {dismissable: true} ) + console.error "cannot fetch the build log from projects/#{project.id}/jobs/#{job.id}/trace", error + ) + + openLog: (projectPath, job) -> + { host, project, repos } = @projects[projectPath] + @loadJob(host, project, job) + .then( (downloadedLog) => + atom.workspace.open(undefined, { + # split : 'right' + }).then (editor) => + # editor.setFileName("#{job.name}.#{job.ref}.#{job.id}") + editor.setGrammar(atom.grammars.grammarForScopeName('text.ansi')) + editor.insertText @printHeader downloadedLog.job + editor.insertNewline() + editor.insertNewline() + editor.insertText(downloadedLog.text) + ) + .catch((error) -> + console.error "cannot open editor for the build log of #{projectPath} and job #{job.id}", error + ) + + openFailedLogs: (projectPath, jobs) -> + @openLogs(projectPath, jobs.filter (job) -> job.status is 'failed') + + openFailedLogsInGroup: (projectPath, jobs, mainJob) -> + @openFailedLogs(projectPath, jobs.filter (job) -> job.name is mainJob.name) + + openLogs: (projectPath, jobs) -> + { host, project, repos } = @projects[projectPath] + + atom.notifications.addInfo( + "Downloading build logs for #{jobs.length} jobs", + {dismissable: true} + ) + + jobsToLoad = ( + @loadJob(host, project, aJob) for aJob in jobs + ) + + Promise.all(jobsToLoad) + .then (logs) => + logs?.sort (a, b) -> + return -1 if a.job.name < b.job.name + return 1 if a.job.name > b.job.name + + return -1 if a.job.id < b.job.id + return 1 if a.job.id > b.job.id + return 0 + + atom.workspace.open(undefined, { + # split : 'right' + }).then (editor) => + # editor.setFileName("#{job.name}.#{job.ref}.#{job.id}") + editor.setGrammar(atom.grammars.grammarForScopeName('text.ansi')) + for l in logs + editor.insertNewline() + editor.insertText @printHeader l.job + editor.insertNewline() + editor.insertText(l.text) + # atom.notifications.addInfo( + # "Build log #{l.job.name} (#{l.job.id}) downloaded", + # {dismissable: true} + # ) + .catch((error) -> + console.error "cannot open editor for build logs of jobs of #{projectPath}", error + ) + + openReport: (projectPath, job) -> + if not job.artifacts_file + atom.notifications.addWarning( + "No artifacts for job #{job.name}", + {dismissable: true} + ) + return + + path = @artifactReportPath.split('').join(job.name) + + if not path + atom.notifications.addWarning( + "Unknown path for artifact to download for #{job.name}", + {dismissable: true} + ) + return + + { host, project, repos } = @projects[projectPath] + shell.openExternal("https://#{host}/#{encodeURI(projectPath)}/-/jobs/#{job.id}/artifacts/raw/#{path}"); + + openGitlabCICD: (projectPath) -> + { host, project, repos } = @projects[projectPath] + return unless host + shell.openExternal("https://#{host}/#{projectPath}/pipelines"); - endUpdate: (project) -> - log "project #{project} update end" - @updating[project] = false - @pending = @pending.filter((pending) => pending isnt project) - if @pending.length is 0 - @view.onStagesUpdate(@jobs) - @schedule() - @jobs[project.path_with_namespace] - - updateJobs: (host, project, pipeline) -> - if not @jobs[project.path_with_namespace]? - @view.loading(project.path_with_namespace, "loading jobs...") - @fetch(host, "projects/#{project.id}/" + "pipelines/#{pipeline.id}/jobs", true) - .then((jobs) => - log "received jobs from #{host}/#{project.id}/#{pipeline.id}", jobs - if jobs.length is 0 - @onJobs(project, [ - name: pipeline.name - status: pipeline.status - jobs: [] - ]) - else - @onJobs(project, jobs.sort((a, b) -> a.id - b.id).reduce( - (stages, job) -> - stage = stages.find( - (stage) -> stage.name is job.stage - ) - if not stage? - stage = - name: job.stage - status: 'success' - jobs: [] - stages = stages.concat([stage]) - stage.jobs = stage.jobs.concat([job]) - return stages - , []).map((stage) -> - Object.assign(stage, { - status: stage.jobs - .sort((a, b) -> b.id - a.id) - .reduce((status, job) -> - switch - when job.status is 'pending' then 'pending' - when job.status is 'created' then 'created' - when job.status is 'canceled' then 'canceled' - when job.status is 'running' then 'running' - when job.status is 'skipped' then 'skipped' - when job.status is 'failed' and - status is 'success' then 'failed' - else status - , 'success') - }) - )) - ).catch((error) => - console.error "cannot fetch jobs for pipeline ##{pipeline.id} of project #{project.path_with_namespace}", error + openPipeline: (projectPath, stages) -> + if stages + { host, project, repos } = @projects[projectPath] + shell.openExternal("https://#{host}/#{projectPath}/pipelines/#{stages[0].pipeline}"); + else + @openGitlabCICD(projectPath) + + openJobSelector: (projectPath, stage) -> + selector = new JobSelectorView(stage.jobs, @ , projectPath) + + openPipelineSelector: (projectPath) -> + { host, project, repos } = @projects[projectPath] + selector = new PipelineSelectorView(project.pipelines, @ , projectPath) + + openAllPipelineSelector: (projectPath) -> + { host, project, repos } = @projects[projectPath] + @fetch(host, "projects/#{project.id}/pipelines") + .then( (pipelines) => + loads = [] + loads.push @loadPipelineJobs(host, project, pipeline) for pipeline in pipelines + Promise.all(loads).then( ()=> + return pipelines + ) + ) + .then( (pipelines) => + selector = new AllPipelineSelectorView(pipelines, @ , projectPath) + ) + schedule: -> + @timeout = setTimeout @update.bind(@), @period + + update: -> + @pending = Object.keys(@projects).slice() + @updatePipelines() + + updatePipeline: (pipeline, projectPath) -> + { host, project, repos } = @projects[projectPath] + @jobs[project.path_with_namespace] = null + project.userForcedPipeline = pipeline + @updateJobs(host, project, pipeline) + + updatePipelines: -> + Object.keys(@projects).map( + (projectPath) => + { host, project, repos } = @projects[projectPath] + if project? and project.id? and not @updating[projectPath] + @updating[projectPath] = true + ref = project.userForcedPipeline?.ref || repos?.getShortHead?() + if ref? + log "project #{project} ref is #{ref}" + ref = "?ref=#{ref}" + else + ref = "" + if not @jobs[projectPath]? + @view.loading(projectPath, "loading pipelines...") + @fetch(host, "projects/#{project.id}/pipelines#{ref}").then( + (pipelines) => + log "received pipelines from #{host}/#{project.id}", pipelines + project.pipelines = pipelines; + if pipelines.length > 0 + if project.userForcedPipeline + currentPipelineWrapped = pipelines.filter( (p) => p.id is project.userForcedPipeline.id) + # is in the pipelines? + if currentPipelineWrapped?.length > 0 + currentPipeline = currentPipelineWrapped[0] + if not currentPipeline + currentPipeline = pipelines[0] + project.userForcedPipeline = null + @updateJobs(host, project, currentPipeline) + @loadPipelineJobs(host, project, pipeline) for pipeline in pipelines + else + @onJobs(project, []) + ).catch((error) => + console.error "cannot fetch pipelines for project #{projectPath}", error @endUpdate(project) - ) + ) + ) + + endUpdate: (project) -> + log "project #{project} update end" + @updating[project] = false + @pending = @pending.filter((pending) => pending isnt project) + if @pending.length is 0 + @view.onStagesUpdate(@jobs) + @schedule() + @jobs[project.path_with_namespace] + + updateJobs: (host, project, pipeline) -> + if not @jobs[project.path_with_namespace]? + @view.loading(project.path_with_namespace, "loading jobs...") + @fetch(host, "projects/#{project.id}/" + "pipelines/#{pipeline.id}/jobs", true) + .then((jobs) => + log "received jobs from #{host}/#{project.id}/#{pipeline.id}", jobs + jobs.every (job) -> job.search = "id:#{job.id} name:#{job.name} runner:#{job.runner?.description} status:#{job.status}" + if jobs.length is 0 + @onJobs(project, [ + name: pipeline.name + pipeline: pipeline.id + pipelineStatus: pipeline.status + status: pipeline.status + jobs: [] + ]) + else + @onJobs(project, jobs.sort((a, b) -> a.id - b.id).reduce( + (stages, job) -> + stage = stages.find( + (stage) -> stage.name is job.stage + ) + if not stage? + stage = + name: job.stage + pipeline: pipeline.id + pipelineStatus: pipeline.status + status: 'created' + jobs: [] + stages = stages.concat([stage]) + stage.jobs = stage.jobs.concat([job]) + return stages + , []).map((stage) -> + Object.assign(stage, { + firstFailedJob: stage.jobs + .filter( (job) -> job.status is 'failed' )?[0] + + status: stage?.jobs + .sort((a, b) -> b.id - a.id) + .reduce((status, job) -> + switch status + when 'failed' then 'failed' + when 'pending' then 'pending' + when 'manual' then 'manual' + when 'running' then 'running' + else job.status + , 'created') + }) + )) + ).catch((error) => + console.error "cannot fetch jobs for pipeline ##{pipeline.id} of project #{project.path_with_namespace}", error + @endUpdate(project) + ) + + alwaysFailed: (items)-> + passedNames = items.filter((job) => job.status is "success").map( (job)=> job.name) + return items.filter((job) => job.status is "failed" and job.name not in passedNames) + + alwaysSuccess: (items)-> + failedNames = items.filter((job) => job.status is "failed").map( (job)=> job.name) + return items.filter((job) => job.status is "success" and job.name not in failedNames) + + statistics: (jobs)-> + if jobs?.length + total = jobs.filter ( (j) => j.status is 'success' or 'failed') + + alwaysSuccess = @alwaysSuccess( jobs ) + failed = jobs.filter ( (j) => j.status is 'failed') + alwaysFailed = @alwaysFailed( jobs ) + unstable = failed.filter( (j) => j not in alwaysFailed) + + return {alwaysSuccess, unstable, alwaysFailed, total} + else + return {alwaysSuccess:[], unstable:[], alwaysFailed:[], total:[]} + + toHHMMSS: (sec_num) -> + sec_num = Math.round(sec_num) + hours = Math.floor(sec_num / 3600) + minutes = Math.floor((sec_num - (hours * 3600)) / 60) + seconds = sec_num - (hours * 3600) - (minutes * 60) + if hours < 10 + hours = "0"+hours + if minutes < 10 + minutes = "0"+minutes + if seconds < 10 + seconds = "0"+seconds + if hours is "00" + "#{minutes}m #{seconds}s" + else + "#{hours}h #{minutes}m #{seconds}s" + + toType: (item, percentile) -> + deviation = Math.round(item.duration / percentile * 100) + if deviation < 85 then type = 'success' + if deviation > 115 then type = 'warning' + if deviation > 130 then type = 'error' + return type + + loadPipelineJobs: (host, project, pipeline) -> + @fetch(host, "projects/#{project.id}/" + "pipelines/#{pipeline.id}/jobs", true) + .then((jobs) -> + if jobs?.length > 0 + pipeline.commit = jobs[0].commit + pipeline.created_at = jobs[0].created_at + pipeline.finished_at = jobs[jobs?.length-1].finished_at + pipeline.user = jobs[0].user + pipeline.search = "id:#{pipeline.id} ref:#{pipeline.ref} sha:#{pipeline.commit?.short_id} status:#{pipeline.status}" + pipeline.durationSuccess = jobs.filter( (j) -> j.status is 'success').reduce( ((max, j) -> + Math.max(max, j.duration || 0) + ), 0) + pipeline.duration = jobs.reduce( ((max, j) -> + Math.max(max, j.duration || 0) + ), 0) + pipeline.loadedJobs = jobs + ) + .catch((error) => + console.error "cannot fetch jobs for pipeline ##{pipeline.id} of project #{project.path_with_namespace}", error + ) - onJobs: (project, stages) -> - @jobs[project.path_with_namespace] = stages.slice() - @endUpdate(project.path_with_namespace) - Promise.resolve(stages) + onJobs: (project, stages) -> + @jobs[project.path_with_namespace] = stages.slice() + @endUpdate(project.path_with_namespace) + Promise.resolve(stages) - stop: -> - if @timeout? - clearTimeout @timeout - if @watchTimeout? - clearTimeout @watchTimeout - @view.hide() + stop: -> + if @timeout? + clearTimeout @timeout + if @watchTimeout? + clearTimeout @watchTimeout + @view.hide() - deactivate: -> - @stop() + deactivate: -> + @stop() module.exports = GitlabStatus diff --git a/lib/job-selector-view.coffee b/lib/job-selector-view.coffee new file mode 100644 index 0000000..9315829 --- /dev/null +++ b/lib/job-selector-view.coffee @@ -0,0 +1,143 @@ +{$$, SelectListView} = require 'atom-space-pen-views' +percentile = require 'percentile' +moment = require 'moment' + +class JobSelectorView extends SelectListView + initialize: (jobs, controller, projectPath) -> + super + + @jobs = jobs + @controller = controller + @projectPath = projectPath + {@alwaysSuccess, @unstable, @alwaysFailed, @total} = @controller.statistics(@jobs) + @addClass('overlay from-top') + @calculate jobs + @setItems jobs + @panel ?= atom.workspace.addModalPanel(item: this) + @focusFilterEditor() + $$(@extraContent(@)).insertBefore(@error) + @handleEvents() + @panel.show() + + getFilterKey: -> 'search' + + extraContent: (thiz) -> + if thiz.jobs?.length > 0 + commit = thiz.jobs[0].commit + ref = thiz.jobs[0].ref + return -> + @div class: 'block', => + @div class: 'block', => + @button outlet: 'allButton', class: 'btn btn-info', ' All', => + @span class: 'badge badge-small', thiz.jobs?.length + @div class: 'btn-group', => + @button outlet: 'alwaysSuccessButton', class: 'btn btn-success', ' Always Success', => + @span class: 'badge badge-small', thiz.alwaysSuccess?.length + @button outlet: 'sometimesFailedButton', class: 'btn btn-warning', ' Sometimes Failed', => + @span class: 'badge badge-small', thiz.unstable?.length + @button outlet: 'alwaysFailedButton', class: 'btn btn-error', ' Always Failed', => + @span class: 'badge badge-small', thiz.alwaysFailed?.length + @div class: 'block', => + @span class: 'icon icon-git-commit', commit?.title + @span class: 'icon icon-git-branch text-muted pull-right', " #{ref} / #{commit?.short_id}" + @div class: 'block', => + @span class: 'icon icon-clock text-center', "#{moment(commit?.created_at).format('lll')} / #{moment(commit?.created_at).fromNow()}" + @span class: 'pull-right', => + @raw " #{thiz.user?.name}" + @div class: 'block', => + @div class: 'btn-group', => + @button outlet: 'sortById', class: 'btn', ' Sort by id' + @button outlet: 'sortByName', class: 'btn', ' Sort by name' + @button outlet: 'sortByDate', class: 'btn', ' Sort by date' + @button outlet: 'sortByDuration', class: 'btn', ' Sort by duration' + + handleEvents: -> + @wireOutlets(@) + + @alwaysSuccessButton.on 'mouseover', (e) => + @setItems @alwaysSuccess + @calculate @items + @sometimesFailedButton.on 'mouseover', (e) => + @setItems @unstable + @calculate @items + @alwaysFailedButton.on 'mouseover', (e) => + @setItems @alwaysFailed + @calculate @items + + @allButton.on 'mouseover', (e) => + @setItems @jobs + @calculate @items + + @sortById.on 'mouseover', (e) => + @setItems @items.sort (a, b) -> + return a.id - b.id + + @sortByName.on 'mouseover', (e) => + @setItems @items.sort (a, b) -> + return -1 if a.name < b.name + return 1 if a.name > b.name + return a.id - b.id + + @sortByDate.on 'mouseover', (e) => + @setItems @items.sort (a, b) -> + if a.created_at and b.created_at + return moment(b.created_at).diff(moment(a.created_at)) + else + return 0 + + @sortByDuration.on 'mouseover', (e) => + @setItems @items.sort (a, b) -> + return b.duration - a.duration + + calculate: (items) -> + if items?.length > 0 + @user = items[0].user + @averageDuration = percentile(50, items, (item) -> item.duration).duration + @maxDuration = items?.reduce( ((max, j) -> + Math.max(max, j.duration || 0) + ), 0 ) + + viewForItem: (job) -> + type = @controller.toType(job, @averageDuration) + + artifactIcon = if job.artifacts_file then "icon gitlab-artifact" else "no-icon" + "
  • +
    +
    + #{job.name} + ♨︎ #{@controller.toHHMMSS(job.duration)} + #{job.id} +
    +
    +
    + +
    + #{moment(job.created_at).format('lll')} - #{moment(job.finished_at).format('lll')} + #{job.runner?.description} +
  • " + + confirmed: (job) => + @cancel() + if job.artifacts_file + atom.confirm + message: 'What to open?' + detailedMessage: "Do you want to open the log or the report or both or all logs of the group #{job.name}?" + buttons: + Group: => @controller.openFailedLogsInGroup(@projectPath, @items, job) + Log: => @controller.openLog(@projectPath, job) + Report: => @controller.openReport(@projectPath, job) + Both: => + @controller.openLog(@projectPath, job) + @controller.openReport(@projectPath, job) + else + atom.confirm + message: 'What to open?' + detailedMessage: "Do you want to open the log or all logs of the group #{job.name}?" + buttons: + Group: => @controller.openFailedLogsInGroup(@projectPath, @items, job) + Log: => @controller.openLog(@projectPath, job) + + cancelled: -> + @panel.hide() + +module.exports = JobSelectorView diff --git a/lib/pipeline-selector-view.coffee b/lib/pipeline-selector-view.coffee new file mode 100644 index 0000000..f5e333e --- /dev/null +++ b/lib/pipeline-selector-view.coffee @@ -0,0 +1,190 @@ +{$$, SelectListView} = require 'atom-space-pen-views' +percentile = require 'percentile' +moment = require 'moment' + +Array::unique = -> + output = {} + output[@[key]] = @[key] for key in [0...@length] + value for key, value of output + +class PipelineSelectorView extends SelectListView + initialize: (pipelines, controller, projectPath) -> + super + + @pipelines = pipelines + @controller = controller + @projectPath = projectPath + + @addClass('overlay from-top') + @calculate pipelines + @setItems pipelines + @panel ?= atom.workspace.addModalPanel(item: this) + @focusFilterEditor() + $$(@extraContent(@)).insertBefore(@error) + @handleEvents() + @panel.show() + + getFilterKey: -> 'search' + + extraContent: (thiz) -> + return -> + alwaysSuccess = thiz.asUniqueNames(thiz.alwaysSuccess) + unstable = thiz.asUniqueNames(thiz.unstable) + alwaysFailed = thiz.asUniqueNames(thiz.alwaysFailed) + + @div class: 'block', => + @div class: 'block', => + @span class: 'icon icon-git-branch', " #{thiz.branch}" + + @div class: 'block', => + @raw " +
    + All 50% ♨︎ #{thiz.controller.toHHMMSS(thiz.averageDuration)} + All MAX ♨︎ #{thiz.controller.toHHMMSS(thiz.maxDuration)} +
    + " + + if thiz.maxDurationSuccess + @raw "
    + Success 50% ♨︎ #{thiz.controller.toHHMMSS(thiz.averageDurationSuccess)} + Sucess MAX ♨︎ #{thiz.controller.toHHMMSS(thiz.maxDurationSuccess)} +
    + " + + @raw "
    + STABLE: #{alwaysSuccess} +
    +
    + UNSTABLE: #{unstable} +
    +
    + ERROR: #{alwaysFailed} +
    +
    + #{alwaysSuccess.length} + #{unstable.length} + #{alwaysFailed.length} +
    " + + @div class: 'block', => + @div class: 'btn-group', => + @button outlet: 'sortById', class: 'btn', ' Sort by id' + @button outlet: 'sortBySha', class: 'btn', ' Sort by sha' + @button outlet: 'sortByDate', class: 'btn', ' Sort by date' + @button outlet: 'sortByDuration', class: 'btn', ' Sort by duration' + + handleEvents: -> + @wireOutlets(@) + + @sortById.on 'mouseover', (e) => + @setItems @items.sort (a, b) -> + return a.id - b.id + + @sortBySha.on 'mouseover', (e) => + @setItems @items.sort (a, b) -> + return -1 if a.sha < b.sha + return 1 if a.sha > b.sha + return 0 + + @sortByDate.on 'mouseover', (e) => + @setItems @items.sort (a, b) -> + if a.created_at and b.created_at + return moment(b.created_at).diff(moment(a.created_at)) + else + return 0 + + @sortByDuration.on 'mouseover', (e) => + @setItems @items.sort (a, b) -> + return b.duration - a.duration + + calculate: (items) -> + if items?.length > 0 + @branch = items[0].ref + + @maxDuration = items.reduce( ((max, p) -> + Math.max(max, p.duration || 0) + ), 0 ) + + @averageDuration = percentile(50, items, (item) -> item.duration).duration + + success = items.filter( (p) -> p.status is 'success') + if success?.length > 0 + @maxDurationSuccess = success?.reduce( ((max, p) -> + Math.max(max, p.durationSuccess || 0) + ), 0 ) + @averageDurationSuccess = percentile(50, success, (item) -> item.durationSuccess).durationSuccess + + allJobs = items.reduce(((all, p) -> + if p.loadedJobs?.length > 0 + all.concat(p.loadedJobs) + else + all + ), []) + + {@alwaysSuccess, @unstable, @alwaysFailed, @total} = @controller.statistics(allJobs) + + asUniqueNames: (jobs) => + return jobs.map((j) => j.name).unique() + + viewForItem: (pipeline) -> + pipeline.elapsed = moment(pipeline.finished_at).diff(moment(pipeline.created_at), 'seconds') + + if pipeline.loadedJobs?.length > 0 + {alwaysSuccess, unstable, alwaysFailed, total} = @controller.statistics(pipeline.loadedJobs) + + type = @controller.toType(pipeline, @averageDuration) + + "
  • +
    +
    + #{pipeline.id} + #{moment(pipeline.created_at).format('lll')} / #{moment(pipeline.created_at).fromNow()} + + #{pipeline.commit?.short_id} + +
    +
    +
    + #{pipeline.commit?.title} +
    +
    + + ♨︎ #{@controller.toHHMMSS(pipeline.duration)} + / ABS #{@controller.toHHMMSS(pipeline.elapsed)} +
    + #{@asUniqueNames(alwaysSuccess)} +
    + #{@asUniqueNames(unstable)} +
    +
    + #{@asUniqueNames(alwaysFailed)} +
    + #{total.length} + #{alwaysSuccess.length} + #{unstable.length} + #{alwaysFailed.length} + + #{pipeline.user?.name} + +
  • " + else + "
  • +
    +
    + #{pipeline.id} + #{pipeline.sha} + #{pipeline.commit?.message} +
    +
    + +
    +
  • " + + confirmed: (pipeline) => + @cancel() + @controller.updatePipeline(pipeline, @projectPath); + + cancelled: -> + @panel.hide() + +module.exports = PipelineSelectorView diff --git a/lib/status-bar-view.coffee b/lib/status-bar-view.coffee index a9aaff3..d0a9cd2 100644 --- a/lib/status-bar-view.coffee +++ b/lib/status-bar-view.coffee @@ -9,6 +9,10 @@ class StatusBarView extends HTMLElement @stages = {} @statuses = {} @tooltips = [] + @controller = null + + setController: (controller) => + @controller = controller activate: => @displayed = false deactivate: => @@ -59,8 +63,10 @@ class StatusBarView extends HTMLElement @disposeTooltips() status = document.createElement('div') status.classList.add('inline-block') - icon = document.createElement('span') + icon = document.createElement('a') icon.classList.add('icon', 'icon-gitlab') + icon.onclick = (e) => + @controller.openGitlabCICD(project); @tooltips.push atom.tooltips.add icon, { title: "GitLab project #{project}" } @@ -85,10 +91,12 @@ class StatusBarView extends HTMLElement @disposeTooltips() status = document.createElement('div') status.classList.add('inline-block') - icon = document.createElement('span') + icon = document.createElement('a') icon.classList.add('icon', 'icon-gitlab') + icon.onclick = (e) => + @controller.openGitlabCICD(project); @tooltips.push atom.tooltips.add icon, { - title: "GitLab project #{project}" + title: "GitLab project #{project} #{stages[0]?.pipeline} on branch #{stages[0]?.jobs[0]?.ref}" } status.appendChild icon if stages.length is 0 @@ -99,29 +107,48 @@ class StatusBarView extends HTMLElement } status.appendChild e else + icon.onclick = (e) => + @controller.openPipeline(project, stages); + + allPipeline = document.createElement('span') + allPipeline.classList.add('icon', 'icon-inbox') + @tooltips.push atom.tooltips.add allPipeline, { + title: "Open all pipeline selector" + } + allPipeline.onclick = (e) => + @controller.openAllPipelineSelector(project); + status.appendChild allPipeline + + pipeline = document.createElement('span') + pipeline.classList.add('icon', "gitlab-#{stages[0]?.pipelineStatus}") + pipeline.innerHTML = "#{stages[0]?.pipeline}  " + pipeline.onclick = (e) => + @controller.openPipelineSelector(project); + @tooltips.push atom.tooltips.add pipeline, { + title: "Open branch pipeline selector" + } + status.appendChild pipeline + stages.forEach((stage) => - e = document.createElement('span') - switch - when stage.status is 'success' - e.classList.add('icon', 'gitlab-success') - when stage.status is 'failed' - e.classList.add('icon', 'gitlab-failed') - when stage.status is 'running' - e.classList.add('icon', 'gitlab-running') - when stage.status is 'pending' - e.classList.add('icon', 'gitlab-pending') - when stage.status is 'skipped' - e.classList.add('icon', 'gitlab-skipped') - when stage.status is 'canceled' - e.classList.add('icon', 'gitlab-canceled') - when stage.status is 'created' - e.classList.add('icon', 'gitlab-created') - when stage.status is 'manual' - e.classList.add('icon', 'gitlab-manual') + failedJobs = stage.jobs.filter( (job) -> job.status is 'failed' ) + + e = document.createElement('a') + e.classList.add('icon', "gitlab-#{stage.status}") + e.onclick = (e) => + @controller.openJobSelector(project, stage); @tooltips.push atom.tooltips.add e, { - title: "#{stage.name}: #{stage.status}" + title: "#{stage.name}: #{stage.status} | #{failedJobs.length} failed jobs out of #{stage.jobs.length} | Click to individually select a job's log to download." } status.appendChild e + if failedJobs.length > 0 + e = document.createElement('a') + e.classList.add('icon', "gitlab-artifact") + e.onclick = (e) => + @controller.openFailedLogs(project, stage.jobs); + @tooltips.push atom.tooltips.add e, { + title: "Download all failed logs (#{failedJobs.length}) from the stage #{stage.name}" + } + status.appendChild e ) @setchild(status) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d83303e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,345 @@ +{ + "name": "gitlab-integration", + "version": "0.4.4", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "assertion-error": { + "version": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.0.2.tgz", + "integrity": "sha1-E8pRXYYgbaC6xm6DTdOX2HWBCUw=", + "dev": true + }, + "atom-space-pen-views": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/atom-space-pen-views/-/atom-space-pen-views-2.2.0.tgz", + "integrity": "sha1-plsskg7QL3JAFPp9Plw9ePv1mZc=", + "requires": { + "fuzzaldrin": "2.1.0", + "space-pen": "5.1.2" + } + }, + "chai": { + "version": "https://registry.npmjs.org/chai/-/chai-3.5.0.tgz", + "integrity": "sha1-TQJjewZ/6Vi9v906QOxW/vc3Mkc=", + "dev": true, + "requires": { + "assertion-error": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.0.2.tgz", + "deep-eql": "https://registry.npmjs.org/deep-eql/-/deep-eql-0.1.3.tgz", + "type-detect": "https://registry.npmjs.org/type-detect/-/type-detect-1.0.0.tgz" + } + }, + "d": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/d/-/d-0.1.1.tgz", + "integrity": "sha1-2hhMU10Y2O57oqoim5FACfrhEwk=", + "requires": { + "es5-ext": "0.10.37" + } + }, + "debug": { + "version": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha1-XRKFFd8TT/Mn6QpMk/Tgd6U2NB8=", + "dev": true, + "requires": { + "ms": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz" + } + }, + "deep-eql": { + "version": "https://registry.npmjs.org/deep-eql/-/deep-eql-0.1.3.tgz", + "integrity": "sha1-71WKyrjeJSBs1xOQbXTlaTDrafI=", + "dev": true, + "requires": { + "type-detect": "https://registry.npmjs.org/type-detect/-/type-detect-0.1.1.tgz" + }, + "dependencies": { + "type-detect": { + "version": "https://registry.npmjs.org/type-detect/-/type-detect-0.1.1.tgz", + "integrity": "sha1-C6XsKohWQORw6k6FBZcZANrFiCI=", + "dev": true + } + } + }, + "deep-equal": { + "version": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha1-9dJgKStmDghO/0zbyfCK0yR0SLU=", + "dev": true + }, + "emissary": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/emissary/-/emissary-1.3.3.tgz", + "integrity": "sha1-phjZLWgrIy0xER3DYlpd9mF5lgY=", + "requires": { + "es6-weak-map": "0.1.4", + "mixto": "1.0.0", + "property-accessors": "1.1.3", + "underscore-plus": "1.6.6" + } + }, + "encoding": { + "version": "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz", + "integrity": "sha1-U4tm8+5izRq1HsMjgp0flIDHS+s=", + "requires": { + "iconv-lite": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.19.tgz" + } + }, + "es5-ext": { + "version": "0.10.37", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.37.tgz", + "integrity": "sha1-DudB0Ui4AGm6J9AgOTdWryV978M=", + "requires": { + "es6-iterator": "2.0.3", + "es6-symbol": "3.1.1" + }, + "dependencies": { + "d": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.0.tgz", + "integrity": "sha1-dUu1v+VUUdpppYuU1F9MWwRi1Y8=", + "requires": { + "es5-ext": "0.10.37" + } + }, + "es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha1-p96IkUGgWpSwhUQDstCg+/qY87c=", + "requires": { + "d": "1.0.0", + "es5-ext": "0.10.37", + "es6-symbol": "3.1.1" + } + }, + "es6-symbol": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.1.tgz", + "integrity": "sha1-vwDvT9q2uhtG7Le2KbTH7VcVzHc=", + "requires": { + "d": "1.0.0", + "es5-ext": "0.10.37" + } + } + } + }, + "es6-iterator": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-0.1.3.tgz", + "integrity": "sha1-1vWLjE/EE8JJtLqhl2j45NfIlE4=", + "requires": { + "d": "0.1.1", + "es5-ext": "0.10.37", + "es6-symbol": "2.0.1" + } + }, + "es6-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-2.0.1.tgz", + "integrity": "sha1-dhtcZ8/U8dGK+yNPaR1nhoLLO/M=", + "requires": { + "d": "0.1.1", + "es5-ext": "0.10.37" + } + }, + "es6-weak-map": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-0.1.4.tgz", + "integrity": "sha1-cGzvnpmqI2undmwjnIueKG6n0ig=", + "requires": { + "d": "0.1.1", + "es5-ext": "0.10.37", + "es6-iterator": "0.1.3", + "es6-symbol": "2.0.1" + } + }, + "fuzzaldrin": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fuzzaldrin/-/fuzzaldrin-2.1.0.tgz", + "integrity": "sha1-kCBMPi/appQbso0WZF1BgGOpDps=" + }, + "git-up": { + "version": "https://registry.npmjs.org/git-up/-/git-up-2.0.9.tgz", + "integrity": "sha1-IZv9J8gtrurYSVvrOG3Bjq5jY20=", + "requires": { + "is-ssh": "https://registry.npmjs.org/is-ssh/-/is-ssh-1.3.0.tgz", + "parse-url": "https://registry.npmjs.org/parse-url/-/parse-url-1.3.11.tgz" + } + }, + "git-url-parse": { + "version": "https://registry.npmjs.org/git-url-parse/-/git-url-parse-7.0.1.tgz", + "integrity": "sha1-Gj3/xuqp42NN7txY4g2eMxBtPUE=", + "requires": { + "git-up": "https://registry.npmjs.org/git-up/-/git-up-2.0.9.tgz" + } + }, + "grim": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/grim/-/grim-1.5.0.tgz", + "integrity": "sha1-sysI71Z88YUvgXWe2caLDXE5ajI=", + "requires": { + "emissary": "1.3.3" + } + }, + "iconv-lite": { + "version": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.19.tgz", + "integrity": "sha1-90aPYBNfXl2tM5nAqBvpoWA6CCs=" + }, + "is-ssh": { + "version": "https://registry.npmjs.org/is-ssh/-/is-ssh-1.3.0.tgz", + "integrity": "sha1-6+oRaaJhTaOSpjdANmw84EnY3/Y=", + "requires": { + "protocols": "https://registry.npmjs.org/protocols/-/protocols-1.4.6.tgz" + } + }, + "is-stream": { + "version": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=" + }, + "isomorphic-fetch": { + "version": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz", + "integrity": "sha1-YRrhrPFPXoH3KVB0coGf6XM1WKk=", + "requires": { + "node-fetch": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz", + "whatwg-fetch": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-2.0.3.tgz" + } + }, + "jquery": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-2.1.4.tgz", + "integrity": "sha1-IoveaYoMYUMdwmMKahVPFYkNIxc=" + }, + "json-stringify-safe": { + "version": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", + "dev": true + }, + "lodash": { + "version": "https://registry.npmjs.org/lodash/-/lodash-4.17.4.tgz", + "integrity": "sha1-eCA6TRwyiuHYbcpkYONptX9AVa4=", + "dev": true + }, + "minimist": { + "version": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", + "dev": true + }, + "mixto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/mixto/-/mixto-1.0.0.tgz", + "integrity": "sha1-wyDvYbUvKJj1IuF9i7xtUG2EJbY=" + }, + "mkdirp": { + "version": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "dev": true, + "requires": { + "minimist": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz" + } + }, + "moment": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.20.1.tgz", + "integrity": "sha512-Yh9y73JRljxW5QxN08Fner68eFLxM5ynNOAw2LbIB1YAGeQzZT8QFSUvkAz609Zf+IHhhaUxqZK8dG3W/+HEvg==" + }, + "ms": { + "version": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + }, + "nock": { + "version": "https://registry.npmjs.org/nock/-/nock-9.1.4.tgz", + "integrity": "sha1-XN2onF7/rtD0SEhvATW/ex578dw=", + "dev": true, + "requires": { + "chai": "https://registry.npmjs.org/chai/-/chai-3.5.0.tgz", + "debug": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "deep-equal": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", + "json-stringify-safe": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "lodash": "https://registry.npmjs.org/lodash/-/lodash-4.17.4.tgz", + "mkdirp": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "propagate": "https://registry.npmjs.org/propagate/-/propagate-0.4.0.tgz", + "qs": "https://registry.npmjs.org/qs/-/qs-6.5.1.tgz", + "semver": "https://registry.npmjs.org/semver/-/semver-5.4.1.tgz" + } + }, + "node-fetch": { + "version": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz", + "integrity": "sha1-mA9vcthSEaU0fGsrwYxbhMPrR+8=", + "requires": { + "encoding": "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz", + "is-stream": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz" + } + }, + "parse-url": { + "version": "https://registry.npmjs.org/parse-url/-/parse-url-1.3.11.tgz", + "integrity": "sha1-V8FUKKuKiSsfQ4aWRccR0OFEtVQ=", + "requires": { + "is-ssh": "https://registry.npmjs.org/is-ssh/-/is-ssh-1.3.0.tgz", + "protocols": "https://registry.npmjs.org/protocols/-/protocols-1.4.6.tgz" + } + }, + "percentile": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/percentile/-/percentile-1.2.0.tgz", + "integrity": "sha1-+jsFwf/TVbNSKFKYNOX6N/C9Rl0=" + }, + "propagate": { + "version": "https://registry.npmjs.org/propagate/-/propagate-0.4.0.tgz", + "integrity": "sha1-8/zKCm/gZzanulcpZgaWF8EwtIE=", + "dev": true + }, + "property-accessors": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/property-accessors/-/property-accessors-1.1.3.tgz", + "integrity": "sha1-Hd6EAkYxhlkJ7zBwM2VoDF+SixU=", + "requires": { + "es6-weak-map": "0.1.4", + "mixto": "1.0.0" + } + }, + "protocols": { + "version": "https://registry.npmjs.org/protocols/-/protocols-1.4.6.tgz", + "integrity": "sha1-+LsmPqG1/Xp2BNJri+Ob13Z4v4o=" + }, + "qs": { + "version": "https://registry.npmjs.org/qs/-/qs-6.5.1.tgz", + "integrity": "sha1-NJzfbu+J7EXBLX1es/wMhwNDptg=", + "dev": true + }, + "semver": { + "version": "https://registry.npmjs.org/semver/-/semver-5.4.1.tgz", + "integrity": "sha1-4FnAnYVx8FQII3M0M1BdOi8AsY4=", + "dev": true + }, + "space-pen": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/space-pen/-/space-pen-5.1.2.tgz", + "integrity": "sha1-Ivu+EOCwROe3pHsCPamdlLWE748=", + "requires": { + "grim": "1.5.0", + "jquery": "2.1.4", + "underscore-plus": "1.6.6" + } + }, + "type-detect": { + "version": "https://registry.npmjs.org/type-detect/-/type-detect-1.0.0.tgz", + "integrity": "sha1-diIXzAbbJY7EiQihKY6LlRIejqI=", + "dev": true + }, + "underscore": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.6.0.tgz", + "integrity": "sha1-izixDKze9jM3uLJOT/htRa6lKag=" + }, + "underscore-plus": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/underscore-plus/-/underscore-plus-1.6.6.tgz", + "integrity": "sha1-ZezeG9xEGjXYnmUP1w3PE65Dmn0=", + "requires": { + "underscore": "1.6.0" + } + }, + "whatwg-fetch": { + "version": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-2.0.3.tgz", + "integrity": "sha1-nITsLc9oGH/wC8ZOEnS0QhduHIQ=" + } + } +} diff --git a/package.json b/package.json index e94b638..bcb3196 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,11 @@ } }, "dependencies": { + "atom-space-pen-views": "^2.2.0", + "git-url-parse": "^7.0.0", "isomorphic-fetch": "^2.2.1", - "git-url-parse": "^7.0.0" + "moment": "^2.20.1", + "percentile": "^1.2.0" }, "devDependencies": { "nock": "^9.0.14" diff --git a/styles/icons.less b/styles/icons.less index f663c7f..35da178 100644 --- a/styles/icons.less +++ b/styles/icons.less @@ -20,6 +20,15 @@ border-style: solid; } +.gitlab-avatar { + width: 16px !important; + height: 16px !important; + vertical-align: -4px; + margin: 0; + padding: 0; + .gitlab-round; +} + .gitlab-created:before { .gitlab-icon(created); border-color: #c4c4c4; @@ -69,6 +78,12 @@ .gitlab-round; } +.gitlab-artifact:before { + .gitlab-icon(download); + border-color: #2e2e2e; + fill: #2e2e2e; +} + .gitlab-canceled:before { .gitlab-icon(canceled); border-color: #2e2e2e; @@ -97,3 +112,29 @@ width: 12px !important; height: 16px; } + +.progress-variant(@type) { + @text-color-name: "text-color-@{type}"; + + &::-webkit-progress-value { + background-color: @@text-color-name; + } +} + +progress { + &.progress-info { + .progress-variant(info); + } + + &.progress-success { + .progress-variant(success); + } + + &.progress-warning { + .progress-variant(warning); + } + + &.progress-error { + .progress-variant(error); + } +}