Browse Source

Merge pull request #20 from matrix-org/kegan/jira-commands

Implement processing of !jira create commands
kegan/create-with-json
Kegsay 8 years ago
committed by GitHub
parent
commit
79990c833d
  1. 11
      src/github.com/matrix-org/go-neb/database/db.go
  2. 29
      src/github.com/matrix-org/go-neb/database/schema.go
  3. 1
      src/github.com/matrix-org/go-neb/goneb.go
  4. 5
      src/github.com/matrix-org/go-neb/plugin/plugin.go
  5. 124
      src/github.com/matrix-org/go-neb/realms/jira/jira.go
  6. 181
      src/github.com/matrix-org/go-neb/services/jira/jira.go

11
src/github.com/matrix-org/go-neb/database/db.go

@ -184,6 +184,17 @@ func (d *ServiceDB) LoadAuthRealm(realmID string) (realm types.AuthRealm, err er
return return
} }
// LoadAuthRealmsByType loads all auth realms with the given type from the database.
// The realms are ordered based on their realm ID.
// Returns an empty list if there are no realms with that type.
func (d *ServiceDB) LoadAuthRealmsByType(realmType string) (realms []types.AuthRealm, err error) {
err = runTransaction(d.db, func(txn *sql.Tx) error {
realms, err = selectRealmsByTypeTxn(txn, realmType)
return err
})
return
}
// StoreAuthRealm stores the given AuthRealm, clobbering based on the realm ID. // StoreAuthRealm stores the given AuthRealm, clobbering based on the realm ID.
// This function updates the time added/updated values. The previous realm, if any, is // This function updates the time added/updated values. The previous realm, if any, is
// returned. // returned.

29
src/github.com/matrix-org/go-neb/database/schema.go

@ -262,6 +262,35 @@ func selectRealmTxn(txn *sql.Tx, realmID string) (types.AuthRealm, error) {
return realm, nil return realm, nil
} }
const selectRealmsByTypeSQL = `
SELECT realm_id, realm_json FROM auth_realms WHERE realm_type = $1 ORDER BY realm_id
`
func selectRealmsByTypeTxn(txn *sql.Tx, realmType string) (realms []types.AuthRealm, err error) {
rows, err := txn.Query(selectRealmsByTypeSQL, realmType)
if err != nil {
return
}
defer rows.Close()
for rows.Next() {
var realmID string
var realmJSON []byte
if err = rows.Scan(&realmID, &realmJSON); err != nil {
return
}
realm := types.CreateAuthRealm(realmID, realmType)
if realm == nil {
err = fmt.Errorf("Cannot create realm %s of type %s", realmID, realmType)
return
}
if err = json.Unmarshal(realmJSON, realm); err != nil {
return
}
realms = append(realms, realm)
}
return
}
const updateRealmSQL = ` const updateRealmSQL = `
UPDATE auth_realms SET realm_type=$1, realm_json=$2, time_updated_ms=$3 UPDATE auth_realms SET realm_type=$1, realm_json=$2, time_updated_ms=$3
WHERE realm_id=$4 WHERE realm_id=$4

1
src/github.com/matrix-org/go-neb/goneb.go

