├── .env ├── .gitignore ├── License.md ├── Readme.md ├── assets ├── favicon.png └── leeroy-black.png ├── changelog.md ├── contributors.md ├── database ├── command.go ├── command_test.go ├── config.go ├── config_test.go ├── db.go ├── db_test.go ├── job.go ├── job_test.go ├── mailserver.go ├── mailserver_test.go ├── notification.go ├── notification_test.go ├── repository.go ├── repository_test.go ├── user.go └── user_test.go ├── docs ├── buildscripts.md ├── configuration.md ├── deploymentscripts.md └── success.png ├── github ├── api.go ├── api_test.go ├── handle.go ├── pr.go ├── pr_test.go ├── push.go ├── push_test.go ├── request.go └── request_test.go ├── leeroy.go ├── leeroy_test.go ├── notification ├── campfire.go ├── campfire_test.go ├── email.go ├── email_test.go ├── hipchat.go ├── hipchat_test.go ├── messages │ ├── campfire-text-build.tmpl │ ├── campfire-text-deploy-end.tmpl │ ├── campfire-text-deploy-start.tmpl │ ├── campfire-text-test.tmpl │ ├── email-html-build.tmpl │ ├── email-html-deploy-end.tmpl │ ├── email-html-deploy-start.tmpl │ ├── email-html-job.tmpl │ ├── email-html-test.tmpl │ ├── email-text-build.tmpl │ ├── email-text-deploy-end.tmpl │ ├── email-text-deploy-start.tmpl │ ├── email-text-test.tmpl │ ├── hipchat-text-build.tmpl │ ├── hipchat-text-deploy-end.tmpl │ ├── hipchat-text-deploy-start.tmpl │ ├── hipchat-text-test.tmpl │ ├── slack-text-build.tmpl │ ├── slack-text-deploy-end.tmpl │ ├── slack-text-deploy-start.tmpl │ └── slack-text-test.tmpl ├── notify.go ├── notify_test.go ├── slack.go ├── slack_test.go ├── template.go ├── template_test.go └── websocket.go ├── runner ├── manager.go ├── manager_test.go ├── runner.go └── runner_test.go ├── web ├── callback.go ├── command_admin.go ├── config.go ├── context.go ├── job.go ├── login.go ├── logout.go ├── mailserver_admin.go ├── middleware_accesskey.go ├── middleware_admin.go ├── middleware_auth.go ├── middleware_logging.go ├── middleware_noconfig.go ├── middleware_panic.go ├── notification_admin.go ├── repository_admin.go ├── router.go ├── setup.go ├── static │ ├── build-images │ │ ├── buildsuccessful.svg │ │ ├── failed.svg │ │ └── nobuild.svg │ ├── css │ │ ├── base.css │ │ ├── bootstrap-theme.css │ │ ├── bootstrap-theme.css.map │ │ ├── bootstrap-theme.min.css │ │ ├── bootstrap.css │ │ ├── bootstrap.css.map │ │ ├── bootstrap.min.css │ │ ├── jobs.css │ │ ├── login.css │ │ └── setup.css │ ├── favicon.png │ ├── fonts │ │ ├── glyphicons-halflings-regular.eot │ │ ├── glyphicons-halflings-regular.svg │ │ ├── glyphicons-halflings-regular.ttf │ │ ├── glyphicons-halflings-regular.woff │ │ └── glyphicons-halflings-regular.woff2 │ ├── js │ │ ├── bootstrap.js │ │ ├── bootstrap.min.js │ │ ├── npm.js │ │ └── setup.js │ └── leeroy.png ├── template.go ├── template_test.go ├── templates │ ├── 403.html │ ├── base.html │ ├── command │ │ └── admin │ │ │ ├── create.html │ │ │ └── update.html │ ├── config │ │ └── admin │ │ │ └── update.html │ ├── job │ │ ├── detail.html │ │ └── list.html │ ├── login.html │ ├── mailserver │ │ └── admin │ │ │ └── update.html │ ├── notification │ │ └── admin │ │ │ ├── create.html │ │ │ └── update.html │ ├── repository │ │ └── admin │ │ │ ├── create.html │ │ │ ├── list.html │ │ │ └── update.html │ ├── setup.html │ └── user │ │ ├── admin │ │ ├── create.html │ │ ├── list.html │ │ └── update.html │ │ └── settings.html ├── user.go ├── user_admin.go ├── utils.go └── utils_test.go └── websocket ├── client.go ├── client └── receiver.go ├── client_test.go ├── message.go ├── message_test.go ├── server.go └── server_test.go /.env: -------------------------------------------------------------------------------- 1 | export DATABASE_URL="sqlite3 /Users/timo/tmp/leeory.sqlite3" 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode 3 | /.idea 4 | -------------------------------------------------------------------------------- /License.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Timo Zimmermann 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Leeroy CI 2 | Leeroy is a self hosted, continuous integration, build and deployment service. It is designed to be easy to setup and will not require an additional ops person to keep running. It runs on your own server, so you can create the test environment you want, exactly mirroring production, without having to trust anyone else to keep your source code or eventually database images with sensitive information safe. 3 | 4 | ![Leeroy](https://raw.github.com/fallenhitokiri/leeroyci/master/assets/leeroy-black.png) 5 | 6 | ## Integrations 7 | Currently Leeroy plays nicely with GitHub. An integration for GitLab is current WIP, BitBucket should be supported before the first release. 8 | 9 | ## Features 10 | - self hosted 11 | - bring your own build / test scripts 12 | - comment on GitHub pull requests 13 | - close GitHub pull requests if the build for HEAD fails 14 | - send notifications about the build via email 15 | - post results to a Slack, Campfire or HipChat channel 16 | - see all builds on an acceptable designed - read bootstrap - webinterface 17 | - continuous deployment using your own deployment scripts - deploy to whichever environment you want. 18 | - search for branches, commits and repositories 19 | 20 | ## Quickstart 21 | For now please check out the master branch of this repository and run it via `go run leeroy.go`. Binaries will be available with the first stable release. 22 | Master can be considered production ready. Development is used by people who want to have the latest features and can accept if smaller problems show up, but the goal is to keep it stable, too. 23 | 24 | ### Build Script 25 | Before you start make sure you have a script that is able to run tests for your repository. Two arguments are passed to your build script, the repository URL (first argument) and the branch name (second argument) to which was pushed. Let us use a really simple one for now 26 | 27 | #! /bin/bash 28 | ls 29 | 30 | We assume this script is saved in `/home/ec2-user/test.sh`. See `docs/buildscripts.md` for more information and sample scripts. 31 | 32 | ### Configuration 33 | To set the path for the SQLite database you can use the environment variable `DATABASE_URL`. The format is `sqlite3 /path/to/leeory.sqlite3`. 34 | 35 | Once Leeroy is running go to port `8082` in your web browser and click through the setup assistant. The user you create will automatically be an administrator. If you add an SSL certificate you have to restart Leeroy after completing the setup. 36 | 37 | To configure a repository click on `Admin -> Repository Management -> Add Repository`. After adding the repository you can add commands and notifications on the repository detail page you are redirected to. The access key needs permissions to update the status of your commits, comment on PRs and close them if you want to use that feature. 38 | 39 | For a command you can select a kind, name, branch and script to run. If a branch is specified the command will only run when you push to the specific branch. For the script please specify the full path. 40 | 41 | The order to run commands is 42 | 43 | 1. tests 44 | 2. builds 45 | 3. deploy 46 | 47 | The script runner exists on the first failed step, so if tests fail builds and deploys will never run. 48 | 49 | On GitHub you have to setup a webhook pointing to `http://yourhost:8082/callback/github/` - or `https://…` if you configured SSL and make sure it sends "push" and "pull" events. 50 | 51 | ## Planned Features 52 | While Leeroy is working and doing its job it is far from being feature complete. Before version 1.0 will be released the following features will be finished 53 | 54 | - GitLab integration 55 | - Bitbucket integration 56 | - support for custom templates and notifications 57 | - website with a browsable documentation and more default snippets 58 | 59 | ## Contributing 60 | Feel free to open issues about bugs or features you want to see or open pull requests. Beside using `go fmt` and `go vet` on your code please try to keep the code length around 80 characters. This is no hard limit. If a line is 86 characters long but easy to read and understand there is no need to break it into multiple lines. 61 | 62 | ## License 63 | Leeroy is released under the MIT license. 64 | -------------------------------------------------------------------------------- /assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fallenhitokiri/leeroyci/cd8a5fb95f63d53385d803439b0b48de70105119/assets/favicon.png -------------------------------------------------------------------------------- /assets/leeroy-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fallenhitokiri/leeroyci/cd8a5fb95f63d53385d803439b0b48de70105119/assets/leeroy-black.png -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | 2015-10-18 Timo Zimmermann 2 | 3 | * websocket support 4 | * setup and configure LeeroyCI through the webinterface 5 | * improve test coverage 6 | 7 | 2015-05-02 Timo Zimmermann 8 | 9 | * use SQLite as data store 10 | * support for users 11 | * new webinterface 12 | * enqueue already finished jobs if they failed 13 | 14 | 2015-01-24 Timo Zimmermann 15 | 16 | * add support for GitHubs status API 17 | 18 | 2015-01-21 Timo Zimmermann 19 | 20 | * update notifications to use text/template 21 | * introduce one template for all notification types 22 | * add deployment notifications 23 | 24 | 2014-11-09 Timo Zimmermann 25 | 26 | * add support for custom templates 27 | 28 | 2014-10-26 Timo Zimmermann 29 | 30 | * update web interface 31 | * add option for deploying code after successfully building a branch 32 | 33 | 2014-10-18 Timo Zimmermann 34 | 35 | * add configuration validation 36 | * individual tokens for repositories 37 | * new configuration format 38 | 39 | 2014-08-23 Timo Zimmermann 40 | 41 | * add JSON support to all endpoints 42 | triggered by appending `?format=json` to the URL 43 | 44 | 2014-08-21 Timo Zimmermann 45 | 46 | * update web interface 47 | hide test output by default and expand it on click. 48 | 49 | 2014-08-15 Timo Zimmermann 50 | 51 | * add HipChat support 52 | -------------------------------------------------------------------------------- /contributors.md: -------------------------------------------------------------------------------- 1 | * Timo Zimmermann 2 | * Jean Singer 3 | * Hardy Böhm <@nhboehm> 4 | -------------------------------------------------------------------------------- /database/command.go: -------------------------------------------------------------------------------- 1 | // Package database provides a wrapper between the database and stucts 2 | package database 3 | 4 | import ( 5 | "errors" 6 | "time" 7 | ) 8 | 9 | const ( 10 | // CommandKindTest is used when a command runs tests. 11 | CommandKindTest = "test" 12 | 13 | // CommandKindBuild is used when a command builds a package or project. 14 | CommandKindBuild = "build" 15 | 16 | // CommandKindDeploy is used when a command deploys a branch. 17 | CommandKindDeploy = "deploy" 18 | ) 19 | 20 | // Command stores a short name and the path or command to execute when a users 21 | // pushes to a repository. 22 | type Command struct { 23 | ID int64 24 | Name string 25 | Kind string 26 | Branch string 27 | Execute string 28 | 29 | RepositoryID int64 30 | 31 | CreatedAt time.Time 32 | UpdatedAt time.Time 33 | } 34 | 35 | // CommandLog stored a finnished command and the output of the task. 36 | type CommandLog struct { 37 | ID int64 38 | Name string // we only keep the name, no reference to the command, in case it changes. 39 | Return string 40 | Output string `sql:"type:text"` 41 | 42 | JobID int64 43 | } 44 | 45 | // CreateCommand adds a new command to a repository. 46 | func CreateCommand(repo *Repository, name, execute, branch, kind string) (*Command, error) { 47 | if kind != CommandKindTest && kind != CommandKindBuild && kind != CommandKindDeploy { 48 | return nil, errors.New("wrong kind") 49 | } 50 | 51 | c := Command{ 52 | Name: name, 53 | Execute: execute, 54 | Kind: kind, 55 | RepositoryID: repo.ID, 56 | Branch: branch, 57 | } 58 | 59 | db.Save(&c) 60 | 61 | return &c, nil 62 | } 63 | 64 | // GetCommand returns a command for a specific ID. 65 | func GetCommand(id int64) (*Command, error) { 66 | c := &Command{} 67 | db.Where("ID = ?", id).First(&c) 68 | return c, nil 69 | } 70 | 71 | // Update a command. 72 | func (c *Command) Update(name, kind, branch, execute string) error { 73 | c.Name = name 74 | c.Kind = kind 75 | c.Branch = branch 76 | c.Execute = execute 77 | 78 | db.Save(c) 79 | 80 | return nil 81 | } 82 | 83 | // Delete deletes a command. 84 | func (c *Command) Delete() { 85 | db.Delete(c) 86 | } 87 | 88 | // CreateCommandLog adds a new log. 89 | func CreateCommandLog(command *Command, job *Job, ret, out string) *CommandLog { 90 | log := CommandLog{ 91 | Name: command.Name, 92 | Return: ret, 93 | Output: out, 94 | JobID: job.ID, 95 | } 96 | 97 | db.Save(&log) 98 | 99 | return &log 100 | } 101 | 102 | // GetCommandLogsForJob returns all logs for a job. 103 | func GetCommandLogsForJob(id int64) []*CommandLog { 104 | var logs []*CommandLog 105 | db.Where("job_id = ?", id).Find(&logs) 106 | return logs 107 | } 108 | 109 | // Passed returns true if the command completed successfully. 110 | func (t *CommandLog) Passed() bool { 111 | if t.Return == "" { 112 | return true 113 | } 114 | 115 | return false 116 | } 117 | -------------------------------------------------------------------------------- /database/command_test.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestCommandCRUD(t *testing.T) { 8 | repo, _ := CreateRepository("", "", "", false, false) 9 | 10 | add, _ := CreateCommand(repo, "name", "execute", "branch", CommandKindBuild) 11 | get1, _ := GetCommand(add.ID) 12 | get1.Update("name", "kind", "branch", CommandKindDeploy) 13 | get2, _ := GetCommand(add.ID) 14 | get1.Delete() 15 | get3, _ := GetCommand(add.ID) 16 | 17 | if get1.ID != get2.ID { 18 | t.Error("ID mismatch", get1.ID, get2.ID) 19 | } 20 | 21 | if get1.Kind != "kind" { 22 | t.Error("Kind not updated") 23 | } 24 | 25 | if get3.ID != 0 { 26 | t.Error("Not deleted") 27 | } 28 | } 29 | 30 | func TestWrongKind(t *testing.T) { 31 | repo, _ := CreateRepository("", "", "", false, false) 32 | _, err := CreateCommand(repo, "name", "execute", "branch", "baz") 33 | 34 | if err == nil { 35 | t.Error("No error") 36 | } 37 | } 38 | 39 | func TestCommandLogPassed(t *testing.T) { 40 | log := CommandLog{Return: ""} 41 | 42 | if log.Passed() == false { 43 | t.Error("Passed not true") 44 | } 45 | 46 | log.Return = "1" 47 | 48 | if log.Passed() == true { 49 | t.Error("Passed not false") 50 | } 51 | } 52 | 53 | func TestCommandLogCR(t *testing.T) { 54 | repo, _ := CreateRepository("asdf", "", "", false, false) 55 | com, _ := CreateCommand(repo, "name", "execute", "branch", CommandKindBuild) 56 | job := CreateJob(repo, "branch", "commit", "commitURL", "name", "email") 57 | CreateCommandLog(com, job, "", "foo") 58 | 59 | got := GetCommandLogsForJob(job.ID) 60 | 61 | if len(got) != 1 { 62 | t.Error("Wrong length", len(got)) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /database/config.go: -------------------------------------------------------------------------------- 1 | // Package database provides a wrapper between the database and stucts 2 | package database 3 | 4 | import ( 5 | "net/url" 6 | "time" 7 | ) 8 | 9 | // Config represents the complete configuration for the CI. 10 | type Config struct { 11 | ID int64 12 | Secret string 13 | URL string 14 | Cert string 15 | Key string 16 | Parallel int 17 | 18 | CreatedAt time.Time 19 | UpdatedAt time.Time 20 | } 21 | 22 | // AddConfig adds a new configuration. 23 | func AddConfig(secret, url, cert, key string, parallel int) *Config { 24 | c := &Config{ 25 | Secret: secret, 26 | URL: url, 27 | Cert: cert, 28 | Key: key, 29 | Parallel: parallel, 30 | } 31 | 32 | db.Save(c) 33 | 34 | return c 35 | } 36 | 37 | // GetConfig returns the current configuration. 38 | func GetConfig() *Config { 39 | c := &Config{} 40 | db.First(c) 41 | 42 | if c.Parallel < 1 && c.ID != 0 { 43 | c.Parallel = 1 44 | db.Save(c) 45 | } 46 | 47 | return c 48 | } 49 | 50 | // UpdateConfig updates the config. 51 | func UpdateConfig(secret, url, cert, key string, parallel int) *Config { 52 | c := GetConfig() 53 | 54 | c.Secret = secret 55 | c.URL = url 56 | c.Cert = cert 57 | c.Key = key 58 | c.Parallel = parallel 59 | 60 | db.Save(c) 61 | 62 | return c 63 | } 64 | 65 | // DeleteConfig delete the existing configuration. 66 | func DeleteConfig() { 67 | c := GetConfig() 68 | db.Delete(c) 69 | } 70 | 71 | // Scheme returns the URL scheme used. 72 | func (c *Config) Scheme() string { 73 | u, err := url.Parse(c.URL) 74 | 75 | if err != nil { 76 | panic(err) 77 | } 78 | 79 | return u.Scheme 80 | } 81 | 82 | // Host returns the host. 83 | func (c *Config) Host() string { 84 | u, err := url.Parse(c.URL) 85 | 86 | if err != nil { 87 | panic(err) 88 | } 89 | 90 | return u.Host 91 | } 92 | -------------------------------------------------------------------------------- /database/config_test.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestScheme(t *testing.T) { 8 | c := Config{ 9 | URL: "https://foo:8080", 10 | } 11 | 12 | if c.Scheme() != "https" { 13 | t.Error("Wrong scheme", c.Scheme()) 14 | } 15 | } 16 | 17 | func TestHost(t *testing.T) { 18 | c := Config{ 19 | URL: "https://foo:8080", 20 | } 21 | 22 | if c.Host() != "foo:8080" { 23 | t.Error("Wrong host", c.Host()) 24 | } 25 | } 26 | 27 | func TestConfigCRUD(t *testing.T) { 28 | AddConfig("secret", "url", "cert", "key", 1) 29 | get1 := GetConfig() 30 | updated := UpdateConfig("secret2", "url", "cert", "key", 1) 31 | get2 := GetConfig() 32 | DeleteConfig() 33 | get3 := GetConfig() 34 | 35 | if get1.ID != get2.ID || updated.ID != get2.ID { 36 | t.Error("ID mismatch") 37 | } 38 | 39 | if get1.Secret == get2.Secret { 40 | t.Error("Secret not updated") 41 | } 42 | 43 | if get3.ID != 0 { 44 | t.Error("Not deleted") 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /database/db.go: -------------------------------------------------------------------------------- 1 | // Package database provides a wrapper between the database and stucts 2 | package database 3 | 4 | import ( 5 | "log" 6 | "os" 7 | "strings" 8 | 9 | "github.com/jinzhu/gorm" 10 | _ "github.com/lib/pq" // Postgres driver 11 | _ "github.com/mattn/go-sqlite3" // Sqlite3 driver (used for testing) 12 | ) 13 | 14 | var db *gorm.DB 15 | 16 | // Configured indicates if there is a valid configuration. 17 | var Configured bool 18 | 19 | // NewDatabase established a database connection and stores it in `db`. 20 | func NewDatabase(driver, options string) error { 21 | if driver == "" { 22 | driver, options = envURL() 23 | } 24 | 25 | sql, err := gorm.Open(driver, options) 26 | 27 | if err != nil { 28 | return err 29 | } 30 | 31 | sql.DB() 32 | db = sql 33 | 34 | db.AutoMigrate( 35 | &Command{}, 36 | &Config{}, 37 | &Job{}, 38 | &MailServer{}, 39 | &Notification{}, 40 | &Repository{}, 41 | &CommandLog{}, 42 | &User{}, 43 | ) 44 | 45 | cfg := GetConfig() 46 | 47 | if cfg.ID == 0 { 48 | Configured = false 49 | } else { 50 | Configured = true 51 | } 52 | 53 | return nil 54 | } 55 | 56 | // envURL returns the database type and connection settings read from the environment 57 | // variable `DATABASE_URL`. 58 | // 59 | // Format: "SQLDRIVER connection settings for driver" 60 | func envURL() (string, string) { 61 | dbURL := os.Getenv("DATABASE_URL") 62 | 63 | s := strings.SplitN(dbURL, " ", 2) 64 | 65 | if len(s) != 2 { 66 | log.Println("Invalid DATABASE_URL - using sqlite3 `leeroy.sqlite3`") 67 | return "sqlite3", "leeroy.sqlite3" 68 | } 69 | 70 | return s[0], s[1] 71 | } 72 | 73 | // NewInMemoryDatabase creates a new database using :memory: 74 | func NewInMemoryDatabase() { 75 | NewDatabase("sqlite3", ":memory:") 76 | } 77 | -------------------------------------------------------------------------------- /database/db_test.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestMain(m *testing.M) { 9 | NewInMemoryDatabase() 10 | 11 | i := m.Run() 12 | 13 | os.Exit(i) 14 | } 15 | 16 | func TestNewDatabase(t *testing.T) { 17 | err := db.DB().Ping() 18 | 19 | if err != nil { 20 | t.Error("No database connection") 21 | } 22 | } 23 | 24 | func TestEnvURL(t *testing.T) { 25 | d, s := envURL() 26 | 27 | if d != "sqlite3" { 28 | t.Error("Wrong driver", d) 29 | } 30 | 31 | if s != "/Users/timo/tmp/leeory.sqlite3" { 32 | t.Error("Wrong connection string", s) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /database/job.go: -------------------------------------------------------------------------------- 1 | // Package database provides a wrapper between the database and stucts 2 | package database 3 | 4 | import ( 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | // Define all statuses a job can have. 10 | const ( 11 | JobStatusSuccess = "success" 12 | JobStatusError = "error" 13 | JobStatusPending = "pending" 14 | ) 15 | 16 | // Job stores all information about one commit and the executed tasks. 17 | type Job struct { 18 | ID int64 19 | 20 | Cancelled bool 21 | TasksStarted time.Time 22 | TasksFinished time.Time 23 | DeployFinished time.Time 24 | 25 | Repository Repository 26 | RepositoryID int64 27 | 28 | Branch string 29 | Commit string `gorm:"column:commit_sha"` 30 | CommitURL string 31 | 32 | Name string 33 | Email string 34 | 35 | CreatedAt time.Time 36 | UpdatedAt time.Time 37 | 38 | CommandLogs []CommandLog 39 | } 40 | 41 | // CreateJob adds a new job to the database. 42 | func CreateJob(repo *Repository, branch, commit, commitURL, name, email string) *Job { 43 | j := &Job{ 44 | Repository: *repo, 45 | Branch: branch, 46 | Commit: commit, 47 | CommitURL: commitURL, 48 | Name: name, 49 | Email: email, 50 | Cancelled: false, 51 | } 52 | 53 | db.Save(j) 54 | 55 | return j 56 | } 57 | 58 | // GetJob returns a job for a given ID. 59 | func GetJob(id int64) *Job { 60 | j := &Job{} 61 | db.Preload("Repository").Preload("CommandLogs").Where("ID = ?", id).Last(&j) 62 | return j 63 | } 64 | 65 | // GetJobs returns a list of jobs for a given range. 66 | func GetJobs(offset, limit int) []*Job { 67 | var jobs []*Job 68 | 69 | db.Preload( 70 | "Repository", 71 | ).Preload( 72 | "CommandLogs", 73 | ).Offset( 74 | offset, 75 | ).Limit( 76 | limit, 77 | ).Order( 78 | "created_at desc", 79 | ).Find(&jobs) 80 | 81 | return jobs 82 | } 83 | 84 | // GetJobByCommit returns with a specific commit ID or nil. 85 | func GetJobByCommit(commit string) *Job { 86 | job := &Job{} 87 | 88 | db.Where("commit_sha = ?", commit).Last(&job) 89 | 90 | return job 91 | } 92 | 93 | // NumberOfJobs returns the number of all existing jobs. 94 | func NumberOfJobs() int { 95 | var count int 96 | 97 | db.Table("jobs").Count(&count) 98 | 99 | return count 100 | } 101 | 102 | // Passed returns true if all commands succeeded. 103 | func (j *Job) Passed() bool { 104 | logs := GetCommandLogsForJob(j.ID) 105 | 106 | for _, c := range logs { 107 | if !c.Passed() { 108 | return false 109 | } 110 | } 111 | return true 112 | } 113 | 114 | // Status returns the current status fo the job. 115 | func (j *Job) Status() string { 116 | n := time.Time{} 117 | 118 | if j.TasksFinished.After(n) { 119 | if j.Passed() { 120 | return JobStatusSuccess 121 | } 122 | 123 | return JobStatusError 124 | } 125 | return JobStatusPending 126 | } 127 | 128 | // TasksDone sets TasksDone 129 | func (j *Job) TasksDone() { 130 | j.TasksFinished = time.Now() 131 | db.Save(j) 132 | } 133 | 134 | // DeployDone sets DeployDone 135 | func (j *Job) DeployDone() { 136 | j.DeployFinished = time.Now() 137 | db.Save(j) 138 | } 139 | 140 | // URL returns the URL for this job, including the configured server URL. 141 | func (j *Job) URL() string { 142 | config := GetConfig() 143 | return fmt.Sprintf("%s/%d", config.URL, j.ID) 144 | } 145 | 146 | // ShouldBuild returns true if there are build commands for this job. 147 | func (j *Job) ShouldBuild() bool { 148 | commands := j.Repository.GetCommands(j.Branch, CommandKindBuild) 149 | 150 | if len(commands) > 0 { 151 | return j.Passed() 152 | } 153 | 154 | return false 155 | } 156 | 157 | // ShouldDeploy returns true if there are deploy commands for this job. 158 | func (j *Job) ShouldDeploy() bool { 159 | commands := j.Repository.GetCommands(j.Branch, CommandKindDeploy) 160 | 161 | if len(commands) > 0 { 162 | return j.Passed() 163 | } 164 | 165 | return false 166 | } 167 | 168 | // Started sets the started time to now indicating that this job 169 | // started running. 170 | func (j *Job) Started() { 171 | j.TasksStarted = time.Now() 172 | db.Save(j) 173 | } 174 | 175 | // IsRunning returns true if this job is not finished with all its 176 | // tasks. 177 | func (j *Job) IsRunning() bool { 178 | if j.TasksStarted.After(time.Time{}) && !j.Done() { 179 | return true 180 | } 181 | return false 182 | } 183 | 184 | // Done returns true if all commands finished executing. 185 | func (j *Job) Done() bool { 186 | if j.ShouldDeploy() && !j.DeployFinished.After(time.Time{}) { 187 | return false 188 | } else if !j.TasksFinished.After(time.Time{}) { 189 | return false 190 | } 191 | return true 192 | } 193 | 194 | // Cancel cancels a job. 195 | func (j *Job) Cancel() { 196 | j.Cancelled = true 197 | db.Save(j) 198 | } 199 | 200 | // SearchJobs returns all jobs where the branch or commit contains the query 201 | // string. 202 | func SearchJobs(query string) []*Job { 203 | var branch []*Job 204 | var commits []*Job 205 | 206 | like := "%" + query + "%" 207 | 208 | db.Preload( 209 | "Repository", 210 | ).Preload( 211 | "CommandLogs", 212 | ).Where( 213 | "(branch LIKE ? OR commit_sha LIKE ?)", like, like, 214 | ).Order( 215 | "created_at desc", 216 | ).Find(&branch) 217 | 218 | return append(branch, commits...) 219 | } 220 | -------------------------------------------------------------------------------- /database/job_test.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestCGDoneJob(t *testing.T) { 9 | repo, _ := CreateRepository("foo", "baz", "accessKey", false, false) 10 | 11 | job := CreateJob(repo, "branch", "commit", "commitURL", "name", "email") 12 | job.TasksDone() 13 | job.DeployDone() 14 | get := GetJob(job.ID) 15 | 16 | if job.TasksFinished == get.TasksFinished { 17 | t.Error("tasks not finished") 18 | } 19 | 20 | if job.DeployFinished == get.DeployFinished { 21 | t.Error("deploy not finished") 22 | } 23 | } 24 | 25 | func TestGetJobByCommit(t *testing.T) { 26 | repo, _ := CreateRepository("foo", "baz", "accessKey", false, false) 27 | job := CreateJob(repo, "branch", "bar", "commit URL", "name", "email") 28 | 29 | j1 := GetJobByCommit("foo") 30 | j2 := GetJobByCommit("bar") 31 | 32 | if j1.ID != 0 { 33 | t.Error("j1 not nil", j1) 34 | } 35 | 36 | if j2.Branch != job.Branch { 37 | t.Error("j2 branches do not match", j2.Branch) 38 | } 39 | } 40 | 41 | func TestGetJobs(t *testing.T) { 42 | db.Exec("DELETE FROM jobs WHERE id > 0") 43 | repo, _ := CreateRepository("foo", "baz", "accessKey", false, false) 44 | job1 := CreateJob(repo, "branch", "bar", "commit URL", "name", "email") 45 | job2 := CreateJob(repo, "branch", "bar", "commit URL", "name", "email") 46 | job3 := CreateJob(repo, "branch", "bar", "commit URL", "name", "email") 47 | 48 | jobs := GetJobs(0, 1) 49 | 50 | if len(jobs) != 1 { 51 | t.Error("Wrong length", len(jobs)) 52 | } 53 | 54 | if jobs[0].ID != job3.ID { 55 | t.Error("Wrong job", jobs[0].ID) 56 | } 57 | 58 | jobs = GetJobs(1, 2) 59 | 60 | if len(jobs) != 2 { 61 | t.Error("Wrong length", len(jobs)) 62 | } 63 | 64 | if jobs[0].ID != job2.ID { 65 | t.Error("Wrong job", jobs[0].ID) 66 | } 67 | 68 | if jobs[1].ID != job1.ID { 69 | t.Error("Wrong job", jobs[1].ID) 70 | } 71 | } 72 | 73 | func TestNumberOfJobs(t *testing.T) { 74 | NewInMemoryDatabase() 75 | repo, _ := CreateRepository("foo", "baz", "accessKey", false, false) 76 | CreateJob(repo, "branch", "bar", "commit URL", "name", "email") 77 | CreateJob(repo, "branch", "bar", "commit URL", "name", "email") 78 | CreateJob(repo, "branch", "bar", "commit URL", "name", "email") 79 | 80 | count := NumberOfJobs() 81 | 82 | if count != 3 { 83 | t.Error("Wrong number of jobs", count) 84 | } 85 | } 86 | 87 | func TestJobPassed(t *testing.T) { 88 | repo, _ := CreateRepository("foo", "baz", "accessKey", false, false) 89 | job := CreateJob(repo, "branch", "bar", "commit URL", "name", "email") 90 | 91 | if job.Passed() != true { 92 | t.Error("Job did not pass") 93 | } 94 | 95 | com, _ := CreateCommand(repo, "name", "execute", "branch", CommandKindBuild) 96 | CreateCommandLog(com, job, "1", "foo") 97 | 98 | if job.Passed() != false { 99 | t.Error("Job did pass") 100 | } 101 | } 102 | 103 | func TestJobStatus(t *testing.T) { 104 | repo, _ := CreateRepository("foo", "baz", "accessKey", false, false) 105 | job := CreateJob(repo, "branch", "bar", "commit URL", "name", "email") 106 | 107 | if job.Status() != JobStatusPending { 108 | t.Error("Job not pending") 109 | } 110 | 111 | job.TasksDone() 112 | 113 | if job.Status() != JobStatusSuccess { 114 | t.Error("Job not success") 115 | } 116 | 117 | com, _ := CreateCommand(repo, "name", "execute", "branch", CommandKindBuild) 118 | CreateCommandLog(com, job, "1", "foo") 119 | 120 | if job.Status() != JobStatusError { 121 | t.Error("Job not error") 122 | } 123 | } 124 | 125 | func TestJobDeployDone(t *testing.T) { 126 | repo, _ := CreateRepository("foo", "baz", "accessKey", false, false) 127 | job := CreateJob(repo, "branch", "bar", "commit URL", "name", "email") 128 | 129 | job.DeployDone() 130 | 131 | blank := time.Time{} 132 | if job.DeployFinished == blank { 133 | t.Error("Deploy time not set") 134 | } 135 | } 136 | 137 | func TestJobURL(t *testing.T) { 138 | db.Exec("DELETE FROM jobs WHERE id > 0") 139 | AddConfig("secret", "url", "cert", "key", 1) 140 | repo, _ := CreateRepository("foo", "baz", "accessKey", false, false) 141 | job := CreateJob(repo, "branch", "bar", "commit URL", "name", "email") 142 | 143 | if job.URL() != "url/7" { 144 | t.Error("Wrong URL", job.URL()) 145 | } 146 | } 147 | 148 | func TestJobShouldBuild(t *testing.T) { 149 | AddConfig("secret", "url", "cert", "key", 1) 150 | repo, _ := CreateRepository("foo", "baz", "accessKey", false, false) 151 | job := CreateJob(repo, "branch", "bar", "commit URL", "name", "email") 152 | CreateCommand(repo, "name", "execute", "branch", CommandKindTest) 153 | 154 | if job.ShouldBuild() == true { 155 | t.Error("ShouldBuild = true") 156 | } 157 | 158 | CreateCommand(repo, "name", "execute", "branch", CommandKindBuild) 159 | 160 | if job.ShouldBuild() == false { 161 | t.Error("ShouldBuild = false") 162 | } 163 | } 164 | 165 | func TestJobShouldDeploy(t *testing.T) { 166 | AddConfig("secret", "url", "cert", "key", 1) 167 | repo, _ := CreateRepository("foo", "baz", "accessKey", false, false) 168 | job := CreateJob(repo, "branch", "bar", "commit URL", "name", "email") 169 | CreateCommand(repo, "name", "execute", "branch", CommandKindTest) 170 | 171 | if job.ShouldDeploy() == true { 172 | t.Error("ShouldDeploy = true") 173 | } 174 | 175 | CreateCommand(repo, "name", "execute", "branch", CommandKindDeploy) 176 | 177 | if job.ShouldDeploy() == false { 178 | t.Error("ShouldDeploy = false") 179 | } 180 | } 181 | 182 | func TestJobStarted(t *testing.T) { 183 | AddConfig("secret", "url", "cert", "key", 1) 184 | repo, _ := CreateRepository("foo", "baz", "accessKey", false, false) 185 | job := CreateJob(repo, "branch", "bar", "commit URL", "name", "email") 186 | 187 | job.Started() 188 | 189 | if !job.TasksStarted.After(time.Time{}) { 190 | t.Error("No started time.") 191 | } 192 | } 193 | 194 | func TestJobIsRunningTasks(t *testing.T) { 195 | NewInMemoryDatabase() 196 | AddConfig("secret", "url", "cert", "key", 1) 197 | repo, _ := CreateRepository("foo", "baz", "accessKey", false, false) 198 | job := CreateJob(repo, "branch", "bar", "commit URL", "name", "email") 199 | 200 | if job.IsRunning() { 201 | t.Error("Job is running, but should not - no start / end") 202 | } 203 | 204 | job.Started() 205 | 206 | if !job.IsRunning() { 207 | t.Error("Job is not running, but should - start") 208 | } 209 | 210 | job.TasksDone() 211 | 212 | if job.IsRunning() { 213 | t.Error("Job is running, but should not - start / end") 214 | } 215 | 216 | CreateCommand(repo, "name", "execute", "branch", CommandKindDeploy) 217 | 218 | if !job.IsRunning() { 219 | t.Error("Job should be running - deploy, but is not") 220 | } 221 | 222 | job.DeployDone() 223 | 224 | if job.IsRunning() { 225 | t.Error("Job is running, but should not") 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /database/mailserver.go: -------------------------------------------------------------------------------- 1 | // Package database provides a wrapper between the database and stucts 2 | package database 3 | 4 | import ( 5 | "net/mail" 6 | "net/smtp" 7 | "strconv" 8 | "time" 9 | ) 10 | 11 | // MailServer stores a mailserver configuration. 12 | type MailServer struct { 13 | ID int64 14 | Host string 15 | Sender string 16 | Port int 17 | User string 18 | Password string 19 | 20 | CreatedAt time.Time 21 | UpdatedAt time.Time 22 | } 23 | 24 | // AddMailServer adds a new mail server. 25 | func AddMailServer(host, sender, user, password string, port int) *MailServer { 26 | m := &MailServer{ 27 | Host: host, 28 | Sender: sender, 29 | Port: port, 30 | User: user, 31 | Password: password, 32 | } 33 | 34 | db.Save(m) 35 | 36 | return m 37 | } 38 | 39 | // GetMailServer returns a mail server configuration based on the current configuration. 40 | func GetMailServer() *MailServer { 41 | m := &MailServer{} 42 | db.First(m) 43 | return m 44 | } 45 | 46 | // UpdateMailServer updates the existing mail server configuration. 47 | func UpdateMailServer(host, sender, user, password string, port int) *MailServer { 48 | m := GetMailServer() 49 | 50 | m.Host = host 51 | m.Sender = sender 52 | m.User = user 53 | m.Password = password 54 | m.Port = port 55 | 56 | db.Save(m) 57 | 58 | return m 59 | } 60 | 61 | // DeleteMailServer delete the existing mail server configuration. 62 | func DeleteMailServer() { 63 | m := GetMailServer() 64 | db.Delete(m) 65 | } 66 | 67 | // Server returns the host name and port for a mailserver. 68 | func (m *MailServer) Server() string { 69 | return m.Host + ":" + strconv.Itoa(m.Port) 70 | } 71 | 72 | // From returns net/mail.Address with sender information for the mail server. 73 | func (m *MailServer) From() mail.Address { 74 | return mail.Address{ 75 | Name: "Leeroy CI", 76 | Address: m.Sender, 77 | } 78 | } 79 | 80 | // Auth returns net/smtp.Auth for this mail server. 81 | func (m *MailServer) Auth() smtp.Auth { 82 | return smtp.PlainAuth("", m.User, m.Password, m.Host) 83 | } 84 | -------------------------------------------------------------------------------- /database/mailserver_test.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestServer(t *testing.T) { 8 | m := &MailServer{} 9 | m.Host = "foo" 10 | m.Port = 1234 11 | 12 | s := m.Server() 13 | 14 | if s != "foo:1234" { 15 | t.Error("Wrong server", s) 16 | } 17 | } 18 | 19 | func TestMailServerCRUD(t *testing.T) { 20 | _ = AddMailServer("host", "sender", "user", "password", 1234) 21 | get1 := GetMailServer() 22 | updated := UpdateMailServer("host", "sender", "user", "password", 4321) 23 | get2 := GetMailServer() 24 | DeleteMailServer() 25 | get3 := GetMailServer() 26 | 27 | if get1.ID != get2.ID || updated.ID != get2.ID { 28 | t.Error("ID mismatch") 29 | } 30 | 31 | if get1.Port == get2.Port { 32 | t.Error("Port not updated") 33 | } 34 | 35 | if get3.ID != 0 { 36 | t.Error("Not deleted") 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /database/notification.go: -------------------------------------------------------------------------------- 1 | // Package database provides a wrapper between the database and stucts 2 | package database 3 | 4 | import ( 5 | "errors" 6 | "strings" 7 | ) 8 | 9 | const ( 10 | // NotificationServiceEmail type for email notifications. 11 | NotificationServiceEmail = "email" 12 | 13 | // NotificationServiceSlack type for slack notifications. 14 | NotificationServiceSlack = "slack" 15 | 16 | // NotificationServiceCampfire type for campfire notifications. 17 | NotificationServiceCampfire = "campfire" 18 | 19 | // NotificationServiceHipchat type for hipchat notifications. 20 | NotificationServiceHipchat = "hipchat" 21 | ) 22 | 23 | // Notification stores the configuration needed for a notification plugin to 24 | // work. All optiones required by the services are stored as map - it is the 25 | // job of the notification plugin to access them correctly and handle missing 26 | // ones. 27 | type Notification struct { 28 | ID int64 29 | Service string 30 | Arguments string 31 | 32 | Repository Repository 33 | RepositoryID int64 `sql:"index"` 34 | } 35 | 36 | // CreateNotification create a new notification for a repository. 37 | func CreateNotification(service, arguments string, repo *Repository) (*Notification, error) { 38 | not := Notification{ 39 | Service: service, 40 | Arguments: arguments, 41 | Repository: *repo, 42 | } 43 | 44 | db.Save(¬) 45 | 46 | return ¬, nil 47 | } 48 | 49 | // GetNotification returns a notification. 50 | func GetNotification(id int64) (*Notification, error) { 51 | not := &Notification{} 52 | db.Where("id = ?", id).First(¬) 53 | return not, nil 54 | } 55 | 56 | // GetNotificationForRepoAndType returns a specific notification for a repository. 57 | func GetNotificationForRepoAndType(repo *Repository, service string) (*Notification, error) { 58 | not := &Notification{} 59 | db.Where("repository_id = ? AND service = ?", repo.ID, service).First(¬) 60 | return not, nil 61 | } 62 | 63 | // Update this notification. 64 | func (n *Notification) Update(service, arguments string) error { 65 | n.Service = service 66 | n.Arguments = arguments 67 | db.Save(n) 68 | return nil 69 | } 70 | 71 | // Delete this notification. 72 | func (n *Notification) Delete() { 73 | db.Delete(n) 74 | } 75 | 76 | // GetConfigValue returns a configuration value for a given key that is stored in Arguments. 77 | func (n *Notification) GetConfigValue(key string) (string, error) { 78 | if n.Arguments == "" { 79 | return "", errors.New("No Arguments defined.") 80 | } 81 | 82 | for _, pair := range strings.Split(n.Arguments, ":::::") { 83 | split := strings.Split(pair, ":::") 84 | 85 | if split[0] == key { 86 | return split[1], nil 87 | } 88 | } 89 | 90 | return "", errors.New("Not found.") 91 | } 92 | -------------------------------------------------------------------------------- /database/notification_test.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestNotificationCRUD(t *testing.T) { 8 | r, _ := CreateRepository("name", "url", "accessKey", false, false) 9 | n1, _ := CreateNotification("service", "arguments", r) 10 | n2, _ := GetNotification(n1.ID) 11 | n2.Update("service", "arguments2") 12 | n2.Delete() 13 | n3, _ := GetNotification(n1.ID) 14 | 15 | if n1.Service != n2.Service { 16 | t.Error("Service does not match") 17 | } 18 | 19 | if n2.Arguments == "arguments" { 20 | t.Error("Arguments not updated") 21 | } 22 | 23 | if n3.ID == n1.ID || n3.ID != 0 { 24 | t.Error("Notification not deleted") 25 | } 26 | } 27 | 28 | func TestGetNotificationForRepoAndType(t *testing.T) { 29 | r, _ := CreateRepository("name", "url", "accessKey", false, false) 30 | not, _ := CreateNotification(NotificationServiceSlack, "arguments", r) 31 | 32 | notGot, _ := GetNotificationForRepoAndType(r, NotificationServiceSlack) 33 | 34 | if not.ID != notGot.ID { 35 | t.Error("got the wrong notification.") 36 | } 37 | } 38 | 39 | func TestGetConfigValue(t *testing.T) { 40 | r, _ := CreateRepository("name", "url", "accessKey", false, false) 41 | not, _ := CreateNotification(NotificationServiceSlack, "", r) 42 | 43 | _, err := not.GetConfigValue("foo") 44 | if err.Error() != "No Arguments defined." { 45 | t.Error("Wrong return", err.Error()) 46 | } 47 | 48 | not, _ = CreateNotification(NotificationServiceSlack, "foo:::bar:::::zab:::123", r) 49 | 50 | _, err = not.GetConfigValue("baz") 51 | if err.Error() != "Not found." { 52 | t.Error("Wrong return", err.Error()) 53 | } 54 | 55 | val, _ := not.GetConfigValue("zab") 56 | if val != "123" { 57 | t.Error("Wrong return", val) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /database/repository.go: -------------------------------------------------------------------------------- 1 | // Package database provides a wrapper between the database and stucts 2 | package database 3 | 4 | import ( 5 | "time" 6 | ) 7 | 8 | // Repository holds all information needed to identify a repository and run 9 | // tests and builds. 10 | type Repository struct { 11 | ID int64 12 | Name string 13 | URL string 14 | 15 | ClosePR bool 16 | StatusPR bool 17 | AccessKey string 18 | 19 | CreatedAt time.Time 20 | UpdatedAt time.Time 21 | 22 | Notifications []Notification 23 | Commands []Command 24 | } 25 | 26 | // CreateRepository adds a new repository. 27 | func CreateRepository(name, url, accessKey string, closePR, statusPR bool) (*Repository, error) { 28 | repo := Repository{ 29 | Name: name, 30 | URL: url, 31 | AccessKey: accessKey, 32 | ClosePR: closePR, 33 | StatusPR: statusPR, 34 | } 35 | 36 | db.Save(&repo) 37 | 38 | return &repo, nil 39 | } 40 | 41 | // GetRepository returns the repository based on the URL that pushed changes. 42 | func GetRepository(url string) *Repository { 43 | repo := &Repository{} 44 | db.Preload("Notifications").Preload("Commands").Where("URL = ?", url).First(&repo) 45 | return repo 46 | } 47 | 48 | // GetRepositoryByID returns the repository based on the ID. 49 | func GetRepositoryByID(id int64) (*Repository, error) { 50 | repo := &Repository{} 51 | db.Preload("Notifications").Preload("Commands").Where("ID = ?", id).First(&repo) 52 | return repo, nil 53 | } 54 | 55 | // Update this repository. 56 | func (r *Repository) Update(name, url, accessKey string, closePR, statusPR bool) (*Repository, error) { 57 | r.Name = name 58 | r.StatusPR = statusPR 59 | r.ClosePR = closePR 60 | r.AccessKey = accessKey 61 | 62 | db.Save(r) 63 | 64 | return r, nil 65 | } 66 | 67 | // ListRepositories returns all repositories. 68 | func ListRepositories() []*Repository { 69 | var repos []*Repository 70 | db.Find(&repos) 71 | return repos 72 | } 73 | 74 | // Delete this repository. 75 | func (r *Repository) Delete() error { 76 | db.Delete(r) 77 | 78 | return nil 79 | } 80 | 81 | // Jobs returns all jobs for this repository. 82 | func (r *Repository) Jobs() []Job { 83 | jobs := []Job{} 84 | 85 | db.Where("repository_id = ?", r.ID).Find(&jobs) 86 | 87 | return jobs 88 | } 89 | 90 | // GetCommands returns all commands for a repository, branch and kind 91 | func (r *Repository) GetCommands(branch, kind string) []Command { 92 | commands := []Command{} 93 | branchSpecific := []Command{} 94 | 95 | db.Where(&Command{ 96 | RepositoryID: r.ID, 97 | Kind: kind, 98 | }).Where( 99 | "branch LIKE ''", 100 | ).Find(&commands) 101 | 102 | db.Where(&Command{ 103 | RepositoryID: r.ID, 104 | Kind: kind, 105 | Branch: branch, 106 | }).Find(&branchSpecific) 107 | 108 | commands = append(commands, branchSpecific...) 109 | 110 | return commands 111 | } 112 | -------------------------------------------------------------------------------- /database/repository_test.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestAddRepositoryGetRepository(t *testing.T) { 8 | r1, _ := CreateRepository("foo", "bar", "accessKey", false, false) 9 | r2 := GetRepository("bar") 10 | r2.Update("baz", "bar", "accessKey", false, false) 11 | r2.Delete() 12 | r3 := GetRepository("bar") 13 | 14 | if r1.ID != r2.ID { 15 | t.Error("IDs do not match.") 16 | } 17 | 18 | if r2.Name == "bar" { 19 | t.Error("Names are the same.") 20 | } 21 | 22 | if r3.ID == r1.ID || r3.ID != 0 { 23 | t.Error("Repository not deleted.") 24 | } 25 | } 26 | 27 | func TestJobs(t *testing.T) { 28 | r1, _ := CreateRepository("name", "url", "accessKey", false, false) 29 | r2, _ := CreateRepository("name2", "url2", "accessKey", false, false) 30 | 31 | j1 := CreateJob(r1, "branch", "commit", "commitURL", "name", "email") 32 | j2 := CreateJob(r1, "branch2", "commit", "commitURL", "name", "email") 33 | CreateJob(r2, "branch3", "commit", "commitURL", "name", "email") 34 | 35 | j := r1.Jobs() 36 | 37 | if len(j) != 2 { 38 | t.Error("Wrong number of jobs", len(j)) 39 | } 40 | 41 | for _, v := range j { 42 | if v.ID != j1.ID && v.ID != j2.ID { 43 | t.Error("Wrong job") 44 | } 45 | } 46 | } 47 | 48 | func TestAddCommandCommand(t *testing.T) { 49 | repo, _ := CreateRepository("", "", "", false, false) 50 | com1, err := CreateCommand(repo, "", "", "", CommandKindBuild) 51 | 52 | if err != nil { 53 | t.Error(err) 54 | } 55 | 56 | coms := repo.GetCommands("", CommandKindBuild) 57 | 58 | if coms[0].ID != com1.ID { 59 | t.Error("ID mismatch") 60 | } 61 | } 62 | 63 | func TestAddCommandGetCommandDifferentKind(t *testing.T) { 64 | repo, _ := CreateRepository("", "", "", false, false) 65 | _, err := CreateCommand(repo, "", "", "", CommandKindBuild) 66 | 67 | if err != nil { 68 | t.Error(err) 69 | } 70 | 71 | coms := repo.GetCommands("", CommandKindTest) 72 | 73 | if len(coms) != 0 { 74 | t.Error("Wrong number of commands", len(coms)) 75 | } 76 | } 77 | 78 | func TestGetRepositoryByID(t *testing.T) { 79 | repo, _ := CreateRepository("", "", "", false, false) 80 | _, err := GetRepositoryByID(repo.ID) 81 | 82 | if err != nil { 83 | t.Error(err) 84 | } 85 | } 86 | 87 | func TestListRepositories(t *testing.T) { 88 | db.Exec("DELETE FROM repositories WHERE id > 0") 89 | CreateRepository("", "", "", false, false) 90 | CreateRepository("", "", "", false, false) 91 | CreateRepository("", "", "", false, false) 92 | repos := ListRepositories() 93 | 94 | if len(repos) != 3 { 95 | t.Error("Wrong repository count", len(repos)) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /database/user.go: -------------------------------------------------------------------------------- 1 | // Package database provides a wrapper between the database and stucts 2 | package database 3 | 4 | import ( 5 | "crypto/rand" 6 | "crypto/sha512" 7 | "encoding/base64" 8 | "errors" 9 | "strings" 10 | 11 | "golang.org/x/crypto/bcrypt" 12 | ) 13 | 14 | const sessionDictionary = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" 15 | const accessKeyDictionary = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_?!+=%$&/()" 16 | const sessionLength = 256 17 | const accessKeyLength = 64 18 | 19 | // User stores a user account including the password using bcrypt. 20 | type User struct { 21 | ID int64 22 | Email string 23 | FirstName string 24 | LastName string 25 | Password string 26 | Admin bool 27 | Session string 28 | AccessKey string 29 | } 30 | 31 | // ListUsers returns a list of all users. 32 | func ListUsers() []*User { 33 | var u []*User 34 | db.Find(&u) 35 | return u 36 | } 37 | 38 | // GetUser returns the user for a given email address. 39 | func GetUser(email string) (*User, error) { 40 | u := &User{} 41 | db.Where("email = ?", email).First(u) 42 | 43 | if u.ID == 0 { 44 | return nil, errors.New("Could not find user.") 45 | } 46 | 47 | return u, nil 48 | } 49 | 50 | // GetUserBySession returns the user for a given session key. 51 | func GetUserBySession(key string) (*User, error) { 52 | u := &User{} 53 | db.Where("session = ?", key).First(u) 54 | 55 | if u.ID == 0 { 56 | return nil, errors.New("Could not find user.") 57 | } 58 | 59 | return u, nil 60 | } 61 | 62 | // GetUserByAccessKey returns the user for a given access key. 63 | func GetUserByAccessKey(key string) (*User, error) { 64 | u := &User{} 65 | db.Where("access_key = ?", key).First(u) 66 | 67 | if u.ID == 0 { 68 | return nil, errors.New("Could not find user.") 69 | } 70 | 71 | return u, nil 72 | } 73 | 74 | // GetUserByID returns the user for a given ID. 75 | func GetUserByID(id int64) (*User, error) { 76 | u := &User{} 77 | db.Where("ID = ?", id).First(u) 78 | 79 | if u.ID == 0 { 80 | return nil, errors.New("Could not find user.") 81 | } 82 | 83 | return u, nil 84 | } 85 | 86 | // CreateUser creates a new user. 87 | func CreateUser(email, firstName, lastName, password string, admin bool) (*User, error) { 88 | u, err := GetUser(email) 89 | 90 | if err == nil { 91 | return nil, errors.New("User with this email address already exists.") 92 | } 93 | 94 | hash, err := hashPassword(password) 95 | 96 | if err != nil { 97 | return nil, err 98 | } 99 | 100 | u = &User{ 101 | Email: email, 102 | FirstName: firstName, 103 | LastName: lastName, 104 | Password: hash, 105 | Admin: admin, 106 | } 107 | 108 | db.Create(u) 109 | 110 | return u, nil 111 | } 112 | 113 | // Update updates an existing user. 114 | func (u *User) Update(email, firstName, lastName, password string, admin bool) (*User, error) { 115 | u.FirstName = firstName 116 | u.LastName = lastName 117 | u.Email = email 118 | u.Admin = admin 119 | 120 | if password != "" { 121 | hash, err := hashPassword(password) 122 | 123 | if err != nil { 124 | return nil, err 125 | } 126 | 127 | u.Password = hash 128 | } 129 | 130 | db.Save(u) 131 | 132 | return u, nil 133 | } 134 | 135 | // Delete this user. 136 | func (u *User) Delete() error { 137 | db.Delete(u) 138 | 139 | return nil 140 | } 141 | 142 | // NewSession generates a session key and stores it. 143 | func (u *User) NewSession() string { 144 | for { 145 | key := generateSessionID(u.Email, sessionDictionary, sessionLength) 146 | 147 | _, err := GetUserBySession(key) 148 | 149 | if err != nil { 150 | u.Session = key 151 | db.Save(u) 152 | return u.Session 153 | } 154 | } 155 | } 156 | 157 | // NewAccessKey generates a access key and stores it. 158 | func (u *User) NewAccessKey() string { 159 | for { 160 | key := generateAccessKey(accessKeyDictionary, accessKeyLength) 161 | 162 | _, err := GetUserByAccessKey(key) 163 | 164 | if err != nil { 165 | u.AccessKey = key 166 | db.Save(u) 167 | return u.AccessKey 168 | } 169 | } 170 | } 171 | 172 | // generateSessionID generates a new session ID for a user combining the 173 | // email address and a random string. 174 | func generateSessionID(email, dictionary string, length int) string { 175 | var random = make([]byte, length) 176 | rand.Read(random) 177 | 178 | for k, v := range random { 179 | random[k] = dictionary[v%byte(len(dictionary))] 180 | } 181 | 182 | joined := strings.Join([]string{email, string(random)}, "") 183 | 184 | hash := sha512.New() 185 | hash.Write([]byte(joined)) 186 | 187 | return base64.StdEncoding.EncodeToString(hash.Sum(nil)) 188 | } 189 | 190 | // generateAccessKey generates a new access key for a user. 191 | func generateAccessKey(dictionary string, length int) string { 192 | var random = make([]byte, length) 193 | rand.Read(random) 194 | 195 | for k, v := range random { 196 | random[k] = dictionary[v%byte(len(dictionary))] 197 | } 198 | 199 | return string(random) 200 | } 201 | 202 | // HashPassword generates a hash using bcrypt. 203 | func hashPassword(password string) (string, error) { 204 | hash, err := bcrypt.GenerateFromPassword([]byte(password), 10) 205 | return string(hash), err 206 | } 207 | 208 | // ComparePassword returns true if the password matches the hash. 209 | func ComparePassword(password, hash string) bool { 210 | err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) 211 | 212 | if err != nil { 213 | return false 214 | } 215 | 216 | return true 217 | } 218 | -------------------------------------------------------------------------------- /database/user_test.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestHashComparePassword(t *testing.T) { 8 | pass := "asdf" 9 | 10 | h, err := hashPassword(pass) 11 | 12 | if err != nil { 13 | t.Error(err) 14 | } 15 | 16 | v := ComparePassword(pass, h) 17 | 18 | if v != true { 19 | t.Error("password did not match") 20 | } 21 | 22 | v = ComparePassword("foo", h) 23 | 24 | if v != false { 25 | t.Error("passwords did match") 26 | } 27 | } 28 | 29 | func TestCreateGetUpdateDelete(t *testing.T) { 30 | u, err := CreateUser("foo@bar.tld", "foo", "bar", "adsf", false) 31 | 32 | if err != nil { 33 | t.Error(err) 34 | } 35 | 36 | u2, err := GetUser("foo@bar.tld") 37 | 38 | if err != nil { 39 | t.Error(err) 40 | } 41 | 42 | if u.ID != u2.ID { 43 | t.Error("IDs do not match") 44 | } 45 | 46 | u3, err := u.Update("foo@bar.tld", "foo", "baz", "adsf", false) 47 | 48 | if err != nil { 49 | t.Error(err) 50 | } 51 | 52 | if u3.LastName == u2.LastName { 53 | t.Error("Name not updated") 54 | } 55 | 56 | u.Delete() 57 | 58 | _, err = GetUser("foo@bar.tld") 59 | 60 | if err == nil { 61 | t.Error("User found, should be deleted.") 62 | } 63 | } 64 | 65 | func TestListUsers(t *testing.T) { 66 | db.Exec("DELETE FROM users WHERE id > 0") 67 | CreateUser("foo1@bar.tld", "foo", "bar", "adsf", false) 68 | CreateUser("foo2@bar.tld", "foo", "bar", "adsf", false) 69 | CreateUser("foo3@bar.tld", "foo", "bar", "adsf", false) 70 | 71 | users := ListUsers() 72 | 73 | if len(users) != 3 { 74 | t.Error("Wrong user count", len(users)) 75 | } 76 | } 77 | 78 | func TestGetUserBySession(t *testing.T) { 79 | db.Exec("DELETE FROM users WHERE id > 0") 80 | user, _ := CreateUser("foo1@bar.tld", "foo", "bar", "adsf", false) 81 | user.NewSession() 82 | 83 | _, err := GetUserBySession("asdf") 84 | 85 | if err == nil { 86 | t.Error("Got user with incorrect session.") 87 | } 88 | 89 | got, _ := GetUserBySession(user.Session) 90 | 91 | if got.ID != user.ID { 92 | t.Error("Got wrong user") 93 | } 94 | } 95 | 96 | func TestGetUserbyID(t *testing.T) { 97 | db.Exec("DELETE FROM users WHERE id > 0") 98 | user, _ := CreateUser("foo1@bar.tld", "foo", "bar", "adsf", false) 99 | 100 | _, err := GetUserByID(9999999) 101 | 102 | if err == nil { 103 | t.Error("Found user with impossible ID") 104 | } 105 | 106 | _, err = GetUserByID(user.ID) 107 | 108 | if err != nil { 109 | t.Error(err) 110 | } 111 | } 112 | 113 | func TestNewAccessKey(t *testing.T) { 114 | NewInMemoryDatabase() 115 | user, _ := CreateUser("foo@bar.tld", "foo", "bar", "adsf", false) 116 | 117 | key := user.NewAccessKey() 118 | 119 | user2, err := GetUserByAccessKey(key) 120 | 121 | if err != nil { 122 | t.Error(err) 123 | } 124 | 125 | if user.ID != user2.ID { 126 | t.Error("Got the wrong user.") 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /docs/buildscripts.md: -------------------------------------------------------------------------------- 1 | # Buildscripts 2 | Leeroy requires you to bring your own build scripts. This is actually pretty easy and allows you to run whatever you want. The output of your scripts will be saved to the build log and displayed on the webinterface. 3 | 4 | If your scripts work when you run them manually it should also "just work" with Leeroy. For GitHub you have to make sure to add your SSH key to the deployment keys of your repository if it is a private one. 5 | 6 | Your scripts get two arguments. The first one is the repository URL, the second one the branch name to which was pushed. 7 | 8 | ## Django 9 | This is a script I use to run tests for a Django project. 10 | 11 | #! /bin/bash 12 | cd /home/ec2-user/test 13 | git fetch 14 | git checkout $2 15 | git pull 16 | source /home/ec2-user/test/.env 17 | python /home/ec2-user/test/manage.py test -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | ## Configuring Notifications 2 | Setting up notifications is relatively easy, you just have to make sure to add all arguments a service expects and stick with the format `key:::value:::::key::value`. 3 | 4 | ##### Email 5 | No need for any arguments. It will use the mailserver that is already configured. 6 | 7 | ##### Slack 8 | Slack expects two arguments: 9 | 10 | - `channel` channel to post to 11 | - `endpoint` your Slack notification endpoint 12 | 13 | ##### HipChat 14 | HipChat expects two arguments: 15 | 16 | - `channel` channel to post to 17 | - `key` access key for HipChat 18 | 19 | ##### Campfire 20 | Campfire expects three arguments: 21 | 22 | - `id` your Campfire ID 23 | - `room` room to post to 24 | - `key` access key for Campfire 25 | 26 | ## SSL 27 | If you want to use a self-signed certificate make sure to disable GitHubs SSL verification. You can generate a certificate and key with the following command 28 | 29 | openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout leeroy.key -out leeroy.crt 30 | 31 | You can add it in `Admin - Leeroy Config` if you did not setup SSL during the initial setup. You have to restart Leeroy to activate SSL. -------------------------------------------------------------------------------- /docs/deploymentscripts.md: -------------------------------------------------------------------------------- 1 | # Deployment scripts 2 | Leeroy requires you to bring your own deployment scripts. This is actually pretty easy and allows you to run whatever you want. The output of your scripts will be saved to the build log and displayed on the webinterface. 3 | 4 | If your scripts work when you run them manually it should also "just work" with Leeroy. 5 | 6 | Your scripts get two arguments. The first one is the repository URL, the second one the branch name to which was pushed. 7 | 8 | ## Deploying to AWS Elastic Beanstalk 9 | The following scripts demonstrates one way to deploy to AWS EB 10 | 11 | #! /bin/bash 12 | cd /home/ec2-user/test-deploy 13 | git fetch 14 | get reset --hard origin/master 15 | git aws.push 16 | -------------------------------------------------------------------------------- /docs/success.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fallenhitokiri/leeroyci/cd8a5fb95f63d53385d803439b0b48de70105119/docs/success.png -------------------------------------------------------------------------------- /github/api.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "net/http" 7 | ) 8 | 9 | type github interface { 10 | // makeRequest handles HTTP requests to GitHubs API. 11 | // If the API endpoint does not expect any information nil should be passed as payload. 12 | makeRequest(method string, url string, token string, payload []byte) ([]byte, error) 13 | } 14 | 15 | type githubAPI struct{} 16 | 17 | // makeRequest handles HTTP requests to GitHubs API. 18 | // If the API endpoint does not expect any information nil should be passed as payload. 19 | func (g githubAPI) makeRequest(method string, url string, token string, payload []byte) ([]byte, error) { 20 | r, err := http.NewRequest(method, url, bytes.NewReader(payload)) 21 | 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | addHeaders(token, r) 27 | 28 | c := http.Client{} 29 | 30 | re, err := c.Do(r) 31 | 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | defer re.Body.Close() 37 | 38 | b, err := ioutil.ReadAll(re.Body) 39 | 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | return b, nil 45 | } 46 | -------------------------------------------------------------------------------- /github/api_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | ) 8 | 9 | type githubMock struct { 10 | method string 11 | url string 12 | token string 13 | payload []byte 14 | } 15 | 16 | func (g *githubMock) makeRequest(method string, url string, token string, payload []byte) ([]byte, error) { 17 | g.method = method 18 | g.url = url 19 | g.token = token 20 | g.payload = payload 21 | 22 | return nil, nil 23 | } 24 | 25 | func TestGithubAPIMakeRequest(t *testing.T) { 26 | var request *http.Request 27 | 28 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 29 | request = r 30 | })) 31 | defer ts.Close() 32 | 33 | githubAPI{}.makeRequest("POST", ts.URL, "foo", nil) 34 | 35 | if request.Header["Authorization"][0] != "token foo" { 36 | t.Error("Wrong auth token", request.Header["Authorization"][0]) 37 | } 38 | 39 | if request.Method != "POST" { 40 | t.Error("Wrong request method", request.Method) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /github/handle.go: -------------------------------------------------------------------------------- 1 | // Package github integrates everything necessary to test commits, comment on 2 | // pull requests and close them if the build failed. 3 | package github 4 | 5 | import ( 6 | "log" 7 | "net/http" 8 | ) 9 | 10 | // Handle checks if we are dealing with a pull request or a commit and either 11 | // creates a new job in the queue or a PR watcher. 12 | func Handle(req *http.Request) { 13 | event := req.Header.Get("X-Github-Event") 14 | 15 | switch event { 16 | case "push": 17 | handlePush(req) 18 | case "pull_request": 19 | handlePR(req) 20 | default: 21 | log.Println("event not supported", event) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /github/pr.go: -------------------------------------------------------------------------------- 1 | // Package github integrates everything necessary to test commits, comment on 2 | // pull requests and close them if the build failed. 3 | package github 4 | 5 | import ( 6 | "encoding/json" 7 | "io/ioutil" 8 | "log" 9 | "net/http" 10 | "time" 11 | 12 | "github.com/fallenhitokiri/leeroyci/database" 13 | ) 14 | 15 | type pullRequestCallback struct { 16 | Number int 17 | Action string 18 | PR pullRequest `json:"pull_request"` 19 | } 20 | 21 | type pullRequest struct { 22 | URL string `json:"url"` 23 | State string `json:"state"` 24 | CommentsURL string `json:"comments_url"` 25 | StatusURL string `json:"statuses_url"` 26 | Head pullRequestCommit 27 | } 28 | 29 | type pullRequestCommit struct { 30 | Commit string `json:"sha"` 31 | Repository pullRequestRepository `json:"repo"` 32 | } 33 | 34 | type pullRequestRepository struct { 35 | HTMLURL string `json:"html_url"` 36 | } 37 | 38 | func (p *pullRequestCallback) repositoryURL() string { 39 | return p.PR.Head.Repository.HTMLURL 40 | } 41 | 42 | func (p *pullRequestCallback) updatePR() { 43 | for { 44 | if p.isCurrent() == false { 45 | log.Println("not current") 46 | return 47 | } 48 | 49 | job := database.GetJobByCommit(p.PR.Head.Commit) 50 | 51 | if job.ID == 0 { 52 | time.Sleep(30 * time.Second) 53 | continue 54 | } 55 | 56 | nilTime := time.Time{} 57 | if !job.TasksFinished.After(nilTime) { 58 | time.Sleep(10 * time.Second) 59 | continue 60 | } 61 | 62 | repository, err := database.GetRepositoryByID(job.RepositoryID) 63 | 64 | if err != nil { 65 | log.Println(err) 66 | return 67 | } 68 | 69 | if repository.ClosePR && job.Passed() == false { 70 | closePR(job, repository, p.PR.URL, githubAPI{}) 71 | } 72 | 73 | return 74 | } 75 | } 76 | 77 | func (p *pullRequestCallback) isCurrent() bool { 78 | repo := database.GetRepository(p.repositoryURL()) 79 | response, err := githubAPI{}.makeRequest("GET", p.PR.URL, repo.AccessKey, nil) 80 | 81 | if err != nil { 82 | return false 83 | } 84 | 85 | var pr pullRequest 86 | err = json.Unmarshal(response, &pr) 87 | 88 | if err != nil { 89 | return false 90 | } 91 | 92 | if pr.Head.Commit != p.PR.Head.Commit { 93 | return false 94 | } 95 | 96 | if pr.State != "open" { 97 | return false 98 | } 99 | 100 | return true 101 | } 102 | 103 | func handlePR(req *http.Request) { 104 | body, err := ioutil.ReadAll(req.Body) 105 | 106 | if err != nil { 107 | log.Println(err) 108 | return 109 | } 110 | 111 | var callback pullRequestCallback 112 | 113 | err = json.Unmarshal(body, &callback) 114 | 115 | if err != nil { 116 | log.Println("Could not unmarshal request") 117 | return 118 | } 119 | 120 | if callback.Action != "closed" { 121 | go callback.updatePR() 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /github/push.go: -------------------------------------------------------------------------------- 1 | // Package github integrates everything necessary to test commits, comment on 2 | // pull requests and close them if the build failed. 3 | package github 4 | 5 | import ( 6 | "encoding/json" 7 | "io/ioutil" 8 | "log" 9 | "net/http" 10 | "strings" 11 | 12 | "github.com/fallenhitokiri/leeroyci/database" 13 | "github.com/fallenhitokiri/leeroyci/runner" 14 | ) 15 | 16 | type pushCallback struct { 17 | Ref string 18 | After string 19 | Before string 20 | Created bool 21 | Deleted bool 22 | Forced bool 23 | Compare string 24 | Commits []pushCommit 25 | HeadCommit pushCommit `json:"head_commit"` 26 | Repository pushRepository 27 | Pusher pushUser 28 | } 29 | 30 | type pushCommit struct { 31 | ID string 32 | Distinct bool 33 | Message string 34 | Timestamp string 35 | URL string 36 | Author pushUser 37 | Committer pushUser 38 | Added []string 39 | Removed []string 40 | Modified []string 41 | } 42 | 43 | type pushUser struct { 44 | Name string 45 | Email string 46 | } 47 | 48 | type pushRepository struct { 49 | ID int64 50 | Name string 51 | URL string 52 | Description string 53 | CreatedAt int64 `json:"created_at"` 54 | PushedAt int64 `json:"pushed_at"` 55 | StatusURL string `json:"statuses_url"` 56 | } 57 | 58 | // repositoryURL returns the URL for the repository 59 | func (p *pushCallback) repositoryURL() string { 60 | return p.Repository.URL 61 | } 62 | 63 | // branch returns the name of the branch. 64 | func (p *pushCallback) branch() string { 65 | s := strings.Split(p.Ref, "/") 66 | return s[2] 67 | } 68 | 69 | // returns the ID of the head commit. 70 | func (p *pushCallback) commit() string { 71 | return p.HeadCommit.ID 72 | } 73 | 74 | // commitURL returns the URL to the head commit. 75 | func (p *pushCallback) commitURL() string { 76 | return p.HeadCommit.URL 77 | } 78 | 79 | // name returns the name of the git user. 80 | func (p *pushCallback) name() string { 81 | return p.Pusher.Name 82 | } 83 | 84 | // email returns the email of the git user. 85 | func (p *pushCallback) email() string { 86 | return p.Pusher.Email 87 | } 88 | 89 | // shouldRun returns if this push should create a job. 90 | func (p *pushCallback) shouldRun() bool { 91 | if p.Deleted == true { 92 | return false 93 | } 94 | return true 95 | } 96 | 97 | func (p *pushCallback) statusURL() string { 98 | return strings.Replace(p.Repository.StatusURL, "{sha}", p.commit(), 1) 99 | } 100 | 101 | // createJob adds a new job to the database. 102 | func (p *pushCallback) createJob() error { 103 | if p.shouldRun() == false { 104 | log.Println("Not adding", p.repositoryURL(), p.branch()) 105 | return nil 106 | } 107 | 108 | repo := database.GetRepository(p.repositoryURL()) 109 | 110 | job := database.CreateJob( 111 | repo, 112 | p.branch(), 113 | p.commit(), 114 | p.commitURL(), 115 | p.name(), 116 | p.email(), 117 | ) 118 | 119 | status := make(chan bool, 1) 120 | 121 | queueJob := runner.QueueJob{ 122 | JobID: job.ID, 123 | Status: status, 124 | } 125 | 126 | queueJob.Enqueue() 127 | 128 | if repo.StatusPR { 129 | <-status 130 | job = database.GetJob(job.ID) 131 | postStatus(job, repo, p.statusURL(), githubAPI{}) 132 | } 133 | 134 | return nil 135 | } 136 | 137 | func handlePush(req *http.Request) { 138 | body, err := ioutil.ReadAll(req.Body) 139 | 140 | if err != nil { 141 | log.Println(err) 142 | return 143 | } 144 | 145 | var callback pushCallback 146 | 147 | err = json.Unmarshal(body, &callback) 148 | 149 | if err != nil { 150 | log.Println("Could not unmarshal request") 151 | return 152 | } 153 | 154 | go callback.createJob() 155 | } 156 | -------------------------------------------------------------------------------- /github/request.go: -------------------------------------------------------------------------------- 1 | // Package github integrates everything necessary to test commits, comment on 2 | // pull requests and close them if the build failed. 3 | package github 4 | 5 | import ( 6 | "encoding/json" 7 | "log" 8 | "net/http" 9 | 10 | "github.com/fallenhitokiri/leeroyci/database" 11 | ) 12 | 13 | var ( 14 | statusSuccess = 1 15 | statusFailed = 2 16 | ) 17 | 18 | // Payload to update / close a PR / commit. 19 | type commitStatus struct { 20 | State string `json:"state"` 21 | TargetURL string `json:"target_url"` 22 | Description string `json:"description"` 23 | Context string `json:"context"` 24 | } 25 | 26 | // status messages linked to their status code. 27 | var statusMessages = map[int]map[string]string{ 28 | statusSuccess: map[string]string{ 29 | "state": "success", 30 | "description": "Build successful", 31 | }, 32 | statusFailed: map[string]string{ 33 | "state": "failure", 34 | "description": "Build failed", 35 | }, 36 | } 37 | 38 | type update struct { 39 | State string `json:"state"` 40 | } 41 | 42 | // newStatus returns a status struct with the correct URL and messages. 43 | func newStatus(job *database.Job) *commitStatus { 44 | state := statusSuccess 45 | 46 | if !job.Passed() { 47 | state = statusFailed 48 | } 49 | 50 | return &commitStatus{ 51 | State: statusMessages[state]["state"], 52 | TargetURL: job.URL(), 53 | Description: statusMessages[state]["description"], 54 | Context: "continuous-integration/leeeroyci", 55 | } 56 | } 57 | 58 | func postStatus(job *database.Job, repo *database.Repository, URL string, api github) { 59 | status := newStatus(job) 60 | payload, err := json.Marshal(&status) 61 | 62 | if err != nil { 63 | log.Println(err) 64 | return 65 | } 66 | 67 | _, err = api.makeRequest("POST", URL, repo.AccessKey, payload) 68 | 69 | if err != nil { 70 | log.Println(err) 71 | } 72 | } 73 | 74 | func closePR(job *database.Job, repo *database.Repository, URL string, api github) { 75 | status := newStatus(job) 76 | status.State = "closed" 77 | payload, err := json.Marshal(&status) 78 | 79 | if err != nil { 80 | log.Println(err) 81 | return 82 | } 83 | 84 | _, err = api.makeRequest("PATCH", URL, repo.AccessKey, payload) 85 | 86 | if err != nil { 87 | log.Println(err) 88 | } 89 | } 90 | 91 | // AddHeaders adds all headers to a request to conform to GitHubs API. 92 | // token is the API token that will be used for the request. 93 | func addHeaders(token string, req *http.Request) { 94 | req.Header.Add("content-type", "application/json") 95 | req.Header.Add("Authorization", "token "+token) 96 | } 97 | -------------------------------------------------------------------------------- /github/request_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/fallenhitokiri/leeroyci/database" 8 | ) 9 | 10 | func TestAddHeaders(t *testing.T) { 11 | r, _ := http.NewRequest("GET", "foo", nil) 12 | addHeaders("foo", r) 13 | 14 | if r.Header["Authorization"][0] != "token foo" { 15 | t.Error("Wrong authorization headers ", r.Header["Authorization"][0]) 16 | } 17 | } 18 | 19 | func TestNewStatusAPI(t *testing.T) { 20 | database.NewInMemoryDatabase() 21 | repo, _ := database.CreateRepository("name", "url", "accessKey", false, false) 22 | j1 := database.CreateJob(repo, "branch", "commit", "commitURL", "name", "email") 23 | j2 := database.CreateJob(repo, "branch", "commit", "commitURL", "name", "email") 24 | com, _ := database.CreateCommand(repo, "", "", "", database.CommandKindBuild) 25 | database.CreateCommandLog(com, j2, "1", "foo") 26 | 27 | passed := newStatus(j1) 28 | failed := newStatus(j2) 29 | 30 | if passed.State != statusMessages[statusSuccess]["state"] { 31 | t.Error("Job did not pass") 32 | } 33 | 34 | if failed.State != statusMessages[statusFailed]["state"] { 35 | t.Error("Job did pass") 36 | } 37 | } 38 | 39 | func TestPostStatus(t *testing.T) { 40 | database.NewInMemoryDatabase() 41 | repo, _ := database.CreateRepository("name", "url", "accessKey", false, false) 42 | job := database.CreateJob(repo, "branch", "commit", "commitURL", "name", "email") 43 | api := &githubMock{} 44 | postStatus(job, repo, "foo", api) 45 | 46 | if api.method != "POST" { 47 | t.Error("Wrong method", api.method) 48 | } 49 | 50 | if api.token != "accessKey" { 51 | t.Error("Wrong access key", api.token) 52 | } 53 | } 54 | 55 | func TestClosePR(t *testing.T) { 56 | database.NewInMemoryDatabase() 57 | repo, _ := database.CreateRepository("name", "url", "accessKey", false, false) 58 | job := database.CreateJob(repo, "branch", "commit", "commitURL", "name", "email") 59 | api := &githubMock{} 60 | closePR(job, repo, "foo", api) 61 | 62 | if api.method != "PATCH" { 63 | t.Error("Wrong method", api.method) 64 | } 65 | 66 | if api.token != "accessKey" { 67 | t.Error("Wrong access key", api.token) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /leeroy.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "os" 7 | "time" 8 | 9 | "github.com/fallenhitokiri/leeroyci/database" 10 | "github.com/fallenhitokiri/leeroyci/runner" 11 | "github.com/fallenhitokiri/leeroyci/web" 12 | "github.com/fallenhitokiri/leeroyci/websocket" 13 | ) 14 | 15 | func main() { 16 | database.NewDatabase("", "") 17 | websocket.NewServer() 18 | go runner.Runner() 19 | 20 | router := web.Routes() 21 | config := database.GetConfig() 22 | 23 | httpd := &http.Server{ 24 | Addr: port(), 25 | Handler: router, 26 | ReadTimeout: 10 * time.Second, 27 | WriteTimeout: 10 * time.Second, 28 | MaxHeaderBytes: 1 << 20, 29 | } 30 | 31 | if config.Cert != "" { 32 | log.Fatalln(httpd.ListenAndServeTLS(config.Cert, config.Key)) 33 | } else { 34 | log.Fatalln(httpd.ListenAndServe()) 35 | } 36 | } 37 | 38 | func port() string { 39 | port := os.Getenv("PORT") 40 | 41 | if port == "" { 42 | return ":8082" 43 | } 44 | 45 | return ":" + port 46 | } 47 | -------------------------------------------------------------------------------- /leeroy_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestPort(t *testing.T) { 9 | existing := os.Getenv("PORT") 10 | os.Setenv("PORT", "") 11 | 12 | if port() != ":8082" { 13 | t.Error("Wrong port", port()) 14 | } 15 | 16 | os.Setenv("PORT", "1234") 17 | 18 | if port() != ":1234" { 19 | t.Error("Wrong port", port()) 20 | } 21 | 22 | os.Setenv("PORT", existing) 23 | } 24 | -------------------------------------------------------------------------------- /notification/campfire.go: -------------------------------------------------------------------------------- 1 | // Package notification handles all notifications for a job. This includes 2 | // build and deployment notifications. 3 | package notification 4 | 5 | import ( 6 | "bytes" 7 | "encoding/json" 8 | "fmt" 9 | "log" 10 | "net/http" 11 | 12 | "github.com/fallenhitokiri/leeroyci/database" 13 | ) 14 | 15 | var campfireEndpoint = "https://%s.campfirenow.com/room/%s/speak.json" 16 | 17 | // Payload Campfire expects to be POSTed to their API. 18 | type campfirePayload struct { 19 | Message *campfireMessage `json:"message"` 20 | } 21 | 22 | // Message part of the payload Campfire expects. 23 | type campfireMessage struct { 24 | Body string `json:"body"` 25 | } 26 | 27 | // sendCampfire sends a notification to Campfire 28 | func sendCampfire(job *database.Job, event string) { 29 | payload, err := payloadCampfire(job, event) 30 | 31 | if err != nil { 32 | log.Println(err) 33 | return 34 | } 35 | 36 | not, _ := database.GetNotificationForRepoAndType( 37 | &job.Repository, 38 | database.NotificationServiceCampfire, 39 | ) 40 | 41 | id, err := not.GetConfigValue("id") 42 | 43 | if err != nil { 44 | log.Println(err) 45 | return 46 | } 47 | 48 | room, err := not.GetConfigValue("room") 49 | 50 | if err != nil { 51 | log.Println(err) 52 | return 53 | } 54 | 55 | endpoint := endpointCampfire(id, room) 56 | 57 | key, err := not.GetConfigValue("key") 58 | 59 | if err != nil { 60 | log.Println(err) 61 | return 62 | } 63 | 64 | request := requestCampfire(endpoint, key, payload) 65 | client := &http.Client{} 66 | 67 | _, err = client.Do(request) 68 | 69 | if err != nil { 70 | log.Println(err) 71 | } 72 | } 73 | 74 | // Build the payload to send to Campfire. 75 | func payloadCampfire(job *database.Job, event string) ([]byte, error) { 76 | msg := message(job, database.NotificationServiceCampfire, event, TypeText) 77 | 78 | p := campfirePayload{ 79 | Message: &campfireMessage{ 80 | Body: msg, 81 | }, 82 | } 83 | 84 | return json.Marshal(p) 85 | } 86 | 87 | // Build the endpoint for campfire 88 | func endpointCampfire(id, room string) string { 89 | return fmt.Sprintf(campfireEndpoint, id, room) 90 | } 91 | 92 | // Build the request for the campfire API. 93 | func requestCampfire(endpoint string, key string, payload []byte) *http.Request { 94 | req, err := http.NewRequest("POST", endpoint, bytes.NewReader(payload)) 95 | 96 | if err != nil { 97 | log.Fatal(err) 98 | } 99 | 100 | // There is no need for a password. Campire API documentation suggests 101 | // to use X so a password is present in case a component of the 102 | // implementation has problems without one. 103 | req.SetBasicAuth(key, "X") 104 | req.Header.Add("Content-Type", "application/json") 105 | 106 | return req 107 | } 108 | -------------------------------------------------------------------------------- /notification/campfire_test.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/fallenhitokiri/leeroyci/database" 10 | ) 11 | 12 | func TestEndpointCampfire(t *testing.T) { 13 | end := endpointCampfire("1", "2") 14 | 15 | if end != "https://1.campfirenow.com/room/2/speak.json" { 16 | t.Error("Wrong endpoint", end) 17 | } 18 | } 19 | 20 | func TestPayloadCampfire(t *testing.T) { 21 | repo, _ := database.CreateRepository("repo", "", "", false, false) 22 | job := database.CreateJob(repo, "branch", "bar", "commit URL", "name", "email") 23 | 24 | pay, err := payloadCampfire(job, EventBuild) 25 | 26 | if err != nil { 27 | t.Error(err.Error()) 28 | } 29 | 30 | if !strings.Contains(string(pay), "repo") { 31 | t.Error("Wrong payload", string(pay)) 32 | } 33 | } 34 | 35 | func TestRequestCampfire(t *testing.T) { 36 | r := requestCampfire("foo", "bar", []byte("baz")) 37 | 38 | if r.Method != "POST" { 39 | t.Error("Wrong method ", r.Method) 40 | } 41 | 42 | u, _, _ := r.BasicAuth() 43 | 44 | if u != "bar" { 45 | t.Error("Wrong username", u) 46 | } 47 | } 48 | 49 | func TestSendCampfire(t *testing.T) { 50 | database.NewInMemoryDatabase() 51 | repo, _ := database.CreateRepository("repo", "", "", false, false) 52 | job := database.CreateJob(repo, "branch", "bar", "commit URL", "name", "email") 53 | database.CreateNotification( 54 | database.NotificationServiceCampfire, 55 | "id:::foo:::::room:::bar:::::key:::baz", 56 | repo, 57 | ) 58 | 59 | var request *http.Request 60 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 61 | request = r 62 | })) 63 | defer ts.Close() 64 | 65 | campfireEndpoint = ts.URL + "/%s/%s" 66 | 67 | sendCampfire(job, EventBuild) 68 | 69 | if request.URL.Path != "/foo/bar" { 70 | t.Error("Wrong URL path", request.URL.Path) 71 | } 72 | 73 | if request.Header["Authorization"][0] != "Basic YmF6Olg=" { 74 | t.Error("Wrong auth token", request.Header["Authorization"][0]) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /notification/email.go: -------------------------------------------------------------------------------- 1 | // Package notification handles all notifications for a job. This includes 2 | // build and deployment notifications. 3 | package notification 4 | 5 | import ( 6 | "fmt" 7 | "log" 8 | "net/mail" 9 | 10 | "github.com/jpoehls/gophermail" 11 | 12 | "github.com/fallenhitokiri/leeroyci/database" 13 | ) 14 | 15 | // sendEmail sends an email notification. Event specifies which notification to 16 | // send. Valid choices are EVENT_ (see template.go). 17 | func sendEmail(job *database.Job, event string) { 18 | mailServer := database.GetMailServer() 19 | 20 | htmlMessage := message(job, database.NotificationServiceEmail, event, TypeHTML) 21 | txtMessage := message(job, database.NotificationServiceEmail, event, TypeText) 22 | subject := emailSubject(job, event) 23 | recipient := mail.Address{ 24 | Name: job.Name, 25 | Address: job.Email, 26 | } 27 | 28 | message := gophermail.Message{ 29 | From: mailServer.From(), 30 | To: []mail.Address{recipient}, 31 | Subject: subject, 32 | Body: txtMessage, 33 | HTMLBody: htmlMessage, 34 | } 35 | 36 | err := gophermail.SendMail(mailServer.Server(), mailServer.Auth(), &message) 37 | 38 | if err != nil { 39 | log.Println(err) 40 | } 41 | } 42 | 43 | // emailSubject returns the subject for an email. 44 | func emailSubject(job *database.Job, event string) string { 45 | if event == EventBuild { 46 | return fmt.Sprintf("%s/%s build", job.Repository.Name, job.Branch) 47 | } 48 | 49 | if event == EventTest { 50 | return fmt.Sprintf("%s/%s tests", job.Repository.Name, job.Branch) 51 | } 52 | 53 | if event == EventDeployStart { 54 | return fmt.Sprintf("%s/%s deployment started", job.Repository.Name, job.Branch) 55 | } 56 | 57 | if event == EventDeployEnd { 58 | return fmt.Sprintf("%s/%s deploy %s", job.Repository.Name, job.Branch, job.Status()) 59 | } 60 | 61 | return "LeeroyCI is confused - not sure which message this is." 62 | } 63 | -------------------------------------------------------------------------------- /notification/email_test.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/fallenhitokiri/leeroyci/database" 7 | ) 8 | 9 | func TestEmailSubject(t *testing.T) { 10 | repo, _ := database.CreateRepository("repo", "bar", "accessKey", false, false) 11 | job := database.CreateJob(repo, "branch", "1234", "commitURL", "foo", "bar") 12 | job.TasksDone() 13 | 14 | build := emailSubject(job, EventBuild) 15 | test := emailSubject(job, EventTest) 16 | deployStart := emailSubject(job, EventDeployStart) 17 | deployEnd := emailSubject(job, EventDeployEnd) 18 | 19 | if build != "repo/branch build" { 20 | t.Error("Wrong message", build) 21 | } 22 | 23 | if test != "repo/branch tests" { 24 | t.Error("Wrong message", test) 25 | } 26 | 27 | if deployStart != "repo/branch deployment started" { 28 | t.Error("Wrong message", deployStart) 29 | } 30 | 31 | if deployEnd != "repo/branch deploy success" { 32 | t.Error("Wrong message", deployEnd) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /notification/hipchat.go: -------------------------------------------------------------------------------- 1 | // Package notification handles all notifications for a job. This includes 2 | // build and deployment notifications. 3 | package notification 4 | 5 | import ( 6 | "bytes" 7 | "fmt" 8 | "log" 9 | "net/http" 10 | "net/url" 11 | 12 | "github.com/fallenhitokiri/leeroyci/database" 13 | ) 14 | 15 | var hipchatEndpoint = "https://www.hipchat.com/v1/rooms/message?auth_token=%s" 16 | 17 | // Payload HipChat expects to be POSTed to the API. 18 | type hipchatPayload struct { 19 | Room string 20 | From string 21 | Color string 22 | Message string 23 | Notify bool 24 | Format string 25 | Status bool 26 | } 27 | 28 | // HipChat expects www-form-urlencoded - prepare the struct. 29 | func (h *hipchatPayload) toURLEncoded() []byte { 30 | d := url.Values{} 31 | d.Add("room_id", h.Room) 32 | d.Add("from", h.From) 33 | d.Add("message", h.Message) 34 | d.Add("message_format", h.Format) 35 | 36 | if h.Notify == true { 37 | d.Add("notify", "1") 38 | } else { 39 | d.Add("notify", "2") 40 | } 41 | 42 | if h.Status == true { 43 | d.Add("color", "green") 44 | } else { 45 | d.Add("color", "red") 46 | } 47 | 48 | return []byte(d.Encode()) 49 | } 50 | 51 | func sendHipchat(job *database.Job, event string) { 52 | not, _ := database.GetNotificationForRepoAndType( 53 | &job.Repository, 54 | database.NotificationServiceHipchat, 55 | ) 56 | 57 | channel, err := not.GetConfigValue("channel") 58 | 59 | if err != nil { 60 | log.Println(err) 61 | return 62 | } 63 | 64 | payload := payloadHipchat(job, event, channel) 65 | 66 | key, err := not.GetConfigValue("key") 67 | 68 | if err != nil { 69 | log.Println(err) 70 | return 71 | } 72 | 73 | endpoint := endpointHipChat(key) 74 | 75 | _, err = http.Post( 76 | endpoint, 77 | "application/x-www-form-urlencoded", 78 | bytes.NewReader(payload.toURLEncoded()), 79 | ) 80 | 81 | if err != nil { 82 | log.Println(err) 83 | } 84 | } 85 | 86 | // Convert a job to a hipchat payload. 87 | func payloadHipchat(job *database.Job, event, channel string) hipchatPayload { 88 | msg := message(job, database.NotificationServiceHipchat, event, TypeText) 89 | 90 | return hipchatPayload{ 91 | Color: "green", 92 | Notify: true, 93 | Format: "text", 94 | Room: channel, 95 | From: "LeeroyCI", 96 | Message: msg, 97 | Status: job.Passed(), 98 | } 99 | } 100 | 101 | // Build the endpoint for HipChat 102 | func endpointHipChat(key string) string { 103 | return fmt.Sprintf(hipchatEndpoint, key) 104 | } 105 | -------------------------------------------------------------------------------- /notification/hipchat_test.go: -------------------------------------------------------------------------------- 1 | // Package notification handles all notifications for a job. This includes 2 | // build and deployment notifications. 3 | package notification 4 | 5 | import ( 6 | "net/http" 7 | "net/http/httptest" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/fallenhitokiri/leeroyci/database" 12 | ) 13 | 14 | func TestBuildHipChat(t *testing.T) { 15 | repo := database.Repository{ 16 | Name: "repo", 17 | } 18 | 19 | j := database.Job{ 20 | Repository: repo, 21 | Branch: "branch", 22 | Name: "name", 23 | Email: "email", 24 | } 25 | 26 | p := payloadHipchat(&j, "foo", "bar") 27 | 28 | if p.Room != "bar" { 29 | t.Error("Wrong room", p.Room) 30 | } 31 | } 32 | 33 | func TestToURLEncoded(t *testing.T) { 34 | h := hipchatPayload{ 35 | Room: "foo", 36 | From: "bar", 37 | Message: "baz", 38 | Notify: true, 39 | Format: "text", 40 | Status: true, 41 | } 42 | 43 | e := string(h.toURLEncoded()) 44 | 45 | if strings.Contains(e, "notify=1") == false { 46 | t.Error("Wrong notification settings") 47 | } 48 | 49 | if strings.Contains(e, "color=green") == false { 50 | t.Error("Wrong notification color") 51 | } 52 | 53 | h.Status = false 54 | h.Notify = false 55 | 56 | e = string(h.toURLEncoded()) 57 | 58 | if strings.Contains(e, "notify=2") == false { 59 | t.Error("Wrong notification settings") 60 | } 61 | 62 | if strings.Contains(e, "color=red") == false { 63 | t.Error("Wrong notification color") 64 | } 65 | } 66 | 67 | func TestEndpointHipChat(t *testing.T) { 68 | exp := "https://www.hipchat.com/v1/rooms/message?auth_token=foo" 69 | e := endpointHipChat("foo") 70 | 71 | if e != exp { 72 | t.Error("Wrong API endpoint ", e) 73 | } 74 | } 75 | 76 | func TestSendHipchat(t *testing.T) { 77 | database.NewInMemoryDatabase() 78 | repo, _ := database.CreateRepository("repo", "", "", false, false) 79 | job := database.CreateJob(repo, "branch", "bar", "commit URL", "name", "email") 80 | database.CreateNotification( 81 | database.NotificationServiceHipchat, 82 | "channel:::foo:::::key:::bar", 83 | repo, 84 | ) 85 | 86 | var request *http.Request 87 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 88 | request = r 89 | })) 90 | defer ts.Close() 91 | 92 | hipchatEndpoint = ts.URL + "/%s" 93 | 94 | sendHipchat(job, EventBuild) 95 | 96 | if request.URL.Path != "/bar" { 97 | t.Error("Wrong URL path", request.URL.Path) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /notification/messages/campfire-text-build.tmpl: -------------------------------------------------------------------------------- 1 | repository: {{ .Repository.Name }} 2 | branch: {{ .Branch }} 3 | {{ range .CommandLogs }} 4 | {{ .Name }}: {{ if .Passed }}success{{ else }}failed{{ end }} 5 | {{ end }} -------------------------------------------------------------------------------- /notification/messages/campfire-text-deploy-end.tmpl: -------------------------------------------------------------------------------- 1 | repository: {{ .Repository.Name }} 2 | branch: {{ .Branch }} 3 | 4 | deploy finished 5 | 6 | {{ range .CommandLogs }} 7 | {{ .Name }}: {{ if .Passed }}success{{ else }}failed{{ end }} 8 | {{ end }} -------------------------------------------------------------------------------- /notification/messages/campfire-text-deploy-start.tmpl: -------------------------------------------------------------------------------- 1 | repository: {{ .Repository.Name }} 2 | branch: {{ .Branch }} 3 | 4 | deploy started -------------------------------------------------------------------------------- /notification/messages/campfire-text-test.tmpl: -------------------------------------------------------------------------------- 1 | repository: {{ .Repository.Name }} 2 | branch: {{ .Branch }} 3 | {{ range .CommandLogs }} 4 | {{ .Name }}: {{ if .Passed }}success{{ else }}failed{{ end }} 5 | {{ end }} -------------------------------------------------------------------------------- /notification/messages/email-text-build.tmpl: -------------------------------------------------------------------------------- 1 | repository: {{ .Repository.Name }} 2 | branch: {{ .Branch }} 3 | {{ range .CommandLogs }} 4 | {{ .Name }}: {{ if .Passed }}success{{ else }}failed{{ end }} 5 | {{ end }} 6 | 7 | details: {{ .URL }} -------------------------------------------------------------------------------- /notification/messages/email-text-deploy-end.tmpl: -------------------------------------------------------------------------------- 1 | repository: {{ .Repository.Name }} 2 | branch: {{ .Branch }} 3 | 4 | deploy finished 5 | 6 | {{ range .CommandLogs }} 7 | {{ .Name }}: {{ if .Passed }}success{{ else }}failed{{ end }} 8 | {{ end }} 9 | 10 | details: {{ .URL }} -------------------------------------------------------------------------------- /notification/messages/email-text-deploy-start.tmpl: -------------------------------------------------------------------------------- 1 | repository: {{ .Repository.Name }} 2 | branch: {{ .Branch }} 3 | 4 | deploy started 5 | 6 | details: {{ .URL }} -------------------------------------------------------------------------------- /notification/messages/email-text-test.tmpl: -------------------------------------------------------------------------------- 1 | repository: {{ .Repository.Name }} 2 | branch: {{ .Branch }} 3 | {{ range .CommandLogs }} 4 | {{ .Name }}: {{ if .Passed }}success{{ else }}failed{{ end }} 5 | {{ end }} 6 | 7 | details: {{ .URL }} -------------------------------------------------------------------------------- /notification/messages/hipchat-text-build.tmpl: -------------------------------------------------------------------------------- 1 | repository: {{ .Repository.Name }} 2 | branch: {{ .Branch }} 3 | {{ range .CommandLogs }} 4 | {{ .Name }}: {{ if .Passed }}success{{ else }}failed{{ end }} 5 | {{ end }} -------------------------------------------------------------------------------- /notification/messages/hipchat-text-deploy-end.tmpl: -------------------------------------------------------------------------------- 1 | repository: {{ .Repository.Name }} 2 | branch: {{ .Branch }} 3 | 4 | deploy finished 5 | 6 | {{ range .CommandLogs }} 7 | {{ .Name }}: {{ if .Passed }}success{{ else }}failed{{ end }} 8 | {{ end }} -------------------------------------------------------------------------------- /notification/messages/hipchat-text-deploy-start.tmpl: -------------------------------------------------------------------------------- 1 | repository: {{ .Repository.Name }} 2 | branch: {{ .Branch }} 3 | 4 | deploy started -------------------------------------------------------------------------------- /notification/messages/hipchat-text-test.tmpl: -------------------------------------------------------------------------------- 1 | repository: {{ .Repository.Name }} 2 | branch: {{ .Branch }} 3 | {{ range .CommandLogs }} 4 | {{ .Name }}: {{ if .Passed }}success{{ else }}failed{{ end }} 5 | {{ end }} -------------------------------------------------------------------------------- /notification/messages/slack-text-build.tmpl: -------------------------------------------------------------------------------- 1 | repository: {{ .Repository.Name }} 2 | branch: {{ .Branch }} 3 | {{ range .CommandLogs }} 4 | {{ .Name }}: {{ if .Passed }}success{{ else }}failed{{ end }} 5 | {{ end }} 6 | -------------------------------------------------------------------------------- /notification/messages/slack-text-deploy-end.tmpl: -------------------------------------------------------------------------------- 1 | repository: {{ .Repository.Name }} 2 | branch: {{ .Branch }} 3 | 4 | deploy finished 5 | 6 | {{ range .CommandLogs }} 7 | {{ .Name }}: {{ if .Passed }}success{{ else }}failed{{ end }} 8 | {{ end }} 9 | -------------------------------------------------------------------------------- /notification/messages/slack-text-deploy-start.tmpl: -------------------------------------------------------------------------------- 1 | repository: {{ .Repository.Name }} 2 | branch: {{ .Branch }} 3 | 4 | deploy started 5 | 6 | {{ range .CommandLogs }} 7 | {{ .Name }}: {{ if .Passed }}success{{ else }}failed{{ end }} 8 | {{ end }} 9 | -------------------------------------------------------------------------------- /notification/messages/slack-text-test.tmpl: -------------------------------------------------------------------------------- 1 | repository: {{ .Repository.Name }} 2 | branch: {{ .Branch }} 3 | {{ range .CommandLogs }} 4 | {{ .Name }}: {{ if .Passed }}success{{ else }}failed{{ end }} 5 | {{ end }} 6 | -------------------------------------------------------------------------------- /notification/notify.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/fallenhitokiri/leeroyci/database" 7 | ) 8 | 9 | // Notify sends all relevant notifications for a job that are configured for 10 | // the jobs repository. 11 | func Notify(job *database.Job, event string) { 12 | repo, err := database.GetRepositoryByID(job.RepositoryID) 13 | 14 | if err != nil { 15 | log.Println(err) 16 | return 17 | } 18 | 19 | sendWebsocket(job, event) 20 | 21 | for _, notificaiton := range repo.Notifications { 22 | switch notificaiton.Service { 23 | case database.NotificationServiceEmail: 24 | sendEmail(job, event) 25 | case database.NotificationServiceSlack: 26 | sendSlack(job, event) 27 | case database.NotificationServiceHipchat: 28 | sendHipchat(job, event) 29 | case database.NotificationServiceCampfire: 30 | sendCampfire(job, event) 31 | default: 32 | continue 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /notification/notify_test.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/fallenhitokiri/leeroyci/database" 8 | ) 9 | 10 | func TestMain(m *testing.M) { 11 | database.NewDatabase("sqlite3", ":memory:") 12 | 13 | i := m.Run() 14 | 15 | os.Exit(i) 16 | } 17 | -------------------------------------------------------------------------------- /notification/slack.go: -------------------------------------------------------------------------------- 1 | // Package notification handles all notifications for a job. This includes 2 | // build and deployment notifications. 3 | package notification 4 | 5 | import ( 6 | "bytes" 7 | "encoding/json" 8 | "log" 9 | "net/http" 10 | 11 | "github.com/fallenhitokiri/leeroyci/database" 12 | ) 13 | 14 | // Payload Slack expects to be POSTed to their API. 15 | type slackPayload struct { 16 | Channel string `json:"channel"` 17 | Username string `json:"username"` 18 | Text string `json:"text"` 19 | } 20 | 21 | // Send a notification to Slack 22 | func sendSlack(job *database.Job, event string) { 23 | notification, _ := database.GetNotificationForRepoAndType( 24 | &job.Repository, 25 | database.NotificationServiceSlack, 26 | ) 27 | 28 | channel, err := notification.GetConfigValue("channel") 29 | 30 | if err != nil { 31 | log.Print(err) 32 | return 33 | } 34 | 35 | endpoint, err := notification.GetConfigValue("endpoint") 36 | 37 | if err != nil { 38 | log.Print(err) 39 | return 40 | } 41 | 42 | payload, err := payloadSlack(job, event, channel) 43 | 44 | _, err = http.Post( 45 | endpoint, 46 | "application/json", 47 | bytes.NewReader(payload), 48 | ) 49 | 50 | if err != nil { 51 | log.Println(err) 52 | } 53 | } 54 | 55 | // Build the payload to send to Slack. 56 | func payloadSlack(job *database.Job, event, channel string) ([]byte, error) { 57 | msg := message(job, database.NotificationServiceSlack, event, TypeText) 58 | 59 | payload := slackPayload{ 60 | Channel: channel, 61 | Username: "CI", 62 | Text: msg, 63 | } 64 | 65 | marsh, err := json.Marshal(payload) 66 | 67 | if err != nil { 68 | log.Println(err) 69 | } 70 | 71 | return marsh, err 72 | } 73 | -------------------------------------------------------------------------------- /notification/slack_test.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/fallenhitokiri/leeroyci/database" 9 | ) 10 | 11 | func TestBuildSlack(t *testing.T) { 12 | repo := database.Repository{ 13 | Name: "repo", 14 | } 15 | 16 | job := database.Job{ 17 | Branch: "branch", 18 | Commit: "1234", 19 | Repository: repo, 20 | } 21 | 22 | _, err := payloadSlack(&job, EventBuild, "foo") 23 | 24 | if err != nil { 25 | t.Error(err) 26 | } 27 | } 28 | 29 | func TestSendSlack(t *testing.T) { 30 | database.NewInMemoryDatabase() 31 | repo, _ := database.CreateRepository("repo", "", "", false, false) 32 | job := database.CreateJob(repo, "branch", "bar", "commit URL", "name", "email") 33 | 34 | var request *http.Request 35 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 36 | request = r 37 | })) 38 | defer ts.Close() 39 | 40 | database.CreateNotification( 41 | database.NotificationServiceSlack, 42 | "channel:::foo:::::endpoint:::"+ts.URL, 43 | repo, 44 | ) 45 | 46 | sendSlack(job, EventBuild) 47 | 48 | if request.URL.Path != "/" { 49 | t.Error("Wrong URL path", request.URL.Path) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /notification/template.go: -------------------------------------------------------------------------------- 1 | // Package notification handles all notifications for a job. This includes 2 | // build and deployment notifications. 3 | package notification 4 | 5 | import ( 6 | "bytes" 7 | "html/template" 8 | "log" 9 | 10 | "github.com/GeertJohan/go.rice" 11 | 12 | "github.com/fallenhitokiri/leeroyci/database" 13 | ) 14 | 15 | // Supported events 16 | const ( 17 | EventTest = "test" 18 | EventBuild = "build" 19 | EventDeployStart = "deploy-start" 20 | EventDeployEnd = "deploy-end" 21 | ) 22 | 23 | // Event notification formats 24 | const ( 25 | TypeHTML = "html" 26 | TypeText = "text" 27 | ) 28 | 29 | // message returns a formatted message to send through a notification system. 30 | // event specifies what happened - tests completed e.x. 31 | // kind specifies the notification system. 32 | func message(job *database.Job, service, event, kind string) string { 33 | logs := database.GetCommandLogsForJob(job.ID) 34 | 35 | ctx := map[string]interface{}{ 36 | "TasksFinished": job.TasksFinished, 37 | "DeployFinished": job.DeployFinished, 38 | "Repository": job.Repository, 39 | "Branch": job.Branch, 40 | "Commit": job.Commit, 41 | "CommitURL": job.CommitURL, 42 | "Name": job.Name, 43 | "Email": job.Email, 44 | "CommandLogs": logs, 45 | "URL": job.URL(), 46 | } 47 | 48 | tmpl, err := getTemplate(service, event, kind) 49 | 50 | if err != nil { 51 | log.Println(err) 52 | return "" 53 | } 54 | 55 | var buf bytes.Buffer 56 | tmpl.Execute(&buf, ctx) 57 | return buf.String() 58 | } 59 | 60 | // getTemplate returns the template to use for a notification. 61 | func getTemplate(service, event, kind string) (*template.Template, error) { 62 | box, err := rice.FindBox("messages") 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | name := service + "-" + kind + "-" + event + ".tmpl" 68 | 69 | tmplStr, err := box.String(name) 70 | 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | tmpl, err := template.New(name).Parse(tmplStr) 76 | 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | return tmpl, nil 82 | } 83 | -------------------------------------------------------------------------------- /notification/template_test.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/fallenhitokiri/leeroyci/database" 8 | ) 9 | 10 | func TestGetTemplate(t *testing.T) { 11 | _, err := getTemplate("email", EventTest, "text") 12 | 13 | if err != nil { 14 | t.Error(err) 15 | } 16 | 17 | _, err = getTemplate("foo", EventTest, "email-asdf") 18 | 19 | if err == nil { 20 | t.Error("No error") 21 | } 22 | } 23 | 24 | func TestMessage(t *testing.T) { 25 | j := database.Job{ 26 | Branch: "foo", 27 | Commit: "bar", 28 | CommitURL: "url", 29 | Name: "baz", 30 | Email: "foo@bar.tld", 31 | } 32 | 33 | tmpl := message(&j, "email", EventTest, "text") 34 | 35 | if !strings.Contains(tmpl, "branch: foo") { 36 | t.Error("branch name not found", tmpl) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /notification/websocket.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "github.com/fallenhitokiri/leeroyci/database" 5 | "github.com/fallenhitokiri/leeroyci/websocket" 6 | ) 7 | 8 | func sendWebsocket(job *database.Job, event string) { 9 | msg := websocket.NewMessage(job, event) 10 | websocket.Send(msg) 11 | } 12 | -------------------------------------------------------------------------------- /runner/manager.go: -------------------------------------------------------------------------------- 1 | // Package runner runs all tasks for all commands associated with a repository. 2 | package runner 3 | 4 | import ( 5 | "sync" 6 | 7 | "github.com/fallenhitokiri/leeroyci/database" 8 | ) 9 | 10 | var manager taskManager 11 | 12 | type taskManager struct { 13 | progress map[int]string 14 | mutex sync.Mutex 15 | } 16 | 17 | func newTaskManager() { 18 | config := database.GetConfig() 19 | 20 | manager = taskManager{ 21 | progress: map[int]string{}, 22 | } 23 | 24 | for index := 1; index <= config.Parallel; index++ { 25 | manager.progress[index] = "" 26 | } 27 | } 28 | 29 | func (t *taskManager) getTaskID(repository, branch string) int { 30 | ident := repository + branch 31 | 32 | t.mutex.Lock() 33 | defer t.mutex.Unlock() 34 | 35 | // only one task per branch 36 | for _, val := range t.progress { 37 | if val == ident { 38 | return 0 39 | } 40 | } 41 | 42 | for id, val := range t.progress { 43 | if val == "" { 44 | t.progress[id] = ident 45 | return id 46 | } 47 | } 48 | 49 | return 0 50 | } 51 | 52 | func (t *taskManager) doneWithID(id int) { 53 | t.mutex.Lock() 54 | defer t.mutex.Unlock() 55 | 56 | t.progress[id] = "" 57 | } 58 | -------------------------------------------------------------------------------- /runner/manager_test.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/fallenhitokiri/leeroyci/database" 7 | ) 8 | 9 | func TestNewTaskManager(t *testing.T) { 10 | database.NewInMemoryDatabase() 11 | database.AddConfig("secret", "url", "cert", "key", 5) 12 | newTaskManager() 13 | 14 | if manager.progress[1] != "" { 15 | t.Error("Wrong value at 1", manager.progress[1]) 16 | } 17 | 18 | if manager.progress[2] != "" { 19 | t.Error("Wrong value at 2", manager.progress[2]) 20 | } 21 | 22 | if manager.progress[3] != "" { 23 | t.Error("Wrong value at 3", manager.progress[3]) 24 | } 25 | 26 | if manager.progress[4] != "" { 27 | t.Error("Wrong value at 4", manager.progress[4]) 28 | } 29 | 30 | if manager.progress[5] != "" { 31 | t.Error("Wrong value at 5", manager.progress[5]) 32 | } 33 | } 34 | 35 | func TestGetTaskID(t *testing.T) { 36 | database.NewInMemoryDatabase() 37 | database.AddConfig("secret", "url", "cert", "key", 2) 38 | newTaskManager() 39 | 40 | tID1 := manager.getTaskID("foo", "bar") 41 | 42 | if tID1 == 0 { 43 | t.Error("Got no ID") 44 | } 45 | 46 | if manager.getTaskID("foo", "bar") != 0 { 47 | t.Error("Got a second ID") 48 | } 49 | 50 | tID2 := manager.getTaskID("foo", "baz") 51 | 52 | if tID2 == 0 { 53 | t.Error("Got no ID") 54 | } 55 | 56 | if tID1 == tID2 { 57 | t.Error("Got same ID as tID1") 58 | } 59 | 60 | if manager.getTaskID("foo", "baz") != 0 { 61 | t.Error("Got a second ID") 62 | } 63 | 64 | tID3 := manager.getTaskID("foo", "zab") 65 | 66 | if tID3 != 0 { 67 | t.Error("Got an ID, no free IDs") 68 | } 69 | 70 | manager.doneWithID(tID1) 71 | 72 | tID3 = manager.getTaskID("foo", "zab") 73 | 74 | if tID3 == 0 { 75 | t.Error("Got no ID, but one is free") 76 | t.Error(manager.progress) 77 | } 78 | 79 | if tID1 != tID3 { 80 | t.Error("Got a wrong ID") 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /runner/runner.go: -------------------------------------------------------------------------------- 1 | // Package runner runs all tasks for all commands associated with a repository. 2 | package runner 3 | 4 | import ( 5 | "log" 6 | "os/exec" 7 | "strconv" 8 | "time" 9 | 10 | "github.com/fallenhitokiri/leeroyci/database" 11 | "github.com/fallenhitokiri/leeroyci/notification" 12 | ) 13 | 14 | // QueueJob represents a job put on the runner queue. The status channel is 15 | // used to notify about finished builds. 16 | type QueueJob struct { 17 | JobID int64 18 | Status chan bool 19 | } 20 | 21 | // Enqueue a job for running. 22 | func (q *QueueJob) Enqueue() { 23 | runQueue <- q 24 | } 25 | 26 | // RunQueue receives job IDs for which commands should run. 27 | var runQueue = make(chan *QueueJob, 100) 28 | 29 | // Runner waits for jobs to be pushed on RunQueue and runs all commands. It also 30 | // creates the command logs and sends the necessary notifications. 31 | func Runner() { 32 | newTaskManager() 33 | 34 | for { 35 | queueJob := <-runQueue 36 | 37 | job := database.GetJob(queueJob.JobID) 38 | repository, err := database.GetRepositoryByID(job.RepositoryID) 39 | 40 | if err != nil { 41 | log.Println("Could not find repository for", job.Repository.URL) 42 | continue 43 | } 44 | 45 | if job.Cancelled == true { 46 | log.Println("Job cancelled, not running commands", job.ID) 47 | continue 48 | } 49 | 50 | taskID := manager.getTaskID(repository.Name, job.Branch) 51 | 52 | if taskID != 0 { 53 | go handleJob(job, repository, queueJob, taskID) 54 | } else { 55 | runQueue <- queueJob 56 | // if we cannot handle any more jobs we wait for 10 seconds before 57 | // checking again. 58 | time.Sleep(10 * time.Second) 59 | } 60 | } 61 | } 62 | 63 | // handleJob runs all tasks for a job and updates the queueJob status field once 64 | // builds and tests are done. 65 | func handleJob(job *database.Job, repository *database.Repository, 66 | queueJob *QueueJob, taskID int) { 67 | 68 | job.Started() 69 | 70 | run(job, repository, database.CommandKindTest, taskID) 71 | notification.Notify(job, notification.EventTest) 72 | 73 | if job.Passed() && job.ShouldBuild() { 74 | run(job, repository, database.CommandKindBuild, taskID) 75 | notification.Notify(job, notification.EventBuild) 76 | } 77 | 78 | job.TasksDone() 79 | 80 | if queueJob.Status != nil { 81 | queueJob.Status <- true 82 | } 83 | 84 | manager.doneWithID(taskID) 85 | 86 | if job.Passed() && job.ShouldDeploy() { 87 | go deploy(job, repository, taskID) 88 | } 89 | } 90 | 91 | // deploy is a wrapper around the run commnad to make running the deploy commands 92 | // in a separate go routine more convenient. 93 | func deploy(job *database.Job, repository *database.Repository, taskID int) { 94 | notification.Notify(job, notification.EventDeployStart) 95 | run(job, repository, database.CommandKindDeploy, taskID) 96 | job.DeployDone() 97 | notification.Notify(job, notification.EventDeployEnd) 98 | } 99 | 100 | // run runs the command that is specified in Command.Execute and creates the 101 | // command log with the results of the command. 102 | func run(job *database.Job, repository *database.Repository, kind string, taskID int) { 103 | commands := repository.GetCommands(job.Branch, kind) 104 | 105 | for _, command := range commands { 106 | if command.Kind == kind { 107 | if (command.Branch != "" && command.Branch == job.Branch) || command.Branch == "" { 108 | repository := job.Repository.Name 109 | branch := job.Branch 110 | 111 | log.Println("Running", command.Name, "for", repository, branch) 112 | 113 | cmd := exec.Command(command.Execute, repository, branch, strconv.Itoa(taskID)) 114 | out, err := cmd.CombinedOutput() 115 | 116 | returnValue := "" 117 | 118 | if err != nil { 119 | returnValue = err.Error() 120 | } 121 | 122 | database.CreateCommandLog(&command, job, returnValue, string(out)) 123 | } 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /runner/runner_test.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/fallenhitokiri/leeroyci/database" 8 | ) 9 | 10 | func TestQueueJobEnqueue(t *testing.T) { 11 | qj := QueueJob{ 12 | JobID: 1, 13 | } 14 | 15 | qj.Enqueue() 16 | 17 | got := <-runQueue 18 | 19 | if got.JobID != qj.JobID { 20 | t.Error("Got wrong job ID", got.JobID) 21 | } 22 | } 23 | 24 | func TestRun(t *testing.T) { 25 | database.NewInMemoryDatabase() 26 | repo, _ := database.CreateRepository("name", "url", "accessKey", false, false) 27 | job := database.CreateJob(repo, "branch", "commit", "commitURL", "name", "email") 28 | cmd, _ := database.CreateCommand(repo, "foo", "", "", database.CommandKindBuild) 29 | 30 | run(job, repo, database.CommandKindBuild, 1) 31 | 32 | logs := database.GetCommandLogsForJob(job.ID) 33 | 34 | if len(logs) != 1 { 35 | t.Error("Wrong logs length", len(logs)) 36 | } 37 | 38 | if logs[0].Name != cmd.Name { 39 | t.Error("Wrong name", logs[0].Name) 40 | } 41 | } 42 | 43 | func TestRunWrongKind(t *testing.T) { 44 | database.NewInMemoryDatabase() 45 | repo, _ := database.CreateRepository("name", "url", "accessKey", false, false) 46 | job := database.CreateJob(repo, "branch", "commit", "commitURL", "name", "email") 47 | database.CreateCommand(repo, "foo", "", "", database.CommandKindBuild) 48 | 49 | run(job, repo, database.CommandKindTest, 1) 50 | 51 | logs := database.GetCommandLogsForJob(job.ID) 52 | 53 | if len(logs) != 0 { 54 | t.Error("Wrong logs length", len(logs)) 55 | } 56 | } 57 | 58 | func TestRunWrongBranch(t *testing.T) { 59 | database.NewInMemoryDatabase() 60 | repo, _ := database.CreateRepository("name", "url", "accessKey", false, false) 61 | job := database.CreateJob(repo, "branch", "commit", "commitURL", "name", "email") 62 | database.CreateCommand(repo, "foo", "", "bar", database.CommandKindBuild) 63 | 64 | run(job, repo, database.CommandKindBuild, 1) 65 | 66 | logs := database.GetCommandLogsForJob(job.ID) 67 | 68 | if len(logs) != 0 { 69 | t.Error("Wrong logs length", len(logs)) 70 | } 71 | } 72 | 73 | func TestRunMatchingBranch(t *testing.T) { 74 | database.NewInMemoryDatabase() 75 | repo, _ := database.CreateRepository("name", "url", "accessKey", false, false) 76 | job := database.CreateJob(repo, "branch", "commit", "commitURL", "name", "email") 77 | cmd, _ := database.CreateCommand(repo, "foo", "", "branch", database.CommandKindBuild) 78 | 79 | run(job, repo, database.CommandKindBuild, 1) 80 | 81 | logs := database.GetCommandLogsForJob(job.ID) 82 | 83 | if len(logs) != 1 { 84 | t.Error("Wrong logs length", len(logs)) 85 | } 86 | 87 | if logs[0].Name != cmd.Name { 88 | t.Error("Wrong name", logs[0].Name) 89 | } 90 | } 91 | 92 | func TestDeploy(t *testing.T) { 93 | database.NewInMemoryDatabase() 94 | 95 | repo, _ := database.CreateRepository("name", "url", "accessKey", false, false) 96 | job := database.CreateJob(repo, "branch", "commit", "commitURL", "name", "email") 97 | 98 | deploy(job, repo, 1) 99 | 100 | job = database.GetJob(job.ID) 101 | 102 | blank := time.Time{} 103 | if job.DeployFinished == blank { 104 | t.Error("Deploy not finished - time not set.") 105 | } 106 | } 107 | 108 | func TestHandleJob(t *testing.T) { 109 | database.NewInMemoryDatabase() 110 | 111 | repo, _ := database.CreateRepository("name", "url", "accessKey", false, false) 112 | job := database.CreateJob(repo, "branch", "commit", "commitURL", "name", "email") 113 | status := make(chan bool, 5) 114 | qJob := QueueJob{ 115 | JobID: job.ID, 116 | Status: status, 117 | } 118 | blank := time.Time{} 119 | 120 | handleJob(job, repo, &qJob, 1) 121 | 122 | if job.TasksStarted == blank { 123 | t.Error("Tasks not started") 124 | } 125 | 126 | if job.TasksFinished == blank { 127 | t.Error("Tasks not finished") 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /web/callback.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | 7 | "github.com/gorilla/mux" 8 | 9 | "github.com/fallenhitokiri/leeroyci/github" 10 | ) 11 | 12 | func viewCallback(w http.ResponseWriter, r *http.Request) { 13 | vars := mux.Vars(r) 14 | service := vars["service"] 15 | 16 | switch service { 17 | case "github": 18 | github.Handle(r) 19 | default: 20 | log.Println("Service not supported.") 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /web/command_admin.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/gorilla/mux" 9 | "github.com/gorilla/schema" 10 | 11 | "github.com/fallenhitokiri/leeroyci/database" 12 | ) 13 | 14 | type commandAdminForm struct { 15 | ID int64 `schema:"ID"` 16 | Name string `schema:"name"` 17 | Kind string `schema:"kind"` 18 | Branch string `schema:"branch"` 19 | Execute string `schema:"execute"` 20 | } 21 | 22 | func (c commandAdminForm) create(request *http.Request, repo *database.Repository) error { 23 | err := request.ParseForm() 24 | 25 | if err != nil { 26 | return err 27 | } 28 | 29 | decoder := schema.NewDecoder() 30 | form := new(commandAdminForm) 31 | 32 | err = decoder.Decode(form, request.PostForm) 33 | 34 | if err != nil { 35 | return err 36 | } 37 | 38 | _, err = database.CreateCommand( 39 | repo, 40 | form.Name, 41 | form.Execute, 42 | form.Branch, 43 | form.Kind, 44 | ) 45 | 46 | return err 47 | } 48 | 49 | func (c commandAdminForm) update(request *http.Request, com *database.Command) error { 50 | err := request.ParseForm() 51 | 52 | if err != nil { 53 | return err 54 | } 55 | 56 | decoder := schema.NewDecoder() 57 | form := new(commandAdminForm) 58 | 59 | err = decoder.Decode(form, request.PostForm) 60 | 61 | if err != nil { 62 | return err 63 | } 64 | 65 | err = com.Update(form.Name, form.Kind, form.Branch, form.Execute) 66 | 67 | return err 68 | } 69 | 70 | func viewAdminCreateCommand(w http.ResponseWriter, r *http.Request) { 71 | template := "command/admin/create.html" 72 | ctx := make(responseContext) 73 | 74 | vars := mux.Vars(r) 75 | rid, _ := strconv.Atoi(vars["rid"]) 76 | 77 | repo, err := database.GetRepositoryByID(int64(rid)) 78 | 79 | if err != nil { 80 | render(w, r, "403.html", make(responseContext)) // TODO: create 500 81 | return 82 | } 83 | 84 | ctx["repository"] = repo 85 | ctx["kinds"] = []string{ 86 | database.CommandKindBuild, 87 | database.CommandKindDeploy, 88 | database.CommandKindTest, 89 | } 90 | 91 | if r.Method == "POST" { 92 | err := commandAdminForm{}.create(r, repo) 93 | 94 | if err != nil { 95 | ctx["error"] = err.Error() 96 | } else { 97 | uri := fmt.Sprintf("/admin/repository/%d", rid) 98 | http.Redirect(w, r, uri, 302) 99 | return 100 | } 101 | } 102 | 103 | render(w, r, template, ctx) 104 | } 105 | 106 | func viewAdminUpdateCommand(w http.ResponseWriter, r *http.Request) { 107 | template := "command/admin/update.html" 108 | ctx := make(responseContext) 109 | 110 | vars := mux.Vars(r) 111 | rid, _ := strconv.Atoi(vars["rid"]) 112 | cid, _ := strconv.Atoi(vars["cid"]) 113 | 114 | repo, err := database.GetRepositoryByID(int64(rid)) 115 | 116 | if err != nil { 117 | render(w, r, "403.html", make(responseContext)) // TODO: create 500 118 | return 119 | } 120 | 121 | com, err := database.GetCommand(int64(cid)) 122 | 123 | if err != nil { 124 | render(w, r, "403.html", make(responseContext)) // TODO: create 500 125 | return 126 | } 127 | 128 | ctx["repository"] = repo 129 | ctx["command"] = com 130 | ctx["kinds"] = []string{ 131 | database.CommandKindBuild, 132 | database.CommandKindDeploy, 133 | database.CommandKindTest, 134 | } 135 | 136 | if r.Method == "POST" { 137 | err := commandAdminForm{}.update(r, com) 138 | 139 | if err == nil { 140 | ctx["message"] = "Update successful." 141 | } else { 142 | ctx["error"] = err.Error() 143 | } 144 | } 145 | 146 | render(w, r, template, ctx) 147 | } 148 | 149 | func viewAdminDeleteCommand(w http.ResponseWriter, r *http.Request) { 150 | vars := mux.Vars(r) 151 | rid := vars["rid"] 152 | cid, _ := strconv.Atoi(vars["cid"]) 153 | 154 | com, err := database.GetCommand(int64(cid)) 155 | 156 | if err == nil { 157 | com.Delete() 158 | } 159 | 160 | uri := fmt.Sprintf("/admin/repository/%s", rid) 161 | http.Redirect(w, r, uri, 302) 162 | } 163 | -------------------------------------------------------------------------------- /web/config.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gorilla/schema" 7 | 8 | "github.com/fallenhitokiri/leeroyci/database" 9 | ) 10 | 11 | type configAdminForm struct { 12 | Secret string 13 | URL string 14 | Cert string 15 | Key string 16 | Parallel int 17 | } 18 | 19 | func (c configAdminForm) update(request *http.Request) error { 20 | err := request.ParseForm() 21 | 22 | if err != nil { 23 | return err 24 | } 25 | 26 | decoder := schema.NewDecoder() 27 | form := new(configAdminForm) 28 | 29 | err = decoder.Decode(form, request.PostForm) 30 | 31 | if err != nil { 32 | return err 33 | } 34 | 35 | database.UpdateConfig(form.Secret, form.URL, form.Cert, form.Key, form.Parallel) 36 | return nil 37 | } 38 | 39 | func viewAdminUpdateConfig(w http.ResponseWriter, r *http.Request) { 40 | template := "config/admin/update.html" 41 | ctx := make(responseContext) 42 | 43 | if r.Method == "POST" { 44 | err := configAdminForm{}.update(r) 45 | 46 | if err == nil { 47 | ctx["message"] = "Update successful." 48 | } else { 49 | ctx["error"] = err.Error() 50 | } 51 | } 52 | 53 | config := database.GetConfig() 54 | ctx["config"] = config 55 | 56 | render(w, r, template, ctx) 57 | } 58 | -------------------------------------------------------------------------------- /web/context.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | type contextKey string 4 | 5 | const contextUser contextKey = "user" 6 | const contextTemplate contextKey = "template" 7 | const contextCtx contextKey = "ctx" 8 | -------------------------------------------------------------------------------- /web/job.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | 7 | "github.com/gorilla/mux" 8 | 9 | "github.com/fallenhitokiri/leeroyci/database" 10 | "github.com/fallenhitokiri/leeroyci/runner" 11 | ) 12 | 13 | const limit = 20 14 | 15 | // viewListJobs shows a paginated list of all jobs. 16 | func viewListJobs(w http.ResponseWriter, r *http.Request) { 17 | template := "job/list.html" 18 | ctx := make(responseContext) 19 | 20 | offset := 0 21 | paramOffset := r.URL.Query().Get("offset") 22 | 23 | if len(paramOffset) > 0 { 24 | offset = stringToInt(paramOffset) 25 | } 26 | 27 | ctx["jobs"] = database.GetJobs(offset, limit) 28 | 29 | prev, next, first := previousNextNumber(offset) 30 | 31 | ctx["previous_offset"] = prev 32 | ctx["next_offset"] = next 33 | ctx["first_page"] = first 34 | 35 | render(w, r, template, ctx) 36 | } 37 | 38 | // viewJobDetail shows a specific job with all related information. 39 | func viewDetailJob(w http.ResponseWriter, r *http.Request) { 40 | template := "job/detail.html" 41 | ctx := make(responseContext) 42 | 43 | vars := mux.Vars(r) 44 | jobID, _ := strconv.Atoi(vars["jid"]) 45 | 46 | job := database.GetJob(int64(jobID)) 47 | ctx["job"] = job 48 | 49 | render(w, r, template, ctx) 50 | } 51 | 52 | // viewCancelJob cancels a job. 53 | func viewCancelJob(w http.ResponseWriter, r *http.Request) { 54 | vars := mux.Vars(r) 55 | jobID, _ := strconv.Atoi(vars["jid"]) 56 | 57 | job := database.GetJob(int64(jobID)) 58 | job.Cancel() 59 | 60 | http.Redirect(w, r, "/", 302) 61 | } 62 | 63 | // viewRerunJob resets a job status and enqueues it agian. 64 | func viewRerunJob(w http.ResponseWriter, r *http.Request) { 65 | vars := mux.Vars(r) 66 | jobID, _ := strconv.Atoi(vars["jid"]) 67 | 68 | old := database.GetJob(int64(jobID)) 69 | job := database.CreateJob( 70 | &old.Repository, 71 | old.Branch, 72 | old.Commit, 73 | old.CommitURL, 74 | old.Name, 75 | old.Email, 76 | ) 77 | 78 | queueJob := runner.QueueJob{ 79 | JobID: job.ID, 80 | } 81 | 82 | queueJob.Enqueue() 83 | 84 | http.Redirect(w, r, "/", 302) 85 | } 86 | 87 | // viewSearchJobs returns a list of jobs filtered by the search string. 88 | func viewSearchJobs(w http.ResponseWriter, r *http.Request) { 89 | template := "job/list.html" 90 | ctx := make(responseContext) 91 | 92 | query := r.URL.Query().Get("query") 93 | 94 | ctx["jobs"] = database.SearchJobs(query) 95 | ctx["query"] = query 96 | 97 | render(w, r, template, ctx) 98 | } 99 | 100 | // returns the offset for the previous and next page. 101 | func previousNextNumber(offset int) (int, int, bool) { 102 | count := database.NumberOfJobs() 103 | prev := offset - limit 104 | next := offset + limit 105 | 106 | if prev < 0 { 107 | prev = 0 108 | } 109 | 110 | if next >= count { 111 | next = -1 112 | } 113 | 114 | first := false 115 | 116 | if count > limit && next != limit { 117 | first = true 118 | } 119 | 120 | return prev, next, first 121 | } 122 | -------------------------------------------------------------------------------- /web/login.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/gorilla/schema" 8 | 9 | "github.com/fallenhitokiri/leeroyci/database" 10 | ) 11 | 12 | type loginForm struct { 13 | Email string `schema:"email"` 14 | Password string `schema:"password"` 15 | } 16 | 17 | func (l loginForm) authenticate(request *http.Request) (string, error) { 18 | err := request.ParseForm() 19 | 20 | if err != nil { 21 | return "", err 22 | } 23 | 24 | decoder := schema.NewDecoder() 25 | form := new(loginForm) 26 | 27 | err = decoder.Decode(form, request.PostForm) 28 | 29 | if err != nil { 30 | return "", err 31 | } 32 | 33 | user, err := database.GetUser(form.Email) 34 | 35 | if err != nil { 36 | return "", err 37 | } 38 | 39 | auth := database.ComparePassword(form.Password, user.Password) 40 | 41 | if auth == false { 42 | return "", errors.New("Username and password do not match.") 43 | } 44 | 45 | return user.NewSession(), nil 46 | } 47 | 48 | func viewLogin(w http.ResponseWriter, r *http.Request) { 49 | template := "login.html" 50 | ctx := make(responseContext) 51 | 52 | if r.Method == "POST" { 53 | sessionID, err := loginForm{}.authenticate(r) 54 | 55 | if err == nil { 56 | session, _ := sessionStore.Get(r, "leeroyci") 57 | session.Values["session_key"] = sessionID 58 | session.Save(r, w) 59 | 60 | http.Redirect(w, r, "/", 302) 61 | return 62 | } 63 | 64 | ctx["error"] = err.Error() 65 | } 66 | 67 | render(w, r, template, ctx) 68 | } 69 | -------------------------------------------------------------------------------- /web/logout.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | func viewLogout(w http.ResponseWriter, r *http.Request) { 8 | session, _ := sessionStore.Get(r, "leeroyci") 9 | session.Values["session_key"] = nil 10 | session.Save(r, w) 11 | http.Redirect(w, r, "/login", 302) 12 | return 13 | } 14 | -------------------------------------------------------------------------------- /web/mailserver_admin.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gorilla/schema" 7 | 8 | "github.com/fallenhitokiri/leeroyci/database" 9 | ) 10 | 11 | type mailserverAdminForm struct { 12 | Host string 13 | Sender string 14 | Port int 15 | User string 16 | Password string 17 | } 18 | 19 | func (m mailserverAdminForm) update(request *http.Request) error { 20 | err := request.ParseForm() 21 | 22 | if err != nil { 23 | return err 24 | } 25 | 26 | decoder := schema.NewDecoder() 27 | form := new(mailserverAdminForm) 28 | 29 | err = decoder.Decode(form, request.PostForm) 30 | 31 | if err != nil { 32 | return err 33 | } 34 | 35 | database.UpdateMailServer(form.Host, form.Sender, form.User, form.Password, form.Port) 36 | return nil 37 | } 38 | 39 | func viewAdminUpdateMailserver(w http.ResponseWriter, r *http.Request) { 40 | template := "mailserver/admin/update.html" 41 | ctx := make(responseContext) 42 | 43 | if r.Method == "POST" { 44 | err := mailserverAdminForm{}.update(r) 45 | 46 | if err == nil { 47 | ctx["message"] = "Update successful." 48 | } else { 49 | ctx["error"] = err.Error() 50 | } 51 | } 52 | 53 | server := database.GetMailServer() 54 | ctx["server"] = server 55 | 56 | render(w, r, template, ctx) 57 | } 58 | -------------------------------------------------------------------------------- /web/middleware_accesskey.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/fallenhitokiri/leeroyci/database" 7 | ) 8 | 9 | // middlewareAccesskey tries to get a user by the access key in the 10 | // request header. If this is not possible we return a 401 error 11 | func middlewareAccessKey(next http.Handler) http.Handler { 12 | fn := func(w http.ResponseWriter, r *http.Request) { 13 | header := r.Header["Accesskey"] 14 | 15 | if len(header) != 1 { 16 | http.Error(w, "Wrong access key number", http.StatusBadRequest) 17 | return 18 | } 19 | 20 | accessKey := header[0] 21 | 22 | if accessKey == "" { 23 | http.Error(w, "No access key", http.StatusBadRequest) 24 | return 25 | } 26 | 27 | _, err := database.GetUserByAccessKey(accessKey) 28 | 29 | if err != nil { 30 | http.Error(w, "Access key not found", http.StatusUnauthorized) 31 | return 32 | } 33 | 34 | next.ServeHTTP(w, r) 35 | } 36 | 37 | return http.HandlerFunc(fn) 38 | } 39 | -------------------------------------------------------------------------------- /web/middleware_admin.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/fallenhitokiri/leeroyci/database" 7 | ) 8 | 9 | // middlewareAdmin checks if the authenticated user is an admin. If this is not 10 | // the case we raise an 403 11 | func middlewareAdmin(next http.Handler) http.Handler { 12 | fn := func(w http.ResponseWriter, r *http.Request) { 13 | session, _ := sessionStore.Get(r, "leeroyci") 14 | sessionKey := session.Values["session_key"] 15 | 16 | if sessionKey == nil { 17 | http.Redirect(w, r, "/login", 302) 18 | return 19 | } 20 | 21 | user, err := database.GetUserBySession(sessionKey.(string)) 22 | 23 | if err == nil { 24 | if user.Admin == false { 25 | render(w, r, "403.html", make(responseContext)) 26 | return 27 | } 28 | next.ServeHTTP(w, r) 29 | } else { 30 | http.Redirect(w, r, "/login", 302) 31 | return 32 | } 33 | } 34 | 35 | return http.HandlerFunc(fn) 36 | } 37 | -------------------------------------------------------------------------------- /web/middleware_auth.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gorilla/context" 7 | 8 | "github.com/fallenhitokiri/leeroyci/database" 9 | ) 10 | 11 | // middlewareAuth tries to get a session key and authenticate a user adding 12 | // the user instance to the context. 13 | func middlewareAuth(next http.Handler) http.Handler { 14 | fn := func(w http.ResponseWriter, r *http.Request) { 15 | session, _ := sessionStore.Get(r, "leeroyci") 16 | sessionKey := session.Values["session_key"] 17 | 18 | if sessionKey == nil { 19 | http.Redirect(w, r, "/login", 302) 20 | return 21 | } 22 | 23 | user, err := database.GetUserBySession(sessionKey.(string)) 24 | 25 | if err == nil { 26 | context.Set(r, contextUser, user) 27 | next.ServeHTTP(w, r) 28 | } else { 29 | http.Redirect(w, r, "/login", 302) 30 | return 31 | } 32 | } 33 | 34 | return http.HandlerFunc(fn) 35 | } 36 | -------------------------------------------------------------------------------- /web/middleware_logging.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | // middlewareLogging logs the time a request takes to the console. 10 | func middlewareLogging(next http.Handler) http.Handler { 11 | fn := func(w http.ResponseWriter, r *http.Request) { 12 | t1 := time.Now() 13 | next.ServeHTTP(w, r) 14 | t2 := time.Now() 15 | log.Printf("[%s] %q %v\n", r.Method, r.URL.String(), t2.Sub(t1)) 16 | } 17 | 18 | return http.HandlerFunc(fn) 19 | } 20 | -------------------------------------------------------------------------------- /web/middleware_noconfig.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | 7 | "github.com/fallenhitokiri/leeroyci/database" 8 | ) 9 | 10 | // middlewareNoConfig redirects to /setup if there is no valid configuration 11 | // and the path is not /setup or /static 12 | func middlewareNoConfig(next http.Handler) http.Handler { 13 | fn := func(w http.ResponseWriter, r *http.Request) { 14 | path := r.URL.String() 15 | 16 | if database.Configured || path == "/setup" || strings.HasPrefix(path, "/static") { 17 | next.ServeHTTP(w, r) 18 | } else { 19 | http.Redirect(w, r, "/setup", 302) 20 | return 21 | } 22 | } 23 | 24 | return http.HandlerFunc(fn) 25 | } 26 | -------------------------------------------------------------------------------- /web/middleware_panic.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "errors" 5 | "log" 6 | "net/http" 7 | ) 8 | 9 | // middlewarePanic recovers from panics during the whole request process and 10 | // renders a 500 error page. 11 | func middlewarePanic(next http.Handler) http.Handler { 12 | fn := func(w http.ResponseWriter, r *http.Request) { 13 | var err error 14 | 15 | defer func() { 16 | rec := recover() 17 | 18 | if rec != nil { 19 | switch kind := rec.(type) { 20 | case string: 21 | err = errors.New(kind) 22 | case error: 23 | err = kind 24 | default: 25 | err = errors.New("Unknown error") 26 | } 27 | log.Println("panic: ", err.Error()) 28 | http.Error(w, err.Error(), http.StatusInternalServerError) 29 | } 30 | }() 31 | 32 | next.ServeHTTP(w, r) 33 | } 34 | 35 | return http.HandlerFunc(fn) 36 | } 37 | -------------------------------------------------------------------------------- /web/notification_admin.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/gorilla/mux" 9 | "github.com/gorilla/schema" 10 | 11 | "github.com/fallenhitokiri/leeroyci/database" 12 | ) 13 | 14 | type notificationAdminForm struct { 15 | ID int64 `schema:"ID"` 16 | Service string `schema:"service"` 17 | Arguments string `schema:"arguments"` 18 | } 19 | 20 | func (n notificationAdminForm) create(request *http.Request, repo *database.Repository) error { 21 | err := request.ParseForm() 22 | 23 | if err != nil { 24 | return err 25 | } 26 | 27 | decoder := schema.NewDecoder() 28 | form := new(notificationAdminForm) 29 | 30 | err = decoder.Decode(form, request.PostForm) 31 | 32 | if err != nil { 33 | return err 34 | } 35 | 36 | _, err = database.CreateNotification(form.Service, form.Arguments, repo) 37 | 38 | return err 39 | } 40 | 41 | func (n notificationAdminForm) update(request *http.Request, not *database.Notification) error { 42 | err := request.ParseForm() 43 | 44 | if err != nil { 45 | return err 46 | } 47 | 48 | decoder := schema.NewDecoder() 49 | form := new(notificationAdminForm) 50 | 51 | err = decoder.Decode(form, request.PostForm) 52 | 53 | if err != nil { 54 | return err 55 | } 56 | 57 | err = not.Update(form.Service, form.Arguments) 58 | 59 | return err 60 | } 61 | 62 | func viewAdminCreateNotification(w http.ResponseWriter, r *http.Request) { 63 | template := "notification/admin/create.html" 64 | ctx := make(responseContext) 65 | 66 | vars := mux.Vars(r) 67 | rid, _ := strconv.Atoi(vars["rid"]) 68 | 69 | repo, err := database.GetRepositoryByID(int64(rid)) 70 | 71 | if err != nil { 72 | render(w, r, "403.html", make(responseContext)) // TODO: create 500 73 | return 74 | } 75 | 76 | ctx["repository"] = repo 77 | ctx["services"] = []string{ 78 | database.NotificationServiceEmail, 79 | database.NotificationServiceSlack, 80 | database.NotificationServiceCampfire, 81 | database.NotificationServiceHipchat, 82 | } 83 | 84 | if r.Method == "POST" { 85 | err := notificationAdminForm{}.create(r, repo) 86 | 87 | if err != nil { 88 | ctx["error"] = err.Error() 89 | } else { 90 | uri := fmt.Sprintf("/admin/repository/%d", rid) 91 | http.Redirect(w, r, uri, 302) 92 | return 93 | } 94 | } 95 | 96 | render(w, r, template, ctx) 97 | } 98 | 99 | func viewAdminUpdateNotification(w http.ResponseWriter, r *http.Request) { 100 | template := "notification/admin/update.html" 101 | ctx := make(responseContext) 102 | 103 | vars := mux.Vars(r) 104 | rid, _ := strconv.Atoi(vars["rid"]) 105 | nid, _ := strconv.Atoi(vars["nid"]) 106 | 107 | repo, err := database.GetRepositoryByID(int64(rid)) 108 | 109 | if err != nil { 110 | render(w, r, "403.html", make(responseContext)) // TODO: create 500 111 | return 112 | } 113 | 114 | not, err := database.GetNotification(int64(nid)) 115 | 116 | if err != nil { 117 | render(w, r, "403.html", make(responseContext)) // TODO: create 500 118 | return 119 | } 120 | 121 | ctx["repository"] = repo 122 | ctx["notification"] = not 123 | ctx["services"] = []string{ 124 | database.NotificationServiceEmail, 125 | database.NotificationServiceSlack, 126 | database.NotificationServiceHipchat, 127 | database.NotificationServiceCampfire, 128 | } 129 | 130 | if r.Method == "POST" { 131 | err := notificationAdminForm{}.update(r, not) 132 | 133 | if err == nil { 134 | ctx["message"] = "Update successful." 135 | } else { 136 | ctx["error"] = err.Error() 137 | } 138 | } 139 | 140 | render(w, r, template, ctx) 141 | } 142 | 143 | func viewAdminDeleteNotification(w http.ResponseWriter, r *http.Request) { 144 | vars := mux.Vars(r) 145 | rid := vars["rid"] 146 | nid, _ := strconv.Atoi(vars["nid"]) 147 | 148 | not, err := database.GetNotification(int64(nid)) 149 | 150 | if err == nil { 151 | not.Delete() 152 | } 153 | 154 | uri := fmt.Sprintf("/admin/repository/%s", rid) 155 | http.Redirect(w, r, uri, 302) 156 | } 157 | -------------------------------------------------------------------------------- /web/repository_admin.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/gorilla/mux" 9 | "github.com/gorilla/schema" 10 | 11 | "github.com/fallenhitokiri/leeroyci/database" 12 | ) 13 | 14 | type repositoryAdminForm struct { 15 | Name string `schema:"name"` 16 | URL string `schema:"url"` 17 | 18 | ClosePR bool `schema:"close_pr"` 19 | StatusPR bool `schema:"status_pr"` 20 | AccessKey string `schema:"access_key"` 21 | } 22 | 23 | func (r repositoryAdminForm) create(request *http.Request) (*database.Repository, error) { 24 | err := request.ParseForm() 25 | 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | decoder := schema.NewDecoder() 31 | form := new(repositoryAdminForm) 32 | 33 | err = decoder.Decode(form, request.PostForm) 34 | 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | repo, err := database.CreateRepository( 40 | form.Name, 41 | form.URL, 42 | form.AccessKey, 43 | form.ClosePR, 44 | form.StatusPR, 45 | ) 46 | 47 | return repo, err 48 | } 49 | 50 | func (r repositoryAdminForm) update(request *http.Request, rid int64) error { 51 | err := request.ParseForm() 52 | 53 | if err != nil { 54 | return err 55 | } 56 | 57 | decoder := schema.NewDecoder() 58 | form := new(repositoryAdminForm) 59 | 60 | err = decoder.Decode(form, request.PostForm) 61 | 62 | if err != nil { 63 | return err 64 | } 65 | 66 | repo, err := database.GetRepositoryByID(rid) 67 | 68 | if err != nil { 69 | return err 70 | } 71 | 72 | _, err = repo.Update( 73 | form.Name, 74 | form.URL, 75 | form.AccessKey, 76 | form.ClosePR, 77 | form.StatusPR, 78 | ) 79 | 80 | return err 81 | } 82 | 83 | func viewAdminListRepositories(w http.ResponseWriter, r *http.Request) { 84 | template := "repository/admin/list.html" 85 | ctx := make(responseContext) 86 | 87 | ctx["repositories"] = database.ListRepositories() 88 | 89 | render(w, r, template, ctx) 90 | } 91 | 92 | func viewAdminCreateRepository(w http.ResponseWriter, r *http.Request) { 93 | template := "repository/admin/create.html" 94 | ctx := make(responseContext) 95 | 96 | if r.Method == "POST" { 97 | repo, err := repositoryAdminForm{}.create(r) 98 | 99 | if err != nil { 100 | ctx["error"] = err.Error() 101 | } else { 102 | uri := fmt.Sprintf("/admin/repository/%d", repo.ID) 103 | http.Redirect(w, r, uri, 302) 104 | return 105 | } 106 | } 107 | 108 | render(w, r, template, ctx) 109 | } 110 | 111 | func viewAdminUpdateRepository(w http.ResponseWriter, r *http.Request) { 112 | template := "repository/admin/update.html" 113 | ctx := make(responseContext) 114 | 115 | vars := mux.Vars(r) 116 | rid, _ := strconv.Atoi(vars["rid"]) 117 | 118 | if r.Method == "POST" { 119 | err := repositoryAdminForm{}.update(r, int64(rid)) 120 | 121 | if err == nil { 122 | ctx["message"] = "Update successful." 123 | } else { 124 | ctx["error"] = err.Error() 125 | } 126 | } 127 | 128 | repo, err := database.GetRepositoryByID(int64(rid)) 129 | 130 | if err != nil { 131 | ctx["error"] = err.Error() 132 | } else { 133 | ctx["repository"] = repo 134 | } 135 | 136 | render(w, r, template, ctx) 137 | } 138 | 139 | func viewAdminDeleteRepository(w http.ResponseWriter, r *http.Request) { 140 | vars := mux.Vars(r) 141 | rid, _ := strconv.Atoi(vars["rid"]) 142 | 143 | repo, err := database.GetRepositoryByID(int64(rid)) 144 | 145 | if err == nil { 146 | repo.Delete() 147 | } 148 | 149 | http.Redirect(w, r, "/admin/repositories", 302) 150 | } 151 | -------------------------------------------------------------------------------- /web/router.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/GeertJohan/go.rice" 7 | "github.com/gorilla/mux" 8 | "github.com/gorilla/sessions" 9 | "github.com/justinas/alice" 10 | 11 | "github.com/fallenhitokiri/leeroyci/websocket" 12 | ) 13 | 14 | var sessionStore = sessions.NewCookieStore([]byte("something-very-secret")) 15 | 16 | // Routes returns a new Goriall router. 17 | func Routes() *mux.Router { 18 | chainUnauth := alice.New(middlewareLogging, middlewarePanic, middlewareNoConfig) 19 | chainAuth := alice.New(middlewareLogging, middlewarePanic, middlewareNoConfig, middlewareAuth) 20 | chainAdmin := alice.New(middlewareLogging, middlewarePanic, middlewareNoConfig, middlewareAuth, middlewareAdmin) 21 | 22 | router := mux.NewRouter() 23 | router.Handle("/setup", chainUnauth.ThenFunc(viewSetup)) 24 | router.Handle("/login", chainUnauth.ThenFunc(viewLogin)) 25 | router.Handle("/logout", chainUnauth.ThenFunc(viewLogout)) 26 | router.Handle("/callback/{service:[a-zA-Z]+}/{secret:[a-zA-Z0-9-]+}", chainUnauth.ThenFunc(viewCallback)) 27 | 28 | router.Handle("/", chainAuth.ThenFunc(viewListJobs)).Name("listJobs") 29 | router.Handle("/{jid:[0-9]+}", chainAuth.ThenFunc(viewDetailJob)) 30 | router.Handle("/{jid:[0-9]+}/cancel", chainAuth.ThenFunc(viewCancelJob)) 31 | router.Handle("/{jid:[0-9]+}/rerun", chainAuth.ThenFunc(viewRerunJob)) 32 | router.Handle("/search", chainAuth.ThenFunc(viewSearchJobs)) 33 | 34 | router.Handle("/user/settings", chainAuth.ThenFunc(viewUpdateUser)) 35 | router.Handle("/user/regenerate-accesskey", chainAuth.ThenFunc(viewRegenrateAccessKey)) 36 | 37 | router.Handle("/admin/users", chainAdmin.ThenFunc(viewAdminListUsers)) 38 | router.Handle("/admin/user/create", chainAdmin.ThenFunc(viewAdminCreateUser)) 39 | router.Handle("/admin/user/{uid:[0-9]+}", chainAdmin.ThenFunc(viewAdminUpdateUser)) 40 | router.Handle("/admin/user/delete/{uid:[0-9]+}", chainAdmin.ThenFunc(viewAdminDeleteUser)) 41 | 42 | router.Handle("/admin/repositories", chainAdmin.ThenFunc(viewAdminListRepositories)) 43 | router.Handle("/admin/repository/create", chainAdmin.ThenFunc(viewAdminCreateRepository)) 44 | router.Handle("/admin/repository/{rid:[0-9]+}", chainAdmin.ThenFunc(viewAdminUpdateRepository)) 45 | router.Handle("/admin/repository/delete/{rid:[0-9]+}", chainAdmin.ThenFunc(viewAdminDeleteRepository)) 46 | 47 | router.Handle("/admin/mailserver/update", chainAdmin.ThenFunc(viewAdminUpdateMailserver)) 48 | 49 | router.Handle("/admin/config/update", chainAdmin.ThenFunc(viewAdminUpdateConfig)) 50 | 51 | router.Handle("/admin/repository/{rid:[0-9]+}/notification/create", chainAdmin.ThenFunc(viewAdminCreateNotification)) 52 | router.Handle("/admin/repository/{rid:[0-9]+}/notification/{nid:[0-9]+}", chainAdmin.ThenFunc(viewAdminUpdateNotification)) 53 | router.Handle("/admin/repository/{rid:[0-9]+}/notification/delete/{nid:[0-9]+}", chainAdmin.ThenFunc(viewAdminDeleteNotification)) 54 | 55 | router.Handle("/admin/repository/{rid:[0-9]+}/command/create", chainAdmin.ThenFunc(viewAdminCreateCommand)) 56 | router.Handle("/admin/repository/{rid:[0-9]+}/command/{cid:[0-9]+}", chainAdmin.ThenFunc(viewAdminUpdateCommand)) 57 | router.Handle("/admin/repository/{rid:[0-9]+}/command/delete/{cid:[0-9]+}", chainAdmin.ThenFunc(viewAdminDeleteCommand)) 58 | 59 | // add rice box to serve static files. Do not use the full middleware stack but 60 | // only the logging handler. We do not want "notConfigured" to run e.x. so we 61 | // can make the setup look nice. 62 | box := rice.MustFindBox("static") 63 | staticFileServer := http.StripPrefix("/static/", http.FileServer(box.HTTPBox())) 64 | router.Handle("/static/{path:.*}", middlewareLogging(staticFileServer)) 65 | 66 | router.Handle("/websocket", middlewareAccessKey(websocket.GetHandler())) 67 | 68 | return router 69 | } 70 | -------------------------------------------------------------------------------- /web/setup.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gorilla/schema" 7 | 8 | "github.com/fallenhitokiri/leeroyci/database" 9 | ) 10 | 11 | type configForm struct { 12 | Email string `schema:"email"` 13 | FirstName string `schema:"first_name"` 14 | LastName string `schema:"last_name"` 15 | Password string `schema:"password"` 16 | 17 | URL string `schema:"url"` 18 | Secret string `schema:"secret"` 19 | SSLCert string `schema:"ssl_cert"` 20 | SSLKey string `schema:"ssl_key"` 21 | 22 | Host string `schema:"host"` 23 | Port int `schema:"port"` 24 | Sender string `schema:"sender"` 25 | SMTPUser string `schema:"smtp_user"` 26 | SMPTPassword string `schema:"smtp_password"` 27 | } 28 | 29 | func (c configForm) save(request *http.Request) (*database.User, error) { 30 | err := request.ParseForm() 31 | 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | decoder := schema.NewDecoder() 37 | form := new(configForm) 38 | 39 | err = decoder.Decode(form, request.PostForm) 40 | 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | user, err := database.CreateUser( 46 | form.Email, 47 | form.FirstName, 48 | form.LastName, 49 | form.Password, 50 | true, 51 | ) 52 | 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | database.AddConfig( 58 | form.Secret, 59 | form.URL, 60 | form.SSLCert, 61 | form.SSLKey, 62 | 1, 63 | ) 64 | 65 | database.AddMailServer( 66 | form.Host, 67 | form.Sender, 68 | form.SMTPUser, 69 | form.SMPTPassword, 70 | form.Port, 71 | ) 72 | 73 | return user, nil 74 | } 75 | 76 | func viewSetup(w http.ResponseWriter, r *http.Request) { 77 | template := "setup.html" 78 | ctx := make(responseContext) 79 | 80 | if r.Method == "POST" { 81 | _, err := configForm{}.save(r) 82 | 83 | if err == nil { 84 | database.Configured = true 85 | http.Redirect(w, r, "/login", 302) 86 | return 87 | } 88 | 89 | ctx["error"] = err.Error() 90 | } 91 | 92 | render(w, r, template, ctx) 93 | } 94 | -------------------------------------------------------------------------------- /web/static/build-images/buildsuccessful.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 12 | 15 | 16 | 17 | 20 | 25 | 28 | 33 | 38 | 39 | 44 | 49 | 50 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /web/static/build-images/failed.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 11 | 12 | 13 | 14 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /web/static/build-images/nobuild.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 14 | 18 | 21 | 22 | 23 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/static/css/base.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding-top:70px; 3 | padding-bottom:20px; 4 | font-size: 1.3em; 5 | } 6 | 7 | .navbar-brand img { 8 | height:30px; 9 | margin-top: -5px; 10 | } 11 | 12 | -------------------------------------------------------------------------------- /web/static/css/jobs.css: -------------------------------------------------------------------------------- 1 | .job-list { 2 | list-style-type: none; 3 | padding:0; 4 | } 5 | 6 | .job-list .job-row { 7 | background: #efefef; 8 | border-radius: 5px; 9 | border-width: 0 0 0 30px; 10 | border-style: solid; 11 | border-color: #efefef; 12 | padding:10px; 13 | margin:0 0 20px 0; 14 | } 15 | 16 | .job-list .job-success { 17 | border-color: rgb(102, 188, 92); 18 | } 19 | 20 | .job-list .job-error { 21 | border-color: rgb(212, 83, 83); 22 | } 23 | 24 | .job-list .job-pending { 25 | border-color: rgb(119, 119, 119); 26 | } 27 | 28 | .job-list .job-info { 29 | font-size:1.4em; 30 | margin-bottom: 15px; 31 | display:block; 32 | } 33 | 34 | .job-list .job-details { 35 | display:block; 36 | } 37 | 38 | .job-list .job-details span { 39 | margin-right: 20px; 40 | } 41 | 42 | .job-list .tasks { 43 | display: block; 44 | margin-bottom: 20px; 45 | font-size: 1.2em; 46 | } 47 | 48 | .job-list a { 49 | color:#000000; 50 | text-decoration: none; 51 | } 52 | 53 | .job-list a.btn { 54 | color:#FFFFFF; 55 | } 56 | 57 | -------------------------------------------------------------------------------- /web/static/css/login.css: -------------------------------------------------------------------------------- 1 | #login { 2 | background:#000; 3 | } 4 | 5 | #login #logo { 6 | text-align: center; 7 | margin-top: 30px; 8 | margin-bottom: 30px; 9 | } 10 | 11 | #login fieldset { 12 | background: white; 13 | border: 0 none; 14 | border-radius: 3px; 15 | box-shadow: 0 0 15px 1px rgba(0, 0, 0, 0.4); 16 | padding: 20px; 17 | box-sizing: border-box; 18 | width: 80%; 19 | margin: 0 10%; 20 | } 21 | 22 | #login h1 { 23 | font-size:22px; 24 | font-weight:bold; 25 | margin-bottom:20px; 26 | text-align: center; 27 | } 28 | 29 | #login button { 30 | width:100%; 31 | margin-top:10px; 32 | } 33 | -------------------------------------------------------------------------------- /web/static/css/setup.css: -------------------------------------------------------------------------------- 1 | #setup { 2 | background:#000; 3 | } 4 | 5 | #setup h3 { 6 | font-size: 20px; 7 | font-weight: bold; 8 | margin:0 0 10px 0; 9 | } 10 | 11 | #setup label { 12 | font-size:12px; 13 | font-weight:normal; 14 | } 15 | 16 | #setup #logo { 17 | text-align: center; 18 | margin-top: 30px; 19 | margin-bottom: 30px; 20 | } 21 | 22 | #setup fieldset { 23 | background: white; 24 | border: 0 none; 25 | border-radius: 3px; 26 | box-shadow: 0 0 15px 1px rgba(0, 0, 0, 0.4); 27 | padding: 20px; 28 | box-sizing: border-box; 29 | width: 80%; 30 | margin: 0 10%; 31 | position: absolute; 32 | } 33 | 34 | #setup fieldset:not(:first-of-type) { 35 | display: none; 36 | } 37 | 38 | 39 | #setup #progressbar { 40 | margin-bottom: 30px; 41 | overflow: hidden; 42 | counter-reset: step; 43 | text-align: center; 44 | } 45 | 46 | #setup #progressbar li { 47 | list-style-type: none; 48 | color: white; 49 | text-transform: uppercase; 50 | font-size: 9px; 51 | width: 33.33%; 52 | float: left; 53 | position: relative; 54 | } 55 | 56 | #setup #progressbar li:before { 57 | content: counter(step); 58 | counter-increment: step; 59 | width: 20px; 60 | line-height: 20px; 61 | display: block; 62 | font-size: 10px; 63 | color: #333; 64 | background: white; 65 | border-radius: 3px; 66 | margin: 0 auto 5px auto; 67 | } 68 | 69 | #setup #progressbar li:after { 70 | content: ''; 71 | width: 100%; 72 | height: 2px; 73 | background: white; 74 | position: absolute; 75 | left: -50%; 76 | top: 9px; 77 | z-index: -1; 78 | } 79 | 80 | #setup #progressbar li:first-child:after { 81 | content: none; 82 | } 83 | 84 | #setup #progressbar li.active:before, #progressbar li.active:after{ 85 | background: rgb(229, 14, 15); 86 | color: white; 87 | } 88 | -------------------------------------------------------------------------------- /web/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fallenhitokiri/leeroyci/cd8a5fb95f63d53385d803439b0b48de70105119/web/static/favicon.png -------------------------------------------------------------------------------- /web/static/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fallenhitokiri/leeroyci/cd8a5fb95f63d53385d803439b0b48de70105119/web/static/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /web/static/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fallenhitokiri/leeroyci/cd8a5fb95f63d53385d803439b0b48de70105119/web/static/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /web/static/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fallenhitokiri/leeroyci/cd8a5fb95f63d53385d803439b0b48de70105119/web/static/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /web/static/fonts/glyphicons-halflings-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fallenhitokiri/leeroyci/cd8a5fb95f63d53385d803439b0b48de70105119/web/static/fonts/glyphicons-halflings-regular.woff2 -------------------------------------------------------------------------------- /web/static/js/npm.js: -------------------------------------------------------------------------------- 1 | // This file is autogenerated via the `commonjs` Grunt task. You can require() this file in a CommonJS environment. 2 | require('../../js/transition.js') 3 | require('../../js/alert.js') 4 | require('../../js/button.js') 5 | require('../../js/carousel.js') 6 | require('../../js/collapse.js') 7 | require('../../js/dropdown.js') 8 | require('../../js/modal.js') 9 | require('../../js/tooltip.js') 10 | require('../../js/popover.js') 11 | require('../../js/scrollspy.js') 12 | require('../../js/tab.js') 13 | require('../../js/affix.js') -------------------------------------------------------------------------------- /web/static/js/setup.js: -------------------------------------------------------------------------------- 1 | var current_fs, next_fs, previous_fs; 2 | var left, opacity, scale; 3 | var animating; 4 | 5 | $(".next").click(function() { 6 | if(animating) return false; 7 | animating = true; 8 | 9 | current_fs = $(this).parent(); 10 | next_fs = $(this).parent().next(); 11 | 12 | $("#progressbar li").eq($("fieldset").index(next_fs)).addClass("active"); 13 | 14 | next_fs.show(); 15 | 16 | current_fs.animate({opacity: 0}, { 17 | step: function(now, mx) { 18 | scale = 1 - (1 - now) * 0.2; 19 | left = (now * 50)+"%"; 20 | opacity = 1 - now; 21 | current_fs.css({'transform': 'scale('+scale+')'}); 22 | next_fs.css({'left': left, 'opacity': opacity}); 23 | }, 24 | duration: 800, 25 | complete: function() { 26 | current_fs.hide(); 27 | animating = false; 28 | }, 29 | easing: 'easeInOutBack' 30 | }); 31 | }); 32 | 33 | $(".previous").click(function() { 34 | if(animating) return false; 35 | animating = true; 36 | 37 | current_fs = $(this).parent(); 38 | previous_fs = $(this).parent().prev(); 39 | 40 | $("#progressbar li").eq($("fieldset").index(current_fs)).removeClass("active"); 41 | 42 | previous_fs.show(); 43 | 44 | current_fs.animate({opacity: 0}, { 45 | step: function(now, mx) { 46 | scale = 0.8 + (1 - now) * 0.2; 47 | left = ((1-now) * 50)+"%"; 48 | opacity = 1 - now; 49 | current_fs.css({'left': left}); 50 | previous_fs.css({'transform': 'scale('+scale+')', 'opacity': opacity}); 51 | }, 52 | duration: 800, 53 | complete: function() { 54 | current_fs.hide(); 55 | animating = false; 56 | }, 57 | easing: 'easeInOutBack' 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /web/static/leeroy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fallenhitokiri/leeroyci/cd8a5fb95f63d53385d803439b0b48de70105119/web/static/leeroy.png -------------------------------------------------------------------------------- /web/template.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "html/template" 5 | "net/http" 6 | 7 | "github.com/GeertJohan/go.rice" 8 | "github.com/gorilla/context" 9 | ) 10 | 11 | type responseContext map[string]interface{} 12 | 13 | func render(w http.ResponseWriter, r *http.Request, template string, ctx responseContext) { 14 | tmpl := getTemplates(template) 15 | 16 | ctx["user"] = context.Get(r, contextUser) 17 | 18 | tmpl.Execute(w, ctx) 19 | } 20 | 21 | // getTemplates returns the template 'name' fully prepared for rendering. 22 | func getTemplates(name string) *template.Template { 23 | box, err := rice.FindBox("templates") 24 | if err != nil { 25 | panic(err) 26 | } 27 | 28 | base, err := box.String("base.html") 29 | 30 | if err != nil { 31 | panic(err) 32 | } 33 | 34 | tmpl, err := template.New(name).Parse(base) 35 | 36 | base, err = box.String(name) 37 | 38 | if err != nil { 39 | panic(err) 40 | } 41 | 42 | tmpl, err = tmpl.Parse(base) 43 | 44 | if err != nil { 45 | panic(err) 46 | } 47 | 48 | return tmpl 49 | } 50 | -------------------------------------------------------------------------------- /web/template_test.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestGetTemplates(t *testing.T) { 8 | template := getTemplates("job/list.html") 9 | 10 | if template.Name() != "job/list.html" { 11 | t.Error("Got wrong template", template.Name()) 12 | } 13 | 14 | if len(template.Templates()) != 6 { 15 | t.Error("Wrong template count", len(template.Templates())) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /web/templates/403.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}403{{ end }} 2 | 3 | {{ define "body" }}permission-denied{{ end }} 4 | 5 | {{ define "content" }} 6 |
7 |
8 |
9 | No permission to do that. 10 |
11 |
12 |
13 | {{ end }} 14 | 15 | {{ define "js" }}{{ end }} 16 | 17 | {{ define "css" }}{{ end }} 18 | -------------------------------------------------------------------------------- /web/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{template "title" .}} 5 | 6 | 7 | 8 | 9 | 10 | 11 | {{template "css" .}} 12 | 13 | 14 | 15 | 16 | {{ if .user }} 17 | 63 | {{ end }} 64 | 65 |
66 | {{template "content" .}} 67 |
68 | 69 | 70 | 71 | {{template "js" .}} 72 | 73 | 74 | -------------------------------------------------------------------------------- /web/templates/command/admin/create.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Add New Command for {{ .repository.Name }}{{ end }} 2 | 3 | {{ define "body" }}admin-command-add{{ end }} 4 | 5 | {{ define "content" }} 6 |
7 |
8 |
9 |
10 |

