Initial Gitlab Support

This commit is contained in:
Nurahmadie 2014-03-29 06:37:50 +00:00
parent deb2c79c2f
commit 43c0a54718
15 changed files with 686 additions and 29 deletions

View file

@ -16,7 +16,6 @@ import (
"github.com/drone/drone/pkg/channel"
"github.com/drone/drone/pkg/database"
"github.com/drone/drone/pkg/handler"
"github.com/drone/drone/pkg/handler/gitlab"
"github.com/drone/drone/pkg/queue"
)
@ -117,6 +116,7 @@ func setupHandlers() {
queue := queue.Start(workers, queueRunner)
hookHandler := handler.NewHookHandler(queue)
gitlab := handler.NewGitlabHandler(queue)
m := pat.New()
m.Get("/login", handler.ErrorHandler(handler.Login))
@ -136,13 +136,16 @@ func setupHandlers() {
m.Post("/new/github.com", handler.UserHandler(handler.RepoCreateGithub))
m.Get("/new/github.com", handler.UserHandler(handler.RepoAdd))
// handlers for setting up your GitLab repository
m.Post("/new/gitlab", handler.UserHandler(gitlab.RepoCreate))
m.Get("/new/gitlab", handler.UserHandler(gitlab.RepoAdd))
// handlers for linking your GitHub account
m.Get("/auth/login/github", handler.UserHandler(handler.LinkGithub))
// handlers for setting up your GitLab repository
m.Post("/new/gitlab", handler.UserHandler(gitlab.Create))
m.Get("/new/gitlab", handler.UserHandler(gitlab.Add))
// handler for linking GitLab account
m.Post("/link/gitlab", handler.UserHandler(gitlab.Link))
// handlers for dashboard pages
m.Get("/dashboard/team/:team", handler.UserHandler(handler.TeamShow))
m.Get("/dashboard", handler.UserHandler(handler.UserShow))
@ -184,7 +187,7 @@ func setupHandlers() {
m.Post("/hook/github.com", handler.ErrorHandler(hookHandler.Hook))
// handlers for GitLab post-commit hooks
m.Post("/hook/gitlab", handler.ErrorHandler(gitlab.Hook))
//m.Post("/hook/gitlab", handler.ErrorHandler(gitlab.Hook))
// handlers for first-time installation
m.Get("/install", handler.ErrorHandler(handler.Install))

View file

@ -0,0 +1,34 @@
package migrate
type rev20140328201430 struct{}
var AddGitlabColumns = &rev20140328201430{}
func (r *rev20140328201430) Revision() int64 {
return 20140328201430
}
func (r *rev20140328201430) Up(mg *MigrationDriver) error {
// Migration steps here
if _, err := mg.AddColumn("settings", mg.T.String("gitlab_domain")); err != nil {
return err
}
if _, err := mg.AddColumn("settings", mg.T.String("gitlab_apiurl")); err != nil {
return err
}
if _, err := mg.Tx.Exec(`update settings set gitlab_domain='gitlab.com', gitlab_apiurl='https://gitlab.com'`); err != nil {
return err
}
_, err := mg.AddColumn("users", mg.T.String("gitlab_token"))
return err
}
func (r *rev20140328201430) Down(mg *MigrationDriver) error {
// Revert migration steps here
if _, err := mg.DropColumns("users", "gitlab_token"); err != nil {
return err
}
_, err:= mg.DropColumns("settings", "gitlab_domain", "gitlab_apiurl")
return err
}

View file

@ -12,6 +12,7 @@ func (m *Migration) All() *Migration {
m.Add(RenamePrivelegedToPrivileged)
m.Add(GitHubEnterpriseSupport)
m.Add(AddOpenInvitationColumn)
m.Add(AddGitlabColumns)
// m.Add(...)
// ...

View file

@ -11,8 +11,8 @@ const settingsTable = "settings"
// SQL Queries to retrieve the system settings
const settingsStmt = `
SELECT id, github_key, github_secret, github_domain, github_apiurl, bitbucket_key, bitbucket_secret,
smtp_server, smtp_port, smtp_address, smtp_username, smtp_password, hostname, scheme, open_invitations
FROM settings WHERE id = 1
gitlab_domain, gitlab_apiurl, smtp_server, smtp_port, smtp_address, smtp_username, smtp_password,
hostname, scheme, open_invitations FROM settings WHERE id = 1
`
//var (

View file

@ -37,6 +37,7 @@ func Setup() {
Email: "brad.rydzewski@gmail.com",
Gravatar: "8c58a0be77ee441bb8f8595b7f1b4e87",
Token: "123",
GitlabToken: "123",
Admin: true}
user2 := User{
Password: "$2a$10$b8d63QsTL38vx7lj0HEHfOdbu1PCAg6Gfca74UavkXooIBx9YxopS",
@ -44,6 +45,7 @@ func Setup() {
Email: "cavepig@gmail.com",
Gravatar: "c62f7126273f7fa786274274a5dec8ce",
Token: "456",
GitlabToken: "456",
Admin: false}
user3 := User{
Password: "$2a$10$b8d63QsTL38vx7lj0HEHfOdbu1PCAg6Gfca74UavkXooIBx9YxopS",
@ -51,6 +53,7 @@ func Setup() {
Email: "ytsejammer@gmail.com",
Gravatar: "c2180a539620d90d68eaeb848364f1c2",
Token: "789",
GitlabToken: "789",
Admin: false}
database.SaveUser(&user1)

129
pkg/handler/gitlab.go Normal file
View file

@ -0,0 +1,129 @@
package handler
import (
"fmt"
"net/http"
"github.com/drone/drone/pkg/database"
. "github.com/drone/drone/pkg/model"
"github.com/drone/drone/pkg/queue"
"github.com/plouc/go-gitlab-client"
)
type GitlabHandler struct {
queue *queue.Queue
apiPath string
}
func NewGitlabHandler(queue *queue.Queue) *GitlabHandler {
return &GitlabHandler{
queue: queue,
apiPath: "/api/v3",
}
}
func (g *GitlabHandler) Add(w http.ResponseWriter, r *http.Request, u *User) error {
settings := database.SettingsMust()
teams, err := database.ListTeams(u.ID)
if err != nil {
return err
}
data := struct {
User *User
Teams []*Team
Settings *Settings
}{u, teams, settings}
// if the user hasn't linked their GitLab account
// render a different template
if len(u.GitlabToken) == 0 {
return RenderTemplate(w, "gitlab_link.html", &data)
}
// otherwise display the template for adding
// a new GitLab repository.
return RenderTemplate(w, "gitlab_add.html", &data)
}
func (g *GitlabHandler) Link(w http.ResponseWriter, r *http.Request, u *User) error {
var err error
return err
}
func (g *GitlabHandler) Create(w http.ResponseWriter, r *http.Request, u *User) error {
teamName := r.FormValue("team")
owner := r.FormValue("owner")
name := r.FormValue("name")
repo, err := g.newGitlabRepo(u, owner, name)
if err != nil {
return err
}
if len(teamName) > 0 {
team, err := database.GetTeamSlug(teamName)
if err != nil {
return fmt.Errorf("Unable to find Team %s.", teamName)
}
// user must be an admin member of the team
if ok, _ := database.IsMemberAdmin(u.ID, team.ID); !ok {
return fmt.Errorf("Invalid permission to access Team %s.", teamName)
}
repo.TeamID = team.ID
}
// Save to the database
if err := database.SaveRepo(repo); err != nil {
return fmt.Errorf("Error saving repository to the database. %s", err)
}
return RenderText(w, http.StatusText(http.StatusOK), http.StatusOK)
}
func (g *GitlabHandler) newGitlabRepo(u *User, owner, name string) (*Repo, error) {
settings := database.SettingsMust()
gl := gogitlab.NewGitlab(settings.GitlabApiUrl, g.apiPath, u.GitlabToken)
project, err := gl.Project(ns(owner, name))
if err != nil {
return nil, err
}
var cloneUrl string
if project.Public {
cloneUrl = project.HttpRepoUrl
} else {
cloneUrl = project.SshRepoUrl
}
repo, err := NewRepo(settings.GitlabDomain, owner, name, ScmGit, cloneUrl)
if err != nil {
return nil, err
}
repo.UserID = u.ID
repo.Private = !project.Public
if repo.Private {
// name the key
keyName := fmt.Sprintf("%s@%s", repo.Owner, settings.Domain)
// TODO: (fudanchii) check if we already opted to use UserKey
// create the github key, or update if one already exists
if err := gl.AddProjectDeployKey(ns(owner, name), keyName, repo.PublicKey); err != nil {
return nil, fmt.Errorf("Unable to add Public Key to your GitLab repository.")
}
}
link := fmt.Sprintf("%s://%s/hook/gitlab?id=%s", settings.Scheme, settings.Domain, repo.Slug)
if err := gl.AddProjectHook(ns(owner, name), link, true, false, true); err != nil {
return nil, fmt.Errorf("Unable to add Hook to your GitLab repository.")
}
return repo, err
}
// ns namespaces user and repo.
// Returns user%2Frepo
func ns(user, repo string) string {
return fmt.Sprintf("%s%%252F%s", user, repo)
}

View file

@ -0,0 +1,330 @@
package testing
import (
"database/sql"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/drone/drone/pkg/database"
"github.com/drone/drone/pkg/handler"
"github.com/drone/drone/pkg/queue"
"github.com/drone/drone/pkg/model"
dbtest "github.com/drone/drone/pkg/database/testing"
. "github.com/smartystreets/goconvey/convey"
)
// Tests the ability to create GitHub repositories.
func Test_GitLabCreate(t *testing.T) {
// seed the database with values
SetupGitlabFixtures()
defer TeardownGitlabFixtures()
q := &queue.Queue{}
gl := handler.NewGitlabHandler(q)
// mock request
req := http.Request{}
req.Form = url.Values{}
// get user that will add repositories
user, _ := database.GetUser(1)
settings := database.SettingsMust()
Convey("Given request to setup gitlab repo", t, func() {
Convey("When repository is public", func() {
req.Form.Set("owner", "example")
req.Form.Set("name", "public")
req.Form.Set("team", "")
res := httptest.NewRecorder()
err := gl.Create(res, &req, user)
repo, _ := database.GetRepoSlug(settings.GitlabDomain + "/example/public")
Convey("The repository is created", func() {
So(err, ShouldBeNil)
So(repo, ShouldNotBeNil)
So(repo.ID, ShouldNotEqual, 0)
So(repo.Owner, ShouldEqual, "example")
So(repo.Name, ShouldEqual, "public")
So(repo.Host, ShouldEqual, settings.GitlabDomain)
So(repo.TeamID, ShouldEqual, 0)
So(repo.UserID, ShouldEqual, user.ID)
So(repo.Private, ShouldEqual, false)
So(repo.SCM, ShouldEqual, "git")
})
Convey("The repository is public", func() {
So(repo.Private, ShouldEqual, false)
})
})
Convey("When repository is private", func() {
req.Form.Set("owner", "example")
req.Form.Set("name", "private")
req.Form.Set("team", "")
res := httptest.NewRecorder()
err := gl.Create(res, &req, user)
repo, _ := database.GetRepoSlug(settings.GitlabDomain + "/example/private")
Convey("The repository is created", func() {
So(err, ShouldBeNil)
So(repo, ShouldNotBeNil)
So(repo.ID, ShouldNotEqual, 0)
})
Convey("The repository is private", func() {
So(repo.Private, ShouldEqual, true)
})
})
Convey("When repository is not found", func() {
req.Form.Set("owner", "example")
req.Form.Set("name", "notfound")
req.Form.Set("team", "")
res := httptest.NewRecorder()
err := gl.Create(res, &req, user)
Convey("The result is an error", func() {
So(err, ShouldNotBeNil)
So(err.Error(), ShouldEqual, "*Gitlab.buildAndExecRequest failed: 404 Not Found")
})
Convey("The repository is not created", func() {
_, err := database.GetRepoSlug("example/notfound")
So(err, ShouldNotBeNil)
So(err, ShouldEqual, sql.ErrNoRows)
})
})
Convey("When repository hook is not writable", func() {
req.Form.Set("owner", "example")
req.Form.Set("name", "hookerr")
req.Form.Set("team", "")
res := httptest.NewRecorder()
err := gl.Create(res, &req, user)
Convey("The result is an error", func() {
So(err, ShouldNotBeNil)
So(err.Error(), ShouldEqual, "Unable to add Hook to your GitLab repository.")
})
Convey("The repository is not created", func() {
_, err := database.GetRepoSlug("example/hookerr")
So(err, ShouldNotBeNil)
So(err, ShouldEqual, sql.ErrNoRows)
})
})
Convey("When repository ssh key is not writable", func() {
req.Form.Set("owner", "example")
req.Form.Set("name", "keyerr")
req.Form.Set("team", "")
res := httptest.NewRecorder()
err := gl.Create(res, &req, user)
Convey("The result is an error", func() {
So(err, ShouldNotBeNil)
So(err.Error(), ShouldEqual, "Unable to add Public Key to your GitLab repository.")
})
Convey("The repository is not created", func() {
_, err := database.GetRepoSlug("example/keyerr")
So(err, ShouldNotBeNil)
So(err, ShouldEqual, sql.ErrNoRows)
})
})
Convey("When a team is provided", func() {
req.Form.Set("owner", "example")
req.Form.Set("name", "team")
req.Form.Set("team", "drone")
res := httptest.NewRecorder()
// invoke handler
err := gl.Create(res, &req, user)
team, _ := database.GetTeamSlug("drone")
repo, _ := database.GetRepoSlug(settings.GitlabDomain + "/example/team")
Convey("The repository is created", func() {
So(err, ShouldBeNil)
So(repo, ShouldNotBeNil)
So(repo.ID, ShouldNotEqual, 0)
})
Convey("The team should be set", func() {
So(repo.TeamID, ShouldEqual, team.ID)
})
})
Convey("When a team is not found", func() {
req.Form.Set("owner", "example")
req.Form.Set("name", "public")
req.Form.Set("team", "faketeam")
res := httptest.NewRecorder()
err := gl.Create(res, &req, user)
Convey("The result is an error", func() {
So(err, ShouldNotBeNil)
So(err.Error(), ShouldEqual, "Unable to find Team faketeam.")
})
})
Convey("When a team is forbidden", func() {
req.Form.Set("owner", "example")
req.Form.Set("name", "public")
req.Form.Set("team", "golang")
res := httptest.NewRecorder()
err := gl.Create(res, &req, user)
Convey("The result is an error", func() {
So(err, ShouldNotBeNil)
So(err.Error(), ShouldEqual, "Invalid permission to access Team golang.")
})
})
})
}
// this code should be refactored and centralized, but for now
// it is just proof-of-concepting a testing strategy, so we'll
// revisit later.
// server is a test HTTP server used to provide mock API responses.
var glServer *httptest.Server
func SetupGitlabFixtures() {
dbtest.Setup()
// test server
mux := http.NewServeMux()
glServer = httptest.NewServer(mux)
url, _ := url.Parse(glServer.URL)
// set database to use a localhost url for GitHub
settings := model.Settings{}
settings.GitlabApiUrl = url.String() // normall would be "https://api.github.com"
settings.GitlabDomain = url.Host // normally would be "github.com"
settings.Scheme = url.Scheme
settings.Domain = "localhost"
database.SaveSettings(&settings)
// -----------------------------------------------------------------------------------
// fixture to return a public repository and successfully
// create a commit hook.
mux.HandleFunc("/api/v3/projects/example%2Fpublic", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{
"name": "public",
"path_with_namespace": "example/public",
"public": true
}`)
})
mux.HandleFunc("/api/v3/projects/example%2Fpublic/hooks", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `{
"url": "https://example.com/example/public/hooks/1",
"id": 1
}`)
})
// -----------------------------------------------------------------------------------
// fixture to return a private repository and successfully
// create a commit hook and ssh deploy key
mux.HandleFunc("/api/v3/projects/example%2Fprivate", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{
"name": "private",
"path_with_namespace": "example/private",
"public": false
}`)
})
mux.HandleFunc("/api/v3/projects/example%2Fprivate/hooks", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `{
"url": "https://example.com/example/private/hooks/1",
"id": 1
}`)
})
mux.HandleFunc("/api/v3/projects/example%2Fprivate/keys", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `{
"id": 1,
"key": "ssh-rsa AAA...",
"url": "https://api.github.com/user/keys/1",
"title": "octocat@octomac"
}`)
})
// -----------------------------------------------------------------------------------
// fixture to return a not found when accessing a github repository.
mux.HandleFunc("/api/v3/projects/example%2Fnotfound", func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
})
// -----------------------------------------------------------------------------------
// fixture to return a public repository and successfully
// create a commit hook.
mux.HandleFunc("/api/v3/projects/example%2Fhookerr", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{
"name": "hookerr",
"path_with_namespace": "example/hookerr",
"public": true
}`)
})
mux.HandleFunc("/api/v3/projects/example%2Fhookerr/hooks", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Forbidden", http.StatusForbidden)
})
// -----------------------------------------------------------------------------------
// fixture to return a private repository and successfully
// create a commit hook and ssh deploy key
mux.HandleFunc("/api/v3/projects/example%2Fkeyerr", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{
"name": "keyerr",
"path_with_namespace": "example/keyerr",
"public": false
}`)
})
mux.HandleFunc("/api/v3/projects/example%2Fkeyerr/hooks", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `{
"url": "https://api.github.com/api/v3/projects/example/keyerr/hooks/1",
"id": 1
}`)
})
mux.HandleFunc("/api/v3/projects/example%2Fkeyerr/keys", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Forbidden", http.StatusForbidden)
})
// -----------------------------------------------------------------------------------
// fixture to return a public repository and successfully to
// test adding a team.
mux.HandleFunc("/api/v3/projects/example%2Fteam", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{
"name": "team",
"path_with_namespace": "example/team",
"public": true
}`)
})
mux.HandleFunc("/api/v3/projects/example%2Fteam/hooks", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `{
"url": "https://api.github.com/api/v3/projects/example/team/hooks/1",
"id": 1
}`)
})
}
func TeardownGitlabFixtures() {
dbtest.Teardown()
glServer.Close()
}

View file

@ -32,6 +32,10 @@ type Settings struct {
BitbucketKey string `meddler:"bitbucket_key"`
BitbucketSecret string `meddler:"bitbucket_secret"`
// GitLab Domain
GitlabDomain string `meddler:"gitlab_domain"`
GitlabApiUrl string `meddler:"gitlab_apiurl"`
// Domain of the server, eg drone.io
Domain string `meddler:"hostname"`

View file

@ -41,6 +41,8 @@ type User struct {
BitbucketLogin string `meddler:"bitbucket_login" json:"-"`
BitbucketToken string `meddler:"bitbucket_token" json:"-"`
BitbucketSecret string `meddler:"bitbucket_secret" json:"-"`
GitlabToken string `meddler:"gitlab_token" json:"-"`
}
// Creates a new User from the given Name and Email.

View file

@ -15,6 +15,7 @@
<div class="col-xs-3">
<ul class="nav nav-pills nav-stacked">
<li class="active"><a href="/new/github.com">GitHub</a></li>
<li><a href="/new/gitlab">GitLab</a></li>
<li><a href="/new/bitbucket.org">Bitbucket <small>(coming soon)</small></a></li>
</ul>
</div><!-- ./col-xs-3 -->

View file

@ -15,6 +15,7 @@
<div class="col-xs-3">
<ul class="nav nav-pills nav-stacked">
<li class="active"><a href="/new/github.com">GitHub</a></li>
<li><a href="/new/gitlab">GitLab</a></li>
<li><a href="/new/bitbucket.org">Bitbucket <small>(coming soon)</small></a></li>
</ul>
</div><!-- ./col-xs-3 -->

View file

@ -0,0 +1,102 @@
{{ define "title" }}GitHub · Add Repository{{ end }}
{{ define "content" }}
<div class="subhead">
<div class="container">
<h1>
<span>Repository Setup</span>
<small>GitHub</small>
</h1>
</div><!-- ./container -->
</div><!-- ./subhead -->
<div class="container">
<div class="row">
<div class="col-xs-3">
<ul class="nav nav-pills nav-stacked">
<li><a href="/new/github.com">GitHub</a></li>
<li class="active"><a href="/new/gitlab">GitLab</a></li>
<li><a href="/new/bitbucket.org">Bitbucket <small>(coming soon)</small></a></li>
</ul>
</div><!-- ./col-xs-3 -->
<div class="col-xs-9" role="main">
<div class="alert">
Enter your repository details
<a class="btn btn-default pull-right" href="/auth/login/github" style="font-size: 18px;background:#f4f4f4;">Re-Link Account</a>
</div>
<form class="form-repo" method="POST" action="/new/github.com">
<input type="hidden" name="domain" autocomplete="off" value="{{.Settings.GitHubDomain}}">
<div class="field-group">
<div>
<label>GitHub Owner</label>
<div>
<input class="form-control form-control-large" type="text" name="owner" autocomplete="off">
</div>
</div>
</div>
<div class="field-separator">/</div>
<div class="field-group">
<div>
<label>Repository Name</label>
<div>
<input class="form-control form-control-large" type="text" name="name" autocomplete="off">
</div>
</div>
</div>
<br/>
<div class="alert">Select your Drone account</div>
<ul>
<li>
<input type="radio" name="team" checked="True" value="">
<img src="{{ .User.Image }}?s=32">
<span>Me</span>
</li>
{{ range .Teams }}
<li>
<input type="radio" name="team" value="{{ .Slug }}">
<img src="{{ .Image }}?s=32">
<span>{{ .Name }}</span>
</li>
{{ end }}
</ul>
<div class="alert alert-success hide" id="successAlert"></div>
<div class="alert alert-error hide" id="failureAlert"></div>
<div class="form-actions">
<input class="btn btn-primary" id="submitButton" type="submit" value="Add" data-loading-text="Saving ..">
<a class="btn btn-default" href="/dashboard">Cancel</a>
</div>
</form>
</div><!-- ./col-xs-9 -->
</div><!-- ./row -->
</div><!-- ./container -->
{{ end }}
{{ define "script" }}
<script>
document.forms[0].onsubmit = function(event) {
$("#successAlert").hide();
$("#failureAlert").hide();
$('#submitButton').button('loading')
var form = event.target
var formData = new FormData(form);
xhr = new XMLHttpRequest();
xhr.open('POST', form.action);
xhr.onload = function() {
if (this.status == 200) {
var name = $("input[name=name]").val()
var owner = $("input[name=owner]").val()
var domain = $("input[name=domain]").val()
window.location.pathname = "/" + domain + "/"+owner+"/"+name
} else {
$("#failureAlert").text("Unable to setup the Repository");
$("#failureAlert").show().removeClass("hide");
$('#submitButton').button('reset')
};
};
xhr.send(formData);
return false;
}
</script>
{{ end }}

View file

@ -0,0 +1,45 @@
{{ define "title" }}GitLab · Add Repository{{ end }}
{{ define "content" }}
<div class="subhead">
<div class="container">
<h1>
<span>Repository Setup</span>
<small>GitLab</small>
</h1>
</div><!-- ./container -->
</div><!-- ./subhead -->
<div class="container">
<div class="row">
<div class="col-xs-3">
<ul class="nav nav-pills nav-stacked">
<li><a href="/new/github.com">GitHub</a></li>
<li class="active"><a href="/new/gitlab">GitLab</a></li>
<li><a href="/new/bitbucket.org">Bitbucket <small>(coming soon)</small></a></li>
</ul>
</div><!-- ./col-xs-3 -->
<div class="col-xs-9" role="main">
<div class="alert">Link Your GitLab Account</div>
<form class="form-repo" method="POST" action="/link/gitlab">
<div class="field-group">
<div><label>GitLab Private Token</label>
<div><input class="form-control form-control-large" type="text" name="token" autocomplete="off"></div>
</div>
</div>
<div class="alert alert-success hide" id="successAlert"></div>
<div class="alert alert-error hide" id="failureAlert"></div>
<div class="form-actions">
<input class="btn btn-primary" id="submitButton" type="submit" value="Link" data-loading-text="Saving ..">
<a class="btn btn-default" href="/dashboard">Cancel</a>
</div>
</form>
</div><!-- ./col-xs-9 -->
</div><!-- ./row -->
</div><!-- ./container -->
{{ end }}
{{ define "script" }}{{ end }}

View file

@ -82,6 +82,8 @@ func init() {
"admin_settings.html",
"github_add.html",
"github_link.html",
"gitlab_add.html",
"gitlab_link.html",
}
// extract the base template as a string