commit d5e5797934b80e0203d0f47b207c4848cfc834bf
Author: Brad Rydzewski
Date: Fri Feb 7 03:10:01 2014 -0700
initial public commit
diff --git a/.drone.yml b/.drone.yml
new file mode 100644
index 000000000..f43edc7bf
--- /dev/null
+++ b/.drone.yml
@@ -0,0 +1,24 @@
+image: mischief/docker-golang
+env:
+ - GOROOT=/usr/local/go
+ - GOPATH=/var/cache/drone
+ - PATH=$GOPATH/bin:$GOPATH/bin:$PATH
+script:
+ - apt-get -y install libsqlite3-dev sqlite3 mercurial bzr 1> /dev/null 2> /dev/null
+ - make deps
+ - make
+ - make test
+ - make dpkg
+notify:
+ email:
+ recipients:
+ - brad@drone.io
+publish:
+ s3:
+ acl: public-read
+ region: us-east-1
+ bucket: downloads.drone.io
+ access_key: $AWS_KEY
+ secret_key: $AWS_SECRET
+ source: /tmp/drone.deb
+ target: latest/
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 000000000..f5ba54803
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,14 @@
+drone.sublime-project
+drone.sublime-workspace
+*~
+~*
+*.sqlite
+*.deb
+*.rice-box.go
+
+bin/drone
+bin/droned
+cmd/drone/drone
+cmd/droned/droned
+deb/drone/usr/local/bin/drone
+deb/drone/usr/local/bin/droned
diff --git a/AUTHORS b/AUTHORS
new file mode 100644
index 000000000..deb3b8cc3
--- /dev/null
+++ b/AUTHORS
@@ -0,0 +1,5 @@
+# This file lists all individuals having contributed content to the repository.
+# If you're submitting a patch, please add your name here in alphabetical order as part of the patch.
+
+Brad Rydzewski
+Thomas Burke
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 000000000..7a4a3ea24
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
\ No newline at end of file
diff --git a/Makefile b/Makefile
new file mode 100644
index 000000000..a8fe1a6a1
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,78 @@
+
+all: embed build
+
+deps:
+ go get code.google.com/p/go.crypto/bcrypt
+ go get code.google.com/p/go.crypto/ssh
+ go get code.google.com/p/go.net/websocket
+ go get code.google.com/p/go.text/unicode/norm
+ go get launchpad.net/goyaml
+ go get github.com/andybons/hipchat
+ go get github.com/bmizerany/pat
+ go get github.com/dchest/authcookie
+ go get github.com/dchest/passwordreset
+ go get github.com/dchest/uniuri
+ go get github.com/dotcloud/docker/archive
+ go get github.com/dotcloud/docker/pkg/term
+ go get github.com/dotcloud/docker/utils
+ go get github.com/drone/go-github/github
+ go get github.com/drone/go-bitbucket/bitbucket
+ go get github.com/GeertJohan/go.rice
+ go get github.com/GeertJohan/go.rice/rice
+ go get github.com/mattn/go-sqlite3
+ go get github.com/russross/meddler
+
+embed:
+ cd cmd/droned && rice embed
+ cd pkg/template && rice embed
+
+build:
+ cd cmd/drone && go build -o ../../bin/drone
+ cd cmd/droned && go build -o ../../bin/droned
+
+test:
+ go test -v github.com/drone/drone/pkg/build
+ go test -v github.com/drone/drone/pkg/build/buildfile
+ go test -v github.com/drone/drone/pkg/build/docker
+ go test -v github.com/drone/drone/pkg/build/dockerfile
+ go test -v github.com/drone/drone/pkg/build/proxy
+ go test -v github.com/drone/drone/pkg/build/repo
+ go test -v github.com/drone/drone/pkg/build/script
+ go test -v github.com/drone/drone/pkg/channel
+ go test -v github.com/drone/drone/pkg/database
+ go test -v github.com/drone/drone/pkg/database/encrypt
+ go test -v github.com/drone/drone/pkg/database/testing
+ go test -v github.com/drone/drone/pkg/mail
+ go test -v github.com/drone/drone/pkg/model
+ go test -v github.com/drone/drone/pkg/queue
+
+install:
+ cp deb/drone/etc/init/drone.conf /etc/init/drone.conf
+ cd bin && install -t /usr/local/bin drone
+ cd bin && install -t /usr/local/bin droned
+ mkdir -p /var/lib/drone
+
+clean:
+ cd cmd/droned && rice clean
+ cd pkg/template && rice clean
+ rm -rf cmd/drone/drone
+ rm -rf cmd/droned/droned
+ rm -rf cmd/droned/drone.sqlite
+ rm -rf bin/drone
+ rm -rf bin/droned
+ rm -rf deb/drone.deb
+ rm -rf usr/local/bin/drone
+ rm -rf usr/local/bin/droned
+ rm -rf drone.sqlite
+
+# creates a debian package for drone
+# to install `sudo dpkg -i drone.deb`
+dpkg:
+ mkdir -p deb/drone/usr/local/bin
+ mkdir -p deb/drone/var/lib/drone
+ cp bin/drone deb/drone/usr/local/bin
+ cp bin/droned deb/drone/usr/local/bin
+ dpkg-deb --build deb/drone
+
+run:
+ bin/droned --port=":8080" --datasource="/tmp/drone.sqlite"
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 000000000..45aca82d7
--- /dev/null
+++ b/README.md
@@ -0,0 +1,128 @@
+Drone is a Continuous Integration platform built on Docker
+
+### System
+
+Drone is tested on the following versions of Ubuntu:
+
+* Ubuntu Precise 12.04 (LTS) (64-bit)
+* Ubuntu Raring 13.04 (64 bit)
+
+Drone's only external dependency is the latest version of Docker (0.8)
+
+### Setup
+
+Drone is packaged and distributed as a debian file. You can download an install
+using the following commands:
+
+```sh
+$ wget http://downloads.drone.io/latest/drone.deb
+$ dpkg -i drone.deb
+$ sudo start drone
+```
+
+Once Drone is running (by default on :80) navigate to **http://localhost:80/install**
+and follow the steps in the setup wizard.
+
+### Builds
+
+Drone use a **.drone.yml** configuration file in the root of your
+repository to run your build:
+
+```
+image: mischief/docker-golang
+env:
+ - GOPATH=/var/cache/drone
+script:
+ - go build
+ - go test -v
+service:
+ - redis
+notify:
+ email:
+ recipients:
+ - brad@drone.io
+ - burke@drone.io
+```
+
+### Environment
+
+Drone clones your repository into a Docker container
+at the following location:
+
+```
+/var/cache/drone/src/github.com/$owner/$name
+```
+
+Please take this into consideration when setting up your build image. For example,
+you may need set the $GOAPTH or other environment variables appropriately.
+
+### Databases
+
+Drone can launch database containers for your build:
+
+```
+service:
+ - cassandra
+ - couchdb
+ - elasticsearch
+ - neo4j
+ - mongodb
+ - mysql
+ - postgres
+ - rabbitmq
+ - redis
+ - riak
+ - zookeeper
+```
+
+**NOTE:** database and service containers are exposed over TCP connections and
+have their own local IP address. If the **socat** utility is installed inside your
+Docker image, Drone will automatically proxy localhost connections to the correct
+IP address.
+
+### Deployments
+
+Drone can trigger a deployment at the successful completion of your build:
+
+```
+deploy:
+ heroku:
+ app: safe-island-6261
+
+publish:
+ s3:
+ acl: public-read
+ region: us-east-1
+ bucket: downloads.drone.io
+ access_key: C24526974F365C3B
+ secret_key: 2263c9751ed084a68df28fd2f658b127
+ source: /tmp/drone.deb
+ target: latest/
+
+```
+
+### Notifications
+
+Drone can trigger email, hipchat and web hook notification at the completion
+of your build:
+
+```
+notify:
+ email:
+ recipients:
+ - brad@drone.io
+ - burke@drone.io
+
+ urls:
+ - http://my-deploy-hook.com
+
+ hipchat:
+ room: support
+ token: 3028700e5466d375
+```
+
+### Docs
+
+Coming Soon to [drone.readthedocs.org](http://drone.readthedocs.org/)
+
+
diff --git a/bin/README.md b/bin/README.md
new file mode 100644
index 000000000..4e2a5f731
--- /dev/null
+++ b/bin/README.md
@@ -0,0 +1 @@
+This is where Drone binaries go after running `make` in the Drone root directory.
\ No newline at end of file
diff --git a/cmd/drone/drone.go b/cmd/drone/drone.go
new file mode 100644
index 000000000..e3f97ee3b
--- /dev/null
+++ b/cmd/drone/drone.go
@@ -0,0 +1,288 @@
+package main
+
+import (
+ "bytes"
+ "flag"
+ "fmt"
+ "io/ioutil"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+
+ "github.com/drone/drone/pkg/build"
+ "github.com/drone/drone/pkg/build/log"
+ "github.com/drone/drone/pkg/build/repo"
+ "github.com/drone/drone/pkg/build/script"
+
+ "launchpad.net/goyaml"
+)
+
+var (
+ // identity file (id_rsa) that will be injected
+ // into the container if specified
+ identity = flag.String("identity", "", "")
+
+ // runs Drone in parallel mode if True
+ parallel = flag.Bool("parallel", false, "")
+
+ // build will timeout after N milliseconds.
+ // this will default to 500 minutes (6 hours)
+ timeout = flag.Duration("timeout", 300*time.Minute, "")
+
+ // runs Drone with verbose output if True
+ verbose = flag.Bool("v", false, "")
+
+ // displays the help / usage if True
+ help = flag.Bool("h", false, "")
+)
+
+func init() {
+ // default logging
+ log.SetPrefix("\033[2m[DRONE] ")
+ log.SetSuffix("\033[0m\n")
+ log.SetOutput(os.Stdout)
+ log.SetPriority(log.LOG_NOTICE)
+}
+
+func main() {
+ // Parse the input parameters
+ flag.Usage = usage
+ flag.Parse()
+
+ if *help {
+ flag.Usage()
+ os.Exit(0)
+ }
+
+ if *verbose {
+ log.SetPriority(log.LOG_DEBUG)
+ }
+
+ // Must speicify a command
+ args := flag.Args()
+ if len(args) == 0 {
+ flag.Usage()
+ os.Exit(0)
+ }
+
+ switch {
+ // run drone build assuming the current
+ // working directory contains the drone.yml
+ case args[0] == "build" && len(args) == 1:
+ path, _ := os.Getwd()
+ path = filepath.Join(path, ".drone.yml")
+ run(path)
+
+ // run drone build where the path to the
+ // source directory is provided
+ case args[0] == "build" && len(args) == 2:
+ path := args[1]
+ path = filepath.Clean(path)
+ path, _ = filepath.Abs(path)
+ path = filepath.Join(path, ".drone.yml")
+ run(path)
+
+ // run drone vet where the path to the
+ // source directory is provided
+ case args[0] == "vet" && len(args) == 2:
+ path := args[1]
+ path = filepath.Clean(path)
+ path, _ = filepath.Abs(path)
+ path = filepath.Join(path, ".drone.yml")
+ vet(path)
+
+ // run drone vet assuming the current
+ // working directory contains the drone.yml
+ case args[0] == "vet" && len(args) == 1:
+ path, _ := os.Getwd()
+ path = filepath.Join(path, ".drone.yml")
+ vet(path)
+
+ // print the help message
+ case args[0] == "help" && len(args) == 1:
+ flag.Usage()
+ }
+
+ os.Exit(0)
+}
+
+func vet(path string) {
+ // parse the Drone yml file
+ script, err := script.ParseBuildFile(path)
+ if err != nil {
+ log.Err(err.Error())
+ os.Exit(1)
+ return
+ }
+
+ // print the Drone yml as parsed
+ out, _ := goyaml.Marshal(script)
+ log.Noticef("parsed yaml:\n%s", string(out))
+}
+
+func run(path string) {
+ // parse the Drone yml file
+ s, err := script.ParseBuildFile(path)
+ if err != nil {
+ log.Err(err.Error())
+ os.Exit(1)
+ return
+ }
+
+ // get the repository root directory
+ dir := filepath.Dir(path)
+ code := repo.Repo{Path: dir}
+
+ // does the local repository match the
+ // $GOPATH/src/{package} pattern? This is
+ // important so we know the target location
+ // where the code should be copied inside
+ // the container.
+ if gopath, ok := getRepoPath(dir); ok {
+ code.Dir = gopath
+
+ } else if gopath, ok := getGoPath(dir); ok {
+ // in this case we found a GOPATH and
+ // reverse engineered the package path
+ code.Dir = gopath
+
+ } else {
+ // otherwise just use directory name
+ code.Dir = filepath.Base(dir)
+ }
+
+ // this is where the code gets uploaded to the container
+ // TODO move this code to the build package
+ code.Dir = filepath.Join("/var/cache/drone/src", filepath.Clean(code.Dir))
+
+ // track all build results
+ var builders []*build.Builder
+
+ // ssh key to import into container
+ var key []byte
+ if len(*identity) != 0 {
+ key, err = ioutil.ReadFile(*identity)
+ if err != nil {
+ fmt.Printf("[Error] Could not find or read identity file %s\n", identity)
+ os.Exit(1)
+ return
+ }
+ }
+
+ builds := []*script.Build{s}
+
+ // loop through and create builders
+ for _, b := range builds { //script.Builds {
+ builder := build.Builder{}
+ builder.Build = b
+ builder.Repo = &code
+ builder.Key = key
+ builder.Stdout = os.Stdout
+ builder.Timeout = *timeout
+
+ if *parallel == true {
+ var buf bytes.Buffer
+ builder.Stdout = &buf
+ }
+
+ builders = append(builders, &builder)
+ }
+
+ switch *parallel {
+ case false:
+ runSequential(builders)
+ case true:
+ runParallel(builders)
+ }
+
+ // if in parallel mode, print out the buffer
+ // if we had a failure
+ for _, builder := range builders {
+ if builder.BuildState.ExitCode == 0 {
+ continue
+ }
+
+ if buf, ok := builder.Stdout.(*bytes.Buffer); ok {
+ log.Noticef("printing stdout for failed build %s", builder.Build.Name)
+ println(buf.String())
+ }
+ }
+
+ // this exit code is initially 0 and will
+ // be set to an error code if any of the
+ // builds fail.
+ var exit int
+
+ fmt.Printf("\nDrone Build Results \033[90m(%v)\033[0m\n", len(builders))
+
+ // loop through and print results
+ for _, builder := range builders {
+ build := builder.Build
+ res := builder.BuildState
+ duration := time.Duration(res.Finished - res.Started)
+ switch {
+ case builder.BuildState.ExitCode == 0:
+ fmt.Printf(" \033[32m\u2713\033[0m %v \033[90m(%v)\033[0m\n", build.Name, humanizeDuration(duration*time.Second))
+ case builder.BuildState.ExitCode != 0:
+ fmt.Printf(" \033[31m\u2717\033[0m %v \033[90m(%v)\033[0m\n", build.Name, humanizeDuration(duration*time.Second))
+ exit = builder.BuildState.ExitCode
+ }
+ }
+
+ os.Exit(exit)
+}
+
+func runSequential(builders []*build.Builder) {
+ // loop through and execute each build
+ for _, builder := range builders {
+ if err := builder.Run(); err != nil {
+ log.Errf("Error executing build: %s", err.Error())
+ os.Exit(1)
+ }
+ }
+}
+
+func runParallel(builders []*build.Builder) {
+ // spawn four worker goroutines
+ var wg sync.WaitGroup
+ for _, builder := range builders {
+ // Increment the WaitGroup counter
+ wg.Add(1)
+ // Launch a goroutine to run the build
+ go func(builder *build.Builder) {
+ defer wg.Done()
+ builder.Run()
+ }(builder)
+ time.Sleep(500 * time.Millisecond) // get weird iptables failures unless we sleep.
+ }
+
+ // wait for the workers to finish
+ wg.Wait()
+}
+
+var usage = func() {
+ fmt.Println(`Drone is a tool for building and testing code in Docker containers.
+
+Usage:
+
+ drone command [arguments]
+
+The commands are:
+
+ build build and test the repository
+ version print the version number
+ vet validate the yaml configuration file
+
+ -v runs drone with verbose output
+ -h display this help and exit
+ --parallel runs drone build tasks in parallel
+ --timeout=300ms timeout build after 300 milliseconds
+
+Examples:
+ drone build builds the source in the pwd
+ drone build /path/to/repo builds the source repository
+
+Use "drone help [command]" for more information about a command.
+`)
+}
diff --git a/cmd/drone/util.go b/cmd/drone/util.go
new file mode 100644
index 000000000..a5f628ca1
--- /dev/null
+++ b/cmd/drone/util.go
@@ -0,0 +1,90 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "time"
+)
+
+// getGoPath checks the source codes absolute path
+// in reference to the host operating system's GOPATH
+// to correctly determine the code's package path. This
+// is Go-specific, since Go code must exist in
+// $GOPATH/src/github.com/{owner}/{name}
+func getGoPath(dir string) (string, bool) {
+ path := os.Getenv("GOPATH")
+ if len(path) == 0 {
+ return "", false
+ }
+ // append src to the GOPATH, since
+ // the code will be stored in the src dir
+ path = filepath.Join(path, "src")
+ if !filepath.HasPrefix(dir, path) {
+ return "", false
+ }
+
+ // remove the prefix from the directory
+ // this should leave us with the go package name
+ return dir[len(path):], true
+}
+
+var gopathExp = regexp.MustCompile("./src/(github.com/[^/]+/[^/]+|bitbucket.org/[^/]+/[^/]+|code.google.com/[^/]+/[^/]+)")
+
+// getRepoPath checks the source codes absolute path
+// on the host operating system in an attempt
+// to correctly determine the code's package path. This
+// is Go-specific, since Go code must exist in
+// $GOPATH/src/github.com/{owner}/{name}
+func getRepoPath(dir string) (path string, ok bool) {
+ // let's get the package directory based
+ // on the path in the host OS
+ indexes := gopathExp.FindStringIndex(dir)
+ if len(indexes) == 0 {
+ return
+ }
+
+ index := indexes[len(indexes)-1]
+
+ // if the dir is /home/ubuntu/go/src/github.com/foo/bar
+ // the index will start at /src/github.com/foo/bar.
+ // We'll need to strip "/src/" which is where the
+ // magic number 5 comes from.
+ index = strings.LastIndex(dir, "/src/")
+ return dir[index+5:], true
+}
+
+// getGitOrigin checks the .git origin in an attempt
+// to correctly determine the code's package path. This
+// is Go-specific, since Go code must exist in
+// $GOPATH/src/github.com/{owner}/{name}
+func getGitOrigin(dir string) (path string, ok bool) {
+ // TODO
+ return
+}
+
+// prints the time as a human readable string
+func humanizeDuration(d time.Duration) string {
+ if seconds := int(d.Seconds()); seconds < 1 {
+ return "Less than a second"
+ } else if seconds < 60 {
+ return fmt.Sprintf("%d seconds", seconds)
+ } else if minutes := int(d.Minutes()); minutes == 1 {
+ return "About a minute"
+ } else if minutes < 60 {
+ return fmt.Sprintf("%d minutes", minutes)
+ } else if hours := int(d.Hours()); hours == 1 {
+ return "About an hour"
+ } else if hours < 48 {
+ return fmt.Sprintf("%d hours", hours)
+ } else if hours < 24*7*2 {
+ return fmt.Sprintf("%d days", hours/24)
+ } else if hours < 24*30*3 {
+ return fmt.Sprintf("%d weeks", hours/24/7)
+ } else if hours < 24*365*2 {
+ return fmt.Sprintf("%d months", hours/24/30)
+ }
+ return fmt.Sprintf("%f years", d.Hours()/24/365)
+}
diff --git a/cmd/droned/assets/css/drone.css b/cmd/droned/assets/css/drone.css
new file mode 100644
index 000000000..c997f02e0
--- /dev/null
+++ b/cmd/droned/assets/css/drone.css
@@ -0,0 +1,1076 @@
+body {
+ background: #FFF;
+}
+.container {
+ max-width: none !important;
+ width: 940px;
+ padding: 0px;
+}
+.row {
+ margin: 0px;
+ padding: 0px;
+}
+.col-xs-1,
+.col-xs-2,
+.col-xs-3,
+.col-xs-4,
+.col-xs-5,
+.col-xs-6,
+.col-xs-7,
+.col-xs-8,
+.col-xs-9 {
+ padding: 0px;
+}
+.row > .col-xs-2:last-child,
+.row > .col-xs-3:last-child,
+.row > .col-xs-4:last-child,
+.row > .col-xs-5:last-child,
+.row > .col-xs-6:last-child,
+.row > .col-xs-7:last-child,
+.row > .col-xs-8:last-child,
+.row > .col-xs-9:last-child {
+ padding-left: 20px;
+}
+.navbar-inverse {
+ background: #262829;
+ margin-bottom: 0px;
+}
+.navbar-inverse .container {
+ padding-right: 0px;
+}
+.navbar-fixed-top,
+.navbar-fixed-bottom {
+ position: relative;
+}
+.navbar-inverse .navbar-brand {
+ padding: 21px 20px 0px 0px;
+ font-weight: normal;
+ text-shadow: none;
+ font-family: 'Orbitron';
+ font-size: 28px;
+ color: #DDDDDD;
+ margin-left: 0px !IMPORTANT;
+}
+.navbar .nav {
+ float: right;
+ margin-right: 0px;
+}
+.navbar .nav > li > a {
+ padding: 18px 20px;
+}
+.navbar .nav > li > a.btn {
+ padding: 7px 20px;
+ margin: 0px;
+ margin-top: 11px;
+ border-radius: 4px;
+ -webkit-border-radius: 4px;
+ -moz-border-radius: 4px;
+ background: #363839;
+ border: none;
+ box-shadow: none;
+ text-shadow: none;
+ color: #DDD;
+ margin-left: 15px;
+}
+.navbar .nav > li > a.btn.btn-search,
+.navbar .nav > li > a.btn.btn-config {
+ padding: 7px 15px;
+}
+.subhead {
+ margin-bottom: 22px;
+ background: #f4f4f4;
+ border: none;
+ position: relative;
+ -webkit-box-shadow: none;
+ -moz-box-shadow: none;
+ box-shadow: none;
+}
+.subhead h1 {
+ font-family: 'Open Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
+ font-size: 26px;
+ font-weight: normal;
+ color: #777;
+ line-height: 55px;
+ margin-top: 10px;
+ margin-bottom: 10px;
+}
+.subhead h1 span {
+ padding-right: 10px;
+}
+.subhead h1 small {
+ color: #999;
+ font-size: 60%;
+}
+.subhead h1.user span {
+ padding-left: 10px;
+ font-size: 24px;
+ line-height: 24px;
+}
+.subhead h1.user img {
+ width: 42px;
+ height: 42px;
+ border-radius: 50%;
+ -webkit-border-radius: 50%;
+ -moz-border-radius: 50%;
+}
+.subhead h1.user small {
+ color: #777;
+ font-size: 60%;
+}
+.subhead .container {
+ position: relative;
+ padding: 0px;
+}
+.subhead .nav-tabs {
+ position: absolute;
+ right: 0px;
+ bottom: 0px;
+ margin: 0px;
+ padding: 0px;
+ border-bottom: none;
+}
+.subhead .nav-tabs > li {
+ margin-bottom: 0px;
+}
+.subhead .nav-tabs > li > a {
+ color: #999;
+ padding: 10px 20px;
+ line-height: 20px;
+ border: 0px;
+}
+.subhead .nav-tabs > li.active > a {
+ color: #777;
+ border: 0px;
+}
+.nav-repos,
+.nav-branches {
+ margin-top: 0px;
+ border: none;
+}
+.nav-repos li a,
+.nav-branches li a {
+ line-height: 22px;
+ margin-bottom: 0px;
+ padding: 5px 15px;
+ padding: 10px 15px;
+ border: none;
+ margin: 0px !IMPORTANT;
+ font-size: 15px;
+ border-radius: 0px;
+ -moz-border-radius: 0px;
+ -webkit-border-radius: 0px;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+.nav-repos li a span,
+.nav-branches li a span {
+ color: #777;
+}
+.nav-repos li a span [class^="icon-"],
+.nav-branches li a span [class^="icon-"] {
+ color: #999;
+}
+.nav-repos li a i,
+.nav-branches li a i {
+ padding-right: 10px;
+ color: #AAA;
+ font-size: 18px;
+ vertical-align: middle;
+}
+.nav-repos li.active a,
+.nav-branches li.active a {
+ background: #f4f4f4;
+}
+.nav-repos li.active a:hover,
+.nav-branches li.active a:hover {
+ background: #f4f4f4;
+}
+.nav-repos .nav-header,
+.nav-branches .nav-header {
+ padding: 15px 15px;
+ margin-bottom: 0px;
+ color: #666;
+ font-size: 24px;
+ text-transform: lowercase;
+ font-family: 'Open Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
+ font-weight: normal;
+ display: none;
+}
+.nav-repos li a,
+.nav-branches li a {
+ padding: 12px 15px;
+ margin-bottom: 0px;
+ color: #777;
+}
+.nav-pills.nav-repos > li > a,
+.nav-repos > li > a {
+ padding: 13px 15px;
+}
+.nav-pills.nav-repos > li:nth-child(odd),
+.nav-repos > li:nth-child(odd) {
+ background: #f7f7f7;
+}
+.nav-repos > li > a span {
+ color: #AAA;
+}
+.nav-repos > li > a span:last-child {
+ color: #555;
+}
+.nav-pills.nav-branches > li > a {
+ padding: 10px 15px;
+}
+.alert {
+ border: none;
+ border-radius: 0px;
+ background: #f4f4f4;
+ color: #999;
+ padding: 30px;
+ font-size: 18px;
+ margin-bottom: 20px;
+}
+.alert .pull-right {
+ margin-top: -10px;
+}
+.alert .thumbnails {
+ margin: 0px;
+ padding: 0px;
+}
+.alert .thumbnails li {
+ margin: 0px;
+ padding: 0px;
+ margin-left: 20px;
+ margin-top: 20px;
+}
+.alert .thumbnails .thumbnail {
+ background: transparent;
+}
+.alert.alert-feed {
+ margin-top: 0px;
+ margin-bottom: 0px;
+ padding: 15px 20px;
+}
+.alert.alert-feed span {
+ line-height: 50px;
+}
+.alert.alert-feed span.label {
+ float: right;
+ color: #BBB;
+ font-size: 16px;
+ padding-right: 10px;
+ font-weight: normal;
+}
+.alert.alert-feed .thumbnails > li {
+ margin: 0px;
+ margin-left: 5px;
+ padding: 0px;
+ display: inline-block;
+}
+.alert.alert-feed .thumbnails > li a {
+ border: 0px;
+ padding: 0px;
+}
+.alert.alert-feed .thumbnails > li a img {
+ border-radius: 50%;
+ -moz-border-radius: 50%;
+ -webkit-border-radius: 50%;
+ width: 32px;
+ height: 32px;
+ margin-top: 10px;
+}
+.alert.alert-success {
+ color: #3c763d;
+ background-color: #dff0d8;
+ text-shadow: none;
+ font-size: 16px;
+ margin-top: 30px;
+ margin-bottom: 10px;
+}
+.alert.alert-error {
+ background: #ebccd1;
+ color: #a94442;
+ text-shadow: none;
+ font-size: 16px;
+ margin-top: 30px;
+ margin-bottom: 10px;
+}
+.commit-list {
+ list-style: none;
+ margin: 0px;
+ padding-left: 0px;
+}
+.commit-list > li {
+ margin: 0px;
+ position: relative;
+ padding: 25px 20px;
+ padding-left: 25px;
+ list-style: none;
+}
+.commit-list > li > img {
+ position: absolute;
+ right: 20px;
+ top: 20px;
+ -webkit-border-radius: 50%;
+ -moz-border-radius: 50%;
+ border-radius: 50%;
+ width: 48px;
+ height: 48px;
+ background: #EEE;
+}
+.commit-list > li:last-child {
+ border-bottom: none;
+}
+.commit-list > li:nth-child(even) {
+ background: #f7f7f7;
+}
+.commit-list.commit-list-alt > li:nth-child(even) {
+ background: #fff;
+}
+.commit-list.commit-list-alt > li:nth-child(odd) {
+ background: #f7f7f7;
+}
+.commit-list > li > h3 {
+ display: inline;
+ line-height: 18px;
+ height: 18px;
+ font-weight: normal;
+ color: #777;
+ font-size: 18px;
+ margin-top: 10px;
+}
+.commit-list > li > h3 a,
+.commit-list > li > h3 span {
+ margin-top: 2px;
+ display: inline-block;
+}
+.commit-list > li > h3 a {
+ color: #555;
+ text-decoration: none;
+}
+.commit-list > li > h3 a:hover {
+ text-decoration: underline;
+}
+.commit-list > li > h3 small {
+ font-size: 14px;
+ display: inline-block;
+ margin-left: 5px;
+ white-space: nowrap;
+}
+.commit-list > li > h3 p {
+ font-size: 14px;
+ color: #999;
+ margin: 0px;
+ padding: 0px;
+ padding-top: 2px;
+ display: block;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ max-width: 350px;
+}
+.btn.btn-Success,
+.btn.btn-Failure,
+.btn.btn-Pending,
+.btn.btn-Started,
+.btn.btn-Error,
+.btn.btn-None {
+ border: none;
+ background: #BBB;
+ color: #FFF;
+ text-shadow: none;
+ box-shadow: none;
+ padding: 10px 15px;
+ min-width: 75px;
+ float: left;
+ margin-right: 25px;
+ font-size: 16px;
+ text-transform: none;
+ font-weight: normal;
+ padding: 0px;
+ border-bottom: 0px;
+ width: 48px;
+ height: 48px;
+ min-height: 48px;
+ max-height: 48px;
+ min-width: 48px;
+ max-width: 48px;
+ border-radius: 50%;
+ -webkit-border-radius: 50%;
+ -moz-border-radius: 50%;
+}
+.btn.btn-success,
+.btn.btn-Success {
+ background: rgba(81, 163, 81, 0.75);
+}
+.btn.btn-failure,
+.btn.btn-Failure,
+.btn.btn-Error {
+ background: rgba(189, 54, 47, 0.8);
+}
+.btn.btn-Scheduled,
+.btn.btn-Pending,
+.btn.btn-Started {
+ background: #D5E802;
+}
+.btn.btn-Success:before {
+ content: "\f00c";
+ font-family: 'FontAwesome';
+ font-size: 22px;
+ line-height: 48px;
+ opacity: 0.8;
+ color: #FFF;
+}
+.btn.btn-Error:before,
+.btn.btn-Failure:before {
+ content: "\f00d";
+ font-family: 'FontAwesome';
+ font-size: 22px;
+ line-height: 48px;
+ opacity: 0.8;
+ color: #fff;
+}
+.btn.btn-refresh {
+ position: absolute;
+ left: -95px;
+ float: left;
+ background: #f7f7f7;
+ width: 75px;
+ border-radius: 0px;
+ -webkit-border-radius: 0px;
+ -moz-border-radius: 0px;
+ text-align: center;
+ font-size: 22px;
+ text-decoration: none;
+ color: #999;
+ padding: 10px 0px;
+ z-index: 1;
+}
+.btn.btn-refresh span {
+ display: block;
+ font-size: 14px;
+ padding-top: 5px;
+}
+.btn.btn-Started:before,
+.btn.btn-Scheduled:before,
+.btn.btn-Pending:before {
+ content: "\f021";
+ font-family: 'FontAwesome';
+ font-size: 22px;
+ line-height: 48px;
+ color: #FFF;
+ display: inline-block;
+ -webkit-animation: spin 1.5s infinite linear;
+ -moz-animation: spin 1.5s infinite linear;
+ -ms-animation: spin 1.5s infinite linear;
+ -o-animation: spin 1.5s infinite linear;
+ animation: spin 1.5s infinite linear;
+}
+.btn.btn-mini {
+ width: 24px;
+ height: 24px;
+ max-width: 24px;
+ max-height: 24px;
+ min-width: 24px;
+ min-height: 24px;
+}
+.btn.btn-mini.btn-Success:before,
+.btn.btn-mini.btn-Failure:before,
+.btn.btn-mini.btn-Error:before,
+.btn.btn-mini.btn-Started:before,
+.btn.btn-mini.btn-Scheduled:before,
+.btn.btn-mini.btn-Pending:before {
+ line-height: 24px !IMPORTANT;
+ font-size: 14px !IMPORTANT;
+}
+@-webkit-keyframes spin {
+ to {
+ -webkit-transform: rotate(360deg);
+ }
+}
+@-moz-keyframes spin {
+ to {
+ -moz-transform: rotate(360deg);
+ }
+}
+@-ms-keyframes spin {
+ to {
+ -ms-transform: rotate(360deg);
+ }
+}
+@-o-keyframes spin {
+ to {
+ -o-transform: rotate(360deg);
+ }
+}
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+.btn.btn-None {
+ background: rgba(81, 163, 81, 0.75);
+ border-bottom: 2px solid #51a351;
+}
+.btn.btn-default {
+ padding: 7px 20px;
+ padding-bottom: 6px;
+ border-bottom-width: 2px;
+ color: #777;
+}
+.btn.btn-primary {
+ background: rgba(251, 136, 35, 0.85);
+ padding: 7px 20px;
+ border: none;
+ border-bottom: 2px solid #cd6d1d;
+}
+.btn.btn-primary:hover,
+.btn.btn-primary:active {
+ background: #fb8823;
+ border-bottom: 2px solid #cd6d1d;
+}
+.btn.btn-danger {
+ border: none;
+ padding: 7px 20px;
+ background: rgba(189, 54, 47, 0.8);
+ border-bottom: 2px solid #bd362f;
+}
+.btn.btn-danger:hover,
+.btn.btn-danger:active {
+ background: rgba(189, 54, 47, 0.9);
+ border-bottom: 2px solid #bd362f;
+}
+.nav-pills > li a {
+ padding: 7px 15px;
+ color: #777777;
+}
+.nav-pills > li > a,
+.nav-pills > li > a:hover,
+.nav-pills > li > a:focus {
+ border-radius: 0px;
+ -webkit-border-radius: 0px;
+ -moz-border-radius: 0px;
+}
+.nav-pills > .active {
+ border-radius: 0px;
+ -webkit-border-radius: 0px;
+ -moz-border-radius: 0px;
+}
+.nav-pills > li.active > a,
+.nav-pills > li.active > a:hover,
+.nav-pills > li.active > a:focus,
+.nav-pills > .active > a,
+.nav-pills > .active > a:hover,
+.nav-pills > .active > a:focus {
+ border-radius: 0px;
+ -webkit-border-radius: 0px;
+ -moz-border-radius: 0px;
+ margin-top: 0px;
+ background-color: #f4f4f4;
+ color: #777777;
+}
+form label {
+ color: #777;
+ margin-top: 25px;
+ margin-bottom: 7px;
+ margin-left: 2px;
+ cursor: default;
+ font-weight: normal;
+ display: block;
+}
+form label:first-child {
+ margin-top: 0px;
+}
+form .form-actions {
+ background: transparent;
+ padding: 30px 0px;
+ margin: 0px;
+ border: none;
+}
+form .form-actions .btn {
+ margin-right: 3px;
+}
+form .form-group {
+ margin-bottom: 30px;
+}
+input.form-control,
+select.form-control {
+ display: inline-block;
+ height: 30px;
+ padding: 4px 6px;
+ margin-bottom: 0px;
+ font-size: 14px;
+ line-height: 20px;
+ color: #555555;
+ -webkit-border-radius: 4px;
+ -moz-border-radius: 4px;
+ border-radius: 4px;
+ vertical-align: middle;
+ width: 270px;
+}
+input.form-control-small,
+select.form-control-small {
+ width: 90px;
+}
+input.form-control-large,
+select.form-control-large {
+ width: 210px;
+}
+input.form-control-xlarge,
+select.form-control-xlarge {
+ width: 270px;
+}
+span.form-control {
+ color: #999999;
+ background-color: #fcfcfc;
+ border-color: #cccccc;
+ -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.025);
+ -moz-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.025);
+ box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.025);
+ cursor: not-allowed;
+}
+textarea {
+ font-family: 'Droid Sans Mono', monospace;
+}
+.avatar-link {
+ float: right;
+ position: relative;
+ background: #eee;
+ padding: 5px;
+ text-decoration: none;
+}
+.avatar-link span {
+ display: block;
+ text-align: center;
+ color: #777;
+ padding: 5px;
+}
+.avatar-link img {
+ width: 160px;
+ height: 160px;
+ max-width: 160px;
+ max-height: 160px;
+ min-width: 160px;
+ min-height: 160px;
+}
+.avatar-link:hover {
+ text-decoration: none;
+}
+.avatar-link:hover:before {
+ color: #fff;
+ text-shadow: 1px 1px rgba(0, 0, 0, 0.5);
+ font-family: 'FontAwesome';
+ content: "\f044";
+ position: absolute;
+ right: 10px;
+ top: 10px;
+ font-size: 16px;
+}
+.row.gravatar-list {
+ margin-left: 0px;
+}
+.row.gravatar-list .col-xs-3 {
+ width: 23.404255319148934%;
+ margin: 0px;
+ padding: 0px;
+ margin-right: 2.127659574468085%;
+}
+.row.gravatar-list .col-xs-3:nth-child(4) {
+ margin-right: 0px;
+}
+.row.gravatar-list .col-xs-3 a.thumbnail,
+.row.gravatar-list .col-xs-3 a.thumbnail:hover {
+ border-radius: 0px;
+ -webkit-border-radius: 0px;
+ -moz-border-radius: 0px;
+ box-shadow: none;
+ border: none;
+ text-decoration: none;
+ background: #f4f4f4;
+}
+.row.gravatar-list .col-xs-3 a.thumbnail img,
+.row.gravatar-list .col-xs-3 a.thumbnail:hover img {
+ width: 156px;
+ height: 156px;
+}
+.row.gravatar-list .col-xs-3 a.thumbnail .caption > h3,
+.row.gravatar-list .col-xs-3 a.thumbnail:hover .caption > h3 {
+ font-weight: normal;
+ text-align: center;
+ font-size: 15px;
+ padding: 0px;
+ line-height: 16px;
+ margin: 0px;
+ max-width: 156px;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ color: #555555;
+}
+.row.gravatar-list .col-xs-3 a.thumbnail.team-add > span,
+.row.gravatar-list .col-xs-3 a.thumbnail:hover.team-add > span {
+ min-width: 156px;
+ min-height: 156px;
+ display: inline-block;
+ text-align: center;
+ vertical-align: middle;
+ line-height: 156px;
+ font-size: 64px;
+ color: #d7d7d7;
+}
+.row.gravatar-list .col-xs-3 a.thumbnail.team-add .caption > h3,
+.row.gravatar-list .col-xs-3 a.thumbnail:hover.team-add .caption > h3 {
+ color: #999;
+}
+/* Checkbox hack */
+.switch input,
+.switch a {
+ cursor: pointer;
+ height: 100%;
+ opacity: 0;
+ position: absolute;
+ width: 100%;
+ z-index: 100;
+}
+/* Container */
+.switch {
+ height: 40px;
+ position: relative;
+ width: 100px;
+}
+/* Background unchecked */
+.switch label {
+ background: rgba(189, 54, 47, 0.8);
+ border-radius: 2px;
+ display: block;
+ height: 100%;
+ position: relative;
+ transition: all .15s ease;
+ width: 100%;
+ border-radius: 5px;
+ color: #FFF;
+}
+/* Slider unchecked */
+.switch label div {
+ background: #fff;
+ border-radius: 2px;
+ display: block;
+ height: 28px;
+ left: 6px;
+ position: absolute;
+ top: 6px;
+ transition: all .15s ease;
+ width: 35px;
+ z-index: 3;
+ border-radius: 5px;
+}
+/* Vertical lines on slider */
+.switch label div:before {
+ bottom: 0;
+ content: '';
+ display: block;
+ height: 15px;
+ left: 0;
+ margin: auto;
+ position: absolute;
+ right: 0;
+ top: 0;
+ transition: all .15s ease;
+ width: 3px;
+}
+/* Icon styles */
+.switch .fontawesome-ok {
+ font-size: 18px;
+ left: 15px;
+ position: relative;
+ top: 8px;
+ z-index: 2;
+}
+.switch .fontawesome-ok:before {
+ font-family: 'FontAwesome';
+ content: "\f00c";
+ opacity: 0.8;
+}
+.switch .fontawesome-remove {
+ font-size: 18px;
+ left: 45px;
+ position: relative;
+ top: 8px;
+ z-index: 2;
+}
+.switch .fontawesome-remove:before {
+ font-family: 'FontAwesome';
+ content: "\f00d";
+ opacity: 0.8;
+}
+/* Checked States */
+.switch input:checked ~ label {
+ background: rgba(81, 163, 81, 0.75);
+}
+.switch input:checked ~ label div {
+ left: 58px;
+}
+pre {
+ white-space: pre-wrap;
+ background: #464849;
+ color: #FFF;
+ padding: 20px 20px;
+ border-radius: 5px;
+ font-size: 12px;
+ margin-top: 0px;
+ overflow: hidden;
+ font-family: 'Droid Sans Mono', 'monospace';
+ line-height: 20px;
+}
+.alert.alert-build-Success,
+.alert.alert-build-Error,
+.alert.alert-build-Failure,
+.alert.alert-build-Pending,
+.alert.alert-build-Started {
+ text-shadow: none;
+ margin-top: 5px;
+ margin-bottom: 30px;
+ font-size: 15px;
+ padding: 15px 20px;
+ border-radius: 5px;
+ -webkit-border-radius: 5px;
+ -moz-border-radius: 5px;
+}
+.alert.alert-build-Success span,
+.alert.alert-build-Error span,
+.alert.alert-build-Failure span,
+.alert.alert-build-Pending span,
+.alert.alert-build-Started span {
+ line-height: 32px;
+}
+.alert.alert-build-Success span span,
+.alert.alert-build-Error span span,
+.alert.alert-build-Failure span span,
+.alert.alert-build-Pending span span,
+.alert.alert-build-Started span span {
+ text-decoration: underline;
+}
+.alert.alert-build-Success a.btn,
+.alert.alert-build-Error a.btn,
+.alert.alert-build-Failure a.btn,
+.alert.alert-build-Pending a.btn,
+.alert.alert-build-Started a.btn {
+ width: 32px;
+ height: 32px;
+ max-width: 32px;
+ max-height: 32px;
+ min-width: 32px;
+ min-height: 32px;
+ margin-right: 20px !IMPORTANT;
+}
+.alert.alert-build-Success a.btn:before,
+.alert.alert-build-Error a.btn:before,
+.alert.alert-build-Failure a.btn:before,
+.alert.alert-build-Pending a.btn:before,
+.alert.alert-build-Started a.btn:before {
+ font-size: 22px !IMPORTANT;
+ line-height: 32px !IMPORTANT;
+}
+.build-details {
+ background: #FFF;
+ margin-bottom: 40px;
+}
+.build-details dt {
+ float: left;
+ width: 90px;
+ color: #333;
+ font-weight: normal;
+}
+.build-details dt:after {
+ content: ':';
+}
+.build-details dd {
+ color: #555;
+ white-space: nowrap;
+}
+.build-details .build-summary {
+ float: left;
+ width: 250px;
+ padding-left: 20px;
+}
+.build-details img {
+ float: left;
+ border-radius: 50%;
+ -webkit-border-radius: 50%;
+ -moz-border-radius: 50%;
+ margin-right: 30px;
+ width: 58px;
+ height: 58px;
+}
+.build-details .commit-summary {
+ float: left;
+}
+.build-details.affix {
+ top: 0px;
+ padding-top: 15px;
+ padding-bottom: 30px;
+}
+.alert.alert-build-Success {
+ color: #468847;
+ background-color: #dff0d8;
+ border-color: #d6e9c6;
+}
+.alert.alert-build-Error,
+.alert.alert-build-Failure {
+ background-color: #f2dede;
+ color: #b94a48;
+}
+.alert.alert-build-Pending,
+.alert.alert-build-Started {
+ color: #c09853;
+ background: rgba(213, 232, 2, 0.2);
+ background-color: rgba(213, 232, 2, 0.2);
+}
+.form-repo .field-group {
+ display: inline-block;
+ margin-bottom: 30px;
+}
+.form-repo .field-group label {
+ margin-top: 0px;
+}
+.form-repo .field-separator {
+ display: inline-block;
+ font-size: 20px;
+}
+.form-repo ul {
+ padding-top: 15px;
+ margin-bottom: 0px;
+ padding-bottom: 0px;
+ list-style: none;
+ padding-left: 0px;
+ margin-left: 0px;
+}
+.form-repo ul li {
+ padding-bottom: 10px;
+}
+.form-repo ul li img {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ -webkit-border-radius: 50%;
+ -moz-border-radius: 50%;
+ margin-left: 10px;
+ margin-right: 10px;
+}
+ul.account-radio-group {
+ padding-top: 15px;
+ margin-bottom: 0px;
+ padding-bottom: 0px;
+ list-style: none;
+ padding-left: 0px;
+ margin-left: 0px;
+}
+ul.account-radio-group li {
+ padding-bottom: 10px;
+}
+ul.account-radio-group li img {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ -webkit-border-radius: 50%;
+ -moz-border-radius: 50%;
+ margin-left: 10px;
+ margin-right: 10px;
+}
+.form-centered {
+ background: #FFF;
+ overflow: hidden;
+ max-width: 350px;
+ margin: 0 auto;
+ text-align: center;
+ margin-top: 40px;
+ padding: 0px 0px 20px 0px;
+ border-radius: 5px;
+ -webkit-border-radius: 5px;
+ -moz-border-radius: 5px;
+}
+.form-centered h1 {
+ background-color: #262829;
+ color: #FFF;
+ border-top-left-radius: 5px;
+ border-top-right-radius: 5px;
+ font-family: 'Orbitron';
+ font-weight: normal;
+ font-size: 18px;
+ padding: 25px 10px;
+ margin-top: 0px;
+ margin-bottom: 30px;
+}
+.form-centered input[type="submit"] {
+ border: none;
+ color: #FFF;
+ padding: 10px;
+ font-size: 18px;
+ margin-top: 30px;
+ margin-bottom: 10px;
+ width: 280px;
+ background: #AAA;
+ border-radius: 5px;
+ -webkit-border-radius: 5px;
+ -moz-border-radius: 5px;
+}
+.form-centered input[type="text"],
+.form-centered input[type="password"] {
+ margin-bottom: 0px;
+ font-size: 16px;
+ padding: 10px;
+ width: 280px;
+ height: 40px;
+}
+.form-centered input[type="text"]:last-child,
+.form-centered input[type="password"]:last-child {
+ border-top-width: 0px;
+ border-top-right-radius: 0px;
+ border-top-left-radius: 0px;
+}
+.form-centered input[type="text"]:first-child,
+.form-centered input[type="password"]:first-child {
+ border-bottom-right-radius: 0px;
+ border-bottom-left-radius: 0px;
+}
+.form-centered input[type="text"][disabled],
+.form-centered input[type="password"][disabled] {
+ box-shadow: none;
+ background: #f7f7f7;
+ color: #999;
+ text-shadow: none;
+}
+.form-centered input[type="text"].only-child,
+.form-centered input[type="password"].only-child {
+ border-bottom-right-radius: 4px;
+ border-bottom-left-radius: 4px;
+ border-top-right-radius: 4px;
+ border-top-left-radius: 4px;
+ border-bottom-width: 1px;
+ border-top-width: 1px;
+}
+.form-centered a,
+.form-centered a:visited {
+ color: #999;
+ text-decoration: underline;
+}
+.form-centered h1 ~ div.alert {
+ margin-top: -30px;
+}
+.form-centered .alert.alert-danger {
+ background: #ebccd1;
+ color: #a94442;
+ text-shadow: none;
+ font-weight: normal;
+ font-size: 16px;
+ padding: 20px;
+ margin-bottom: 30px;
+}
+.form-centered .alert.alert-success {
+ color: #3c763d;
+ background-color: #dff0d8;
+ text-shadow: none;
+ font-weight: normal;
+ font-size: 16px;
+ padding: 20px;
+ margin-bottom: 30px;
+}
diff --git a/cmd/droned/assets/css/drone.less b/cmd/droned/assets/css/drone.less
new file mode 100644
index 000000000..1ed5070d3
--- /dev/null
+++ b/cmd/droned/assets/css/drone.less
@@ -0,0 +1,1263 @@
+// I SINCERELY APOLOGIZE FOR ANYONE READING AND TRYING
+// TO UNDERSTAND THIS FILE. I PROMISE I WILL REFACTOR
+// AND CLEAN UP THIS MESS AS SOON AS I HAVE TIME!
+
+body {
+ background:#FFF;
+}
+
+// here we need to reset the container so that we are
+// displaying the page in non-responsive mode.
+.container {
+ max-width: none !important;
+ width: 940px;
+ padding:0px;
+}
+
+// as part of displaying the page in non-responsive
+// mode we need to remove some weird negative margins
+// that exist on the rows, and columns.
+.row {
+ margin:0px;
+ padding:0px;
+}
+
+.col-xs-1,
+.col-xs-2,
+.col-xs-3,
+.col-xs-4,
+.col-xs-5,
+.col-xs-6,
+.col-xs-7,
+.col-xs-8,
+.col-xs-9 {
+ padding:0px;
+}
+
+
+.row > .col-xs-2:last-child,
+.row > .col-xs-3:last-child,
+.row > .col-xs-4:last-child,
+.row > .col-xs-5:last-child,
+.row > .col-xs-6:last-child,
+.row > .col-xs-7:last-child,
+.row > .col-xs-8:last-child,
+.row > .col-xs-9:last-child, {
+ padding-left:20px;
+}
+
+//---------------------------------------------------------------------
+// Navigation Bar
+
+.navbar-inverse {
+ background: #262829;
+ margin-bottom: 0px;
+ .container {
+ padding-right:0px;
+ }
+}
+
+.navbar-fixed-top, .navbar-fixed-bottom {
+ position:relative;
+}
+
+.navbar-inverse .navbar-brand {
+ padding: 21px 20px 0px 0px;
+ font-weight: normal;
+ text-shadow: none;
+ font-family: 'Orbitron';
+ font-size: 28px;
+ color: #DDDDDD;
+ margin-left: 0px !IMPORTANT;
+}
+
+.navbar .nav {
+ float:right;
+ margin-right:0px;
+}
+
+.navbar .nav > li > a {
+ padding: 18px 20px;
+}
+
+.navbar .nav > li > a.btn {
+ padding: 7px 20px;
+ margin: 0px;
+ margin-top:11px;
+ // TODO this should use a custom LESS function
+ border-radius:4px;
+ -webkit-border-radius:4px;
+ -moz-border-radius:4px;
+ background:#363839;
+ border: none;
+ box-shadow: none;
+ text-shadow: none;
+ color: #DDD;
+ margin-left: 15px;
+}
+
+.navbar .nav > li > a.btn.btn-search,
+.navbar .nav > li > a.btn.btn-config {
+ padding: 7px 15px;
+}
+
+//---------------------------------------------------------------------
+// Subheader on (pretty much) every page
+
+.subhead {
+ margin-bottom:22px;
+ background:#f4f4f4;
+ border:none;
+ position: relative;
+ // DO I NEED THIS?
+ -webkit-box-shadow: none;
+ -moz-box-shadow: none;
+ box-shadow: none;
+}
+
+.subhead h1 {
+ font-family: 'Open Sans','Helvetica Neue', Helvetica, Arial, sans-serif;
+ font-size:26px;
+ font-weight:normal;
+ color:#777;
+ line-height: 55px;
+ margin-top: 10px;
+ margin-bottom: 10px;
+ span {
+ padding-right:10px;
+ }
+ small {
+ color:#999;
+ font-size:60%;
+ }
+}
+
+.subhead h1.user {
+ span {
+ padding-left:10px;
+ font-size: 24px;
+ line-height: 24px;
+ }
+ img {
+ width:42px;
+ height:42px;
+ border-radius:50%;
+ -webkit-border-radius:50%;
+ -moz-border-radius:50%;
+ }
+ small {
+ color:#777;
+ font-size:60%;
+ }
+ }
+
+.subhead .container {
+ position:relative;
+ padding:0px;
+}
+
+// Give the tabs something to sit on
+.subhead .nav-tabs {
+ //border-bottom: 1px solid #dee3e6;
+ position:absolute;
+ right:0px;
+ bottom:0px;
+ margin:0px;
+ padding:0px;
+ border-bottom:none;
+}
+// Make the list-items overlay the bottom border
+.subhead .nav-tabs > li {
+ margin-bottom: 0px;
+}
+// Actual tabs (as links)
+// Actual tabs (as links)
+.subhead .nav-tabs > li > a {
+ color:#999;
+ padding:10px 20px;
+ line-height:20px;
+ border:0px;
+}
+
+.subhead .nav-tabs > li.active > a{
+ color:#777;
+ border:0px;
+}
+
+//
+// nav-repos
+// --------------------------------------------
+
+.nav-repos,
+.nav-branches {
+ margin-top: 0px;
+ //margin-top:20px;
+ border:none;
+ li {
+ a {
+ line-height:22px;
+
+ margin-bottom: 0px;
+ padding: 5px 15px;
+ padding:10px 15px;
+ border:none;
+ margin:0px !IMPORTANT;
+ font-size:15px;
+ border-radius:0px;
+ -moz-border-radius:0px;
+ -webkit-border-radius:0px;
+ overflow:hidden;
+ white-space:nowrap;
+ text-overflow:ellipsis;
+ span {
+ color:#777;
+ }
+ span [class^="icon-"] {
+ color:#999;
+ }
+ i {
+ padding-right:10px;
+ color:#AAA;
+ font-size:18px;
+ vertical-align: middle;
+ }
+ }
+ }
+ li.active {
+ a {
+ background: #f4f4f4;
+ }
+ a:hover {
+ background: #f4f4f4;
+ }
+ }
+}
+
+.nav-repos .nav-header,
+.nav-branches .nav-header {
+ padding: 15px 15px;
+ margin-bottom: 0px;
+ color: #666;
+ font-size: 24px;
+ text-transform:lowercase;
+ font-family: 'Open Sans','Helvetica Neue',Helvetica,Arial,sans-serif;
+ font-weight: normal;
+ display:none;
+}
+
+.nav-repos,
+.nav-branches {
+ li {
+ a {
+ //border-top: 1px solid #EEE;
+ padding: 12px 15px;
+ margin-bottom:0px;
+ color:#777;
+ }
+ }
+}
+
+.nav-pills.nav-repos > li,
+.nav-repos > li {
+ &> a {
+ padding:13px 15px;
+ }
+ &:nth-child(odd) {
+ background: #f7f7f7;
+ }
+}
+.nav-repos > li > a span {
+ color:#AAA;
+ &:last-child {
+ color:#555;
+ }
+}
+
+.nav-pills.nav-branches > li > a {
+ padding:10px 15px;
+}
+
+// ALERTS
+
+.alert {
+ border: none;
+ border-radius: 0px;
+ background: #f4f4f4;
+ color: #999;
+ padding: 30px;
+ font-size: 18px;
+ margin-bottom: 20px;
+ .pull-right {
+ margin-top:-10px;
+ }
+}
+
+.alert .thumbnails {
+ margin:0px;
+ padding:0px;
+ li {
+ margin:0px;
+ padding:0px;
+ margin-left:20px;
+ margin-top:20px;
+ }
+
+ .thumbnail {
+ background:transparent;
+ }
+}
+
+.alert.alert-feed {
+ margin-top: 0px;
+ margin-bottom: 0px;
+ padding:15px 20px;
+
+ span {
+ line-height:50px;
+ }
+
+ span.label {
+ float:right;
+ color: #BBB;
+ font-size: 16px;
+ padding-right:10px;
+ font-weight:normal;
+ }
+
+ .thumbnails > li {
+ margin:0px;
+ margin-left:5px;
+ padding:0px;
+ display:inline-block;
+ a {
+ border: 0px;
+ padding: 0px;
+ img {
+ border-radius:50%;
+ -moz-border-radius:50%;
+ -webkit-border-radius:50%;
+ width: 32px;
+ height: 32px;
+ margin-top:10px;
+ }
+ }
+ }
+}
+
+.alert.alert-success {
+ color: #3c763d;
+ background-color: #dff0d8;
+
+ text-shadow: none;
+ font-size: 16px;
+ margin-top: 30px;
+ margin-bottom: 10px;
+}
+
+.alert.alert-error {
+ background: #ebccd1;
+ color: #a94442;
+
+ text-shadow: none;
+ font-size: 16px;
+ margin-top: 30px;
+ margin-bottom: 10px;
+}
+
+//
+// build list
+// --------------------------------------------
+
+.commit-list {
+ list-style: none;
+ margin:0px;
+ padding-left:0px;
+}
+
+
+
+.commit-list > li {
+ margin:0px;
+ position:relative;
+ padding:25px 20px;
+ padding-left:25px;
+ list-style:none;
+
+ &> img {
+ position: absolute;
+ right: 20px;
+ top: 20px;
+ -webkit-border-radius: 50%;
+ -moz-border-radius: 50%;
+ border-radius: 50%;
+ width: 48px;
+ height: 48px;
+ background: #EEE;
+ }
+}
+
+.commit-list > li:last-child {
+ border-bottom:none;
+}
+
+.commit-list > li:nth-child(even) {
+ background:#f7f7f7;
+}
+
+.commit-list.commit-list-alt > li:nth-child(even) {
+ background:#fff;
+}
+
+.commit-list.commit-list-alt > li:nth-child(odd) {
+ background:#f7f7f7;
+}
+
+.commit-list > li > h3 {
+ display:inline;
+ line-height:18px;
+ height:18px;
+ font-weight:normal;
+ color: #777;
+ font-size: 18px;
+ margin-top:10px;
+ a, span {
+ margin-top:2px;
+ display:inline-block;
+ }
+ a {
+ color:#555;
+ text-decoration:none;
+ &:hover {
+ text-decoration:underline;
+ }
+ }
+ small {
+ font-size:14px;
+ display:inline-block;
+ margin-left:5px;
+ white-space:nowrap;
+ }
+ p {
+ font-size:14px;
+ color:#999;
+ margin:0px;
+ padding:0px;
+ padding-top:2px;
+ display: block;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ max-width: 350px;
+ }
+}
+
+
+// BUTTONS
+
+.btn.btn-Success,
+.btn.btn-Failure,
+.btn.btn-Pending,
+.btn.btn-Started,
+.btn.btn-Error,
+.btn.btn-None {
+
+ border: none;
+ background: #BBB;
+ color: #FFF;
+ font-weight: normal;
+ text-shadow: none;
+ box-shadow: none;
+ padding: 10px 15px;
+ font-size: 16px;
+ min-width:75px;
+ float:left;
+ margin-right:25px;
+ font-size:16px;
+ text-transform:none;
+ font-weight:normal;
+
+ padding: 0px;
+ border-bottom: 0px;
+ width: 48px;
+ height: 48px;
+ min-height: 48px;
+ max-height: 48px;
+ min-width: 48px;
+ max-width: 48px;
+ border-radius:50%;
+ -webkit-border-radius:50%;
+ -moz-border-radius:50%;
+}
+
+.btn.btn-success,
+.btn.btn-Success {
+ background:rgba(81, 163, 81, 0.75);
+}
+.btn.btn-failure,
+.btn.btn-Failure,
+.btn.btn-Error {
+ background:rgba(189, 54, 47, 0.8);
+}
+
+.btn.btn-Scheduled,
+.btn.btn-Pending,
+.btn.btn-Started {
+ background: #D5E802;
+}
+
+.btn.btn-Success:before {
+ content: "\f00c";
+ font-family: 'FontAwesome';
+ font-size: 22px;
+ line-height: 48px;
+ opacity:0.8;
+ color:#FFF;
+}
+.btn.btn-Error:before,
+.btn.btn-Failure:before {
+ content: "\f00d";
+ font-family: 'FontAwesome';
+ font-size: 22px;
+ line-height: 48px;
+ opacity:0.8;
+ color:#fff;
+}
+
+.btn.btn-refresh {
+ position: absolute;
+ left: -95px;
+ float: left;
+ background: #f7f7f7;
+ width: 75px;
+ border-radius: 0px;
+ -webkit-border-radius: 0px;
+ -moz-border-radius: 0px;
+ text-align: center;
+ font-size: 22px;
+ text-decoration: none;
+ color: #999;
+ padding:10px 0px;
+ z-index:1;
+ span {
+ display:block;
+ font-size:14px;
+ padding-top:5px;
+ }
+}
+
+.btn.btn-Started:before,
+.btn.btn-Scheduled:before,
+.btn.btn-Pending:before {
+ content: "\f021";
+ font-family: 'FontAwesome';
+ font-size: 22px;
+ line-height: 48px;
+ color:#FFF;
+
+ display: inline-block;
+
+ -webkit-animation: spin 1.5s infinite linear;
+ -moz-animation: spin 1.5s infinite linear;
+ -ms-animation: spin 1.5s infinite linear;
+ -o-animation: spin 1.5s infinite linear;
+ animation: spin 1.5s infinite linear;
+}
+
+.btn.btn-mini {
+ width:24px;
+ height:24px;
+ max-width:24px;
+ max-height:24px;
+ min-width:24px;
+ min-height:24px;
+}
+
+.btn.btn-mini.btn-Success:before,
+.btn.btn-mini.btn-Failure:before,
+.btn.btn-mini.btn-Error:before,
+.btn.btn-mini.btn-Started:before,
+.btn.btn-mini.btn-Scheduled:before,
+.btn.btn-mini.btn-Pending:before {
+ line-height:24px !IMPORTANT;
+ font-size:14px !IMPORTANT;
+}
+
+@-webkit-keyframes spin {
+ to { -webkit-transform: rotate(360deg); }
+}
+
+@-moz-keyframes spin {
+ to { -moz-transform: rotate(360deg); }
+}
+
+@-ms-keyframes spin {
+ to { -ms-transform: rotate(360deg); }
+}
+
+@-o-keyframes spin {
+ to { -o-transform: rotate(360deg); }
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+
+.btn.btn-None {
+ //background: #ebebeb;
+ //border-bottom: 2px solid #CCC;
+ //color: #737373;
+ background:rgba(81, 163, 81, 0.75);
+ border-bottom:2px solid rgba(81, 163, 81, 1);
+}
+
+
+.btn.btn-default {
+ padding:7px 20px;
+ padding-bottom:6px;
+ border-bottom-width: 2px;
+ color:#777;
+}
+
+.btn.btn-primary, {
+ background:rgba(251, 136, 35, 0.85);
+ padding:7px 20px;
+ border:none;
+ border-bottom: 2px solid #cd6d1d;
+ &:hover, &:active {
+ background:rgba(251, 136, 35, 1);
+ border-bottom: 2px solid #cd6d1d;
+ }
+}
+
+.btn.btn-danger {
+ border:none;
+ padding:7px 20px;
+ background: rgba(189, 54, 47, 0.8);
+ border-bottom: 2px solid #bd362f;
+ &:hover, &:active {
+ background:rgba(189, 54, 47, 0.9);
+ border-bottom: 2px solid #bd362f;
+ }
+}
+
+///////////////////////////////////////////////////////
+///////////////////////////////////////////////////////
+// NAV PILLS
+
+.nav-pills > li {
+ //margin-top: 2px;
+ a {
+ padding:7px 15px;
+ color: rgb(119, 119, 119);
+ }
+}
+
+
+.nav-pills > li > a,
+.nav-pills > li > a:hover,
+.nav-pills > li > a:focus{
+ border-radius:0px;
+ -webkit-border-radius:0px;
+ -moz-border-radius:0px;
+ //margin-top:0px;
+ //padding:7px 15px;
+}
+
+.nav-pills > .active {
+ border-radius:0px;
+ -webkit-border-radius:0px;
+ -moz-border-radius:0px;
+ //margin-top:0px;
+}
+
+.nav-pills>li.active>a,
+.nav-pills>li.active>a:hover,
+.nav-pills>li.active>a:focus,
+.nav-pills > .active > a,
+.nav-pills > .active > a:hover,
+.nav-pills > .active > a:focus{
+ border-radius:0px;
+ -webkit-border-radius:0px;
+ -moz-border-radius:0px;
+ //margin-bottom:0px;
+ margin-top:0px;
+ background-color: #f4f4f4;
+ color: rgb(119, 119, 119);
+
+}
+
+//////////////////////////////////////
+// FORM
+
+form label {
+ color: #777;
+ margin-top: 25px;
+ margin-bottom: 7px;
+ margin-left: 2px;
+ cursor:default;
+ font-weight:normal;
+ display:block;
+
+ &:first-child {
+ margin-top:0px;
+ }
+}
+
+form .form-actions {
+ background:transparent;
+ padding:30px 0px;
+ margin:0px;
+ border:none;
+
+ .btn {
+ margin-right:3px;
+ }
+}
+
+form .form-group {
+ margin-bottom:30px;
+}
+
+input.form-control,
+select.form-control {
+ display: inline-block;
+ height: 30px;
+ padding: 4px 6px;
+ margin-bottom: 0px;
+ font-size: 14px;
+ line-height: 20px;
+ color: #555555;
+ -webkit-border-radius: 4px;
+ -moz-border-radius: 4px;
+ border-radius: 4px;
+ vertical-align: middle;
+
+ width:270px;
+}
+
+input.form-control-small,
+select.form-control-small {
+ width: 90px;
+}
+
+input.form-control-large,
+select.form-control-large {
+ width: 210px;
+}
+
+input.form-control-xlarge,
+select.form-control-xlarge {
+ width: 270px;
+}
+
+span.form-control {
+ color: #999999;
+ background-color: #fcfcfc;
+ border-color: #cccccc;
+ -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.025);
+ -moz-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.025);
+ box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.025);
+ cursor: not-allowed;
+}
+
+textarea {
+ font-family: 'Droid Sans Mono', monospace;
+}
+
+///////////////////////////////
+
+////////////////////////////////
+
+.avatar-link {
+ float:right;
+ position:relative;
+ background:#eee;
+ padding:5px;
+ text-decoration:none;
+ span {
+ display: block;
+ text-align: center;
+ color: #777;
+ padding: 5px;
+ }
+ img {
+ width:160px;
+ height:160px;
+ max-width:160px;
+ max-height:160px;
+ min-width:160px;
+ min-height:160px;
+ }
+ &:hover {
+ text-decoration:none;
+ &:before {
+ color: #fff;
+ text-shadow: 1px 1px rgba(0,0,0,0.5);
+ font-family:'FontAwesome';
+ content:"\f044";
+ position:absolute;
+ right:10px;
+ top:10px;
+ font-size:16px;
+ }
+ }
+}
+
+///////////////////////////
+
+.row.gravatar-list {
+ margin-left:0px;
+ .col-xs-3 {
+ width:23.404255319148934%;
+ margin:0px;
+ padding:0px;
+ margin-right:2.127659574468085%;
+ &:nth-child(4) {
+ margin-right:0px;
+ }
+ a.thumbnail, a.thumbnail:hover {
+ border-radius:0px;
+ -webkit-border-radius:0px;
+ -moz-border-radius:0px;
+ box-shadow:none;
+ border:none;
+ text-decoration:none;
+ background:#f4f4f4;
+ img {
+ width:156px;
+ height:156px;
+ }
+ .caption > h3 {
+ font-weight:normal;
+ text-align:center;
+ font-size:15px;
+ padding: 0px;
+ line-height: 16px;
+ margin: 0px;
+ max-width: 156px;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ color:rgb(85, 85, 85);
+ }
+ &.team-add {
+ &> span {
+ min-width:156px;
+ min-height:156px;
+ display:inline-block;
+ text-align: center;
+ vertical-align: middle;
+ line-height: 156px;
+ font-size: 64px;
+ color: #d7d7d7;
+ }
+ .caption > h3 {
+ color: #999;
+ }
+ }
+ }
+ }
+}
+
+
+
+
+
+
+
+////////////////////////////////////////
+// SWITCH
+
+/* Checkbox hack */
+.switch input,
+.switch a {
+ cursor: pointer;
+ height: 100%;
+ opacity: 0;
+ position: absolute;
+ width: 100%;
+ z-index: 100;
+}
+
+/* Container */
+.switch {
+ height: 40px;
+ position: relative;
+ width: 100px;
+}
+
+/* Background unchecked */
+.switch label {
+ background: rgba(189, 54, 47, 0.8);
+ border-radius: 2px;
+ display: block;
+ height: 100%;
+ position: relative;
+ transition: all .15s ease;
+ width: 100%;
+ border-radius:5px;
+ color:#FFF;
+}
+
+/* Slider unchecked */
+.switch label div {
+ background: #fff;
+ border-radius: 2px;
+ display: block;
+ height: 28px;
+ left: 6px;
+ position: absolute;
+ top: 6px;
+ transition: all .15s ease;
+ width: 35px;
+ z-index: 3;
+ border-radius:5px;
+}
+
+/* Vertical lines on slider */
+.switch label div:before {
+ bottom: 0;
+ content: '';
+ display: block;
+ height: 15px;
+ left: 0;
+ margin: auto;
+ position: absolute;
+ right: 0;
+ top: 0;
+ transition: all .15s ease;
+ width: 3px;
+ //box-shadow: -6px 0 0 0 #d3d3d3, 6px 0 0 0 #d3d3d3;
+}
+
+/* Icon styles */
+.switch .fontawesome-ok {
+ font-size: 18px;
+ left: 15px;
+ position: relative;
+ top: 8px;
+ z-index: 2;
+}
+
+.switch .fontawesome-ok:before {
+ font-family: 'FontAwesome';
+ content:"\f00c";
+ opacity:0.8;
+}
+
+
+.switch .fontawesome-remove {
+ font-size: 18px;
+ left: 45px;
+ position: relative;
+ top: 8px;
+ z-index: 2;
+}
+
+.switch .fontawesome-remove:before {
+ font-family: 'FontAwesome';
+ content:"\f00d";
+ opacity:0.8;
+}
+
+/* Checked States */
+.switch input:checked~label {
+ background: rgba(81, 163, 81, 0.75);
+}
+
+.switch input:checked~label div {
+ left: 58px;
+}
+
+
+/////// BUID OUTPUT
+
+pre {
+ white-space: pre-wrap;
+ background: #464849;
+ color: #FFF;
+ padding: 20px 20px;
+ border-radius: 5px;
+ font-size: 12px;
+ margin-top: 0px;
+ overflow: hidden;
+ font-family:'Droid Sans Mono', 'monospace';
+ line-height: 20px
+
+}
+
+.alert.alert-build-Success,
+.alert.alert-build-Error,
+.alert.alert-build-Failure,
+.alert.alert-build-Pending,
+.alert.alert-build-Started {
+ text-shadow:none;
+ margin-top:5px;
+ margin-bottom:30px;
+ font-size:15px;
+ padding:15px 20px;
+ border-radius:5px;
+ -webkit-border-radius:5px;
+ -moz-border-radius:5px;
+
+ span {
+ line-height:32px;
+ span {
+ text-decoration:underline;
+ }
+ }
+
+ a.btn {
+ width:32px;
+ height:32px;
+ max-width:32px;
+ max-height:32px;
+ min-width:32px;
+ min-height:32px;
+ margin-right:20px !IMPORTANT;
+ &:before {
+ font-size: 22px !IMPORTANT;
+ line-height: 32px !IMPORTANT;
+ }
+ }
+}
+
+.build-details {
+
+ dt {
+ float:left;
+ width:90px;
+ color:#333;
+ font-weight:normal;
+ &:after {
+ content:':';
+ }
+ }
+ dd {
+ color:#555;
+ white-space:nowrap;
+ }
+
+ .build-summary {
+ float:left;
+ width:250px;
+ padding-left:20px;
+ }
+
+ img {
+ float:left;
+ border-radius:50%;
+ -webkit-border-radius:50%;
+ -moz-border-radius:50%;
+ margin-right: 30px;
+ width: 58px;
+ height: 58px;
+ }
+
+ .commit-summary {
+ float:left;
+ }
+
+ background:#FFF;
+ margin-bottom:40px;
+
+
+
+}
+
+.build-details.affix {
+ top: 0px;
+ padding-top:15px;
+ padding-bottom:30px;
+}
+.build-details.affix-top {
+}
+
+.alert.alert-build-Success {
+ color: #468847;
+ background-color: #dff0d8;
+ border-color: #d6e9c6;
+}
+
+.alert.alert-build-Error,
+.alert.alert-build-Failure {
+ background-color:#f2dede;
+ color:#b94a48;
+}
+
+.alert.alert-build-Pending,
+.alert.alert-build-Started {
+ color: #c09853;
+ background: rgba(213, 232, 2, 0.2);
+ background-color: rgba(213, 232, 2, 0.2);
+}
+
+
+
+
+
+
+// new repo form
+
+.form-repo {
+ .field-group {
+ display:inline-block;
+ margin-bottom:30px;
+ label {
+ margin-top:0px;
+ }
+ }
+ .field-separator {
+ display:inline-block;
+ font-size:20px;
+ }
+
+ ul {
+ padding-top:15px;
+ margin-bottom:0px;
+ padding-bottom:0px;
+ list-style: none;
+ padding-left:0px;
+ margin-left:0px;
+ li {
+ padding-bottom:10px;
+ img {
+ width:32px;
+ height:32px;
+ border-radius:50%;
+ -webkit-border-radius:50%;
+ -moz-border-radius:50%;
+ margin-left:10px;
+ margin-right:10px;
+ }
+ }
+ }
+}
+
+ ul.account-radio-group {
+ padding-top:15px;
+ margin-bottom:0px;
+ padding-bottom:0px;
+ list-style: none;
+ padding-left:0px;
+ margin-left:0px;
+ li {
+ padding-bottom:10px;
+ img {
+ width:32px;
+ height:32px;
+ border-radius:50%;
+ -webkit-border-radius:50%;
+ -moz-border-radius:50%;
+ margin-left:10px;
+ margin-right:10px;
+ }
+ }
+ }
+
+
+
+// LOGIN FORM
+
+.form-centered {
+ background:#FFF;
+ overflow:hidden;
+ max-width: 350px;
+ margin: 0 auto;
+ text-align: center;
+ margin-top: 40px;
+ padding: 0px 0px 20px 0px;
+ border-radius:5px;
+ -webkit-border-radius:5px;
+ -moz-border-radius:5px;
+
+ h1 {
+ background-color: #262829;
+ color: #FFF;
+ border-top-left-radius: 5px;
+ border-top-right-radius: 5px;
+ font-family: 'Orbitron';
+ font-weight: normal;
+ font-size: 18px;
+ padding: 25px 10px;
+ margin-top:0px;
+ margin-bottom:30px;
+ }
+
+ input[type="submit"] {
+
+ border:none;
+ color: #FFF;
+ padding: 10px;
+ font-size: 18px;
+ margin-top: 30px;
+ margin-bottom: 10px;
+ width: 280px;
+ background: #AAA;
+ border-radius:5px;
+ -webkit-border-radius:5px;
+ -moz-border-radius:5px;
+ }
+
+ input[type="text"],
+ input[type="password"] {
+ margin-bottom: 0px;
+ font-size: 16px;
+ padding: 10px;
+ width: 280px;
+ height:40px;
+ &:last-child {
+ border-top-width: 0px;
+ border-top-right-radius: 0px;
+ border-top-left-radius: 0px;
+ }
+ &:first-child {
+ border-bottom-right-radius: 0px;
+ border-bottom-left-radius: 0px;
+ }
+ &[disabled] {
+ box-shadow: none;
+ background: #f7f7f7;
+ color: #999;
+ text-shadow: none;
+ }
+ &.only-child {
+ border-bottom-right-radius: 4px;
+ border-bottom-left-radius: 4px;
+ border-top-right-radius: 4px;
+ border-top-left-radius: 4px;
+ border-bottom-width: 1px;
+ border-top-width: 1px;
+ }
+ }
+
+ a, a:visited {
+ color:#999;
+ text-decoration:underline;
+ }
+
+ h1~div.alert {
+ margin-top:-30px;
+ }
+
+ .alert.alert-danger {
+ background: #ebccd1;
+ color: #a94442;
+ text-shadow: none;
+ font-weight: normal;
+ font-size: 16px;
+ padding: 20px;
+ margin-bottom:30px;
+ }
+
+ .alert.alert-success {
+ color: #3c763d;
+ background-color: #dff0d8;
+ text-shadow: none;
+ font-weight: normal;
+ font-size: 16px;
+ padding: 20px;
+ margin-bottom:30px;
+ }
+}
\ No newline at end of file
diff --git a/cmd/droned/assets/img/build_failing.png b/cmd/droned/assets/img/build_failing.png
new file mode 100644
index 000000000..ed9087edd
Binary files /dev/null and b/cmd/droned/assets/img/build_failing.png differ
diff --git a/cmd/droned/assets/img/build_none.png b/cmd/droned/assets/img/build_none.png
new file mode 100644
index 000000000..f4f431d1c
Binary files /dev/null and b/cmd/droned/assets/img/build_none.png differ
diff --git a/cmd/droned/assets/img/build_success.png b/cmd/droned/assets/img/build_success.png
new file mode 100644
index 000000000..2d1cc7585
Binary files /dev/null and b/cmd/droned/assets/img/build_success.png differ
diff --git a/cmd/droned/assets/img/build_unknown.png b/cmd/droned/assets/img/build_unknown.png
new file mode 100644
index 000000000..6f3e3e871
Binary files /dev/null and b/cmd/droned/assets/img/build_unknown.png differ
diff --git a/cmd/droned/assets/img/favicon.ico b/cmd/droned/assets/img/favicon.ico
new file mode 100644
index 000000000..81b66798a
Binary files /dev/null and b/cmd/droned/assets/img/favicon.ico differ
diff --git a/cmd/droned/assets/img/favicon.png b/cmd/droned/assets/img/favicon.png
new file mode 100644
index 000000000..661fa5ef8
Binary files /dev/null and b/cmd/droned/assets/img/favicon.png differ
diff --git a/cmd/droned/drone.go b/cmd/droned/drone.go
new file mode 100644
index 000000000..6397d134e
--- /dev/null
+++ b/cmd/droned/drone.go
@@ -0,0 +1,187 @@
+package main
+
+import (
+ "database/sql"
+ "flag"
+ "log"
+ "net/http"
+ "strings"
+
+ "code.google.com/p/go.net/websocket"
+ "github.com/GeertJohan/go.rice"
+ "github.com/bmizerany/pat"
+ _ "github.com/mattn/go-sqlite3"
+ "github.com/russross/meddler"
+
+ "github.com/drone/drone/pkg/channel"
+ "github.com/drone/drone/pkg/database"
+ "github.com/drone/drone/pkg/handler"
+)
+
+var (
+ // local path where the SQLite database
+ // should be stored. By default this is
+ // in the current working directory.
+ path string
+
+ // port the server will run on
+ port string
+
+ // database driver used to connect to the database
+ driver string
+
+ // driver specific connection information. In this
+ // case, it should be the location of the SQLite file
+ datasource string
+)
+
+func main() {
+ // parse command line flags
+ flag.StringVar(&path, "path", "", "")
+ flag.StringVar(&port, "port", ":8080", "")
+ flag.StringVar(&driver, "driver", "sqlite3", "")
+ flag.StringVar(&datasource, "datasource", "drone.sqlite", "")
+ flag.Parse()
+
+ // setup database and handlers
+ setupDatabase()
+ setupStatic()
+ setupHandlers()
+
+ // start the webserver on the default port.
+ panic(http.ListenAndServe(port, nil))
+}
+
+// setup the database connection and register with the
+// global database package.
+func setupDatabase() {
+ // inform meddler we're using sqlite
+ meddler.Default = meddler.SQLite
+
+ // connect to the SQLite database
+ db, err := sql.Open(driver, datasource)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ database.Set(db)
+}
+
+// setup routes for static assets. These assets may
+// be directly embedded inside the application using
+// the `rice embed` command, else they are served from disk.
+func setupStatic() {
+ box := rice.MustFindBox("assets")
+ http.Handle("/css/", http.FileServer(box.HTTPBox()))
+ http.Handle("/img/", http.FileServer(box.HTTPBox()))
+}
+
+// setup routes for serving dynamic content.
+func setupHandlers() {
+ m := pat.New()
+ m.Get("/login", handler.ErrorHandler(handler.Login))
+ m.Post("/login", handler.ErrorHandler(handler.Authorize))
+ m.Get("/logout", handler.ErrorHandler(handler.Logout))
+ m.Get("/forgot", handler.ErrorHandler(handler.Forgot))
+ m.Post("/forgot", handler.ErrorHandler(handler.ForgotPost))
+ m.Get("/reset", handler.ErrorHandler(handler.Reset))
+ m.Post("/reset", handler.ErrorHandler(handler.ResetPost))
+ m.Get("/register", handler.ErrorHandler(handler.Register))
+ m.Post("/register", handler.ErrorHandler(handler.RegisterPost))
+ m.Get("/accept", handler.UserHandler(handler.TeamMemberAccept))
+
+ // handlers for setting up your GitHub repository
+ m.Post("/new/github.com", handler.UserHandler(handler.RepoCreateGithub))
+ m.Get("/new/github.com", handler.UserHandler(handler.RepoAdd))
+
+ // handlers for linking your GitHub account
+ m.Get("/auth/login/github", handler.UserHandler(handler.LinkGithub))
+
+ // handlers for dashboard pages
+ m.Get("/dashboard/team/:team", handler.UserHandler(handler.TeamShow))
+ m.Get("/dashboard", handler.UserHandler(handler.UserShow))
+
+ // handlers for user account management
+ m.Get("/account/user/profile", handler.UserHandler(handler.UserEdit))
+ m.Post("/account/user/profile", handler.UserHandler(handler.UserUpdate))
+ m.Get("/account/user/delete", handler.UserHandler(handler.UserDeleteConfirm))
+ m.Post("/account/user/delete", handler.UserHandler(handler.UserDelete))
+ m.Get("/account/user/password", handler.UserHandler(handler.UserPass))
+ m.Post("/account/user/password", handler.UserHandler(handler.UserPassUpdate))
+ m.Get("/account/user/teams/add", handler.UserHandler(handler.TeamAdd))
+ m.Post("/account/user/teams/add", handler.UserHandler(handler.TeamCreate))
+ m.Get("/account/user/teams", handler.UserHandler(handler.UserTeams))
+
+ // handlers for team managements
+ m.Get("/account/team/:team/profile", handler.UserHandler(handler.TeamEdit))
+ m.Post("/account/team/:team/profile", handler.UserHandler(handler.TeamUpdate))
+ m.Get("/account/team/:team/delete", handler.UserHandler(handler.TeamDeleteConfirm))
+ m.Post("/account/team/:team/delete", handler.UserHandler(handler.TeamDelete))
+ m.Get("/account/team/:team/members/add", handler.UserHandler(handler.TeamMemberAdd))
+ m.Post("/account/team/:team/members/add", handler.UserHandler(handler.TeamMemberInvite))
+ m.Get("/account/team/:team/members/edit", handler.UserHandler(handler.TeamMemberEdit))
+ m.Post("/account/team/:team/members/edit", handler.UserHandler(handler.TeamMemberUpdate))
+ m.Post("/account/team/:team/members/delete", handler.UserHandler(handler.TeamMemberDelete))
+ m.Get("/account/team/:team/members", handler.UserHandler(handler.TeamMembers))
+
+ // handlers for system administration
+ m.Get("/account/admin/settings", handler.AdminHandler(handler.AdminSettings))
+ m.Post("/account/admin/settings", handler.AdminHandler(handler.AdminSettingsUpdate))
+ m.Get("/account/admin/users/edit", handler.AdminHandler(handler.AdminUserEdit))
+ m.Post("/account/admin/users/edit", handler.AdminHandler(handler.AdminUserUpdate))
+ m.Post("/account/admin/users/delete", handler.AdminHandler(handler.AdminUserDelete))
+ m.Get("/account/admin/users/add", handler.AdminHandler(handler.AdminUserAdd))
+ m.Post("/account/admin/users", handler.AdminHandler(handler.AdminUserInvite))
+ m.Get("/account/admin/users", handler.AdminHandler(handler.AdminUserList))
+
+ // handlers for GitHub post-commit hooks
+ m.Post("/hook/github.com", handler.ErrorHandler(handler.Hook))
+
+ // handlers for first-time installation
+ m.Get("/install", handler.ErrorHandler(handler.Install))
+ m.Post("/install", handler.ErrorHandler(handler.InstallPost))
+
+ // handlers for repository, commits and build details
+ m.Get("/:host/:owner/:name/commit/:commit/build/:label/out.txt", handler.RepoHandler(handler.BuildOut))
+ m.Get("/:host/:owner/:name/commit/:commit/build/:label", handler.RepoHandler(handler.CommitShow))
+ m.Get("/:host/:owner/:name/commit/:commit", handler.RepoHandler(handler.CommitShow))
+ m.Get("/:host/:owner/:name/tree/:branch/status.png", handler.ErrorHandler(handler.Badge))
+ m.Get("/:host/:owner/:name/tree/:branch", handler.RepoHandler(handler.RepoDashboard))
+ m.Get("/:host/:owner/:name/status.png", handler.ErrorHandler(handler.Badge))
+ m.Get("/:host/:owner/:name/settings", handler.RepoAdminHandler(handler.RepoSettingsForm))
+ m.Get("/:host/:owner/:name/params", handler.RepoAdminHandler(handler.RepoParamsForm))
+ m.Get("/:host/:owner/:name/badges", handler.RepoAdminHandler(handler.RepoBadges))
+ m.Get("/:host/:owner/:name/keys", handler.RepoAdminHandler(handler.RepoKeys))
+ m.Get("/:host/:owner/:name/delete", handler.RepoAdminHandler(handler.RepoDeleteForm))
+ m.Post("/:host/:owner/:name/delete", handler.RepoAdminHandler(handler.RepoDelete))
+ m.Get("/:host/:owner/:name", handler.RepoHandler(handler.RepoDashboard))
+ m.Post("/:host/:owner/:name", handler.RepoHandler(handler.RepoUpdate))
+ http.Handle("/feed", websocket.Handler(channel.Read))
+
+ // no routes are served at the root URL. Instead we will
+ // redirect the user to his/her dashboard page.
+ m.Get("/", http.RedirectHandler("/dashboard", http.StatusSeeOther))
+
+ // the first time a page is requested we should record
+ // the scheme and hostname.
+ http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ // get the hostname and scheme
+
+ // our multiplexer is a bit finnicky and therefore requires
+ // us to strip any trailing slashes in order to correctly
+ // find and match a route.
+ if r.URL.Path != "/" && strings.HasSuffix(r.URL.Path, "/") {
+ http.Redirect(w, r, r.URL.Path[:len(r.URL.Path)-1], http.StatusSeeOther)
+ return
+ }
+
+ // standard header variables that should be set, for good measure.
+ w.Header().Add("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate")
+ w.Header().Add("X-Frame-Options", "DENY")
+ w.Header().Add("X-Content-Type-Options", "nosniff")
+ w.Header().Add("X-XSS-Protection", "1; mode=block")
+
+ // ok, now we're ready to serve the request.
+ m.ServeHTTP(w, r)
+ })
+}
diff --git a/deb/drone/DEBIAN/control b/deb/drone/DEBIAN/control
new file mode 100644
index 000000000..a7d76e06b
--- /dev/null
+++ b/deb/drone/DEBIAN/control
@@ -0,0 +1,7 @@
+Package: drone
+Version: 0.1
+Section: base
+Priority: optional
+Architecture: amd64
+Maintainer: Brad Rydzewski
+Description: Drone continuous integration server
diff --git a/deb/drone/etc/init/drone.conf b/deb/drone/etc/init/drone.conf
new file mode 100644
index 000000000..102b2bc34
--- /dev/null
+++ b/deb/drone/etc/init/drone.conf
@@ -0,0 +1,8 @@
+start on (filesystem and net-device-up)
+
+chdir /var/lib/drone
+console log
+
+script
+ droned --port=":80"
+end script
\ No newline at end of file
diff --git a/pkg/build/build.go b/pkg/build/build.go
new file mode 100644
index 000000000..3fb5aecd9
--- /dev/null
+++ b/pkg/build/build.go
@@ -0,0 +1,471 @@
+package build
+
+import (
+ "fmt"
+ "io"
+ "io/ioutil"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/drone/drone/pkg/build/buildfile"
+ "github.com/drone/drone/pkg/build/docker"
+ "github.com/drone/drone/pkg/build/dockerfile"
+ "github.com/drone/drone/pkg/build/log"
+ "github.com/drone/drone/pkg/build/proxy"
+ "github.com/drone/drone/pkg/build/repo"
+ "github.com/drone/drone/pkg/build/script"
+)
+
+// instance of the Docker client
+var client = docker.New()
+
+// BuildState stores information about a build
+// process including the Exit status and various
+// Runtime statistics (coming soon).
+type BuildState struct {
+ Started int64
+ Finished int64
+ ExitCode int
+
+ // we may eventually include detailed resource
+ // usage statistics, including including CPU time,
+ // Max RAM, Max Swap, Disk space, and more.
+}
+
+// Builder represents a build process being prepared
+// to run.
+type Builder struct {
+ // Image specifies the Docker Image that will be
+ // used to virtualize the Build process.
+ Build *script.Build
+
+ // Source specifies the Repository path of the code
+ // that we are testing.
+ //
+ // The source repository may be a local repository
+ // on the current filesystem, or a remote repository
+ // on GitHub, Bitbucket, etc.
+ Repo *repo.Repo
+
+ // Key is an identify file, such as an RSA private key, that
+ // will be copied into the environments ~/.ssh/id_rsa file.
+ Key []byte
+
+ // Timeout is the maximum amount of to will wait for a process
+ // to exit.
+ //
+ // The default is no timeout.
+ Timeout time.Duration
+
+ // Stdout specifies the builds's standard output.
+ //
+ // If stdout is nil, Run connects the corresponding file descriptor
+ // to the null device (os.DevNull).
+ Stdout io.Writer
+
+ // BuildState contains information about an exited build,
+ // available after a call to Run.
+ BuildState *BuildState
+
+ // Docker image that was created for
+ // this build.
+ image *docker.Image
+
+ // Docker container was that created
+ // for this build.
+ container *docker.Run
+
+ // Docker containers created for the
+ // specified services and linked to
+ // this build.
+ services []*docker.Container
+}
+
+func (b *Builder) Run() error {
+ // teardown will remove the Image and stop and
+ // remove the service containers after the
+ // build is done running.
+ defer b.teardown()
+
+ // setup will create the Image and supporting
+ // service containers.
+ if err := b.setup(); err != nil {
+ return err
+ }
+
+ // make sure build state is not nil
+ b.BuildState = &BuildState{}
+ b.BuildState.ExitCode = 0
+ b.BuildState.Started = time.Now().UTC().Unix()
+
+ c := make(chan error, 1)
+ go func() {
+ c <- b.run()
+ }()
+
+ // wait for either a) the job to complete or b) the job to timeout
+ select {
+ case err := <-c:
+ return err
+ case <-time.After(b.Timeout):
+ log.Errf("time limit exceeded for build %s", b.Build.Name)
+ b.BuildState.ExitCode = 124
+ b.BuildState.Finished = time.Now().UTC().Unix()
+ return nil
+ }
+
+ return nil
+}
+
+func (b *Builder) setup() error {
+
+ // temp directory to store all files required
+ // to generate the Docker image.
+ dir, err := ioutil.TempDir("", "drone-")
+ if err != nil {
+ return err
+ }
+
+ // clean up after our mess.
+ defer os.RemoveAll(dir)
+
+ // make sure the image isn't empty. this would be bad
+ if len(b.Build.Image) == 0 {
+ log.Err("Fatal Error, No Docker Image specified")
+ return fmt.Errorf("Error: missing Docker image")
+ }
+
+ // if we're using an alias for the build name we
+ // should substitute it now
+ if alias, ok := builders[b.Build.Image]; ok {
+ b.Build.Image = alias.Tag
+ }
+
+ // if this is a local repository we should symlink
+ // to the source code in our temp directory
+ if b.Repo.IsLocal() {
+ // this is where we used to use symlinks. We should
+ // talk to the docker team about this, since copying
+ // the entire repository is slow :(
+ //
+ // see https://github.com/dotcloud/docker/pull/3567
+
+ //src := filepath.Join(dir, "src")
+ //err = os.Symlink(b.Repo.Path, src)
+ //if err != nil {
+ // return err
+ //}
+
+ src := filepath.Join(dir, "src")
+ cmd := exec.Command("cp", "-a", b.Repo.Path, src)
+ if err := cmd.Run(); err != nil {
+ return err
+ }
+ }
+
+ // start all services required for the build
+ // that will get linked to the container.
+ for _, service := range b.Build.Services {
+ image, ok := services[service]
+ if !ok {
+ return fmt.Errorf("Error: Invalid or unknown service %s", service)
+ }
+
+ // debugging
+ log.Infof("starting service container %s", image.Tag)
+
+ // Run the contianer
+ run, err := client.Containers.RunDaemonPorts(image.Tag, image.Ports...)
+ if err != nil {
+ return err
+ }
+
+ // Get the container info
+ info, err := client.Containers.Inspect(run.ID)
+ if err != nil {
+ // on error kill the container since it hasn't yet been
+ // added to the array and would therefore not get
+ // removed in the defer statement.
+ client.Containers.Stop(run.ID, 10)
+ client.Containers.Remove(run.ID)
+ return err
+ }
+
+ // Add the running service to the list
+ b.services = append(b.services, info)
+
+ }
+
+ if err := b.writeIdentifyFile(dir); err != nil {
+ return err
+ }
+
+ if err := b.writeBuildScript(dir); err != nil {
+ return err
+ }
+
+ if err := b.writeProxyScript(dir); err != nil {
+ return err
+ }
+
+ if err := b.writeDockerfile(dir); err != nil {
+ return err
+ }
+
+ // debugging
+ log.Info("creating build image")
+
+ // check for build container (ie bradrydzewski/go:1.2)
+ // and download if it doesn't already exist
+ if _, err := client.Images.Inspect(b.Build.Image); err == docker.ErrNotFound {
+ // download the image if it doesn't exist
+ if err := client.Images.Pull(b.Build.Image); err != nil {
+ return err
+ }
+ }
+
+ // create the Docker image
+ id := createUID()
+ if err := client.Images.Build(id, dir); err != nil {
+ return err
+ }
+
+ // debugging
+ log.Infof("copying repository to %s", b.Repo.Dir)
+
+ // get the image details
+ b.image, err = client.Images.Inspect(id)
+ if err != nil {
+ // if we have problems with the image make sure
+ // we remove it before we exit
+ client.Images.Remove(id)
+ return err
+ }
+
+ return nil
+}
+
+// teardown is a helper function that we can use to
+// stop and remove the build container, its supporting image,
+// and the supporting service containers.
+func (b *Builder) teardown() error {
+
+ // stop and destroy the container
+ if b.container != nil {
+
+ // debugging
+ log.Info("removing build container")
+
+ // stop the container, ignore error message
+ client.Containers.Stop(b.container.ID, 15)
+
+ // remove the container, ignore error message
+ if err := client.Containers.Remove(b.container.ID); err != nil {
+ log.Errf("failed to delete build container %s", b.container.ID)
+ }
+ }
+
+ // stop and destroy the container services
+ for i, container := range b.services {
+ // debugging
+ log.Infof("removing service container %s", b.Build.Services[i])
+
+ // stop the service container, ignore the error
+ client.Containers.Stop(container.ID, 15)
+
+ // remove the service container, ignore the error
+ if err := client.Containers.Remove(container.ID); err != nil {
+ log.Errf("failed to delete service container %s", container.ID)
+ }
+ }
+
+ // destroy the underlying image
+ if b.image != nil {
+ // debugging
+ log.Info("removing build image")
+
+ if _, err := client.Images.Remove(b.image.ID); err != nil {
+ log.Errf("failed to completely delete build image %s. %s", b.image.ID, err.Error())
+ }
+ }
+
+ return nil
+}
+
+func (b *Builder) run() error {
+ // create and run the container
+ conf := docker.Config{
+ Image: b.image.ID,
+ AttachStdin: false,
+ AttachStdout: true,
+ AttachStderr: true,
+ }
+ host := docker.HostConfig{
+ Privileged: false,
+ }
+
+ // debugging
+ log.Noticef("starting build %s", b.Build.Name)
+
+ // link service containers
+ for i, service := range b.services {
+ image, ok := services[b.Build.Services[i]]
+ if !ok {
+ continue // THIS SHOULD NEVER HAPPEN
+ }
+ // link the service container to our
+ // build container.
+ host.Links = append(host.Links, service.Name[1:]+":"+image.Name)
+ }
+
+ // create the container from the image
+ run, err := client.Containers.Create(&conf)
+ if err != nil {
+ return err
+ }
+
+ // cache instance of docker.Run
+ b.container = run
+
+ // attach to the container
+ go func() {
+ client.Containers.Attach(run.ID, &writer{b.Stdout})
+ }()
+
+ // start the container
+ if err := client.Containers.Start(run.ID, &host); err != nil {
+ b.BuildState.ExitCode = 1
+ b.BuildState.Finished = time.Now().UTC().Unix()
+ return err
+ }
+
+ // wait for the container to stop
+ wait, err := client.Containers.Wait(run.ID)
+ if err != nil {
+ b.BuildState.ExitCode = 1
+ b.BuildState.Finished = time.Now().UTC().Unix()
+ return err
+ }
+
+ // set completion time
+ b.BuildState.Finished = time.Now().UTC().Unix()
+
+ // get the exit code if possible
+ b.BuildState.ExitCode = wait.StatusCode //b.container.State.ExitCode
+
+ return nil
+}
+
+// writeDockerfile is a helper function that generates a
+// Dockerfile and writes to the builds temporary directory
+// so that it can be used to create the Image.
+func (b *Builder) writeDockerfile(dir string) error {
+ var dockerfile = dockerfile.New(b.Build.Image)
+ dockerfile.WriteWorkdir(b.Repo.Dir)
+ dockerfile.WriteAdd("drone", "/usr/local/bin/")
+
+ // upload source code if repository is stored
+ // on the host machine
+ if b.Repo.IsRemote() == false {
+ dockerfile.WriteAdd("src", filepath.Join(b.Repo.Dir))
+ }
+
+ switch {
+ case strings.HasPrefix(b.Build.Image, "bradrydzewski/"),
+ strings.HasPrefix(b.Build.Image, "drone/"):
+ // the default user for all official Drone imnage
+ // is the "ubuntu" user, since all build images
+ // inherit from the ubuntu cloud ISO
+ dockerfile.WriteUser("ubuntu")
+ dockerfile.WriteEnv("HOME", "/home/ubuntu")
+ dockerfile.WriteEnv("LANG", "en_US.UTF-8")
+ dockerfile.WriteEnv("LANGUAGE", "en_US:en")
+ dockerfile.WriteEnv("LOGNAME", "ubuntu")
+ dockerfile.WriteEnv("TERM", "xterm")
+ dockerfile.WriteEnv("SHELL", "/bin/bash")
+ dockerfile.WriteAdd("id_rsa", "/home/ubuntu/.ssh/id_rsa")
+ dockerfile.WriteRun("sudo chown -R ubuntu:ubuntu /home/ubuntu/.ssh")
+ dockerfile.WriteRun("sudo chown -R ubuntu:ubuntu /var/cache/drone")
+ dockerfile.WriteRun("sudo chown -R ubuntu:ubuntu /usr/local/bin/drone")
+ dockerfile.WriteRun("sudo chmod 600 /home/ubuntu/.ssh/id_rsa")
+ default:
+ // all other images are assumed to use
+ // the root user.
+ dockerfile.WriteUser("root")
+ dockerfile.WriteEnv("HOME", "/root")
+ dockerfile.WriteEnv("LANG", "en_US.UTF-8")
+ dockerfile.WriteEnv("LANGUAGE", "en_US:en")
+ dockerfile.WriteEnv("LOGNAME", "root")
+ dockerfile.WriteEnv("TERM", "xterm")
+ dockerfile.WriteEnv("SHELL", "/bin/bash")
+ dockerfile.WriteEnv("GOPATH", "/var/cache/drone")
+ dockerfile.WriteAdd("id_rsa", "/root/.ssh/id_rsa")
+ dockerfile.WriteRun("chmod 600 /root/.ssh/id_rsa")
+ }
+
+ dockerfile.WriteAdd("proxy.sh", "/etc/drone.d/")
+ dockerfile.WriteEntrypoint("/bin/bash -e /usr/local/bin/drone")
+
+ // write the Dockerfile to the temporary directory
+ return ioutil.WriteFile(filepath.Join(dir, "Dockerfile"), dockerfile.Bytes(), 0700)
+}
+
+// writeBuildScript is a helper function that
+// will generate the build script file in the builder's
+// temp directory to be added to the Image.
+func (b *Builder) writeBuildScript(dir string) error {
+ f := buildfile.New()
+
+ // if the repository is remote then we should
+ // add the commands to the build script to
+ // clone the repository
+ if b.Repo.IsRemote() {
+ for _, cmd := range b.Repo.Commands() {
+ f.WriteCmd(cmd)
+ }
+ }
+
+ // if the commit is for merging a pull request
+ // we should only execute the build commands,
+ // and omit the deploy and publish commands.
+ if len(b.Repo.PR) == 0 {
+ b.Build.Write(f)
+ } else {
+ // only write the build commands
+ b.Build.WriteBuild(f)
+ }
+
+ scriptfilePath := filepath.Join(dir, "drone")
+ return ioutil.WriteFile(scriptfilePath, f.Bytes(), 0700)
+}
+
+// writeProxyScript is a helper function that
+// will generate the proxy.sh file in the builder's
+// temp directory to be added to the Image.
+func (b *Builder) writeProxyScript(dir string) error {
+ var proxyfile = proxy.Proxy{}
+
+ // loop through services so that we can
+ // map ip address to localhost
+ for _, container := range b.services {
+ // create an entry for each port
+ for port, _ := range container.NetworkSettings.Ports {
+ proxyfile.Set(port.Port(), container.NetworkSettings.IPAddress)
+ }
+ }
+
+ // write the proxyfile to the temp directory
+ proxyfilePath := filepath.Join(dir, "proxy.sh")
+ return ioutil.WriteFile(proxyfilePath, proxyfile.Bytes(), 0755)
+}
+
+// writeIdentifyFile is a helper function that
+// will generate the id_rsa file in the builder's
+// temp directory to be added to the Image.
+func (b *Builder) writeIdentifyFile(dir string) error {
+ keyfilePath := filepath.Join(dir, "id_rsa")
+ return ioutil.WriteFile(keyfilePath, b.Key, 0700)
+}
diff --git a/pkg/build/buildfile/buildfile.go b/pkg/build/buildfile/buildfile.go
new file mode 100644
index 000000000..9e6d8fad8
--- /dev/null
+++ b/pkg/build/buildfile/buildfile.go
@@ -0,0 +1,72 @@
+package buildfile
+
+import (
+ "bytes"
+ "fmt"
+)
+
+type Buildfile struct {
+ bytes.Buffer
+}
+
+func New() *Buildfile {
+ b := Buildfile{}
+ b.WriteString(base)
+ return &b
+}
+
+// WriteCmd writes a command to the build file. The
+// command will be echoed back as a base16 encoded
+// command so that it can be parsed and appended to
+// the build output
+func (b *Buildfile) WriteCmd(command string) {
+ // echo the command as an encoded value
+ b.WriteString(fmt.Sprintf("echo '#DRONE:%x'\n", command))
+ // and then run the command
+ b.WriteString(fmt.Sprintf("%s\n", command))
+}
+
+// WriteCmdSilent writes a command to the build file
+// but does not echo the command.
+func (b *Buildfile) WriteCmdSilent(command string) {
+ b.WriteString(fmt.Sprintf("%s\n", command))
+}
+
+// WriteComment adds a comment to the build file. This
+// is really only used internally for debugging purposes.
+func (b *Buildfile) WriteComment(comment string) {
+ b.WriteString(fmt.Sprintf("#%s\n", comment))
+}
+
+// WriteEnv exports the environment variable as
+// part of the script. The environment variables
+// are not echoed back to the console, and are
+// kept private by default.
+func (b *Buildfile) WriteEnv(key, value string) {
+ b.WriteString(fmt.Sprintf("export %s=%s\n", key, value))
+}
+
+// every build script starts with the following
+// code at the start.
+var base = `
+#!/bin/bash
+
+# drone configuration files are stored in /etc/drone.d
+# execute these files prior to our build to set global
+# environment variables and initialize programs (like rbenv)
+if [ -d /etc/drone.d ]; then
+ for i in /etc/drone.d/*.sh; do
+ if [ -r $i ]; then
+ . $i
+ fi
+ done
+ unset i
+fi
+
+# be sure to exit on error and print out
+# our bash commands, so we can which commands
+# are executing and troubleshoot failures.
+set -e
+
+# user-defined commands below ##############################
+`
diff --git a/pkg/build/docker/client.go b/pkg/build/docker/client.go
new file mode 100644
index 000000000..1bfcbd660
--- /dev/null
+++ b/pkg/build/docker/client.go
@@ -0,0 +1,258 @@
+package docker
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "io/ioutil"
+ "net"
+ "net/http"
+ "net/http/httputil"
+ "os"
+ "strings"
+
+ "github.com/dotcloud/docker/pkg/term"
+ "github.com/dotcloud/docker/utils"
+)
+
+const (
+ APIVERSION = 1.9
+ DEFAULTHTTPPORT = 4243
+ DEFAULTUNIXSOCKET = "/var/run/docker.sock"
+ DEFAULTPROTOCOL = "unix"
+ DEFAULTTAG = "latest"
+ VERSION = "0.8.0"
+)
+
+// Enables verbose logging to the Terminal window
+var Logging = true
+
+// New creates an instance of the Docker Client
+func New() *Client {
+ c := &Client{}
+ c.proto = DEFAULTPROTOCOL
+ c.addr = DEFAULTUNIXSOCKET
+
+ // if the default socket doesn't exist then
+ // we'll try to connect to the default tcp address
+ if _, err := os.Stat(DEFAULTUNIXSOCKET); err != nil {
+ c.proto = "tcp"
+ c.addr = "0.0.0.0:4243"
+ }
+
+ c.Images = &ImageService{c}
+ c.Containers = &ContainerService{c}
+ return c
+}
+
+type Client struct {
+ proto string
+ addr string
+
+ Images *ImageService
+ Containers *ContainerService
+}
+
+var (
+ // Returned if the specified resource does not exist.
+ ErrNotFound = errors.New("Not Found")
+
+ // Returned if the caller attempts to make a call or modify a resource
+ // for which the caller is not authorized.
+ //
+ // The request was a valid request, the caller's authentication credentials
+ // succeeded but those credentials do not grant the caller permission to
+ // access the resource.
+ ErrForbidden = errors.New("Forbidden")
+
+ // Returned if the call requires authentication and either the credentials
+ // provided failed or no credentials were provided.
+ ErrNotAuthorized = errors.New("Unauthorized")
+
+ // Returned if the caller submits a badly formed request. For example,
+ // the caller can receive this return if you forget a required parameter.
+ ErrBadRequest = errors.New("Bad Request")
+)
+
+// helper function used to make HTTP requests to the Docker daemon.
+func (c *Client) do(method, path string, in, out interface{}) error {
+ // if data input is provided, serialize to JSON
+ var payload io.Reader
+ if in != nil {
+ buf, err := json.Marshal(in)
+ if err != nil {
+ return err
+ }
+ payload = bytes.NewBuffer(buf)
+ }
+
+ // create the request
+ req, err := http.NewRequest(method, fmt.Sprintf("/v%g%s", APIVERSION, path), payload)
+ if err != nil {
+ return err
+ }
+
+ // set the appropariate headers
+ req.Header = http.Header{}
+ req.Header.Set("User-Agent", "Docker-Client/"+VERSION)
+ req.Header.Set("Content-Type", "application/json")
+
+ // dial the host server
+ req.Host = c.addr
+ dial, err := net.Dial(c.proto, c.addr)
+ if err != nil {
+ return err
+ }
+
+ // make the request
+ conn := httputil.NewClientConn(dial, nil)
+ resp, err := conn.Do(req)
+ defer conn.Close()
+ if err != nil {
+ return err
+ }
+
+ // Read the bytes from the body (make sure we defer close the body)
+ defer resp.Body.Close()
+ body, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ return err
+ }
+
+ // Check for an http error status (ie not 200 StatusOK)
+ switch resp.StatusCode {
+ case 404:
+ return ErrNotFound
+ case 403:
+ return ErrForbidden
+ case 401:
+ return ErrNotAuthorized
+ case 400:
+ return ErrBadRequest
+ }
+
+ // Unmarshall the JSON response
+ if out != nil {
+ return json.Unmarshal(body, out)
+ }
+
+ return nil
+}
+
+func (c *Client) hijack(method, path string, setRawTerminal bool, out io.Writer) error {
+ req, err := http.NewRequest(method, fmt.Sprintf("/v%g%s", APIVERSION, path), nil)
+ if err != nil {
+ return err
+ }
+
+ req.Header.Set("User-Agent", "Docker-Client/"+VERSION)
+ req.Header.Set("Content-Type", "plain/text")
+ req.Host = c.addr
+
+ dial, err := net.Dial(c.proto, c.addr)
+ if err != nil {
+ if strings.Contains(err.Error(), "connection refused") {
+ return fmt.Errorf("Can't connect to docker daemon. Is 'docker -d' running on this host?")
+ }
+ return err
+ }
+ clientconn := httputil.NewClientConn(dial, nil)
+ defer clientconn.Close()
+
+ // Server hijacks the connection, error 'connection closed' expected
+ clientconn.Do(req)
+
+ // Hijack the connection to read / write
+ rwc, br := clientconn.Hijack()
+ defer rwc.Close()
+
+ // launch a goroutine to copy the stream
+ // of build output to the writer.
+ errStdout := make(chan error, 1)
+ go func() {
+ var err error
+ if setRawTerminal {
+ _, err = io.Copy(out, br)
+ } else {
+ _, err = utils.StdCopy(out, out, br)
+ }
+
+ errStdout <- err
+ }()
+
+ // wait for a response
+ if err := <-errStdout; err != nil {
+ return err
+ }
+ return nil
+}
+
+func (c *Client) stream(method, path string, in io.Reader, out io.Writer, headers http.Header) error {
+ if (method == "POST" || method == "PUT") && in == nil {
+ in = bytes.NewReader(nil)
+ }
+
+ // setup the request
+ req, err := http.NewRequest(method, fmt.Sprintf("/v%g%s", APIVERSION, path), in)
+ if err != nil {
+ return err
+ }
+
+ // set default headers
+ req.Header = headers
+ req.Header.Set("User-Agent", "Docker-Client/0.6.4")
+ req.Header.Set("Content-Type", "plain/text")
+
+ // dial the host server
+ req.Host = c.addr
+ dial, err := net.Dial(c.proto, c.addr)
+ if err != nil {
+ return err
+ }
+
+ // make the request
+ conn := httputil.NewClientConn(dial, nil)
+ resp, err := conn.Do(req)
+ defer conn.Close()
+ if err != nil {
+ return err
+ }
+
+ // make sure we defer close the body
+ defer resp.Body.Close()
+
+ // Check for an http error status (ie not 200 StatusOK)
+ switch resp.StatusCode {
+ case 404:
+ return ErrNotFound
+ case 403:
+ return ErrForbidden
+ case 401:
+ return ErrNotAuthorized
+ case 400:
+ return ErrBadRequest
+ }
+
+ // If no output we exit now with no errors
+ if out == nil {
+ return nil
+ }
+
+ // copy the output stream to the writer
+ if resp.Header.Get("Content-Type") == "application/json" {
+ var terminalFd = os.Stdin.Fd()
+ var isTerminal = term.IsTerminal(terminalFd)
+
+ // it may not make sense to put this code here, but it works for
+ // us at the moment, and I don't feel like refactoring
+ return utils.DisplayJSONMessagesStream(resp.Body, out, terminalFd, isTerminal)
+ }
+ // otherwise plain text
+ if _, err := io.Copy(out, resp.Body); err != nil {
+ return err
+ }
+
+ return nil
+}
diff --git a/pkg/build/docker/container.go b/pkg/build/docker/container.go
new file mode 100644
index 000000000..31f54b0e3
--- /dev/null
+++ b/pkg/build/docker/container.go
@@ -0,0 +1,147 @@
+package docker
+
+import (
+ "fmt"
+ "io"
+)
+
+type ContainerService struct {
+ *Client
+}
+
+// List only running containers.
+func (c *ContainerService) List() ([]*Containers, error) {
+ containers := []*Containers{}
+ err := c.do("GET", "/containers/json?all=0", nil, &containers)
+ return containers, err
+}
+
+// List all containers
+func (c *ContainerService) ListAll() ([]*Containers, error) {
+ containers := []*Containers{}
+ err := c.do("GET", "/containers/json?all=1", nil, &containers)
+ return containers, err
+}
+
+// Create a Container
+func (c *ContainerService) Create(conf *Config) (*Run, error) {
+ run, err := c.create(conf)
+ switch {
+ // if no error, exit immediately
+ case err == nil:
+ return run, nil
+ // if error we exit, unless it is
+ // a NOT FOUND error, which means we just
+ // need to download the Image from the center
+ // image index
+ case err != nil && err != ErrNotFound:
+ return nil, err
+ }
+
+ // attempt to pull the image
+ if err := c.Images.Pull(conf.Image); err != nil {
+ return nil, err
+ }
+
+ // now that we have the image, re-try creation
+ return c.create(conf)
+}
+
+func (c *ContainerService) create(conf *Config) (*Run, error) {
+ run := Run{}
+ err := c.do("POST", "/containers/create", conf, &run)
+ return &run, err
+}
+
+// Start the container id
+func (c *ContainerService) Start(id string, conf *HostConfig) error {
+ return c.do("POST", fmt.Sprintf("/containers/%s/start", id), &conf, nil)
+}
+
+// Stop the container id
+func (c *ContainerService) Stop(id string, timeout int) error {
+ return c.do("POST", fmt.Sprintf("/containers/%s/stop?t=%v", id, timeout), nil, nil)
+}
+
+// Remove the container id from the filesystem.
+func (c *ContainerService) Remove(id string) error {
+ return c.do("DELETE", fmt.Sprintf("/containers/%s", id), nil, nil)
+}
+
+// Block until container id stops, then returns the exit code
+func (c *ContainerService) Wait(id string) (*Wait, error) {
+ wait := Wait{}
+ err := c.do("POST", fmt.Sprintf("/containers/%s/wait", id), nil, &wait)
+ return &wait, err
+}
+
+// Attach to the container to stream the stdout and stderr
+func (c *ContainerService) Attach(id string, out io.Writer) error {
+ path := fmt.Sprintf("/containers/%s/attach?&stream=1&stdout=1&stderr=1", id)
+ return c.hijack("POST", path, false, out)
+}
+
+// Stop the container id
+func (c *ContainerService) Inspect(id string) (*Container, error) {
+ container := Container{}
+ err := c.do("GET", fmt.Sprintf("/containers/%s/json", id), nil, &container)
+ return &container, err
+}
+
+// Run the container
+func (c *ContainerService) Run(conf *Config, host *HostConfig, out io.Writer) (*Wait, error) {
+ // create the container from the image
+ run, err := c.Create(conf)
+ if err != nil {
+ return nil, err
+ }
+
+ // attach to the container
+ go func() {
+ c.Attach(run.ID, out)
+ }()
+
+ // start the container
+ if err := c.Start(run.ID, host); err != nil {
+ return nil, err
+ }
+
+ // wait for the container to stop
+ wait, err := c.Wait(run.ID)
+ if err != nil {
+ return nil, err
+ }
+
+ return wait, nil
+}
+
+// Run the container as a Daemon
+func (c *ContainerService) RunDaemon(conf *Config, host *HostConfig) (*Run, error) {
+ run, err := c.Create(conf)
+ if err != nil {
+ return nil, err
+ }
+
+ // start the container
+ err = c.Start(run.ID, host)
+ return run, err
+}
+
+func (c *ContainerService) RunDaemonPorts(image string, ports ...string) (*Run, error) {
+ // setup configuration
+ config := Config{Image: image}
+ config.ExposedPorts = make(map[Port]struct{})
+
+ // host configuration
+ host := HostConfig{}
+ host.PortBindings = make(map[Port][]PortBinding)
+
+ // loop through and add ports
+ for _, port := range ports {
+ config.ExposedPorts[Port(port+"/tcp")] = struct{}{}
+ host.PortBindings[Port(port+"/tcp")] = []PortBinding{{HostIp: "127.0.0.1", HostPort: ""}}
+ }
+ //127.0.0.1::%s
+ //map[3306/tcp:{}] map[3306/tcp:[{127.0.0.1 }]]
+ return c.RunDaemon(&config, &host)
+}
diff --git a/pkg/build/docker/image.go b/pkg/build/docker/image.go
new file mode 100644
index 000000000..259128f86
--- /dev/null
+++ b/pkg/build/docker/image.go
@@ -0,0 +1,124 @@
+package docker
+
+import (
+ "fmt"
+ "io"
+ "io/ioutil"
+ "net/http"
+ "net/url"
+ "os"
+ "time"
+
+ "github.com/dotcloud/docker/archive"
+ "github.com/dotcloud/docker/utils"
+)
+
+type Images struct {
+ ID string `json:"Id"`
+ RepoTags []string `json:",omitempty"`
+ Created int64
+ Size int64
+ VirtualSize int64
+ ParentId string `json:",omitempty"`
+
+ // DEPRECATED
+ Repository string `json:",omitempty"`
+ Tag string `json:",omitempty"`
+}
+
+type Image struct {
+ ID string `json:"id"`
+ Parent string `json:"parent,omitempty"`
+ Comment string `json:"comment,omitempty"`
+ Created time.Time `json:"created"`
+ Container string `json:"container,omitempty"`
+ ContainerConfig Config `json:"container_config,omitempty"`
+ DockerVersion string `json:"docker_version,omitempty"`
+ Author string `json:"author,omitempty"`
+ Config *Config `json:"config,omitempty"`
+ Architecture string `json:"architecture,omitempty"`
+ OS string `json:"os,omitempty"`
+ Size int64
+}
+
+type Delete struct {
+ Deleted string `json:",omitempty"`
+ Untagged string `json:",omitempty"`
+}
+
+type ImageService struct {
+ *Client
+}
+
+// List Images
+func (c *ImageService) List() ([]*Images, error) {
+ images := []*Images{}
+ err := c.do("GET", "/images/json?all=0", nil, &images)
+ return images, err
+}
+
+// Create an image, either by pull it from the registry or by importing it.
+func (c *ImageService) Create(image string) error {
+ return c.do("POST", fmt.Sprintf("/images/create?fromImage=%s"), nil, nil)
+}
+
+func (c *ImageService) Pull(image string) error {
+ name, tag := utils.ParseRepositoryTag(image)
+ if len(tag) == 0 {
+ tag = DEFAULTTAG
+ }
+ return c.PullTag(name, tag)
+}
+
+func (c *ImageService) PullTag(name, tag string) error {
+ var out io.Writer
+ if Logging {
+ out = os.Stdout
+ }
+
+ path := fmt.Sprintf("/images/create?fromImage=%s&tag=%s", name, tag)
+ return c.stream("POST", path, nil, out, http.Header{})
+}
+
+// Remove the image name from the filesystem
+func (c *ImageService) Remove(image string) ([]*Delete, error) {
+ resp := []*Delete{}
+ err := c.do("DELETE", fmt.Sprintf("/images/%s", image), nil, &resp)
+ return resp, err
+}
+
+// Inspect the image
+func (c *ImageService) Inspect(name string) (*Image, error) {
+ image := Image{}
+ err := c.do("GET", fmt.Sprintf("/images/%s/json", name), nil, &image)
+ return &image, err
+}
+
+// Build the Image
+func (c *ImageService) Build(tag, dir string) error {
+
+ // tar the file
+ context, err := archive.Tar(dir, archive.Uncompressed)
+ if err != nil {
+ return err
+ }
+
+ var body io.Reader
+ body = ioutil.NopCloser(context)
+
+ // Upload the build context
+ v := url.Values{}
+ v.Set("t", tag)
+ v.Set("q", "1")
+ //v.Set("rm", "1")
+
+ // url path
+ path := fmt.Sprintf("/build?%s", v.Encode())
+
+ // set content type to tar file
+ headers := http.Header{}
+ headers.Set("Content-Type", "application/tar")
+
+ // make the request
+ return c.stream("POST", path, body, nil, headers)
+}
diff --git a/pkg/build/docker/structs.go b/pkg/build/docker/structs.go
new file mode 100644
index 000000000..be0aeaa03
--- /dev/null
+++ b/pkg/build/docker/structs.go
@@ -0,0 +1,166 @@
+package docker
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// These are structures copied from the Docker project.
+// We avoid importing the libraries due to a CGO
+// depenency on libdevmapper that we'd like to avoid.
+
+type KeyValuePair struct {
+ Key string
+ Value string
+}
+
+type HostConfig struct {
+ Binds []string
+ ContainerIDFile string
+ LxcConf []KeyValuePair
+ Privileged bool
+ PortBindings map[Port][]PortBinding
+ Links []string
+ PublishAllPorts bool
+}
+
+type Top struct {
+ Titles []string
+ Processes [][]string
+}
+
+type Containers struct {
+ ID string `json:"Id"`
+ Image string
+ Command string
+ Created int64
+ Status string
+ Ports []Port
+ SizeRw int64
+ SizeRootFs int64
+ Names []string
+}
+
+type Run struct {
+ ID string `json:"Id"`
+ Warnings []string `json:",omitempty"`
+}
+
+type Wait struct {
+ StatusCode int
+}
+
+type State struct {
+ Running bool
+ Pid int
+ ExitCode int
+ StartedAt time.Time
+ FinishedAt time.Time
+ Ghost bool
+}
+
+type PortBinding struct {
+ HostIp string
+ HostPort string
+}
+
+// 80/tcp
+type Port string
+
+func (p Port) Proto() string {
+ parts := strings.Split(string(p), "/")
+ if len(parts) == 1 {
+ return "tcp"
+ }
+ return parts[1]
+}
+
+func (p Port) Port() string {
+ return strings.Split(string(p), "/")[0]
+}
+
+func (p Port) Int() int {
+ i, err := parsePort(p.Port())
+ if err != nil {
+ panic(err)
+ }
+ return i
+}
+
+func parsePort(rawPort string) (int, error) {
+ port, err := strconv.ParseUint(rawPort, 10, 16)
+ if err != nil {
+ return 0, err
+ }
+ return int(port), nil
+}
+
+func NewPort(proto, port string) Port {
+ return Port(fmt.Sprintf("%s/%s", port, proto))
+}
+
+type PortMapping map[string]string // Deprecated
+
+type NetworkSettings struct {
+ IPAddress string
+ IPPrefixLen int
+ Gateway string
+ Bridge string
+ PortMapping map[string]PortMapping // Deprecated
+ Ports map[Port][]PortBinding
+}
+
+type Config struct {
+ Hostname string
+ Domainname string
+ User string
+ Memory int64 // Memory limit (in bytes)
+ MemorySwap int64 // Total memory usage (memory + swap); set `-1' to disable swap
+ CpuShares int64 // CPU shares (relative weight vs. other containers)
+ AttachStdin bool
+ AttachStdout bool
+ AttachStderr bool
+ PortSpecs []string // Deprecated - Can be in the format of 8080/tcp
+ ExposedPorts map[Port]struct{}
+ Tty bool // Attach standard streams to a tty, including stdin if it is not closed.
+ OpenStdin bool // Open stdin
+ StdinOnce bool // If true, close stdin after the 1 attached client disconnects.
+ Env []string
+ Cmd []string
+ Dns []string
+ Image string // Name of the image as it was passed by the operator (eg. could be symbolic)
+ Volumes map[string]struct{}
+ VolumesFrom string
+ WorkingDir string
+ Entrypoint []string
+ NetworkDisabled bool
+}
+
+type Container struct {
+ ID string
+
+ Created time.Time
+
+ Path string
+ Args []string
+
+ Config *Config
+ State State
+ Image string
+
+ NetworkSettings *NetworkSettings
+
+ SysInitPath string
+ ResolvConfPath string
+ HostnamePath string
+ HostsPath string
+ Name string
+ Driver string
+
+ Volumes map[string]string
+ // Store rw/ro in a separate structure to preserve reverse-compatibility on-disk.
+ // Easier than migrating older container configs :)
+ VolumesRW map[string]bool
+}
diff --git a/pkg/build/dockerfile/dockerfile.go b/pkg/build/dockerfile/dockerfile.go
new file mode 100644
index 000000000..cab6eade7
--- /dev/null
+++ b/pkg/build/dockerfile/dockerfile.go
@@ -0,0 +1,44 @@
+package dockerfile
+
+import (
+ "bytes"
+ "fmt"
+)
+
+type Dockerfile struct {
+ bytes.Buffer
+}
+
+func New(from string) *Dockerfile {
+ d := Dockerfile{}
+ d.WriteFrom(from)
+ return &d
+}
+
+func (d *Dockerfile) WriteAdd(from, to string) {
+ d.WriteString(fmt.Sprintf("ADD %s %s\n", from, to))
+}
+
+func (d *Dockerfile) WriteFrom(from string) {
+ d.WriteString(fmt.Sprintf("FROM %s\n", from))
+}
+
+func (d *Dockerfile) WriteRun(cmd string) {
+ d.WriteString(fmt.Sprintf("RUN %s\n", cmd))
+}
+
+func (d *Dockerfile) WriteUser(user string) {
+ d.WriteString(fmt.Sprintf("USER %s\n", user))
+}
+
+func (d *Dockerfile) WriteEnv(key, val string) {
+ d.WriteString(fmt.Sprintf("ENV %s %s\n", key, val))
+}
+
+func (d *Dockerfile) WriteWorkdir(workdir string) {
+ d.WriteString(fmt.Sprintf("WORKDIR %s\n", workdir))
+}
+
+func (d *Dockerfile) WriteEntrypoint(entrypoint string) {
+ d.WriteString(fmt.Sprintf("ENTRYPOINT %s\n", entrypoint))
+}
diff --git a/pkg/build/images.go b/pkg/build/images.go
new file mode 100644
index 000000000..d887ee367
--- /dev/null
+++ b/pkg/build/images.go
@@ -0,0 +1,238 @@
+package build
+
+type image struct {
+ // default ports the service will run on.
+ // for example, 3306 for mysql. Note that a service
+ // may expose multiple prots, for example, Riak
+ // exposes 8087 and 8089.
+ Ports []string
+
+ // tag of the docker image to pull in order
+ // to run this service.
+ Tag string
+
+ // display name of the image type
+ Name string
+}
+
+// List of 3rd party services (database, queue, etc) that
+// are known to work with this Build utility.
+var services = map[string]*image{
+
+ // neo4j
+ "neo4j": {
+ Ports: []string{"7474"},
+ Tag: "bradrydzewski/neo4j:1.9",
+ Name: "neo4j",
+ },
+ "neo4j:1.9": {
+ Ports: []string{"7474"},
+ Tag: "bradrydzewski/neo4j:1.9",
+ Name: "neo4j",
+ },
+
+ // elasticsearch servers
+ "elasticsearch": {
+ Ports: []string{"9200"},
+ Tag: "bradrydzewski/elasticsearch:0.90",
+ Name: "elasticsearch",
+ },
+ "elasticsearch:0.20": {
+ Ports: []string{"9200"},
+ Tag: "bradrydzewski/elasticsearch:0.20",
+ Name: "elasticsearch",
+ },
+ "elasticsearch:0.90": {
+ Ports: []string{"9200"},
+ Tag: "bradrydzewski/elasticsearch:0.90",
+ Name: "elasticsearch",
+ },
+
+ // redis servers
+ "redis": {
+ Ports: []string{"6379"},
+ Tag: "bradrydzewski/redis:2.8",
+ Name: "redis",
+ },
+ "redis:2.8": {
+ Ports: []string{"6379"},
+ Tag: "bradrydzewski/redis:2.8",
+ Name: "redis",
+ },
+ "redis:2.6": {
+ Ports: []string{"6379"},
+ Tag: "bradrydzewski/redis:2.6",
+ Name: "redis",
+ },
+
+ // mysql servers
+ "mysql": {
+ Tag: "bradrydzewski/mysql:5.5",
+ Ports: []string{"3306"},
+ Name: "mysql",
+ },
+ "mysql:5.5": {
+ Tag: "bradrydzewski/mysql:5.5",
+ Ports: []string{"3306"},
+ Name: "mysql",
+ },
+
+ // memcached
+ "memcached": {
+ Ports: []string{"11211"},
+ Tag: "bradrydzewski/memcached",
+ Name: "memcached",
+ },
+
+ // mongodb
+ "mongodb": {
+ Ports: []string{"27017"},
+ Tag: "bradrydzewski/mongodb:2.4",
+ Name: "mongodb",
+ },
+ "mongodb:2.4": {
+ Ports: []string{"27017"},
+ Tag: "bradrydzewski/mongodb:2.4",
+ Name: "mongodb",
+ },
+ "mongodb:2.2": {
+ Ports: []string{"27017"},
+ Tag: "bradrydzewski/mongodb:2.2",
+ Name: "mongodb",
+ },
+
+ // postgres
+ "postgres": {
+ Ports: []string{"5432"},
+ Tag: "bradrydzewski/postgres:9.1",
+ Name: "postgres",
+ },
+ "postgres:9.1": {
+ Ports: []string{"5432"},
+ Tag: "bradrydzewski/postgres:9.1",
+ Name: "postgres",
+ },
+
+ // couchdb
+ "couchdb": {
+ Ports: []string{"5984"},
+ Tag: "bradrydzewski/couchdb:1.0",
+ Name: "couchdb",
+ },
+ "couchdb:1.0": {
+ Ports: []string{"5984"},
+ Tag: "bradrydzewski/couchdb:1.0",
+ Name: "couchdb",
+ },
+ "couchdb:1.4": {
+ Ports: []string{"5984"},
+ Tag: "bradrydzewski/couchdb:1.4",
+ Name: "couchdb",
+ },
+ "couchdb:1.5": {
+ Ports: []string{"5984"},
+ Tag: "bradrydzewski/couchdb:1.5",
+ Name: "couchdb",
+ },
+
+ // rabbitmq
+ "rabbitmq": {
+ Ports: []string{"5672", "15672"},
+ Tag: "bradrydzewski/rabbitmq:3.2",
+ Name: "rabbitmq",
+ },
+ "rabbitmq:3.2": {
+ Ports: []string{"5672", "15672"},
+ Tag: "bradrydzewski/rabbitmq:3.2",
+ Name: "rabbitmq",
+ },
+
+ // experimental images from 3rd parties
+
+ "zookeeper": {
+ Ports: []string{"2181"},
+ Tag: "jplock/zookeeper:3.4.5",
+ Name: "zookeeper",
+ },
+
+ // cassandra
+ "cassandra": {
+ Ports: []string{"9042", "7000", "7001", "7199", "9160", "49183"},
+ Tag: "relateiq/cassandra",
+ Name: "cassandra",
+ },
+
+ // riak - TESTED
+ "riak": {
+ Ports: []string{"8087", "8098"},
+ Tag: "guillermo/riak",
+ Name: "riak",
+ },
+}
+
+// List of official Drone build images.
+var builders = map[string]*image{
+
+ // Clojure build images
+ "lein": {Tag: "bradrydzewski/lein"},
+
+ // Dart build images
+ "dart": {Tag: "bradrydzewski/dart:stable"},
+ "dart_stable": {Tag: "bradrydzewski/dart:stable"},
+ "dart_dev": {Tag: "bradrydzewski/dart:dev"},
+
+ // Erlang build images
+ "erlang": {Tag: "bradrydzewski/erlang:R16B02"},
+ "erlangR16B": {Tag: "bradrydzewski/erlang:R16B"},
+ "erlangR16B02": {Tag: "bradrydzewski/erlang:R16B02"},
+ "erlangR16B01": {Tag: "bradrydzewski/erlang:R16B01"},
+
+ // GCC build images
+ "gcc": {Tag: "bradrydzewski/gcc:4.6"},
+ "gcc4.6": {Tag: "bradrydzewski/gcc:4.6"},
+ "gcc4.8": {Tag: "bradrydzewski/gcc:4.8"},
+
+ // Golang build images
+ "go": {Tag: "bradrydzewski/go:1.2"},
+ "go1": {Tag: "bradrydzewski/go:1.0"},
+ "go1.1": {Tag: "bradrydzewski/go:1.1"},
+ "go1.2": {Tag: "bradrydzewski/go:1.2"},
+
+ // Haskell build images
+ "haskell": {Tag: "bradrydzewski/haskell:7.4"},
+ "haskell7.4": {Tag: "bradrydzewski/haskell:7.4"},
+
+ // Java build images
+ "java": {Tag: "bradrydzewski/java:openjdk7"},
+ "openjdk6": {Tag: "bradrydzewski/java:openjdk6"},
+ "openjdk7": {Tag: "bradrydzewski/java:openjdk7"},
+ "oraclejdk7": {Tag: "bradrydzewski/java:oraclejdk7"},
+ "oraclejdk8": {Tag: "bradrydzewski/java:oraclejdk8"},
+
+ // Node build images
+ "node": {Tag: "bradrydzewski/node:0.10"},
+ "node0.10": {Tag: "bradrydzewski/node:0.10"},
+ "node0.8": {Tag: "bradrydzewski/node:0.8"},
+
+ // PHP build images
+ "php": {Tag: "bradrydzewski/php:5.5"},
+ "php5.5": {Tag: "bradrydzewski/php:5.5"},
+ "php5.4": {Tag: "bradrydzewski/php:5.4"},
+
+ // Python build images
+ "python": {Tag: "bradrydzewski/python:2.7"},
+ "python2.7": {Tag: "bradrydzewski/python:2.7"},
+ "python3.2": {Tag: "bradrydzewski/python:3.2"},
+ "python3.3": {Tag: "bradrydzewski/python:3.3"},
+ "pypy": {Tag: "bradrydzewski/python:pypy"},
+
+ // Ruby build images
+ "ruby": {Tag: "bradrydzewski/ruby:2.0.0"},
+ "ruby2.0.0": {Tag: "bradrydzewski/ruby:2.0.0"},
+ "ruby1.9.3": {Tag: "bradrydzewski/ruby:1.9.3"},
+
+ // Scala build images
+ "scala": {Tag: "bradrydzewski/scala:2.10.3"},
+ "scala2.10.3": {Tag: "bradrydzewski/scala:2.10.3"},
+ "scala2.9.3": {Tag: "bradrydzewski/scala:2.9.3"},
+}
diff --git a/pkg/build/log/log.go b/pkg/build/log/log.go
new file mode 100644
index 000000000..de3556b56
--- /dev/null
+++ b/pkg/build/log/log.go
@@ -0,0 +1,105 @@
+package log
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "sync"
+)
+
+const (
+ LOG_EMERG = iota
+ LOG_ALERT
+ LOG_CRIT
+ LOG_ERR
+ LOG_WARNING
+ LOG_NOTICE
+ LOG_INFO
+ LOG_DEBUG
+)
+
+var mu sync.Mutex
+
+// the default Log priority
+var priority int = LOG_DEBUG
+
+// the default Log output destination
+var output io.Writer = os.Stdout
+
+// the log prefix
+var prefix string
+
+// the log suffix
+var suffix string = "/n"
+
+// SetPriority sets the default log level.
+func SetPriority(level int) {
+ mu.Lock()
+ defer mu.Unlock()
+ priority = level
+}
+
+// SetOutput sets the output destination.
+func SetOutput(w io.Writer) {
+ mu.Lock()
+ defer mu.Unlock()
+ output = w
+}
+
+// SetPrefix sets the prefix for the log message.
+func SetPrefix(pre string) {
+ mu.Lock()
+ defer mu.Unlock()
+ prefix = pre
+}
+
+// SetSuffix sets the suffix for the log message.
+func SetSuffix(suf string) {
+ mu.Lock()
+ defer mu.Unlock()
+ suffix = suf
+}
+
+func Write(out string, level int) {
+ mu.Lock()
+ defer mu.Unlock()
+
+ // append the prefix and suffix
+ out = prefix + out + suffix
+
+ if priority >= level {
+ output.Write([]byte(out))
+ }
+}
+
+func Debug(out string) {
+ Write(out, LOG_DEBUG)
+}
+
+func Debugf(format string, a ...interface{}) {
+ Debug(fmt.Sprintf(format, a...))
+}
+
+func Info(out string) {
+ Write(out, LOG_INFO)
+}
+
+func Infof(format string, a ...interface{}) {
+ Info(fmt.Sprintf(format, a...))
+}
+
+func Err(out string) {
+ Write(out, LOG_ERR)
+}
+
+func Errf(format string, a ...interface{}) {
+ Err(fmt.Sprintf(format, a...))
+}
+
+func Notice(out string) {
+ Write(out, LOG_NOTICE)
+}
+
+func Noticef(format string, a ...interface{}) {
+ Notice(fmt.Sprintf(format, a...))
+}
diff --git a/pkg/build/proxy/proxy.go b/pkg/build/proxy/proxy.go
new file mode 100644
index 000000000..55ef9a11c
--- /dev/null
+++ b/pkg/build/proxy/proxy.go
@@ -0,0 +1,41 @@
+package proxy
+
+import (
+ "bytes"
+ "fmt"
+)
+
+// bash header
+const header = "#!/bin/bash\n"
+
+// this command string will check if the socat utility
+// exists, and if it does, will proxy connections to
+// the external IP address.
+const command = "[ -x /usr/bin/socat ] && socat TCP-LISTEN:%s,fork TCP:%s:%s &\n"
+
+// Proxy stores proxy configuration details mapping
+// a local port to an external IP address with the
+// same port number.
+type Proxy map[string]string
+
+func (p Proxy) Set(port, ip string) {
+ p[port] = ip
+}
+
+// String converts the proxy configuration details
+// to a bash script.
+func (p Proxy) String() string {
+ var buf bytes.Buffer
+ buf.WriteString(header)
+ for port, ip := range p {
+ buf.WriteString(fmt.Sprintf(command, port, ip, port))
+ }
+
+ return buf.String()
+}
+
+// Bytes converts the proxy configuration details
+// to a bash script in byte array format.
+func (p Proxy) Bytes() []byte {
+ return []byte(p.String())
+}
diff --git a/pkg/build/proxy/proxy_test.go b/pkg/build/proxy/proxy_test.go
new file mode 100644
index 000000000..19ef98539
--- /dev/null
+++ b/pkg/build/proxy/proxy_test.go
@@ -0,0 +1,32 @@
+package proxy
+
+import (
+ "testing"
+)
+
+func TestProxy(t *testing.T) {
+ // test creating a proxy with a few different
+ // addresses, and our ability to create the
+ // proxy shell script.
+ p := Proxy{}
+ p.Set("8080", "172.1.4.5")
+ p.Set("8000", "172.1.3.1")
+ b := p.Bytes()
+
+ expected := `#!/bin/bash
+[ -x /usr/bin/socat ] && socat TCP-LISTEN:8080,fork TCP:172.1.4.5:8080 &
+[ -x /usr/bin/socat ] && socat TCP-LISTEN:8000,fork TCP:172.1.3.1:8000 &
+`
+ if string(b) != expected {
+ t.Errorf("Invalid proxy \n%s", expected)
+ }
+
+ // test creating a proxy script when there
+ // are no proxy addresses added to the map
+ p = Proxy{}
+ b = p.Bytes()
+ expected = "#!/bin/bash\n"
+ if string(b) != expected {
+ t.Errorf("Invalid proxy \n%s", expected)
+ }
+}
diff --git a/pkg/build/repo/repo.go b/pkg/build/repo/repo.go
new file mode 100644
index 000000000..581be9688
--- /dev/null
+++ b/pkg/build/repo/repo.go
@@ -0,0 +1,118 @@
+package repo
+
+import (
+ "fmt"
+ "strings"
+)
+
+type Repo struct {
+ // The path of the Repoisotry. This could be
+ // the remote path of a Git repository or the path of
+ // of the repository on the local file system.
+ //
+ // A remote path must start with http://, https://,
+ // git://, ssh:// or git@. Otherwise we'll assume
+ // the repository is located on the local filesystem.
+ Path string
+
+ // (optional) Specific Branch that we should checkout
+ // when the Repository is cloned. If no value is
+ // provided we'll assume the default, master branch.
+ Branch string
+
+ // (optional) Specific Commit Hash that we should
+ // checkout when the Repository is cloned. If no
+ // value is provided we'll assume HEAD.
+ Commit string
+
+ // (optional) Pull Request number that we should
+ // checkout when the Repository is cloned.
+ PR string
+
+ // (optional) The filesystem path that the repository
+ // will be cloned into (or copied to) inside the
+ // host system (Docker Container).
+ Dir string
+}
+
+// IsRemote returns true if the Repository is located
+// on a remote server (ie Github, Bitbucket)
+func (r *Repo) IsRemote() bool {
+ switch {
+ case strings.HasPrefix(r.Path, "git://"):
+ return true
+ case strings.HasPrefix(r.Path, "git@"):
+ return true
+ case strings.HasPrefix(r.Path, "http://"):
+ return true
+ case strings.HasPrefix(r.Path, "https://"):
+ return true
+ case strings.HasPrefix(r.Path, "ssh://"):
+ return true
+ }
+
+ return false
+}
+
+// IsLocal returns true if the Repository is located
+// on the local filesystem.
+func (r *Repo) IsLocal() bool {
+ return !r.IsRemote()
+}
+
+// IsGit returns true if the Repository is
+// a Git repoisitory.
+func (r *Repo) IsGit() bool {
+ switch {
+ case strings.HasPrefix(r.Path, "git://"):
+ return true
+ case strings.HasPrefix(r.Path, "git@"):
+ return true
+ case strings.HasPrefix(r.Path, "ssh://git@"):
+ return true
+ case strings.HasPrefix(r.Path, "https://github.com/"):
+ return true
+ case strings.HasPrefix(r.Path, "http://github.com"):
+ return true
+ case strings.HasSuffix(r.Path, ".git"):
+ return true
+ }
+
+ // we could also ping the repository to check
+
+ return false
+}
+
+// returns commands that can be used in a Dockerfile
+// to clone the repository.
+//
+// TODO we should also enable Mercurial projects and SVN projects
+func (r *Repo) Commands() []string {
+
+ // get the branch. default to master
+ // if no branch exists.
+ branch := r.Branch
+ if len(branch) == 0 {
+ branch = "master"
+ }
+
+ cmds := []string{}
+ cmds = append(cmds, fmt.Sprintf("git clone --branch=%s %s %s", branch, r.Path, r.Dir))
+
+ switch {
+ // if a specific commit is provided then we'll
+ // need to clone it.
+ case len(r.PR) > 0:
+
+ cmds = append(cmds, fmt.Sprintf("git fetch origin +refs/pull/%s/head:refs/remotes/origin/pr/%s", r.PR, r.PR))
+ cmds = append(cmds, fmt.Sprintf("git checkout -qf pr/%s", r.PR))
+ //cmds = append(cmds, fmt.Sprintf("git fetch origin +refs/pull/%s/merge:", r.PR))
+ //cmds = append(cmds, fmt.Sprintf("git checkout -qf %s", "FETCH_HEAD"))
+ // if a specific commit is provided then we'll
+ // need to clone it.
+ case len(r.Commit) > 0:
+ cmds = append(cmds, fmt.Sprintf("git checkout -qf %s", r.Commit))
+ }
+
+ return cmds
+}
diff --git a/pkg/build/repo/repo_test.go b/pkg/build/repo/repo_test.go
new file mode 100644
index 000000000..b8f4d54ef
--- /dev/null
+++ b/pkg/build/repo/repo_test.go
@@ -0,0 +1,54 @@
+package repo
+
+import (
+ "testing"
+)
+
+func TestIsRemote(t *testing.T) {
+ repos := []struct {
+ path string
+ remote bool
+ }{
+ {"git://github.com/foo/far", true},
+ {"git://github.com/foo/far.git", true},
+ {"git@github.com:foo/far", true},
+ {"git@github.com:foo/far.git", true},
+ {"http://github.com/foo/far.git", true},
+ {"https://github.com/foo/far.git", true},
+ {"ssh://baz.com/foo/far.git", true},
+ {"/var/lib/src", false},
+ {"/home/ubuntu/src", false},
+ {"src", false},
+ }
+
+ for _, r := range repos {
+ repo := Repo{Path: r.path}
+ if remote := repo.IsRemote(); remote != r.remote {
+ t.Errorf("IsRemote %s was %v, expected %v", r.path, remote, r.remote)
+ }
+ }
+}
+
+func TestIsGit(t *testing.T) {
+ repos := []struct {
+ path string
+ remote bool
+ }{
+ {"git://github.com/foo/far", true},
+ {"git://github.com/foo/far.git", true},
+ {"git@github.com:foo/far", true},
+ {"git@github.com:foo/far.git", true},
+ {"http://github.com/foo/far.git", true},
+ {"https://github.com/foo/far.git", true},
+ {"ssh://baz.com/foo/far.git", true},
+ {"svn://gcc.gnu.org/svn/gcc/branches/gccgo", false},
+ {"https://code.google.com/p/go", false},
+ }
+
+ for _, r := range repos {
+ repo := Repo{Path: r.path}
+ if remote := repo.IsGit(); remote != r.remote {
+ t.Errorf("IsGit %s was %v, expected %v", r.path, remote, r.remote)
+ }
+ }
+}
diff --git a/pkg/build/script/deployment/appfog.go b/pkg/build/script/deployment/appfog.go
new file mode 100644
index 000000000..367fd2709
--- /dev/null
+++ b/pkg/build/script/deployment/appfog.go
@@ -0,0 +1,12 @@
+package deployment
+
+import (
+ "github.com/drone/drone/pkg/build/buildfile"
+)
+
+type AppFog struct {
+}
+
+func (a *AppFog) Write(f *buildfile.Buildfile) {
+
+}
diff --git a/pkg/build/script/deployment/cloudcontrol.go b/pkg/build/script/deployment/cloudcontrol.go
new file mode 100644
index 000000000..881410617
--- /dev/null
+++ b/pkg/build/script/deployment/cloudcontrol.go
@@ -0,0 +1,12 @@
+package deployment
+
+import (
+ "github.com/drone/drone/pkg/build/buildfile"
+)
+
+type CloudControl struct {
+}
+
+func (c *CloudControl) Write(f *buildfile.Buildfile) {
+
+}
diff --git a/pkg/build/script/deployment/cloudfoundry.go b/pkg/build/script/deployment/cloudfoundry.go
new file mode 100644
index 000000000..1f4620818
--- /dev/null
+++ b/pkg/build/script/deployment/cloudfoundry.go
@@ -0,0 +1,12 @@
+package deployment
+
+import (
+ "github.com/drone/drone/pkg/build/buildfile"
+)
+
+type CloudFoundry struct {
+}
+
+func (c *CloudFoundry) Write(f *buildfile.Buildfile) {
+
+}
diff --git a/pkg/build/script/deployment/deployment.go b/pkg/build/script/deployment/deployment.go
new file mode 100644
index 000000000..eb26dbc2b
--- /dev/null
+++ b/pkg/build/script/deployment/deployment.go
@@ -0,0 +1,42 @@
+package deployment
+
+import (
+ "github.com/drone/drone/pkg/build/buildfile"
+)
+
+// Deploy stores the configuration details
+// for deploying build artifacts when
+// a Build has succeeded
+type Deploy struct {
+ AppFog *AppFog `yaml:"appfog,omitempty"`
+ CloudControl *CloudControl `yaml:"cloudcontrol,omitempty"`
+ CloudFoundry *CloudFoundry `yaml:"cloudfoundry,omitempty"`
+ EngineYard *EngineYard `yaml:"engineyard,omitempty"`
+ Heroku *Heroku `yaml:"heroku,omitempty"`
+ Nodejitsu *Nodejitsu `yaml:"nodejitsu,omitempty"`
+ Openshift *Openshift `yaml:"openshift,omitempty"`
+}
+
+func (d *Deploy) Write(f *buildfile.Buildfile) {
+ if d.AppFog != nil {
+ d.AppFog.Write(f)
+ }
+ if d.CloudControl != nil {
+ d.CloudControl.Write(f)
+ }
+ if d.CloudFoundry != nil {
+ d.CloudFoundry.Write(f)
+ }
+ if d.EngineYard != nil {
+ d.EngineYard.Write(f)
+ }
+ if d.Heroku != nil {
+ d.Heroku.Write(f)
+ }
+ if d.Nodejitsu != nil {
+ d.Nodejitsu.Write(f)
+ }
+ if d.Openshift != nil {
+ d.Openshift.Write(f)
+ }
+}
diff --git a/pkg/build/script/deployment/engineyard.go b/pkg/build/script/deployment/engineyard.go
new file mode 100644
index 000000000..8aefa93d1
--- /dev/null
+++ b/pkg/build/script/deployment/engineyard.go
@@ -0,0 +1,12 @@
+package deployment
+
+import (
+ "github.com/drone/drone/pkg/build/buildfile"
+)
+
+type EngineYard struct {
+}
+
+func (e *EngineYard) Write(f *buildfile.Buildfile) {
+
+}
diff --git a/pkg/build/script/deployment/git.go b/pkg/build/script/deployment/git.go
new file mode 100644
index 000000000..3c65985a3
--- /dev/null
+++ b/pkg/build/script/deployment/git.go
@@ -0,0 +1 @@
+package deployment
diff --git a/pkg/build/script/deployment/heroku.go b/pkg/build/script/deployment/heroku.go
new file mode 100644
index 000000000..60dd2e371
--- /dev/null
+++ b/pkg/build/script/deployment/heroku.go
@@ -0,0 +1,38 @@
+package deployment
+
+import (
+ "fmt"
+ "github.com/drone/drone/pkg/build/buildfile"
+)
+
+type Heroku struct {
+ App string `yaml:"app,omitempty"`
+ Force bool `yaml:"force,omitempty"`
+ Branch string `yaml:"branch,omitempty"`
+}
+
+func (h *Heroku) Write(f *buildfile.Buildfile) {
+ // get the current commit hash
+ f.WriteCmdSilent("COMMIT=$(git rev-parse HEAD)")
+
+ // set the git user and email based on the individual
+ // that made the commit.
+ f.WriteCmdSilent("git config --global user.name $(git --no-pager log -1 --pretty=format:'%an')")
+ f.WriteCmdSilent("git config --global user.email $(git --no-pager log -1 --pretty=format:'%ae')")
+
+ // add heroku as a git remote
+ f.WriteCmd(fmt.Sprintf("git remote add heroku git@heroku.com:%s.git", h.App))
+
+ switch h.Force {
+ case true:
+ // this is useful when the there are artifacts generated
+ // by the build script, such as less files converted to css,
+ // that need to be deployed to Heroku.
+ f.WriteCmd(fmt.Sprintf("git add -A"))
+ f.WriteCmd(fmt.Sprintf("git commit -m 'adding build artifacts'"))
+ f.WriteCmd(fmt.Sprintf("git push heroku $COMMIT:master --force"))
+ case false:
+ // otherwise we just do a standard git push
+ f.WriteCmd(fmt.Sprintf("git push heroku $COMMIT:master"))
+ }
+}
diff --git a/pkg/build/script/deployment/nodejitsu.go b/pkg/build/script/deployment/nodejitsu.go
new file mode 100644
index 000000000..6a0af8a3a
--- /dev/null
+++ b/pkg/build/script/deployment/nodejitsu.go
@@ -0,0 +1,12 @@
+package deployment
+
+import (
+ "github.com/drone/drone/pkg/build/buildfile"
+)
+
+type Nodejitsu struct {
+}
+
+func (n *Nodejitsu) Write(f *buildfile.Buildfile) {
+
+}
diff --git a/pkg/build/script/deployment/openshift.go b/pkg/build/script/deployment/openshift.go
new file mode 100644
index 000000000..dc325c742
--- /dev/null
+++ b/pkg/build/script/deployment/openshift.go
@@ -0,0 +1,12 @@
+package deployment
+
+import (
+ "github.com/drone/drone/pkg/build/buildfile"
+)
+
+type Openshift struct {
+}
+
+func (o *Openshift) Write(f *buildfile.Buildfile) {
+
+}
diff --git a/pkg/build/script/deployment/ssh.go b/pkg/build/script/deployment/ssh.go
new file mode 100644
index 000000000..3c65985a3
--- /dev/null
+++ b/pkg/build/script/deployment/ssh.go
@@ -0,0 +1 @@
+package deployment
diff --git a/pkg/build/script/notification/email.go b/pkg/build/script/notification/email.go
new file mode 100644
index 000000000..cdb39932b
--- /dev/null
+++ b/pkg/build/script/notification/email.go
@@ -0,0 +1,85 @@
+package notification
+
+import (
+ "fmt"
+ "net/smtp"
+)
+
+type Email struct {
+ Recipients []string `yaml:"recipients,omitempty"`
+ Success string `yaml:"on_success"`
+ Failure string `yaml:"on_failure"`
+
+ host string // smtp host address
+ port string // smtp host port
+ user string // smtp username for authentication
+ pass string // smtp password for authentication
+ from string // smtp email address. send from this address
+}
+
+// SetServer is a function that will set the SMTP
+// server location and credentials
+func (e *Email) SetServer(host, port, user, pass, from string) {
+ e.host = host
+ e.port = port
+ e.user = user
+ e.pass = pass
+ e.from = from
+}
+
+// Send will send an email, either success or failure,
+// based on the Commit Status.
+func (e *Email) Send(context *Context) error {
+ switch {
+ case context.Commit.Status == "Success" && e.Success != "never":
+ return e.sendSuccess(context)
+ case context.Commit.Status == "Failure" && e.Failure != "never":
+ return e.sendFailure(context)
+ }
+
+ return nil
+}
+
+// sendFailure sends email notifications to the list of
+// recipients indicating the build failed.
+func (e *Email) sendFailure(context *Context) error {
+ // loop through and email recipients
+ /*for _, email := range e.Recipients {
+ if err := mail.SendFailure(context.Repo.Slug, email, context); err != nil {
+ return err
+ }
+ }*/
+ return nil
+}
+
+// sendSuccess sends email notifications to the list of
+// recipients indicating the build was a success.
+func (e *Email) sendSuccess(context *Context) error {
+ // loop through and email recipients
+ /*for _, email := range e.Recipients {
+ if err := mail.SendSuccess(context.Repo.Slug, email, context); err != nil {
+ return err
+ }
+ }*/
+ return nil
+}
+
+// send is a simple helper function to format and
+// send an email message.
+func (e *Email) send(to, subject, body string) error {
+ // Format the raw email message body
+ raw := fmt.Sprintf(emailTemplate, e.from, to, subject, body)
+ auth := smtp.PlainAuth("", e.user, e.pass, e.host)
+ addr := fmt.Sprintf("%s:%s", e.host, e.port)
+
+ return smtp.SendMail(addr, auth, e.from, []string{to}, []byte(raw))
+}
+
+// text-template used to generate a raw Email message
+var emailTemplate = `From: %s
+To: %s
+Subject: %s
+MIME-version: 1.0
+Content-Type: text/html; charset="UTF-8"
+
+%s`
diff --git a/pkg/build/script/notification/hipchat.go b/pkg/build/script/notification/hipchat.go
new file mode 100644
index 000000000..5e8def651
--- /dev/null
+++ b/pkg/build/script/notification/hipchat.go
@@ -0,0 +1,64 @@
+package notification
+
+import (
+ "fmt"
+
+ "github.com/andybons/hipchat"
+)
+
+const (
+ startedMessage = "Building %s, commit %s, author %s"
+ successMessage = "Success %s, commit %s, author %s"
+ failureMessage = "Failed %s, commit %s, author %s"
+)
+
+type Hipchat struct {
+ Room string `yaml:"room,omitempty"`
+ Token string `yaml:"token,omitempty"`
+ Started bool `yaml:"on_started,omitempty"`
+ Success bool `yaml:"on_success,omitempty"`
+ Failure bool `yaml:"on_failure,omitempty"`
+}
+
+func (h *Hipchat) Send(context *Context) error {
+ switch {
+ case context.Commit.Status == "Started" && h.Started:
+ return h.sendStarted(context)
+ case context.Commit.Status == "Success" && h.Success:
+ return h.sendSuccess(context)
+ case context.Commit.Status == "Failure" && h.Failure:
+ return h.sendFailure(context)
+ }
+
+ return nil
+}
+
+func (h *Hipchat) sendStarted(context *Context) error {
+ msg := fmt.Sprintf(startedMessage, context.Repo.Name, context.Commit.HashShort(), context.Commit.Author)
+ return h.send(hipchat.ColorYellow, hipchat.FormatHTML, msg)
+}
+
+func (h *Hipchat) sendFailure(context *Context) error {
+ msg := fmt.Sprintf(failureMessage, context.Repo.Name, context.Commit.HashShort(), context.Commit.Author)
+ return h.send(hipchat.ColorRed, hipchat.FormatHTML, msg)
+}
+
+func (h *Hipchat) sendSuccess(context *Context) error {
+ msg := fmt.Sprintf(successMessage, context.Repo.Name, context.Commit.HashShort(), context.Commit.Author)
+ return h.send(hipchat.ColorGreen, hipchat.FormatHTML, msg)
+}
+
+// helper function to send Hipchat requests
+func (h *Hipchat) send(color, format, message string) error {
+ c := hipchat.Client{AuthToken: h.Token}
+ req := hipchat.MessageRequest{
+ RoomId: h.Room,
+ From: "Drone",
+ Message: message,
+ Color: color,
+ MessageFormat: format,
+ Notify: true,
+ }
+
+ return c.PostMessage(req)
+}
diff --git a/pkg/build/script/notification/irc.go b/pkg/build/script/notification/irc.go
new file mode 100644
index 000000000..4306c87f1
--- /dev/null
+++ b/pkg/build/script/notification/irc.go
@@ -0,0 +1 @@
+package notification
diff --git a/pkg/build/script/notification/notification.go b/pkg/build/script/notification/notification.go
new file mode 100644
index 000000000..bea1b6e71
--- /dev/null
+++ b/pkg/build/script/notification/notification.go
@@ -0,0 +1,53 @@
+package notification
+
+import (
+ "github.com/drone/drone/pkg/model"
+)
+
+// Context represents the context of an
+// in-progress build request.
+type Context struct {
+ // Global settings
+ Host string
+
+ // User that owns the repository
+ User *model.User
+
+ // Repository being built.
+ Repo *model.Repo
+
+ // Commit being built
+ Commit *model.Commit
+}
+
+type Sender interface {
+ Send(context *Context) error
+}
+
+// Notification stores the configuration details
+// for notifying a user, or group of users,
+// when their Build has completed.
+type Notification struct {
+ Email *Email `yaml:"email,omitempty"`
+ Webhook *Webhook `yaml:"webhook,omitempty"`
+ Hipchat *Hipchat `yaml:"hipchat,omitempty"`
+}
+
+func (n *Notification) Send(context *Context) error {
+ // send email notifications
+ //if n.Email != nil && n.Email.Enabled {
+ // n.Email.Send(context)
+ //}
+
+ // send email notifications
+ if n.Webhook != nil {
+ n.Webhook.Send(context)
+ }
+
+ // send email notifications
+ if n.Hipchat != nil {
+ n.Hipchat.Send(context)
+ }
+
+ return nil
+}
diff --git a/pkg/build/script/notification/webhook.go b/pkg/build/script/notification/webhook.go
new file mode 100644
index 000000000..649fbbe2c
--- /dev/null
+++ b/pkg/build/script/notification/webhook.go
@@ -0,0 +1,59 @@
+package notification
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+
+ "github.com/drone/drone/pkg/model"
+)
+
+type Webhook struct {
+ URL []string `yaml:"urls,omitempty"`
+ Success bool `yaml:"on_success,omitempty"`
+ Failure bool `yaml:"on_failure,omitempty"`
+}
+
+func (w *Webhook) Send(context *Context) error {
+ switch {
+ case context.Commit.Status == "Success" && w.Success:
+ return w.send(context)
+ case context.Commit.Status == "Failure" && w.Failure:
+ return w.send(context)
+ }
+
+ return nil
+}
+
+// helper function to send HTTP requests
+func (w *Webhook) send(context *Context) error {
+ // data will get posted in this format
+ data := struct {
+ Owner *model.User `json:"owner"`
+ Repo *model.Repo `json:"repository"`
+ Commit *model.Commit `json:"commit"`
+ }{context.User, context.Repo, context.Commit}
+
+ // data json encoded
+ payload, err := json.Marshal(data)
+ if err != nil {
+ return err
+ }
+
+ // loop through and email recipients
+ for _, url := range w.URL {
+ go sendJson(url, payload)
+ }
+ return nil
+}
+
+// helper fuction to sent HTTP Post requests
+// with JSON data as the payload.
+func sendJson(url string, payload []byte) {
+ buf := bytes.NewBuffer(payload)
+ resp, err := http.Post(url, "application/json", buf)
+ if err != nil {
+ return
+ }
+ resp.Body.Close()
+}
diff --git a/pkg/build/script/notification/zapier.go b/pkg/build/script/notification/zapier.go
new file mode 100644
index 000000000..4306c87f1
--- /dev/null
+++ b/pkg/build/script/notification/zapier.go
@@ -0,0 +1 @@
+package notification
diff --git a/pkg/build/script/publish/bintray.go b/pkg/build/script/publish/bintray.go
new file mode 100644
index 000000000..30b1a5b2a
--- /dev/null
+++ b/pkg/build/script/publish/bintray.go
@@ -0,0 +1 @@
+package publish
diff --git a/pkg/build/script/publish/dropbox.go b/pkg/build/script/publish/dropbox.go
new file mode 100644
index 000000000..30b1a5b2a
--- /dev/null
+++ b/pkg/build/script/publish/dropbox.go
@@ -0,0 +1 @@
+package publish
diff --git a/pkg/build/script/publish/gems.go b/pkg/build/script/publish/gems.go
new file mode 100644
index 000000000..30b1a5b2a
--- /dev/null
+++ b/pkg/build/script/publish/gems.go
@@ -0,0 +1 @@
+package publish
diff --git a/pkg/build/script/publish/maven.go b/pkg/build/script/publish/maven.go
new file mode 100644
index 000000000..30b1a5b2a
--- /dev/null
+++ b/pkg/build/script/publish/maven.go
@@ -0,0 +1 @@
+package publish
diff --git a/pkg/build/script/publish/npm.go b/pkg/build/script/publish/npm.go
new file mode 100644
index 000000000..30b1a5b2a
--- /dev/null
+++ b/pkg/build/script/publish/npm.go
@@ -0,0 +1 @@
+package publish
diff --git a/pkg/build/script/publish/pub.go b/pkg/build/script/publish/pub.go
new file mode 100644
index 000000000..30b1a5b2a
--- /dev/null
+++ b/pkg/build/script/publish/pub.go
@@ -0,0 +1 @@
+package publish
diff --git a/pkg/build/script/publish/publish.go b/pkg/build/script/publish/publish.go
new file mode 100644
index 000000000..d31088f29
--- /dev/null
+++ b/pkg/build/script/publish/publish.go
@@ -0,0 +1,18 @@
+package publish
+
+import (
+ "github.com/drone/drone/pkg/build/buildfile"
+)
+
+// Publish stores the configuration details
+// for publishing build artifacts when
+// a Build has succeeded
+type Publish struct {
+ S3 *S3 `yaml:"s3,omitempty"`
+}
+
+func (p *Publish) Write(f *buildfile.Buildfile) {
+ if p.S3 != nil {
+ p.S3.Write(f)
+ }
+}
diff --git a/pkg/build/script/publish/pypi.go b/pkg/build/script/publish/pypi.go
new file mode 100644
index 000000000..d46cd60ca
--- /dev/null
+++ b/pkg/build/script/publish/pypi.go
@@ -0,0 +1,2 @@
+package publish
+
diff --git a/pkg/build/script/publish/s3.go b/pkg/build/script/publish/s3.go
new file mode 100644
index 000000000..cfa75e7d1
--- /dev/null
+++ b/pkg/build/script/publish/s3.go
@@ -0,0 +1,85 @@
+package publish
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/drone/drone/pkg/build/buildfile"
+)
+
+type S3 struct {
+ Key string `yaml:"access_key,omitempty"`
+ Secret string `yaml:"secret_key,omitempty"`
+ Bucket string `yaml:"bucket,omitempty"`
+
+ // us-east-1
+ // us-west-1
+ // us-west-2
+ // eu-west-1
+ // ap-southeast-1
+ // ap-southeast-2
+ // ap-northeast-1
+ // sa-east-1
+ Region string `yaml:"region,omitempty"`
+
+ // Indicates the files ACL, which should be one
+ // of the following:
+ // private
+ // public-read
+ // public-read-write
+ // authenticated-read
+ // bucket-owner-read
+ // bucket-owner-full-control
+ Access string `yaml:"acl,omitempty"`
+
+ // Copies the files from the specified directory.
+ // Regexp matching will apply to match multiple
+ // files
+ //
+ // Examples:
+ // /path/to/file
+ // /path/to/*.txt
+ // /path/to/*/*.txt
+ // /path/to/**
+ Source string `yaml:"source,omitempty"`
+ Target string `yaml:"target,omitempty"`
+
+ // Recursive uploads
+ Recursive bool `yaml:"recursive"`
+
+ Branch string `yaml:"branch,omitempty"`
+}
+
+func (s *S3) Write(f *buildfile.Buildfile) {
+ // install the AWS cli using PIP
+ f.WriteCmdSilent("[ -f /usr/bin/sudo ] || pip install awscli 1> /dev/null 2> /dev/null")
+ f.WriteCmdSilent("[ -f /usr/bin/sudo ] && sudo pip install awscli 1> /dev/null 2> /dev/null")
+
+ f.WriteEnv("AWS_ACCESS_KEY_ID", s.Key)
+ f.WriteEnv("AWS_SECRET_ACCESS_KEY", s.Secret)
+
+ // make sure a default region is set
+ if len(s.Region) == 0 {
+ s.Region = "us-east-1"
+ }
+
+ // make sure a default access is set
+ // let's be conservative and assume private
+ if len(s.Region) == 0 {
+ s.Region = "private"
+ }
+
+ // if the target starts with a "/" we need
+ // to remove it, otherwise we might adding
+ // a 3rd slash to s3://
+ if strings.HasPrefix(s.Target, "/") {
+ s.Target = s.Target[1:]
+ }
+
+ switch s.Recursive {
+ case true:
+ f.WriteCmd(fmt.Sprintf(`aws s3 cp %s s3://%s/%s --recursive --acl %s --region %s`, s.Source, s.Bucket, s.Target, s.Access, s.Region))
+ case false:
+ f.WriteCmd(fmt.Sprintf(`aws s3 cp %s s3://%s/%s --acl %s --region %s`, s.Source, s.Bucket, s.Target, s.Access, s.Region))
+ }
+}
diff --git a/pkg/build/script/report/README.md b/pkg/build/script/report/README.md
new file mode 100644
index 000000000..03260a5b1
--- /dev/null
+++ b/pkg/build/script/report/README.md
@@ -0,0 +1,5 @@
+cobertura.go
+coveralls.go
+gocov.go
+junit.go
+phpunit.go
\ No newline at end of file
diff --git a/pkg/build/script/script.go b/pkg/build/script/script.go
new file mode 100644
index 000000000..72af02a0f
--- /dev/null
+++ b/pkg/build/script/script.go
@@ -0,0 +1,123 @@
+package script
+
+import (
+ "io/ioutil"
+ "strings"
+
+ "launchpad.net/goyaml"
+
+ "github.com/drone/drone/pkg/build/buildfile"
+ "github.com/drone/drone/pkg/build/script/deployment"
+ "github.com/drone/drone/pkg/build/script/notification"
+ "github.com/drone/drone/pkg/build/script/publish"
+)
+
+func ParseBuild(data []byte) (*Build, error) {
+ build := Build{}
+
+ // parse the build configuration file
+ err := goyaml.Unmarshal(data, &build)
+ return &build, err
+}
+
+func ParseBuildFile(filename string) (*Build, error) {
+ data, err := ioutil.ReadFile(filename)
+ if err != nil {
+ return nil, err
+ }
+
+ return ParseBuild(data)
+}
+
+// Build stores the configuration details for
+// building, testing and deploying code.
+type Build struct {
+ // Image specifies the Docker Image that will be
+ // used to virtualize the Build process.
+ Image string
+
+ // Name specifies a user-defined label used
+ // to identify the build.
+ Name string
+
+ // Script specifies the build and test commands.
+ Script []string
+
+ // Env specifies the environment of the build.
+ Env []string
+
+ // Services specifies external services, such as
+ // database or messaging queues, that should be
+ // linked to the build environment.
+ Services []string
+
+ Deploy *deployment.Deploy `yaml:"deploy,omitempty"`
+ Publish *publish.Publish `yaml:"publish,omitempty"`
+ Notifications *notification.Notification `yaml:"notify,omitempty"`
+}
+
+// Write adds all the steps to the build script, including
+// build commands, deploy and publish commands.
+func (b *Build) Write(f *buildfile.Buildfile) {
+ // append build commands
+ b.WriteBuild(f)
+
+ // write publish commands
+ if b.Publish != nil {
+ b.Publish.Write(f)
+ }
+
+ // write deployment commands
+ if b.Deploy != nil {
+ b.Deploy.Write(f)
+ }
+}
+
+// WriteBuild adds only the build steps to the build script,
+// omitting publish and deploy steps. This is important for
+// pull requests, where deployment would be undesirable.
+func (b *Build) WriteBuild(f *buildfile.Buildfile) {
+ // append environment variables
+ for _, env := range b.Env {
+ parts := strings.Split(env, "=")
+ if len(parts) != 2 {
+ continue
+ }
+ f.WriteEnv(parts[0], parts[1])
+ }
+
+ // append build commands
+ for _, cmd := range b.Script {
+ f.WriteCmd(cmd)
+ }
+}
+
+type Publish interface {
+ Write(f *buildfile.Buildfile)
+}
+
+type Deployment interface {
+ Write(f *buildfile.Buildfile)
+}
+
+type Notification interface {
+ Set(c Context)
+}
+
+type Context interface {
+ Host() string
+ Owner() string
+ Name() string
+
+ Branch() string
+ Hash() string
+ Status() string
+ Message() string
+ Author() string
+ Gravatar() string
+
+ Duration() int64
+ HumanDuration() string
+
+ //Settings
+}
diff --git a/pkg/build/util.go b/pkg/build/util.go
new file mode 100644
index 000000000..eb96530b3
--- /dev/null
+++ b/pkg/build/util.go
@@ -0,0 +1,28 @@
+package build
+
+import (
+ "crypto/rand"
+ "crypto/sha1"
+ "fmt"
+ "io"
+)
+
+// createUID is a helper function that will
+// create a random, unique identifier.
+func createUID() string {
+ c := sha1.New()
+ r := createRandom()
+ io.WriteString(c, string(r))
+ s := fmt.Sprintf("%x", c.Sum(nil))
+ return "drone-" + s[0:10]
+}
+
+// createRandom creates a random block of bytes
+// that we can use to generate unique identifiers.
+func createRandom() []byte {
+ k := make([]byte, sha1.BlockSize)
+ if _, err := io.ReadFull(rand.Reader, k); err != nil {
+ return nil
+ }
+ return k
+}
diff --git a/pkg/build/writer.go b/pkg/build/writer.go
new file mode 100644
index 000000000..2f5fe9e1c
--- /dev/null
+++ b/pkg/build/writer.go
@@ -0,0 +1,57 @@
+package build
+
+import (
+ //"bytes"
+ "fmt"
+ "io"
+
+ "strings"
+)
+
+var (
+ // the prefix used to determine if this is
+ // data that should be stripped from the output
+ prefix = []byte("#DRONE:")
+)
+
+// custom writer to intercept the build
+// output
+type writer struct {
+ io.Writer
+}
+
+// Write appends the contents of p to the buffer. It will
+// scan for DRONE special formatting codes embedded in the
+// output, and will alter the output accordingly.
+func (w *writer) Write(p []byte) (n int, err error) {
+
+ lines := strings.Split(string(p), "\n")
+ for i, line := range lines {
+
+ if strings.HasPrefix(line, "#DRONE:") {
+ var cmd string
+
+ // extract the command (base16 encoded)
+ // from the output
+ fmt.Sscanf(line[7:], "%x", &cmd)
+
+ // echo the decoded command
+ cmd = fmt.Sprintf("$ %s", cmd)
+ w.Writer.Write([]byte(cmd))
+
+ } else {
+ w.Writer.Write([]byte(line))
+ }
+
+ if i < len(lines)-1 {
+ w.Writer.Write([]byte("\n"))
+ }
+ }
+
+ return len(p), nil
+}
+
+// WriteString appends the contents of s to the buffer.
+func (w *writer) WriteString(s string) (n int, err error) {
+ return w.Write([]byte(s))
+}
diff --git a/pkg/build/writer_test.go b/pkg/build/writer_test.go
new file mode 100644
index 000000000..255e15d31
--- /dev/null
+++ b/pkg/build/writer_test.go
@@ -0,0 +1,27 @@
+package build
+
+import (
+ "bytes"
+ "testing"
+)
+
+func TestSetupDockerfile(t *testing.T) {
+ var buf bytes.Buffer
+
+ // wrap the buffer so we can analyze output
+ w := writer{&buf}
+
+ w.WriteString("#DRONE:676f206275696c64\n")
+ w.WriteString("#DRONE:676f2074657374202d76\n")
+ w.WriteString("PASS\n")
+ w.WriteString("ok github.com/garyburd/redigo/redis 0.113s\n")
+
+ expected := `$ go build
+$ go test -v
+PASS
+ok github.com/garyburd/redigo/redis 0.113s
+`
+ if expected != buf.String() {
+ t.Errorf("Expected commands decoded and echoed correctly. got \n%s", buf.String())
+ }
+}
diff --git a/pkg/channel/channel.go b/pkg/channel/channel.go
new file mode 100644
index 000000000..3c908090e
--- /dev/null
+++ b/pkg/channel/channel.go
@@ -0,0 +1,157 @@
+package channel
+
+import (
+ "crypto/rand"
+ "encoding/json"
+ "fmt"
+ "io"
+ "time"
+
+ "code.google.com/p/go.net/websocket"
+ "github.com/dchest/authcookie"
+)
+
+// secret key used to generate tokens
+var secret = make([]byte, 32)
+
+func init() {
+ // generate the secret key by reading
+ // from crypto/random
+ if _, err := io.ReadFull(rand.Reader, secret); err != nil {
+ panic(err)
+ }
+}
+
+// Create will generate a token and create a new
+// channel over which messages will be sent.
+func Create(name string) string {
+ mu.Lock()
+ defer mu.Unlock()
+
+ if _, ok := hubs[name]; !ok {
+ hub := newHub(false, true)
+ hubs[name] = hub
+ go hub.run()
+ }
+ return authcookie.NewSinceNow(name, 24*time.Hour, secret)
+}
+
+// CreateStream will generate a token and create a new
+// channel over which messages streams (ie build output)
+// are sent.
+func CreateStream(name string) string {
+ mu.Lock()
+ defer mu.Unlock()
+
+ if _, ok := hubs[name]; !ok {
+ hub := newHub(true, false)
+ hubs[name] = hub
+ go hub.run()
+ }
+ return authcookie.NewSinceNow(name, 24*time.Hour, secret)
+}
+
+// Token will generate a token, but will not create
+// a new channel.
+func Token(name string) string {
+ return authcookie.NewSinceNow(name, 24*time.Hour, secret)
+}
+
+// Send sends a message on the named channel.
+func Send(name string, message string) error {
+ return SendBytes(name, []byte(message))
+}
+
+// SendJSON sends a JSON-encoded value on
+// the named channel.
+func SendJSON(name string, value interface{}) error {
+ m, err := json.Marshal(value)
+ if err != nil {
+ return err
+ }
+
+ return SendBytes(name, m)
+}
+
+// SendBytes send a message in byte format on
+// the named channel.
+func SendBytes(name string, value []byte) error {
+ // get the hub for the specified channel name
+ mu.RLock()
+ hub, ok := hubs[name]
+ mu.RUnlock()
+
+ if !ok {
+ return fmt.Errorf("channel does not exist")
+ }
+
+ go hub.Write(value)
+ return nil
+}
+
+func Read(ws *websocket.Conn) {
+
+ // get the name from the request
+ hash := ws.Request().FormValue("token")
+
+ // get the hash of the token
+ name := authcookie.Login(hash, secret)
+
+ // get the hub for the specified channel name
+ mu.RLock()
+ hub, ok := hubs[name]
+ mu.RUnlock()
+
+ // if hub not found, exit
+ if !ok {
+ ws.Close()
+ return
+ }
+
+ // internal representation of a connection
+ // maximum queue of 100000 messages
+ conn := &connection{
+ send: make(chan string, 100000),
+ ws: ws,
+ }
+
+ // register the connection with the hub
+ hub.register <- conn
+
+ defer func() {
+ go func() {
+ hub.unregister <- conn
+ }()
+ closed := <-hub.closed
+
+ // this will remove the hub when the connection is
+ // closed if the
+ if hub.autoClose && closed {
+ mu.Lock()
+ delete(hubs, name)
+ mu.Unlock()
+ }
+ }()
+
+ go conn.writer()
+ conn.reader()
+}
+
+func Close(name string) {
+ // get the hub for the specified channel name
+ mu.RLock()
+ hub, ok := hubs[name]
+ mu.RUnlock()
+
+ if !ok {
+ return
+ }
+
+ // close hub connections
+ hub.Close()
+
+ // remove the hub
+ mu.Lock()
+ delete(hubs, name)
+ mu.Unlock()
+}
diff --git a/pkg/channel/conn.go b/pkg/channel/conn.go
new file mode 100644
index 000000000..d8c8fac55
--- /dev/null
+++ b/pkg/channel/conn.go
@@ -0,0 +1,36 @@
+package channel
+
+import (
+ "code.google.com/p/go.net/websocket"
+)
+
+type connection struct {
+ // The websocket connection.
+ ws *websocket.Conn
+
+ // Buffered channel of outbound messages.
+ send chan string
+}
+
+func (c *connection) reader() {
+ for {
+ var message string
+ err := websocket.Message.Receive(c.ws, &message)
+ if err != nil {
+ break
+ }
+ }
+
+ c.ws.Close()
+}
+
+func (c *connection) writer() {
+ for message := range c.send {
+ err := websocket.Message.Send(c.ws, message)
+ if err != nil {
+ break
+ }
+ }
+
+ c.ws.Close()
+}
diff --git a/pkg/channel/hub.go b/pkg/channel/hub.go
new file mode 100644
index 000000000..8cea6d433
--- /dev/null
+++ b/pkg/channel/hub.go
@@ -0,0 +1,133 @@
+package channel
+
+import (
+ "sync"
+)
+
+// mutex to lock access to the
+// internal map of hubs.
+var mu sync.RWMutex
+
+// a map of hubs. each hub represents a different
+// channel that a set of users can listen on. For
+// example, we may have a hub to stream build output
+// for github.com/foo/bar or a channel to post
+// updates for user octocat.
+var hubs = map[string]*hub{}
+
+type hub struct {
+ // Registered connections
+ connections map[*connection]bool
+
+ // Inbound messages from the connections.
+ broadcast chan string
+
+ // Register requests from the connections.
+ register chan *connection
+
+ // Unregister requests from connections.
+ unregister chan *connection
+
+ // Buffer of sent data. This is used mostly
+ // for build output. A client may connect after
+ // the build has already started, in which case
+ // we need to stream them the build history.
+ history []string
+
+ // Send a "shutdown" signal
+ close chan bool
+
+ // Hub responds on this channel letting you know
+ // if it's active
+ closed chan bool
+
+ // Auto shutdown when last connection removed
+ autoClose bool
+
+ // Send history
+ sendHistory bool
+}
+
+func newHub(sendHistory, autoClose bool) *hub {
+ h := hub{
+ broadcast: make(chan string),
+ register: make(chan *connection),
+ unregister: make(chan *connection),
+ connections: make(map[*connection]bool),
+ history: make([]string, 0), // This should be pre-allocated, but it's not
+ close: make(chan bool),
+ autoClose: autoClose,
+ closed: make(chan bool),
+ sendHistory: sendHistory,
+ }
+
+ return &h
+}
+
+func sendHistory(c *connection, history []string) {
+ if len(history) > 0 {
+ for i := range history {
+ c.send <- history[i]
+ }
+ }
+}
+
+func (h *hub) run() {
+ // make sure we don't bring down the application
+ // if somehow we encounter a nil pointer or some
+ // other unexpected behavior.
+ defer func() {
+ recover()
+ }()
+
+ for {
+ select {
+ case c := <-h.register:
+ h.connections[c] = true
+ if len(h.history) > 0 {
+ b := make([]string, len(h.history))
+ copy(b, h.history)
+ go sendHistory(c, b)
+ }
+ case c := <-h.unregister:
+ delete(h.connections, c)
+ close(c.send)
+ shutdown := h.autoClose && (len(h.connections) == 0)
+ if shutdown {
+ h.closed <- shutdown
+ return
+ }
+ h.closed <- shutdown
+ case m := <-h.broadcast:
+ if h.sendHistory {
+ h.history = append(h.history, m)
+ }
+ for c := range h.connections {
+ select {
+ case c.send <- m:
+ // do nothing
+ default:
+ delete(h.connections, c)
+ go c.ws.Close()
+ }
+ }
+ case <-h.close:
+ for c := range h.connections {
+ delete(h.connections, c)
+ close(c.send)
+ }
+ h.closed <- true
+ return
+ }
+
+ }
+}
+
+func (h *hub) Close() {
+ h.close <- true
+}
+
+func (h *hub) Write(p []byte) (n int, err error) {
+ h.broadcast <- string(p)
+ return len(p), nil
+}
diff --git a/pkg/database/builds.go b/pkg/database/builds.go
new file mode 100644
index 000000000..8aacf32dd
--- /dev/null
+++ b/pkg/database/builds.go
@@ -0,0 +1,71 @@
+package database
+
+import (
+ . "github.com/drone/drone/pkg/model"
+ "github.com/russross/meddler"
+)
+
+// Name of the Build table in the database
+const buildTable = "builds"
+
+// SQL Queries to retrieve a list of all Commits belonging to a Repo.
+const buildStmt = `
+SELECT id, commit_id, slug, status, started, finished, duration, created, updated, stdout
+FROM builds
+WHERE commit_id = ?
+ORDER BY slug ASC
+`
+
+// SQL Queries to retrieve a Build by id.
+const buildFindStmt = `
+SELECT id, commit_id, slug, status, started, finished, duration, created, updated, stdout
+FROM builds
+WHERE id = ?
+LIMIT 1
+`
+
+// SQL Queries to retrieve a Commit by name and repo id.
+const buildFindSlugStmt = `
+SELECT id, commit_id, slug, status, started, finished, duration, created, updated, stdout
+FROM builds
+WHERE slug = ? AND commit_id = ?
+LIMIT 1
+`
+
+// SQL Queries to delete a Commit.
+const buildDeleteStmt = `
+DELETE FROM builds WHERE id = ?
+`
+
+// Returns the Build with the given ID.
+func GetBuild(id int64) (*Build, error) {
+ build := Build{}
+ err := meddler.QueryRow(db, &build, buildFindStmt, id)
+ return &build, err
+}
+
+// Returns the Build with the given slug.
+func GetBuildSlug(slug string, commit int64) (*Build, error) {
+ build := Build{}
+ err := meddler.QueryRow(db, &build, buildFindSlugStmt, slug, commit)
+ return &build, err
+}
+
+// Creates a new Build.
+func SaveBuild(build *Build) error {
+ return meddler.Save(db, buildTable, build)
+}
+
+// Deletes an existing Build.
+func DeleteBuild(id int64) error {
+ _, err := db.Exec(buildDeleteStmt, id)
+ return err
+}
+
+// Returns a list of all Builds associated
+// with the specified Commit ID and branch.
+func ListBuilds(id int64) ([]*Build, error) {
+ var builds []*Build
+ err := meddler.QueryAll(db, &builds, buildStmt, id)
+ return builds, err
+}
diff --git a/pkg/database/commits.go b/pkg/database/commits.go
new file mode 100644
index 000000000..74e0d86b4
--- /dev/null
+++ b/pkg/database/commits.go
@@ -0,0 +1,174 @@
+package database
+
+import (
+ "time"
+
+ . "github.com/drone/drone/pkg/model"
+ "github.com/russross/meddler"
+)
+
+// Name of the Commit table in the database
+const commitTable = "commits"
+
+// SQL Queries to retrieve a list of all Commits belonging to a Repo.
+const commitStmt = `
+SELECT id, repo_id, status, started, finished, duration,
+hash, branch, pull_request, author, gravatar, timestamp, message, created, updated
+FROM commits
+WHERE repo_id = ? AND branch = ?
+ORDER BY created DESC
+LIMIT 10
+`
+
+// SQL Queries to retrieve the latest Commit.
+const commitLatestStmt = `
+SELECT id, repo_id, status, started, finished, duration,
+hash, branch, pull_request, author, gravatar, timestamp, message, created, updated
+FROM commits
+WHERE repo_id = ? AND branch = ?
+ORDER BY created DESC
+LIMIT 1
+`
+
+// SQL Queries to retrieve a Commit by id.
+const commitFindStmt = `
+SELECT id, repo_id, status, started, finished, duration,
+hash, branch, pull_request, author, gravatar, timestamp, message, created, updated
+FROM commits
+WHERE id = ?
+`
+
+// SQL Queries to retrieve a Commit by name and repo id.
+const commitFindHashStmt = `
+SELECT id, repo_id, status, started, finished, duration,
+hash, branch, pull_request, author, gravatar, timestamp, message, created, updated
+FROM commits
+WHERE hash = ? AND repo_id = ?
+LIMIT 1
+`
+
+// SQL Query to retrieve a list of recent commits by user.
+const userCommitRecentStmt = `
+SELECT r.slug, r.host, r.owner, r.name,
+c.status, c.started, c.finished, c.duration, c.hash, c.branch, c.pull_request,
+c.author, c.gravatar, c.timestamp, c.message, c.created, c.updated
+FROM repos r, commits c
+WHERE r.user_id = ?
+AND r.team_id = 0
+AND r.id = c.repo_id
+AND c.status IN ('Success', 'Failure')
+ORDER BY c.created desc
+LIMIT 10
+`
+
+// SQL Query to retrieve a list of recent commits by team.
+const teamCommitRecentStmt = `
+SELECT r.slug, r.host, r.owner, r.name,
+c.status, c.started, c.finished, c.duration, c.hash, c.branch, c.pull_request,
+c.author, c.gravatar, c.timestamp, c.message, c.created, c.updated
+FROM repos r, commits c
+WHERE r.team_id = ?
+AND r.id = c.repo_id
+AND c.status IN ('Success', 'Failure')
+ORDER BY c.created desc
+LIMIT 10
+`
+
+// SQL Queries to delete a Commit.
+const commitDeleteStmt = `
+DELETE FROM commits WHERE id = ?
+`
+
+// SQL Queries to retrieve the latest Commits for each branch.
+const commitBranchesStmt = `
+SELECT id, repo_id, status, started, finished, duration,
+hash, branch, pull_request, author, gravatar, timestamp, message, created, updated
+FROM commits
+WHERE id IN (
+ SELECT MAX(id)
+ FROM commits
+ WHERE repo_id = ?
+ GROUP BY branch)
+ ORDER BY branch ASC
+ `
+
+// SQL Queries to retrieve the latest Commits for each branch.
+const commitBranchStmt = `
+SELECT id, repo_id, status, started, finished, duration,
+hash, branch, pull_request, author, gravatar, timestamp, message, created, updated
+FROM commits
+WHERE id IN (
+ SELECT MAX(id)
+ FROM commits
+ WHERE repo_id = ?
+ AND branch = ?
+ GROUP BY branch)
+LIMIT 1
+ `
+
+// Returns the Commit with the given ID.
+func GetCommit(id int64) (*Commit, error) {
+ commit := Commit{}
+ err := meddler.QueryRow(db, &commit, commitFindStmt, id)
+ return &commit, err
+}
+
+// Returns the Commit with the given hash.
+func GetCommitHash(hash string, repo int64) (*Commit, error) {
+ commit := Commit{}
+ err := meddler.QueryRow(db, &commit, commitFindHashStmt, hash, repo)
+ return &commit, err
+}
+
+// Returns the most recent Commit for the given branch.
+func GetBranch(repo int64, branch string) (*Commit, error) {
+ commit := Commit{}
+ err := meddler.QueryRow(db, &commit, commitBranchStmt, repo, branch)
+ return &commit, err
+}
+
+// Creates a new Commit.
+func SaveCommit(commit *Commit) error {
+ if commit.ID == 0 {
+ commit.Created = time.Now().UTC()
+ }
+ commit.Updated = time.Now().UTC()
+ return meddler.Save(db, commitTable, commit)
+}
+
+// Deletes an existing Commit.
+func DeleteCommit(id int64) error {
+ _, err := db.Exec(commitDeleteStmt, id)
+ return err
+}
+
+// Returns a list of all Commits associated
+// with the specified Repo ID.
+func ListCommits(repo int64, branch string) ([]*Commit, error) {
+ var commits []*Commit
+ err := meddler.QueryAll(db, &commits, commitStmt, repo, branch)
+ return commits, err
+}
+
+// Returns a list of recent Commits associated
+// with the specified User ID
+func ListCommitsUser(user int64) ([]*RepoCommit, error) {
+ var commits []*RepoCommit
+ err := meddler.QueryAll(db, &commits, userCommitRecentStmt, user)
+ return commits, err
+}
+
+// Returns a list of recent Commits associated
+// with the specified Team ID
+func ListCommitsTeam(team int64) ([]*RepoCommit, error) {
+ var commits []*RepoCommit
+ err := meddler.QueryAll(db, &commits, teamCommitRecentStmt, team)
+ return commits, err
+}
+
+// Returns a list of the most recent commits for each branch.
+func ListBranches(repo int64) ([]*Commit, error) {
+ var commits []*Commit
+ err := meddler.QueryAll(db, &commits, commitBranchesStmt, repo)
+ return commits, err
+}
diff --git a/pkg/database/database.go b/pkg/database/database.go
new file mode 100644
index 000000000..223405433
--- /dev/null
+++ b/pkg/database/database.go
@@ -0,0 +1,24 @@
+package database
+
+import (
+ "database/sql"
+ "log"
+
+ "github.com/drone/drone/pkg/database/schema"
+)
+
+// global instance of our database connection.
+var db *sql.DB
+
+// Set sets the default database.
+func Set(database *sql.DB) {
+ // set the global database
+ db = database
+
+ // load the database schema. If this is
+ // a new database all the tables and
+ // indexes will be created.
+ if err := schema.Load(db); err != nil {
+ log.Fatal(err)
+ }
+}
diff --git a/pkg/database/encrypt/encrypt.go b/pkg/database/encrypt/encrypt.go
new file mode 100644
index 000000000..f3f971c29
--- /dev/null
+++ b/pkg/database/encrypt/encrypt.go
@@ -0,0 +1,133 @@
+package encrypt
+
+import (
+ "bytes"
+ "crypto/cipher"
+ "crypto/rand"
+ "encoding/gob"
+ "fmt"
+ "io"
+)
+
+// EncryptedField handles encrypted and decryption of
+// values to and from database columns.
+type EncryptedField struct {
+ Cipher cipher.Block
+}
+
+// PreRead is called before a Scan operation. It is given a pointer to
+// the raw struct field, and returns the value that will be given to
+// the database driver.
+func (e *EncryptedField) PreRead(fieldAddr interface{}) (scanTarget interface{}, err error) {
+ // give a pointer to a byte buffer to grab the raw data
+ return new([]byte), nil
+}
+
+// PostRead is called after a Scan operation. It is given the value returned
+// by PreRead and a pointer to the raw struct field. It is expected to fill
+// in the struct field if the two are different.
+func (e *EncryptedField) PostRead(fieldAddr interface{}, scanTarget interface{}) error {
+ ptr := scanTarget.(*[]byte)
+ if ptr == nil {
+ return fmt.Errorf("encrypter.PostRead: nil pointer")
+ }
+ raw := *ptr
+
+ // ignore fields that aren't set at all
+ if len(raw) == 0 {
+ return nil
+ }
+
+ // decrypt value for gob decoding
+ var err error
+ raw, err = decrypt(e.Cipher, raw)
+ if err != nil {
+ return fmt.Errorf("Gob decryption error: %v", err)
+ }
+
+ // decode gob
+ gobDecoder := gob.NewDecoder(bytes.NewReader(raw))
+ if err := gobDecoder.Decode(fieldAddr); err != nil {
+ return fmt.Errorf("Gob decode error: %v", err)
+ }
+
+ return nil
+}
+
+// PreWrite is called before an Insert or Update operation. It is given
+// a pointer to the raw struct field, and returns the value that will be
+// given to the database driver.
+func (e *EncryptedField) PreWrite(field interface{}) (saveValue interface{}, err error) {
+ buffer := new(bytes.Buffer)
+
+ // gob encode
+ gobEncoder := gob.NewEncoder(buffer)
+ if err := gobEncoder.Encode(field); err != nil {
+ return nil, fmt.Errorf("Gob encoding error: %v", err)
+ }
+ // and then ecrypt
+ encrypted, err := encrypt(e.Cipher, buffer.Bytes())
+ if err != nil {
+ return nil, fmt.Errorf("Gob decryption error: %v", err)
+ }
+
+ return encrypted, nil
+}
+
+// encrypt is a helper function to encrypt a slice
+// of bytes using the specified block cipher.
+func encrypt(block cipher.Block, v []byte) ([]byte, error) {
+ // if no block cipher value exists we'll assume
+ // the database is running in non-ecrypted mode.
+ if block == nil {
+ return v, nil
+ }
+
+ value := make([]byte, len(v))
+ copy(value, v)
+
+ // Generate a random initialization vector
+ iv := generateRandomKey(block.BlockSize())
+ if len(iv) != block.BlockSize() {
+ return nil, fmt.Errorf("Could not generate a valid initialization vector for encryption")
+ }
+
+ // Encrypt it.
+ stream := cipher.NewCTR(block, iv)
+ stream.XORKeyStream(value, value)
+
+ // Return iv + ciphertext.
+ return append(iv, value...), nil
+}
+
+// decrypt is a helper function to decrypt a slice
+// using the specified block cipher.
+func decrypt(block cipher.Block, value []byte) ([]byte, error) {
+ // if no block cipher value exists we'll assume
+ // the database is running in non-ecrypted mode.
+ if block == nil {
+ return value, nil
+ }
+
+ size := block.BlockSize()
+ if len(value) > size {
+ // Extract iv.
+ iv := value[:size]
+ // Extract ciphertext.
+ value = value[size:]
+ // Decrypt it.
+ stream := cipher.NewCTR(block, iv)
+ stream.XORKeyStream(value, value)
+ return value, nil
+ }
+ return nil, fmt.Errorf("Could not decrypt the value")
+}
+
+// GenerateRandomKey creates a random key of size length bytes
+func generateRandomKey(strength int) []byte {
+ k := make([]byte, strength)
+ if _, err := io.ReadFull(rand.Reader, k); err != nil {
+ return nil
+ }
+ return k
+}
diff --git a/pkg/database/members.go b/pkg/database/members.go
new file mode 100644
index 000000000..c6d6ebff3
--- /dev/null
+++ b/pkg/database/members.go
@@ -0,0 +1,86 @@
+package database
+
+import (
+ . "github.com/drone/drone/pkg/model"
+ "github.com/russross/meddler"
+)
+
+// Name of the Member table in the database
+const memberTable = "members"
+
+// SQL Queries to retrieve a list of all members belonging to a team.
+const memberStmt = `
+SELECT user_id, name, email, gravatar, role
+FROM members, users
+WHERE users.id = members.user_id
+AND team_id = ?
+`
+
+// SQL Queries to retrieve a team by id and user.
+const memberFindStmt = `
+SELECT user_id, name, email, gravatar, role
+FROM members, users
+WHERE users.id = members.user_id
+AND user_id = ?
+AND team_id = ?
+`
+
+// SQL Queries to retrieve a team by name .
+const memberDeleteStmt = `
+DELETE FROM members
+WHERE user_id = ? AND team_id = ?
+`
+
+// SQL Queries to retrieve a member's role by id and user.
+const roleFindStmt = `
+SELECT role FROM members
+WHERE user_id = ? AND team_id = ?
+`
+
+// Returns the Member with the given user and team IDs.
+func GetMember(user, team int64) (*Member, error) {
+ member := Member{}
+ err := meddler.QueryRow(db, &member, memberFindStmt, user, team)
+ return &member, err
+}
+
+// Returns true if the user is a member of the team
+func IsMember(user, team int64) (bool, error) {
+ role := Role{}
+ err := meddler.QueryRow(db, &role, roleFindStmt, user, team)
+ return len(role.Role) > 0, err
+}
+
+// Returns true is the user is an admin member of the team.
+func IsMemberAdmin(user, team int64) (bool, error) {
+ role := Role{}
+ err := meddler.QueryRow(db, &role, roleFindStmt, user, team)
+ return role.Role == RoleAdmin || role.Role == RoleOwner, err
+}
+
+// Creates a new Member.
+func SaveMember(user, team int64, role string) error {
+ r := Role{}
+ if err := meddler.QueryRow(db, &r, roleFindStmt, user, team); err == nil {
+ r.Role = role
+ return meddler.Save(db, memberTable, &r)
+ }
+
+ r.UserID = user
+ r.TeamID = team
+ r.Role = role
+ return meddler.Save(db, memberTable, &r)
+}
+
+// Deletes an existing Member.
+func DeleteMember(user, team int64) error {
+ _, err := db.Exec(memberDeleteStmt, user, team)
+ return err
+}
+
+// Returns a list of all Team members.
+func ListMembers(team int64) ([]*Member, error) {
+ var members []*Member
+ err := meddler.QueryAll(db, &members, memberStmt, team)
+ return members, err
+}
diff --git a/pkg/database/repos.go b/pkg/database/repos.go
new file mode 100644
index 000000000..e4407c028
--- /dev/null
+++ b/pkg/database/repos.go
@@ -0,0 +1,92 @@
+package database
+
+import (
+ "time"
+
+ . "github.com/drone/drone/pkg/model"
+ "github.com/russross/meddler"
+)
+
+// Name of the Repos table in the database
+const repoTable = "repos"
+
+// SQL Queries to retrieve a list of all repos belonging to a User.
+const repoStmt = `
+SELECT id, slug, host, owner, name, private, disabled, disabled_pr, scm, url, username, password,
+public_key, private_key, params, timeout, priveleged, created, updated, user_id, team_id
+FROM repos
+WHERE user_id = ? AND team_id = 0
+ORDER BY slug ASC
+`
+
+// SQL Queries to retrieve a list of all repos belonging to a Team.
+const repoTeamStmt = `
+SELECT id, slug, host, owner, name, private, disabled, disabled_pr, scm, url, username, password,
+public_key, private_key, params, timeout, priveleged, created, updated, user_id, team_id
+FROM repos
+WHERE team_id = ?
+ORDER BY slug ASC
+`
+
+// SQL Queries to retrieve a repo by id.
+const repoFindStmt = `
+SELECT id, slug, host, owner, name, private, disabled, disabled_pr, scm, url, username, password,
+public_key, private_key, params, timeout, priveleged, created, updated, user_id, team_id
+FROM repos
+WHERE id = ?
+`
+
+// SQL Queries to retrieve a repo by name.
+const repoFindSlugStmt = `
+SELECT id, slug, host, owner, name, private, disabled, disabled_pr, scm, url, username, password,
+public_key, private_key, params, timeout, priveleged, created, updated, user_id, team_id
+FROM repos
+WHERE slug = ?
+`
+
+// Returns the Repo with the given ID.
+func GetRepo(id int64) (*Repo, error) {
+ repo := Repo{}
+ err := meddler.QueryRow(db, &repo, repoFindStmt, id)
+ return &repo, err
+}
+
+// Returns the Repo with the given slug.
+func GetRepoSlug(slug string) (*Repo, error) {
+ repo := Repo{}
+ err := meddler.QueryRow(db, &repo, repoFindSlugStmt, slug)
+ return &repo, err
+}
+
+// Creates a new Repository.
+func SaveRepo(repo *Repo) error {
+ if repo.ID == 0 {
+ repo.Created = time.Now().UTC()
+ }
+ repo.Updated = time.Now().UTC()
+ return meddler.Save(db, repoTable, repo)
+}
+
+// Deletes an existing Repository.
+// TODO need to delete builds too.
+func DeleteRepo(id int64) error {
+ _, err := db.Exec("DELETE FROM repos WHERE id = ?", id)
+ db.Exec("DELETE FROM commits WHERE repo_id = ?", id)
+ return err
+}
+
+// Returns a list of all Repos associated
+// with the specified User ID.
+func ListRepos(id int64) ([]*Repo, error) {
+ var repos []*Repo
+ err := meddler.QueryAll(db, &repos, repoStmt, id)
+ return repos, err
+}
+
+// Returns a list of all Repos associated
+// with the specified Team ID.
+func ListReposTeam(id int64) ([]*Repo, error) {
+ var repos []*Repo
+ err := meddler.QueryAll(db, &repos, repoTeamStmt, id)
+ return repos, err
+}
diff --git a/pkg/database/schema/sample.sql b/pkg/database/schema/sample.sql
new file mode 100644
index 000000000..6b3f6f297
--- /dev/null
+++ b/pkg/database/schema/sample.sql
@@ -0,0 +1,126 @@
+DELETE FROM builds;
+DELETE FROM commits;
+DELETE FROM repos;
+DELETE FROM members;
+DELETE FROM teams;
+DELETE FROM users;
+DELETE FROM settings;
+
+-- insert users (default password is "password")
+INSERT INTO users values (1, 'brad.rydzewski@gmail.com' , '$2a$10$b8d63QsTL38vx7lj0HEHfOdbu1PCAg6Gfca74UavkXooIBx9YxopS', 'nPmsbl6YNLUIUo0I7gkMcQ' ,'Brad Rydzewski', '8c58a0be77ee441bb8f8595b7f1b4e87', '2013-09-16 00:00:00', '2013-09-16 00:00:00', 1, '', '', '', '', '');
+INSERT INTO users values (2, 'thomas.d.burke@gmail.com' , '$2a$10$b8d63QsTL38vx7lj0HEHfOdbu1PCAg6Gfca74UavkXooIBx9YxopS', 'sal5Tzy6S10yZCaE0jl6QA', 'Thomas Burke', 'c62f7126273f7fa786274274a5dec8ce', '2013-09-16 00:00:00', '2013-09-16 00:00:00', 1, '', '', '', '', '');
+INSERT INTO users values (3, 'carlos.morales.duran@gmail.com', '$2a$10$b8d63QsTL38vx7lj0HEHfOdbu1PCAg6Gfca74UavkXooIBx9YxopS', 'bq87o8AmDUOahKApEy2tVQ', 'Carlos Morales', 'c2180a539620d90d68eaeb848364f1c2', '2013-09-16 00:00:00', '2013-09-17 00:00:00', 1, '', '', '', '', '');
+
+-- insert teams
+insert into teams values (1, 'drone', 'Drone' , 'brad@drone.io' , '0057e90a8036c29b1ddb22d0fd08b72c', '2013-09-16 00:00:00', '2013-09-16 00:00:00');
+insert into teams values (2, 'google', 'Google', 'dev@google.com' , '24ba30616d2a20673f54c2aee36d159e', '2013-09-16 00:00:00', '2013-09-16 00:00:00');
+insert into teams values (3, 'gradle', 'Gradle', 'dev@gradle.com' , '5cc3b557e3a3978d52036da9a5be2a08', '2013-09-16 00:00:00', '2013-09-16 00:00:00');
+insert into teams values (4, 'dart', 'Dart' , 'dev@dartlang.org', 'f41fe13f979f2f93cc8b971e1875bdf8', '2013-09-16 00:00:00', '2013-09-16 00:00:00');
+
+-- insert team members
+insert into members values (1, 1, 1, 'Owner');
+insert into members values (2, 1, 2, 'Admin');
+insert into members values (3, 1, 3, 'Write');
+
+-- insert repository
+insert into repos values (1, 'github.com/drone/jkl', 'github.com', 'drone', 'jkl', 0, 0, 0, 0, 900, 'git', 'git://github.com/drone/jkl.git', '', '', '', '', '', '2013-09-16 00:00:00', '2013-09-16 00:00:00', 1, 1);
+insert into repos values (2, 'github.com/drone/drone', 'github.com', 'drone', 'drone', 1, 0, 0, 0, 900, 'git', 'git@github.com:drone/drone.git', '', '', '', '', '', '2013-09-16 00:00:00', '2013-09-16 00:00:00', 1, 1);
+insert into repos values (3, 'github.com/bradrydzewski/drone', 'github.com', 'bradrydzewski', 'drone', 1, 0, 0, 0, 900, 'git', 'git@github.com:bradrydzewski/drone.git', '', '', '', '', '', '2013-09-16 00:00:00', '2013-09-16 00:00:00', 1, 1);
+insert into repos values (4, 'github.com/bradrydzewski/blog', 'github.com', 'bradrydzewski', 'blog', 0, 0, 0, 0, 900, 'git', 'git://github.com/bradrydzewski/blog.git', '', '', '', '', '', '2013-09-16 00:00:00', '2013-09-16 00:00:00', 1, 0);
+
+-- insert commits
+
+insert into commits values (1, 1, 'Success', '2013-09-16 00:00:00','2013-09-16 00:00:00', 60, 'ef2221722e6f07a6eaf8af8907b45324428a891d', 'master', '','brad.rydzewski@gmail.com', '8c58a0be77ee441bb8f8595b7f1b4e87', '2013-09-16 00:00:00', 'Fixed mock db class for entity', '2013-09-16 00:00:00', '2013-09-16 00:00:00');
+insert into commits values (2, 1, 'Success', '2013-09-16 00:00:00','2013-09-16 00:00:00', 60, '867477aa487d01df28522cee84cd06f5aa154e53', 'master', '','brad.rydzewski@gmail.com', '8c58a0be77ee441bb8f8595b7f1b4e87', '2013-09-16 00:00:00', 'Fixed mock db class for entity', '2013-09-16 00:00:00', '2013-09-16 00:00:00');
+insert into commits values (3, 1, 'Success', '2013-09-16 00:00:00','2013-09-16 00:00:00', 60, 'e43427ab462417cb3d53b8702c298c1675deb926', 'master', '','brad.rydzewski@gmail.com', '8c58a0be77ee441bb8f8595b7f1b4e87', '2013-09-16 00:00:00', 'Save deleted entity data to database', '2013-09-16 00:00:00', '2013-09-16 00:00:00');
+insert into commits values (4, 1, 'Success', '2013-09-16 00:00:00','2013-09-16 00:00:00', 60, 'a43427ab462417cb3d53b8702c298c1675deb926', 'dev', '','brad.rydzewski@gmail.com', '8c58a0be77ee441bb8f8595b7f1b4e87', '2013-09-16 00:00:00', 'Save deleted entity data to database', '2013-09-16 00:00:00', '2013-09-16 00:00:00');
+
+-- insert builds
+
+insert into builds values (1, 1, 'node_0.10', 'Success', '2013-09-16 00:00:00','2013-09-16 00:00:00', 60, '2013-09-16 00:00:00','2013-09-16 00:00:00', '');
+insert into builds values (2, 1, 'node_0.90', 'Success', '2013-09-16 00:00:00','2013-09-16 00:00:00', 60, '2013-09-16 00:00:00','2013-09-16 00:00:00', '');
+insert into builds values (3, 1, 'node_0.80', 'Success', '2013-09-16 00:00:00','2013-09-16 00:00:00', 60, '2013-09-16 00:00:00','2013-09-16 00:00:00', '');
+insert into builds values (4, 2, 'node_0.10', 'Success', '2013-09-16 00:00:00','2013-09-16 00:00:00', 60, '2013-09-16 00:00:00','2013-09-16 00:00:00', '');
+insert into builds values (5, 2, 'node_0.90', 'Success', '2013-09-16 00:00:00','2013-09-16 00:00:00', 60, '2013-09-16 00:00:00','2013-09-16 00:00:00', '');
+insert into builds values (6, 2, 'node_0.80', 'Success', '2013-09-16 00:00:00','2013-09-16 00:00:00', 60, '2013-09-16 00:00:00','2013-09-16 00:00:00', '');
+insert into builds values (7, 3, 'node_0.10', 'Success', '2013-09-16 00:00:00','2013-09-16 00:00:00', 60, '2013-09-16 00:00:00','2013-09-16 00:00:00', '');
+insert into builds values (8, 3, 'node_0.90', 'Success', '2013-09-16 00:00:00','2013-09-16 00:00:00', 60, '2013-09-16 00:00:00','2013-09-16 00:00:00', '');
+insert into builds values (9, 3, 'node_0.80', 'Success', '2013-09-16 00:00:00','2013-09-16 00:00:00', 60, '2013-09-16 00:00:00','2013-09-16 00:00:00', '');
+
+-- insert default, dummy settings
+
+insert into settings values (1,'','','','','','','','','','localhost:8080','http');
+
+-- add public & private keys to all repositories
+
+update repos set public_key = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCybgl9+Y0VY0mKng3AB3CwCMAOVvg+Xh4X/4lP7SR815GaeEJQusaA0p33HkZfS/2XREWYMtiopHP0bZuBIht76JdhrJlHh1AcLoPQvWJROFvRGol6igVEVZzs9sUdZaPrexFz1CS/j6BJFzPsHnL4gXT3s4PYYST9++pThI90Aw==';
+
+update repos set private_key = '-----BEGIN RSA PRIVATE KEY-----
+MIICWwIBAAKBgQCybgl9+Y0VY0mKng3AB3CwCMAOVvg+Xh4X/4lP7SR815GaeEJQ
+usaA0p33HkZfS/2XREWYMtiopHP0bZuBIht76JdhrJlHh1AcLoPQvWJROFvRGol6
+igVEVZzs9sUdZaPrexFz1CS/j6BJFzPsHnL4gXT3s4PYYST9++pThI90AwIDAQAB
+AoGAaxvs7MdaLsWcRu7cGDMfLT0DdVg1ytKaxBMsrWMQrTSGfjDEtkt4j6pfExIE
+cn5ea2ibUmLrdkjKJqeJWrpLvlOZGhahBcL/SueFOfr6Lm+m8LvlTrX6JhyLXpx5
+NbeEFr0mN16PC6JqkN0xRCN9BfV9m6gnpuP/ojD3RKYMZtkCQQDFbSX/ddEfp9ME
+vRNAYif+bFxI6PEgMmwrCIjJGHOsq7zba3Z7KWjW034x2rJ3Cbhs8xtyTcA5qy9F
+OzL3pFs3AkEA514SUXowIiqjh6ypnSvUBaQZsWjexDxTXN09DTYPt+Ck1qdzTHWP
+9nerg2G3B6bTOWZBftHMaZ/plZ/eyV0LlQJACU1rTO4wPF2cA80k6xO07rgMYSMY
+uXumvSBZ0Z/lU22EKJKXspXw6q5sc8zqO9GpbvjFgk1HkXAPeiOf8ys7YQJAD1CI
+wd/mo7xSyr5BE+g8xorQMJASfsbHddQnIGK9s5wpDRRUa3E0sEnHjpC/PsBqJth/
+6VcVwsAVBBRq+MUx6QJAS9KKxKcMf8JpnDheV7jh+WJKckabA1L2bq8sN6kXfPn0
+o7deiE1FKJizXKJ6gd6anfuG3m7VAs7wJhzc685yMg==
+-----END RSA PRIVATE KEY-----';
+
+-- add standard output to all builds
+
+update builds set stdout = '$ mvn test
+-------------------------------------------------------
+ T E S T S
+-------------------------------------------------------
+Running brooklyn.qa.longevity.MonitorUtilsTest
+Configuring TestNG with: TestNG652Configurator
+[GC 69952K->6701K(253440K), 0.0505760 secs]
+2013-08-21 21:12:58,327 INFO TESTNG RUNNING: Suite: "Command line test" containing "7" Tests (config: null)
+2013-08-21 21:12:58,342 INFO BrooklynLeakListener.onStart attempting to terminate all extant ManagementContexts: name=Command line test; includedGroups=[]; excludedGroups=[Integration, Acceptance, Live, WIP]; suiteName=brooklyn.qa.longevity.MonitorUtilsTest; outDir=/scratch/jenkins/workspace/brooklyncentral/brooklyn/usage/qa/target/surefire-reports/brooklyn.qa.longevity.MonitorUtilsTest
+2013-08-21 21:12:58,473 INFO TESTNG INVOKING: "Command line test" - brooklyn.qa.longevity.MonitorUtilsTest.testFindOwnPid()
+2013-08-21 21:12:58,939 INFO executing cmd: ps -p 7484
+2013-08-21 21:12:59,030 INFO TESTNG PASSED: "Command line test" - brooklyn.qa.longevity.MonitorUtilsTest.testFindOwnPid() finished in 595 ms
+2013-08-21 21:12:59,033 INFO TESTNG INVOKING: "Command line test" - brooklyn.qa.longevity.MonitorUtilsTest.testGetRunningPids()
+2013-08-21 21:12:59,035 INFO executing cmd: ps ax
+2013-08-21 21:12:59,137 INFO TESTNG PASSED: "Command line test" - brooklyn.qa.longevity.MonitorUtilsTest.testGetRunningPids() finished in 104 ms
+2013-08-21 21:12:59,139 INFO TESTNG INVOKING: "Command line test" - brooklyn.qa.longevity.MonitorUtilsTest.testGroovyExecuteAndWaitForConsumingOutputStream()
+2013-08-21 21:12:59,295 INFO TESTNG PASSED: "Command line test" - brooklyn.qa.longevity.MonitorUtilsTest.testGroovyExecuteAndWaitForConsumingOutputStream() finished in 155 ms
+2013-08-21 21:12:59,298 INFO TESTNG INVOKING: "Command line test" - brooklyn.qa.longevity.MonitorUtilsTest.testIsPidRunning()
+2013-08-21 21:12:59,300 INFO executing cmd: ps ax
+2013-08-21 21:12:59,384 INFO executing cmd: ps -p 7484
+2013-08-21 21:12:59,391 INFO executing cmd: ps -p 10000
+2013-08-21 21:12:59,443 INFO pid 10000 not running:
+2013-08-21 21:12:59,446 INFO executing cmd: ps -p 1234567
+2013-08-21 21:12:59,455 INFO pid 1234567 not running:
+2013-08-21 21:12:59,456 INFO TESTNG PASSED: "Command line test" - brooklyn.qa.longevity.MonitorUtilsTest.testIsPidRunning() finished in 158 ms
+2013-08-21 21:12:59,481 INFO TESTNG INVOKING: "Command line test" - brooklyn.qa.longevity.MonitorUtilsTest.testIsUrlUp()
+[GC 76653K->7013K(253440K), 0.0729880 secs]
+2013-08-21 21:13:00,726 INFO Error reading URL http://localhost/thispathdoesnotexist: org.apache.http.conn.HttpHostConnectException: Connection to http://localhost refused
+2013-08-21 21:13:00,727 INFO TESTNG PASSED: "Command line test" - brooklyn.qa.longevity.MonitorUtilsTest.testIsUrlUp() finished in 1246 ms
+2013-08-21 21:13:00,760 INFO TESTNG INVOKING: "Command line test" - brooklyn.qa.longevity.MonitorUtilsTest.testMemoryUsage()
+2013-08-21 21:13:00,762 INFO executing cmd: jmap -histo 7484
+2013-08-21 21:13:02,275 INFO executing cmd: jmap -histo 7484
+2013-08-21 21:13:03,690 INFO executing cmd: jmap -histo 7484
+2013-08-21 21:13:04,725 INFO TESTNG PASSED: "Command line test" - brooklyn.qa.longevity.MonitorUtilsTest.testMemoryUsage() finished in 3965 ms
+2013-08-21 21:13:04,752 INFO TESTNG INVOKING: "Command line test" - brooklyn.qa.longevity.MonitorUtilsTest.testSearchLog()
+2013-08-21 21:13:04,816 INFO executing cmd: grep -E line1 /tmp/monitorUtilsTest.testSearchLog2369184699231420767.txt
+2013-08-21 21:13:04,848 INFO executing cmd: grep -E line1|line2 /tmp/monitorUtilsTest.testSearchLog2369184699231420767.txt
+2013-08-21 21:13:04,854 INFO executing cmd: grep -E textnotthere /tmp/monitorUtilsTest.testSearchLog2369184699231420767.txt
+2013-08-21 21:13:04,858 INFO executing cmd: grep -E line /tmp/monitorUtilsTest.testSearchLog2369184699231420767.txt
+2013-08-21 21:13:04,897 INFO TESTNG PASSED: "Command line test" - brooklyn.qa.longevity.MonitorUtilsTest.testSearchLog() finished in 145 ms
+2013-08-21 21:13:04,917 INFO TESTNG
+===============================================
+ Command line test
+ Tests run: 7, Failures: 0, Skips: 0
+===============================================
+2013-08-21 21:13:04,944 INFO BrooklynLeakListener.onFinish attempting to terminate all extant ManagementContexts: name=Command line test; includedGroups=[]; excludedGroups=[Integration, Acceptance, Live, WIP]; suiteName=brooklyn.qa.longevity.MonitorUtilsTest; outDir=/scratch/jenkins/workspace/brooklyncentral/brooklyn/usage/qa/target/surefire-reports/brooklyn.qa.longevity.MonitorUtilsTest
+Tests run: 7, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 10.849 sec
+
+Results :
+
+Tests run: 7, Failures: 0, Errors: 0, Skipped: 0';
\ No newline at end of file
diff --git a/pkg/database/schema/schema.go b/pkg/database/schema/schema.go
new file mode 100644
index 000000000..993db07e2
--- /dev/null
+++ b/pkg/database/schema/schema.go
@@ -0,0 +1,198 @@
+package schema
+
+import (
+ "database/sql"
+)
+
+// SQL statement to create the User Table.
+var userTableStmt = `
+CREATE TABLE users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT
+ ,email VARCHAR(255) UNIQUE
+ ,password VARCHAR(255)
+ ,token VARCHAR(255) UNIQUE
+ ,name VARCHAR(255)
+ ,gravatar VARCHAR(255)
+ ,created TIMESTAMP
+ ,updated TIMESTAMP
+ ,admin BOOLEAN
+ ,github_login VARCHAR(255)
+ ,github_token VARCHAR(255)
+ ,bitbucket_login VARCHAR(255)
+ ,bitbucket_token VARCHAR(255)
+ ,bitbucket_secret VARCHAR(255)
+);
+`
+
+// SQL statement to create the Team Table.
+var teamTableStmt = `
+CREATE TABLE teams (
+ id INTEGER PRIMARY KEY AUTOINCREMENT
+ ,slug VARCHAR(255) UNIQUE
+ ,name VARCHAR(255)
+ ,email VARCHAR(255)
+ ,gravatar VARCHAR(255)
+ ,created TIMESTAMP
+ ,updated TIMESTAMP
+);
+`
+
+// SQL statement to create the Member Table.
+var memberTableStmt = `
+CREATE TABLE members (
+ id INTEGER PRIMARY KEY AUTOINCREMENT
+ ,team_id INTEGER
+ ,user_id INTEGER
+ ,role INTEGER
+);
+`
+
+// SQL statement to create the Repo Table.
+var repoTableStmt = `
+CREATE TABLE repos (
+ id INTEGER PRIMARY KEY AUTOINCREMENT
+ ,slug VARCHAR(1024) UNIQUE
+ ,host VARCHAR(255)
+ ,owner VARCHAR(255)
+ ,name VARCHAR(255)
+ ,private BOOLEAN
+ ,disabled BOOLEAN
+ ,disabled_pr BOOLEAN
+ ,priveleged BOOLEAN
+ ,timeout INTEGER
+ ,scm VARCHAR(25)
+ ,url VARCHAR(1024)
+ ,username VARCHAR(255)
+ ,password VARCHAR(255)
+ ,public_key VARCHAR(1024)
+ ,private_key VARCHAR(1024)
+ ,params VARCHAR(2000)
+ ,created TIMESTAMP
+ ,updated TIMESTAMP
+ ,user_id INTEGER
+ ,team_id INTEGER
+);
+`
+
+// SQL statement to create the Commit Table.
+var commitTableStmt = `
+CREATE TABLE commits (
+ id INTEGER PRIMARY KEY AUTOINCREMENT
+ ,repo_id INTEGER
+ ,status VARCHAR(255)
+ ,started TIMESTAMP
+ ,finished TIMESTAMP
+ ,duration INTEGER
+ ,attempts INTEGER
+ ,hash VARCHAR(255)
+ ,branch VARCHAR(255)
+ ,pull_request VARCHAR(255)
+ ,author VARCHAR(255)
+ ,gravatar VARCHAR(255)
+ ,timestamp VARCHAR(255)
+ ,message VARCHAR(255)
+ ,created TIMESTAMP
+ ,updated TIMESTAMP
+);
+`
+
+// SQL statement to create the Build Table.
+var buildTableStmt = `
+CREATE TABLE builds (
+ id INTEGER PRIMARY KEY AUTOINCREMENT
+ ,commit_id INTEGER
+ ,slug VARCHAR(255)
+ ,status VARCHAR(255)
+ ,started TIMESTAMP
+ ,finished TIMESTAMP
+ ,duration INTEGER
+ ,created TIMESTAMP
+ ,updated TIMESTAMP
+ ,stdout BLOB
+);
+`
+
+// SQL statement to create the Settings
+var settingsTableStmt = `
+CREATE TABLE settings (
+ id INTEGER PRIMARY KEY
+ ,github_key VARCHAR(255)
+ ,github_secret VARCHAR(255)
+ ,bitbucket_key VARCHAR(255)
+ ,bitbucket_secret VARCHAR(255)
+ ,smtp_server VARCHAR(1024)
+ ,smtp_port VARCHAR(5)
+ ,smtp_address VARCHAR(1024)
+ ,smtp_username VARCHAR(1024)
+ ,smtp_password VARCHAR(1024)
+ ,hostname VARCHAR(1024)
+ ,scheme VARCHAR(5)
+);
+`
+
+var memberUniqueIndex = `
+CREATE UNIQUE INDEX member_uix ON members (team_id, user_id);
+`
+
+var memberTeamIndex = `
+CREATE INDEX member_team_ix ON members (team_id);
+`
+
+var memberUserIndex = `
+CREATE INDEX member_user_ix ON members (user_id);
+`
+
+var commitUniqueIndex = `
+CREATE UNIQUE INDEX commits_uix ON commits (repo_id, hash, branch);
+`
+
+var commitRepoIndex = `
+CREATE INDEX commits_repo_ix ON commits (repo_id);
+`
+
+var commitBranchIndex = `
+CREATE INDEX commits_repo_ix ON commits (repo_id, branch);
+`
+
+var repoTeamIndex = `
+CREATE INDEX repo_team_ix ON repos (team_id);
+`
+
+var repoUserIndex = `
+CREATE INDEX repo_user_ix ON repos (user_id);
+`
+
+var buildCommitIndex = `
+CREATE INDEX builds_commit_ix ON builds (commit_id);
+`
+
+var buildSlugIndex = `
+CREATE INDEX builds_commit_slug_ix ON builds (commit_id, slug);
+`
+
+// Load will apply the DDL commands to
+// the provided database.
+func Load(db *sql.DB) error {
+
+ // created tables
+ db.Exec(userTableStmt)
+ db.Exec(teamTableStmt)
+ db.Exec(memberTableStmt)
+ db.Exec(repoTableStmt)
+ db.Exec(commitTableStmt)
+ db.Exec(buildTableStmt)
+ db.Exec(settingsTableStmt)
+
+ db.Exec(memberUniqueIndex)
+ db.Exec(memberTeamIndex)
+ db.Exec(memberUserIndex)
+ db.Exec(commitUniqueIndex)
+ db.Exec(commitRepoIndex)
+ db.Exec(commitBranchIndex)
+ db.Exec(repoTeamIndex)
+ db.Exec(repoUserIndex)
+ db.Exec(buildCommitIndex)
+ db.Exec(buildSlugIndex)
+
+ return nil
+}
diff --git a/pkg/database/schema/schema.sql b/pkg/database/schema/schema.sql
new file mode 100644
index 000000000..950458508
--- /dev/null
+++ b/pkg/database/schema/schema.sql
@@ -0,0 +1,127 @@
+DROP TABLE IF EXISTS builds;
+DROP TABLE IF EXISTS commits;
+DROP TABLE IF EXISTS repos;
+DROP TABLE IF EXISTS members;
+DROP TABLE IF EXISTS teams;
+DROP TABLE IF EXISTS users;
+DROP TABLE IF EXISTS settings;
+
+CREATE TABLE users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT
+ ,email VARCHAR(255) UNIQUE
+ ,password VARCHAR(255)
+ ,token VARCHAR(255) UNIQUE
+ ,name VARCHAR(255)
+ ,gravatar VARCHAR(255)
+ ,created TIMESTAMP
+ ,updated TIMESTAMP
+ ,admin BOOLEAN
+
+ ,github_login VARCHAR(255)
+ ,github_token VARCHAR(255)
+
+ ,bitbucket_login VARCHAR(255)
+ ,bitbucket_token VARCHAR(255)
+ ,bitbucket_secret VARCHAR(255)
+);
+
+CREATE TABLE teams (
+ id INTEGER PRIMARY KEY AUTOINCREMENT
+ ,slug VARCHAR(255) UNIQUE
+ ,name VARCHAR(255) UNIQUE
+ ,email VARCHAR(255)
+ ,gravatar VARCHAR(255)
+ ,created TIMESTAMP
+ ,updated TIMESTAMP
+);
+
+CREATE TABLE members (
+ id INTEGER PRIMARY KEY AUTOINCREMENT
+ ,team_id INTEGER
+ ,user_id INTEGER
+ ,role INTEGER
+);
+
+CREATE TABLE repos (
+ id INTEGER PRIMARY KEY AUTOINCREMENT
+ ,slug VARCHAR(1024) UNIQUE
+ ,host VARCHAR(255)
+ ,owner VARCHAR(255)
+ ,name VARCHAR(255)
+ ,private BOOLEAN
+ ,disabled BOOLEAN
+ ,disabled_pr BOOLEAN
+ ,priveleged BOOLEAN
+ ,timeout INTEGER
+
+ ,scm VARCHAR(25)
+ ,url VARCHAR(1024)
+ ,username VARCHAR(255)
+ ,password VARCHAR(255)
+ ,public_key VARCHAR(1024)
+ ,private_key VARCHAR(1024)
+ ,params VARCHAR(2000)
+
+ ,created TIMESTAMP
+ ,updated TIMESTAMP
+ ,user_id INTEGER
+ ,team_id INTEGER
+);
+
+CREATE TABLE commits (
+ id INTEGER PRIMARY KEY AUTOINCREMENT
+ ,repo_id INTEGER
+ ,status VARCHAR(255)
+ ,started TIMESTAMP
+ ,finished TIMESTAMP
+ ,duration INTEGER
+ ,hash VARCHAR(255)
+ ,branch VARCHAR(255)
+ ,pull_request VARCHAR(255)
+ ,author VARCHAR(255)
+ ,gravatar VARCHAR(255)
+ ,timestamp VARCHAR(255)
+ ,message VARCHAR(255)
+ ,created TIMESTAMP
+ ,updated TIMESTAMP
+);
+
+CREATE TABLE builds (
+ id INTEGER PRIMARY KEY AUTOINCREMENT
+ ,commit_id INTEGER
+ ,slug VARCHAR(255)
+ ,status VARCHAR(255)
+ ,started TIMESTAMP
+ ,finished TIMESTAMP
+ ,duration INTEGER
+ ,created TIMESTAMP
+ ,updated TIMESTAMP
+ ,stdout BLOB
+);
+
+CREATE TABLE settings (
+ id INTEGER PRIMARY KEY
+ ,github_key VARCHAR(255)
+ ,github_secret VARCHAR(255)
+ ,bitbucket_key VARCHAR(255)
+ ,bitbucket_secret VARCHAR(255)
+ ,smtp_server VARCHAR(1024)
+ ,smtp_port VARCHAR(5)
+ ,smtp_address VARCHAR(1024)
+ ,smtp_username VARCHAR(1024)
+ ,smtp_password VARCHAR(1024)
+ ,hostname VARCHAR(1024)
+ ,scheme VARCHAR(5)
+);
+
+CREATE UNIQUE INDEX member_uix ON members (team_id, user_id);
+CREATE UNIQUE INDEX commits_uix ON commits (repo_id, hash, branch);
+
+CREATE INDEX member_team_ix ON members (team_id);
+CREATE INDEX member_user_ix ON members (user_id);
+CREATE INDEX repo_team_ix ON repos (team_id);
+CREATE INDEX repo_user_ix ON repos (user_id);
+CREATE INDEX commits_repo_ix ON commits (repo_id);
+CREATE INDEX commits_repo_branch_ix ON commits (repo_id, branch);
+CREATE INDEX builds_commit_ix ON builds (commit_id);
+CREATE INDEX builds_commit_slug_ix ON builds (commit_id, slug);
diff --git a/pkg/database/settings.go b/pkg/database/settings.go
new file mode 100644
index 000000000..0e210c991
--- /dev/null
+++ b/pkg/database/settings.go
@@ -0,0 +1,72 @@
+package database
+
+import (
+ . "github.com/drone/drone/pkg/model"
+ "github.com/russross/meddler"
+)
+
+// Name of the Settings table in the database
+const settingsTable = "settings"
+
+// SQL Queries to retrieve the system settings
+const settingsStmt = `
+SELECT id, github_key, github_secret, bitbucket_key, bitbucket_secret,
+smtp_server, smtp_port, smtp_address, smtp_username, smtp_password, hostname, scheme
+FROM settings WHERE id = 1
+`
+
+//var (
+// // mutex for locking the local settings cache
+// settingsLock sync.Mutex
+//
+// // cached settings
+// settingsCache = &Settings{}
+//)
+
+// Returns the system Settings.
+func GetSettings() (*Settings, error) {
+ //settingsLock.Lock()
+ //defer settingsLock.Unlock()
+
+ // return a copy of the settings
+ //if settingsCache.ID == 0 {
+ /// settingsCopy := &Settings{}
+ // *settingsCopy = *settingsCache
+ // return settingsCopy, nil
+ //}
+
+ settings := Settings{}
+ err := meddler.QueryRow(db, &settings, settingsStmt)
+ //if err == sql.ErrNoRows {
+ // // we ignore the NoRows error in case this
+ // // is the first time the system is being used
+ // err = nil
+ //}
+ return &settings, err
+}
+
+// Returns the system Settings. This is expected
+// always pass, and will panic on failure.
+func SettingsMust() *Settings {
+ settings, err := GetSettings()
+ if err != nil {
+ panic(err)
+ }
+ return settings
+}
+
+// Saves the system Settings.
+func SaveSettings(settings *Settings) error {
+ //settingsLock.Lock()
+ //defer settingsLock.Unlock()
+
+ // persist changes to settings
+ err := meddler.Save(db, settingsTable, settings)
+ if err != nil {
+ return err
+ }
+
+ // store updated settings in cache
+ //*settingsCache = *settings
+ return nil
+}
diff --git a/pkg/database/teams.go b/pkg/database/teams.go
new file mode 100644
index 000000000..9ee479bbd
--- /dev/null
+++ b/pkg/database/teams.go
@@ -0,0 +1,73 @@
+package database
+
+import (
+ "time"
+
+ . "github.com/drone/drone/pkg/model"
+ "github.com/russross/meddler"
+)
+
+// Name of the Team table in the database
+const teamTable = "teams"
+
+// SQL Queries to retrieve a list of all teams belonging to a user.
+const teamStmt = `
+SELECT id, slug, name, email, gravatar, created, updated
+FROM teams
+WHERE id IN (select team_id from members where user_id = ?)
+`
+
+// SQL Queries to retrieve a team by id and user.
+const teamFindStmt = `
+SELECT id, slug, name, email, gravatar, created, updated
+FROM teams
+WHERE id = ?
+`
+
+// SQL Queries to retrieve a team by slug.
+const teamFindSlugStmt = `
+SELECT id, slug, name, email, gravatar, created, updated
+FROM teams
+WHERE slug = ?
+`
+
+// Returns the Team with the given ID.
+func GetTeam(id int64) (*Team, error) {
+ team := Team{}
+ err := meddler.QueryRow(db, &team, teamFindStmt, id)
+ return &team, err
+}
+
+// Returns the Team with the given slug.
+func GetTeamSlug(slug string) (*Team, error) {
+ team := Team{}
+ err := meddler.QueryRow(db, &team, teamFindSlugStmt, slug)
+ return &team, err
+}
+
+// Saves a Team.
+func SaveTeam(team *Team) error {
+ if team.ID == 0 {
+ team.Created = time.Now().UTC()
+ }
+ team.Updated = time.Now().UTC()
+ return meddler.Save(db, teamTable, team)
+}
+
+// Deletes an existing Team account.
+func DeleteTeam(id int64) error {
+ // disassociate all repos with this team
+ db.Exec("UPDATE repos SET team_id = 0 WHERE team_id = ?", id)
+ // delete the team memberships and the team itself
+ db.Exec("DELETE FROM members WHERE team_id = ?", id)
+ db.Exec("DELETE FROM teams WHERE id = ?", id)
+ return nil
+}
+
+// Returns a list of all Teams associated
+// with the specified User ID.
+func ListTeams(id int64) ([]*Team, error) {
+ var teams []*Team
+ err := meddler.QueryAll(db, &teams, teamStmt, id)
+ return teams, err
+}
diff --git a/pkg/database/testing/builds_test.go b/pkg/database/testing/builds_test.go
new file mode 100644
index 000000000..3dd6bb4db
--- /dev/null
+++ b/pkg/database/testing/builds_test.go
@@ -0,0 +1,136 @@
+package database
+
+import (
+ "testing"
+
+ "github.com/drone/drone/pkg/database"
+)
+
+func TestGetBuild(t *testing.T) {
+ Setup()
+ defer Teardown()
+
+ build, err := database.GetBuild(1)
+ if err != nil {
+ t.Error(err)
+ }
+
+ if build.ID != 1 {
+ t.Errorf("Exepected ID %d, got %d", 1, build.ID)
+ }
+
+ if build.Slug != "node_0.10" {
+ t.Errorf("Exepected Slug %s, got %s", "node_0.10", build.Slug)
+ }
+
+ if build.Status != "Success" {
+ t.Errorf("Exepected Status %s, got %s", "Success", build.Status)
+ }
+}
+
+func TestGetBuildSlug(t *testing.T) {
+ Setup()
+ defer Teardown()
+
+ build, err := database.GetBuildSlug("node_0.10", 1)
+ if err != nil {
+ t.Error(err)
+ }
+
+ if build.ID != 1 {
+ t.Errorf("Exepected ID %d, got %d", 1, build.ID)
+ }
+
+ if build.Slug != "node_0.10" {
+ t.Errorf("Exepected Slug %s, got %s", "node_0.10", build.Slug)
+ }
+
+ if build.Status != "Success" {
+ t.Errorf("Exepected Status %s, got %s", "Success", build.Status)
+ }
+}
+
+func TestSaveBbuild(t *testing.T) {
+ Setup()
+ defer Teardown()
+
+ // get the build we plan to update
+ build, err := database.GetBuild(1)
+ if err != nil {
+ t.Error(err)
+ }
+
+ // update fields
+ build.Status = "Failing"
+
+ // update the database
+ if err := database.SaveBuild(build); err != nil {
+ t.Error(err)
+ }
+
+ // get the updated build
+ updatedBuild, err := database.GetBuild(1)
+ if err != nil {
+ t.Error(err)
+ }
+
+ if build.ID != updatedBuild.ID {
+ t.Errorf("Exepected ID %d, got %d", updatedBuild.ID, build.ID)
+ }
+
+ if build.Slug != updatedBuild.Slug {
+ t.Errorf("Exepected Slug %s, got %s", updatedBuild.Slug, build.Slug)
+ }
+
+ if build.Status != updatedBuild.Status {
+ t.Errorf("Exepected Status %s, got %s", updatedBuild.Status, build.Status)
+ }
+}
+
+func TestDeleteBuild(t *testing.T) {
+ Setup()
+ defer Teardown()
+
+ if err := database.DeleteBuild(1); err != nil {
+ t.Error(err)
+ }
+
+ // try to get the deleted row
+ _, err := database.GetBuild(1)
+ if err == nil {
+ t.Fail()
+ }
+}
+
+func TestListBuilds(t *testing.T) {
+ Setup()
+ defer Teardown()
+
+ // builds for commit_id = 1
+ builds, err := database.ListBuilds(1)
+ if err != nil {
+ t.Error(err)
+ }
+
+ // verify user count
+ if len(builds) != 2 {
+ t.Errorf("Exepected %d builds in database, got %d", 2, len(builds))
+ return
+ }
+
+ // get the first user in the list and verify
+ // fields are being populated correctly
+ build := builds[1]
+
+ if build.ID != 1 {
+ t.Errorf("Exepected ID %d, got %d", 1, build.ID)
+ }
+
+ if build.Slug != "node_0.10" {
+ t.Errorf("Exepected Slug %s, got %s", "node_0.10", build.Slug)
+ }
+
+ if build.Status != "Success" {
+ t.Errorf("Exepected Status %s, got %s", "Success", build.Status)
+ }
+}
diff --git a/pkg/database/testing/commits_test.go b/pkg/database/testing/commits_test.go
new file mode 100644
index 000000000..1f6b8b9de
--- /dev/null
+++ b/pkg/database/testing/commits_test.go
@@ -0,0 +1,164 @@
+package database
+
+import (
+ "testing"
+
+ "github.com/drone/drone/pkg/database"
+)
+
+func TestGetCommit(t *testing.T) {
+ Setup()
+ defer Teardown()
+
+ commit, err := database.GetCommit(1)
+ if err != nil {
+ t.Error(err)
+ }
+
+ if commit.ID != 1 {
+ t.Errorf("Exepected ID %d, got %d", 1, commit.ID)
+ }
+
+ if commit.Status != "Success" {
+ t.Errorf("Exepected Status %s, got %s", "Success", commit.Status)
+ }
+
+ if commit.Hash != "4f4c4594be6d6ddbc1c0dd521334f7ecba92b608" {
+ t.Errorf("Exepected Hash %s, got %s", "4f4c4594be6d6ddbc1c0dd521334f7ecba92b608", commit.Hash)
+ }
+
+ if commit.Branch != "master" {
+ t.Errorf("Exepected Branch %s, got %s", "master", commit.Branch)
+ }
+
+ if commit.Author != "brad.rydzewski@gmail.com" {
+ t.Errorf("Exepected Author %s, got %s", "master", commit.Author)
+ }
+
+ if commit.Message != "commit message" {
+ t.Errorf("Exepected Message %s, got %s", "master", commit.Message)
+ }
+
+ if commit.Gravatar != "8c58a0be77ee441bb8f8595b7f1b4e87" {
+ t.Errorf("Exepected Gravatar %s, got %s", "8c58a0be77ee441bb8f8595b7f1b4e87", commit.Gravatar)
+ }
+}
+
+func TestGetCommitHash(t *testing.T) {
+ Setup()
+ defer Teardown()
+
+ commit, err := database.GetCommitHash("4f4c4594be6d6ddbc1c0dd521334f7ecba92b608", 1)
+ if err != nil {
+ t.Error(err)
+ }
+
+ if commit.ID != 1 {
+ t.Errorf("Exepected ID %d, got %d", 1, commit.ID)
+ }
+
+ if commit.Hash != "4f4c4594be6d6ddbc1c0dd521334f7ecba92b608" {
+ t.Errorf("Exepected Hash %s, got %s", "4f4c4594be6d6ddbc1c0dd521334f7ecba92b608", commit.Hash)
+ }
+
+ if commit.Status != "Success" {
+ t.Errorf("Exepected Status %s, got %s", "Success", commit.Status)
+ }
+}
+
+func TestSaveCommit(t *testing.T) {
+ Setup()
+ defer Teardown()
+
+ // get the commit we plan to update
+ commit, err := database.GetCommit(1)
+ if err != nil {
+ t.Error(err)
+ }
+
+ // update fields
+ commit.Status = "Failing"
+
+ // update the database
+ if err := database.SaveCommit(commit); err != nil {
+ t.Error(err)
+ }
+
+ // get the updated commit
+ updatedCommit, err := database.GetCommit(1)
+ if err != nil {
+ t.Error(err)
+ }
+
+ if commit.Hash != updatedCommit.Hash {
+ t.Errorf("Exepected Hash %s, got %s", updatedCommit.Hash, commit.Hash)
+ }
+
+ if commit.Status != "Failing" {
+ t.Errorf("Exepected Status %s, got %s", updatedCommit.Status, commit.Status)
+ }
+}
+
+func TestDeleteCommit(t *testing.T) {
+ Setup()
+ defer Teardown()
+
+ if err := database.DeleteCommit(1); err != nil {
+ t.Error(err)
+ }
+
+ // try to get the deleted row
+ _, err := database.GetCommit(1)
+ if err == nil {
+ t.Fail()
+ }
+}
+
+func TestListCommits(t *testing.T) {
+ Setup()
+ defer Teardown()
+
+ // commits for repo_id = 1
+ commits, err := database.ListCommits(1, "master")
+ if err != nil {
+ t.Error(err)
+ }
+
+ // verify commit count
+ if len(commits) != 2 {
+ t.Errorf("Exepected %d commits in database, got %d", 2, len(commits))
+ return
+ }
+
+ // get the first user in the list and verify
+ // fields are being populated correctly
+ commit := commits[1] // TODO something strange is happening with ordering here
+
+ if commit.ID != 1 {
+ t.Errorf("Exepected ID %d, got %d", 1, commit.ID)
+ }
+
+ if commit.Status != "Success" {
+ t.Errorf("Exepected Status %s, got %s", "Success", commit.Status)
+ }
+
+ if commit.Hash != "4f4c4594be6d6ddbc1c0dd521334f7ecba92b608" {
+ t.Errorf("Exepected Hash %s, got %s", "4f4c4594be6d6ddbc1c0dd521334f7ecba92b608", commit.Hash)
+ }
+
+ if commit.Branch != "master" {
+ t.Errorf("Exepected Branch %s, got %s", "master", commit.Branch)
+ }
+
+ if commit.Author != "brad.rydzewski@gmail.com" {
+ t.Errorf("Exepected Author %s, got %s", "master", commit.Author)
+ }
+
+ if commit.Message != "commit message" {
+ t.Errorf("Exepected Message %s, got %s", "master", commit.Message)
+ }
+
+ if commit.Gravatar != "8c58a0be77ee441bb8f8595b7f1b4e87" {
+ t.Errorf("Exepected Gravatar %s, got %s", "8c58a0be77ee441bb8f8595b7f1b4e87", commit.Gravatar)
+ }
+}
diff --git a/pkg/database/testing/members_test.go b/pkg/database/testing/members_test.go
new file mode 100644
index 000000000..aeebcf98a
--- /dev/null
+++ b/pkg/database/testing/members_test.go
@@ -0,0 +1,140 @@
+package database
+
+import (
+ "testing"
+
+ "github.com/drone/drone/pkg/database"
+ "github.com/drone/drone/pkg/model"
+)
+
+// TODO unit test to verify unique constraint on Team.Name
+
+// TestGetMember tests the ability to retrieve a Team
+// Member from the database by Unique ID.
+func TestGetMember(t *testing.T) {
+ Setup()
+ defer Teardown()
+
+ // get member by user_id and team_id
+ member, err := database.GetMember(1, 1)
+ if err != nil {
+ t.Error(err)
+ }
+
+ if member.ID != 1 {
+ t.Errorf("Exepected ID %d, got %d", 1, member.ID)
+ }
+
+ if member.Name != "Brad Rydzewski" {
+ t.Errorf("Exepected Name %s, got %s", "Brad Rydzewski", member.Name)
+ }
+
+ if member.Email != "brad.rydzewski@gmail.com" {
+ t.Errorf("Exepected Email %s, got %s", "brad.rydzewski@gmail.com", member.Email)
+ }
+
+ if member.Gravatar != "8c58a0be77ee441bb8f8595b7f1b4e87" {
+ t.Errorf("Exepected Gravatar %s, got %s", "8c58a0be77ee441bb8f8595b7f1b4e87", member.Gravatar)
+ }
+
+ if member.Role != model.RoleOwner {
+ t.Errorf("Exepected Role %s, got %s", model.RoleOwner, member.Role)
+ }
+}
+
+func TestIsMember(t *testing.T) {
+ Setup()
+ defer Teardown()
+
+ ok, err := database.IsMember(1, 1)
+ if err != nil {
+ t.Error(err)
+ }
+
+ if !ok {
+ t.Errorf("Expected IsMember to return true, returned false")
+ }
+}
+
+func TestIsMemberAdmin(t *testing.T) {
+ Setup()
+ defer Teardown()
+
+ // expecting user is Owner
+ if ok, err := database.IsMemberAdmin(1, 1); err != nil {
+ t.Error(err)
+ } else if !ok {
+ t.Errorf("Expected IsMemberAdmin to return true, returned false")
+ }
+
+ // expecting user is Admin
+ if ok, err := database.IsMemberAdmin(2, 1); err != nil {
+ t.Error(err)
+ } else if !ok {
+ t.Errorf("Expected IsMemberAdmin to return true, returned false")
+ }
+
+ // expecting user is NOT Admin (Write role)
+ if ok, err := database.IsMemberAdmin(3, 1); err != nil {
+ t.Error(err)
+ } else if ok {
+ t.Errorf("Expected IsMemberAdmin to return false, returned true")
+ }
+}
+
+func TestDeleteMember(t *testing.T) {
+ Setup()
+ defer Teardown()
+
+ // delete member by user_id and team_id
+ if err := database.DeleteMember(1, 1); err != nil {
+ t.Error(err)
+ }
+
+ // get member by user_id and team_id
+ if _, err := database.GetMember(1, 1); err == nil {
+ t.Error(err)
+ }
+
+}
+
+func TestListMembers(t *testing.T) {
+ Setup()
+ defer Teardown()
+
+ // list members by team_id
+ members, err := database.ListMembers(1)
+ if err != nil {
+ t.Error(err)
+ }
+
+ // verify team count
+ if len(members) != 3 {
+ t.Errorf("Exepected %d Team Members in database, got %d", 3, len(members))
+ return
+ }
+
+ // get the first member in the list and verify
+ // fields are being populated correctly
+ member := members[0]
+
+ if member.ID != 1 {
+ t.Errorf("Exepected ID %d, got %d", 1, member.ID)
+ }
+
+ if member.Name != "Brad Rydzewski" {
+ t.Errorf("Exepected Name %s, got %s", "Brad Rydzewski", member.Name)
+ }
+
+ if member.Email != "brad.rydzewski@gmail.com" {
+ t.Errorf("Exepected Email %s, got %s", "brad.rydzewski@gmail.com", member.Email)
+ }
+
+ if member.Gravatar != "8c58a0be77ee441bb8f8595b7f1b4e87" {
+ t.Errorf("Exepected Gravatar %s, got %s", "8c58a0be77ee441bb8f8595b7f1b4e87", member.Gravatar)
+ }
+
+ if member.Role != model.RoleOwner {
+ t.Errorf("Exepected Role %s, got %s", model.RoleOwner, member.Role)
+ }
+}
diff --git a/pkg/database/testing/repos_test.go b/pkg/database/testing/repos_test.go
new file mode 100644
index 000000000..a6420184b
--- /dev/null
+++ b/pkg/database/testing/repos_test.go
@@ -0,0 +1,403 @@
+package database
+
+import (
+ "testing"
+
+ "github.com/drone/drone/pkg/database"
+)
+
+// TODO unit test to verify unique constraint on Member.UserID and Member.TeamID
+
+// TestGetRepo tests the ability to retrieve a Repo
+// from the database by Unique ID.
+func TestGetRepo(t *testing.T) {
+ Setup()
+ defer Teardown()
+
+ repo, err := database.GetRepo(1)
+ if err != nil {
+ t.Error(err)
+ }
+
+ if repo.ID != 1 {
+ t.Errorf("Exepected ID %d, got %d", 1, repo.ID)
+ }
+
+ if repo.Slug != "github.com/drone/drone" {
+ t.Errorf("Exepected Slug %s, got %s", "github.com/drone/drone", repo.Slug)
+ }
+
+ if repo.Host != "github.com" {
+ t.Errorf("Exepected Host %s, got %s", "github.com", repo.Host)
+ }
+
+ if repo.Owner != "drone" {
+ t.Errorf("Exepected Owner %s, got %s", "drone", repo.Owner)
+ }
+
+ if repo.Name != "drone" {
+ t.Errorf("Exepected Name %s, got %s", "drone", repo.Name)
+ }
+
+ if repo.Private != true {
+ t.Errorf("Exepected Private %v, got %v", true, repo.Private)
+ }
+
+ if repo.Disabled != false {
+ t.Errorf("Exepected Private %v, got %v", false, repo.Disabled)
+ }
+
+ if repo.SCM != "git" {
+ t.Errorf("Exepected Type %s, got %s", "git", repo.SCM)
+ }
+
+ if repo.URL != "git@github.com:drone/drone.git" {
+ t.Errorf("Exepected URL %s, got %s", "git@github.com:drone/drone.git", repo.URL)
+ }
+
+ if repo.Username != "no username" {
+ t.Errorf("Exepected Username %s, got %s", "no username", repo.Username)
+ }
+
+ if repo.Password != "no password" {
+ t.Errorf("Exepected Password %s, got %s", "no password", repo.Password)
+ }
+
+ if repo.PublicKey != "public key" {
+ t.Errorf("Exepected PublicKey %s, got %s", "public key", repo.PublicKey)
+ }
+
+ if repo.PrivateKey != "private key" {
+ t.Errorf("Exepected PrivateKey %s, got %s", "private key", repo.PrivateKey)
+ }
+
+ if repo.UserID != 1 {
+ t.Errorf("Exepected ID %d, got %d", 1, repo.UserID)
+ }
+
+ if repo.TeamID != 1 {
+ t.Errorf("Exepected ID %d, got %d", 1, repo.TeamID)
+ }
+}
+
+// TestGetRepoSlug tests the ability to retrieve a Repo
+// from the database by it's Canonical Name.
+func TestGetRepoSlug(t *testing.T) {
+ Setup()
+ defer Teardown()
+
+ repo, err := database.GetRepoSlug("github.com/drone/drone")
+ if err != nil {
+ t.Error(err)
+ }
+
+ if repo.ID != 1 {
+ t.Errorf("Exepected ID %d, got %d", 1, repo.ID)
+ }
+
+ if repo.Slug != "github.com/drone/drone" {
+ t.Errorf("Exepected Slug %s, got %s", "github.com/drone/drone", repo.Slug)
+ }
+
+ if repo.Host != "github.com" {
+ t.Errorf("Exepected Host %s, got %s", "github.com", repo.Host)
+ }
+
+ if repo.Owner != "drone" {
+ t.Errorf("Exepected Owner %s, got %s", "drone", repo.Owner)
+ }
+
+ if repo.Name != "drone" {
+ t.Errorf("Exepected Name %s, got %s", "drone", repo.Name)
+ }
+
+ if repo.Private != true {
+ t.Errorf("Exepected Private %v, got %v", true, repo.Private)
+ }
+
+ if repo.Disabled != false {
+ t.Errorf("Exepected Private %v, got %v", false, repo.Disabled)
+ }
+
+ if repo.SCM != "git" {
+ t.Errorf("Exepected Type %s, got %s", "git", repo.SCM)
+ }
+
+ if repo.URL != "git@github.com:drone/drone.git" {
+ t.Errorf("Exepected URL %s, got %s", "git@github.com:drone/drone.git", repo.URL)
+ }
+
+ if repo.Username != "no username" {
+ t.Errorf("Exepected Username %s, got %s", "no username", repo.Username)
+ }
+
+ if repo.Password != "no password" {
+ t.Errorf("Exepected Password %s, got %s", "no password", repo.Password)
+ }
+
+ if repo.PublicKey != "public key" {
+ t.Errorf("Exepected PublicKey %s, got %s", "public key", repo.PublicKey)
+ }
+
+ if repo.PrivateKey != "private key" {
+ t.Errorf("Exepected PrivateKey %s, got %s", "private key", repo.PrivateKey)
+ }
+
+ if repo.UserID != 1 {
+ t.Errorf("Exepected ID %d, got %d", 1, repo.UserID)
+ }
+
+ if repo.TeamID != 1 {
+ t.Errorf("Exepected ID %d, got %d", 1, repo.TeamID)
+ }
+}
+
+func TestSaveRepo(t *testing.T) {
+ Setup()
+ defer Teardown()
+
+ // get the repo we plan to update
+ repo, err := database.GetRepo(1)
+ if err != nil {
+ t.Error(err)
+ }
+
+ // update fields
+ repo.Slug = "bitbucket.org/drone/drone"
+ repo.Host = "bitbucket.org"
+ repo.Private = false
+ repo.Disabled = true
+ repo.SCM = "hg"
+ repo.URL = "https://bitbucket.org/drone/drone"
+ repo.Username = "brad"
+ repo.Password = "password"
+ repo.TeamID = 0
+
+ // update the database
+ if err := database.SaveRepo(repo); err != nil {
+ t.Error(err)
+ }
+
+ // get the updated repo
+ updatedRepo, err := database.GetRepo(1)
+ if err != nil {
+ t.Error(err)
+ }
+
+ if updatedRepo.Slug != repo.Slug {
+ t.Errorf("Exepected Slug %s, got %s", updatedRepo.Slug, repo.Slug)
+ }
+
+ if updatedRepo.Host != repo.Host {
+ t.Errorf("Exepected Host %s, got %s", updatedRepo.Host, repo.Host)
+ }
+
+ if updatedRepo.Private != repo.Private {
+ t.Errorf("Exepected Private %v, got %v", updatedRepo.Private, repo.Private)
+ }
+
+ if updatedRepo.Disabled != repo.Disabled {
+ t.Errorf("Exepected Private %v, got %v", updatedRepo.Disabled, repo.Disabled)
+ }
+
+ if updatedRepo.SCM != repo.SCM {
+ t.Errorf("Exepected Type %s, got %s", true, repo.SCM)
+ }
+
+ if updatedRepo.URL != repo.URL {
+ t.Errorf("Exepected URL %s, got %s", updatedRepo.URL, repo.URL)
+ }
+
+ if updatedRepo.Username != repo.Username {
+ t.Errorf("Exepected Username %s, got %s", updatedRepo.Username, repo.Username)
+ }
+
+ if updatedRepo.Password != repo.Password {
+ t.Errorf("Exepected Password %s, got %s", updatedRepo.Password, repo.Password)
+ }
+
+ if updatedRepo.TeamID != repo.TeamID {
+ t.Errorf("Exepected TeamID %d, got %d", updatedRepo.TeamID, repo.TeamID)
+ }
+}
+
+func TestDeleteRepo(t *testing.T) {
+ Setup()
+ defer Teardown()
+
+ if err := database.DeleteRepo(1); err != nil {
+ t.Error(err)
+ }
+
+ // try to get the deleted row
+ _, err := database.GetRepo(1)
+ if err == nil {
+ t.Fail()
+ }
+}
+
+/*
+func TestListRepos(t *testing.T) {
+ Setup()
+ defer Teardown()
+
+ // repos for user_id = 1
+ repos, err := database.ListRepos(1)
+ if err != nil {
+ t.Error(err)
+ }
+
+ // verify user count
+ if len(repos) != 2 {
+ t.Errorf("Exepected %d repos in database, got %d", 2, len(repos))
+ return
+ }
+
+ // get the second repo in the list and verify
+ // fields are being populated correctly
+ // NOTE: we get the 2nd repo due to sorting
+ repo := repos[1]
+
+ if repo.ID != 1 {
+ t.Errorf("Exepected ID %d, got %d", 1, repo.ID)
+ }
+
+ if repo.Name != "github.com/drone/drone" {
+ t.Errorf("Exepected Name %s, got %s", "github.com/drone/drone", repo.Name)
+ }
+
+ if repo.Host != "github.com" {
+ t.Errorf("Exepected Host %s, got %s", "github.com", repo.Host)
+ }
+
+ if repo.Owner != "drone" {
+ t.Errorf("Exepected Owner %s, got %s", "drone", repo.Owner)
+ }
+
+ if repo.Slug != "drone" {
+ t.Errorf("Exepected Slug %s, got %s", "drone", repo.Slug)
+ }
+
+ if repo.Private != true {
+ t.Errorf("Exepected Private %v, got %v", true, repo.Private)
+ }
+
+ if repo.Disabled != false {
+ t.Errorf("Exepected Private %v, got %v", false, repo.Disabled)
+ }
+
+ if repo.SCM != "git" {
+ t.Errorf("Exepected Type %s, got %s", "git", repo.SCM)
+ }
+
+ if repo.URL != "git@github.com:drone/drone.git" {
+ t.Errorf("Exepected URL %s, got %s", "git@github.com:drone/drone.git", repo.URL)
+ }
+
+ if repo.Username != "no username" {
+ t.Errorf("Exepected Username %s, got %s", "no username", repo.Username)
+ }
+
+ if repo.Password != "no password" {
+ t.Errorf("Exepected Password %s, got %s", "no password", repo.Password)
+ }
+
+ if repo.PublicKey != "public key" {
+ t.Errorf("Exepected PublicKey %s, got %s", "public key", repo.PublicKey)
+ }
+
+ if repo.PrivateKey != "private key" {
+ t.Errorf("Exepected PrivateKey %s, got %s", "private key", repo.PrivateKey)
+ }
+
+ if repo.UserID != 1 {
+ t.Errorf("Exepected ID %d, got %d", 1, repo.UserID)
+ }
+
+ if repo.TeamID != 1 {
+ t.Errorf("Exepected ID %d, got %d", 1, repo.TeamID)
+ }
+}
+*/
+
+func TestListReposTeam(t *testing.T) {
+ Setup()
+ defer Teardown()
+
+ // repos for team_id = 1
+ repos, err := database.ListReposTeam(1)
+ if err != nil {
+ t.Error(err)
+ }
+
+ // verify user count
+ if len(repos) != 2 {
+ t.Errorf("Exepected %d repos in database, got %d", 2, len(repos))
+ return
+ }
+
+ // get the second repo in the list and verify
+ // fields are being populated correctly
+ // NOTE: we get the 2nd repo due to sorting
+ repo := repos[1]
+
+ if repo.ID != 1 {
+ t.Errorf("Exepected ID %d, got %d", 1, repo.ID)
+ }
+
+ if repo.Slug != "github.com/drone/drone" {
+ t.Errorf("Exepected Slug %s, got %s", "github.com/drone/drone", repo.Slug)
+ }
+
+ if repo.Host != "github.com" {
+ t.Errorf("Exepected Host %s, got %s", "github.com", repo.Host)
+ }
+
+ if repo.Owner != "drone" {
+ t.Errorf("Exepected Owner %s, got %s", "drone", repo.Owner)
+ }
+
+ if repo.Name != "drone" {
+ t.Errorf("Exepected Name %s, got %s", "drone", repo.Name)
+ }
+
+ if repo.Private != true {
+ t.Errorf("Exepected Private %v, got %v", true, repo.Private)
+ }
+
+ if repo.Disabled != false {
+ t.Errorf("Exepected Private %v, got %v", false, repo.Disabled)
+ }
+
+ if repo.SCM != "git" {
+ t.Errorf("Exepected Type %s, got %s", "git", repo.SCM)
+ }
+
+ if repo.URL != "git@github.com:drone/drone.git" {
+ t.Errorf("Exepected URL %s, got %s", "git@github.com:drone/drone.git", repo.URL)
+ }
+
+ if repo.Username != "no username" {
+ t.Errorf("Exepected Username %s, got %s", "no username", repo.Username)
+ }
+
+ if repo.Password != "no password" {
+ t.Errorf("Exepected Password %s, got %s", "no password", repo.Password)
+ }
+
+ if repo.PublicKey != "public key" {
+ t.Errorf("Exepected PublicKey %s, got %s", "public key", repo.PublicKey)
+ }
+
+ if repo.PrivateKey != "private key" {
+ t.Errorf("Exepected PrivateKey %s, got %s", "private key", repo.PrivateKey)
+ }
+
+ if repo.UserID != 1 {
+ t.Errorf("Exepected ID %d, got %d", 1, repo.UserID)
+ }
+
+ if repo.TeamID != 1 {
+ t.Errorf("Exepected ID %d, got %d", 1, repo.TeamID)
+ }
+}
diff --git a/pkg/database/testing/settings_test.go b/pkg/database/testing/settings_test.go
new file mode 100644
index 000000000..c8a9eec1e
--- /dev/null
+++ b/pkg/database/testing/settings_test.go
@@ -0,0 +1,63 @@
+package database
+
+import (
+ "testing"
+
+ "github.com/drone/drone/pkg/database"
+)
+
+func TestGetSettings(t *testing.T) {
+ Setup()
+ defer Teardown()
+
+ // even though no settings exist yet, we should
+ // not see an error since we supress the msg
+ settings, err := database.GetSettings()
+ //if err != nil {
+ // t.Error(err)
+ //}
+
+ // add some settings
+ //settings := &modelSettings{}
+ settings.Scheme = "https"
+ settings.Domain = "foo.com"
+ settings.BitbucketKey = "bitbucketkey"
+ settings.BitbucketSecret = "bitbucketsecret"
+ settings.GitHubKey = "githubkey"
+ settings.GitHubSecret = "githubsecret"
+ settings.SmtpAddress = "noreply@foo.bar"
+ settings.SmtpServer = "0.0.0.0"
+ settings.SmtpUsername = "username"
+ settings.SmtpPassword = "password"
+
+ // save the updated settings
+ if err := database.SaveSettings(settings); err != nil {
+ t.Error(err)
+ }
+
+ // re-retrieve the settings post-save
+ settings, err = database.GetSettings()
+ if err != nil {
+ t.Error(err)
+ }
+
+ if settings.ID != 1 {
+ t.Errorf("Exepected ID %d, got %d", 1, settings.ID)
+ }
+
+ if settings.Scheme != "https" {
+ t.Errorf("Exepected Scheme %s, got %s", "https", settings.Scheme)
+ }
+
+ if settings.Domain != "foo.com" {
+ t.Errorf("Exepected Domain %s, got %s", "foo.com", settings.Domain)
+ }
+
+ // Verify caching works and is threadsafe
+ settingsA, _ := database.GetSettings()
+ settingsB, _ := database.GetSettings()
+ settingsA.Domain = "foo.bar.baz"
+ if settingsA.Domain == settingsB.Domain {
+ t.Errorf("Exepected Domain ThreadSafe and unchanged")
+ }
+}
diff --git a/pkg/database/testing/teams_test.go b/pkg/database/testing/teams_test.go
new file mode 100644
index 000000000..b86634391
--- /dev/null
+++ b/pkg/database/testing/teams_test.go
@@ -0,0 +1,169 @@
+package database
+
+import (
+ "testing"
+
+ "github.com/drone/drone/pkg/database"
+)
+
+// TODO unit test to verify unique constraint on Member.UserID and Member.TeamID
+
+// TestGetTeam tests the ability to retrieve a Team
+// from the database by Unique ID.
+func TestGetTeam(t *testing.T) {
+ Setup()
+ defer Teardown()
+
+ team, err := database.GetTeam(1)
+ if err != nil {
+ t.Error(err)
+ }
+
+ if team.ID != 1 {
+ t.Errorf("Exepected ID %d, got %d", 1, team.ID)
+ }
+
+ if team.Name != "Drone" {
+ t.Errorf("Exepected Name %s, got %s", "Drone", team.Name)
+ }
+
+ if team.Slug != "drone" {
+ t.Errorf("Exepected Slug %s, got %s", "drone", team.Slug)
+ }
+
+ if team.Email != "support@drone.io" {
+ t.Errorf("Exepected Email %s, got %s", "brad@drone.io", team.Email)
+ }
+
+ if team.Gravatar != "8c58a0be77ee441bb8f8595b7f1b4e87" {
+ t.Errorf("Exepected Gravatar %s, got %s", "8c58a0be77ee441bb8f8595b7f1b4e87", team.Gravatar)
+ }
+}
+
+// TestGetTeamName tests the ability to retrieve a Team
+// from the database by Unique Team Name (aka Slug).
+func TestGetTeamSlug(t *testing.T) {
+ Setup()
+ defer Teardown()
+
+ team, err := database.GetTeamSlug("drone")
+ if err != nil {
+ t.Error(err)
+ }
+
+ if team.ID != 1 {
+ t.Errorf("Exepected ID %d, got %d", 1, team.ID)
+ }
+
+ if team.Name != "Drone" {
+ t.Errorf("Exepected Name %s, got %s", "Drone", team.Name)
+ }
+
+ if team.Slug != "drone" {
+ t.Errorf("Exepected Slug %s, got %s", "drone", team.Slug)
+ }
+
+ if team.Email != "support@drone.io" {
+ t.Errorf("Exepected Email %s, got %s", "brad@drone.io", team.Email)
+ }
+
+ if team.Gravatar != "8c58a0be77ee441bb8f8595b7f1b4e87" {
+ t.Errorf("Exepected Gravatar %s, got %s", "8c58a0be77ee441bb8f8595b7f1b4e87", team.Gravatar)
+ }
+}
+
+// TestUpdateTeam tests the ability to updatee an
+// existing Team in the database.
+func TestUpdateTeam(t *testing.T) {
+ Setup()
+ defer Teardown()
+
+ // get the user we plan to update
+ team, err := database.GetTeam(1)
+ if err != nil {
+ t.Error(err)
+ }
+
+ // update fields
+ team.Email = "brad@drone.io"
+ team.Gravatar = "61024896f291303615bcd4f7a0dcfb74"
+
+ // update the database
+ if err := database.SaveTeam(team); err != nil {
+ t.Error(err)
+ }
+
+ // get the updated team
+ updatedTeam, err := database.GetTeam(1)
+ if err != nil {
+ t.Error(err)
+ }
+
+ // verify the updated fields
+ if team.Email != updatedTeam.Email {
+ t.Errorf("Exepected Email %s, got %s", team.Email, updatedTeam.Email)
+ }
+
+ if team.Gravatar != updatedTeam.Gravatar {
+ t.Errorf("Exepected Gravatar %s, got %s", team.Gravatar, updatedTeam.Gravatar)
+ }
+}
+
+// Test the ability to delete a Team.
+func TestDeleteTeam(t *testing.T) {
+ Setup()
+ defer Teardown()
+
+ // get the team we plan to update
+ if err := database.DeleteTeam(1); err != nil {
+ t.Error(err)
+ }
+
+ // now try to get the team from the database
+ _, err := database.GetTeam(1)
+ if err == nil {
+ t.Fail()
+ }
+}
+
+// Test the ability to get a list of Teams
+// to which a User belongs.
+func TestListTeam(t *testing.T) {
+ Setup()
+ defer Teardown()
+
+ teams, err := database.ListTeams(1)
+ if err != nil {
+ t.Error(err)
+ }
+
+ // verify team count
+ if len(teams) != 3 {
+ t.Errorf("Exepected %d teams in database, got %d", 3, len(teams))
+ return
+ }
+
+ // get the first user in the list and verify
+ // fields are being populated correctly
+ team := teams[0]
+
+ if team.ID != 1 {
+ t.Errorf("Exepected ID %d, got %d", 1, team.ID)
+ }
+
+ if team.Name != "Drone" {
+ t.Errorf("Exepected Name %s, got %s", "Drone", team.Name)
+ }
+
+ if team.Slug != "drone" {
+ t.Errorf("Exepected Slug %s, got %s", "drone", team.Slug)
+ }
+
+ if team.Email != "support@drone.io" {
+ t.Errorf("Exepected Email %s, got %s", "brad@drone.io", team.Email)
+ }
+
+ if team.Gravatar != "8c58a0be77ee441bb8f8595b7f1b4e87" {
+ t.Errorf("Exepected Gravatar %s, got %s", "8c58a0be77ee441bb8f8595b7f1b4e87", team.Gravatar)
+ }
+}
diff --git a/pkg/database/testing/testing.go b/pkg/database/testing/testing.go
new file mode 100644
index 000000000..11106e2d3
--- /dev/null
+++ b/pkg/database/testing/testing.go
@@ -0,0 +1,207 @@
+package database
+
+import (
+ "crypto/aes"
+ "database/sql"
+ "log"
+
+ "github.com/drone/drone/pkg/database"
+ "github.com/drone/drone/pkg/database/encrypt"
+ . "github.com/drone/drone/pkg/model"
+
+ _ "github.com/mattn/go-sqlite3"
+ "github.com/russross/meddler"
+)
+
+// in-memory database used for
+// unit testing purposes.
+var db *sql.DB
+
+func init() {
+ // create a cipher for ecnrypting and decrypting
+ // database fields
+ cipher, err := aes.NewCipher([]byte("38B241096B8DA08131563770F4CDDFAC"))
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // register function with meddler to encrypt and
+ // decrypt database fields.
+ meddler.Register("gobencrypt", &encrypt.EncryptedField{cipher})
+
+ // notify meddler that we are working with sqlite
+ meddler.Default = meddler.SQLite
+}
+
+func Setup() {
+ // create an in-memory database
+ db, _ = sql.Open("sqlite3", ":memory:")
+
+ // make sure all the tables and indexes are created
+ database.Set(db)
+
+ // create dummy user data
+ user1 := User{
+ Password: "$2a$10$b8d63QsTL38vx7lj0HEHfOdbu1PCAg6Gfca74UavkXooIBx9YxopS",
+ Name: "Brad Rydzewski",
+ Email: "brad.rydzewski@gmail.com",
+ Gravatar: "8c58a0be77ee441bb8f8595b7f1b4e87",
+ Token: "123",
+ Admin: true}
+ user2 := User{
+ Password: "$2a$10$b8d63QsTL38vx7lj0HEHfOdbu1PCAg6Gfca74UavkXooIBx9YxopS",
+ Name: "Thomas Burke",
+ Email: "cavepig@gmail.com",
+ Gravatar: "c62f7126273f7fa786274274a5dec8ce",
+ Token: "456",
+ Admin: false}
+ user3 := User{
+ Password: "$2a$10$b8d63QsTL38vx7lj0HEHfOdbu1PCAg6Gfca74UavkXooIBx9YxopS",
+ Name: "Carlos Morales",
+ Email: "ytsejammer@gmail.com",
+ Gravatar: "c2180a539620d90d68eaeb848364f1c2",
+ Token: "789",
+ Admin: false}
+
+ database.SaveUser(&user1)
+ database.SaveUser(&user2)
+ database.SaveUser(&user3)
+
+ // create dummy team data
+ team1 := Team{
+ Slug: "drone",
+ Name: "Drone",
+ Email: "support@drone.io",
+ Gravatar: "8c58a0be77ee441bb8f8595b7f1b4e87"}
+ team2 := Team{
+ Slug: "github",
+ Name: "Github",
+ Email: "support@github.com",
+ Gravatar: "61024896f291303615bcd4f7a0dcfb74"}
+ team3 := Team{
+ Slug: "golang",
+ Name: "Golang",
+ Email: "support@golang.org",
+ Gravatar: "991695cc770c6b8354b68cd18c280b95"}
+
+ database.SaveTeam(&team1)
+ database.SaveTeam(&team2)
+ database.SaveTeam(&team3)
+
+ // create team membership data
+ database.SaveMember(user1.ID, team1.ID, RoleOwner)
+ database.SaveMember(user2.ID, team1.ID, RoleAdmin)
+ database.SaveMember(user3.ID, team1.ID, RoleWrite)
+ database.SaveMember(user1.ID, team2.ID, RoleOwner)
+ database.SaveMember(user2.ID, team2.ID, RoleAdmin)
+ database.SaveMember(user3.ID, team2.ID, RoleWrite)
+ database.SaveMember(user1.ID, team3.ID, RoleOwner)
+
+ // create dummy repo data
+ repo1 := Repo{
+ Slug: "github.com/drone/drone",
+ Host: "github.com",
+ Owner: "drone",
+ Name: "drone",
+ Private: true,
+ Disabled: false,
+ SCM: "git",
+ URL: "git@github.com:drone/drone.git",
+ Username: "no username",
+ Password: "no password",
+ PublicKey: "public key",
+ PrivateKey: "private key",
+ UserID: user1.ID,
+ TeamID: team1.ID,
+ }
+ repo2 := Repo{
+ Slug: "bitbucket.org/drone/test",
+ Host: "bitbucket.org",
+ Owner: "drone",
+ Name: "test",
+ Private: false,
+ Disabled: false,
+ SCM: "hg",
+ URL: "https://bitbucket.org/drone/test",
+ Username: "no username",
+ Password: "no password",
+ PublicKey: "public key",
+ PrivateKey: "private key",
+ UserID: user1.ID,
+ TeamID: team1.ID,
+ }
+ repo3 := Repo{
+ Slug: "bitbucket.org/brydzewski/test",
+ Host: "bitbucket.org",
+ Owner: "brydzewski",
+ Name: "test",
+ Private: false,
+ Disabled: false,
+ SCM: "hg",
+ URL: "https://bitbucket.org/brydzewski/test",
+ Username: "no username",
+ Password: "no password",
+ PublicKey: "public key",
+ PrivateKey: "private key",
+ UserID: user2.ID,
+ }
+
+ database.SaveRepo(&repo1)
+ database.SaveRepo(&repo2)
+ database.SaveRepo(&repo3)
+
+ commit1 := Commit{
+ RepoID: repo1.ID,
+ Status: "Success",
+ Hash: "4f4c4594be6d6ddbc1c0dd521334f7ecba92b608",
+ Branch: "master",
+ Author: user1.Email,
+ Gravatar: user1.Gravatar,
+ Message: "commit message",
+ }
+ commit2 := Commit{
+ RepoID: repo1.ID,
+ Status: "Failure",
+ Hash: "0eb2fa13e9f4139e803b6ad37831708d4786c74a",
+ Branch: "master",
+ Author: user1.Email,
+ Gravatar: user1.Gravatar,
+ Message: "commit message",
+ }
+ commit3 := Commit{
+ RepoID: repo1.ID,
+ Status: "Failure",
+ Hash: "60a7fe87ccf01d0152e53242528399e05acaf047",
+ Branch: "dev",
+ Author: user1.Email,
+ Gravatar: user1.Gravatar,
+ Message: "commit message",
+ }
+ commit4 := Commit{
+ RepoID: repo2.ID,
+ Status: "Success",
+ Hash: "a4078d1e9a0842cdd214adbf0512578799a4f2ba",
+ Branch: "master",
+ Author: user1.Email,
+ Gravatar: user1.Gravatar,
+ Message: "commit message",
+ }
+
+ // create dummy commit data
+ database.SaveCommit(&commit1)
+ database.SaveCommit(&commit2)
+ database.SaveCommit(&commit3)
+ database.SaveCommit(&commit4)
+
+ // create dummy build data
+ database.SaveBuild(&Build{CommitID: commit1.ID, Slug: "node_0.10", Status: "Success", Duration: 60})
+ database.SaveBuild(&Build{CommitID: commit1.ID, Slug: "node_0.09", Status: "Success", Duration: 70})
+ database.SaveBuild(&Build{CommitID: commit2.ID, Slug: "node_0.10", Status: "Success", Duration: 10})
+ database.SaveBuild(&Build{CommitID: commit2.ID, Slug: "node_0.09", Status: "Failure", Duration: 65})
+ database.SaveBuild(&Build{CommitID: commit3.ID, Slug: "node_0.10", Status: "Failure", Duration: 50})
+ database.SaveBuild(&Build{CommitID: commit3.ID, Slug: "node_0.09", Status: "Failure", Duration: 55})
+}
+
+func Teardown() {
+ db.Close()
+}
diff --git a/pkg/database/testing/users_test.go b/pkg/database/testing/users_test.go
new file mode 100644
index 000000000..9742c42bb
--- /dev/null
+++ b/pkg/database/testing/users_test.go
@@ -0,0 +1,169 @@
+package database
+
+import (
+ "testing"
+
+ "github.com/drone/drone/pkg/database"
+)
+
+// TODO unit test to verify unique constraint on User.Username
+// TODO unit test to verify unique constraint on User.Email
+
+// TestGetUser tests the ability to retrieve a User
+// from the database by Unique ID.
+func TestGetUser(t *testing.T) {
+ Setup()
+ defer Teardown()
+
+ u, err := database.GetUser(1)
+ if err != nil {
+ t.Error(err)
+ }
+
+ if u.ID != 1 {
+ t.Errorf("Exepected ID %d, got %d", 1, u.ID)
+ }
+
+ if u.Password != "$2a$10$b8d63QsTL38vx7lj0HEHfOdbu1PCAg6Gfca74UavkXooIBx9YxopS" {
+ t.Errorf("Exepected Password %s, got %s", "$2a$10$b8d63QsTL38vx7lj0HEHfOdbu1PCAg6Gfca74UavkXooIBx9YxopS", u.Password)
+ }
+
+ if u.Name != "Brad Rydzewski" {
+ t.Errorf("Exepected Name %s, got %s", "Brad Rydzewski", u.Name)
+ }
+
+ if u.Email != "brad.rydzewski@gmail.com" {
+ t.Errorf("Exepected Email %s, got %s", "brad.rydzewski@gmail.com", u.Email)
+ }
+
+ if u.Gravatar != "8c58a0be77ee441bb8f8595b7f1b4e87" {
+ t.Errorf("Exepected Gravatar %s, got %s", "8c58a0be77ee441bb8f8595b7f1b4e87", u.Gravatar)
+ }
+}
+
+// TestGetUseEmail tests the ability to retrieve a User
+// from the database by Email address.
+func TestGetUserEmail(t *testing.T) {
+ Setup()
+ defer Teardown()
+
+ u, err := database.GetUserEmail("brad.rydzewski@gmail.com")
+ if err != nil {
+ t.Error(err)
+ }
+
+ if u.ID != 1 {
+ t.Errorf("Exepected ID %d, got %d", 1, u.ID)
+ }
+
+ if u.Password != "$2a$10$b8d63QsTL38vx7lj0HEHfOdbu1PCAg6Gfca74UavkXooIBx9YxopS" {
+ t.Errorf("Exepected Password %s, got %s", "$2a$10$b8d63QsTL38vx7lj0HEHfOdbu1PCAg6Gfca74UavkXooIBx9YxopS", u.Password)
+ }
+
+ if u.Name != "Brad Rydzewski" {
+ t.Errorf("Exepected Name %s, got %s", "Brad Rydzewski", u.Name)
+ }
+
+ if u.Email != "brad.rydzewski@gmail.com" {
+ t.Errorf("Exepected Email %s, got %s", "brad.rydzewski@gmail.com", u.Email)
+ }
+
+ if u.Gravatar != "8c58a0be77ee441bb8f8595b7f1b4e87" {
+ t.Errorf("Exepected Gravatar %s, got %s", "8c58a0be77ee441bb8f8595b7f1b4e87", u.Gravatar)
+ }
+}
+
+// TestUpdateUser tests the ability to updatee an
+// existing User in the database.
+func TestUpdateUser(t *testing.T) {
+ Setup()
+ defer Teardown()
+
+ // get the user we plan to update
+ user, err := database.GetUser(1)
+ if err != nil {
+ t.Error(err)
+ }
+
+ // update fields
+ user.Email = "brad@drone.io"
+ user.Password = "password"
+
+ // update the database
+ if err := database.SaveUser(user); err != nil {
+ t.Error(err)
+ }
+
+ // get the updated user
+ updatedUser, err := database.GetUser(1)
+ if err != nil {
+ t.Error(err)
+ }
+
+ // verify the updated fields
+ if user.Email != updatedUser.Email {
+ t.Errorf("Exepected Email %s, got %s", user.Email, updatedUser.Email)
+ }
+
+ if user.Password != updatedUser.Password {
+ t.Errorf("Exepected Password %s, got %s", user.Email, updatedUser.Password)
+ }
+}
+
+// Deletes an existing User account.
+func TestDeleteUser(t *testing.T) {
+ Setup()
+ defer Teardown()
+
+ // get the user we plan to update
+ if err := database.DeleteUser(1); err != nil {
+ t.Error(err)
+ }
+
+ // now try to get the user from the database
+ _, err := database.GetUser(1)
+ if err == nil {
+ t.Fail()
+ }
+}
+
+// Returns a list of all Users.
+func TestListUsers(t *testing.T) {
+ Setup()
+ defer Teardown()
+
+ users, err := database.ListUsers()
+ if err != nil {
+ t.Error(err)
+ }
+
+ // verify user count
+ if len(users) != 3 {
+ t.Errorf("Exepected %d users in database, got %d", 3, len(users))
+ return
+ }
+
+ // get the first user in the list and verify
+ // fields are being populated correctly
+ u := users[0]
+
+ if u.ID != 1 {
+ t.Errorf("Exepected ID %d, got %d", 1, u.ID)
+ }
+
+ if u.Password != "$2a$10$b8d63QsTL38vx7lj0HEHfOdbu1PCAg6Gfca74UavkXooIBx9YxopS" {
+ t.Errorf("Exepected Password %s, got %s", "$2a$10$b8d63QsTL38vx7lj0HEHfOdbu1PCAg6Gfca74UavkXooIBx9YxopS", u.Password)
+ }
+
+ if u.Name != "Brad Rydzewski" {
+ t.Errorf("Exepected Name %s, got %s", "Brad Rydzewski", u.Name)
+ }
+
+ if u.Email != "brad.rydzewski@gmail.com" {
+ t.Errorf("Exepected Email %s, got %s", "brad.rydzewski@gmail.com", u.Email)
+ }
+
+ if u.Gravatar != "8c58a0be77ee441bb8f8595b7f1b4e87" {
+ t.Errorf("Exepected Gravatar %s, got %s", "8c58a0be77ee441bb8f8595b7f1b4e87", u.Gravatar)
+ }
+}
diff --git a/pkg/database/users.go b/pkg/database/users.go
new file mode 100644
index 000000000..602f71d84
--- /dev/null
+++ b/pkg/database/users.go
@@ -0,0 +1,90 @@
+package database
+
+import (
+ "time"
+
+ . "github.com/drone/drone/pkg/model"
+ "github.com/russross/meddler"
+)
+
+// Name of the User table in the database
+const userTable = "users"
+
+// SQL Queries to retrieve a user by their unique database key
+const userFindIdStmt = `
+SELECT id, email, password, name, gravatar, created, updated, admin,
+github_login, github_token, bitbucket_login, bitbucket_token, bitbucket_secret
+FROM users WHERE id = ?
+`
+
+// SQL Queries to retrieve a user by their email address
+const userFindEmailStmt = `
+SELECT id, email, password, name, gravatar, created, updated, admin,
+github_login, github_token, bitbucket_login, bitbucket_token, bitbucket_secret
+FROM users WHERE email = ?
+`
+
+// SQL Queries to retrieve a list of all users
+const userStmt = `
+SELECT id, email, password, name, gravatar, created, updated, admin,
+github_login, github_token, bitbucket_login, bitbucket_token, bitbucket_secret
+FROM users
+ORDER BY name ASC
+`
+
+// Returns the User with the given ID.
+func GetUser(id int64) (*User, error) {
+ user := User{}
+ err := meddler.QueryRow(db, &user, userFindIdStmt, id)
+ return &user, err
+}
+
+// Returns the User with the given email address.
+func GetUserEmail(email string) (*User, error) {
+ user := User{}
+ err := meddler.QueryRow(db, &user, userFindEmailStmt, email)
+ return &user, err
+}
+
+// Returns the User Password Hash for the given
+// email address.
+func GetPassEmail(email string) ([]byte, error) {
+ user, err := GetUserEmail(email)
+ if err != nil {
+ return nil, err
+ }
+
+ return []byte(user.Password), nil
+}
+
+// Saves the User account.
+func SaveUser(user *User) error {
+ if user.ID == 0 {
+ user.Created = time.Now().UTC()
+ }
+ user.Updated = time.Now().UTC()
+ return meddler.Save(db, userTable, user)
+}
+
+// Deletes an existing User account.
+func DeleteUser(id int64) error {
+ db.Exec("DELETE FROM members WHERE user_id = ?", id)
+ db.Exec("DELETE FROM users WHERE id = ?", id)
+ // TODO delete all projects
+ return nil
+}
+
+// Returns a list of all Users.
+func ListUsers() ([]*User, error) {
+ var users []*User
+ err := meddler.QueryAll(db, &users, userStmt)
+ return users, err
+}
+
+// Returns a list of Users within the specified
+// range (for pagination purposes).
+func ListUsersRange(limit, offset int) ([]*User, error) {
+ var users []*User
+ err := meddler.QueryAll(db, &users, userStmt)
+ return users, err
+}
diff --git a/pkg/handler/admin.go b/pkg/handler/admin.go
new file mode 100644
index 000000000..be3b67a75
--- /dev/null
+++ b/pkg/handler/admin.go
@@ -0,0 +1,256 @@
+package handler
+
+import (
+ "fmt"
+ "log"
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/dchest/authcookie"
+ "github.com/drone/drone/pkg/database"
+ "github.com/drone/drone/pkg/mail"
+ . "github.com/drone/drone/pkg/model"
+)
+
+// Display a list of ALL users in the system
+func AdminUserList(w http.ResponseWriter, r *http.Request, u *User) error {
+ users, err := database.ListUsers()
+ if err != nil {
+ return err
+ }
+
+ data := struct {
+ User *User
+ Users []*User
+ }{u, users}
+
+ return RenderTemplate(w, "admin_users.html", &data)
+}
+
+// Invite a user to join the system
+func AdminUserAdd(w http.ResponseWriter, r *http.Request, u *User) error {
+ return RenderTemplate(w, "admin_users_add.html", &struct{ User *User }{u})
+}
+
+// Invite a user to join the system
+func AdminUserInvite(w http.ResponseWriter, r *http.Request, u *User) error {
+ // generate the password reset token
+ email := r.FormValue("email")
+ token := authcookie.New(email, time.Now().Add(12*time.Hour), secret)
+
+ // get settings
+ hostname := database.SettingsMust().URL().String()
+ emailEnabled := database.SettingsMust().SmtpServer != ""
+
+ if !emailEnabled {
+ // Email is not enabled, so must let the user know the signup link
+ link := fmt.Sprintf("%v/register?token=%v", hostname, token)
+ return RenderText(w, link, http.StatusOK)
+ }
+
+ // send data to template
+ data := struct {
+ Host string
+ Email string
+ Token string
+ }{hostname, email, token}
+
+ // send the email message async
+ go func() {
+ if err := mail.SendActivation(email, data); err != nil {
+ log.Printf("error sending account activation email to %s. %s", email, err)
+ }
+ }()
+
+ return RenderText(w, http.StatusText(http.StatusOK), http.StatusOK)
+}
+
+// Form to edit a user
+func AdminUserEdit(w http.ResponseWriter, r *http.Request, u *User) error {
+ idstr := r.FormValue("id")
+ id, err := strconv.Atoi(idstr)
+ if err != nil {
+ return err
+ }
+
+ // get the user from the database
+ user, err := database.GetUser(int64(id))
+ if err != nil {
+ return err
+ }
+
+ data := struct {
+ User *User
+ EditUser *User
+ }{u, user}
+
+ return RenderTemplate(w, "admin_users_edit.html", &data)
+}
+
+func AdminUserUpdate(w http.ResponseWriter, r *http.Request, u *User) error {
+ // get the ID from the URL parameter
+ idstr := r.FormValue("id")
+ id, err := strconv.Atoi(idstr)
+ if err != nil {
+ return err
+ }
+
+ // get the user from the database
+ user, err := database.GetUser(int64(id))
+ if err != nil {
+ return err
+ }
+
+ // update if user is administrator or not
+ switch r.FormValue("Admin") {
+ case "true":
+ user.Admin = true
+ case "false":
+ user.Admin = false
+ }
+
+ // saving user
+ if err := database.SaveUser(user); err != nil {
+ return err
+ }
+
+ return RenderText(w, http.StatusText(http.StatusOK), http.StatusOK)
+}
+
+func AdminUserDelete(w http.ResponseWriter, r *http.Request, u *User) error {
+ // get the ID from the URL parameter
+ idstr := r.FormValue("id")
+ id, err := strconv.Atoi(idstr)
+ if err != nil {
+ return err
+ }
+
+ // cannot delete self
+ if u.ID == int64(id) {
+ return RenderForbidden(w)
+ }
+
+ // delete the user
+ if err := database.DeleteUser(int64(id)); err != nil {
+ return err
+ }
+
+ http.Redirect(w, r, "/account/admin/users", http.StatusSeeOther)
+ return nil
+}
+
+// Display a list of ALL users in the system
+func AdminSettings(w http.ResponseWriter, r *http.Request, u *User) error {
+ // get settings from database
+ settings := database.SettingsMust()
+
+ data := struct {
+ User *User
+ Settings *Settings
+ }{u, settings}
+
+ return RenderTemplate(w, "admin_settings.html", &data)
+}
+
+// Display a list of ALL users in the system
+func AdminSettingsUpdate(w http.ResponseWriter, r *http.Request, u *User) error {
+ // get settings from database
+ settings := database.SettingsMust()
+
+ // update smtp settings
+ settings.Domain = r.FormValue("Domain")
+ settings.Scheme = r.FormValue("Scheme")
+
+ // update bitbucket settings
+ settings.BitbucketKey = r.FormValue("BitbucketKey")
+ settings.BitbucketSecret = r.FormValue("BitbucketSecret")
+
+ // update github settings
+ settings.GitHubKey = r.FormValue("GitHubKey")
+ settings.GitHubSecret = r.FormValue("GitHubSecret")
+
+ // update smtp settings
+ settings.SmtpServer = r.FormValue("SmtpServer")
+ settings.SmtpPort = r.FormValue("SmtpPort")
+ settings.SmtpAddress = r.FormValue("SmtpAddress")
+ settings.SmtpUsername = r.FormValue("SmtpUsername")
+ settings.SmtpPassword = r.FormValue("SmtpPassword")
+
+ // persist changes
+ if err := database.SaveSettings(settings); err != nil {
+ return RenderError(w, err, http.StatusBadRequest)
+ }
+
+ // make sure the mail package is updated with the
+ // latest client information.
+ //mail.SetClient(&mail.SMTPClient{
+ // Host: settings.SmtpServer,
+ // Port: settings.SmtpPort,
+ // User: settings.SmtpUsername,
+ // Pass: settings.SmtpPassword,
+ // From: settings.SmtpAddress,
+ //})
+
+ return RenderText(w, http.StatusText(http.StatusOK), http.StatusOK)
+}
+
+func Install(w http.ResponseWriter, r *http.Request) error {
+ // we can only perform the inital installation if no
+ // users exist in the system
+ if users, err := database.ListUsers(); err != nil {
+ return RenderError(w, err, http.StatusBadRequest)
+ } else if len(users) != 0 {
+ // if users exist in the systsem
+ // we should render a NotFound page
+ return RenderNotFound(w)
+ }
+
+ return RenderTemplate(w, "install.html", true)
+}
+
+func InstallPost(w http.ResponseWriter, r *http.Request) error {
+ // we can only perform the inital installation if no
+ // users exist in the system
+ if users, err := database.ListUsers(); err != nil {
+ return RenderError(w, err, http.StatusBadRequest)
+ } else if len(users) != 0 {
+ // if users exist in the systsem
+ // we should render a NotFound page
+ return RenderNotFound(w)
+ }
+
+ // set the email and name
+ user := NewUser(r.FormValue("name"), r.FormValue("email"))
+ user.Admin = true
+
+ // set the new password
+ if err := user.SetPassword(r.FormValue("password")); err != nil {
+ return RenderError(w, err, http.StatusBadRequest)
+ }
+
+ // verify fields are correct
+ if err := user.Validate(); err != nil {
+ return RenderError(w, err, http.StatusBadRequest)
+ }
+
+ // save to the database
+ if err := database.SaveUser(user); err != nil {
+ return RenderError(w, err, http.StatusBadRequest)
+ }
+
+ // update settings
+ settings := Settings{}
+ settings.Domain = r.FormValue("Domain")
+ settings.Scheme = r.FormValue("Scheme")
+ database.SaveSettings(&settings)
+
+ // add the user to the session object
+ // so that he/she is loggedin
+ SetCookie(w, r, "_sess", user.Email)
+
+ // send the user to the settings page
+ // to complete the configuration.
+ http.Redirect(w, r, "/account/admin/settings", http.StatusSeeOther)
+ return nil
+}
diff --git a/pkg/handler/app.go b/pkg/handler/app.go
new file mode 100644
index 000000000..86cd1121d
--- /dev/null
+++ b/pkg/handler/app.go
@@ -0,0 +1,185 @@
+package handler
+
+import (
+ "crypto/rand"
+ "io"
+ "log"
+ "net/http"
+ "time"
+
+ "github.com/dchest/authcookie"
+ "github.com/dchest/passwordreset"
+ "github.com/drone/drone/pkg/database"
+ "github.com/drone/drone/pkg/mail"
+ . "github.com/drone/drone/pkg/model"
+)
+
+var (
+ // Secret key used to sign auth cookies,
+ // password reset tokens, etc.
+ secret = generateRandomKey(256)
+)
+
+// GenerateRandomKey creates a random key of size length bytes
+func generateRandomKey(strength int) []byte {
+ k := make([]byte, strength)
+ if _, err := io.ReadFull(rand.Reader, k); err != nil {
+ return nil
+ }
+ return k
+}
+
+// Returns an HTML index.html page if the user is
+// not currently authenticated, otherwise redirects
+// the user to their personal dashboard screen
+func Index(w http.ResponseWriter, r *http.Request) error {
+ // is the user already authenticated then
+ // redirect to the dashboard page
+ if _, err := r.Cookie("_sess"); err == nil {
+ http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
+ return nil
+ }
+
+ // otherwise redirect to the login page
+ http.Redirect(w, r, "/login", http.StatusSeeOther)
+ return nil
+}
+
+// Return an HTML form for the User to login.
+func Login(w http.ResponseWriter, r *http.Request) error {
+ return RenderTemplate(w, "login.html", nil)
+}
+
+// Terminate the User session.
+func Logout(w http.ResponseWriter, r *http.Request) error {
+ DelCookie(w, r, "_sess")
+
+ http.Redirect(w, r, "/login", http.StatusSeeOther)
+ return nil
+}
+
+// Return an HTML form for the User to request a password reset.
+func Forgot(w http.ResponseWriter, r *http.Request) error {
+ return RenderTemplate(w, "forgot.html", nil)
+}
+
+// Return an HTML form for the User to perform a password reset.
+// This page must be visited from a Password Reset email that
+// contains a hash to verify the User's identity.
+func Reset(w http.ResponseWriter, r *http.Request) error {
+ return RenderTemplate(w, "reset.html", &struct{ Error string }{""})
+}
+
+// Return an HTML form to register for a new account. This
+// page must be visited from a Signup email that contains
+// a hash to verify the Email address is correct.
+func Register(w http.ResponseWriter, r *http.Request) error {
+ return RenderTemplate(w, "register.html", &struct{ Error string }{""})
+}
+
+func ForgotPost(w http.ResponseWriter, r *http.Request) error {
+ email := r.FormValue("email")
+
+ // attempt to retrieve the user by email address
+ user, err := database.GetUserEmail(email)
+ if err != nil {
+ log.Printf("could not find user %s to reset password. %s", email, err)
+ // if we can't find the email, we still display
+ // the template to the user. This prevents someone
+ // from trying to guess passwords through trial & error
+ return RenderTemplate(w, "forgot_sent.html", nil)
+ }
+
+ // hostname from settings
+ hostname := database.SettingsMust().URL().String()
+
+ // generate the password reset token
+ token := passwordreset.NewToken(user.Email, 12*time.Hour, []byte(user.Password), secret)
+ data := struct {
+ Host string
+ User *User
+ Token string
+ }{hostname, user, token}
+
+ // send the email message async
+ go func() {
+ if err := mail.SendPassword(email, data); err != nil {
+ log.Printf("error sending password reset email to %s. %s", email, err)
+ }
+ }()
+
+ // render the template indicating a success
+ return RenderTemplate(w, "forgot_sent.html", nil)
+}
+
+func ResetPost(w http.ResponseWriter, r *http.Request) error {
+ // verify the token and extract the username
+ token := r.FormValue("token")
+ email, err := passwordreset.VerifyToken(token, database.GetPassEmail, secret)
+ if err != nil {
+ return RenderTemplate(w, "reset.html", &struct{ Error string }{"Your password reset request is expired."})
+ }
+
+ // get the user from the database
+ user, err := database.GetUserEmail(email)
+ if err != nil {
+ return RenderTemplate(w, "reset.html", &struct{ Error string }{"Unable to locate user account."})
+ }
+
+ // get the new password
+ password := r.FormValue("password")
+ if err := user.SetPassword(password); err != nil {
+ return RenderTemplate(w, "reset.html", &struct{ Error string }{err.Error()})
+ }
+
+ // save to the database
+ if err := database.SaveUser(user); err != nil {
+ return RenderTemplate(w, "reset.html", &struct{ Error string }{"Unable to update password. Please try again"})
+ }
+
+ // add the user to the session object
+ //session, _ := store.Get(r, "_sess")
+ //session.Values["username"] = user.Email
+ //session.Save(r, w)
+ SetCookie(w, r, "_sess", user.Email)
+
+ http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
+ return nil
+}
+
+func RegisterPost(w http.ResponseWriter, r *http.Request) error {
+ // verify the token and extract the username
+ token := r.FormValue("token")
+ email := authcookie.Login(token, secret)
+ if len(email) == 0 {
+ return RenderTemplate(w, "register.html", &struct{ Error string }{"Your registration email is expired."})
+ }
+
+ // set the email and name
+ user := User{}
+ user.SetEmail(email)
+ user.Name = r.FormValue("name")
+
+ // set the new password
+ password := r.FormValue("password")
+ if err := user.SetPassword(password); err != nil {
+ return RenderTemplate(w, "register.html", &struct{ Error string }{err.Error()})
+ }
+
+ // verify fields are correct
+ if err := user.Validate(); err != nil {
+ return RenderTemplate(w, "register.html", &struct{ Error string }{err.Error()})
+ }
+
+ // save to the database
+ if err := database.SaveUser(&user); err != nil {
+ return err
+ }
+
+ // add the user to the session object
+ SetCookie(w, r, "_sess", user.Email)
+
+ // redirect the user to their dashboard
+ http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
+ return nil
+}
diff --git a/pkg/handler/auth.go b/pkg/handler/auth.go
new file mode 100644
index 000000000..091bb494d
--- /dev/null
+++ b/pkg/handler/auth.go
@@ -0,0 +1,91 @@
+package handler
+
+import (
+ "net/http"
+
+ "github.com/drone/drone/pkg/database"
+ . "github.com/drone/drone/pkg/model"
+ "github.com/drone/go-github/github"
+ "github.com/drone/go-github/oauth2"
+)
+
+// Create the User session.
+func Authorize(w http.ResponseWriter, r *http.Request) error {
+ // extract form data
+ username := r.FormValue("username")
+ password := r.FormValue("password")
+ returnTo := r.FormValue("return_to")
+
+ // get the user from the database
+ user, err := database.GetUserEmail(username)
+ if err != nil {
+ return RenderTemplate(w, "login_error.html", nil)
+ }
+
+ // verify the password
+ if err := user.ComparePassword(password); err != nil {
+ return RenderTemplate(w, "login_error.html", nil)
+ }
+
+ // add the user to the session object
+ SetCookie(w, r, "_sess", username)
+
+ // where should we send the user to?
+ if len(returnTo) == 0 {
+ returnTo = "/dashboard"
+ }
+
+ // redirect to the homepage
+ http.Redirect(w, r, returnTo, http.StatusSeeOther)
+ return nil
+}
+
+func LinkGithub(w http.ResponseWriter, r *http.Request, u *User) error {
+
+ // get settings from database
+ settings := database.SettingsMust()
+
+ // github OAuth2 Data
+ var oauth = oauth2.Client{
+ RedirectURL: settings.URL().String() + "/auth/login/github",
+ AccessTokenURL: "https://github.com/login/oauth/access_token",
+ AuthorizationURL: "https://github.com/login/oauth/authorize",
+ ClientId: settings.GitHubKey,
+ ClientSecret: settings.GitHubSecret,
+ }
+
+ // get the OAuth code
+ code := r.FormValue("code")
+ if len(code) == 0 {
+ scope := "repo,repo:status,user:email"
+ state := "FqB4EbagQ2o"
+ redirect := oauth.AuthorizeRedirect(scope, state)
+ http.Redirect(w, r, redirect, http.StatusSeeOther)
+ return nil
+ }
+
+ // exchange code for an auth token
+ token, err := oauth.GrantToken(code)
+ if err != nil {
+ return err
+ }
+
+ // create the client
+ client := github.New(token.AccessToken)
+
+ // get the user information
+ githubUser, err := client.Users.Current()
+ if err != nil {
+ return err
+ }
+
+ // save the github token to the user account
+ u.GithubToken = token.AccessToken
+ u.GithubLogin = githubUser.Login
+ if err := database.SaveUser(u); err != nil {
+ return err
+ }
+
+ http.Redirect(w, r, "/new/github.com", http.StatusSeeOther)
+ return nil
+}
diff --git a/pkg/handler/badges.go b/pkg/handler/badges.go
new file mode 100644
index 000000000..bfe13e612
--- /dev/null
+++ b/pkg/handler/badges.go
@@ -0,0 +1,50 @@
+package handler
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/drone/drone/pkg/database"
+)
+
+// Display a static badge (png format) for a specific
+// repository and an optional branch.
+// TODO this needs to implement basic caching
+func Badge(w http.ResponseWriter, r *http.Request) error {
+ branchParam := r.FormValue(":branch")
+ hostParam := r.FormValue(":host")
+ ownerParam := r.FormValue(":owner")
+ nameParam := r.FormValue(":name")
+ repoSlug := fmt.Sprintf("%s/%s/%s", hostParam, ownerParam, nameParam)
+
+ // get the repo from the database
+ repo, err := database.GetRepoSlug(repoSlug)
+ if err != nil {
+ http.NotFound(w, r)
+ return nil
+ }
+
+ // get the default branch for the repository
+ // if no branch is provided.
+ if len(branchParam) == 0 {
+ branchParam = repo.DefaultBranch()
+ }
+
+ // default badge of "unknown"
+ badge := "/img/build_unknown.png"
+
+ // get the latest commit from the database
+ // for the requested branch
+ commit, err := database.GetBranch(repo.ID, branchParam)
+ if err == nil {
+ switch commit.Status {
+ case "Success":
+ badge = "/img/build_success.png"
+ case "Failing", "Failure":
+ badge = "/img/build_failing.png"
+ }
+ }
+
+ http.Redirect(w, r, badge, http.StatusSeeOther)
+ return nil
+}
diff --git a/pkg/handler/builds.go b/pkg/handler/builds.go
new file mode 100644
index 000000000..090ce50bb
--- /dev/null
+++ b/pkg/handler/builds.go
@@ -0,0 +1,34 @@
+package handler
+
+import (
+ "net/http"
+
+ "github.com/drone/drone/pkg/database"
+ . "github.com/drone/drone/pkg/model"
+)
+
+// Returns the combined stdout / stderr for an individual Build.
+func BuildOut(w http.ResponseWriter, r *http.Request, u *User, repo *Repo) error {
+ hash := r.FormValue(":commit")
+ labl := r.FormValue(":label")
+
+ // get the commit from the database
+ commit, err := database.GetCommitHash(hash, repo.ID)
+ if err != nil {
+ return err
+ }
+
+ // get the build from the database
+ build, err := database.GetBuildSlug(labl, commit.ID)
+ if err != nil {
+ return err
+ }
+
+ return RenderText(w, build.Stdout, http.StatusOK)
+}
+
+// Returns the gzipped stdout / stderr for an individual Build
+func BuildOutGzip(w http.ResponseWriter, r *http.Request, u *User) error {
+ // TODO
+ return nil
+}
diff --git a/pkg/handler/commits.go b/pkg/handler/commits.go
new file mode 100644
index 000000000..8ad1b0a12
--- /dev/null
+++ b/pkg/handler/commits.go
@@ -0,0 +1,56 @@
+package handler
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/drone/drone/pkg/channel"
+ "github.com/drone/drone/pkg/database"
+ . "github.com/drone/drone/pkg/model"
+)
+
+// Display a specific Commit.
+func CommitShow(w http.ResponseWriter, r *http.Request, u *User, repo *Repo) error {
+ hash := r.FormValue(":commit")
+ labl := r.FormValue(":label")
+
+ // get the commit from the database
+ commit, err := database.GetCommitHash(hash, repo.ID)
+ if err != nil {
+ return err
+ }
+
+ // get the builds from the database. a commit can have
+ // multiple sub-builds (or matrix builds)
+ builds, err := database.ListBuilds(commit.ID)
+ if err != nil {
+ return err
+ }
+
+ data := struct {
+ User *User
+ Repo *Repo
+ Commit *Commit
+ Build *Build
+ Builds []*Build
+ Token string
+ }{u, repo, commit, builds[0], builds, ""}
+
+ // get the specific build requested by the user. instead
+ // of a database round trip, we can just loop through the
+ // list and extract the requested build.
+ for _, b := range builds {
+ if b.Slug == labl {
+ data.Build = b
+ break
+ }
+ }
+
+ // generate a token to connect with the websocket
+ // handler and stream output, if the build is running.
+ data.Token = channel.Token(fmt.Sprintf(
+ "%s/commit/%s/builds/%s", repo.Slug, commit.Hash, builds[0].Slug))
+
+ // render the repository template.
+ return RenderTemplate(w, "repo_commit.html", &data)
+}
diff --git a/pkg/handler/handler.go b/pkg/handler/handler.go
new file mode 100644
index 000000000..2b3d28069
--- /dev/null
+++ b/pkg/handler/handler.go
@@ -0,0 +1,192 @@
+package handler
+
+import (
+ "fmt"
+ "log"
+ "net/http"
+ "net/url"
+
+ "github.com/drone/drone/pkg/database"
+ . "github.com/drone/drone/pkg/model"
+)
+
+// ErrorHandler wraps the default http.HandleFunc to handle an
+// error as the return value.
+type ErrorHandler func(w http.ResponseWriter, r *http.Request) error
+
+func (h ErrorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if err := h(w, r); err != nil {
+ log.Print(err)
+ }
+}
+
+// UserHandler wraps the default http.HandlerFunc to include
+// the currently authenticated User in the method signature,
+// in addition to handling an error as the return value.
+type UserHandler func(w http.ResponseWriter, r *http.Request, user *User) error
+
+func (h UserHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ user, err := readUser(r)
+ if err != nil {
+ redirectLogin(w, r)
+ return
+ }
+
+ if err = h(w, r, user); err != nil {
+ log.Print(err)
+ RenderError(w, err, http.StatusBadRequest)
+ }
+}
+
+// AdminHandler wraps the default http.HandlerFunc to include
+// the currently authenticated User in the method signature,
+// in addition to handling an error as the return value. It also
+// verifies the user has Administrative priveleges.
+type AdminHandler func(w http.ResponseWriter, r *http.Request, user *User) error
+
+func (h AdminHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ user, err := readUser(r)
+ if err != nil {
+ redirectLogin(w, r)
+ return
+ }
+
+ // User MUST have administrative priveleges in order
+ // to execute the handler.
+ if user.Admin == false {
+ RenderNotFound(w)
+ return
+ }
+
+ if err = h(w, r, user); err != nil {
+ log.Print(err)
+ RenderError(w, err, http.StatusBadRequest)
+ }
+}
+
+// RepoHandler wraps the default http.HandlerFunc to include
+// the currently authenticated User and requested Repository
+// in the method signature, in addition to handling an error
+// as the return value.
+type RepoHandler func(w http.ResponseWriter, r *http.Request, user *User, repo *Repo) error
+
+func (h RepoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ user, err := readUser(r)
+ if err != nil {
+ redirectLogin(w, r)
+ return
+ }
+
+ // repository name from the URL parameters
+ hostParam := r.FormValue(":host")
+ userParam := r.FormValue(":owner")
+ nameParam := r.FormValue(":name")
+ repoName := fmt.Sprintf("%s/%s/%s", hostParam, userParam, nameParam)
+
+ repo, err := database.GetRepoSlug(repoName)
+ if err != nil {
+ RenderNotFound(w)
+ return
+ }
+
+ // The User must own the repository OR be a member
+ // of the Team that owns the repository.
+ if user.ID != repo.UserID {
+ if member, _ := database.IsMember(user.ID, repo.TeamID); !member {
+ RenderNotFound(w)
+ return
+ }
+ }
+
+ if err = h(w, r, user, repo); err != nil {
+ log.Print(err)
+ RenderError(w, err, http.StatusBadRequest)
+ }
+}
+
+// RepoHandler wraps the default http.HandlerFunc to include
+// the currently authenticated User and requested Repository
+// in the method signature, in addition to handling an error
+// as the return value.
+type RepoAdminHandler func(w http.ResponseWriter, r *http.Request, user *User, repo *Repo) error
+
+func (h RepoAdminHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ user, err := readUser(r)
+ if err != nil {
+ redirectLogin(w, r)
+ return
+ }
+
+ // repository name from the URL parameters
+ hostParam := r.FormValue(":host")
+ userParam := r.FormValue(":owner")
+ nameParam := r.FormValue(":name")
+ repoName := fmt.Sprintf("%s/%s/%s", hostParam, userParam, nameParam)
+
+ repo, err := database.GetRepoSlug(repoName)
+ if err != nil {
+ RenderNotFound(w)
+ return
+ }
+
+ // The User must own the repository OR be a member
+ // of the Team that owns the repository.
+ if user.ID != repo.UserID {
+ if admin, _ := database.IsMemberAdmin(user.ID, repo.TeamID); admin == false {
+ RenderNotFound(w)
+ return
+ }
+ }
+
+ if err = h(w, r, user, repo); err != nil {
+ log.Print(err)
+ RenderError(w, err, http.StatusBadRequest)
+ }
+}
+
+// helper function that reads the currently authenticated
+// user from the given http.Request.
+func readUser(r *http.Request) (*User, error) {
+ username := GetCookie(r, "_sess")
+ if len(username) == 0 {
+ return nil, fmt.Errorf("No user session")
+ }
+
+ // get the user from the database
+ user, err := database.GetUserEmail(username)
+ if err != nil || user == nil || user.ID == 0 {
+ return nil, err
+ }
+
+ return user, nil
+}
+
+// helper function that retrieves the repository based
+// on the URL parameters
+func readRepo(r *http.Request) (*Repo, error) {
+ // get the repo data from the URL parameters
+ hostParam := r.FormValue(":host")
+ userParam := r.FormValue(":owner")
+ nameParam := r.FormValue(":slug")
+ repoSlug := fmt.Sprintf("%s/%s/%s", hostParam, userParam, nameParam)
+
+ // get the repo from the database
+ return database.GetRepoSlug(repoSlug)
+}
+
+// helper function that sends the user to the login page.
+func redirectLogin(w http.ResponseWriter, r *http.Request) {
+ v := url.Values{}
+ v.Add("return_to", r.URL.String())
+ http.Redirect(w, r, "/login?"+v.Encode(), http.StatusSeeOther)
+}
+
+func renderNotFound(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ RenderTemplate(w, "404.amber", nil)
+}
+
+func renderBadRequest(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusBadRequest)
+ RenderTemplate(w, "500.amber", nil)
+}
diff --git a/pkg/handler/hooks.go b/pkg/handler/hooks.go
new file mode 100644
index 000000000..bd2c26a2c
--- /dev/null
+++ b/pkg/handler/hooks.go
@@ -0,0 +1,302 @@
+package handler
+
+import (
+ "database/sql"
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/drone/drone/pkg/build/script"
+ "github.com/drone/drone/pkg/database"
+ . "github.com/drone/drone/pkg/model"
+ "github.com/drone/drone/pkg/queue"
+ "github.com/drone/go-github/github"
+)
+
+// Processes a generic POST-RECEIVE hook and
+// attempts to trigger a build.
+func Hook(w http.ResponseWriter, r *http.Request) error {
+
+ // if this is a pull request route
+ // to a different handler
+ if r.Header.Get("X-Github-Event") == "pull_request" {
+ PullRequestHook(w, r)
+ return nil
+ }
+
+ // get the payload of the message
+ // this should contain a json representation of the
+ // repository and commit details
+ payload := r.FormValue("payload")
+
+ // parse the github Hook payload
+ hook, err := github.ParseHook([]byte(payload))
+ if err != nil {
+ println("could not parse hook")
+ return RenderText(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+ }
+
+ // make sure this is being triggered because of a commit
+ // and not something like a tag deletion or whatever
+ if hook.IsTag() || hook.IsGithubPages() ||
+ hook.IsHead() == false || hook.IsDeleted() {
+ return RenderText(w, http.StatusText(http.StatusOK), http.StatusOK)
+ }
+
+ // get the repo from the URL
+ repoId := r.FormValue("id")
+
+ // get the repo from the database, return error if not found
+ repo, err := database.GetRepoSlug(repoId)
+ if err != nil {
+ return RenderText(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
+ }
+
+ // Get the user that owns the repository
+ user, err := database.GetUser(repo.UserID)
+ if err != nil {
+ return RenderText(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+ }
+
+ // Verify that the commit doesn't already exist.
+ // We should never build the same commit twice.
+ _, err = database.GetCommitHash(hook.Head.Id, repo.ID)
+ if err != nil && err != sql.ErrNoRows {
+ println("commit already exists")
+ return RenderText(w, http.StatusText(http.StatusBadGateway), http.StatusBadGateway)
+ }
+
+ // we really only need:
+ // * repo owner
+ // * repo name
+ // * repo host (github)
+ // * commit hash
+ // * commit timestamp
+ // * commit branch
+ // * commit message
+ // * commit author
+ // * pull request
+
+ // once we have this data we could just send directly to the queue
+ // and let it handle everything else
+
+ commit := &Commit{}
+ commit.RepoID = repo.ID
+ commit.Branch = hook.Branch()
+ commit.Hash = hook.Head.Id
+ commit.Status = "Pending"
+ commit.Created = time.Now().UTC()
+
+ // extract the author and message from the commit
+ // this is kind of experimental, since I don't know
+ // what I'm doing here.
+ if hook.Head != nil && hook.Head.Author != nil {
+ commit.Message = hook.Head.Message
+ commit.Timestamp = hook.Head.Timestamp
+ commit.SetAuthor(hook.Head.Author.Email)
+ } else if hook.Commits != nil && len(hook.Commits) > 0 && hook.Commits[0].Author != nil {
+ commit.Message = hook.Commits[0].Message
+ commit.Timestamp = hook.Commits[0].Timestamp
+ commit.SetAuthor(hook.Commits[0].Author.Email)
+ }
+
+ // get the drone.yml file from GitHub
+ client := github.New(user.GithubToken)
+ content, err := client.Contents.FindRef(repo.Owner, repo.Slug, ".drone.yml", commit.Branch) // TODO should this really be the hash??
+ if err != nil {
+ msg := "No .drone.yml was found in this repository. You need to add one.\n"
+ if err := saveFailedBuild(commit, msg); err != nil {
+ return RenderText(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ }
+ return RenderText(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+ }
+
+ // decode the content. Note: Not sure this will ever happen...it basically means a GitHub API issue
+ raw, err := content.DecodeContent()
+ if err != nil {
+ msg := "Could not decode the yaml from GitHub. Check that your .drone.yml is a valid yaml file.\n"
+ if err := saveFailedBuild(commit, msg); err != nil {
+ return RenderText(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ }
+ return RenderText(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+ }
+
+ // parse the build script
+ buildscript, err := script.ParseBuild(raw)
+ if err != nil {
+ msg := "Could not parse your .drone.yml file. It needs to be a valid drone yaml file.\n\n" + err.Error() + "\n"
+ if err := saveFailedBuild(commit, msg); err != nil {
+ return RenderText(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ }
+ return RenderText(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+ }
+
+ // save the commit to the database
+ if err := database.SaveCommit(commit); err != nil {
+ return RenderText(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ }
+
+ // save the build to the database
+ build := &Build{}
+ build.Slug = "1" // TODO
+ build.CommitID = commit.ID
+ build.Created = time.Now().UTC()
+ build.Status = "Pending"
+ if err := database.SaveBuild(build); err != nil {
+ return RenderText(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ }
+
+ // notify websocket that a new build is pending
+ //realtime.CommitPending(repo.UserID, repo.TeamID, repo.ID, commit.ID, repo.Private)
+ //realtime.BuildPending(repo.UserID, repo.TeamID, repo.ID, commit.ID, build.ID, repo.Private)
+
+ queue.Add(&queue.BuildTask{Repo: repo, Commit: commit, Build: build, Script: buildscript}) //Push(repo, commit, build, buildscript)
+
+ // OK!
+ return RenderText(w, http.StatusText(http.StatusOK), http.StatusOK)
+}
+
+func PullRequestHook(w http.ResponseWriter, r *http.Request) {
+
+ // get the payload of the message
+ // this should contain a json representation of the
+ // repository and commit details
+ payload := r.FormValue("payload")
+
+ println("GOT PR HOOK")
+ println(payload)
+
+ hook, err := github.ParsePullRequestHook([]byte(payload))
+ if err != nil {
+ RenderText(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+ return
+ }
+
+ // ignore these
+ if hook.Action != "opened" && hook.Action != "synchronize" {
+ RenderText(w, http.StatusText(http.StatusOK), http.StatusOK)
+ return
+ }
+
+ // get the repo from the URL
+ repoId := r.FormValue("id")
+
+ // get the repo from the database, return error if not found
+ repo, err := database.GetRepoSlug(repoId)
+ if err != nil {
+ RenderText(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
+ return
+ }
+
+ // Get the user that owns the repository
+ user, err := database.GetUser(repo.UserID)
+ if err != nil {
+ RenderText(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+ return
+ }
+
+ // Verify that the commit doesn't already exist.
+ // We should enver build the same commit twice.
+ _, err = database.GetCommitHash(hook.PullRequest.Head.Sha, repo.ID)
+ if err != nil && err != sql.ErrNoRows {
+ RenderText(w, http.StatusText(http.StatusBadGateway), http.StatusBadGateway)
+ return
+ }
+
+ ///////////////////////////////////////////////////////
+
+ commit := &Commit{}
+ commit.RepoID = repo.ID
+ commit.Branch = hook.PullRequest.Head.Ref
+ commit.Hash = hook.PullRequest.Head.Sha
+ commit.Status = "Pending"
+ commit.Created = time.Now().UTC()
+ commit.Gravatar = hook.PullRequest.User.GravatarId
+ commit.PullRequest = strconv.Itoa(hook.Number)
+ commit.Message = hook.PullRequest.Title
+ // label := p.PullRequest.Head.Labe
+
+ // get the drone.yml file from GitHub
+ client := github.New(user.GithubToken)
+ content, err := client.Contents.FindRef(repo.Owner, repo.Slug, ".drone.yml", commit.Hash) // TODO should this really be the hash??
+ if err != nil {
+ println(err.Error())
+ RenderText(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+ return
+ }
+
+ // decode the content
+ raw, err := content.DecodeContent()
+ if err != nil {
+ RenderText(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+ return
+ }
+
+ // parse the build script
+ buildscript, err := script.ParseBuild(raw)
+ if err != nil {
+ // TODO if the YAML is invalid we should create a commit record
+ // with an ERROR status so that the user knows why a build wasn't
+ // triggered in the system
+ RenderText(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+ return
+ }
+
+ // save the commit to the database
+ if err := database.SaveCommit(commit); err != nil {
+ RenderText(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+
+ // save the build to the database
+ build := &Build{}
+ build.Slug = "1" // TODO
+ build.CommitID = commit.ID
+ build.Created = time.Now().UTC()
+ build.Status = "Pending"
+ if err := database.SaveBuild(build); err != nil {
+ RenderText(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+
+ // notify websocket that a new build is pending
+ // TODO we should, for consistency, just put this inside Queue.Add()
+ queue.Add(&queue.BuildTask{Repo: repo, Commit: commit, Build: build, Script: buildscript})
+
+ // OK!
+ RenderText(w, http.StatusText(http.StatusOK), http.StatusOK)
+}
+
+// Helper method for saving a failed build or commit in the case where it never starts to build.
+// This can happen if the yaml is bad or doesn't exist.
+func saveFailedBuild(commit *Commit, msg string) error {
+
+ // Set the commit to failed
+ commit.Status = "Failure"
+ commit.Created = time.Now().UTC()
+ commit.Finished = commit.Created
+ commit.Duration = 0
+ if err := database.SaveCommit(commit); err != nil {
+ return err
+ }
+
+ // save the build to the database
+ build := &Build{}
+ build.Slug = "1" // TODO: This should not be hardcoded
+ build.CommitID = commit.ID
+ build.Created = time.Now().UTC()
+ build.Finished = build.Created
+ commit.Duration = 0
+ build.Status = "Failure"
+ build.Stdout = msg
+ if err := database.SaveBuild(build); err != nil {
+ return err
+ }
+
+ // TODO: Should the status be Error instead of Failure?
+
+ // TODO: Do we need to update the branch table too?
+
+ return nil
+
+}
diff --git a/pkg/handler/members.go b/pkg/handler/members.go
new file mode 100644
index 000000000..ff0dea55d
--- /dev/null
+++ b/pkg/handler/members.go
@@ -0,0 +1,227 @@
+package handler
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/dchest/authcookie"
+ "github.com/drone/drone/pkg/database"
+ "github.com/drone/drone/pkg/mail"
+ . "github.com/drone/drone/pkg/model"
+)
+
+// Display a list of Team Members.
+func TeamMembers(w http.ResponseWriter, r *http.Request, u *User) error {
+ teamParam := r.FormValue(":team")
+ team, err := database.GetTeamSlug(teamParam)
+ if err != nil {
+ return err
+ }
+ // user must be a team member admin
+ if member, _ := database.IsMemberAdmin(u.ID, team.ID); !member {
+ return fmt.Errorf("Forbidden")
+ }
+ members, err := database.ListMembers(team.ID)
+ if err != nil {
+ return err
+ }
+ data := struct {
+ User *User
+ Team *Team
+ Members []*Member
+ }{u, team, members}
+ return RenderTemplate(w, "team_members.html", &data)
+}
+
+// Return an HTML form for creating a new Team Member.
+func TeamMemberAdd(w http.ResponseWriter, r *http.Request, u *User) error {
+ teamParam := r.FormValue(":team")
+ team, err := database.GetTeamSlug(teamParam)
+ if err != nil {
+ return err
+ }
+ if member, _ := database.IsMemberAdmin(u.ID, team.ID); !member {
+ return fmt.Errorf("Forbidden")
+ }
+ data := struct {
+ User *User
+ Team *Team
+ }{u, team}
+ return RenderTemplate(w, "members_add.html", &data)
+}
+
+// Return an HTML form for editing a Team Member.
+func TeamMemberEdit(w http.ResponseWriter, r *http.Request, u *User) error {
+ teamParam := r.FormValue(":team")
+ team, err := database.GetTeamSlug(teamParam)
+ if err != nil {
+ return err
+ }
+ if member, _ := database.IsMemberAdmin(u.ID, team.ID); !member {
+ return fmt.Errorf("Forbidden")
+ }
+
+ // get the ID from the URL parameter
+ idstr := r.FormValue("id")
+ id, err := strconv.Atoi(idstr)
+ if err != nil {
+ return err
+ }
+
+ user, err := database.GetUser(int64(id))
+ if err != nil {
+ return err
+ }
+ member, err := database.GetMember(user.ID, team.ID)
+ if err != nil {
+ return err
+ }
+ data := struct {
+ User *User
+ Team *Team
+ Member *Member
+ }{u, team, member}
+ return RenderTemplate(w, "members_edit.html", &data)
+}
+
+// Update a specific Team Member.
+func TeamMemberUpdate(w http.ResponseWriter, r *http.Request, u *User) error {
+ roleParam := r.FormValue("Role")
+ teamParam := r.FormValue(":team")
+
+ // get the team from the database
+ team, err := database.GetTeamSlug(teamParam)
+ if err != nil {
+ return RenderError(w, err, http.StatusNotFound)
+ }
+ // verify the user is a admin member of the team
+ if member, _ := database.IsMemberAdmin(u.ID, team.ID); !member {
+ return fmt.Errorf("Forbidden")
+ }
+
+ // get the ID from the URL parameter
+ idstr := r.FormValue("id")
+ id, err := strconv.Atoi(idstr)
+ if err != nil {
+ return err
+ }
+
+ // get the user from the database
+ user, err := database.GetUser(int64(id))
+ if err != nil {
+ return RenderError(w, err, http.StatusNotFound)
+ }
+
+ // add the user to the team
+ if err := database.SaveMember(user.ID, team.ID, roleParam); err != nil {
+ return RenderError(w, err, http.StatusInternalServerError)
+ }
+
+ return RenderText(w, http.StatusText(http.StatusOK), http.StatusOK)
+}
+
+// Delete a specific Team Member.
+func TeamMemberDelete(w http.ResponseWriter, r *http.Request, u *User) error {
+ // get the team from the database
+ teamParam := r.FormValue(":team")
+ team, err := database.GetTeamSlug(teamParam)
+ if err != nil {
+ return RenderNotFound(w)
+ }
+
+ if member, _ := database.IsMemberAdmin(u.ID, team.ID); !member {
+ return fmt.Errorf("Forbidden")
+ }
+
+ // get the ID from the URL parameter
+ idstr := r.FormValue("id")
+ id, err := strconv.Atoi(idstr)
+ if err != nil {
+ return err
+ }
+
+ // get the user from the database
+ user, err := database.GetUser(int64(id))
+ if err != nil {
+ return RenderNotFound(w)
+ }
+ // must be at least 1 member
+ members, err := database.ListMembers(team.ID)
+ if err != nil {
+ return err
+ } else if len(members) == 1 {
+ return fmt.Errorf("There must be at least 1 member per team")
+ }
+ // delete the member
+ database.DeleteMember(user.ID, team.ID)
+ http.Redirect(w, r, fmt.Sprintf("/account/team/%s/members", team.Name), http.StatusSeeOther)
+ return nil
+}
+
+// Invite a new Team Member.
+func TeamMemberInvite(w http.ResponseWriter, r *http.Request, u *User) error {
+ teamParam := r.FormValue(":team")
+ mailParam := r.FormValue("email")
+ team, err := database.GetTeamSlug(teamParam)
+ if err != nil {
+ return RenderError(w, err, http.StatusNotFound)
+ }
+ if member, _ := database.IsMemberAdmin(u.ID, team.ID); !member {
+ return fmt.Errorf("Forbidden")
+ }
+
+ // generate a token that is valid for 3 days to join the team
+ token := authcookie.New(team.Name, time.Now().Add(72*time.Hour), secret)
+
+ // hostname from settings
+ hostname := database.SettingsMust().URL().String()
+ emailEnabled := database.SettingsMust().SmtpServer != ""
+
+ if !emailEnabled {
+ // Email is not enabled, so must let the user know the signup link
+ link := fmt.Sprintf("%v/accept?token=%v", hostname, token)
+ return RenderText(w, link, http.StatusOK)
+ }
+
+ // send the invitation
+ data := struct {
+ User *User
+ Team *Team
+ Token string
+ Host string
+ }{u, team, token, hostname}
+
+ // send email async
+ go mail.SendInvitation(team.Name, mailParam, &data)
+
+ return RenderText(w, http.StatusText(http.StatusOK), http.StatusOK)
+}
+
+func TeamMemberAccept(w http.ResponseWriter, r *http.Request, u *User) error {
+ // get the team name from the token
+ token := r.FormValue("token")
+ teamName := authcookie.Login(token, secret)
+ if len(teamName) == 0 {
+ return ErrInvalidTeamName
+ }
+
+ // get the team from the database
+ // TODO it might make more sense to use the ID in case the Slug changes
+ team, err := database.GetTeamSlug(teamName)
+ if err != nil {
+ return RenderError(w, err, http.StatusNotFound)
+ }
+
+ // add the user to the team.
+ // by default the user has write access to the team, which means
+ // they can add and manage new repositories.
+ if err := database.SaveMember(u.ID, team.ID, RoleWrite); err != nil {
+ return RenderError(w, err, http.StatusInternalServerError)
+ }
+
+ // send the user to the dashboard
+ http.Redirect(w, r, "/dashboard/team/"+team.Name, http.StatusSeeOther)
+ return nil
+}
diff --git a/pkg/handler/repos.go b/pkg/handler/repos.go
new file mode 100644
index 000000000..59453aac4
--- /dev/null
+++ b/pkg/handler/repos.go
@@ -0,0 +1,281 @@
+package handler
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/drone/drone/pkg/channel"
+ "github.com/drone/drone/pkg/database"
+ . "github.com/drone/drone/pkg/model"
+ "github.com/drone/go-github/github"
+
+ "launchpad.net/goyaml"
+)
+
+// Display a Repository dashboard.
+func RepoDashboard(w http.ResponseWriter, r *http.Request, u *User, repo *Repo) error {
+ branch := r.FormValue(":branch")
+
+ // get a list of all branches
+ branches, err := database.ListBranches(repo.ID)
+ if err != nil {
+ return err
+ }
+
+ // if no branch is provided then we'll
+ // want to use a default value.
+ if len(branch) == 0 {
+ branch = repo.DefaultBranch()
+ }
+
+ // get a list of recent commits for the
+ // repository and specific branch
+ commits, err := database.ListCommits(repo.ID, branch)
+ if err != nil {
+ return err
+ }
+
+ // get a token that can be exchanged with the
+ // websocket handler to authorize listening
+ // for a stream of changes for this repository
+ token := channel.Create(repo.Slug)
+
+ data := struct {
+ User *User
+ Repo *Repo
+ Branches []*Commit
+ Commits []*Commit
+ Branch string
+ Token string
+ }{u, repo, branches, commits, branch, token}
+
+ return RenderTemplate(w, "repo_dashboard.html", &data)
+}
+
+func RepoAdd(w http.ResponseWriter, r *http.Request, u *User) error {
+ teams, err := database.ListTeams(u.ID)
+ if err != nil {
+ return err
+ }
+ data := struct {
+ User *User
+ Teams []*Team
+ }{u, teams}
+ // if the user hasn't linked their GitHub account
+ // render a different template
+ if len(u.GithubToken) == 0 {
+ return RenderTemplate(w, "github_link.html", &data)
+ }
+ // otherwise display the template for adding
+ // a new GitHub repository.
+ return RenderTemplate(w, "github_add.html", &data)
+}
+
+func RepoCreateGithub(w http.ResponseWriter, r *http.Request, u *User) error {
+ teamName := r.FormValue("team")
+ owner := r.FormValue("owner")
+ name := r.FormValue("name")
+
+ // get the github settings from the database
+ settings := database.SettingsMust()
+
+ // create the GitHub client
+ client := github.New(u.GithubToken)
+ githubRepo, err := client.Repos.Find(owner, name)
+ if err != nil {
+ return err
+ }
+
+ repo, err := NewGitHubRepo(owner, name, githubRepo.Private)
+ if err != nil {
+ return err
+ }
+
+ repo.UserID = u.ID
+
+ // if the user chose to assign to a team account
+ // we need to retrieve the team, verify the user
+ // has access, and then set the team id.
+ if len(teamName) > 0 {
+ team, err := database.GetTeamSlug(teamName)
+ if err != nil {
+ return err
+ }
+
+ // user must be an admin member of the team
+ if ok, _ := database.IsMemberAdmin(u.ID, team.ID); !ok {
+ return fmt.Errorf("Forbidden")
+ }
+
+ repo.TeamID = team.ID
+ }
+
+ // if the repository is private we'll need
+ // to upload a github key to the repository
+ if repo.Private {
+ // name the key
+ keyName := fmt.Sprintf("%s@%s", repo.Owner, settings.Domain)
+
+ // create the github key, or update if one already exists
+ _, err := client.RepoKeys.CreateUpdate(owner, name, repo.PublicKey, keyName)
+ if err != nil {
+ return fmt.Errorf("Unable to add Private Key to your GitHub repository")
+ }
+ }
+
+ // create a hook so that we get notified when code
+ // is pushed to the repository and can execute a build.
+ link := fmt.Sprintf("%s://%s/hook/github.com?id=%s", settings.Scheme, settings.Domain, repo.Slug)
+
+ // add the hook
+ if _, err := client.Hooks.CreateUpdate(owner, name, link); err != nil {
+ return fmt.Errorf("Unable to add Hook to your GitHub repository. %s", err.Error())
+ }
+
+ // Save to the database
+ if err := database.SaveRepo(repo); err != nil {
+ return err
+ }
+
+ return RenderText(w, http.StatusText(http.StatusOK), http.StatusOK)
+}
+
+// Repository Settings
+func RepoSettingsForm(w http.ResponseWriter, r *http.Request, u *User, repo *Repo) error {
+
+ // get the list of teams
+ teams, err := database.ListTeams(u.ID)
+ if err != nil {
+ return err
+ }
+
+ data := struct {
+ Repo *Repo
+ User *User
+ Teams []*Team
+ Owner *User
+ Team *Team
+ }{Repo: repo, User: u, Teams: teams}
+
+ // get the repo owner
+ if repo.TeamID > 0 {
+ data.Team, err = database.GetTeam(repo.TeamID)
+ if err != nil {
+ return err
+ }
+ }
+
+ // get the team owner
+ data.Owner, err = database.GetUser(repo.UserID)
+ if err != nil {
+ return err
+ }
+
+ return RenderTemplate(w, "repo_settings.html", &data)
+}
+
+// Repository Params (YAML parameters) Form
+func RepoParamsForm(w http.ResponseWriter, r *http.Request, u *User, repo *Repo) error {
+
+ data := struct {
+ Repo *Repo
+ User *User
+ Textarea string
+ }{repo, u, ""}
+
+ if repo.Params != nil && len(repo.Params) != 0 {
+ raw, _ := goyaml.Marshal(&repo.Params)
+ data.Textarea = string(raw)
+ }
+
+ return RenderTemplate(w, "repo_params.html", &data)
+}
+
+func RepoBadges(w http.ResponseWriter, r *http.Request, u *User, repo *Repo) error {
+ // hostname from settings
+ hostname := database.SettingsMust().URL().String()
+
+ data := struct {
+ Repo *Repo
+ User *User
+ Host string
+ }{repo, u, hostname}
+ return RenderTemplate(w, "repo_badges.html", &data)
+}
+
+func RepoKeys(w http.ResponseWriter, r *http.Request, u *User, repo *Repo) error {
+ data := struct {
+ Repo *Repo
+ User *User
+ }{repo, u}
+ return RenderTemplate(w, "repo_keys.html", &data)
+}
+
+// Updates an existing repository.
+func RepoUpdate(w http.ResponseWriter, r *http.Request, u *User, repo *Repo) error {
+ switch r.FormValue("action") {
+ case "params":
+ repo.Params = map[string]string{}
+ if err := goyaml.Unmarshal([]byte(r.FormValue("params")), &repo.Params); err != nil {
+ return err
+ }
+ default:
+ repo.Disabled = len(r.FormValue("Disabled")) == 0
+ repo.DisabledPullRequest = len(r.FormValue("DisabledPullRequest")) == 0
+
+ // value of "" indicates the currently authenticated user
+ // should be set as the administrator.
+ if len(r.FormValue("Owner")) == 0 {
+ repo.UserID = u.ID
+ repo.TeamID = 0
+ } else {
+ // else the user has chosen a team
+ team, err := database.GetTeamSlug(r.FormValue("Owner"))
+ if err != nil {
+ return err
+ }
+
+ // verify the user is a member of the team
+ if member, _ := database.IsMemberAdmin(u.ID, team.ID); !member {
+ return fmt.Errorf("Forbidden")
+ }
+
+ // set the team ID
+ repo.TeamID = team.ID
+ }
+ }
+
+ // save the page
+ if err := database.SaveRepo(repo); err != nil {
+ return err
+ }
+
+ http.Redirect(w, r, r.URL.Path, http.StatusSeeOther)
+ return nil
+}
+
+// Deletes a specific repository.
+func RepoDeleteForm(w http.ResponseWriter, r *http.Request, u *User, repo *Repo) error {
+ data := struct {
+ Repo *Repo
+ User *User
+ }{repo, u}
+ return RenderTemplate(w, "repo_delete.html", &data)
+}
+
+// Deletes a specific repository.
+func RepoDelete(w http.ResponseWriter, r *http.Request, u *User, repo *Repo) error {
+ // the user must confirm their password before deleting
+ password := r.FormValue("password")
+ if err := u.ComparePassword(password); err != nil {
+ return err
+ }
+
+ // delete the repo
+ if err := database.DeleteRepo(repo.ID); err != nil {
+ return err
+ }
+
+ http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
+ return nil
+}
diff --git a/pkg/handler/teams.go b/pkg/handler/teams.go
new file mode 100644
index 000000000..2e532ff99
--- /dev/null
+++ b/pkg/handler/teams.go
@@ -0,0 +1,152 @@
+package handler
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/drone/drone/pkg/database"
+ . "github.com/drone/drone/pkg/model"
+)
+
+// Display a specific Team.
+func TeamShow(w http.ResponseWriter, r *http.Request, u *User) error {
+ teamParam := r.FormValue(":team")
+ team, err := database.GetTeamSlug(teamParam)
+ if err != nil {
+ return err
+ }
+ if member, _ := database.IsMember(u.ID, team.ID); !member {
+ return fmt.Errorf("Forbidden")
+ }
+ // list of repositories owned by Team
+ repos, err := database.ListReposTeam(team.ID)
+ if err != nil {
+ return err
+ }
+ // list all user teams
+ teams, err := database.ListTeams(u.ID)
+ if err != nil {
+ return err
+ }
+ // list of recent commits
+ commits, err := database.ListCommitsTeam(team.ID)
+ if err != nil {
+ return err
+ }
+ data := struct {
+ User *User
+ Team *Team
+ Teams []*Team
+ Repos []*Repo
+ Commits []*RepoCommit
+ }{u, team, teams, repos, commits}
+ return RenderTemplate(w, "team_dashboard.html", &data)
+}
+
+// Return an HTML form for editing a Team.
+func TeamEdit(w http.ResponseWriter, r *http.Request, u *User) error {
+ teamParam := r.FormValue(":team")
+ team, err := database.GetTeamSlug(teamParam)
+ if err != nil {
+ return err
+ }
+ if member, _ := database.IsMemberAdmin(u.ID, team.ID); !member {
+ return fmt.Errorf("Forbidden")
+ }
+ data := struct {
+ User *User
+ Team *Team
+ }{u, team}
+ return RenderTemplate(w, "team_profile.html", &data)
+}
+
+// Return an HTML form for creating a Team.
+func TeamAdd(w http.ResponseWriter, r *http.Request, u *User) error {
+ return RenderTemplate(w, "user_teams_add.html", struct{ User *User }{u})
+}
+
+// Create a new Team.
+func TeamCreate(w http.ResponseWriter, r *http.Request, u *User) error {
+ // set the name and email from the form data
+ team := Team{}
+ team.SetName(r.FormValue("name"))
+ team.SetEmail(r.FormValue("email"))
+
+ if err := team.Validate(); err != nil {
+ return RenderError(w, err, http.StatusBadRequest)
+ }
+ if err := database.SaveTeam(&team); err != nil {
+ return RenderError(w, err, http.StatusBadRequest)
+ }
+
+ // add default member to the team (me)
+ if err := database.SaveMember(u.ID, team.ID, RoleOwner); err != nil {
+ return RenderError(w, err, http.StatusInternalServerError)
+ }
+
+ return RenderText(w, http.StatusText(http.StatusOK), http.StatusOK)
+}
+
+// Update a specific Team.
+func TeamUpdate(w http.ResponseWriter, r *http.Request, u *User) error {
+ // get team from the database
+ teamName := r.FormValue(":team")
+ team, err := database.GetTeamSlug(teamName)
+ if err != nil {
+ return fmt.Errorf("Forbidden")
+ }
+ if member, _ := database.IsMemberAdmin(u.ID, team.ID); !member {
+ return fmt.Errorf("Forbidden")
+ }
+
+ team.Name = r.FormValue("name")
+ team.SetEmail(r.FormValue("email"))
+
+ if err := team.Validate(); err != nil {
+ return RenderError(w, err, http.StatusBadRequest)
+ }
+ if err := database.SaveTeam(team); err != nil {
+ return RenderError(w, err, http.StatusBadRequest)
+ }
+
+ return RenderText(w, http.StatusText(http.StatusOK), http.StatusOK)
+}
+
+// Delete Confirmation Page
+func TeamDeleteConfirm(w http.ResponseWriter, r *http.Request, u *User) error {
+ teamParam := r.FormValue(":team")
+ team, err := database.GetTeamSlug(teamParam)
+ if err != nil {
+ return err
+ }
+ if member, _ := database.IsMemberAdmin(u.ID, team.ID); !member {
+ return fmt.Errorf("Forbidden")
+ }
+ data := struct {
+ User *User
+ Team *Team
+ }{u, team}
+ return RenderTemplate(w, "team_delete.html", &data)
+}
+
+// Delete a specific Team.
+func TeamDelete(w http.ResponseWriter, r *http.Request, u *User) error {
+ // get the team from the database
+ teamParam := r.FormValue(":team")
+ team, err := database.GetTeamSlug(teamParam)
+ if err != nil {
+ return RenderNotFound(w)
+ }
+ if member, _ := database.IsMemberAdmin(u.ID, team.ID); !member {
+ return fmt.Errorf("Forbidden")
+ }
+ // the user must confirm their password before deleting
+ password := r.FormValue("password")
+ if err := u.ComparePassword(password); err != nil {
+ return RenderError(w, err, http.StatusBadRequest)
+ }
+
+ database.DeleteTeam(team.ID)
+ http.Redirect(w, r, "/account/user/teams", http.StatusSeeOther)
+ return nil
+}
diff --git a/pkg/handler/testing/team_test.go b/pkg/handler/testing/team_test.go
new file mode 100644
index 000000000..6c0859959
--- /dev/null
+++ b/pkg/handler/testing/team_test.go
@@ -0,0 +1,173 @@
+package testing
+
+import (
+ //"net/http"
+ //"net/http/httptest"
+ //"net/url"
+ "testing"
+
+ //"github.com/drone/drone/database"
+ . "github.com/drone/drone/database/testing"
+ //"github.com/drone/drone/handler"
+ . "github.com/smartystreets/goconvey/convey"
+)
+
+func TestTeamProfilePage(t *testing.T) {
+ // seed the database with values
+ Setup()
+ defer Teardown()
+
+ // dummy request
+ //req := http.Request{}
+ //req.Form = url.Values{}
+
+ Convey("Team Profile Page", t, func() {
+ Convey("View Profile Information", func() {
+
+ SkipConvey("Email Address is correct", func() {
+
+ })
+ SkipConvey("Team Name is correct", func() {
+
+ })
+ SkipConvey("GitHub Login is correct", func() {
+
+ })
+ SkipConvey("Bitbucket Login is correct", func() {
+
+ })
+ })
+ Convey("Update Email Address", func() {
+ SkipConvey("With a Valid Email Address", func() {
+
+ })
+ SkipConvey("With an Invalid Email Address", func() {
+
+ })
+ SkipConvey("With an Empty Email Address", func() {
+
+ })
+ })
+
+ Convey("Update Team Name", func() {
+ SkipConvey("With a Valid Name", func() {
+
+ })
+ SkipConvey("With an Invalid Name", func() {
+
+ })
+ SkipConvey("With an Empty Name", func() {
+
+ })
+ })
+
+ Convey("Delete the Team", func() {
+ SkipConvey("Providing an Invalid Password", func() {
+
+ })
+ SkipConvey("Providing a Valid Password", func() {
+
+ })
+ })
+ })
+}
+
+func TestTeamMembersPage(t *testing.T) {
+ // seed the database with values
+ Setup()
+ defer Teardown()
+
+ // dummy request
+ //req := http.Request{}
+ //req.Form = url.Values{}
+
+ Convey("Team Members Page", t, func() {
+ SkipConvey("View List of Team Members", func() {
+
+ })
+ SkipConvey("Add a New Team Member", func() {
+
+ })
+
+ Convey("Edit a Team Member", func() {
+ SkipConvey("Modify the Role", func() {
+
+ })
+ SkipConvey("Change to an Invalid Role", func() {
+
+ })
+ SkipConvey("Change from Owner to Read", func() {
+
+ })
+ })
+
+ Convey("Delete a Team Member", func() {
+ SkipConvey("Delete a Read-only Member", func() {
+
+ })
+ SkipConvey("Delete the Last Member", func() {
+
+ })
+ SkipConvey("Delete the Owner", func() {
+
+ })
+ })
+
+ Convey("Accept Membership", func() {
+ SkipConvey("Valid Invitation", func() {
+
+ })
+ SkipConvey("Expired Invitation", func() {
+
+ })
+ SkipConvey("Invalid or Forged Invitation", func() {
+
+ })
+ })
+ })
+}
+
+func TestDashboardPage(t *testing.T) {
+ // seed the database with values
+ Setup()
+ defer Teardown()
+
+ // dummy request
+ //req := http.Request{}
+ //req.Form = url.Values{}
+
+ SkipConvey("Team Dashboard", t, func() {
+
+ })
+
+ SkipConvey("User Dashboard", t, func() {
+
+ })
+
+ SkipConvey("Repo Dashboard", t, func() {
+
+ })
+
+ SkipConvey("Repo Settings", t, func() {
+
+ })
+
+ SkipConvey("Commit Dashboard", t, func() {
+
+ })
+
+ Convey("User Account", t, func() {
+ SkipConvey("Login", func() {
+
+ })
+ SkipConvey("Logout", func() {
+
+ })
+ SkipConvey("Register", func() {
+
+ })
+ SkipConvey("Sign Up", func() {
+
+ })
+ })
+}
diff --git a/pkg/handler/testing/users_test.go b/pkg/handler/testing/users_test.go
new file mode 100644
index 000000000..db64c0256
--- /dev/null
+++ b/pkg/handler/testing/users_test.go
@@ -0,0 +1,172 @@
+package testing
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/drone/drone/database"
+ . "github.com/drone/drone/database/testing"
+ "github.com/drone/drone/handler"
+ . "github.com/drone/drone/model"
+ . "github.com/smartystreets/goconvey/convey"
+)
+
+func TestUserProfilePage(t *testing.T) {
+ // seed the database with values
+ Setup()
+ defer Teardown()
+
+ // dummy request
+ req := http.Request{}
+ req.Form = url.Values{}
+
+ Convey("User Profile", t, func() {
+ SkipConvey("View Profile Information", func() {
+ user, _ := database.GetUser(1)
+ res := httptest.NewRecorder()
+ handler.UserUpdate(res, &req, user)
+
+ Convey("Email Address is correct", func() {
+
+ })
+ Convey("User Name is correct", func() {
+
+ })
+ })
+ Convey("Update Email Address", func() {
+ Convey("With a Valid Email Address", func() {
+ user, _ := database.GetUser(1)
+ req.Form.Set("name", "John Smith")
+ req.Form.Set("email", "John.Smith@gmail.com")
+ res := httptest.NewRecorder()
+ handler.UserUpdate(res, &req, user)
+
+ So(res.Code, ShouldEqual, http.StatusOK)
+ })
+ Convey("With an Invalid Email Address", func() {
+ user, _ := database.GetUser(1)
+ req.Form.Set("name", "John Smith")
+ req.Form.Set("email", "John.Smith")
+ res := httptest.NewRecorder()
+ handler.UserUpdate(res, &req, user)
+
+ So(res.Code, ShouldEqual, http.StatusBadRequest)
+ So(res.Body.String(), ShouldContainSubstring, ErrInvalidEmail.Error())
+ })
+ Convey("With an Empty Email Address", func() {
+ user, _ := database.GetUser(1)
+ req.Form.Set("name", "John Smith")
+ req.Form.Set("email", "")
+ res := httptest.NewRecorder()
+ handler.UserUpdate(res, &req, user)
+
+ So(res.Code, ShouldEqual, http.StatusBadRequest)
+ So(res.Body.String(), ShouldContainSubstring, ErrInvalidEmail.Error())
+ })
+ Convey("With a Duplicate Email Address", func() {
+ user, _ := database.GetUser(1)
+ req.Form.Set("name", "John Smith")
+ req.Form.Set("email", "cavepig@gmail.com")
+ res := httptest.NewRecorder()
+ handler.UserUpdate(res, &req, user)
+
+ So(res.Code, ShouldEqual, http.StatusBadRequest)
+ })
+ })
+
+ Convey("Update User Name", func() {
+ Convey("With a Valid Name", func() {
+ user, _ := database.GetUser(1)
+ req.Form.Set("name", "John Smith")
+ req.Form.Set("email", "John.Smith@gmail.com")
+ res := httptest.NewRecorder()
+ handler.UserUpdate(res, &req, user)
+
+ So(res.Code, ShouldEqual, http.StatusOK)
+ })
+ Convey("With an Empty Name", func() {
+ user, _ := database.GetUser(1)
+ req.Form.Set("name", "")
+ req.Form.Set("email", "John.Smith@gmail.com")
+ res := httptest.NewRecorder()
+ handler.UserUpdate(res, &req, user)
+
+ So(res.Code, ShouldEqual, http.StatusBadRequest)
+ So(res.Body.String(), ShouldContainSubstring, ErrInvalidUserName.Error())
+ })
+ })
+
+ Convey("Change Password", func() {
+ Convey("To a Valid Password", func() {
+ user, _ := database.GetUser(1)
+ req.Form.Set("password", "password123")
+ res := httptest.NewRecorder()
+ handler.UserPassUpdate(res, &req, user)
+
+ So(res.Code, ShouldEqual, http.StatusOK)
+ So(user.ComparePassword("password123"), ShouldBeNil)
+ })
+ Convey("To an Invalid Password, too short", func() {
+ user, _ := database.GetUser(1)
+ req.Form.Set("password", "123")
+ res := httptest.NewRecorder()
+ handler.UserPassUpdate(res, &req, user)
+
+ So(res.Code, ShouldEqual, http.StatusBadRequest)
+ })
+ })
+ Convey("Delete the Account", func() {
+ Convey("Providing an Invalid Password", func() {
+ user, _ := database.GetUser(1)
+ req.Form.Set("password", "password111")
+ res := httptest.NewRecorder()
+ handler.UserDelete(res, &req, user)
+
+ So(res.Code, ShouldEqual, http.StatusBadRequest)
+ })
+ SkipConvey("Providing a Valid Password", func() {
+ // TODO Skipping because there are no teampltes
+ // loaded which will cause a panic
+ user, _ := database.GetUser(2)
+ req.Form.Set("password", "password")
+ res := httptest.NewRecorder()
+ handler.UserDelete(res, &req, user)
+
+ So(res.Code, ShouldEqual, http.StatusOK)
+ })
+ })
+ })
+}
+
+func TestUserTeamPage(t *testing.T) {
+ // seed the database with values
+ Setup()
+ defer Teardown()
+
+ // dummy request
+ //req := http.Request{}
+ //req.Form = url.Values{}
+
+ Convey("User Team Page", t, func() {
+ SkipConvey("View List of Teams", func() {
+
+ })
+ SkipConvey("View Empty List of Teams", func() {
+
+ })
+ })
+
+ Convey("Create a Team", t, func() {
+ SkipConvey("With an Invalid Name", func() {
+
+ })
+ SkipConvey("With an Invalid Email", func() {
+
+ })
+ SkipConvey("With a Valid Name and Email", func() {
+
+ })
+ })
+}
diff --git a/pkg/handler/users.go b/pkg/handler/users.go
new file mode 100644
index 000000000..abfdfec45
--- /dev/null
+++ b/pkg/handler/users.go
@@ -0,0 +1,111 @@
+package handler
+
+import (
+ "net/http"
+
+ "github.com/drone/drone/pkg/database"
+ . "github.com/drone/drone/pkg/model"
+)
+
+// Display the dashboard for a specific user
+func UserShow(w http.ResponseWriter, r *http.Request, u *User) error {
+ // list of repositories owned by User
+ repos, err := database.ListRepos(u.ID)
+ if err != nil {
+ return err
+ }
+ // list of user team accounts
+ teams, err := database.ListTeams(u.ID)
+ if err != nil {
+ return err
+ }
+ // list of recent commits
+ commits, err := database.ListCommitsUser(u.ID)
+ if err != nil {
+ return err
+ }
+
+ data := struct {
+ User *User
+ Repos []*Repo
+ Teams []*Team
+ Commits []*RepoCommit
+ }{u, repos, teams, commits}
+ return RenderTemplate(w, "user_dashboard.html", &data)
+}
+
+// return an HTML form for editing a user
+func UserEdit(w http.ResponseWriter, r *http.Request, u *User) error {
+ return RenderTemplate(w, "user_profile.html", struct{ User *User }{u})
+}
+
+// return an HTML form for editing a user password
+func UserPass(w http.ResponseWriter, r *http.Request, u *User) error {
+ return RenderTemplate(w, "user_password.html", struct{ User *User }{u})
+}
+
+// return an HTML form for deleting a user.
+func UserDeleteConfirm(w http.ResponseWriter, r *http.Request, u *User) error {
+ return RenderTemplate(w, "user_delete.html", struct{ User *User }{u})
+}
+
+// update a specific user
+func UserUpdate(w http.ResponseWriter, r *http.Request, u *User) error {
+ // set the name and email from the form data
+ u.Name = r.FormValue("name")
+ u.SetEmail(r.FormValue("email"))
+
+ if err := u.Validate(); err != nil {
+ return RenderError(w, err, http.StatusBadRequest)
+ }
+ if err := database.SaveUser(u); err != nil {
+ return RenderError(w, err, http.StatusBadRequest)
+ }
+
+ return RenderText(w, http.StatusText(http.StatusOK), http.StatusOK)
+}
+
+// update a specific user's password
+func UserPassUpdate(w http.ResponseWriter, r *http.Request, u *User) error {
+ // set the name and email from the form data
+ pass := r.FormValue("password")
+ if err := u.SetPassword(pass); err != nil {
+ return RenderError(w, err, http.StatusBadRequest)
+ }
+ // save the updated password to the database
+ if err := database.SaveUser(u); err != nil {
+ return RenderError(w, err, http.StatusBadRequest)
+ }
+ return RenderText(w, http.StatusText(http.StatusOK), http.StatusOK)
+}
+
+// delete a specific user.
+func UserDelete(w http.ResponseWriter, r *http.Request, u *User) error {
+ // the user must confirm their password before deleting
+ password := r.FormValue("password")
+ if err := u.ComparePassword(password); err != nil {
+ return RenderError(w, err, http.StatusBadRequest)
+ }
+ // TODO we need to delete all repos, builds, commits, branches, etc
+ // TODO we should transfer ownership of all team-owned projects to the team owner
+ // delete the account
+ if err := database.DeleteUser(u.ID); err != nil {
+ return RenderError(w, err, http.StatusBadRequest)
+ }
+
+ Logout(w, r)
+ return nil
+}
+
+// Display a list of all Teams for the currently authenticated User.
+func UserTeams(w http.ResponseWriter, r *http.Request, u *User) error {
+ teams, err := database.ListTeams(u.ID)
+ if err != nil {
+ return err
+ }
+ data := struct {
+ User *User
+ Teams []*Team
+ }{u, teams}
+ return RenderTemplate(w, "user_teams.html", &data)
+}
diff --git a/pkg/handler/util.go b/pkg/handler/util.go
new file mode 100644
index 000000000..a2f978398
--- /dev/null
+++ b/pkg/handler/util.go
@@ -0,0 +1,112 @@
+package handler
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/dchest/authcookie"
+ "github.com/drone/drone/pkg/template"
+)
+
+// -----------------------------------------------------------------------------
+// Rendering Functions
+
+// Renders the named template for the specified data type
+// and write the output to the http.ResponseWriter.
+func RenderTemplate(w http.ResponseWriter, name string, data interface{}) error {
+ w.Header().Add("Content-Type", "text/html; charset=utf-8")
+ return template.ExecuteTemplate(w, name, data)
+}
+
+// Renders the 404 template for the specified data type
+// and write the output to the http.ResponseWriter.
+func RenderNotFound(w http.ResponseWriter) error {
+ w.Header().Add("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusNotFound)
+ return template.ExecuteTemplate(w, "404.amber", nil)
+}
+
+// Renders the 403 template for the specified data type
+// and write the output to the http.ResponseWriter.
+func RenderForbidden(w http.ResponseWriter) error {
+ w.Header().Add("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusNotFound)
+ return template.ExecuteTemplate(w, "403.amber", nil)
+}
+
+// RenderJson renders a JSON representation of resource v and
+// writes to the http.ResposneWriter.
+func RenderJson(w http.ResponseWriter, data interface{}) error {
+ w.Header().Add("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ return json.NewEncoder(w).Encode(data)
+}
+
+// RenderText write the plain text string to the http.ResposneWriter.
+func RenderText(w http.ResponseWriter, text string, code int) error {
+ w.Header().Add("Content-Type", "text/plain")
+ w.WriteHeader(code)
+ w.Write([]byte(text))
+ return nil
+}
+
+// RenderError renders a text representation of the Error message.
+func RenderError(w http.ResponseWriter, err error, code int) error {
+ return RenderText(w, err.Error(), code)
+}
+
+// -----------------------------------------------------------------------------
+// Cookie Helper functions
+
+// SetCookie signs and writes the cookie value.
+func SetCookie(w http.ResponseWriter, r *http.Request, name, value string) {
+ sec := IsHttps(r)
+ str := authcookie.New(value, time.Now().Add(time.Hour*24), secret)
+ cookie := http.Cookie{
+ Name: name,
+ Value: str,
+ Path: "/",
+ Domain: r.URL.Host,
+ HttpOnly: true,
+ Secure: sec,
+ }
+
+ http.SetCookie(w, &cookie)
+}
+
+func IsHttps(r *http.Request) bool {
+ if r.URL.Scheme == "https" {
+ return true
+ }
+ if strings.HasPrefix(r.Proto, "HTTPS") {
+ return true
+ }
+ if r.Header.Get("X-Forwarded-Proto") == "https" {
+ return true
+ }
+ return false
+}
+
+// GetCookie retrieves and verifies the signed cookie value.
+func GetCookie(r *http.Request, name string) string {
+ cookie, err := r.Cookie(name)
+ if err != nil {
+ return ""
+ }
+ return authcookie.Login(cookie.Value, secret)
+}
+
+// DelCookie deletes a secure cookie.
+func DelCookie(w http.ResponseWriter, r *http.Request, name string) {
+ cookie := http.Cookie{
+ Name: name,
+ Value: "deleted",
+ Path: "/",
+ Domain: r.URL.Host,
+ MaxAge: -1,
+ }
+
+ http.SetCookie(w, &cookie)
+}
diff --git a/pkg/mail/mail.go b/pkg/mail/mail.go
new file mode 100644
index 000000000..aa63d444f
--- /dev/null
+++ b/pkg/mail/mail.go
@@ -0,0 +1,144 @@
+package mail
+
+import (
+ "bytes"
+ "fmt"
+ "log"
+ "net/smtp"
+
+ "github.com/drone/drone/pkg/database"
+ "github.com/drone/drone/pkg/template"
+)
+
+// A Message represents an email message. Addresses may be of any
+// form permitted by RFC 822.
+type Message struct {
+ Sender string
+ ReplyTo string // may be empty
+
+ To string
+ Subject string
+ Body string
+}
+
+// Sends a activation email to the User.
+func SendActivation(to string, data interface{}) error {
+ msg := Message{}
+ msg.Subject = "[drone.io] Account Activation"
+ msg.To = to
+
+ var buf bytes.Buffer
+ err := template.ExecuteTemplate(&buf, "activation.html", &data)
+ if err != nil {
+ log.Print(err)
+ return err
+ }
+ msg.Body = buf.String()
+
+ return Send(&msg)
+}
+
+// Sends an invitation to join a Team
+func SendInvitation(team, to string, data interface{}) error {
+ msg := Message{}
+ msg.Subject = "Invited to join " + team
+ msg.To = to
+
+ var buf bytes.Buffer
+ err := template.ExecuteTemplate(&buf, "invitation.html", &data)
+ if err != nil {
+ log.Print(err)
+ return err
+ }
+ msg.Body = buf.String()
+
+ return Send(&msg)
+}
+
+// Sends an email to the User's email address
+// with Password reset information.
+func SendPassword(to string, data interface{}) error {
+ msg := Message{}
+ msg.Subject = "[drone.io] Password Reset"
+ msg.To = to
+
+ var buf bytes.Buffer
+ err := template.ExecuteTemplate(&buf, "reset_password.html", &data)
+ if err != nil {
+ log.Print(err)
+ return err
+ }
+ msg.Body = buf.String()
+
+ return Send(&msg)
+}
+
+// Sends a build success email to the user.
+func SendSuccess(repo, to string, data interface{}) error {
+ msg := Message{}
+ msg.Subject = "[SUCCESS] " + repo
+ msg.To = to
+
+ var buf bytes.Buffer
+ err := template.ExecuteTemplate(&buf, "success.html", &data)
+ if err != nil {
+ log.Print(err)
+ return err
+ }
+ msg.Body = buf.String()
+
+ return Send(&msg)
+}
+
+// Sends a build failure email to the user.
+func SendFailure(repo, to string, data interface{}) error {
+ msg := Message{}
+ msg.Subject = "[FAILURE] " + repo
+ msg.To = to
+
+ var buf bytes.Buffer
+ err := template.ExecuteTemplate(&buf, "failure.html", &data)
+ if err != nil {
+ log.Print(err)
+ return err
+ }
+ msg.Body = buf.String()
+
+ return Send(&msg)
+}
+
+// Send sends an email message.
+func Send(msg *Message) error {
+ // retieve the system settings from the database
+ // so that we can get the SMTP details.
+ s, err := database.GetSettings()
+ if err != nil {
+ log.Print(err)
+ return err
+ }
+
+ // set the FROM address
+ msg.Sender = s.SmtpAddress
+
+ // format the raw email message body
+ body := fmt.Sprintf(emailTemplate, msg.Sender, msg.To, msg.Subject, msg.Body)
+ auth := smtp.PlainAuth("", s.SmtpUsername, s.SmtpPassword, s.SmtpServer)
+ addr := fmt.Sprintf("%s:%s", s.SmtpServer, s.SmtpPort)
+
+ err = smtp.SendMail(addr, auth, msg.Sender, []string{msg.To}, []byte(body))
+ if err != nil {
+ log.Print(err)
+ return err
+ }
+
+ return nil
+}
+
+// Text-template used to generate a raw Email message
+var emailTemplate = `From: %s
+To: %s
+Subject: %s
+MIME-version: 1.0
+Content-Type: text/html; charset="UTF-8"
+
+%s`
diff --git a/pkg/model/build.go b/pkg/model/build.go
new file mode 100644
index 000000000..44864156e
--- /dev/null
+++ b/pkg/model/build.go
@@ -0,0 +1,72 @@
+package model
+
+import (
+ "fmt"
+ "time"
+)
+
+const (
+ StatusNone = "None"
+ StatusEnqueue = "Pending"
+ StatusStarted = "Started"
+ StatusSuccess = "Success"
+ StatusFailure = "Failure"
+ StatusError = "Error"
+)
+
+type Build struct {
+ ID int64 `meddler:"id,pk" json:"id"`
+ CommitID int64 `meddler:"commit_id" json:"-"`
+ Slug string `meddler:"slug" json:"slug"`
+ Status string `meddler:"status" json:"status"`
+ Started time.Time `meddler:"started,utctime" json:"started"`
+ Finished time.Time `meddler:"finished,utctime" json:"finished"`
+ Duration int64 `meddler:"duration" json:"duration"`
+ Created time.Time `meddler:"created,utctime" json:"created"`
+ Updated time.Time `meddler:"updated,utctime" json:"updated"`
+ Stdout string `meddler:"stdout" json:"-"`
+}
+
+// HumanDuration returns a human-readable approximation of a duration
+// (eg. "About a minute", "4 hours ago", etc.)
+func (b *Build) HumanDuration() string {
+ d := time.Duration(b.Duration)
+ if seconds := int(d.Seconds()); seconds < 1 {
+ return "Less than a second"
+ } else if seconds < 60 {
+ return fmt.Sprintf("%d seconds", seconds)
+ } else if minutes := int(d.Minutes()); minutes == 1 {
+ return "About a minute"
+ } else if minutes < 60 {
+ return fmt.Sprintf("%d minutes", minutes)
+ } else if hours := int(d.Hours()); hours == 1 {
+ return "About an hour"
+ } else if hours < 48 {
+ return fmt.Sprintf("%d hours", hours)
+ } else if hours < 24*7*2 {
+ return fmt.Sprintf("%d days", hours/24)
+ } else if hours < 24*30*3 {
+ return fmt.Sprintf("%d weeks", hours/24/7)
+ } else if hours < 24*365*2 {
+ return fmt.Sprintf("%d months", hours/24/30)
+ }
+ return fmt.Sprintf("%f years", d.Hours()/24/365)
+}
+
+// Returns the Started Date as an ISO8601
+// formatted string.
+func (b *Build) StartedString() string {
+ return b.Started.Format("2006-01-02T15:04:05Z")
+}
+
+// Returns the Started Date as an ISO8601
+// formatted string.
+func (b *Build) FinishedString() string {
+ return b.Finished.Format("2006-01-02T15:04:05Z")
+}
+
+// Returns true if the Build statis is Started
+// or Pending, indicating it is currently running.
+func (b *Build) IsRunning() bool {
+ return (b.Status == StatusStarted || b.Status == StatusEnqueue)
+}
diff --git a/pkg/model/commit.go b/pkg/model/commit.go
new file mode 100644
index 000000000..6170fa51e
--- /dev/null
+++ b/pkg/model/commit.go
@@ -0,0 +1,120 @@
+package model
+
+import (
+ "fmt"
+ "time"
+)
+
+type Commit struct {
+ ID int64 `meddler:"id,pk" json:"id"`
+ RepoID int64 `meddler:"repo_id" json:"-"`
+ Status string `meddler:"status" json:"status"`
+ Started time.Time `meddler:"started,utctime" json:"started"`
+ Finished time.Time `meddler:"finished,utctime" json:"finished"`
+ Duration int64 `meddler:"duration" json:"duration"`
+ Hash string `meddler:"hash" json:"hash"`
+ Branch string `meddler:"branch" json:"branch"`
+ PullRequest string `meddler:"pull_request" json:"pull_request"`
+ Author string `meddler:"author" json:"author"`
+ Gravatar string `meddler:"gravatar" json:"gravatar"`
+ Timestamp string `meddler:"timestamp" json:"timestamp"`
+ Message string `meddler:"message" json:"message"`
+
+ Created time.Time `meddler:"created,utctime" json:"created"`
+ Updated time.Time `meddler:"updated,utctime" json:"updated"`
+}
+
+// Returns the Short (--short) Commit Hash.
+func (c *Commit) HashShort() string {
+ if len(c.Hash) > 6 {
+ return c.Hash[:6]
+ } else {
+ return c.Hash
+ }
+}
+
+// Returns the Gravatar Image URL.
+func (c *Commit) Image() string { return fmt.Sprintf(GravatarPattern, c.Gravatar, 42) }
+func (c *Commit) ImageSmall() string { return fmt.Sprintf(GravatarPattern, c.Gravatar, 32) }
+func (c *Commit) ImageLarge() string { return fmt.Sprintf(GravatarPattern, c.Gravatar, 160) }
+
+// Returns the Started Date as an ISO8601
+// formatted string.
+func (c *Commit) StartedString() string {
+ return c.Started.Format("2006-01-02T15:04:05Z")
+}
+
+// Returns the Created Date as an ISO8601
+// formatted string.
+func (c *Commit) CreatedString() string {
+ return c.Created.Format("2006-01-02T15:04:05Z")
+}
+
+// Returns the Started Date as an ISO8601
+// formatted string.
+func (c *Commit) FinishedString() string {
+ return c.Finished.Format("2006-01-02T15:04:05Z")
+}
+
+// Set the Author's email address and calculate the
+// Gravatar hash.
+func (c *Commit) SetAuthor(email string) {
+ c.Author = email
+ c.Gravatar = createGravatar(email)
+}
+
+// Combined Repository and Commit details
+type RepoCommit struct {
+ // Repo Details
+ Slug string `meddler:"slug" json:"slug"`
+ Host string `meddler:"host" json:"host"`
+ Owner string `meddler:"owner" json:"owner"`
+ Name string `meddler:"name" json:"name"`
+
+ // Commit Details
+ Status string `meddler:"status" json:"status"`
+ Started time.Time `meddler:"started,utctime" json:"started"`
+ Finished time.Time `meddler:"finished,utctime" json:"finished"`
+ Duration int64 `meddler:"duration" json:"duration"`
+ Hash string `meddler:"hash" json:"hash"`
+ Branch string `meddler:"branch" json:"branch"`
+ PullRequest string `meddler:"pull_request" json:"pull_request"`
+ Author string `meddler:"author" json:"author"`
+ Gravatar string `meddler:"gravatar" json:"gravatar"`
+ Timestamp string `meddler:"timestamp" json:"timestamp"`
+ Message string `meddler:"message" json:"message"`
+ Created time.Time `meddler:"created,utctime" json:"created"`
+ Updated time.Time `meddler:"updated,utctime" json:"updated"`
+}
+
+// Returns the Short (--short) Commit Hash.
+func (c *RepoCommit) HashShort() string {
+ if len(c.Hash) > 6 {
+ return c.Hash[:6]
+ } else {
+ return c.Hash
+ }
+}
+
+// Returns the Gravatar Image URL.
+func (c *RepoCommit) Image() string { return fmt.Sprintf(GravatarPattern, c.Gravatar, 42) }
+func (c *RepoCommit) ImageSmall() string { return fmt.Sprintf(GravatarPattern, c.Gravatar, 32) }
+func (c *RepoCommit) ImageLarge() string { return fmt.Sprintf(GravatarPattern, c.Gravatar, 160) }
+
+// Returns the Started Date as an ISO8601
+// formatted string.
+func (c *RepoCommit) StartedString() string {
+ return c.Started.Format("2006-01-02T15:04:05Z")
+}
+
+// Returns the Created Date as an ISO8601
+// formatted string.
+func (c *RepoCommit) CreatedString() string {
+ return c.Created.Format("2006-01-02T15:04:05Z")
+}
+
+// Returns the Started Date as an ISO8601
+// formatted string.
+func (c *RepoCommit) FinishedString() string {
+ return c.Finished.Format("2006-01-02T15:04:05Z")
+}
diff --git a/pkg/model/member.go b/pkg/model/member.go
new file mode 100644
index 000000000..28b52bc58
--- /dev/null
+++ b/pkg/model/member.go
@@ -0,0 +1,43 @@
+package model
+
+import (
+ "fmt"
+)
+
+const (
+ // Owners can add / remove team members, create / delete projects,
+ // and have rwx access to all projects owned by the team.
+ RoleOwner = "Owner"
+
+ // Admins can create / delete projects and have rwx acess
+ // to all projects owned by the team.
+ RoleAdmin = "Admin"
+
+ // Write members have rwx access to all projects
+ // owned by the team. They may not create new projects.
+ RoleWrite = "Write"
+
+ // Read members have readonly access to all projects
+ // owned by the team.
+ RoleRead = "Read"
+)
+
+type Role struct {
+ ID int64 `meddler:"id,pk"`
+ TeamID int64 `meddler:"team_id"`
+ UserID int64 `meddler:"user_id"`
+ Role string `meddler:"role"`
+}
+
+type Member struct {
+ ID int64 `meddler:"user_id"`
+ Name string `meddler:"name"`
+ Email string `meddler:"email"`
+ Gravatar string `meddler:"gravatar"`
+ Role string `meddler:"role"`
+}
+
+// Returns the Gravatar Image URL.
+func (m *Member) Image() string { return fmt.Sprintf(GravatarPattern, m.Gravatar, 42) }
+func (m *Member) ImageSmall() string { return fmt.Sprintf(GravatarPattern, m.Gravatar, 32) }
+func (m *Member) ImageLarge() string { return fmt.Sprintf(GravatarPattern, m.Gravatar, 160) }
diff --git a/pkg/model/repo.go b/pkg/model/repo.go
new file mode 100644
index 000000000..5b89ad71c
--- /dev/null
+++ b/pkg/model/repo.go
@@ -0,0 +1,159 @@
+package model
+
+import (
+ "fmt"
+ "time"
+)
+
+const (
+ ScmGit = "git"
+ ScmHg = "hg"
+ ScmSvn = "svn"
+)
+
+const (
+ HostGithub = "github.com"
+ HostBitbucket = "bitbucket.org"
+ HostGoogle = "code.google.com"
+ HostCustom = "custom"
+)
+
+const (
+ DefaultBranchGit = "master"
+ DefaultBranchHg = "default"
+ DefaultBranchSvn = "trunk"
+)
+
+const (
+ githubRepoPattern = "git://github.com/%s/%s.git"
+ githubRepoPatternPrivate = "git@github.com:%s/%s.git"
+ bitbucketRepoPattern = "https://bitbucket.org/%s/%s.git"
+ bitbucketRepoPatternPrivate = "git@bitbucket.org:%s/%s.git"
+)
+
+type Repo struct {
+ ID int64 `meddler:"id,pk" json:"id"`
+
+ // the full, canonical name of the repository, for example:
+ // github.com/bradrydzewski/go.stripe
+ Slug string `meddler:"slug" json:"slug"`
+
+ // the hosting service where the repository is stored,
+ // such as github.com, bitbucket.org, etc
+ Host string `meddler:"host" json:"host"`
+
+ // the owner of the repository on the host system.
+ // for example, the Github username.
+ Owner string `meddler:"owner" json:"owner"`
+
+ // URL-friendly version of a repository name on the
+ // host system.
+ Name string `meddler:"name" json:"name"`
+
+ // A value of True indicates the repository is closed source,
+ // while a value of False indicates the project is open source.
+ Private bool `meddler:"private" json:"private"`
+
+ // A value of True indicates the repository is disabled and
+ // no builds should be executed
+ Disabled bool `meddler:"disabled" json:"disabled"`
+
+ // A value of True indicates that pull requests are disabled
+ // for the repository and no builds will be executed
+ DisabledPullRequest bool `meddler:"disabled_pr" json:"disabled_pr"`
+
+ // indicates the type of repository, such as
+ // Git, Mercurial, Subversion or Bazaar.
+ SCM string `meddler:"scm" json:"scm"`
+
+ // the repository URL, for example:
+ // git://github.com/bradrydzewski/go.stripe.git
+ URL string `meddler:"url" json:"url"`
+
+ // username and password requires to authenticate
+ // to the repository
+ Username string `meddler:"username" json:"username"`
+ Password string `meddler:"password" json:"password"`
+
+ // RSA key pair that will injected into the virtual machine
+ // .ssh/id_rsa and .ssh/id_rsa.pub files.
+ PublicKey string `meddler:"public_key" json:"public_key"`
+ PrivateKey string `meddler:"private_key" json:"public_key"`
+
+ // Parameters stored external to the repository in YAML
+ // format, injected into the Build YAML at runtime.
+ Params map[string]string `meddler:"params,gob" json:"-"`
+
+ // the amount of time, in seconds the build will execute
+ // before exceeding its timelimit and being killed.
+ Timeout int64 `meddler:"timeout" json:"timeout"`
+
+ // Indicates the build should be executed in priveleged
+ // mode. This could, for example, be used to run Docker in Docker.
+ Priveleged bool `meddler:"priveleged" json:"priveleged"`
+
+ // Foreign keys signify the User that created
+ // the repository and team account linked to
+ // the repository.
+ UserID int64 `meddler:"user_id" json:"user_id"`
+ TeamID int64 `meddler:"team_id" json:"team_id"`
+
+ Created time.Time `meddler:"created,utctime" json:"created"`
+ Updated time.Time `meddler:"updated,utctime" json:"updated"`
+}
+
+// Creates a new repository
+func NewRepo(host, owner, name, scm, url string) (*Repo, error) {
+ repo := Repo{}
+ repo.URL = url
+ repo.SCM = scm
+ repo.Host = host
+ repo.Owner = owner
+ repo.Name = name
+ repo.Slug = fmt.Sprintf("%s/%s/%s", host, owner, name)
+ key, err := generatePrivateKey()
+ if err != nil {
+ return nil, err
+ }
+
+ repo.PublicKey = marshalPublicKey(&key.PublicKey)
+ repo.PrivateKey = marshalPrivateKey(key)
+ return &repo, nil
+}
+
+// Creates a new GitHub repository
+func NewGitHubRepo(owner, name string, private bool) (*Repo, error) {
+ var url string
+ switch private {
+ case false:
+ url = fmt.Sprintf(githubRepoPattern, owner, name)
+ case true:
+ url = fmt.Sprintf(githubRepoPatternPrivate, owner, name)
+ }
+ return NewRepo(HostGithub, owner, name, ScmGit, url)
+}
+
+// Creates a new Bitbucket repository
+func NewBitbucketRepo(owner, name string, private bool) (*Repo, error) {
+ var url string
+ switch private {
+ case false:
+ url = fmt.Sprintf(bitbucketRepoPattern, owner, name)
+ case true:
+ url = fmt.Sprintf(bitbucketRepoPatternPrivate, owner, name)
+ }
+ return NewRepo(HostGithub, owner, name, ScmGit, url)
+}
+
+func (r *Repo) DefaultBranch() string {
+ switch r.SCM {
+ case ScmGit:
+ return DefaultBranchGit
+ case ScmHg:
+ return DefaultBranchHg
+ case ScmSvn:
+ return DefaultBranchSvn
+ default:
+ return DefaultBranchGit
+ }
+}
diff --git a/pkg/model/settings.go b/pkg/model/settings.go
new file mode 100644
index 000000000..ec7f69158
--- /dev/null
+++ b/pkg/model/settings.go
@@ -0,0 +1,36 @@
+package model
+
+import (
+ "net/url"
+)
+
+type Settings struct {
+ ID int64 `meddler:"id,pk"`
+
+ // SMTP settings.
+ SmtpServer string `meddler:"smtp_server"`
+ SmtpPort string `meddler:"smtp_port"`
+ SmtpAddress string `meddler:"smtp_address"`
+ SmtpUsername string `meddler:"smtp_username"`
+ SmtpPassword string `meddler:"smtp_password"`
+
+ // GitHub Consumer key and secret.
+ GitHubKey string `meddler:"github_key"`
+ GitHubSecret string `meddler:"github_secret"`
+
+ // Bitbucket Consumer Key and secret.
+ BitbucketKey string `meddler:"bitbucket_key"`
+ BitbucketSecret string `meddler:"bitbucket_secret"`
+
+ // Domain of the server, eg drone.io
+ Domain string `meddler:"hostname"`
+
+ // Scheme of the server, eg https
+ Scheme string `meddler:"scheme"`
+}
+
+func (s *Settings) URL() *url.URL {
+ return &url.URL{
+ Scheme: s.Scheme,
+ Host: s.Domain}
+}
diff --git a/pkg/model/team.go b/pkg/model/team.go
new file mode 100644
index 000000000..e35d2c118
--- /dev/null
+++ b/pkg/model/team.go
@@ -0,0 +1,67 @@
+package model
+
+import (
+ "errors"
+ "fmt"
+ "time"
+)
+
+// ErrInvalidTeamName is returned by the Team validation function
+// when a team name is invalid.
+var ErrInvalidTeamName = errors.New("Invalid Team Name")
+
+type Team struct {
+ ID int64 `meddler:"id,pk" json:"id"`
+ Slug string `meddler:"slug" json:"slug"`
+ Name string `meddler:"name" json:"name"`
+ Email string `meddler:"email" json:"email"`
+ Gravatar string `meddler:"gravatar" json:"gravatar"`
+ Created time.Time `meddler:"created,utctime" json:"created"`
+ Updated time.Time `meddler:"updated,utctime" json:"updated"`
+}
+
+// Creates a new team with the specified email address,
+// and team name.
+func NewTeam(name, email string) *Team {
+ team := Team{}
+ team.SetEmail(email)
+ team.SetName(name)
+ return &team
+}
+
+// Returns the Gravatar Image URL.
+func (t *Team) Image() string { return fmt.Sprintf(GravatarPattern, t.Gravatar, 42) }
+func (t *Team) ImageSmall() string { return fmt.Sprintf(GravatarPattern, t.Gravatar, 32) }
+func (t *Team) ImageLarge() string { return fmt.Sprintf(GravatarPattern, t.Gravatar, 160) }
+
+// Set the name and calculate the slug value.
+func (t *Team) SetName(name string) {
+ t.Name = name
+ t.Slug = createSlug(name)
+}
+
+// Set the email address and calculate the
+// Gravatar hash.
+func (t *Team) SetEmail(email string) {
+ t.Email = email
+ t.Gravatar = createGravatar(email)
+}
+
+// ValidatePassword will compares the supplied password to
+// the user password stored in the database.
+func (t *Team) Validate() error {
+ switch {
+ case len(t.Slug) == 0:
+ return ErrInvalidTeamName
+ case len(t.Slug) >= 255:
+ return ErrInvalidTeamName
+ case len(t.Email) == 0:
+ return ErrInvalidEmail
+ case len(t.Email) >= 255:
+ return ErrInvalidEmail
+ case RegexpEmail.MatchString(t.Email) == false:
+ return ErrInvalidEmail
+ default:
+ return nil
+ }
+}
diff --git a/pkg/model/user.go b/pkg/model/user.go
new file mode 100644
index 000000000..e58df1941
--- /dev/null
+++ b/pkg/model/user.go
@@ -0,0 +1,109 @@
+package model
+
+import (
+ "errors"
+ "fmt"
+ "regexp"
+ "time"
+
+ "code.google.com/p/go.crypto/bcrypt"
+)
+
+var (
+ ErrInvalidUserName = errors.New("Invalid User Name")
+ ErrInvalidPassword = errors.New("Invalid Password")
+ ErrInvalidEmail = errors.New("Invalid Email Address")
+)
+
+// Gravatar URL pattern
+var GravatarPattern = "https://gravatar.com/avatar/%s?s=%v"
+
+// Simple regular expression used to verify that an email
+// address matches the expected standard format.
+var RegexpEmail = regexp.MustCompile(`^[^@]+@[^@.]+\.[^@.]+`)
+
+type User struct {
+ ID int64 `meddler:"id,pk" json:"id"`
+ Email string `meddler:"email" json:"email"`
+ Password string `meddler:"password" json:"-"`
+ Token string `meddler:"token" json:"-"`
+ Name string `meddler:"name" json:"name"`
+ Gravatar string `meddler:"gravatar" json:"gravatar"`
+ Created time.Time `meddler:"created,utctime" json:"created"`
+ Updated time.Time `meddler:"updated,utctime" json:"updated"`
+ Admin bool `meddler:"admin" json:"-"`
+
+ // GitHub OAuth2 token for accessing public repositories.
+ GithubLogin string `meddler:"github_login" json:"-"`
+ GithubToken string `meddler:"github_token" json:"-"`
+
+ // Bitbucket OAuth1.0a token and token secret.
+ BitbucketLogin string `meddler:"bitbucket_login" json:"-"`
+ BitbucketToken string `meddler:"bitbucket_token" json:"-"`
+ BitbucketSecret string `meddler:"bitbucket_secret" json:"-"`
+}
+
+// Creates a new User from the given Name and Email.
+func NewUser(name, email string) *User {
+ user := User{}
+ user.Name = name
+ user.Token = createToken()
+ user.SetEmail(email)
+ return &user
+}
+
+// Returns the Gravatar Image URL.
+func (u *User) Image() string { return fmt.Sprintf(GravatarPattern, u.Gravatar, 42) }
+func (u *User) ImageSmall() string { return fmt.Sprintf(GravatarPattern, u.Gravatar, 32) }
+func (u *User) ImageLarge() string { return fmt.Sprintf(GravatarPattern, u.Gravatar, 160) }
+
+// Set the email address and calculate the
+// Gravatar hash.
+func (u *User) SetEmail(email string) {
+ u.Email = email
+ u.Gravatar = createGravatar(email)
+}
+
+// Set the password and hash with bcrypt
+func (u *User) SetPassword(password string) error {
+ // validate the password is an appropriate size
+ switch {
+ case len(password) < 6:
+ return ErrInvalidPassword
+ case len(password) > 256:
+ return ErrInvalidPassword
+ }
+
+ // convert the password to a hash
+ b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+ if err != nil {
+ return err
+ }
+ // update the user
+ u.Password = string(b)
+ return nil
+}
+
+// Validate verifies all required fields are correctly populated.
+func (u *User) Validate() error {
+ switch {
+ case len(u.Name) == 0:
+ return ErrInvalidUserName
+ case len(u.Name) >= 255:
+ return ErrInvalidUserName
+ case len(u.Email) == 0:
+ return ErrInvalidEmail
+ case len(u.Email) >= 255:
+ return ErrInvalidEmail
+ case RegexpEmail.MatchString(u.Email) == false:
+ return ErrInvalidEmail
+ default:
+ return nil
+ }
+}
+
+// ComparePassword compares the supplied password to
+// the user password stored in the database.
+func (u *User) ComparePassword(password string) error {
+ return bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password))
+}
diff --git a/pkg/model/util.go b/pkg/model/util.go
new file mode 100644
index 000000000..41000e528
--- /dev/null
+++ b/pkg/model/util.go
@@ -0,0 +1,92 @@
+package model
+
+import (
+ "crypto/md5"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/x509"
+ "encoding/pem"
+ "fmt"
+ "strings"
+ "unicode"
+
+ "code.google.com/p/go.crypto/ssh"
+ "code.google.com/p/go.text/unicode/norm"
+ "github.com/dchest/uniuri"
+)
+
+var (
+ lat = []*unicode.RangeTable{unicode.Letter, unicode.Number}
+ nop = []*unicode.RangeTable{unicode.Mark, unicode.Sk, unicode.Lm}
+)
+
+// helper function to create a Gravatar Hash
+// for the given Email address.
+func createGravatar(email string) string {
+ email = strings.ToLower(strings.TrimSpace(email))
+ hash := md5.New()
+ hash.Write([]byte(email))
+ return fmt.Sprintf("%x", hash.Sum(nil))
+}
+
+// helper function to create a Slug for the
+// given string of text.
+func createSlug(s string) string {
+ buf := make([]rune, 0, len(s))
+ dash := false
+ for _, r := range norm.NFKD.String(s) {
+ switch {
+ // unicode 'letters' like mandarin characters pass through
+ case unicode.IsOneOf(lat, r):
+ buf = append(buf, unicode.ToLower(r))
+ dash = true
+ case unicode.IsOneOf(nop, r):
+ // skip
+ case dash:
+ buf = append(buf, '-')
+ dash = false
+ }
+ }
+ if i := len(buf) - 1; i >= 0 && buf[i] == '-' {
+ buf = buf[:i]
+ }
+ return string(buf)
+}
+
+// helper function to create a random 40-byte
+// Token that is URL-friendly.
+func createToken() string {
+ return uniuri.NewLen(40)
+}
+
+// -----------------------------------------------------------------------------
+// SSH Functions
+
+const (
+ RSA_BITS = 2048 // Default number of bits in an RSA key
+ RSA_BITS_MIN = 768 // Minimum number of bits in an RSA key
+)
+
+// helper function to generate an RSA Private Key.
+func generatePrivateKey() (*rsa.PrivateKey, error) {
+ return rsa.GenerateKey(rand.Reader, RSA_BITS)
+}
+
+// helper function that marshalls an RSA Public Key to an SSH
+// .authorized_keys format
+func marshalPublicKey(pubkey *rsa.PublicKey) string {
+ pk, err := ssh.NewPublicKey(pubkey)
+ if err != nil {
+ return ""
+ }
+
+ return string(ssh.MarshalAuthorizedKey(pk))
+}
+
+// helper function that marshalls an RSA Private Key to
+// a PEM encoded file.
+func marshalPrivateKey(privkey *rsa.PrivateKey) string {
+ privateKeyMarshaled := x509.MarshalPKCS1PrivateKey(privkey)
+ privateKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Headers: nil, Bytes: privateKeyMarshaled})
+ return string(privateKeyPEM)
+}
diff --git a/pkg/model/util_test.go b/pkg/model/util_test.go
new file mode 100644
index 000000000..2e612f6da
--- /dev/null
+++ b/pkg/model/util_test.go
@@ -0,0 +1,22 @@
+package model
+
+import (
+ "testing"
+)
+
+func Test_createSlug(t *testing.T) {
+ strings := map[string]string{
+ "John Tyler": "john-tyler",
+ "James K. Polk": "james-k-polk",
+ "George H. W. Bush": "george-h-w-bush",
+ "François Hollande": "francois-hollande",
+ "dàzǒngtǒng": "dazongtong",
+ "大總統": "大總統",
+ }
+
+ for k, v := range strings {
+ if slug := createSlug(k); slug != v {
+ t.Errorf("Expected Slug %s for string %s, got %s", v, k, slug)
+ }
+ }
+}
diff --git a/pkg/plugin/deploy/appfog.go b/pkg/plugin/deploy/appfog.go
new file mode 100644
index 000000000..19e664922
--- /dev/null
+++ b/pkg/plugin/deploy/appfog.go
@@ -0,0 +1,12 @@
+package deploy
+
+import (
+ "github.com/drone/drone/pkg/build/buildfile"
+)
+
+type AppFog struct {
+}
+
+func (a *AppFog) Write(f *buildfile.Buildfile) {
+
+}
diff --git a/pkg/plugin/deploy/cloudcontrol.go b/pkg/plugin/deploy/cloudcontrol.go
new file mode 100644
index 000000000..a9445de37
--- /dev/null
+++ b/pkg/plugin/deploy/cloudcontrol.go
@@ -0,0 +1,12 @@
+package deploy
+
+import (
+ "github.com/drone/drone/pkg/build/buildfile"
+)
+
+type CloudControl struct {
+}
+
+func (c *CloudControl) Write(f *buildfile.Buildfile) {
+
+}
diff --git a/pkg/plugin/deploy/cloudfoundry.go b/pkg/plugin/deploy/cloudfoundry.go
new file mode 100644
index 000000000..b3203636b
--- /dev/null
+++ b/pkg/plugin/deploy/cloudfoundry.go
@@ -0,0 +1,12 @@
+package deploy
+
+import (
+ "github.com/drone/drone/pkg/build/buildfile"
+)
+
+type CloudFoundry struct {
+}
+
+func (c *CloudFoundry) Write(f *buildfile.Buildfile) {
+
+}
diff --git a/pkg/plugin/deploy/deployment.go b/pkg/plugin/deploy/deployment.go
new file mode 100644
index 000000000..71e79c718
--- /dev/null
+++ b/pkg/plugin/deploy/deployment.go
@@ -0,0 +1,42 @@
+package deploy
+
+import (
+ "github.com/drone/drone/pkg/build/buildfile"
+)
+
+// Deploy stores the configuration details
+// for deploying build artifacts when
+// a Build has succeeded
+type Deploy struct {
+ AppFog *AppFog `yaml:"appfog,omitempty"`
+ CloudControl *CloudControl `yaml:"cloudcontrol,omitempty"`
+ CloudFoundry *CloudFoundry `yaml:"cloudfoundry,omitempty"`
+ EngineYard *EngineYard `yaml:"engineyard,omitempty"`
+ Heroku *Heroku `yaml:"heroku,omitempty"`
+ Nodejitsu *Nodejitsu `yaml:"nodejitsu,omitempty"`
+ Openshift *Openshift `yaml:"openshift,omitempty"`
+}
+
+func (d *Deploy) Write(f *buildfile.Buildfile) {
+ if d.AppFog != nil {
+ d.AppFog.Write(f)
+ }
+ if d.CloudControl != nil {
+ d.CloudControl.Write(f)
+ }
+ if d.CloudFoundry != nil {
+ d.CloudFoundry.Write(f)
+ }
+ if d.EngineYard != nil {
+ d.EngineYard.Write(f)
+ }
+ if d.Heroku != nil {
+ d.Heroku.Write(f)
+ }
+ if d.Nodejitsu != nil {
+ d.Nodejitsu.Write(f)
+ }
+ if d.Openshift != nil {
+ d.Openshift.Write(f)
+ }
+}
diff --git a/pkg/plugin/deploy/engineyard.go b/pkg/plugin/deploy/engineyard.go
new file mode 100644
index 000000000..2934f9038
--- /dev/null
+++ b/pkg/plugin/deploy/engineyard.go
@@ -0,0 +1,12 @@
+package deploy
+
+import (
+ "github.com/drone/drone/pkg/build/buildfile"
+)
+
+type EngineYard struct {
+}
+
+func (e *EngineYard) Write(f *buildfile.Buildfile) {
+
+}
diff --git a/pkg/plugin/deploy/git.go b/pkg/plugin/deploy/git.go
new file mode 100644
index 000000000..b2b65d9ca
--- /dev/null
+++ b/pkg/plugin/deploy/git.go
@@ -0,0 +1 @@
+package deploy
diff --git a/pkg/plugin/deploy/heroku.go b/pkg/plugin/deploy/heroku.go
new file mode 100644
index 000000000..4e4aa808d
--- /dev/null
+++ b/pkg/plugin/deploy/heroku.go
@@ -0,0 +1,38 @@
+package deploy
+
+import (
+ "fmt"
+ "github.com/drone/drone/pkg/build/buildfile"
+)
+
+type Heroku struct {
+ App string `yaml:"app,omitempty"`
+ Force bool `yaml:"force,omitempty"`
+ Branch string `yaml:"branch,omitempty"`
+}
+
+func (h *Heroku) Write(f *buildfile.Buildfile) {
+ // get the current commit hash
+ f.WriteCmdSilent("COMMIT=$(git rev-parse HEAD)")
+
+ // set the git user and email based on the individual
+ // that made the commit.
+ f.WriteCmdSilent("git config --global user.name $(git --no-pager log -1 --pretty=format:'%an')")
+ f.WriteCmdSilent("git config --global user.email $(git --no-pager log -1 --pretty=format:'%ae')")
+
+ // add heroku as a git remote
+ f.WriteCmd(fmt.Sprintf("git remote add heroku git@heroku.com:%s.git", h.App))
+
+ switch h.Force {
+ case true:
+ // this is useful when the there are artifacts generated
+ // by the build script, such as less files converted to css,
+ // that need to be deployed to Heroku.
+ f.WriteCmd(fmt.Sprintf("git add -A"))
+ f.WriteCmd(fmt.Sprintf("git commit -m 'adding build artifacts'"))
+ f.WriteCmd(fmt.Sprintf("git push heroku $COMMIT:master --force"))
+ case false:
+ // otherwise we just do a standard git push
+ f.WriteCmd(fmt.Sprintf("git push heroku $COMMIT:master"))
+ }
+}
diff --git a/pkg/plugin/deploy/nodejitsu.go b/pkg/plugin/deploy/nodejitsu.go
new file mode 100644
index 000000000..f1680009d
--- /dev/null
+++ b/pkg/plugin/deploy/nodejitsu.go
@@ -0,0 +1,12 @@
+package deploy
+
+import (
+ "github.com/drone/drone/pkg/build/buildfile"
+)
+
+type Nodejitsu struct {
+}
+
+func (n *Nodejitsu) Write(f *buildfile.Buildfile) {
+
+}
diff --git a/pkg/plugin/deploy/openshift.go b/pkg/plugin/deploy/openshift.go
new file mode 100644
index 000000000..7a25be254
--- /dev/null
+++ b/pkg/plugin/deploy/openshift.go
@@ -0,0 +1,12 @@
+package deploy
+
+import (
+ "github.com/drone/drone/pkg/build/buildfile"
+)
+
+type Openshift struct {
+}
+
+func (o *Openshift) Write(f *buildfile.Buildfile) {
+
+}
diff --git a/pkg/plugin/deploy/ssh.go b/pkg/plugin/deploy/ssh.go
new file mode 100644
index 000000000..b2b65d9ca
--- /dev/null
+++ b/pkg/plugin/deploy/ssh.go
@@ -0,0 +1 @@
+package deploy
diff --git a/pkg/plugin/notify/email.go b/pkg/plugin/notify/email.go
new file mode 100644
index 000000000..ed7b84759
--- /dev/null
+++ b/pkg/plugin/notify/email.go
@@ -0,0 +1,85 @@
+package notify
+
+import (
+ "fmt"
+ "net/smtp"
+)
+
+type Email struct {
+ Recipients []string `yaml:"recipients,omitempty"`
+ Success string `yaml:"on_success"`
+ Failure string `yaml:"on_failure"`
+
+ host string // smtp host address
+ port string // smtp host port
+ user string // smtp username for authentication
+ pass string // smtp password for authentication
+ from string // smtp email address. send from this address
+}
+
+// SetServer is a function that will set the SMTP
+// server location and credentials
+func (e *Email) SetServer(host, port, user, pass, from string) {
+ e.host = host
+ e.port = port
+ e.user = user
+ e.pass = pass
+ e.from = from
+}
+
+// Send will send an email, either success or failure,
+// based on the Commit Status.
+func (e *Email) Send(context *Context) error {
+ switch {
+ case context.Commit.Status == "Success" && e.Success != "never":
+ return e.sendSuccess(context)
+ case context.Commit.Status == "Failure" && e.Failure != "never":
+ return e.sendFailure(context)
+ }
+
+ return nil
+}
+
+// sendFailure sends email notifications to the list of
+// recipients indicating the build failed.
+func (e *Email) sendFailure(context *Context) error {
+ // loop through and email recipients
+ /*for _, email := range e.Recipients {
+ if err := mail.SendFailure(context.Repo.Slug, email, context); err != nil {
+ return err
+ }
+ }*/
+ return nil
+}
+
+// sendSuccess sends email notifications to the list of
+// recipients indicating the build was a success.
+func (e *Email) sendSuccess(context *Context) error {
+ // loop through and email recipients
+ /*for _, email := range e.Recipients {
+ if err := mail.SendSuccess(context.Repo.Slug, email, context); err != nil {
+ return err
+ }
+ }*/
+ return nil
+}
+
+// send is a simple helper function to format and
+// send an email message.
+func (e *Email) send(to, subject, body string) error {
+ // Format the raw email message body
+ raw := fmt.Sprintf(emailTemplate, e.from, to, subject, body)
+ auth := smtp.PlainAuth("", e.user, e.pass, e.host)
+ addr := fmt.Sprintf("%s:%s", e.host, e.port)
+
+ return smtp.SendMail(addr, auth, e.from, []string{to}, []byte(raw))
+}
+
+// text-template used to generate a raw Email message
+var emailTemplate = `From: %s
+To: %s
+Subject: %s
+MIME-version: 1.0
+Content-Type: text/html; charset="UTF-8"
+
+%s`
diff --git a/pkg/plugin/notify/hipchat.go b/pkg/plugin/notify/hipchat.go
new file mode 100644
index 000000000..e652c8e92
--- /dev/null
+++ b/pkg/plugin/notify/hipchat.go
@@ -0,0 +1,64 @@
+package notify
+
+import (
+ "fmt"
+
+ "github.com/andybons/hipchat"
+)
+
+const (
+ startedMessage = "Building %s, commit %s, author %s"
+ successMessage = "Success %s, commit %s, author %s"
+ failureMessage = "Failed %s, commit %s, author %s"
+)
+
+type Hipchat struct {
+ Room string `yaml:"room,omitempty"`
+ Token string `yaml:"token,omitempty"`
+ Started bool `yaml:"on_started,omitempty"`
+ Success bool `yaml:"on_success,omitempty"`
+ Failure bool `yaml:"on_failure,omitempty"`
+}
+
+func (h *Hipchat) Send(context *Context) error {
+ switch {
+ case context.Commit.Status == "Started" && h.Started:
+ return h.sendStarted(context)
+ case context.Commit.Status == "Success" && h.Success:
+ return h.sendSuccess(context)
+ case context.Commit.Status == "Failure" && h.Failure:
+ return h.sendFailure(context)
+ }
+
+ return nil
+}
+
+func (h *Hipchat) sendStarted(context *Context) error {
+ msg := fmt.Sprintf(startedMessage, context.Repo.Name, context.Commit.HashShort(), context.Commit.Author)
+ return h.send(hipchat.ColorYellow, hipchat.FormatHTML, msg)
+}
+
+func (h *Hipchat) sendFailure(context *Context) error {
+ msg := fmt.Sprintf(failureMessage, context.Repo.Name, context.Commit.HashShort(), context.Commit.Author)
+ return h.send(hipchat.ColorRed, hipchat.FormatHTML, msg)
+}
+
+func (h *Hipchat) sendSuccess(context *Context) error {
+ msg := fmt.Sprintf(successMessage, context.Repo.Name, context.Commit.HashShort(), context.Commit.Author)
+ return h.send(hipchat.ColorGreen, hipchat.FormatHTML, msg)
+}
+
+// helper function to send Hipchat requests
+func (h *Hipchat) send(color, format, message string) error {
+ c := hipchat.Client{AuthToken: h.Token}
+ req := hipchat.MessageRequest{
+ RoomId: h.Room,
+ From: "Drone",
+ Message: message,
+ Color: color,
+ MessageFormat: format,
+ Notify: true,
+ }
+
+ return c.PostMessage(req)
+}
diff --git a/pkg/plugin/notify/irc.go b/pkg/plugin/notify/irc.go
new file mode 100644
index 000000000..a3131f139
--- /dev/null
+++ b/pkg/plugin/notify/irc.go
@@ -0,0 +1 @@
+package notify
diff --git a/pkg/plugin/notify/notification.go b/pkg/plugin/notify/notification.go
new file mode 100644
index 000000000..0b80ae4d8
--- /dev/null
+++ b/pkg/plugin/notify/notification.go
@@ -0,0 +1,53 @@
+package notify
+
+import (
+ "github.com/drone/drone/pkg/model"
+)
+
+// Context represents the context of an
+// in-progress build request.
+type Context struct {
+ // Global settings
+ Settings *model.Settings
+
+ // User that owns the repository
+ User *model.User
+
+ // Repository being built.
+ Repo *model.Repo
+
+ // Commit being built
+ Commit *model.Commit
+}
+
+type Sender interface {
+ Send(context *Context) error
+}
+
+// Notification stores the configuration details
+// for notifying a user, or group of users,
+// when their Build has completed.
+type Notification struct {
+ Email *Email `yaml:"email,omitempty"`
+ Webhook *Webhook `yaml:"webhook,omitempty"`
+ Hipchat *Hipchat `yaml:"hipchat,omitempty"`
+}
+
+func (n *Notification) Send(context *Context) error {
+ // send email notifications
+ //if n.Email != nil && n.Email.Enabled {
+ // n.Email.Send(context)
+ //}
+
+ // send email notifications
+ if n.Webhook != nil {
+ n.Webhook.Send(context)
+ }
+
+ // send email notifications
+ if n.Hipchat != nil {
+ n.Hipchat.Send(context)
+ }
+
+ return nil
+}
diff --git a/pkg/plugin/notify/webhook.go b/pkg/plugin/notify/webhook.go
new file mode 100644
index 000000000..7dd23bb56
--- /dev/null
+++ b/pkg/plugin/notify/webhook.go
@@ -0,0 +1,59 @@
+package notify
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+
+ "github.com/drone/drone/pkg/model"
+)
+
+type Webhook struct {
+ URL []string `yaml:"urls,omitempty"`
+ Success bool `yaml:"on_success,omitempty"`
+ Failure bool `yaml:"on_failure,omitempty"`
+}
+
+func (w *Webhook) Send(context *Context) error {
+ switch {
+ case context.Commit.Status == "Success" && w.Success:
+ return w.send(context)
+ case context.Commit.Status == "Failure" && w.Failure:
+ return w.send(context)
+ }
+
+ return nil
+}
+
+// helper function to send HTTP requests
+func (w *Webhook) send(context *Context) error {
+ // data will get posted in this format
+ data := struct {
+ Owner *model.User `json:"owner"`
+ Repo *model.Repo `json:"repository"`
+ Commit *model.Commit `json:"commit"`
+ }{context.User, context.Repo, context.Commit}
+
+ // data json encoded
+ payload, err := json.Marshal(data)
+ if err != nil {
+ return err
+ }
+
+ // loop through and email recipients
+ for _, url := range w.URL {
+ go sendJson(url, payload)
+ }
+ return nil
+}
+
+// helper fuction to sent HTTP Post requests
+// with JSON data as the payload.
+func sendJson(url string, payload []byte) {
+ buf := bytes.NewBuffer(payload)
+ resp, err := http.Post(url, "application/json", buf)
+ if err != nil {
+ return
+ }
+ resp.Body.Close()
+}
diff --git a/pkg/plugin/notify/zapier.go b/pkg/plugin/notify/zapier.go
new file mode 100644
index 000000000..a3131f139
--- /dev/null
+++ b/pkg/plugin/notify/zapier.go
@@ -0,0 +1 @@
+package notify
diff --git a/pkg/plugin/publish/bintray.go b/pkg/plugin/publish/bintray.go
new file mode 100644
index 000000000..30b1a5b2a
--- /dev/null
+++ b/pkg/plugin/publish/bintray.go
@@ -0,0 +1 @@
+package publish
diff --git a/pkg/plugin/publish/dropbox.go b/pkg/plugin/publish/dropbox.go
new file mode 100644
index 000000000..30b1a5b2a
--- /dev/null
+++ b/pkg/plugin/publish/dropbox.go
@@ -0,0 +1 @@
+package publish
diff --git a/pkg/plugin/publish/gems.go b/pkg/plugin/publish/gems.go
new file mode 100644
index 000000000..30b1a5b2a
--- /dev/null
+++ b/pkg/plugin/publish/gems.go
@@ -0,0 +1 @@
+package publish
diff --git a/pkg/plugin/publish/maven.go b/pkg/plugin/publish/maven.go
new file mode 100644
index 000000000..30b1a5b2a
--- /dev/null
+++ b/pkg/plugin/publish/maven.go
@@ -0,0 +1 @@
+package publish
diff --git a/pkg/plugin/publish/npm.go b/pkg/plugin/publish/npm.go
new file mode 100644
index 000000000..30b1a5b2a
--- /dev/null
+++ b/pkg/plugin/publish/npm.go
@@ -0,0 +1 @@
+package publish
diff --git a/pkg/plugin/publish/pub.go b/pkg/plugin/publish/pub.go
new file mode 100644
index 000000000..30b1a5b2a
--- /dev/null
+++ b/pkg/plugin/publish/pub.go
@@ -0,0 +1 @@
+package publish
diff --git a/pkg/plugin/publish/publish.go b/pkg/plugin/publish/publish.go
new file mode 100644
index 000000000..d31088f29
--- /dev/null
+++ b/pkg/plugin/publish/publish.go
@@ -0,0 +1,18 @@
+package publish
+
+import (
+ "github.com/drone/drone/pkg/build/buildfile"
+)
+
+// Publish stores the configuration details
+// for publishing build artifacts when
+// a Build has succeeded
+type Publish struct {
+ S3 *S3 `yaml:"s3,omitempty"`
+}
+
+func (p *Publish) Write(f *buildfile.Buildfile) {
+ if p.S3 != nil {
+ p.S3.Write(f)
+ }
+}
diff --git a/pkg/plugin/publish/pypi.go b/pkg/plugin/publish/pypi.go
new file mode 100644
index 000000000..d46cd60ca
--- /dev/null
+++ b/pkg/plugin/publish/pypi.go
@@ -0,0 +1,2 @@
+package publish
+
diff --git a/pkg/plugin/publish/s3.go b/pkg/plugin/publish/s3.go
new file mode 100644
index 000000000..cfa75e7d1
--- /dev/null
+++ b/pkg/plugin/publish/s3.go
@@ -0,0 +1,85 @@
+package publish
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/drone/drone/pkg/build/buildfile"
+)
+
+type S3 struct {
+ Key string `yaml:"access_key,omitempty"`
+ Secret string `yaml:"secret_key,omitempty"`
+ Bucket string `yaml:"bucket,omitempty"`
+
+ // us-east-1
+ // us-west-1
+ // us-west-2
+ // eu-west-1
+ // ap-southeast-1
+ // ap-southeast-2
+ // ap-northeast-1
+ // sa-east-1
+ Region string `yaml:"region,omitempty"`
+
+ // Indicates the files ACL, which should be one
+ // of the following:
+ // private
+ // public-read
+ // public-read-write
+ // authenticated-read
+ // bucket-owner-read
+ // bucket-owner-full-control
+ Access string `yaml:"acl,omitempty"`
+
+ // Copies the files from the specified directory.
+ // Regexp matching will apply to match multiple
+ // files
+ //
+ // Examples:
+ // /path/to/file
+ // /path/to/*.txt
+ // /path/to/*/*.txt
+ // /path/to/**
+ Source string `yaml:"source,omitempty"`
+ Target string `yaml:"target,omitempty"`
+
+ // Recursive uploads
+ Recursive bool `yaml:"recursive"`
+
+ Branch string `yaml:"branch,omitempty"`
+}
+
+func (s *S3) Write(f *buildfile.Buildfile) {
+ // install the AWS cli using PIP
+ f.WriteCmdSilent("[ -f /usr/bin/sudo ] || pip install awscli 1> /dev/null 2> /dev/null")
+ f.WriteCmdSilent("[ -f /usr/bin/sudo ] && sudo pip install awscli 1> /dev/null 2> /dev/null")
+
+ f.WriteEnv("AWS_ACCESS_KEY_ID", s.Key)
+ f.WriteEnv("AWS_SECRET_ACCESS_KEY", s.Secret)
+
+ // make sure a default region is set
+ if len(s.Region) == 0 {
+ s.Region = "us-east-1"
+ }
+
+ // make sure a default access is set
+ // let's be conservative and assume private
+ if len(s.Region) == 0 {
+ s.Region = "private"
+ }
+
+ // if the target starts with a "/" we need
+ // to remove it, otherwise we might adding
+ // a 3rd slash to s3://
+ if strings.HasPrefix(s.Target, "/") {
+ s.Target = s.Target[1:]
+ }
+
+ switch s.Recursive {
+ case true:
+ f.WriteCmd(fmt.Sprintf(`aws s3 cp %s s3://%s/%s --recursive --acl %s --region %s`, s.Source, s.Bucket, s.Target, s.Access, s.Region))
+ case false:
+ f.WriteCmd(fmt.Sprintf(`aws s3 cp %s s3://%s/%s --acl %s --region %s`, s.Source, s.Bucket, s.Target, s.Access, s.Region))
+ }
+}
diff --git a/pkg/plugin/report/README.md b/pkg/plugin/report/README.md
new file mode 100644
index 000000000..03260a5b1
--- /dev/null
+++ b/pkg/plugin/report/README.md
@@ -0,0 +1,5 @@
+cobertura.go
+coveralls.go
+gocov.go
+junit.go
+phpunit.go
\ No newline at end of file
diff --git a/pkg/queue/init.go b/pkg/queue/init.go
new file mode 100644
index 000000000..c0b4b6972
--- /dev/null
+++ b/pkg/queue/init.go
@@ -0,0 +1,22 @@
+package queue
+
+import (
+ "runtime"
+)
+
+func init() {
+ // get the number of CPUs. Since builds
+ // tend to be CPU-intensive we should only
+ // execute 1 build per CPU.
+ ncpu := runtime.NumCPU()
+
+ // must be at least 1
+ if ncpu < 1 {
+ ncpu = 1
+ }
+
+ // spawn a worker for each CPU
+ for i := 0; i < ncpu; i++ {
+ go work()
+ }
+}
diff --git a/pkg/queue/queue.go b/pkg/queue/queue.go
new file mode 100644
index 000000000..0f0fb7b7a
--- /dev/null
+++ b/pkg/queue/queue.go
@@ -0,0 +1,284 @@
+package queue
+
+import (
+ "bytes"
+ "fmt"
+ bldr "github.com/drone/drone/pkg/build"
+ r "github.com/drone/drone/pkg/build/repo"
+ "github.com/drone/drone/pkg/build/script"
+ "github.com/drone/drone/pkg/build/script/notification"
+ "github.com/drone/drone/pkg/channel"
+ "github.com/drone/drone/pkg/database"
+ "github.com/drone/drone/pkg/mail"
+ . "github.com/drone/drone/pkg/model"
+ "github.com/drone/go-github/github"
+ "path/filepath"
+ "time"
+)
+
+// queue that will store all build tasks until
+// they are processed by a worker.
+var queue = make(chan *BuildTask)
+
+// work is a function that will infinitely
+// run in the background waiting for tasks that
+// it can pull off the queue and execute.
+func work() {
+ var task *BuildTask
+ for {
+ // get work item (pointer) from the queue
+ task = <-queue
+ if task == nil {
+ continue
+ }
+
+ // execute the task
+ task.execute()
+ }
+}
+
+// Add adds the task to the build queue.
+func Add(task *BuildTask) {
+ queue <- task
+}
+
+// BuildTasks represents a build that is pending
+// execution.
+type BuildTask struct {
+ Repo *Repo
+ Commit *Commit
+ Build *Build
+
+ // Build instructions from the .drone.yml
+ // file, unmarshalled.
+ Script *script.Build
+}
+
+// execute will execute the build task and persist
+// the results to the datastore.
+func (b *BuildTask) execute() error {
+ // we need to be sure that we can recover
+ // from any sort panic that could occur
+ // to avoid brining down the entire application
+ defer func() {
+ if e := recover(); e != nil {
+ b.Build.Finished = time.Now().UTC()
+ b.Commit.Finished = time.Now().UTC()
+ b.Build.Duration = b.Build.Finished.Unix() - b.Build.Started.Unix()
+ b.Commit.Duration = b.Build.Finished.Unix() - b.Build.Started.Unix()
+ b.Commit.Status = "Error"
+ b.Build.Status = "Error"
+ database.SaveBuild(b.Build)
+ database.SaveCommit(b.Commit)
+ }
+ }()
+
+ // update commit and build status
+ b.Commit.Status = "Started"
+ b.Build.Status = "Started"
+ b.Build.Started = time.Now().UTC()
+ b.Commit.Started = time.Now().UTC()
+
+ // persist the commit to the database
+ if err := database.SaveCommit(b.Commit); err != nil {
+ return err
+ }
+
+ // persist the build to the database
+ if err := database.SaveBuild(b.Build); err != nil {
+ return err
+ }
+
+ // get settings
+ settings, _ := database.GetSettings()
+
+ // notification context
+ context := ¬ification.Context{
+ Repo: b.Repo,
+ Commit: b.Commit,
+ Host: settings.URL().String(),
+ }
+
+ // send all "started" notifications
+ if b.Script.Notifications != nil {
+ b.Script.Notifications.Send(context)
+ }
+
+ // make sure a channel exists for the repository,
+ // the commit, and the commit output (TODO)
+ reposlug := fmt.Sprintf("%s/%s/%s", b.Repo.Host, b.Repo.Owner, b.Repo.Slug)
+ commitslug := fmt.Sprintf("%s/%s/%s/commit/%s", b.Repo.Host, b.Repo.Owner, b.Repo.Slug, b.Commit.Hash)
+ consoleslug := fmt.Sprintf("%s/%s/%s/commit/%s/builds/%s", b.Repo.Host, b.Repo.Owner, b.Repo.Slug, b.Commit.Hash, b.Build.Slug)
+ channel.Create(reposlug)
+ channel.Create(commitslug)
+ channel.CreateStream(consoleslug)
+
+ // notify the channels that the commit and build started
+ channel.SendJSON(reposlug, b.Commit)
+ channel.SendJSON(commitslug, b.Build)
+
+ var buf = &bufferWrapper{channel: consoleslug}
+
+ // append private parameters to the environment
+ // variable section of the .drone.yml file
+ if b.Repo.Params != nil {
+ for k, v := range b.Repo.Params {
+ b.Script.Env = append(b.Script.Env, k+"="+v)
+ }
+ }
+
+ // execute the build
+ builder := bldr.Builder{}
+ builder.Build = b.Script
+ builder.Repo = &r.Repo{Path: b.Repo.URL, Branch: b.Commit.Branch, Commit: b.Commit.Hash, PR: b.Commit.PullRequest, Dir: filepath.Join("/var/cache/drone/src", b.Repo.Slug)}
+ builder.Key = []byte(b.Repo.PrivateKey)
+ builder.Stdout = buf
+ builder.Timeout = 300 * time.Minute
+ buildErr := builder.Run()
+
+ b.Build.Finished = time.Now().UTC()
+ b.Commit.Finished = time.Now().UTC()
+ b.Build.Duration = b.Build.Finished.UnixNano() - b.Build.Started.UnixNano()
+ b.Commit.Duration = b.Build.Finished.UnixNano() - b.Build.Started.UnixNano()
+ b.Commit.Status = "Success"
+ b.Build.Status = "Success"
+ b.Build.Stdout = buf.buf.String()
+
+ // if exit code != 0 set to failure
+ if builder.BuildState == nil || builder.BuildState.ExitCode != 0 {
+ b.Commit.Status = "Failure"
+ b.Build.Status = "Failure"
+ if buildErr != nil && b.Build.Stdout == "" {
+ // TODO: If you wanted to have very friendly error messages, you could do that here
+ b.Build.Stdout = buildErr.Error() + "\n"
+ }
+ }
+
+ // persist the build to the database
+ if err := database.SaveBuild(b.Build); err != nil {
+ return err
+ }
+
+ // persist the commit to the database
+ if err := database.SaveCommit(b.Commit); err != nil {
+ return err
+ }
+
+ // notify the channels that the commit and build finished
+ channel.SendJSON(reposlug, b.Commit)
+ channel.SendJSON(commitslug, b.Build)
+ channel.Close(consoleslug)
+
+ // add the smtp address to the notificaitons
+ //if b.Script.Notifications != nil && b.Script.Notifications.Email != nil {
+ // b.Script.Notifications.Email.SetServer(settings.SmtpServer, settings.SmtpPort,
+ // settings.SmtpUsername, settings.SmtpPassword, settings.SmtpAddress)
+ //}
+
+ // send all "finished" notifications
+ if b.Script.Notifications != nil {
+ b.sendEmail(context) // send email from queue, not from inside /build/script package
+ b.Script.Notifications.Send(context)
+ }
+
+ // update the status of the commit using the
+ // GitHub status API.
+ if err := updateGitHubStatus(b.Repo, b.Commit); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// updateGitHubStatus is a helper function that will send
+// the build status to GitHub using the Status API.
+// see https://github.com/blog/1227-commit-status-api
+func updateGitHubStatus(repo *Repo, commit *Commit) error {
+
+ // convert from drone status to github status
+ var message, status string
+ switch status {
+ case "Success":
+ status = "success"
+ message = "The build succeeded on drone.io"
+ case "Failure":
+ status = "failure"
+ message = "The build failed on drone.io"
+ case "Pending":
+ status = "pending"
+ message = "The build is pending on drone.io"
+ default:
+ status = "error"
+ message = "The build errored on drone.io"
+ }
+
+ // get the system settings
+ settings, _ := database.GetSettings()
+
+ // get the user from the database
+ // since we need his / her GitHub token
+ user, err := database.GetUser(repo.UserID)
+ if err == nil {
+ return err
+ }
+
+ client := github.New(user.GithubToken)
+ return client.Repos.CreateStatus(repo.Owner, repo.Slug, status, settings.URL().String(), message, commit.Hash)
+}
+
+func (t *BuildTask) sendEmail(c *notification.Context) error {
+ // make sure a notifications object exists
+ if t.Script.Notifications == nil && t.Script.Notifications.Email != nil {
+ return nil
+ }
+
+ switch {
+ case t.Commit.Status == "Success" && t.Script.Notifications.Email.Success != "never":
+ return t.sendSuccessEmail(c)
+ case t.Commit.Status == "Failure" && t.Script.Notifications.Email.Failure != "never":
+ return t.sendFailureEmail(c)
+ default:
+ println("sending nothing")
+ }
+
+ return nil
+}
+
+// sendFailure sends email notifications to the list of
+// recipients indicating the build failed.
+func (t *BuildTask) sendFailureEmail(c *notification.Context) error {
+
+ // loop through and email recipients
+ for _, email := range t.Script.Notifications.Email.Recipients {
+ if err := mail.SendFailure(t.Repo.Slug, email, c); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// sendSuccess sends email notifications to the list of
+// recipients indicating the build was a success.
+func (t *BuildTask) sendSuccessEmail(c *notification.Context) error {
+
+ // loop through and email recipients
+ for _, email := range t.Script.Notifications.Email.Recipients {
+ if err := mail.SendSuccess(t.Repo.Slug, email, c); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+type bufferWrapper struct {
+ buf bytes.Buffer
+
+ // name of the channel
+ channel string
+}
+
+func (b *bufferWrapper) Write(p []byte) (n int, err error) {
+ n, err = b.buf.Write(p)
+ channel.SendBytes(b.channel, p)
+ return
+}
diff --git a/pkg/template/emails/activation.html b/pkg/template/emails/activation.html
new file mode 100644
index 000000000..9f5c5dc96
--- /dev/null
+++ b/pkg/template/emails/activation.html
@@ -0,0 +1,7 @@
+{{ define "title" }}ACCOUNT ACTIVATION{{ end }}
+
+{{ define "content" }}
+Please follow this link to activate your account:
+
+
+{{ end }}
diff --git a/pkg/template/emails/base_email.html b/pkg/template/emails/base_email.html
new file mode 100644
index 000000000..b12c5d7a1
--- /dev/null
+++ b/pkg/template/emails/base_email.html
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+ Drone CI
+
+
+
+
+
+
+
+
+ {{ template "content" . }}
+
+
+
+ To learn more about Drone and all its features, check out our documentation:
+ http://drone.readthedocs.org/
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/pkg/template/emails/failure.html b/pkg/template/emails/failure.html
new file mode 100644
index 000000000..615d62c87
--- /dev/null
+++ b/pkg/template/emails/failure.html
@@ -0,0 +1,28 @@
+{{ define "title" }}FAILURE{{end}}
+
+{{ define "content" }}
+
+
+ Commit {{ .Commit.HashShort }} Failed
+
+
+ {{ .Repo.Owner }} / {{ .Repo.Name }}
+
+
+ commit:
+ {{ .Commit.HashShort }}
+
+
+ branch:
+ {{ .Commit.Branch }}
+
+
+ author:
+ {{ .Commit.Author }}
+
+
+ message:
+ {{ .Commit.message }}
+
+
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/emails/invitation.html b/pkg/template/emails/invitation.html
new file mode 100644
index 000000000..ab042797c
--- /dev/null
+++ b/pkg/template/emails/invitation.html
@@ -0,0 +1,7 @@
+{{ define "title" }}TEAM INVITATION{{ end }}
+
+{{ define "content" }}
+You are invited to join team {{ .Team.Name }} . Please follow this link to join:
+
+
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/emails/reset_password.html b/pkg/template/emails/reset_password.html
new file mode 100644
index 000000000..6a54c5e23
--- /dev/null
+++ b/pkg/template/emails/reset_password.html
@@ -0,0 +1,7 @@
+{{ define "title" }}PASSWORD RESET{{ end }}
+
+{{ define "content" }}
+Please follow this link to activate your account:
+
+
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/emails/success.html b/pkg/template/emails/success.html
new file mode 100644
index 000000000..361bb3546
--- /dev/null
+++ b/pkg/template/emails/success.html
@@ -0,0 +1,28 @@
+{{ define "title" }}FAILURE{{end}}
+
+{{ define "content" }}
+
+
+ Commit {{ .Commit.HashShort }} Passed
+
+
+ {{ .Repo.Owner }} / {{ .Repo.Name }}
+
+
+ commit:
+ {{ .Commit.HashShort }}
+
+
+ branch:
+ {{ .Commit.Branch }}
+
+
+ author:
+ {{ .Commit.Author }}
+
+
+ message:
+ {{ .Commit.message }}
+
+
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/pages/403.html b/pkg/template/pages/403.html
new file mode 100644
index 000000000..97dbb510d
--- /dev/null
+++ b/pkg/template/pages/403.html
@@ -0,0 +1,9 @@
+{{ define "title" }}Forbidden · drone.io{{ end }}
+
+{{ define "content" }}
+Forbidden
+Forbidden
+{{ end }}
+
+{{ define "script" }}
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/pages/404.html b/pkg/template/pages/404.html
new file mode 100644
index 000000000..b615a007a
--- /dev/null
+++ b/pkg/template/pages/404.html
@@ -0,0 +1,9 @@
+{{ define "title" }}Not Found · drone.io{{ end }}
+
+{{ define "content" }}
+Oops!
+We were unable to find the requested page.
+{{ end }}
+
+{{ define "script" }}
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/pages/500.html b/pkg/template/pages/500.html
new file mode 100644
index 000000000..e473901fd
--- /dev/null
+++ b/pkg/template/pages/500.html
@@ -0,0 +1,9 @@
+{{ define "title" }}Bad Request · drone.io{{ end }}
+
+{{ define "content" }}
+Bad Request
+Bad Request
+{{ end }}
+
+{{ define "script" }}
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/pages/admin_settings.html b/pkg/template/pages/admin_settings.html
new file mode 100644
index 000000000..76097d610
--- /dev/null
+++ b/pkg/template/pages/admin_settings.html
@@ -0,0 +1,114 @@
+{{ define "title" }}Profile{{ end }}
+
+{{ define "content" }}
+
+
+
+
+
+{{ end }}
+
+{{ define "script" }}
+
+
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/pages/admin_users.html b/pkg/template/pages/admin_users.html
new file mode 100644
index 000000000..5d8b50c39
--- /dev/null
+++ b/pkg/template/pages/admin_users.html
@@ -0,0 +1,53 @@
+{{ define "title" }}Users · Sysadmin{{ end }}
+
+{{ define "content" }}
+
+
+
+
+
+
+
+
+
+
+
Manage all users in the System.
+
+
+
+
+
+{{ end }}
+
+{{ define "script" }}
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/pages/admin_users_add.html b/pkg/template/pages/admin_users_add.html
new file mode 100644
index 000000000..08d379fd1
--- /dev/null
+++ b/pkg/template/pages/admin_users_add.html
@@ -0,0 +1,78 @@
+{{ define "title" }}Settings · Sysadmin{{ end }}
+
+{{ define "content" }}
+
+
+
+
+
+
+
+
+
+
+
Users will be granted access by Email invitation.
+
+
+
+
+
+
+
+{{ end }}
+
+{{ define "script" }}
+
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/pages/admin_users_edit.html b/pkg/template/pages/admin_users_edit.html
new file mode 100644
index 000000000..c82850ccc
--- /dev/null
+++ b/pkg/template/pages/admin_users_edit.html
@@ -0,0 +1,103 @@
+{{ define "title" }}Settings · Sysadmin{{ end }}
+
+{{ define "content" }}
+
+
+
+
+
+
+
+
+
+
+
Edit the User Account Details
+
+
+
+
+
+
+{{ end }}
+
+{{ define "script" }}
+
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/pages/base.html b/pkg/template/pages/base.html
new file mode 100644
index 000000000..5af15d959
--- /dev/null
+++ b/pkg/template/pages/base.html
@@ -0,0 +1,56 @@
+
+
+
+
+
+
{{ template "title" . }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ template "content" . }}
+
+
+ {{ template "script" . }}
+
+
\ No newline at end of file
diff --git a/pkg/template/pages/forgot.html b/pkg/template/pages/forgot.html
new file mode 100644
index 000000000..d8b8235ad
--- /dev/null
+++ b/pkg/template/pages/forgot.html
@@ -0,0 +1,17 @@
+{{ define "title" }}Forgot Password?{{ end }}
+
+{{ define "content" }}
+
Forgot Password
+
+
+
+
+
+
+
+{{ end }}
+
+{{ define "script" }}
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/pages/forgot_sent.html b/pkg/template/pages/forgot_sent.html
new file mode 100644
index 000000000..aa27bd88b
--- /dev/null
+++ b/pkg/template/pages/forgot_sent.html
@@ -0,0 +1,18 @@
+{{ define "title" }}Forgot Password?{{ end }}
+
+{{ define "content" }}
+
Forgot Password
+
A Password Reset Email was Sent.
+
+
+
+
+
+
+
+{{ end }}
+
+{{ define "script" }}
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/pages/form.html b/pkg/template/pages/form.html
new file mode 100644
index 000000000..8e48aae38
--- /dev/null
+++ b/pkg/template/pages/form.html
@@ -0,0 +1,33 @@
+
+
+
+
+
+
{{ template "title" . }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ template "script" . }}
+
+
diff --git a/pkg/template/pages/github_add.html b/pkg/template/pages/github_add.html
new file mode 100644
index 000000000..397bd2bbe
--- /dev/null
+++ b/pkg/template/pages/github_add.html
@@ -0,0 +1,98 @@
+{{ define "title" }}GitHub · Add Repository{{ end }}
+
+{{ define "content" }}
+
+
+
+ Repository Setup
+ GitHub
+
+
+
+
+
+{{ end }}
+
+{{ define "script" }}
+
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/pages/github_link.html b/pkg/template/pages/github_link.html
new file mode 100644
index 000000000..1898f47f9
--- /dev/null
+++ b/pkg/template/pages/github_link.html
@@ -0,0 +1,30 @@
+{{ define "title" }}GitHub · Add Repository{{ end }}
+
+{{ define "content" }}
+
+
+
+ Repository Setup
+ GitHub
+
+
+
+
+
+{{ end }}
+
+{{ define "script" }}{{ end }}
\ No newline at end of file
diff --git a/pkg/template/pages/install.html b/pkg/template/pages/install.html
new file mode 100644
index 000000000..3a6c848eb
--- /dev/null
+++ b/pkg/template/pages/install.html
@@ -0,0 +1,21 @@
+{{ define "title" }}Installation · drone.io{{ end }}
+
+{{ define "content" }}
+
Installation
+
+
+
+
+
+
+
+
+
+
+{{ end }}
+
+{{ define "script" }}
+
+{{ end }}
diff --git a/pkg/template/pages/login.html b/pkg/template/pages/login.html
new file mode 100644
index 000000000..99b0ee5f1
--- /dev/null
+++ b/pkg/template/pages/login.html
@@ -0,0 +1,18 @@
+{{ define "title" }}Login · drone.io{{ end }}
+
+{{ define "content" }}
+
Login
+
+
+
+
+
+
+
+
+{{ end }}
+
+{{ define "script" }}
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/pages/login_error.html b/pkg/template/pages/login_error.html
new file mode 100644
index 000000000..b5255d45d
--- /dev/null
+++ b/pkg/template/pages/login_error.html
@@ -0,0 +1,19 @@
+{{ define "title" }}Login · drone.io{{ end }}
+
+{{ define "content" }}
+
Login
+
Invalid Email Address or Password
+
+
+
+
+
+
+
+
+{{ end }}
+
+{{ define "script" }}
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/pages/members_add.html b/pkg/template/pages/members_add.html
new file mode 100644
index 000000000..f3e3e0333
--- /dev/null
+++ b/pkg/template/pages/members_add.html
@@ -0,0 +1,87 @@
+{{ define "title" }}{{.Team.Name}} · Profile{{ end }}
+
+{{ define "content" }}
+
+
+
+
+
+
+ {{.Team.Name}}
+
+
+
+
+
+
+
+
+
+
+
+
Invite a collaborator to join your Team.
+
+
+
+
+
+
+{{ end }}
+
+{{ define "script" }}
+
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/pages/members_edit.html b/pkg/template/pages/members_edit.html
new file mode 100644
index 000000000..c5a915da8
--- /dev/null
+++ b/pkg/template/pages/members_edit.html
@@ -0,0 +1,110 @@
+{{ define "title" }}{{.Team.Name}} · Edit Member{{ end }}
+
+{{ define "content" }}
+
+
+
+
+
+
+ {{.Team.Slug}}
+
+
+
+
+
+
+
+
+
+
+
+
Edit Team Membership for {{.Member.Name}}
+
+
+
+
+
+
+
+{{ end }}
+
+{{ define "script" }}
+
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/pages/register.html b/pkg/template/pages/register.html
new file mode 100644
index 000000000..a9bc00855
--- /dev/null
+++ b/pkg/template/pages/register.html
@@ -0,0 +1,21 @@
+{{ define "title" }}Register · drone.io{{ end }}
+
+{{ define "content" }}
+
Registration
+ {{ if .Error }}
+
{{.Error}}
+ {{ end }}
+
+
+
+
+
+{{ end }}
+
+{{ define "script" }}
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/pages/repo_badges.html b/pkg/template/pages/repo_badges.html
new file mode 100644
index 000000000..10d8a2acc
--- /dev/null
+++ b/pkg/template/pages/repo_badges.html
@@ -0,0 +1,50 @@
+{{ define "title" }}{{.Repo.Slug}} · Badges{{ end }}
+
+{{ define "content" }}
+
+
+
+
+
+ {{.Repo.Name}}
+ {{.Repo.Owner}}
+
+
+
+
+
+
+{{ end }}
+
+{{ define "script" }}
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/pages/repo_commit.html b/pkg/template/pages/repo_commit.html
new file mode 100644
index 000000000..93553a0d8
--- /dev/null
+++ b/pkg/template/pages/repo_commit.html
@@ -0,0 +1,80 @@
+{{ define "title" }}{{.Repo.Slug}} · {{ .Commit.HashShort }}{{ end }}
+
+{{ define "content" }}
+
+
+
+
+
+ {{.Repo.Name}}
+ {{.Repo.Owner}}
+
+
+
+
+
+
+
+ {{ if .Commit.PullRequest }}
+
opened pull request # {{ .Commit.PullRequest }}
+ {{ else }}
+
commit {{ .Commit.HashShort }} to {{.Commit.Branch}} branch
+ {{ end }}
+
+
+
+
Status
+ {{.Build.Status}}
+ Started
+
+ Duration
+ {{ if .Build.IsRunning }}--{{else}}{{ .Build.HumanDuration }}{{end}}
+
+
+
+
Commit
+ {{ .Commit.HashShort }}
+ Committer
+ {{ .Commit.Author }}
+ Message
+ {{ .Commit.Message }}
+
+
+
+
+{{ end }}
+
+{{ define "script" }}
+
+
+
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/pages/repo_dashboard.html b/pkg/template/pages/repo_dashboard.html
new file mode 100644
index 000000000..f54600739
--- /dev/null
+++ b/pkg/template/pages/repo_dashboard.html
@@ -0,0 +1,81 @@
+{{ define "title" }}{{.Repo.Slug}} · Dashboard{{ end }}
+
+{{ define "content" }}
+
+
+
+
+ {{.Repo.Name}}
+ {{.Repo.Owner}}
+
+
+
+
+ {{ $repo := .Repo }}
+ {{ $branch := .Branch }}
+
+{{ end }}
+
+{{ define "script" }}
+
+
+
+
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/pages/repo_delete.html b/pkg/template/pages/repo_delete.html
new file mode 100644
index 000000000..d2ef37227
--- /dev/null
+++ b/pkg/template/pages/repo_delete.html
@@ -0,0 +1,54 @@
+{{ define "title" }}{{.Repo.Slug}} · Delete{{ end }}
+
+{{ define "content" }}
+
+
+
+
+
+ {{.Repo.Name}}
+ {{.Repo.Owner}}
+
+
+
+
+
+
+
+
+
+
+
+ Delete this repository.
+ Warning:
+ this action cannot be undone.
+
+
+
+ Enter your password to confim:
+
+
+
+
+
+
+
+
+{{ end }}
+
+{{ define "script" }}
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/pages/repo_keys.html b/pkg/template/pages/repo_keys.html
new file mode 100644
index 000000000..5a4c505bd
--- /dev/null
+++ b/pkg/template/pages/repo_keys.html
@@ -0,0 +1,45 @@
+{{ define "title" }}{{.Repo.Slug}} · Keys{{ end }}
+
+{{ define "content" }}
+
+
+
+
+
+ {{.Repo.Name}}
+ {{.Repo.Owner}}
+
+
+
+
+
+
+
+
+
+
+
Public Key, used for Deployments
+
+ You can add this Key to your Heroku account, SSH .ssh/authorized_keys
file, and more.
+
+ {{.Repo.PublicKey}}
+
+
+
+
+
+{{ end }}
+
+{{ define "script" }}
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/pages/repo_params.html b/pkg/template/pages/repo_params.html
new file mode 100644
index 000000000..4b9d15e9f
--- /dev/null
+++ b/pkg/template/pages/repo_params.html
@@ -0,0 +1,76 @@
+{{ define "title" }}{{.Repo.Slug}} · Secure{{ end }}
+
+{{ define "content" }}
+
+
+
+
+
+ {{.Repo.Name}}
+ {{.Repo.Owner}}
+
+
+
+
+
+
+
+
+
+
+
Secure build configuration parameters
+
+ Build parameters are injected in your drone.yml file at runtime.
+
+ {{.Textarea}}
+
+
+
+
+
+
+
+
+{{ end }}
+
+{{ define "script" }}
+
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/pages/repo_settings.html b/pkg/template/pages/repo_settings.html
new file mode 100644
index 000000000..7d54bbd34
--- /dev/null
+++ b/pkg/template/pages/repo_settings.html
@@ -0,0 +1,103 @@
+{{ define "title" }}{{.Repo.Slug}} · Settings{{ end }}
+
+{{ define "content" }}
+
+
+
+
+ {{.Repo.Name}}
+ {{.Repo.Owner}}
+
+
+
+
+
+
+
+
+
+
+
+
Manage your repository settings.
+
+
+
+
+ Enable Build Hooks
+
+
+
+
+
+ Enable Pull Hooks
+
+
+ Choose the account owner.
+
+
+
+
+
+
+
+
+
+{{ end }}
+
+{{ define "script" }}
+
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/pages/reset.html b/pkg/template/pages/reset.html
new file mode 100644
index 000000000..97143ef46
--- /dev/null
+++ b/pkg/template/pages/reset.html
@@ -0,0 +1,20 @@
+{{ define "title" }}Reset · drone.io{{ end }}
+
+{{ define "content" }}
+
Reset Password
+ {{ if .Error }}
+
{{ .Error }}
+ {{ end }}
+
+
+
+
+
+
+
+{{ end }}
+
+{{ define "script" }}
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/pages/team_dashboard.html b/pkg/template/pages/team_dashboard.html
new file mode 100644
index 000000000..b6ebd5738
--- /dev/null
+++ b/pkg/template/pages/team_dashboard.html
@@ -0,0 +1,111 @@
+{{ define "title" }}{{.Team.Name}} · Dashboard{{ end }}
+
+{{ define "content" }}
+
+
+
+
+
+
+ {{.Team.Name}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ range .Teams }}
+
+
+
+
+
+ {{ end }}
+
+
switch dashboard
+
Recent Builds
+
+
+ {{ if .Commits }}
+
+ {{ end }}
+
+
+
+
+
+{{ end }}
+
+{{ define "script" }}
+
+
+
+
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/pages/team_delete.html b/pkg/template/pages/team_delete.html
new file mode 100644
index 000000000..68e077a6c
--- /dev/null
+++ b/pkg/template/pages/team_delete.html
@@ -0,0 +1,57 @@
+{{ define "title" }}{{.Team.Name}} · Profile{{ end }}
+
+{{ define "content" }}
+
+
+
+
+
+
+ {{.Team.Name}}
+
+
+
+
+
+
+
+
+
+
+
+
Delete this Team account. Warning this action cannot be undone.
+
+
+
+ Enter your password to confim:
+
+
+
+
+
+
+
+
+
+
+
+{{ end }}
+
+{{ define "script" }}
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/pages/team_members.html b/pkg/template/pages/team_members.html
new file mode 100644
index 000000000..3dc6cdfae
--- /dev/null
+++ b/pkg/template/pages/team_members.html
@@ -0,0 +1,65 @@
+{{ define "title" }}{{.Team.Name}} · Profile{{ end }}
+
+{{ define "content" }}
+
+
+
+
+
+
+ {{.Team.Name}}
+
+
+
+
+
+
+
+
+
+
+
+
Members share access to all Repositories owned by this Team account.
+
+
+
+
+{{ end }}
+
+{{ define "script" }}
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/pages/team_profile.html b/pkg/template/pages/team_profile.html
new file mode 100644
index 000000000..f6c35f04a
--- /dev/null
+++ b/pkg/template/pages/team_profile.html
@@ -0,0 +1,94 @@
+{{ define "title" }}{{.Team.Name}} · Profile{{ end }}
+
+{{ define "content" }}
+
+
+
+
+
+
+ {{.Team.Name}}
+
+
+
+
+
+
+
+
+
+
+
+
Manage your team's Profile.
+
+
+
+ Team Name:
+
+
+
+ Gravatar Email Address:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{{ end }}
+
+{{ define "script" }}
+
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/pages/user_dashboard.html b/pkg/template/pages/user_dashboard.html
new file mode 100644
index 000000000..aba1dfafd
--- /dev/null
+++ b/pkg/template/pages/user_dashboard.html
@@ -0,0 +1,110 @@
+{{ define "title" }}Dashboard{{ end }}
+
+{{ define "content" }}
+
+
+
+
+
+
+ {{.User.Name}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ range .Teams }}
+
+
+
+
+
+ {{ end }}
+
+
switch dashboard
+
Recent Builds
+
+
+ {{ if .Commits }}
+
+ {{ end }}
+
+
+
+
+
+
+{{ end }}
+
+{{ define "script" }}
+
+
+
+{{ end }}
diff --git a/pkg/template/pages/user_delete.html b/pkg/template/pages/user_delete.html
new file mode 100644
index 000000000..549c1ca2a
--- /dev/null
+++ b/pkg/template/pages/user_delete.html
@@ -0,0 +1,57 @@
+{{ define "title" }}{{.User.Name}} · Profile{{ end }}
+
+{{ define "content" }}
+
+
+
+
+
+
+ {{.User.Name}}
+
+
+
+
+
+
+
+
+
+
+
+
Delete your account. Warning this action cannot be undone.
+
+
+ Enter your password to confim:
+
+
+
+
+
+
+
+
+
+
+
+{{ end }}
+
+{{ define "script" }}
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/pages/user_password.html b/pkg/template/pages/user_password.html
new file mode 100644
index 000000000..161e47fd6
--- /dev/null
+++ b/pkg/template/pages/user_password.html
@@ -0,0 +1,84 @@
+{{ define "title" }}{{.User.Name}} · Password{{ end }}
+
+{{ define "content" }}
+
+
+
+
+
+
+ {{.User.Name}}
+
+
+
+
+
+
+
+
+
+
+
+
Change your account Password.
+
+
+ New Password:
+
+
+
+
+
+
+
+
+
+
+
+
+
+{{ end }}
+
+{{ define "script" }}
+
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/pages/user_profile.html b/pkg/template/pages/user_profile.html
new file mode 100644
index 000000000..9dd5a6b44
--- /dev/null
+++ b/pkg/template/pages/user_profile.html
@@ -0,0 +1,147 @@
+{{ define "title" }}{{.User.Name}} · Profile{{ end }}
+
+{{ define "content" }}
+
+
+
+
+
+
+ {{.User.Name}}
+
+
+
+
+
+
+
+
+
+
+
+
Manage your account Profile.
+
+
+
+ Your Name:
+
+
+
+ Your Email Address:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{{ end }}
+
+{{ define "script" }}
+
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/pages/user_teams.html b/pkg/template/pages/user_teams.html
new file mode 100644
index 000000000..b5a9de70f
--- /dev/null
+++ b/pkg/template/pages/user_teams.html
@@ -0,0 +1,62 @@
+{{ define "title" }}{{.User.Name}} · Teams{{ end }}
+
+{{ define "content" }}
+
+
+
+
+
+
+ {{.User.Name}}
+
+
+
+
+
+
+
+
+
+
+
+
Teams allow you to share your Repositories with other developers.
+
+
+
+
+
+{{ end }}
+
+{{ define "script" }}
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/pages/user_teams_add.html b/pkg/template/pages/user_teams_add.html
new file mode 100644
index 000000000..afa83fa4d
--- /dev/null
+++ b/pkg/template/pages/user_teams_add.html
@@ -0,0 +1,85 @@
+{{ define "title" }}Profile{{ end }}
+
+{{ define "content" }}
+
+
+
+
+
+
+ {{.User.Name}}
+
+
+
+
+
+
+
+
+
+
+
+
Setup a new Team Account.
+
+
+
+ Team Name:
+
+
+
+ Email (Gravatar):
+
+
+
+
+
+
+
+
+
+
+
+
+
+{{ end }}
+
+{{ define "script" }}
+
+{{ end }}
\ No newline at end of file
diff --git a/pkg/template/template.go b/pkg/template/template.go
new file mode 100644
index 000000000..76117728b
--- /dev/null
+++ b/pkg/template/template.go
@@ -0,0 +1,142 @@
+package template
+
+import (
+ "errors"
+ "html/template"
+ "io"
+
+ "github.com/GeertJohan/go.rice"
+)
+
+// ErrTemplateNotFound indicates the requested template
+// does not exists in the TemplateStore.
+var ErrTemplateNotFound = errors.New("Template Not Found")
+
+// registry stores a map of Templates where the key
+// is the template name and the value is the *template.Template.
+var registry = map[string]*template.Template{}
+
+// ExecuteTemplate applies the template associated with t that has
+// the given name to the specified data object and writes the output to wr.
+func ExecuteTemplate(wr io.Writer, name string, data interface{}) error {
+ templ, ok := registry[name]
+ if !ok {
+ return ErrTemplateNotFound
+ }
+
+ return templ.ExecuteTemplate(wr, "_", data)
+}
+
+// all template are loaded on initialization.
+func init() {
+ // location of templates
+ box := rice.MustFindBox("pages")
+
+ // these are all the files we need to parse. it is
+ // kind of annoying that we can't list files in the
+ // box, and have to enumerate each file here, but it is
+ // a small price to pay to embed everything and simplify
+ // the user installation process :)
+ var files = []string{
+ // these templates use the form.html
+ // shared layout
+ "login.html",
+ "login_error.html",
+ "forgot.html",
+ "forgot_sent.html",
+ "reset.html",
+ "register.html",
+ "install.html",
+
+ // these templates use the default.html
+ // shared layout
+ "403.html",
+ "404.html",
+ "500.html",
+ "user_dashboard.html",
+ "user_password.html",
+ "user_profile.html",
+ "user_delete.html",
+ "user_teams.html",
+ "user_teams_add.html",
+ "team_dashboard.html",
+ "team_profile.html",
+ "team_members.html",
+ "team_delete.html",
+ "members_add.html",
+ "members_edit.html",
+ "repo_dashboard.html",
+ "repo_settings.html",
+ "repo_delete.html",
+ "repo_params.html",
+ "repo_badges.html",
+ "repo_keys.html",
+ "repo_commit.html",
+ "admin_users.html",
+ "admin_users_edit.html",
+ "admin_users_add.html",
+ "admin_settings.html",
+ "github_add.html",
+ "github_link.html",
+ }
+
+ // extract the base template as a string
+ base, err := box.String("base.html")
+ if err != nil {
+ panic(err)
+ }
+
+ // extract the base form template as a string
+ form, err := box.String("form.html")
+ if err != nil {
+ panic(err)
+ }
+
+ // loop through files and create templates
+ for i, file := range files {
+ // extract template from box
+ page, err := box.String(file)
+ if err != nil {
+ panic(err)
+ }
+
+ // HACK: choose which base template to use FOR THE RECORD I
+ // don't really like this, but it works for now.
+ var baseTemplate = base
+ if i < 7 {
+ baseTemplate = form
+ }
+
+ // parse the template and then add to the global map
+ registry[file] = template.Must(template.Must(template.New("_").Parse(baseTemplate)).Parse(page))
+ }
+
+ // location of templates
+ box = rice.MustFindBox("emails")
+
+ files = []string{
+ "activation.html",
+ "failure.html",
+ "success.html",
+ "invitation.html",
+ "reset_password.html",
+ }
+
+ // extract the base template as a string
+ base, err = box.String("base_email.html")
+ if err != nil {
+ panic(err)
+ }
+
+ // loop through files and create templates
+ for _, file := range files {
+ // extract template from box
+ email, err := box.String(file)
+ if err != nil {
+ panic(err)
+ }
+
+ // parse the template and then add to the global map
+ registry[file] = template.Must(template.Must(template.New("_").Parse(base)).Parse(email))
+ }
+}