Add a new command

11 | {{ if .error }} 12 |
13 | {{ .error }} 14 |
15 | {{ end }} 16 | 17 |
18 | 19 | 24 |
25 | 26 |
27 | 28 | 29 |
30 | 31 |
32 | 33 | 34 |
35 | 36 |
37 | 38 | 39 |
40 | 41 | 42 | Cancel 43 |
44 |
45 |
46 |
47 | {{ end }} 48 | 49 | {{ define "js" }}{{ end }} 50 | 51 | {{ define "css" }}{{ end }} 52 | -------------------------------------------------------------------------------- /web/templates/command/admin/update.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Edit Command{{ end }} 2 | 3 | {{ define "body" }}admin-command-edit{{ end }} 4 | 5 | {{ define "content" }} 6 |
7 |
8 |
9 |
10 |

11 | Edit command 12 | Back 13 |

14 | {{ if .error }} 15 |
16 | {{ .error }} 17 |
18 | {{ end }} 19 | 20 | {{ if .message }} 21 |
22 | {{ .message }} 23 |
24 | {{ end }} 25 | 26 |
27 | 28 | 33 |
34 | 35 |
36 | 37 | 38 |
39 | 40 |
41 | 42 | 43 |
44 | 45 |
46 | 47 | 48 |
49 | 50 | 51 | Delete Command 52 |
53 |
54 |
55 |
56 | {{ end }} 57 | 58 | {{ define "js" }}{{ end }} 59 | 60 | {{ define "css" }}{{ end }} 61 | -------------------------------------------------------------------------------- /web/templates/config/admin/update.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Update Mailserver{{ end }} 2 | 3 | {{ define "body" }}admin-config-update{{ end }} 4 | 5 | {{ define "content" }} 6 |
7 |
8 |
9 |
10 |