@ -9,6 +9,7 @@ import (
"github.com/matrix-org/go-neb/server" "github.com/matrix-org/go-neb/server"
_ "github.com/matrix-org/go-neb/services/echo" _ "github.com/matrix-org/go-neb/services/echo"
_ "github.com/matrix-org/go-neb/services/github" _ "github.com/matrix-org/go-neb/services/github"
_ "github.com/matrix-org/go-neb/services/jira"
"github.com/matrix-org/go-neb/types" "github.com/matrix-org/go-neb/types"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
"net/http" "net/http"

5
src/github.com/matrix-org/go-neb/plugin/plugin.go

@ -66,6 +66,11 @@ func runCommandForPlugin(plugin Plugin, event *matrix.Event, arguments []string)
} }
cmdArgs := arguments[len(bestMatch.Path):] cmdArgs := arguments[len(bestMatch.Path):]
log.WithFields(log.Fields{
"room_id": event.RoomID,
"user_id": event.Sender,
"command": bestMatch.Path,
}).Info("Executing command")
content, err := bestMatch.Command(event.RoomID, event.Sender, cmdArgs) content, err := bestMatch.Command(event.RoomID, event.Sender, cmdArgs)
if err != nil { if err != nil {
if content != nil { if content != nil {

124
src/github.com/matrix-org/go-neb/realms/jira/jira.go

@ -3,9 +3,11 @@ package realms
import ( import (
"crypto/rsa" "crypto/rsa"
"crypto/x509" "crypto/x509"
"database/sql"
"encoding/json" "encoding/json"
"encoding/pem" "encoding/pem"
"errors" "errors"
"fmt"
log "github.com/Sirupsen/logrus" log "github.com/Sirupsen/logrus"
"github.com/andygrunwald/go-jira" "github.com/andygrunwald/go-jira"
"github.com/dghubble/oauth1" "github.com/dghubble/oauth1"
@ -14,9 +16,11 @@ import (
"github.com/matrix-org/go-neb/types" "github.com/matrix-org/go-neb/types"
"golang.org/x/net/context" "golang.org/x/net/context"
"net/http" "net/http"
"strings"
) )
type jiraRealm struct {
// JIRARealm is an AuthRealm which can process JIRA installations
type JIRARealm struct {
id string id string
redirectURL string redirectURL string
privateKey *rsa.PrivateKey privateKey *rsa.PrivateKey
@ -57,15 +61,18 @@ func (s *JIRASession) ID() string {
return s.id return s.id
} }
func (r *jiraRealm) ID() string {
// ID returns the ID of this JIRA realm.
func (r *JIRARealm) ID() string {
return r.id return r.id
} }
func (r *jiraRealm) Type() string {
// Type returns the type of realm this is.
func (r *JIRARealm) Type() string {
return "jira" return "jira"
} }
func (r *jiraRealm) Register() error {
// Register is called when this realm is being created from an external entity
func (r *JIRARealm) Register() error {
if r.ConsumerName == "" || r.ConsumerKey == "" || r.ConsumerSecret == "" || r.PrivateKeyPEM == "" { if r.ConsumerName == "" || r.ConsumerKey == "" || r.ConsumerSecret == "" || r.PrivateKeyPEM == "" {
return errors.New("ConsumerName, ConsumerKey, ConsumerSecret, PrivateKeyPEM must be specified.") return errors.New("ConsumerName, ConsumerKey, ConsumerSecret, PrivateKeyPEM must be specified.")
} }
@ -78,7 +85,7 @@ func (r *jiraRealm) Register() error {
} }
// Check to see if JIRA endpoint is valid by pinging an endpoint // Check to see if JIRA endpoint is valid by pinging an endpoint
cli, err := r.jiraClient(r.JIRAEndpoint, "", true)
cli, err := r.JIRAClient("", true)
if err != nil { if err != nil {
return err return err
} }
@ -97,7 +104,8 @@ func (r *jiraRealm) Register() error {
return nil return nil
} }
func (r *jiraRealm) RequestAuthSession(userID string, req json.RawMessage) interface{} {
// RequestAuthSession is called by a user wishing to auth with this JIRA realm
func (r *JIRARealm) RequestAuthSession(userID string, req json.RawMessage) interface{} {
logger := log.WithField("jira_url", r.JIRAEndpoint) logger := log.WithField("jira_url", r.JIRAEndpoint)
if err := r.ensureInited(); err != nil { if err := r.ensureInited(); err != nil {
logger.WithError(err).Print("Failed to init realm") logger.WithError(err).Print("Failed to init realm")
@ -132,7 +140,8 @@ func (r *jiraRealm) RequestAuthSession(userID string, req json.RawMessage) inter
}{authURL.String()} }{authURL.String()}
} }
func (r *jiraRealm) OnReceiveRedirect(w http.ResponseWriter, req *http.Request) {
// OnReceiveRedirect is called when JIRA installations redirect back to NEB
func (r *JIRARealm) OnReceiveRedirect(w http.ResponseWriter, req *http.Request) {
logger := log.WithField("jira_url", r.JIRAEndpoint) logger := log.WithField("jira_url", r.JIRAEndpoint)
if err := r.ensureInited(); err != nil { if err := r.ensureInited(); err != nil {
failWith(logger, w, 500, "Failed to initialise realm", err) failWith(logger, w, 500, "Failed to initialise realm", err)
@ -180,7 +189,8 @@ func (r *jiraRealm) OnReceiveRedirect(w http.ResponseWriter, req *http.Request)
w.Write([]byte("OK!")) w.Write([]byte("OK!"))
} }
func (r *jiraRealm) AuthSession(id, userID, realmID string) types.AuthSession {
// AuthSession returns a JIRASession with the given parameters
func (r *JIRARealm) AuthSession(id, userID, realmID string) types.AuthSession {
return &JIRASession{ return &JIRASession{
id: id, id: id,
userID: userID, userID: userID,
@ -188,31 +198,83 @@ func (r *jiraRealm) AuthSession(id, userID, realmID string) types.AuthSession {
} }
} }
// jiraClient returns an authenticated jira.Client for the given userID. Returns an unauthenticated
// ProjectKeyExists returns true if the given project key exists on this JIRA realm.
// An authenticated client for userID will be used if one exists, else an
// unauthenticated client will be used, which may not be able to see the complete list
// of projects.
func (r *JIRARealm) ProjectKeyExists(userID, projectKey string) (bool, error) {
if err := r.ensureInited(); err != nil {
return false, err
}
cli, err := r.JIRAClient(userID, true)
if err != nil {
return false, err
}
var projects []jira.Project
req, err := cli.NewRequest("GET", "rest/api/2/project", nil)
if err != nil {
return false, err
}
res, err := cli.Do(req, &projects)
if err != nil {
return false, err
}
if res == nil {
return false, errors.New("No response returned")
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
return false, fmt.Errorf("%srest/api/2/project returned code %d",
r.JIRAEndpoint, res.StatusCode)
}
for _, p := range projects {
if strings.EqualFold(p.Key, projectKey) {
return true, nil
}
}
return false, nil
}
// JIRAClient returns an authenticated jira.Client for the given userID. Returns an unauthenticated
// client if allowUnauth is true and no authenticated session is found, else returns an error. // client if allowUnauth is true and no authenticated session is found, else returns an error.
func (r *jiraRealm) jiraClient(jiraBaseURL, userID string, allowUnauth bool) (*jira.Client, error) {
// TODO: Check if user has an auth session. Requires access token+secret
hasAuthSession := false
if hasAuthSession {
// make an authenticated client
var cli *jira.Client
auth := r.oauth1Config(jiraBaseURL)
httpClient := auth.Client(context.TODO(), oauth1.NewToken("access_tokenTODO", "access_secretTODO"))
cli, err := jira.NewClient(httpClient, jiraBaseURL)
return cli, err
} else if allowUnauth {
// make an unauthenticated client
cli, err := jira.NewClient(nil, jiraBaseURL)
return cli, err
} else {
func (r *JIRARealm) JIRAClient(userID string, allowUnauth bool) (*jira.Client, error) {
// Check if user has an auth session.
session, err := database.GetServiceDB().LoadAuthSessionByUser(r.id, userID)
if err != nil {
if err == sql.ErrNoRows {
if allowUnauth {
// make an unauthenticated client
return jira.NewClient(nil, r.JIRAEndpoint)
}
return nil, errors.New("No authenticated session found for " + userID)
}
// some other error
return nil, err
}
jsession, ok := session.(*JIRASession)
if !ok {
return nil, errors.New("Failed to cast user session to a JIRASession")
}
// Make sure they finished the auth process
if jsession.AccessSecret == "" || jsession.AccessToken == "" {
if allowUnauth {
// make an unauthenticated client
return jira.NewClient(nil, r.JIRAEndpoint)
}
return nil, errors.New("No authenticated session found for " + userID) return nil, errors.New("No authenticated session found for " + userID)
} }
// make an authenticated client
auth := r.oauth1Config(r.JIRAEndpoint)
httpClient := auth.Client(
context.TODO(),
oauth1.NewToken(jsession.AccessToken, jsession.AccessSecret),
)
return jira.NewClient(httpClient, r.JIRAEndpoint)
} }
func (r *jiraRealm) ensureInited() error {
func (r *JIRARealm) ensureInited() error {
if err := r.parsePrivateKey(); err != nil { if err := r.parsePrivateKey(); err != nil {
log.WithError(err).Print("Failed to parse private key") log.WithError(err).Print("Failed to parse private key")
return err return err
@ -227,7 +289,7 @@ func (r *jiraRealm) ensureInited() error {
return nil return nil
} }
func (r *jiraRealm) parsePrivateKey() error {
func (r *JIRARealm) parsePrivateKey() error {
if r.privateKey != nil { if r.privateKey != nil {
return nil return nil
} }
@ -244,7 +306,7 @@ func (r *jiraRealm) parsePrivateKey() error {
return nil return nil
} }
func (r *jiraRealm) oauth1Config(jiraBaseURL string) *oauth1.Config {
func (r *JIRARealm) oauth1Config(jiraBaseURL string) *oauth1.Config {
return &oauth1.Config{ return &oauth1.Config{
ConsumerKey: r.ConsumerKey, ConsumerKey: r.ConsumerKey,
ConsumerSecret: r.ConsumerSecret, ConsumerSecret: r.ConsumerSecret,
@ -319,6 +381,6 @@ func failWith(logger *log.Entry, w http.ResponseWriter, code int, msg string, er
func init() { func init() {
types.RegisterAuthRealm(func(realmID, redirectURL string) types.AuthRealm { types.RegisterAuthRealm(func(realmID, redirectURL string) types.AuthRealm {
return &jiraRealm{id: realmID, redirectURL: redirectURL}
return &JIRARealm{id: realmID, redirectURL: redirectURL}
}) })
} }

181
src/github.com/matrix-org/go-neb/services/jira/jira.go

@ -0,0 +1,181 @@
package services
import (
"database/sql"
"errors"
"fmt"
log "github.com/Sirupsen/logrus"
"github.com/andygrunwald/go-jira"
"github.com/matrix-org/go-neb/database"
"github.com/matrix-org/go-neb/matrix"
"github.com/matrix-org/go-neb/plugin"
"github.com/matrix-org/go-neb/realms/jira"
"github.com/matrix-org/go-neb/types"
"net/http"
"regexp"
"strings"
)
// Matches alphas then a -, then a number. E.g "FOO-123"
var issueKeyRegex = regexp.MustCompile("([A-z]+)-([0-9]+)")
var projectKeyRegex = regexp.MustCompile("^[A-z]+$")
type jiraService struct {
id string
UserID string
Rooms []string
}
func (s *jiraService) ServiceUserID() string { return s.UserID }
func (s *jiraService) ServiceID() string { return s.id }
func (s *jiraService) ServiceType() string { return "jira" }
func (s *jiraService) RoomIDs() []string { return s.Rooms }
func (s *jiraService) Register() error { return nil }
func (s *jiraService) PostRegister(old types.Service) {}
func (s *jiraService) Plugin(roomID string) plugin.Plugin {
return plugin.Plugin{
Commands: []plugin.Command{
plugin.Command{
Path: []string{"jira", "create"},
Command: func(roomID, userID string, args []string) (interface{}, error) {
// E.g jira create PROJ "Issue title" "Issue desc"
if len(args) <= 1 {
return nil, errors.New("Missing project key (e.g 'ABC') and/or title")
}
if !projectKeyRegex.MatchString(args[0]) {
return nil, errors.New("Project key must only contain A-Z.")
}
pkey := strings.ToUpper(args[0]) // REST API complains if they are not ALL CAPS
title := args[1]
desc := ""
if len(args) == 3 {
desc = args[2]
} else if len(args) > 3 { // > 3 args is probably a title without quote marks
joinedTitle := strings.Join(args[1:], " ")
title = joinedTitle
}
r, err := s.projectToRealm(userID, pkey)
if err != nil {
log.WithError(err).Print("Failed to map project key to realm")
return nil, errors.New("Failed to map project key to a JIRA endpoint.")
}
if r == nil {
return nil, errors.New("No known project exists with that project key.")
}
iss := jira.Issue{
Fields: &jira.IssueFields{
Summary: title,
Description: desc,
Project: jira.Project{
Key: pkey,
},
// FIXME: This may vary depending on the JIRA install!
Type: jira.IssueType{
Name: "Bug",
},
},
}
cli, err := r.JIRAClient(userID, false)
if err != nil {
return nil, err
}
i, res, err := cli.Issue.Create(&iss)
if err != nil {
log.WithFields(log.Fields{
log.ErrorKey: err,
"user_id": userID,
"project": pkey,
"realm_id": r.ID(),
}).Print("Failed to create issue")
return nil, errors.New("Failed to create issue")
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
return nil, fmt.Errorf("Failed to create issue: JIRA returned %d", res.StatusCode)
}
return &matrix.TextMessage{
"m.notice",
fmt.Sprintf("Created issue: %s", i.Key),
}, nil
},
},
},
}
}
func (s *jiraService) OnReceiveWebhook(w http.ResponseWriter, req *http.Request, cli *matrix.Client) {
w.WriteHeader(200) // Do nothing
}
func (s *jiraService) projectToRealm(userID, pkey string) (*realms.JIRARealm, error) {
// We don't know which JIRA installation this project maps to, so:
// - Get all known JIRA realms and f.e query their endpoints with the
// given user ID's credentials (so if it is a private project they
// can see it will succeed.)
// - If there is a matching project with that key, return that realm.
// We search installations which the user has already OAuthed with first as most likely
// the project key will be on a JIRA they have access to.
// TODO: Return whether they have authed or not so they know if they need to make a starter link
logger := log.WithFields(log.Fields{
"user_id": userID,
"project": pkey,
})
knownRealms, err := database.GetServiceDB().LoadAuthRealmsByType("jira")
if err != nil {
logger.WithError(err).Print("Failed to load jira auth realms")
return nil, err
}
// typecast and move ones which the user has authed with to the front of the queue
var queue []*realms.JIRARealm
var unauthRealms []*realms.JIRARealm
for _, r := range knownRealms {
jrealm, ok := r.(*realms.JIRARealm)
if !ok {
logger.WithField("realm_id", r.ID()).Print(
"Failed to type-cast 'jira' type realm into JIRARealm",
)
continue
}
_, err := database.GetServiceDB().LoadAuthSessionByUser(r.ID(), userID)
if err != nil {
if err == sql.ErrNoRows {
unauthRealms = append(unauthRealms, jrealm)
} else {
logger.WithError(err).WithField("realm_id", r.ID()).Print(
"Failed to load auth sessions for user",
)
}
continue // this may not have been the match anyway so don't give up!
}
queue = append(queue, jrealm)
}
// push unauthed realms to the back
queue = append(queue, unauthRealms...)
for _, jr := range queue {
exists, err := jr.ProjectKeyExists(userID, pkey)
if err != nil {
logger.WithError(err).WithField("realm_id", jr.ID()).Print(
"Failed to check if project key exists on this realm.",
)
continue // may not have been found anyway so keep searching!
}
if exists {
logger.Info("Project exists on ", jr.ID())
return jr, nil
}
}
return nil, nil
}
func init() {
types.RegisterService(func(serviceID, webhookEndpointURL string) types.Service {
return &jiraService{id: serviceID}
})
}
Loading…
Cancel
Save