├── views ├── failure.ejs ├── console.ejs ├── login.ejs ├── index.ejs └── layout.ejs ├── .npmignore ├── .gitignore ├── public ├── images │ ├── logo.png │ ├── hubot_login.png │ ├── building-bot.gif │ ├── robawt-status.gif │ └── disclosure-arrow.png └── css │ └── base.css ├── src ├── jinkies.coffee ├── user.coffee ├── jenkins │ ├── utils │ │ ├── http_request.coffee │ │ ├── job_build_request.coffee │ │ └── campfire.coffee │ ├── server.coffee │ ├── job.coffee │ └── build.coffee ├── robot.coffee └── app.coffee ├── spec ├── base64_spec.coffee ├── trigger_build_spec.coffee ├── helper.coffee ├── job_spec.coffee ├── campfire_spec.coffee └── server_spec.coffee ├── Makefile ├── package.json ├── LICENSE ├── README.md └── bin └── jinkies /views/failure.ejs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | spec 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | .DS_Store 3 | -------------------------------------------------------------------------------- /views/console.ejs: -------------------------------------------------------------------------------- 1 |
<%- output %>
2 | -------------------------------------------------------------------------------- /public/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atmos/jinkies/HEAD/public/images/logo.png -------------------------------------------------------------------------------- /public/images/hubot_login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atmos/jinkies/HEAD/public/images/hubot_login.png -------------------------------------------------------------------------------- /public/images/building-bot.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atmos/jinkies/HEAD/public/images/building-bot.gif -------------------------------------------------------------------------------- /public/images/robawt-status.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atmos/jinkies/HEAD/public/images/robawt-status.gif -------------------------------------------------------------------------------- /public/images/disclosure-arrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atmos/jinkies/HEAD/public/images/disclosure-arrow.png -------------------------------------------------------------------------------- /views/login.ejs: -------------------------------------------------------------------------------- 1 | 2 | Please Login 3 | 4 | -------------------------------------------------------------------------------- /views/index.ejs: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /src/jinkies.coffee: -------------------------------------------------------------------------------- 1 | require.paths.unshift(__dirname) 2 | 3 | Robot = require "./robot" 4 | Server = require "jenkins/server" 5 | ExpressApp = require "./app" 6 | 7 | exports.Job = Server.Job 8 | exports.Robot = Robot.Robot 9 | exports.Build = Server.Build 10 | exports.Server = Server.Server 11 | exports.ExpressApp = ExpressApp.App 12 | exports.CampfireRobot = Robot.CampfireRobot 13 | -------------------------------------------------------------------------------- /spec/base64_spec.coffee: -------------------------------------------------------------------------------- 1 | Helper = require "./helper" 2 | Vows = Helper.Vows 3 | assert = Helper.Assert 4 | Options = Helper.default_options 5 | 6 | Vows 7 | .describe("Jenkins Base64 Library") 8 | .addBatch 9 | "Jenkins Base64 can": 10 | topic: -> 11 | new Buffer "Jinkies Paradise" 12 | "encode strings": (buffer) -> 13 | assert.equal buffer.toString("base64"), "Smlua2llcyBQYXJhZGlzZQ==" 14 | 15 | .export(module) 16 | -------------------------------------------------------------------------------- /spec/trigger_build_spec.coffee: -------------------------------------------------------------------------------- 1 | Helper = require "./helper" 2 | Vows = Helper.Vows 3 | assert = Helper.Assert 4 | Options = Helper.default_options 5 | 6 | Job = require("jinkies").Job 7 | 8 | Vows 9 | .describe("Jenkins Job Build Trigger API") 10 | .addBatch 11 | "Jenkins Jobs Build can": 12 | topic: -> 13 | j = new Job Options.server, Options.job 14 | j.triggerBuild "master", "{}", @callback 15 | 16 | "trigger a build": (err, data) -> 17 | assert.ok data.status 18 | 19 | .export(module) 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | deps: 2 | @test `which coffee` || echo "You need to have CoffeeScript in your PATH.\nPlease install it using `brew install coffee-script` or `npm install coffee-script`." 3 | 4 | build: deps 5 | @coffee -o lib src/ 6 | @coffee -o spec spec/ 7 | 8 | install: deps 9 | @coffee -o lib src/ 10 | @npm install 11 | 12 | publish: deps 13 | @coffee -o lib src/ 14 | @npm publish 15 | 16 | test: build 17 | @vows spec/*_spec.js --spec 18 | 19 | http: build 20 | @node lib/app.js 21 | 22 | dev: deps 23 | @coffee -wc --bare -o lib src/ 24 | 25 | .PHONY: all 26 | -------------------------------------------------------------------------------- /spec/helper.coffee: -------------------------------------------------------------------------------- 1 | require.paths.unshift(__dirname + "/../lib") 2 | 3 | Vows = require "vows" 4 | exports.Vows = Vows 5 | 6 | Assert = require "assert" 7 | exports.Assert = Assert 8 | 9 | exports.default_options = 10 | job: process.env.JENKINS_JOB || "default" 11 | server: process.env.JENKINS_SERVER || "http://localhost:8080" 12 | campfire: 13 | user: process.env.JENKINS_CAMPFIRE_USER || 42 14 | room: process.env.JENKINS_CAMPFIRE_ROOM || 42 15 | token: process.env.JENKINS_CAMPFIRE_TOKEN || "xxxxxxxxxxxxxxxxxxxxxxxx" 16 | account: process.env.JENKINS_CAMPFIRE_ACCOUNT || "unknown" 17 | -------------------------------------------------------------------------------- /views/layout.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%= title %> 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 |
16 |
17 | <%- body %> 18 |
19 |
20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /src/user.coffee: -------------------------------------------------------------------------------- 1 | HTTPS = require "https" 2 | 3 | class User 4 | constructor: (@data) -> 5 | @login = @data.login 6 | 7 | memberOf: (name, callback) -> 8 | path = "/api/v2/json/user/show/#{@login}/organizations" 9 | params = { host: "github.com", path: path } 10 | isMember = false 11 | 12 | console.log path 13 | req = HTTPS.request params, (res) -> 14 | body = "" 15 | res.setEncoding "utf8" 16 | res.on "end", -> 17 | orgs = JSON.parse body 18 | orgs.organizations.forEach (element) -> 19 | if element.login == name 20 | isMember = true 21 | 22 | callback null, isMember 23 | res.on "data", (chunk) -> 24 | body += chunk 25 | req.end() 26 | 27 | exports.User = User 28 | -------------------------------------------------------------------------------- /src/jenkins/utils/http_request.coffee: -------------------------------------------------------------------------------- 1 | Url = require "url" 2 | Http = require "http" 3 | 4 | class HttpRequest 5 | constructor: (@host) -> 6 | @url = Url.parse @host 7 | @port = @url.port 8 | @path = @url.pathname || "" 9 | @hostname = @url.hostname 10 | 11 | fetch: (path, callback) -> 12 | result = "" 13 | headers = 14 | "Accept": "application/json" 15 | 16 | params = 17 | "host": @hostname 18 | "port": @port 19 | "path": "#{@path}#{path}" 20 | "headers": headers 21 | 22 | request = Http.request params, (response) -> 23 | response.on "end", -> 24 | if response.statusCode == 200 25 | callback null, JSON.parse(result) 26 | else 27 | callback null, { "error": response.statusCode, "actions": [ ], "jobs": [ ] } # shady 28 | response.on "data", (chunk) -> 29 | result += chunk 30 | response.on "error", (err) -> 31 | callback err, { } 32 | request.end() 33 | 34 | exports.HttpRequest = HttpRequest 35 | -------------------------------------------------------------------------------- /src/jenkins/server.coffee: -------------------------------------------------------------------------------- 1 | Job = require("jenkins/job").Job 2 | Map = require("async").map 3 | Build = require("jenkins/build").Build 4 | HttpRequest = require("jenkins/utils/http_request").HttpRequest 5 | 6 | class Server 7 | constructor: (@host) -> 8 | @client = new HttpRequest(@host) 9 | @url = @client.url 10 | 11 | job_for: (name) -> 12 | new Job @host, name 13 | 14 | job_names: (callback) -> 15 | @jobs (err, jobs) -> 16 | callback err, (job.name for job in jobs) 17 | 18 | jobs: (callback) -> 19 | self = @ 20 | @client.fetch "/api/json", (err, data) -> 21 | callback err, (self.job_for(job.name) for job in data.jobs) 22 | 23 | jobs_info: (callback) -> 24 | info_callback = (job, infoCallback) -> 25 | job.branches_for "master", (err, data) -> 26 | infoCallback err, data[0] 27 | 28 | @jobs (err, jobs) -> 29 | Map jobs, info_callback, (err, results) -> 30 | callback err, results 31 | 32 | exports.Job = Job 33 | exports.Build = Build 34 | exports.Server = Server 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jinkies", 3 | "description": "GitHub integration with Jenkins CI", 4 | "keywords": "github post-receive hudson jenkins dashboard", 5 | "author": "Corey Donohoe", 6 | "version": "0.3.6", 7 | "licenses": [{ 8 | "type": "MIT", 9 | "url": "http://github.com/atmos/jinkies/raw/master/LICENSE" 10 | }], 11 | 12 | "repository" : { 13 | "type" : "git", 14 | "url" : "http://github.com/atmos/jinkies.git" 15 | }, 16 | 17 | "dependencies" : { 18 | "ejs" : ">= 0.3.1", 19 | "async" : ">= 0.1.8", 20 | "oauth" : ">= 0.9.0", 21 | "redis-node" : ">= 0.4.0", 22 | "coffee-script" : ">= 1.0.1", 23 | "express" : ">= 1.0.7", 24 | "connect-auth" : ">= 0.2.1", 25 | "optparse" : ">= 1.0.1", 26 | "ansi-color" : ">= 0.2.1" 27 | }, 28 | 29 | "engines": { 30 | "node": ">=0.4.1" 31 | }, 32 | "directories": { 33 | "lib": "./lib" 34 | }, 35 | "main": "./lib/jinkies", 36 | "bin": { 37 | "jinkies": "./bin/jinkies" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 Corey Donohoe 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /spec/job_spec.coffee: -------------------------------------------------------------------------------- 1 | Helper = require "./helper" 2 | Vows = Helper.Vows 3 | assert = Helper.Assert 4 | Options = Helper.default_options 5 | 6 | Job = require("jinkies").Job 7 | 8 | Vows 9 | .describe("Jenkins Job API") 10 | .addBatch 11 | "Jenkins Jobs can": 12 | topic: -> 13 | new Job Options.server, Options.job 14 | "get the hostname": (job) -> 15 | assert.equal job.host, Options.server 16 | "get the job name": (job) -> 17 | assert.equal job.name, Options.job 18 | "Jenkins Jobs#builds can": 19 | topic: -> 20 | j = new Job Options.server, Options.job 21 | j.builds @callback 22 | "get a list of builds": (err, info) -> 23 | latest = info[0]["number"] 24 | assert.ok latest.toString().match(/\d+/) 25 | "Jenkins Jobs#status can": 26 | topic: -> 27 | j = new Job Options.server, Options.job 28 | j.status @callback 29 | "get the status of a job": (err, status) -> 30 | assert.equal status, "successful" 31 | "Jenkins Jobs#build_for can": 32 | topic: -> 33 | j = new Job Options.server, Options.job 34 | j.build_for 10, @callback 35 | "get info about a build number": (err, build) -> 36 | assert.ok build.status 37 | assert.equal build.branch, "master" 38 | assert.equal build.sha1 , "3ba21ee37953c344f698171cb2ab725ef7f9b776" 39 | assert.equal build.output, "#{Options.server}/job/#{Options.job}/10/consoleText" 40 | 41 | .export(module) 42 | -------------------------------------------------------------------------------- /src/jenkins/utils/job_build_request.coffee: -------------------------------------------------------------------------------- 1 | Url = require "url" 2 | Http = require "http" 3 | QueryString = require "querystring" 4 | 5 | class JobBuildRequest 6 | constructor: (@url, @name, @branch, @payload) -> 7 | @port = @url.port 8 | @path = "/job/#{@name}/build" 9 | @hostname = @url.hostname 10 | 11 | @options = 12 | parameter: [ 13 | {"name": "GITHUB_BRANCH", "value": @branch}, 14 | {"name": "GITHUB_PAYLOAD", "value": @payload } 15 | ] 16 | 17 | trigger: (callback) -> 18 | result = "" 19 | client = Http.createClient(@port, @hostname) 20 | client.on "error", (err) -> 21 | console.log "Unable to connect to #{@host}, did you set a JENKINS_SERVER environmental variable?" 22 | callback err, { } 23 | 24 | data = QueryString.stringify({json:JSON.stringify(@options)}, "&", "=", false) 25 | 26 | postParams = 27 | "host": @hostname 28 | "Content-Length": data.length 29 | "Content-Type": "application/x-www-form-urlencoded" 30 | 31 | request = client.request "POST", @path, postParams 32 | request.on "response", (response) -> 33 | response.on "end", -> 34 | callback null, {"status": response.statusCode == 302} 35 | response.on "data", (chunk) -> 36 | result += chunk 37 | response.on "error", (err) -> 38 | callback err, { } 39 | request.end data 40 | 41 | exports.JobBuildRequest = JobBuildRequest 42 | -------------------------------------------------------------------------------- /spec/campfire_spec.coffee: -------------------------------------------------------------------------------- 1 | Helper = require "./helper" 2 | Vows = Helper.Vows 3 | assert = Helper.Assert 4 | Options = Helper.default_options 5 | 6 | Campfire = require("jenkins/utils/campfire").Campfire 7 | 8 | Vows 9 | .describe("Jenkins Campfire Library") 10 | .addBatch 11 | "Jenkins Campfire can": 12 | topic: -> 13 | new Campfire({"token": Options.campfire.token, "account": Options.campfire.account}) 14 | "retrieve its token": (campfire) -> 15 | assert.equal campfire.token, Options.campfire.token 16 | "retrieve its account name": (campfire) -> 17 | assert.equal campfire.account, Options.campfire.account 18 | #"Jenkins Campfire Users can": 19 | #topic: -> 20 | #cf = new Campfire({"token": Options.campfire.token, "account": Options.campfire.account}) 21 | #cf.User Options.campfire.user, @callback 22 | #"retrieves a user's information": (user) -> 23 | #console.log user 24 | "Jenkins Campfire Rooms can": 25 | topic: -> 26 | cf = new Campfire({"token": Options.campfire.token, "account": Options.campfire.account}) 27 | cf.Rooms @callback 28 | "retrieves a list of rooms": (rooms) -> 29 | console.log rooms 30 | "Jenkins Campfire Rooms can": 31 | topic: -> 32 | cf = new Campfire({"token": Options.campfire.token, "account": Options.campfire.account}) 33 | room = cf.Room(Options.campfire.room) 34 | room.speak "Vows Test Post", @callback 35 | "retrieve its token": (err, data) -> 36 | assert.equal "Vows Test Post", data.message.body 37 | 38 | .export(module) 39 | -------------------------------------------------------------------------------- /spec/server_spec.coffee: -------------------------------------------------------------------------------- 1 | Helper = require "./helper" 2 | Vows = Helper.Vows 3 | assert = Helper.Assert 4 | Options = Helper.default_options 5 | 6 | Server = require("jinkies").Server 7 | 8 | Vows 9 | .describe("Jenkins Server API") 10 | .addBatch 11 | "Jenkins Servers can": 12 | topic: -> 13 | new Server Options.server 14 | "get the hostname": (jenkins) -> 15 | assert.equal jenkins.host, Options.server 16 | "parse the hostname": (jenkins) -> 17 | assert.ok Options.server.match("#{jenkins.url.hostname}") 18 | "parse the port number": (jenkins) -> 19 | assert.ok jenkins.url.port.match(/\d+/) 20 | "Jenkins Server#job_names": 21 | topic: -> 22 | s = new Server Options.server 23 | s.job_names @callback 24 | 25 | "list the job names": (err, results) -> 26 | found = (name for name in results when name == Options.job) 27 | assert.equal found, Options.job 28 | 29 | "Jenkins Server#jobs": 30 | topic: -> 31 | s = new Server Options.server 32 | s.jobs @callback 33 | 34 | "list the jobs": (err, results) -> 35 | found = (job for job in results when job.name == Options.job)[0] 36 | assert.equal found.url, "#{Options.server}/job/#{Options.job}/" 37 | assert.equal found.name, Options.job 38 | assert.equal found.color, 'blue' 39 | 40 | "Jenkins.job can": 41 | topic: -> 42 | j = new Server Options.server 43 | j.job_for(Options.job) 44 | "get the hostname": (job) -> 45 | assert.equal job.host, Options.server 46 | "get the job name": (job) -> 47 | assert.equal job.name, Options.job 48 | 49 | .export(module) 50 | -------------------------------------------------------------------------------- /src/jenkins/job.coffee: -------------------------------------------------------------------------------- 1 | Map = require("async").map 2 | Redis = require("redis-node") 3 | Build = require("jenkins/build").Build 4 | HttpRequest = require("jenkins/utils/http_request").HttpRequest 5 | BuildRequest = require("jenkins/utils/job_build_request").JobBuildRequest 6 | 7 | RedisClient = Redis.createClient() 8 | RedisClient.select 4 9 | 10 | class Job 11 | constructor: (@host, @name) -> 12 | @client = new HttpRequest @host 13 | 14 | info: (callback) -> 15 | @client.fetch "/job/#{@name}/api/json", (err, data) -> 16 | callback err, data 17 | 18 | builds: (callback) -> 19 | self = @ 20 | @info (err, data) -> 21 | numbers = (hash.number for hash in data.builds) 22 | build_for = 23 | self.build_for.bind(self) 24 | Map numbers, build_for, (err, results) -> 25 | callback err, results 26 | 27 | status: (callback) -> 28 | @branches_for 'master', (err, branches) -> 29 | branch = branches[0] || { status: 'failed' } 30 | callback err, branch.status 31 | 32 | build_for: (number, callback) -> 33 | host = @host 34 | name = @name 35 | client = @client 36 | 37 | RedisClient.get "#{@name}:#{number}", (err, cachedBuild) -> 38 | if cachedBuild 39 | callback err, JSON.parse cachedBuild 40 | else 41 | client.fetch "/job/#{name}/#{number}/api/json", (err, data) -> 42 | build = new Build host, name, number, data 43 | if build.status != "building" 44 | build.consoleText (err, data) -> 45 | build.consoleText = data 46 | RedisClient.set "#{name}:#{number}", JSON.stringify(build), (err, data) -> 47 | callback err, build 48 | else 49 | callback err, build 50 | 51 | branches_for: (branch, callback) -> 52 | self = @ 53 | @builds (err, data) -> 54 | results = (hash for hash in data when hash.branch == branch) 55 | callback err, results[0..10] 56 | 57 | triggerBuild: (branch, payload, callback) -> 58 | req = new BuildRequest @client.url, @name, branch, payload 59 | req.trigger (err, data) -> 60 | callback err, data 61 | 62 | exports.Job = Job 63 | -------------------------------------------------------------------------------- /src/jenkins/build.coffee: -------------------------------------------------------------------------------- 1 | Url = require("url") 2 | Http = require("http") 3 | 4 | class Build 5 | constructor: (@host, @name, @number, @data) -> 6 | @sha1 = "0000000000000000000000000000000000000000" 7 | @branch = "master" 8 | @compare = "" 9 | @output = "#{@host}/job/#{@name}/#{@number}/consoleText" 10 | 11 | switch @data.result 12 | when "SUCCESS" 13 | @status = "successful" 14 | when "FAILURE" 15 | @status = "failed" 16 | else # null 17 | @status = "building" 18 | 19 | @statusClass = @statusClass() 20 | 21 | info = (action for action in @data.actions when action.parameters) 22 | if info[0] 23 | params = info[0].parameters 24 | 25 | @branch = (hash.value for hash in params when hash.name == "GITHUB_BRANCH")[0] 26 | payload = (hash.value for hash in params when hash.name == "GITHUB_PAYLOAD")[0] 27 | 28 | if payload && payload.length > 2 29 | try 30 | @payload = payload.slice(1, payload.length - 1) # strip beginning and end quote :\ 31 | @payload = JSON.parse @payload 32 | @sha1 = @payload.after if @payload.after 33 | @branch = @payload.ref.split("/")[2] if @payload.ref 34 | 35 | consoleText: (callback) -> 36 | result = "" 37 | url = Url.parse @output 38 | 39 | client = Http.createClient url.port, url.hostname 40 | client.on 'error', (err) -> 41 | console.log url 42 | console.log "Unable to connect to #{@host}, did you set a JENKINS_SERVER environmental variable?" 43 | callback(err, { }) 44 | 45 | request = client.request 'GET', url.pathname, {'host': url.hostname } 46 | request.on 'response', (response) -> 47 | response.on 'end', -> 48 | if response.statusCode == 200 49 | callback null, result 50 | else 51 | callback null, "Unable to fetch the output\nWTF\n" 52 | response.on 'data', (chunk) -> 53 | result += chunk 54 | response.on 'error', (err) -> 55 | callback err, { } 56 | request.end() 57 | 58 | statusClass: -> 59 | switch @status 60 | when 'successful' 61 | 'good' 62 | when 'building' 63 | 'building' 64 | else 65 | 'janky' 66 | 67 | exports.Build = Build 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Velma Dinkley said "Jinkies"][1]][2] 2 | 3 | > Her catchphrases are: "**Jinkies!**", "My glasses! I can't see without my glasses!", and "You wouldn't hit someone wearing glasses, would you?". 4 | 5 | -------------------------------------------------------------------------- 6 | 7 | Jinkies is a [coffee-script](http://jashkenas.github.com/coffee-script/) interface to the [Jenkins](http://jenkins-ci.org) ([Continuous Integration](http://martinfowler.com/articles/continuousIntegration.html)) server's JSON API. 8 | 9 | I'd written half of this before I realized I was cloning a lot of [hudson.rb](https://github.com/cowboyd/hudson.rb). 10 | 11 | -------------------------------------------------------------------------- 12 | 13 | You need a current [node.js](http://nodejs.org) development environment. 14 | 15 | [Setting up your environment](https://github.com/atmos/jinkies/wiki/Development) 16 | --------------------------------------------------------------------------------------------------- 17 | 18 | Jinkies is [available](https://github.com/atmos/jinkies) under an [MIT license](https://github.com/atmos/jinkies/blob/master/LICENSE). 19 | 20 | [If you care about testing your hacks](https://github.com/atmos/jinkies/wiki/Testing) 21 | ------------------------------------------------------------------------------------- 22 | 23 | Jinkies has tests that you can run too. 24 | 25 | [If you're interested with GitHub integration](https://github.com/atmos/jinkies/wiki/The-Web-API) 26 | ------------------------------------------------------------------------------------- 27 | 28 | Jinkies works as a post-receive endpoint for GitHub webhooks. 29 | 30 | [If you're interested in the command line](https://github.com/atmos/jinkies/wiki/Command-Line) 31 | ----------------------------------------------------------------------------------------------- 32 | 33 | Jinkies gives you a simple executable that uses the library. 34 | 35 | $ bin/jinkies -h 36 | Usage jinkies [options] 37 | 38 | Available options: 39 | -h, --help Display the help information 40 | -j, --job JOB Specify the jenkins job 41 | -t, --trigger-build Trigger a build for the jenkins jobs 42 | -b, --branch BRANCH Specify the jenkins build should locate 43 | -s, --sha1 SHA1 Specify the SHA1 the jenkins build should locate 44 | -p, --express-port PORT Specify the express port, defaults to 45678 45 | -e, --express-app Start the express webhook endpoint 46 | -r, --robot Start the robot!!!!1 47 | -d, --server SERVER Specify jinkies server to use 48 | -a, --all List all the jobs on the jenkins server 49 | 50 | [1]: http://f.cl.ly/items/370L3N2X363C2S38110W/img-rn_jinkies.jpeg 51 | [2]: http://en.wikipedia.org/wiki/Velma_Dinkley 52 | -------------------------------------------------------------------------------- /src/robot.coffee: -------------------------------------------------------------------------------- 1 | Campfire = require("jenkins/utils/campfire").Campfire 2 | HttpRequest = require("jenkins/utils/http_request").HttpRequest 3 | EventEmitter = require("events").EventEmitter 4 | 5 | class Build 6 | constructor: (@job, @number) -> 7 | 8 | info: (callback) -> 9 | @job.client.fetch "/jobs/#{@job.name}/builds/#{@number}", (err, data) -> 10 | callback err, data 11 | 12 | notify: (callback) -> 13 | self = @ 14 | @info (err, data) -> 15 | sha = data.sha1.slice(0,7) 16 | number = data.number 17 | branch = data.branch 18 | reply = "Build ##{number} (#{sha}) of #{self.job.name}/#{branch}" 19 | compare = data.payload && data.payload.compare 20 | duration = data.data.duration / 1000 || 0.0 21 | 22 | switch data.status 23 | when "successful" 24 | reply += " was successful " 25 | when "failed" 26 | reply += " failed " 27 | when "building" 28 | reply += " building now " 29 | else 30 | reply += " unknown[#{data.status}] " 31 | 32 | self.status = data.status 33 | 34 | reply += "(#{Math.floor(duration)}s)." 35 | reply += " #{compare}" if compare 36 | 37 | callback err, self, reply, data.consoleText 38 | 39 | class Job 40 | constructor: (@client, @name) -> 41 | @number = null 42 | 43 | poll: (callback) -> 44 | self = @ 45 | @client.fetch "/jobs/#{@name}", (err, data) -> 46 | if !self.number or data.lastCompletedBuild.number > self.number 47 | prev_number = self.number 48 | self.number = data.lastCompletedBuild.number 49 | if prev_number? 50 | build = new Build(self, self.number) 51 | build.notify (err, build, notification, output) -> 52 | callback err, build, notification, output 53 | 54 | setTimeout (-> 55 | self.poll callback 56 | ), 15000 57 | 58 | class Robot extends EventEmitter 59 | constructor: (@host) -> 60 | @client = new HttpRequest(@host) 61 | 62 | findJobs: (callback) -> 63 | client = @client 64 | client.fetch "/jobs", (err, data) -> 65 | for job in data 66 | do (job) -> 67 | callback err, new Job(client, job.name) 68 | 69 | run: (callback) -> 70 | self = @ 71 | @findJobs (err, job) -> 72 | job.poll (err, build, notification, output) -> 73 | self.emit("build", err, build, notification, output) 74 | 75 | class CampfireRobot 76 | constructor: (@host, @options) -> 77 | @robot = new Robot(@host) 78 | @campfire = new Campfire(@options) 79 | @room = @campfire.Room(@options.room) 80 | 81 | run: -> 82 | room = @room 83 | @robot.on "build", (err, build, notification, output) -> 84 | room.speak notification, (err, data) -> 85 | console.log "Sent: #{notification}" 86 | if build.status == "failed" 87 | room.paste output, (err, data) -> 88 | console.log "Pasted failures" 89 | @robot.run() 90 | 91 | exports.Robot = Robot 92 | exports.CampfireRobot = CampfireRobot 93 | -------------------------------------------------------------------------------- /src/jenkins/utils/campfire.coffee: -------------------------------------------------------------------------------- 1 | HTTPS = require "https" 2 | EventEmitter = require("events").EventEmitter 3 | 4 | class Campfire extends EventEmitter 5 | constructor: (options) -> 6 | @token = options.token 7 | @rooms = options.rooms and options.rooms.split(",") 8 | @account = options.account 9 | @domain = @account + ".campfirenow.com" 10 | @authorization = "Basic " + new Buffer("#{@token}:x").toString("base64") 11 | 12 | Rooms: (callback) -> 13 | @get "/rooms", callback 14 | 15 | User: (id, callback) -> 16 | @get "/users/#{id}", callback 17 | 18 | Me: (callback) -> 19 | @get "/users/me", callback 20 | 21 | Room: (id) -> 22 | self = @ 23 | 24 | show: (callback) -> 25 | self.post "/room/#{id}", "", callback 26 | join: (callback) -> 27 | self.post "/room/#{id}/join", "", callback 28 | leave: (callback) -> 29 | self.post "/room/#{id}/leave", "", callback 30 | lock: (callback) -> 31 | self.post "/room/#{id}/lock", "", callback 32 | unlock: (callback) -> 33 | self.post "/room/#{id}/unlock", "", callback 34 | 35 | # say things to this channel on behalf of the token user 36 | paste: (text, callback) -> 37 | @message text, "PasteMessage", callback 38 | sound: (text, callback) -> 39 | @message text, "SoundMessage", callback 40 | speak: (text, callback) -> 41 | @message text, "TextMessage", callback 42 | message: (text, type, callback) -> 43 | body = { message: { "body":text, "type":type } } 44 | self.post "/room/#{id}/speak", body, callback 45 | 46 | # listen for activity in channels 47 | listen: -> 48 | headers = 49 | "Host" : "streaming.campfirenow.com", 50 | "Authorization" : self.authorization 51 | 52 | options = 53 | "host" : "streaming.campfirenow.com" 54 | "port" : 443 55 | "path" : "/room/#{id}/live.json" 56 | "method" : "GET" 57 | "headers": headers 58 | 59 | request = HTTPS.request options, (response) -> 60 | response.setEncoding("utf8") 61 | response.on "data", (chunk) -> 62 | #console.log "#{new Date}: Received #{id} \"#{chunk}\"" 63 | if chunk.match(/^\S+/) 64 | try 65 | chunk.split("\r").forEach (part) -> 66 | data = JSON.parse part 67 | 68 | self.emit data.type, data.id, data.created_at, data.room_id, data.user_id, data.body 69 | data 70 | 71 | response.on "end", -> 72 | console.log "Streaming Connection closed. :(" 73 | 74 | response.on "error", (err) -> 75 | console.log err 76 | request.end() 77 | request.on "error", (err) -> 78 | console.log err 79 | console.log err.stack 80 | 81 | # Convenience HTTP Methods for posting on behalf of the token"d user 82 | get: (path, callback) -> 83 | @request "GET", path, null, callback 84 | 85 | post: (path, body, callback) -> 86 | @request "POST", path, body, callback 87 | 88 | request: (method, path, body, callback) -> 89 | headers = 90 | "Authorization" : @authorization 91 | "Host" : @domain 92 | "Content-Type" : "application/json" 93 | 94 | options = 95 | "host" : @domain 96 | "port" : 443 97 | "path" : path 98 | "method" : method 99 | "headers": headers 100 | 101 | if method == "POST" 102 | if typeof(body) != "string" 103 | body = JSON.stringify body 104 | options.headers["Content-Length"] = body.length 105 | 106 | request = HTTPS.request options, (response) -> 107 | data = "" 108 | response.on "data", (chunk) -> 109 | data += chunk 110 | response.on "end", -> 111 | try 112 | callback null, JSON.parse(data) 113 | catch err 114 | callback null, data || { } 115 | response.on "error", (err) -> 116 | callback err, { } 117 | 118 | if method == "POST" 119 | request.end(body) 120 | else 121 | request.end() 122 | request.on "error", (err) -> 123 | console.log err 124 | console.log err.stack 125 | 126 | exports.Campfire = Campfire 127 | -------------------------------------------------------------------------------- /bin/jinkies: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env coffee 2 | require.paths.unshift(__dirname + "/../lib") 3 | 4 | Color = require "ansi-color" 5 | Jinkies = require "jinkies" 6 | OptParse = require "optparse" 7 | 8 | Switches = [ 9 | [ "-h", "--help", "Display the help information"], 10 | [ "-j", "--job JOB", "Specify the jenkins job"], 11 | [ "-t", "--trigger-build", "Trigger a build for the jenkins jobs"], 12 | [ "-b", "--branch BRANCH", "Specify the jenkins build should locate"], 13 | [ "-s", "--sha1 SHA1", "Specify the SHA1 the jenkins build should locate"], 14 | [ "-p", "--express-port PORT", "Specify the express port, defaults to 45678"], 15 | [ "-e", "--express-app", "Start the express webhook endpoint"], 16 | [ "-r", "--robot", "Start a shell robot"], 17 | [ "-c", "--campfire", "Start a campfire notifier"], 18 | [ "-d", "--server SERVER", "Specify jinkies server to use"], 19 | [ "-a", "--all", "List all the jobs on the jenkins server"] 20 | ] 21 | 22 | Options = 23 | mode: "all" 24 | sha1: "" 25 | port: 45678 26 | job: process.env.JENKINS_JOB || "default" 27 | server: process.env.JENKINS_SERVER || "http://localhost:8080" 28 | branch: process.env.JENKINS_BRANCH || "master" 29 | 30 | Parser = new OptParse.OptionParser(Switches) 31 | Parser.banner = "Usage jinkies [options]" 32 | 33 | Parser.on "server", (opt, value) -> 34 | Options.server = value 35 | 36 | Parser.on "all", -> 37 | Options.mode = "all" 38 | 39 | Parser.on "branch", (opt, value) -> 40 | Options.branch = value 41 | Options.mode = "job" 42 | 43 | Parser.on "sha1", (opt, value) -> 44 | Options.sha1 = value 45 | Options.mode = "job" 46 | 47 | Parser.on "job", (opt, value) -> 48 | Options.job = value 49 | Options.mode = "job" 50 | 51 | Parser.on "robot", (opt, value) -> 52 | Options.mode = "robot" 53 | 54 | Parser.on "campfire", (opt, value) -> 55 | Options.mode = "campfire" 56 | 57 | Parser.on "trigger-build", (opt, value) -> 58 | Options.mode = "build" 59 | 60 | Parser.on "express-app", (opt, value) -> 61 | Options.mode = "express" 62 | 63 | Parser.on "express-port", (opt, value) -> 64 | Options.port = value 65 | 66 | Parser.on "help", (opt, value) -> 67 | console.log Parser.toString() 68 | process.exit 0 69 | 70 | Parser.parse process.ARGV 71 | 72 | server = new Jinkies.Server(Options.server) 73 | 74 | switch Options.mode 75 | when "all" 76 | server.jobs (err, jobs) -> 77 | for job in jobs 78 | do (job) -> 79 | job.color = "green" if job.color == "blue" 80 | console.log("#{Color.set(job.name, job.color)} - #{job.url}") 81 | when "job" 82 | job = server.job_for Options.job 83 | job.info (err, info) -> 84 | info.color = "green" if info.color == "blue" 85 | console.log("#{Color.set(info.name, info.color)} - #{info.url}") 86 | job.build_for info.lastSuccessfulBuild.number, (err, build) -> 87 | console.log(" Last Successful Build - ##{build.number} - #{build.branch} - #{build.sha1}") 88 | if info.lastFailedBuild 89 | job.build_for info.lastFailedBuild.number, (err, build) -> 90 | console.log(" Last Failed Build - ##{build.number} - #{build.branch} - #{build.sha1}") 91 | else 92 | console.log(" Last Failed Build - never") 93 | when "build" 94 | job = server.job_for Options.job 95 | job.triggerBuild Options.branch, "{}", (err, data) -> 96 | status = Color.set("failed", "red") 97 | status = Color.set("succeeded", "green") if data.status == true 98 | console.log "Build request for #{job.name}: #{status}" 99 | 100 | when "express" 101 | app = Jinkies.ExpressApp 102 | app.jinkies = new Jinkies.Server(Options.server) 103 | app.listen(Options.port) 104 | process.on 'uncaughtException', (err) -> 105 | console.log('Caught exception: ' + err) 106 | console.log err.stack 107 | 108 | when "robot" 109 | robot = new Jinkies.Robot(Options.server) 110 | robot.on "build", (err, build, notification) -> 111 | console.log(notification) 112 | robot.run() 113 | process.on 'uncaughtException', (err) -> 114 | console.log('Caught exception: ' + err) 115 | console.log err.stack 116 | when "campfire" 117 | campfireOptions = 118 | room: process.env.JENKINS_CAMPFIRE_ROOM 119 | token: process.env.JENKINS_CAMPFIRE_TOKEN 120 | account: process.env.JENKINS_CAMPFIRE_ACCOUNT 121 | 122 | robot = new Jinkies.CampfireRobot(Options.server, campfireOptions) 123 | robot.run() 124 | process.on 'uncaughtException', (err) -> 125 | console.log('Caught exception: ' + err) 126 | console.log err.stack 127 | 128 | #console.log(Options) 129 | 130 | # vim:ft=coffee 131 | -------------------------------------------------------------------------------- /src/app.coffee: -------------------------------------------------------------------------------- 1 | Fs = require "fs" 2 | Auth = require "connect-auth" 3 | Path = require "path" 4 | User = require "user" 5 | Express = require "express" 6 | 7 | app = Express.createServer() 8 | 9 | Config = 10 | scope: "email,offline_access" 11 | appId: process.env.GITHUB_CLIENT_ID 12 | appSecret: process.env.GITHUB_CLIENT_SECRET 13 | callback: process.env.GITHUB_CALLBACK 14 | sessionSecret: process.env.GITHUB_SESSION_SECRET || "unknown" 15 | apiPassword: process.env.JINKIES_API_PASSWORD || "password" 16 | organization: process.env.JINKIES_ORGANIZATION || "github" 17 | 18 | apiUserPasswordFunction = (username, password, successCallback, failureCallback) -> 19 | if username == "api" && password == Config.apiPassword 20 | successCallback() 21 | else 22 | failureCallback() 23 | 24 | app.configure -> 25 | app.set "root", Path.join(__filename, "..", "..") 26 | app.set "view engine", "ejs" 27 | app.set "views", Path.join(__filename, "..", "..", "views") 28 | app.use Express.logger() 29 | app.use Express.methodOverride() 30 | app.use Express.errorHandler({ showStack: true, dumpExceptions: true }) 31 | app.use Express.static Path.join(__filename, "..", "..", "public") 32 | app.use Express.cookieParser() 33 | app.use Express.session { secret: Config.sessionSecret, lifetime: 150000, reapInterval: 10000 } 34 | app.use(Auth([ Auth.Anonymous(), Auth.Basic({validatePassword: apiUserPasswordFunction}), Auth.Github(Config) ])) 35 | app.use Express.bodyParser() 36 | 37 | # Oauth related Callbacks 38 | app.get "/auth/github/callback", (req, res) -> 39 | req.authenticate ["github"], (err, success) -> 40 | if success 41 | user = new User.User req.getAuthDetails().user 42 | user.memberOf Config.organization, (err, isMember) -> 43 | if isMember 44 | res.redirect "/jobs" 45 | else 46 | res.redirect "/auth/failure" 47 | else 48 | res.redirect "/auth/failure" 49 | 50 | app.get "/auth/failure", (req, res) -> 51 | res.render "failure", { title: "Unable to Authenticate, bummer." } 52 | 53 | app.get "/auth/login", (req, res) -> 54 | res.render "login", { title: "Login to our Janky CI Server with GitHub!" } 55 | 56 | app.get "/auth/logout", (req, res) -> 57 | req.logout() 58 | res.redirect("/jobs", 303) 59 | 60 | # GitHub post-receives land here 61 | app.post "/", (req, res) -> 62 | info = JSON.parse(req.body.payload) 63 | owner = info.repository.owner.name 64 | branch = info["ref"].split("/")[2] 65 | 66 | project = "#{owner}-#{info.repository.name}" 67 | 68 | if info.deleted or (info.created and info.commits.length == 0) 69 | # ignore branch deletions, and new branches with no commits 70 | else 71 | job = app.jinkies.job_for project 72 | job.triggerBuild branch, req.body.payload, (err, data) -> 73 | res.send data, {"Content-Type": "application/json"}, 200 74 | 75 | # Everything else requires Basic-Auth or Oauth 76 | app.all "*", (req, res, next) -> 77 | if req.headers.accept == "application/json" 78 | if req.socket.remoteAddress == '127.0.0.1' 79 | next() 80 | else 81 | req.authenticate ["basic"], (err, success) -> 82 | if success 83 | next() 84 | else 85 | res.send "Unauthorized", 401 86 | else 87 | if req.isAuthenticated() 88 | next() 89 | else 90 | res.redirect "/auth/login" 91 | 92 | # API for jobs info 93 | app.get "/jobs", (req, res) -> 94 | app.jinkies.jobs_info (err, jobs) -> 95 | if req.headers.accept == "application/json" 96 | res.send jobs, {"Content-Type": "application/json"}, 200 97 | else 98 | res.render "index", { title: "Janky CI Server", jobs: jobs } 99 | 100 | app.get "/jobs/:job", (req, res) -> 101 | job = app.jinkies.job_for req.params.job 102 | job.info (err, info) -> 103 | res.send info, {"Content-Type": "application/json"}, 200 104 | 105 | app.post "/jobs/:job/builds", (req, res) -> 106 | job = app.jinkies.job_for req.params.job 107 | branch_name = req.body.branch || "master" 108 | job.triggerBuild branch_name, "{}", (err, data) -> 109 | res.send data, {"Content-Type": "application/json"}, 200 110 | 111 | app.get "/jobs/:job/builds/:build", (req, res) -> 112 | job = app.jinkies.job_for req.params.job 113 | job.build_for req.params.build, (err, build) -> 114 | res.send build, {"Content-Type": "application/json"}, 200 115 | 116 | app.get "/jobs/:job/builds/:build/console", (req, res) -> 117 | name = req.params.job 118 | number = req.params.build 119 | job = app.jinkies.job_for name 120 | job.build_for number, (err, build) -> 121 | title = "Build output for #{name} # #{number}" 122 | res.render "console", { title: title, output: build.consoleText } 123 | 124 | app.get "/jobs/:job/branches/:branch", (req, res) -> 125 | job = app.jinkies.job_for req.params.job 126 | job.branches_for req.params.branch, (err, branches) -> 127 | res.send branches, {"Content-Type": "application/json"}, 200 128 | 129 | exports.App = app 130 | -------------------------------------------------------------------------------- /public/css/base.css: -------------------------------------------------------------------------------- 1 | /*------------------------------------------------------------------------------------ 2 | @group Global Reset 3 | ------------------------------------------------------------------------------------*/ 4 | * { 5 | padding:0; 6 | margin:0; 7 | } 8 | h1, h2, h3, h4, h5, h6, p, pre, blockquote, label, ul, ol, dl, fieldset, address { margin:1em 0; } 9 | li, dd { margin-left:5%; } 10 | fieldset { padding: .5em; } 11 | select option{ padding:0 5px; } 12 | 13 | .access{ display:none; } /* For accessibility related elements */ 14 | .clear{ clear:both; height:0px; font-size:0px; line-height:0px; overflow:hidden; } 15 | a{ outline:none; } 16 | a img{ border:none; } 17 | 18 | .clearfix:after { 19 | content: "."; 20 | display: block; 21 | height: 0; 22 | clear: both; 23 | visibility: hidden; 24 | } 25 | * html .clearfix {height: 1%;} 26 | .clearfix {display:inline-block;} 27 | .clearfix {display: block;} 28 | 29 | /* @end */ 30 | 31 | /*---------------------------------------------------------------------------- 32 | @group Base Layout 33 | ----------------------------------------------------------------------------*/ 34 | 35 | body{ 36 | margin:0; 37 | padding:0; 38 | font-size:14px; 39 | line-height:1.6; 40 | font-family:Helvetica, Arial, sans-serif; 41 | background:#fff; 42 | } 43 | 44 | #wrapper{ 45 | margin:0 auto; 46 | width:600px; 47 | } 48 | .wide #wrapper{ 49 | width:1000px; 50 | } 51 | 52 | h2#logo{ 53 | width:600px; 54 | margin:0 auto 25px auto; 55 | } 56 | h2#logo a{ 57 | display:block; 58 | height:156px; 59 | text-indent:-9999px; 60 | text-decoration:none; 61 | background:url(../images/logo.png); 62 | } 63 | 64 | .content{ 65 | padding:5px; 66 | background:#ededed; 67 | border-radius:4px; 68 | } 69 | .content > .inside{ 70 | border:1px solid #ddd; 71 | background:#fff; 72 | border-radius:3px; 73 | } 74 | 75 | /* @end */ 76 | 77 | /*---------------------------------------------------------------------------- 78 | @group Builds 79 | ----------------------------------------------------------------------------*/ 80 | 81 | ul.builds{ 82 | margin:0; 83 | } 84 | 85 | ul.builds li{ 86 | list-style-type:none; 87 | margin:0; 88 | padding:12px 10px; 89 | border-bottom:1px solid #e5e5e5; 90 | border-top:1px solid #fff; 91 | background:-webkit-gradient(linear, left top, left bottom, from(#fdfdfd), to(#f2f2f2)); 92 | background:-moz-linear-gradient(top, #fdfdfd, #f2f2f2); 93 | } 94 | ul.builds li:first-child{ 95 | border-top:none; 96 | border-top-left-radius: 3px; 97 | border-top-right-radius: 3px; 98 | } 99 | ul.builds li:last-child{ 100 | border-bottom:none; 101 | border-bottom-left-radius: 3px; 102 | border-bottom-right-radius: 3px; 103 | } 104 | ul.builds li:hover{ 105 | background:-webkit-gradient(linear, left top, left bottom, from(#f5f9fb), to(#e9eef0)); 106 | background:-moz-linear-gradient(top, #f5f9fb, #e9eef0); 107 | } 108 | ul.builds li.building:hover{ 109 | background:-webkit-gradient(linear, left top, left bottom, from(#fdfdfd), to(#f2f2f2)); 110 | background:-moz-linear-gradient(top, #fdfdfd, #f2f2f2); 111 | } 112 | 113 | ul.builds a{ 114 | display:block; 115 | text-decoration:none; 116 | background:url(../images/disclosure-arrow.png) 100% 10px no-repeat; 117 | } 118 | ul.builds li:hover a{ 119 | background-position:100% -90px; 120 | } 121 | ul.builds .building a{ 122 | background:none; 123 | cursor:default; 124 | } 125 | 126 | ul.builds .status{ 127 | float:left; 128 | margin-top:5px; 129 | margin-right:10px; 130 | width:37px; 131 | height:34px; 132 | background:url(../images/robawt-status.gif) 0 0 no-repeat; 133 | } 134 | ul.builds .building .status{ 135 | background:url(../images/building-bot.gif); 136 | } 137 | ul.builds .janky .status{ 138 | background-position:0 -200px; 139 | } 140 | 141 | ul.builds h2{ 142 | margin:0; 143 | font-size:16px; 144 | text-shadow:0 1px #fff; 145 | } 146 | ul.builds .good a h2{ 147 | color:#358c00; 148 | } 149 | ul.builds .building a h2{ 150 | color:#e59741; 151 | } 152 | ul.builds .janky a h2{ 153 | color:#ae0000; 154 | } 155 | 156 | ul.builds p{ 157 | margin:-2px 0 0 0; 158 | font-size:13px; 159 | font-weight:200; 160 | color:#666; 161 | text-shadow:0 1px #fff; 162 | } 163 | ul.builds .building p{ 164 | color:#999; 165 | } 166 | 167 | /* @end */ 168 | 169 | /*---------------------------------------------------------------------------- 170 | @group Text Styles 171 | ----------------------------------------------------------------------------*/ 172 | 173 | pre{ 174 | margin:10px; 175 | font-size:12px; 176 | overflow:auto; 177 | } 178 | pre::-webkit-scrollbar { 179 | height: 8px; 180 | width: 8px; 181 | } 182 | pre::-webkit-scrollbar-track-piece{ 183 | margin-bottom:10px; 184 | background-color: #e5e5e5; 185 | border-bottom-left-radius: 4px 4px; 186 | border-bottom-right-radius: 4px 4px; 187 | border-top-left-radius: 4px 4px; 188 | border-top-right-radius: 4px 4px; 189 | } 190 | 191 | pre::-webkit-scrollbar-thumb:vertical{ 192 | height: 25px; 193 | background-color: #ccc; 194 | -webkit-border-radius: 4px; 195 | -webkit-box-shadow: 0 1px 1px rgba(255,255,255,1); 196 | } 197 | pre::-webkit-scrollbar-thumb:horizontal{ 198 | width: 25px; 199 | background-color: #ccc; 200 | -webkit-border-radius: 4px; 201 | } 202 | 203 | /* @end */ --------------------------------------------------------------------------------