{{ .repository.Name }}

11 | {{ if .message }} 12 |
13 | {{ .message }} 14 |
15 | {{ end }} 16 | 17 | {{ if .error }} 18 |
19 | {{ .error }} 20 |
21 | {{ end }} 22 | 23 |
24 | 25 | 26 |
27 | 28 |
29 | 30 | 31 |
32 | 33 |
34 | 35 | 36 |
37 | 38 |
39 | 40 | 41 |
42 | 43 |
44 | 45 | 46 |
47 | 48 | 49 |
50 |
51 |
52 |
53 | {{ end }} 54 | 55 | {{ define "js" }}{{ end }} 56 | 57 | {{ define "css" }}{{ end }} 58 | -------------------------------------------------------------------------------- /web/templates/job/detail.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Job {{ .job.ID }} - LeeroyCI{{ end }} 2 | 3 | {{ define "body" }}detail-job{{ end }} 4 | 5 | {{ define "content" }} 6 |
7 |
8 | {{ if .job }} 9 |
    10 |
  • 11 | {{ .job.Repository.Name }} {{ .job.Branch }} 12 | 13 | 14 | {{ range .job.CommandLogs }} 15 | {{ .Name }} 16 |
    {{ .Output }}
    17 | {{ end }} 18 |
    19 | 20 | 21 | 22 | {{ .job.Name }} <{{ .job.Email }}> 23 | 24 | 25 | {{ .job.Commit }} 26 | 27 | 28 | 29 | {{ .job.CreatedAt }} 30 | 31 | 32 |
  • 33 |
