├── .gitignore ├── LICENSE ├── Procfile ├── README.md ├── app.js ├── app.json ├── bin └── www ├── debug.sh ├── package.json ├── public └── stylesheets │ └── style.css ├── routes ├── badge.js ├── index.js ├── redis.js ├── v1.js └── xcs_deeplink.js └── views ├── error.jade ├── index.jade └── layout.jade /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | 29 | .env 30 | Dockerfile 31 | docker-compose.yml 32 | 33 | .idea -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Honza Dvorsky 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: node bin/www -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # satellite 2 | 3 | [![License](https://img.shields.io/badge/license-MIT-blue.svg)](http://en.wikipedia.org/wiki/MIT_License) 4 | [![Blog](https://img.shields.io/badge/blog-honzadvorsky.com-green.svg)](http://honzadvorsky.com) 5 | [![Twitter Czechboy0](https://img.shields.io/badge/twitter-czechboy0-green.svg)](http://twitter.com/czechboy0) 6 | 7 | > Node.js server for providing build status badges for GitHub branches. 8 | 9 | Example badges: 10 | ![example badge](http://img.shields.io/badge/build-passing-brightgreen.svg) 11 | ![example badge](http://img.shields.io/badge/build-failing-red.svg) 12 | ![example badge](http://img.shields.io/badge/build-unknown-lightgray.svg) 13 | 14 | Used by [Buildasaur](https://github.com/czechboy0/Buildasaur), [XcodeServerSDK](https://github.com/czechboy0/XcodeServerSDK) and other projects backed by Xcode Server to show a build status badge like the ones coming from hosted CI services. Since Xcode Server is self-hosted, if you use [Buildasaur](https://github.com/czechboy0/Buildasaur) to sync your build results from Xcode Server to GitHub, you can take advantage of Satellite to get a badge of your own. 15 | 16 | - Runs on [`https://stlt.herokuapp.com/`](https://stlt.herokuapp.com/) 17 | 18 | # :nut_and_bolt: usage 19 | Just add a badge like this to your README and **satellite** will return the right badge - by going to GitHub and checking the [GitHub status](https://developer.github.com/v3/repos/statuses/) (you know, those little green tickmarks and Pull Request statuses) - and returning the appropriate badge. 20 | 21 | Add this to your README: 22 | ``` 23 | [![satellite badge](https://stlt.herokuapp.com/v1/badge/USER/REPO/BRANCH)](https://github.com/USER/REPO/branches) 24 | ``` 25 | If you don't specify a branch, *master* will be used. 26 | 27 | # API 28 | 29 | ### `/v1/badge/:USER/:REPO/:BRANCH?` 30 | And since the service is running on `https://stlt.herokuapp.com`, an example call would be 31 | 32 | ``` 33 | HTTP GET https://stlt.herokuapp.com/v1/badge/czechboy0/xcodeserversdk/swift-2 34 | ``` 35 | which returns an SVG image of the badge. 36 | 37 | ### `/v1/xcs_deeplink/:HOSTNAME/:BOT_ID/:INTEGRATION_ID` 38 | Returns 301 redirect to `xcbot://HOSTNAME/botID/BOT_ID/integrationID/INTEGRATION_ID` so that you can link to your Bots from e.g. GitHub, which blocks all non-https URL schemes. 39 | 40 | # details 41 | The service uses [Redis](http://redis.io) for caching of the status (1 minute) and of the badge image data (1 hour). 42 | 43 | # :white_check_mark: installation 44 | There is an instance running at `https://stlt.herokuapp.com`, but you can also run your own if you want. 45 | Clone the repo and run `npm install` and then start the server locally with `./bin/www`. 46 | Or, deploy it to your heroku account: [![Deploy](https://www.herokucdn.com/deploy/button.png)](https://heroku.com/deploy) 47 | 48 | # :gift_heart: Contributing 49 | Please create an issue with a description of a problem or a pull request with a fix. 50 | 51 | # :v: License 52 | MIT 53 | 54 | # :alien: Author 55 | Honza Dvorsky - http://honzadvorsky.com, [@czechboy0](http://twitter.com/czechboy0) 56 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var path = require('path'); 3 | var favicon = require('serve-favicon'); 4 | var logger = require('morgan'); 5 | var cookieParser = require('cookie-parser'); 6 | var bodyParser = require('body-parser'); 7 | 8 | var routes = require('./routes/index'); 9 | var v1 = require('./routes/v1'); 10 | 11 | var app = express(); 12 | 13 | // view engine setup 14 | app.set('views', path.join(__dirname, 'views')); 15 | app.set('view engine', 'jade'); 16 | 17 | // uncomment after placing your favicon in /public 18 | //app.use(favicon(path.join(__dirname, 'public', 'favicon.ico'))); 19 | app.use(logger('dev')); 20 | app.use(bodyParser.json()); 21 | app.use(bodyParser.urlencoded({ extended: false })); 22 | app.use(cookieParser()); 23 | app.use(express.static(path.join(__dirname, 'public'))); 24 | 25 | app.use('/', routes); 26 | app.use('/v1', v1); 27 | 28 | // catch 404 and forward to error handler 29 | app.use(function(req, res, next) { 30 | var err = new Error('Not Found'); 31 | err.status = 404; 32 | next(err); 33 | }); 34 | 35 | // error handlers 36 | 37 | // development error handler 38 | // will print stacktrace 39 | if (app.get('env') === 'development') { 40 | app.use(function(err, req, res, next) { 41 | res.status(err.status || 500); 42 | res.render('error', { 43 | message: err.message, 44 | error: err 45 | }); 46 | }); 47 | } 48 | 49 | // production error handler 50 | // no stacktraces leaked to user 51 | app.use(function(err, req, res, next) { 52 | res.status(err.status || 500); 53 | res.render('error', { 54 | message: err.message, 55 | error: {} 56 | }); 57 | }); 58 | 59 | 60 | module.exports = app; 61 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "satellite", 3 | "description": "Node.js server for providing build status badges for GitHub branches.", 4 | "repository": "https://github.com/czechboy0/satellite" 5 | } -------------------------------------------------------------------------------- /bin/www: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Module dependencies. 5 | */ 6 | 7 | var app = require('../app'); 8 | var debug = require('debug')('satellite:server'); 9 | var http = require('http'); 10 | 11 | /** 12 | * Get port from environment and store in Express. 13 | */ 14 | 15 | var port = normalizePort(process.env.PORT || '3000'); 16 | app.set('port', port); 17 | 18 | /** 19 | * Create HTTP server. 20 | */ 21 | 22 | var server = http.createServer(app); 23 | 24 | /** 25 | * Listen on provided port, on all network interfaces. 26 | */ 27 | 28 | server.listen(port); 29 | server.on('error', onError); 30 | server.on('listening', onListening); 31 | 32 | /** 33 | * Normalize a port into a number, string, or false. 34 | */ 35 | 36 | function normalizePort(val) { 37 | var port = parseInt(val, 10); 38 | 39 | if (isNaN(port)) { 40 | // named pipe 41 | return val; 42 | } 43 | 44 | if (port >= 0) { 45 | // port number 46 | return port; 47 | } 48 | 49 | return false; 50 | } 51 | 52 | /** 53 | * Event listener for HTTP server "error" event. 54 | */ 55 | 56 | function onError(error) { 57 | if (error.syscall !== 'listen') { 58 | throw error; 59 | } 60 | 61 | var bind = typeof port === 'string' 62 | ? 'Pipe ' + port 63 | : 'Port ' + port; 64 | 65 | // handle specific listen errors with friendly messages 66 | switch (error.code) { 67 | case 'EACCES': 68 | console.error(bind + ' requires elevated privileges'); 69 | process.exit(1); 70 | break; 71 | case 'EADDRINUSE': 72 | console.error(bind + ' is already in use'); 73 | process.exit(1); 74 | break; 75 | default: 76 | throw error; 77 | } 78 | } 79 | 80 | /** 81 | * Event listener for HTTP server "listening" event. 82 | */ 83 | 84 | function onListening() { 85 | var addr = server.address(); 86 | var bind = typeof addr === 'string' 87 | ? 'pipe ' + addr 88 | : 'port ' + addr.port; 89 | debug('Listening on ' + bind); 90 | } 91 | -------------------------------------------------------------------------------- /debug.sh: -------------------------------------------------------------------------------- 1 | DEBUG=satellite:* npm start 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "satellite", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "node ./bin/www" 7 | }, 8 | "dependencies": { 9 | "body-parser": "~1.13.2", 10 | "cookie-parser": "~1.3.5", 11 | "debug": "~2.2.0", 12 | "express": "~4.13.1", 13 | "jade": "~1.11.0", 14 | "morgan": "~1.6.1", 15 | "serve-favicon": "~2.3.0", 16 | "redis": "1.0.0" 17 | }, 18 | "engines": { 19 | "node": "5.0.0" 20 | } 21 | } -------------------------------------------------------------------------------- /public/stylesheets/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 50px; 3 | font: 14px "Lucida Grande", Helvetica, Arial, sans-serif; 4 | } 5 | 6 | a { 7 | color: #00B7FF; 8 | } 9 | -------------------------------------------------------------------------------- /routes/badge.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by honzadvorsky on 03/09/15. 3 | */ 4 | 5 | var express = require('express'); 6 | var router = express.Router(); 7 | var https = require('https'); 8 | var db = require('./redis'); 9 | 10 | router.get('/:user/:repo/:branch?', function(req, res, next) { 11 | var params = req.params; 12 | var user = params.user; 13 | var repo = params.repo; 14 | var branch = params.branch || "master"; 15 | getStatus(user, repo, branch, function(err, data, contentType) { 16 | 17 | if (err) { 18 | res.status(err['status'] || 500).send(err); 19 | } else { 20 | res.header('Content-Type', contentType).send(data); 21 | } 22 | }); 23 | }); 24 | 25 | function getStatus(user, repo, branch, completion) { 26 | 27 | fetchUrlFromRedis(user, repo, branch, function(err, url) { 28 | if (err) { 29 | completion(err, null, null); 30 | } else { 31 | getBadgeData(url, completion); 32 | } 33 | }); 34 | } 35 | 36 | function fetchUrlFromRedis(user, repo, branch, completion) { 37 | 38 | var timeout = 60; 39 | var key = ["badges", user, repo, branch].join(":"); 40 | var badge_url = null; 41 | 42 | db().get(key, function(err, reply) { 43 | if (err) { 44 | completion(err, null); 45 | } else { 46 | if (reply) { 47 | completion(null, reply); 48 | } else { 49 | //we don't have the url yet 50 | fetchBadgeUrl(user, repo, branch, function(err, badgeUrl) { 51 | if (err) { 52 | completion(err, null); 53 | } else { 54 | //we have the badge, save it and complete 55 | db().setex(key, timeout, badgeUrl, function(err) { 56 | if (err) { 57 | completion(err, null); 58 | } else { 59 | completion(null, badgeUrl); 60 | } 61 | }); 62 | } 63 | }); 64 | } 65 | } 66 | }); 67 | } 68 | 69 | 70 | //completion: err, data, content-type 71 | function getBadgeData(badgeUrl, completion) { 72 | 73 | var timeout = 3600; 74 | var key = ["badge_data", badgeUrl].join(":"); 75 | db().hgetall(key, function(err, reply) { 76 | if (err) { 77 | completion(err, null, null); 78 | } else if (reply && reply.data && reply.data.length > 0) { 79 | completion(null, reply.data, reply.content_type); 80 | } else { 81 | //we don't have the badge data - fetch it again 82 | fetchBadgeData(badgeUrl, function(err, data, contentType) { 83 | if (err) { 84 | completion(err, null, null); 85 | } else { 86 | //save the badge data into redis 87 | db().hmset(key, "data", data, "content_type", contentType, function(err) { 88 | if (err) { 89 | completion(err, null, null); 90 | } else { 91 | //also expire in an hour 92 | db().expire(key, timeout, function(err) { 93 | if (err) { 94 | completion(err, null, null); 95 | } else { 96 | completion(null, data, contentType); 97 | } 98 | }) 99 | } 100 | }); 101 | } 102 | }); 103 | } 104 | }); 105 | } 106 | 107 | //completion: err, data, content-type 108 | function fetchBadgeData(badgeUrl, completion) { 109 | console.log('Refetching badge data ' + badgeUrl); 110 | 111 | var path = "/badge/" + badgeUrl; 112 | var options = { 113 | hostname: 'img.shields.io', 114 | port: 443, 115 | path: path, 116 | method: 'GET', 117 | headers: { 118 | 'User-Agent': 'satellite' 119 | } 120 | }; 121 | 122 | https.get(options, function(response) { 123 | 124 | var contentType = response.headers['content-type']; 125 | var data = ''; 126 | response.on('error', function(err) { 127 | console.error(err); 128 | completion(err, null); 129 | }).on('data', function(d) { 130 | data += d; 131 | }).on('end', function() { 132 | var svg = data; 133 | completion(null, svg, contentType); 134 | }); 135 | }); 136 | } 137 | 138 | function fetchBadgeUrl(user, repo, branch, completion) { 139 | 140 | //TODO: authenticated requests? private repos? better rate limit? 141 | console.log('Refetching status for ' + user + "/" + repo + "/" + branch); 142 | 143 | var path = "/repos/" + user + "/" + repo + "/commits/" + branch + "/status"; 144 | var options = { 145 | hostname: 'api.github.com', 146 | port: 443, 147 | path: path, 148 | method: 'GET', 149 | headers: { 150 | 'User-Agent': 'satellite' 151 | } 152 | }; 153 | 154 | https.get(options, function(response) { 155 | 156 | var data = ''; 157 | response.on('error', function(err) { 158 | console.error(err); 159 | completion(err, null); 160 | 161 | }).on('data', function(d) { 162 | 163 | data += d; 164 | }).on('end', function() { 165 | 166 | //find the rate limit 167 | rateLimit(response.headers); 168 | 169 | var status = response.statusCode; 170 | if (status >= 200 && status < 300) { 171 | //we have data 172 | var parsed = JSON.parse(data); 173 | var state = parsed['state']; 174 | var url = urlFromState(state); 175 | completion(null, url); 176 | } else { 177 | var err = { 178 | status: status, 179 | message: response.statusMessage 180 | }; 181 | completion(err, null); 182 | } 183 | }); 184 | }); 185 | } 186 | 187 | function rateLimit(headers) { 188 | var remaining = headers['x-ratelimit-remaining']; 189 | var limit = headers['x-ratelimit-limit']; 190 | var reset = headers['x-ratelimit-reset'] * 1000; //seconds -> millis 191 | var now = Date.now(); //millis 192 | var timeRemaining = (reset - now) / 1000; //back to seconds 193 | var minutesRemaining = Math.floor(timeRemaining / 60); 194 | console.log("Rate limit: [remaining: " + remaining + ", limit: " + limit + ", resets in " + minutesRemaining + " minutes]"); 195 | } 196 | 197 | function urlFromState(state) { 198 | 199 | var imgPath = ''; 200 | if (state === 'success') { 201 | imgPath = imgPath + 'build-passing-brightgreen.svg'; 202 | } else if (state === 'failure') { 203 | imgPath = imgPath + 'build-failing-red.svg'; 204 | } else { 205 | imgPath = imgPath + 'build-unknown-lightgray.svg'; 206 | } 207 | return imgPath; 208 | } 209 | 210 | module.exports = router; 211 | 212 | 213 | //function getStatus(req, res, next) { 214 | // 215 | // //see if we have it in Redis already, with TTL of 90 seconds 216 | // let db = P.DB.shared(); 217 | // db.getBuildasaurStatusBadgeUrl((err, badgeUrl) => { 218 | // 219 | // if (badgeUrl) { 220 | // //had it in cache, just return 221 | // replyWithBadgeUrl(badgeUrl, res); 222 | // } else { 223 | // 224 | // //we must refetch 225 | // refetchBadgeUrl((err, badgeUrl) => { 226 | // if (err) { 227 | // replyWithBadgeUrl(null, res); 228 | // } else { 229 | // //cache now that we have it again 230 | // db.setBuildasaurStatusBadgeUrl(badgeUrl, 90, (err) => { 231 | // if (err) { 232 | // replyWithBadgeUrl(null, res); 233 | // } else { 234 | // replyWithBadgeUrl(badgeUrl, res); 235 | // } 236 | // }) 237 | // } 238 | //}) 239 | //} 240 | //}); 241 | //} -------------------------------------------------------------------------------- /routes/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by honzadvorsky on 03/09/15. 3 | */ 4 | 5 | var express = require('express'); 6 | var router = express.Router(); 7 | 8 | /* GET home page. */ 9 | router.get('/', function(req, res, next) { 10 | res.render('index', { title: 'satellite' }); 11 | }); 12 | 13 | module.exports = router; 14 | -------------------------------------------------------------------------------- /routes/redis.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by honzadvorsky on 03/09/15. 3 | */ 4 | 5 | var redis = require('redis'); 6 | var url = require('url'); 7 | var _client = null; 8 | 9 | module.exports = function() { 10 | if (!_client) { 11 | var redisURL = url.parse(process.env.REDIS_URL); 12 | var client = redis.createClient(redisURL.port, redisURL.hostname); 13 | client.auth(redisURL.auth.split(":")[1]); 14 | _client = client; 15 | } 16 | return _client; 17 | }; 18 | -------------------------------------------------------------------------------- /routes/v1.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by honzadvorsky on 03/09/15. 3 | */ 4 | 5 | var express = require('express'); 6 | var router = express.Router(); 7 | 8 | var badge = require('./badge'); 9 | var xcs_deeplink = require('./xcs_deeplink'); 10 | 11 | router.use('/badge', badge); 12 | router.use('/xcs_deeplink', xcs_deeplink); 13 | 14 | module.exports = router; 15 | -------------------------------------------------------------------------------- /routes/xcs_deeplink.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by honzadvorsky on 10/14/15. 3 | */ 4 | 5 | var express = require('express'); 6 | var router = express.Router(); 7 | 8 | router.get('/:hostname/:botId/:integrationId', function(req, res, next) { 9 | var params = req.params; 10 | var hostname = params.hostname; 11 | var botId = params.botId; 12 | var integrationId = params.integrationId; 13 | 14 | if (!hostname) { res.status(400).send("No hostname provided"); return } 15 | if (!botId) { res.status(400).send("No botId provided"); return } 16 | if (!integrationId) { res.status(400).send("No integrationId provided"); return } 17 | 18 | var link = getDeepLinkUrlFor(hostname, botId, integrationId); 19 | res.status(301).header('Location', link).send('Redirecting to ' + link); 20 | }); 21 | 22 | function getDeepLinkUrlFor(hostname, botId, integrationId) { 23 | var link = "xcbot://" + hostname + "/botID/" + botId + "/integrationID/" + integrationId; 24 | return link; 25 | } 26 | 27 | 28 | module.exports = router; 29 | 30 | -------------------------------------------------------------------------------- /views/error.jade: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | h1= message 5 | h2= error.status 6 | pre #{error.stack} 7 | -------------------------------------------------------------------------------- /views/index.jade: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | a(href="https://github.com/czechboy0/satellite") 5 | h1= title 6 | p What #[a(href="https://github.com/czechboy0/buildasaur") Buildasaur] uses for its build status badge. 7 | -------------------------------------------------------------------------------- /views/layout.jade: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | title= title 5 | link(rel='stylesheet', href='/stylesheets/style.css') 6 | body 7 | block content 8 | --------------------------------------------------------------------------------