34 | {{ else }} 35 |
36 | No job found! 37 |
38 | {{ end }} 39 |
40 |
41 | {{ end }} 42 | 43 | {{ define "js" }}{{ end }} 44 | 45 | {{ define "css" }} 46 | 47 | {{ end }} 48 | -------------------------------------------------------------------------------- /web/templates/job/list.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Jobs - LeeroyCI{{ end }} 2 | 3 | {{ define "body" }}list-jobs{{ end }} 4 | 5 | {{ define "content" }} 6 |
7 |
8 | {{ if .jobs }} 9 | {{ if .query }} 10 |

Search results for "{{ .query }}"

11 | {{ end }} 12 | 13 | 66 | {{ else }} 67 |
68 | No jobs, give me something to do! 69 |
70 | {{ end }} 71 |
72 |
73 | {{ end }} 74 | 75 | {{ define "js" }}{{ end }} 76 | 77 | {{ define "css" }} 78 | 79 | {{ end }} 80 | -------------------------------------------------------------------------------- /web/templates/login.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Login{{ end }} 2 | 3 | {{ define "body" }}login{{ end }} 4 | 5 | {{ define "top" }}{{ end }} 6 | 7 | {{ define "content" }} 8 |
9 | 12 |
13 | 14 |
15 |
16 |
17 |
18 |

Sign In

19 | 20 | {{ if .error }} 21 |
22 | {{ .error }} 23 |
24 | {{ end }} 25 | 26 |
27 | 28 | 29 |
30 | 31 |
32 | 33 | 34 |
35 | 36 | 37 |
38 |
39 |
40 |
41 | {{ end }} 42 | 43 | {{ define "js" }}{{ end }} 44 | 45 | {{ define "css" }} 46 | 47 | {{ end }} 48 | -------------------------------------------------------------------------------- /web/templates/mailserver/admin/update.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Update Mailserver{{ end }} 2 | 3 | {{ define "body" }}admin-mailserver-update{{ end }} 4 | 5 | {{ define "content" }} 6 |
7 |
8 |
9 |
10 |

{{ .repository.Name }}

11 | {{ if .message }} 12 |
13 | {{ .message }} 14 |
15 | {{ end }} 16 | 17 | {{ if .error }} 18 |
19 | {{ .error }} 20 |
21 | {{ end }} 22 | 23 |
24 | 25 | 26 |
27 | 28 |
29 | 30 | 31 |
32 | 33 |
34 | 35 | 36 |
37 | 38 |
39 | 40 | 41 |
42 | 43 |
44 | 45 | 46 |
47 | 48 | 49 |
50 |
51 |
52 |
53 | {{ end }} 54 | 55 | {{ define "js" }}{{ end }} 56 | 57 | {{ define "css" }}{{ end }} 58 | -------------------------------------------------------------------------------- /web/templates/notification/admin/create.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Add New Notification for {{ .repository.Name }}{{ end }} 2 | 3 | {{ define "body" }}admin-notification-add{{ end }} 4 | 5 | {{ define "content" }} 6 |
7 |
8 |
9 |
10 |

Add a new notification

11 | {{ if .error }} 12 |
13 | {{ .error }} 14 |
15 | {{ end }} 16 | 17 |
18 | 19 | 24 |
25 | 26 |
27 | 28 |

29 |

Separate keys and values with 3 : and key - value pairs with 5 :.

30 |

Example channel:::#dev:::::token:::foobar

31 |
32 | 33 | 34 | Cancel 35 |
36 |
37 |
38 |
39 | {{ end }} 40 | 41 | {{ define "js" }}{{ end }} 42 | 43 | {{ define "css" }}{{ end }} 44 | -------------------------------------------------------------------------------- /web/templates/notification/admin/update.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Edit Notification{{ end }} 2 | 3 | {{ define "body" }}admin-notification-edit{{ end }} 4 | 5 | {{ define "content" }} 6 |
7 |
8 |
9 |
10 |

11 | Edit notification 12 | Back 13 |

14 | {{ if .error }} 15 |
16 | {{ .error }} 17 |
18 | {{ end }} 19 | 20 | {{ if .message }} 21 |
22 | {{ .message }} 23 |
24 | {{ end }} 25 | 26 |
27 | 28 | 33 |
34 | 35 |
36 | 37 |

38 |

Separate keys and values with 3 : and key - value pairs with 5 :.

39 |

Example channel:::#dev:::::token:::foobar

40 |
41 | 42 | 43 | Delete Notification 44 |
45 |
46 |
47 |
48 | {{ end }} 49 | 50 | {{ define "js" }}{{ end }} 51 | 52 | {{ define "css" }}{{ end }} 53 | -------------------------------------------------------------------------------- /web/templates/repository/admin/create.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Add New Repository{{ end }} 2 | 3 | {{ define "body" }}admin-repository-add{{ end }} 4 | 5 | {{ define "content" }} 6 |
7 |
8 |
9 |
10 |

Add a new repository

11 | {{ if .error }} 12 |
13 | {{ .error }} 14 |
15 | {{ end }} 16 | 17 |
18 | 19 | 20 |
21 | 22 |
23 | 24 | 25 |
26 | 27 |
28 | 29 | 30 |
31 | 32 |
33 | 34 | 35 |
36 | 37 |
38 | 39 | 40 |
41 | 42 | 43 |
44 |
45 |
46 |
47 | {{ end }} 48 | 49 | {{ define "js" }}{{ end }} 50 | 51 | {{ define "css" }}{{ end }} 52 | -------------------------------------------------------------------------------- /web/templates/repository/admin/list.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}All Repositories{{ end }} 2 | 3 | {{ define "body" }}admin-repository-list{{ end }} 4 | 5 | {{ define "content" }} 6 |
7 |
8 |

Repositories Add Repository

9 |
    10 | {{ range .repositories }} 11 |
  • {{ .Name }} 12 | {{ end }} 13 |
14 |
15 |
16 | {{ end }} 17 | 18 | {{ define "js" }}{{ end }} 19 | 20 | {{ define "css" }}{{ end }} 21 | -------------------------------------------------------------------------------- /web/templates/repository/admin/update.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Edit {{ .repository.Name }}{{ end }} 2 | 3 | {{ define "body" }}admin-repository-edit{{ end }} 4 | 5 | {{ define "content" }} 6 |
7 |
8 |
9 |
10 |

{{ .repository.Name }}

11 | {{ if .message }} 12 |
13 | {{ .message }} 14 |
15 | {{ end }} 16 | 17 | {{ if .error }} 18 |
19 | {{ .error }} 20 |
21 | {{ end }} 22 | 23 |
24 | 25 | 26 |
27 | 28 |
29 | 30 | 31 |
32 | 33 |
34 | 35 | 36 |
37 | 38 |
39 | 40 | 41 |
42 | 43 |
44 | 45 | 46 |
47 | 48 | 49 | Delete Repository 50 |
51 |
52 |
53 | 54 |
55 |

56 | Commands 57 | Add Command 58 |

59 | 60 | {{ if .repository.Commands }} 61 |
    62 | {{ range .repository.Commands }} 63 |
  • 64 | {{ .Name }} 65 | {{ .Kind }} 66 | {{ .Branch }} 67 |
  • 68 | {{ end }} 69 |
70 | {{ else }} 71 |
72 | No commands configured yet - go ahead and add one so things 73 | happen when you push new commits :) 74 |
75 | {{ end }} 76 | 77 | 78 | 79 |

80 | Notifications 81 | Add Notification 82 |

83 | {{ if .repository.Notifications }} 84 | 91 | {{ else }} 92 |
93 | No notifications configured yet - go ahead and add one so you 94 | know what is going on :) 95 |
96 | {{ end }} 97 | 98 |
99 |
100 | {{ end }} 101 | 102 | {{ define "js" }}{{ end }} 103 | 104 | {{ define "css" }}{{ end }} 105 | -------------------------------------------------------------------------------- /web/templates/setup.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Setup{{ end }} 2 | 3 | {{ define "body" }}setup{{ end }} 4 | 5 | {{ define "top" }}{{ end }} 6 | 7 | {{ define "content" }} 8 |
9 | 12 |
13 | 14 |
15 |
16 |
17 |
    18 |
  • Account Setup
  • 19 |
  • Webserver
  • 20 |
  • Mailserver
  • 21 |
22 | 23 |
24 |

Create admin user

25 | 26 |
27 | 28 | 29 |
30 | 31 |
32 | 33 | 34 |
35 | 36 |
37 | 38 | 39 |
40 | 41 |
42 | 43 | 44 |
45 | 46 | 47 |
48 | 49 |
50 |

Webserver Configuration

51 | 52 |
53 | 54 | 55 |
56 | 57 |
58 | 59 | 60 |
61 | 62 |
63 | 64 | 65 |
66 | 67 |
68 | 69 | 70 |
71 | 72 | 73 | 74 |
75 | 76 |
77 |

Mailserver Configuration

78 | 79 |
80 | 81 | 82 |
83 | 84 |
85 | 86 | 87 |
88 | 89 |
90 | 91 | 92 |
93 | 94 |
95 | 96 | 97 |
98 | 99 |
100 | 101 | 102 |
103 | 104 | 105 | 106 |
107 |
108 |
109 |
110 | {{ end }} 111 | 112 | {{ define "js" }} 113 | 114 | 115 | {{ end }} 116 | 117 | {{ define "css" }} 118 | 119 | {{ end }} 120 | -------------------------------------------------------------------------------- /web/templates/user/admin/create.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Add New User{{ end }} 2 | 3 | {{ define "body" }}admin-user-add{{ end }} 4 | 5 | {{ define "content" }} 6 |
7 |
8 |
9 |
10 |

Add a new user

11 | {{ if .error }} 12 |
13 | {{ .error }} 14 |
15 | {{ end }} 16 | 17 |
18 | 19 | 20 |
21 | 22 |
23 | 24 | 25 |
26 | 27 |
28 | 29 | 30 |
31 | 32 |
33 | 34 | 35 |
36 | 37 |
38 | 39 | 40 |
41 | 42 | 43 |
44 |
45 |
46 |
47 | {{ end }} 48 | 49 | {{ define "js" }}{{ end }} 50 | 51 | {{ define "css" }}{{ end }} 52 | -------------------------------------------------------------------------------- /web/templates/user/admin/list.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}All Users{{ end }} 2 | 3 | {{ define "body" }}admin-user-list{{ end }} 4 | 5 | {{ define "content" }} 6 |
7 |
8 |

Users Add User

9 | 14 |
15 |
16 | {{ end }} 17 | 18 | {{ define "js" }}{{ end }} 19 | 20 | {{ define "css" }}{{ end }} 21 | -------------------------------------------------------------------------------- /web/templates/user/admin/update.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Edit {{ .edit_user.FirstName }} {{ .edit_user.LastName }}{{ end }} 2 | 3 | {{ define "body" }}admin-user-edit{{ end }} 4 | 5 | {{ define "content" }} 6 |
7 |
8 |
9 |
10 |

{{ .edit_user.FirstName }} {{ .edit_user.LastName }}

11 | {{ if .message }} 12 |
13 | {{ .message }} 14 |
15 | {{ end }} 16 | 17 | {{ if .error }} 18 |
19 | {{ .error }} 20 |
21 | {{ end }} 22 | 23 |
24 | 25 | 26 |
27 | 28 |
29 | 30 | 31 |
32 | 33 |
34 | 35 | 36 |
37 | 38 |
39 | 40 | 41 |
42 | 43 |
44 | 45 | 46 |
47 | 48 | 49 | Delete User 50 |
51 |
52 |
53 |
54 | {{ end }} 55 | 56 | {{ define "js" }}{{ end }} 57 | 58 | {{ define "css" }}{{ end }} 59 | -------------------------------------------------------------------------------- /web/templates/user/settings.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Settings{{ end }} 2 | 3 | {{ define "body" }}user-settings{{ end }} 4 | 5 | {{ define "content" }} 6 |
7 |
8 |
9 |
10 |

Update your information

11 | {{ if .message }} 12 |
13 | {{ .message }} 14 |
15 | {{ end }} 16 | 17 | {{ if .error }} 18 |
19 | {{ .error }} 20 |
21 | {{ end }} 22 | 23 |
24 | 25 | 26 |
27 | 28 |
29 | 30 | 31 |
32 | 33 |
34 | 35 | 36 |
37 | 38 |
39 | 40 | 41 |
42 | 43 |

Leave this field blank if you do not want to change your password

44 |
45 | 46 | 47 |
48 | 49 |
50 |

Access Key: {{ .user.AccessKey }}

51 |
52 | 53 | 54 | Regenrate Access Key 55 |
56 |
57 |
58 |
59 | {{ end }} 60 | 61 | {{ define "js" }}{{ end }} 62 | 63 | {{ define "css" }}{{ end }} 64 | -------------------------------------------------------------------------------- /web/user.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/gorilla/context" 8 | "github.com/gorilla/schema" 9 | 10 | "github.com/fallenhitokiri/leeroyci/database" 11 | ) 12 | 13 | // userSettingsForm is the form used by users to edit their account. Every 14 | // change requires the password to be entered. Admin status cannot be changed. 15 | type userSettingsForm struct { 16 | Email string `schema:"email"` 17 | FirstName string `schema:"first_name"` 18 | LastName string `schema:"last_name"` 19 | Password string `schema:"password"` 20 | NewPassword string `schema:"new_password"` 21 | } 22 | 23 | // update updates an existing user account. The admin flag passed is taken from 24 | // the user that was fetched from the DB, it cannot be edited through the form. 25 | func (u userSettingsForm) update(request *http.Request) error { 26 | err := request.ParseForm() 27 | 28 | if err != nil { 29 | return err 30 | } 31 | 32 | decoder := schema.NewDecoder() 33 | form := new(userSettingsForm) 34 | 35 | err = decoder.Decode(form, request.PostForm) 36 | 37 | if err != nil { 38 | return err 39 | } 40 | 41 | user := context.Get(request, contextUser).(*database.User) 42 | 43 | auth := database.ComparePassword(form.Password, user.Password) 44 | 45 | if auth == false { 46 | return errors.New("Username and password do not match.") 47 | } 48 | 49 | _, err = user.Update( 50 | form.Email, 51 | form.FirstName, 52 | form.LastName, 53 | form.NewPassword, 54 | user.Admin, 55 | ) 56 | 57 | return err 58 | } 59 | 60 | // viewUpdateUser exposes configuration settings for a user account to the 61 | // user. Admin status cannot be changed here. 62 | func viewUpdateUser(w http.ResponseWriter, r *http.Request) { 63 | template := "user/settings.html" 64 | ctx := make(responseContext) 65 | 66 | if r.Method == "POST" { 67 | err := userSettingsForm{}.update(r) 68 | 69 | if err == nil { 70 | ctx["message"] = "Update successful." 71 | } else { 72 | ctx["error"] = err.Error() 73 | } 74 | } 75 | 76 | render(w, r, template, ctx) 77 | } 78 | 79 | // viewRegenrateAccessKey regenerates the access key for a user. 80 | func viewRegenrateAccessKey(w http.ResponseWriter, r *http.Request) { 81 | user := context.Get(r, contextUser).(*database.User) 82 | user.NewAccessKey() 83 | http.Redirect(w, r, "/user/settings", 302) 84 | } 85 | -------------------------------------------------------------------------------- /web/user_admin.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | 7 | "github.com/gorilla/mux" 8 | "github.com/gorilla/schema" 9 | 10 | "github.com/fallenhitokiri/leeroyci/database" 11 | ) 12 | 13 | // userAdminForm is the form used by admins to edit users. 14 | type userAdminForm struct { 15 | Email string `schema:"email"` 16 | FirstName string `schema:"first_name"` 17 | LastName string `schema:"last_name"` 18 | Password string `schema:"password"` 19 | Admin bool `schema:"is_admin"` 20 | } 21 | 22 | // create creates a new user in the system. 23 | func (u userAdminForm) create(request *http.Request) error { 24 | err := request.ParseForm() 25 | 26 | if err != nil { 27 | return err 28 | } 29 | 30 | decoder := schema.NewDecoder() 31 | form := new(userAdminForm) 32 | 33 | err = decoder.Decode(form, request.PostForm) 34 | 35 | if err != nil { 36 | return err 37 | } 38 | 39 | _, err = database.CreateUser( 40 | form.Email, 41 | form.FirstName, 42 | form.LastName, 43 | form.Password, 44 | form.Admin, 45 | ) 46 | 47 | return err 48 | } 49 | 50 | // update updates an existing user based on the form. 51 | func (u userAdminForm) update(request *http.Request, uid int64) error { 52 | err := request.ParseForm() 53 | 54 | if err != nil { 55 | return err 56 | } 57 | 58 | decoder := schema.NewDecoder() 59 | form := new(userAdminForm) 60 | 61 | err = decoder.Decode(form, request.PostForm) 62 | 63 | if err != nil { 64 | return err 65 | } 66 | 67 | user, err := database.GetUserByID(uid) 68 | 69 | if err != nil { 70 | return err 71 | } 72 | 73 | _, err = user.Update( 74 | form.Email, 75 | form.FirstName, 76 | form.LastName, 77 | form.Password, 78 | form.Admin, 79 | ) 80 | 81 | return err 82 | } 83 | 84 | // viewAdminListUsers lists all users in the system. 85 | func viewAdminListUsers(w http.ResponseWriter, r *http.Request) { 86 | template := "user/admin/list.html" 87 | ctx := make(responseContext) 88 | 89 | ctx["users"] = database.ListUsers() 90 | 91 | render(w, r, template, ctx) 92 | } 93 | 94 | // viewAdminCreateUser creates a new user. 95 | func viewAdminCreateUser(w http.ResponseWriter, r *http.Request) { 96 | template := "user/admin/create.html" 97 | ctx := make(responseContext) 98 | 99 | if r.Method == "POST" { 100 | err := userAdminForm{}.create(r) 101 | 102 | if err != nil { 103 | ctx["error"] = err.Error() 104 | } else { 105 | http.Redirect(w, r, "/admin/users", 302) 106 | return 107 | } 108 | } 109 | 110 | render(w, r, template, ctx) 111 | } 112 | 113 | // viewAdminUpdateUser edits a user for a given uid. 114 | func viewAdminUpdateUser(w http.ResponseWriter, r *http.Request) { 115 | template := "user/admin/update.html" 116 | ctx := make(responseContext) 117 | 118 | vars := mux.Vars(r) 119 | uid, _ := strconv.Atoi(vars["uid"]) 120 | 121 | if r.Method == "POST" { 122 | err := userAdminForm{}.update(r, int64(uid)) 123 | 124 | if err == nil { 125 | ctx["message"] = "Update successful." 126 | } else { 127 | ctx["error"] = err.Error() 128 | } 129 | } 130 | 131 | user, err := database.GetUserByID(int64(uid)) 132 | 133 | if err != nil { 134 | ctx["error"] = err.Error() 135 | } else { 136 | ctx["edit_user"] = user 137 | } 138 | 139 | render(w, r, template, ctx) 140 | } 141 | 142 | // viewAdminDeleteUser deletes a user for a given uid. 143 | func viewAdminDeleteUser(w http.ResponseWriter, r *http.Request) { 144 | vars := mux.Vars(r) 145 | uid, _ := strconv.Atoi(vars["uid"]) 146 | 147 | user, err := database.GetUserByID(int64(uid)) 148 | 149 | if err == nil { 150 | user.Delete() 151 | } 152 | 153 | http.Redirect(w, r, "/admin/users", 302) 154 | } 155 | -------------------------------------------------------------------------------- /web/utils.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "strconv" 5 | ) 6 | 7 | func stringToInt(number string) int { 8 | conv, err := strconv.Atoi(number) 9 | 10 | if err != nil { 11 | panic(err) 12 | } 13 | 14 | return conv 15 | } 16 | -------------------------------------------------------------------------------- /web/utils_test.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestStringToIntPanic(t *testing.T) { 8 | defer func() { 9 | if err := recover(); err == nil { 10 | t.Error("No panic when passing in character string") 11 | } 12 | }() 13 | 14 | stringToInt("asdf") 15 | } 16 | 17 | func TestStringToInt(t *testing.T) { 18 | num := stringToInt("123") 19 | 20 | if num != 123 { 21 | t.Error("Wrong number", num) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /websocket/client.go: -------------------------------------------------------------------------------- 1 | // Package websocket implements the websocket protocol according to RFC 6455. 2 | // Websockets are used to send updates for all events through the notifications 3 | // package. 4 | package websocket 5 | 6 | import ( 7 | "io" 8 | "log" 9 | 10 | "golang.org/x/net/websocket" 11 | ) 12 | 13 | type client struct { 14 | socket *websocket.Conn 15 | } 16 | 17 | // NewClient returns a new websocket client. 18 | func NewClient(ws *websocket.Conn) *client { 19 | return &client{ 20 | socket: ws, 21 | } 22 | } 23 | 24 | func (c *client) write(msg *Message) { 25 | websocket.JSON.Send(c.socket, msg) 26 | } 27 | 28 | func (c *client) listen() { 29 | for { 30 | select { 31 | 32 | default: 33 | var msg Message 34 | err := websocket.JSON.Receive(c.socket, &msg) 35 | if err == io.EOF { 36 | socketServer.removeClient(c) 37 | return 38 | } else if err != nil { 39 | log.Println(err) 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /websocket/client/receiver.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "golang.org/x/net/websocket" 8 | ) 9 | 10 | var origin = "http://localhost/" 11 | var url = "ws://localhost:8082/websocket" 12 | 13 | func main() { 14 | config, err := websocket.NewConfig(url, origin) 15 | config.Header["accesskey"] = []string{"gNV/bxhxG)IrvEeaZK_mA2HkCxC2yu!bHjGK!(MLNQ1tuDnUKyGBL9G/rGhXHNzU"} 16 | 17 | ws, err := websocket.DialConfig(config) 18 | if err != nil { 19 | log.Fatal(err) 20 | } 21 | 22 | for { 23 | var msg = make([]byte, 512) 24 | _, err = ws.Read(msg) 25 | if err != nil { 26 | log.Fatal(err) 27 | } 28 | fmt.Printf("Receive: %s\n", msg) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /websocket/client_test.go: -------------------------------------------------------------------------------- 1 | package websocket 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "golang.org/x/net/websocket" 10 | ) 11 | 12 | func TestClient(t *testing.T) { 13 | http.Handle("/websocket", GetHandler()) 14 | server := httptest.NewServer(nil) 15 | defer server.Close() 16 | uri := fmt.Sprintf("ws://%s%s", server.Listener.Addr(), "/websocket") 17 | 18 | ws, err := websocket.Dial(uri, "", "http://localhost") 19 | 20 | if err != nil { 21 | t.Error(err) 22 | } 23 | 24 | client := NewClient(ws) 25 | 26 | msg := &Message{ 27 | Event: "foo", 28 | } 29 | client.write(msg) 30 | } 31 | -------------------------------------------------------------------------------- /websocket/message.go: -------------------------------------------------------------------------------- 1 | // Package websocket implements the websocket protocol according to RFC 6455. 2 | // Websockets are used to send updates for all events through the notifications 3 | // package. 4 | package websocket 5 | 6 | import ( 7 | "github.com/fallenhitokiri/leeroyci/database" 8 | ) 9 | 10 | // Message contains all fields consumed by differetn websockets to render 11 | // different events. 12 | type Message struct { 13 | Name string `json:"name"` 14 | Email string `json:"email"` 15 | Event string `json:"event"` 16 | RepositoryName string `json:"repository_name"` 17 | Branch string `json:"branch"` 18 | Status string `json:"status"` 19 | URL string `json:"url"` 20 | } 21 | 22 | // NewMessage converts a job and event type to a message that can be send 23 | // through a websocket. 24 | func NewMessage(job *database.Job, event string) *Message { 25 | return &Message{ 26 | Name: job.Name, 27 | Email: job.Email, 28 | Event: event, 29 | RepositoryName: job.Repository.Name, 30 | Branch: job.Branch, 31 | Status: job.Status(), 32 | URL: job.URL(), 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /websocket/message_test.go: -------------------------------------------------------------------------------- 1 | package websocket 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/fallenhitokiri/leeroyci/database" 7 | ) 8 | 9 | func TestNewMessage(t *testing.T) { 10 | database.NewInMemoryDatabase() 11 | repo, _ := database.CreateRepository("foo", "baz", "accessKey", false, false) 12 | job := database.CreateJob(repo, "branch", "commit", "commitURL", "name", "email") 13 | 14 | msg := NewMessage(job, "foo") 15 | 16 | if msg.Status != database.JobStatusPending { 17 | t.Error("Wrong status", msg.Status) 18 | } 19 | 20 | if msg.RepositoryName != "foo" { 21 | t.Error("Wrong repository name", msg.RepositoryName) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /websocket/server.go: -------------------------------------------------------------------------------- 1 | // Package websocket implements the websocket protocol according to RFC 6455. 2 | // Websockets are used to send updates for all events through the notifications 3 | // package. 4 | package websocket 5 | 6 | import ( 7 | "log" 8 | 9 | "golang.org/x/net/websocket" 10 | ) 11 | 12 | var socketServer *server 13 | 14 | type server struct { 15 | clients []*client 16 | } 17 | 18 | // NewServer initializes a new websocket server. 19 | func NewServer() { 20 | socketServer = &server{ 21 | clients: make([]*client, 0), 22 | } 23 | } 24 | 25 | func (s *server) addClient(c *client) { 26 | log.Println("Websocket added client") 27 | s.clients = append(s.clients, c) 28 | } 29 | 30 | func (s *server) removeClient(c *client) { 31 | log.Println("Websocket deleted client") 32 | for index, client := range s.clients { 33 | if client == c { 34 | s.clients[index] = s.clients[len(s.clients)-1] 35 | s.clients[len(s.clients)-1] = nil 36 | s.clients = s.clients[:len(s.clients)-1] 37 | return 38 | } 39 | } 40 | } 41 | 42 | // Send sends a message to all connected clients. 43 | func Send(msg *Message) { 44 | // If the socket server is not initilaized this is a noop 45 | if socketServer == nil { 46 | return 47 | } 48 | 49 | for _, c := range socketServer.clients { 50 | c.write(msg) 51 | } 52 | } 53 | 54 | func connectHandler(ws *websocket.Conn) { 55 | defer func() { 56 | err := ws.Close() 57 | if err != nil { 58 | log.Println(err) 59 | } 60 | }() 61 | 62 | client := NewClient(ws) 63 | socketServer.addClient(client) 64 | client.listen() 65 | } 66 | 67 | // GetHandler returns a net/http.Handler compatible handler for websockets. 68 | func GetHandler() websocket.Handler { 69 | return websocket.Handler(connectHandler) 70 | } 71 | -------------------------------------------------------------------------------- /websocket/server_test.go: -------------------------------------------------------------------------------- 1 | package websocket 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestAddRemove(t *testing.T) { 8 | NewServer() 9 | c1 := NewClient(nil) 10 | c2 := NewClient(nil) 11 | c3 := NewClient(nil) 12 | 13 | socketServer.addClient(c1) 14 | socketServer.addClient(c2) 15 | socketServer.addClient(c3) 16 | 17 | if len(socketServer.clients) != 3 { 18 | t.Error("Wrong number of clients", len(socketServer.clients)) 19 | } 20 | 21 | socketServer.removeClient(c2) 22 | 23 | for _, c := range socketServer.clients { 24 | if c == c2 { 25 | t.Error("client c2 not removed") 26 | } 27 | } 28 | } 29 | --------------------------------------------------------------------------------