From dc63f98c0ce2742bd7bae8fe047ad2058107564d Mon Sep 17 00:00:00 2001 From: Justin Clift Date: Thu, 28 Sep 2017 17:31:35 +0100 Subject: [PATCH] Initial working Merge Request code --- common/postgresql.go | 337 ++++++++-- common/types.go | 87 ++- common/userinput.go | 52 +- common/util.go | 81 ++- common/validate.go | 36 +- database/dbhub.sql | 44 +- webui/main.go | 582 +++++++++++++++++- webui/pages.go | 493 ++++++++++++++- webui/templates/compare.html | 308 ++++++++++ webui/templates/database.html | 32 +- webui/templates/discussioncomments.html | 33 +- webui/templates/discussionlist.html | 15 +- webui/templates/forks.html | 16 +- webui/templates/mergerequestcomments.html | 711 ++++++++++++++++++++++ webui/templates/mergerequestlist.html | 234 +++++++ webui/templates/settings.html | 2 +- 16 files changed, 2901 insertions(+), 162 deletions(-) create mode 100644 webui/templates/compare.html create mode 100644 webui/templates/mergerequestcomments.html create mode 100644 webui/templates/mergerequestlist.html diff --git a/common/postgresql.go b/common/postgresql.go index c424a4327..3b30cf6c9 100644 --- a/common/postgresql.go +++ b/common/postgresql.go @@ -117,6 +117,41 @@ func CheckDBExists(loggedInUser string, dbOwner string, dbFolder string, dbName return true, nil } +// Check if a given database ID is available, and return it's folder/name so the caller can determine if it has been +// renamed. If an error occurs, the true/false value should be ignored, as only the error value is valid. +func CheckDBID(loggedInUser string, dbOwner string, dbID int64) (avail bool, dbFolder string, dbName string, err error) { + dbQuery := ` + SELECT folder, db_name + FROM sqlite_databases + WHERE user_id = ( + SELECT user_id + FROM users + WHERE lower(user_name) = lower($1) + ) + AND db_id = $2 + AND is_deleted = false` + // If the request is from someone who's not logged in, or is for another users database, ensure we only consider + // public databases + if strings.ToLower(loggedInUser) != strings.ToLower(dbOwner) || loggedInUser == "" { + dbQuery += ` + AND public = true` + } + err = pdb.QueryRow(dbQuery, dbOwner, dbID).Scan(&dbFolder, &dbName) + if err != nil { + if err == pgx.ErrNoRows { + avail = false + return + } else { + log.Printf("Checking if a database exists failed: %v\n", err) + return + } + } + + // Database exists + avail = true + return +} + // Check if a database has been starred by a given user. The boolean return value is only valid when err is nil. func CheckDBStarred(loggedInUser string, dbOwner string, dbFolder string, dbName string) (bool, error) { dbQuery := ` @@ -786,14 +821,14 @@ func DisconnectPostgreSQL() { log.Printf("Disconnected from PostgreSQL server: %v:%v\n", Conf.Pg.Server, uint16(Conf.Pg.Port)) } -// Returns the list of discussions for a given database. -// If a non-0 discID value is passed, it will only return the details for that specific discussion. Otherwise it will -// return a list of all discussions for a given database +// Returns the list of discussions or MRs for a given database. +// If a non-0 discID value is passed, it will only return the details for that specific discussion/MR. Otherwise it +// will return a list of all discussions or MRs for a given database // Note - This returns a slice of DiscussionEntry, instead of a map. We use a slice because it lets us use an ORDER // BY clause in the SQL and preserve the returned order (maps don't preserve order). If in future we no longer // need to preserve the order, it might be useful to switch to using a map instead since they're often simpler // to work with. -func Discussions(dbOwner string, dbFolder string, dbName string, discID int) (list []DiscussionEntry, err error) { +func Discussions(dbOwner string, dbFolder string, dbName string, discType DiscussionType, discID int) (list []DiscussionEntry, err error) { dbQuery := ` WITH u AS ( SELECT user_id @@ -806,9 +841,11 @@ func Discussions(dbOwner string, dbFolder string, dbName string, discID int) (li AND db.folder = $2 AND db.db_name = $3) SELECT disc.disc_id, disc.title, disc.open, disc.date_created, users.user_name, users.email, users.avatar_url, - disc.description, last_modified, comment_count + disc.description, last_modified, comment_count, mr_source_db_id, mr_source_db_branch, + mr_destination_branch, mr_state, mr_commits FROM discussions AS disc, d, users WHERE disc.db_id = d.db_id + AND disc.discussion_type = $4 AND disc.creator = users.user_id` if discID != 0 { dbQuery += fmt.Sprintf(` @@ -817,18 +854,20 @@ func Discussions(dbOwner string, dbFolder string, dbName string, discID int) (li dbQuery += ` ORDER BY last_modified DESC` var rows *pgx.Rows - rows, err = pdb.Query(dbQuery, dbOwner, dbFolder, dbName) + rows, err = pdb.Query(dbQuery, dbOwner, dbFolder, dbName, discType) if err != nil { log.Printf("Database query failed: %v\n", err) return } for rows.Next() { - var av, em pgx.NullString + var av, em, sb, db pgx.NullString + var sdb pgx.NullInt64 var oneRow DiscussionEntry - err = rows.Scan(&oneRow.ID, &oneRow.Title, &oneRow.Open, &oneRow.Date_created, &oneRow.Creator, &em, &av, - &oneRow.Body, &oneRow.Last_modified, &oneRow.CommentCount) + err = rows.Scan(&oneRow.ID, &oneRow.Title, &oneRow.Open, &oneRow.DateCreated, &oneRow.Creator, &em, &av, + &oneRow.Body, &oneRow.LastModified, &oneRow.CommentCount, &sdb, &sb, &db, &oneRow.MRDetails.State, + &oneRow.MRDetails.Commits) if err != nil { - log.Printf("Error retrieving discussion list for database '%s%s%s': %v\n", dbOwner, dbFolder, + log.Printf("Error retrieving discussion/MR list for database '%s%s%s': %v\n", dbOwner, dbFolder, dbName, err) rows.Close() return @@ -842,9 +881,46 @@ func Discussions(dbOwner string, dbFolder string, dbName string, discID int) (li oneRow.AvatarURL = fmt.Sprintf("https://www.gravatar.com/avatar/%x?d=identicon&s=30", picHash) } } + if discType == MERGE_REQUEST && sdb.Valid { + oneRow.MRDetails.SourceDBID = sdb.Int64 + } + if sb.Valid { + oneRow.MRDetails.SourceBranch = sb.String + } + if db.Valid { + oneRow.MRDetails.DestBranch = db.String + } oneRow.BodyRendered = string(gfm.Markdown([]byte(oneRow.Body))) list = append(list, oneRow) } + + // For merge requests, turn the source database ID's into full owner/folder/name strings + if discType == MERGE_REQUEST { + for i, j := range list { + // Retrieve the owner/folder/name for a database id + dbQuery = ` + SELECT users.user_name, db.folder, db.db_name + FROM sqlite_databases AS db, users + WHERE db.db_id = $1 + AND db.user_id = users.user_id` + var o, f, n pgx.NullString + err2 := pdb.QueryRow(dbQuery, j.MRDetails.SourceDBID).Scan(&o, &f, &n) + if err2 != nil && err2 != pgx.ErrNoRows { + log.Printf("Retrieving source database owner/folder/name failed: %v\n", err) + return + } + if o.Valid { + list[i].MRDetails.SourceOwner = o.String + } + if f.Valid { + list[i].MRDetails.SourceFolder = f.String + } + if n.Valid { + list[i].MRDetails.SourceDBName = n.String + } + } + } + rows.Close() return } @@ -893,7 +969,7 @@ func DiscussionComments(dbOwner string, dbFolder string, dbName string, discID i for rows.Next() { var av, em pgx.NullString var oneRow DiscussionCommentEntry - err = rows.Scan(&oneRow.ID, &oneRow.Commenter, &em, &av, &oneRow.Date_created, &oneRow.Body, &oneRow.EntryType) + err = rows.Scan(&oneRow.ID, &oneRow.Commenter, &em, &av, &oneRow.DateCreated, &oneRow.Body, &oneRow.EntryType) if err != nil { log.Printf("Error retrieving comment list for database '%s%s%s', discussion '%d': %v\n", dbOwner, dbFolder, dbName, discID, err) @@ -1113,6 +1189,89 @@ func ForkedFrom(dbOwner string, dbFolder string, dbName string) (forkOwn string, return forkOwn, forkFol, forkDB, forkDel, nil } +// Return the parent of a database, if there is one (and it's accessible to the logged in user). If no parent was +// found, the returned Owner/Folder/DBName values will be empty strings +func ForkParent(loggedInUser string, dbOwner string, dbFolder string, dbName string) (parentOwner string, + parentFolder string, parentDBName string, err error) { + dbQuery := ` + SELECT users.user_name, db.folder, db.db_name, db.public, db.db_id, db.forked_from, db.is_deleted + FROM sqlite_databases AS db, users + WHERE db.root_database = ( + SELECT root_database + FROM sqlite_databases + WHERE user_id = ( + SELECT user_id + FROM users + WHERE lower(user_name) = lower($1) + ) + AND folder = $2 + AND db_name = $3 + ) + AND db.user_id = users.user_id + ORDER BY db.forked_from NULLS FIRST` + rows, err := pdb.Query(dbQuery, dbOwner, dbFolder, dbName) + if err != nil { + log.Printf("Database query failed: %v\n", err) + return + } + defer rows.Close() + dbList := make(map[int]ForkEntry) + //var dbList []ForkEntry + for rows.Next() { + var frk pgx.NullInt64 + var oneRow ForkEntry + err = rows.Scan(&oneRow.Owner, &oneRow.Folder, &oneRow.DBName, &oneRow.Public, &oneRow.ID, &frk, &oneRow.Deleted) + if err != nil { + log.Printf("Error retrieving fork parent for '%s%s%s': %v\n", dbOwner, dbFolder, dbName, + err) + return + } + if frk.Valid { + oneRow.ForkedFrom = int(frk.Int64) + } + dbList[oneRow.ID] = oneRow + //dbList = append(dbList, oneRow) + } + + // Safety check + numResults := len(dbList) + if numResults == 0 { + err = fmt.Errorf("Empty list returned instead of fork tree. This shouldn't happen") + return + } + + // Get the ID of the database being called + dbID, err := databaseID(dbOwner, dbFolder, dbName) + if err != nil { + return + } + + // Find the closest (not-deleted) parent for the database + dbEntry, ok := dbList[dbID] + if !ok { + // The database itself wasn't found in the list. This shouldn't happen + err = fmt.Errorf("Internal error when retrieving fork parent info. This shouldn't happen.") + return + } + for dbEntry.ForkedFrom != 0 { + dbEntry, ok = dbList[dbEntry.ForkedFrom] + if !ok { + // Parent database entry wasn't found in the list. This shouldn't happen either + err = fmt.Errorf("Internal error when retrieving fork parent info (#2). This shouldn't happen.") + return + } + if !dbEntry.Deleted { + // Found a parent (that's not deleted). We'll use this and stop looping + parentOwner = dbEntry.Owner + parentFolder = dbEntry.Folder + parentDBName = dbEntry.DBName + break + } + } + + return +} + // Return the complete fork tree for a given database func ForkTree(loggedInUser string, dbOwner string, dbFolder string, dbName string) (outputList []ForkEntry, err error) { dbQuery := ` @@ -1767,7 +1926,6 @@ func GetUsernameFromEmail(email string) (userName string, avatarURL string, err } else { avatarURL = av.String } - return } @@ -2164,7 +2322,7 @@ func StoreBranches(dbOwner string, dbFolder string, dbName string, branches map[ // Adds a comment to a discussion. func StoreComment(dbOwner string, dbFolder string, dbName string, commenter string, discID int, comText string, - discClose bool) error { + discClose bool, mrState MergeRequestState) error { // Begin a transaction tx, err := pdb.Begin() if err != nil { @@ -2177,11 +2335,12 @@ func StoreComment(dbOwner string, dbFolder string, dbName string, commenter stri // person who started the discussion var dbQuery string var discState bool + var discType int64 if discClose == true { // Get the current details for the discussion var discCreator string dbQuery := ` - SELECT disc.open, u.user_name + SELECT disc.open, u.user_name, disc.discussion_type FROM discussions AS disc, users AS u WHERE disc.db_id = ( SELECT db.db_id @@ -2196,7 +2355,7 @@ func StoreComment(dbOwner string, dbFolder string, dbName string, commenter stri ) AND disc.disc_id = $4 AND disc.creator = u.user_id` - err = tx.QueryRow(dbQuery, dbOwner, dbFolder, dbName, discID).Scan(&discState, &discCreator) + err = tx.QueryRow(dbQuery, dbOwner, dbFolder, dbName, discID).Scan(&discState, &discCreator, &discType) if err != nil { log.Printf("Error retrieving current open state for '%s%s%s', discussion '%d': %v\n", dbOwner, dbFolder, dbName, discID, err) @@ -2290,6 +2449,36 @@ func StoreComment(dbOwner string, dbFolder string, dbName string, commenter stri } } + // Update the merge request state for MR's being closed + if discClose == true && discType == MERGE_REQUEST { + dbQuery = ` + UPDATE discussions + SET mr_state = $5 + WHERE db_id = ( + SELECT db.db_id + FROM sqlite_databases AS db + WHERE db.user_id = ( + SELECT user_id + FROM users + WHERE lower(user_name) = lower($1) + ) + AND folder = $2 + AND db_name = $3 + ) + AND disc_id = $4` + commandTag, err = tx.Exec(dbQuery, dbOwner, dbFolder, dbName, discID, mrState) + if err != nil { + log.Printf("Updating MR state for database '%s%s%s', discussion '%d' failed: %v\n", dbOwner, + dbFolder, dbName, discID, err) + return err + } + if numRows := commandTag.RowsAffected(); numRows != 1 { + log.Printf( + "Wrong number of rows (%v) affected when updating MR state for database '%s%s%s', discussion '%d'\n", + numRows, dbOwner, dbFolder, dbName, discID) + } + } + // Update the last_modified date for the parent discussion dbQuery = ` UPDATE discussions @@ -2331,7 +2520,7 @@ func StoreComment(dbOwner string, dbFolder string, dbName string, commenter stri numRows, dbOwner, dbFolder, dbName, discID) } - // Update the counter of open discussions for the database + // Update the open discussion and MR counters for the database dbQuery = ` WITH d AS ( SELECT db.db_id @@ -2350,6 +2539,14 @@ func StoreComment(dbOwner string, dbFolder string, dbName string, commenter stri FROM discussions AS disc, d WHERE disc.db_id = d.db_id AND open = true + AND discussion_type = 0 + ), + merge_requests = ( + SELECT count(disc.*) + FROM discussions AS disc, d + WHERE disc.db_id = d.db_id + AND open = true + AND discussion_type = 1 ) WHERE db_id = (SELECT db_id FROM d)` commandTag, err = tx.Exec(dbQuery, dbOwner, dbFolder, dbName) @@ -2533,11 +2730,13 @@ func StoreDefaultTableName(dbOwner string, folder string, dbName string, tableNa } // Stores a new discussion for a database. -func StoreDiscussion(dbOwner string, dbFolder string, dbName string, loggedInUser string, title string, text string) error { +func StoreDiscussion(dbOwner string, dbFolder string, dbName string, loggedInUser string, title string, text string, + discType DiscussionType, mr MergeRequestEntry) (newID int, err error) { + // Begin a transaction tx, err := pdb.Begin() if err != nil { - return err + return } // Set up an automatic transaction roll back if the function exits without committing defer tx.Rollback() @@ -2555,27 +2754,61 @@ func StoreDiscussion(dbOwner string, dbFolder string, dbName string, loggedInUse AND db.folder = $2 AND db.db_name = $3 ), next_id AS ( - SELECT count(disc.disc_id) + 1 AS id + SELECT coalesce(max(disc.disc_id), 0) + 1 AS id FROM discussions AS disc, d WHERE disc.db_id = d.db_id ) - INSERT INTO discussions (db_id, disc_id, creator, title, description, open) - SELECT (SELECT db_id FROM d), (SELECT id FROM next_id), (SELECT user_id FROM users WHERE lower(user_name) = lower($4)), $5, $6, true` - commandTag, err := tx.Exec(dbQuery, dbOwner, dbFolder, dbName, loggedInUser, title, text) - if err != nil { - log.Printf("Adding new discussion '%s' for '%s%s%s' failed: %v\n", title, dbOwner, dbFolder, dbName, - err) - return err + INSERT INTO discussions (db_id, disc_id, creator, title, description, open, discussion_type` + if discType == MERGE_REQUEST { + dbQuery += `, mr_source_db_id, mr_source_db_branch, mr_destination_branch, mr_commits` } - if numRows := commandTag.RowsAffected(); numRows != 1 { - log.Printf("Wrong number of rows (%v) affected when adding new discussion '%s' to database '%s%s%s'\n", - numRows, title, dbOwner, dbFolder, dbName) + dbQuery += ` + ) + SELECT (SELECT db_id FROM d), + (SELECT id FROM next_id), + (SELECT user_id FROM users WHERE lower(user_name) = lower($4)), + $5, + $6, + true, + $7` + if discType == MERGE_REQUEST { + dbQuery += `,( + SELECT db_id + FROM sqlite_databases + WHERE user_id = ( + SELECT user_id + FROM users + WHERE lower(user_name) = lower($8)) + AND folder = $9 + AND db_name = $10 + AND is_deleted = false + ), $11, $12, $13` + } + dbQuery += ` + RETURNING (SELECT id FROM next_id)` + if discType == MERGE_REQUEST { + err = tx.QueryRow(dbQuery, dbOwner, dbFolder, dbName, loggedInUser, title, text, discType, mr.SourceOwner, + mr.SourceFolder, mr.SourceDBName, mr.SourceBranch, mr.DestBranch, mr.Commits).Scan(&newID) + } else { + err = tx.QueryRow(dbQuery, dbOwner, dbFolder, dbName, loggedInUser, title, text, discType).Scan(&newID) + } + if err != nil { + log.Printf("Adding new discussion or merge request '%s' for '%s%s%s' failed: %v\n", title, dbOwner, + dbFolder, dbName, err) + return } - // Increment the discussion counter for the database + // Increment the discussion or merge request counter for the database dbQuery = ` - UPDATE sqlite_databases - SET discussions = discussions + 1 + UPDATE sqlite_databases` + if discType == DISCUSSION { + dbQuery += ` + SET discussions = discussions + 1` + } else { + dbQuery += ` + SET merge_requests = merge_requests + 1` + } + dbQuery += ` WHERE user_id = ( SELECT user_id FROM users @@ -2583,10 +2816,10 @@ func StoreDiscussion(dbOwner string, dbFolder string, dbName string, loggedInUse ) AND folder = $2 AND db_name = $3` - commandTag, err = tx.Exec(dbQuery, dbOwner, dbFolder, dbName) + commandTag, err := tx.Exec(dbQuery, dbOwner, dbFolder, dbName) if err != nil { log.Printf("Updating discussion counter for '%s%s%s' failed: %v\n", dbOwner, dbFolder, dbName, err) - return err + return } if numRows := commandTag.RowsAffected(); numRows != 1 { log.Printf("Wrong number of rows (%v) affected when updating discussion counter for '%s%s%s'\n", @@ -2596,9 +2829,9 @@ func StoreDiscussion(dbOwner string, dbFolder string, dbName string, loggedInUse // Commit the transaction err = tx.Commit() if err != nil { - return err + return } - return nil + return } // Store a licence. @@ -2986,6 +3219,38 @@ func UpdateDiscussion(dbOwner string, dbFolder string, dbName string, loggedInUs return nil } +// Updates the commit list for a Merge Request +func UpdateMergeRequestCommits(dbOwner string, dbFolder string, dbName string, discID int, mrCommits []CommitEntry) (err error) { + dbQuery := ` + WITH d AS ( + SELECT db.db_id + FROM sqlite_databases AS db + WHERE db.user_id = ( + SELECT user_id + FROM users + WHERE lower(user_name) = lower($1) + ) + AND folder = $2 + AND db_name = $3 + ) + UPDATE discussions AS disc + SET mr_commits = $5 + WHERE disc.db_id = (SELECT db_id FROM d) + AND disc.disc_id = $4` + commandTag, err := pdb.Exec(dbQuery, dbOwner, dbFolder, dbName, discID, mrCommits) + if err != nil { + log.Printf("Updating commit list for database '%s%s%s', MR '%d' failed: %v\n", dbOwner, + dbFolder, dbName, discID, err) + return err + } + if numRows := commandTag.RowsAffected(); numRows != 1 { + log.Printf( + "Wrong number of rows (%v) affected when updating commit list for database '%s%s%s', MR '%d'\n", + numRows, dbOwner, dbFolder, dbName, discID) + } + return nil +} + // Returns details for a user. func User(userName string) (user UserDetails, err error) { dbQuery := ` diff --git a/common/types.go b/common/types.go index 5687451a5..0b6ff7eaf 100644 --- a/common/types.go +++ b/common/types.go @@ -177,6 +177,7 @@ type CommitEntry struct { CommitterName string `json:"committer_name"` ID string `json:"id"` Message string `json:"message"` + OtherParents []string `json:"other_parents"` Parent string `json:"parent"` Timestamp time.Time `json:"timestamp"` Tree DBTree `json:"tree"` @@ -210,12 +211,12 @@ type DBTree struct { Entries []DBTreeEntry `json:"entries"` } type DBTreeEntry struct { - EntryType DBTreeEntryType `json:"entry_type"` - Last_Modified time.Time `json:"last_modified"` - LicenceSHA string `json:"licence"` - Name string `json:"name"` - Sha256 string `json:"sha256"` - Size int `json:"size"` + EntryType DBTreeEntryType `json:"entry_type"` + LastModified time.Time `json:"last_modified"` + LicenceSHA string `json:"licence"` + Name string `json:"name"` + Sha256 string `json:"sha256"` + Size int `json:"size"` } type DBInfo struct { @@ -250,19 +251,6 @@ type DBInfo struct { Watchers int } -type DiscussionEntry struct { - AvatarURL string `json:"avatar_url"` - Body string `json:"body"` - BodyRendered string `json:"body_rendered"` - CommentCount int `json:"comment_count"` - Creator string `json:"creator"` - Date_created time.Time `json:"creation_date"` - ID int `json:"disc_id"` - Last_modified time.Time `json:"last_modified"` - Open bool `json:"open"` - Title string `json:"title"` -} - type DiscussionCommentType string const ( @@ -276,21 +264,43 @@ type DiscussionCommentEntry struct { Body string `json:"body"` BodyRendered string `json:"body_rendered"` Commenter string `json:"commenter"` - Date_created time.Time `json:"creation_date"` + DateCreated time.Time `json:"creation_date"` EntryType DiscussionCommentType `json:"entry_type"` ID int `json:"com_id"` } +type DiscussionType int + +const ( + DISCUSSION DiscussionType = 0 + MERGE_REQUEST = 1 +) + +type DiscussionEntry struct { + AvatarURL string `json:"avatar_url"` + Body string `json:"body"` + BodyRendered string `json:"body_rendered"` + CommentCount int `json:"comment_count"` + Creator string `json:"creator"` + DateCreated time.Time `json:"creation_date"` + ID int `json:"disc_id"` + LastModified time.Time `json:"last_modified"` + MRDetails MergeRequestEntry `json:"mr_details"` + Open bool `json:"open"` + Title string `json:"title"` + Type DiscussionType `json:"discussion_type"` +} + type ForkEntry struct { - DBName string - Folder string - ForkedFrom int - IconList []ForkType - ID int - Owner string - Processed bool - Public bool - Deleted bool + DBName string `json:"database_name"` + Folder string `json:"database_folder"` + ForkedFrom int `json:"forked_from"` + IconList []ForkType `json:"icon_list"` + ID int `json:"id"` + Owner string `json:"database_owner"` + Processed bool `json:"processed"` + Public bool `json:"public"` + Deleted bool `json:"deleted"` } type LicenceEntry struct { @@ -301,6 +311,25 @@ type LicenceEntry struct { URL string `json:"url"` } +type MergeRequestState int + +const ( + OPEN MergeRequestState = 0 + CLOSED_WITH_MERGE = 1 + CLOSED_WITHOUT_MERGE = 2 +) + +type MergeRequestEntry struct { + Commits []CommitEntry `json:"commits"` + DestBranch string `json:"destination_branch"` + SourceBranch string `json:"source_branch"` + SourceDBID int64 `json:"source_database_id"` + SourceDBName string `json:"source_database_name"` + SourceFolder string `json:"source_folder"` + SourceOwner string `json:"source_owner"` + State MergeRequestState `json:"state"` +} + type MetaInfo struct { AvatarURL string Database string diff --git a/common/userinput.go b/common/userinput.go index 682002298..cd9078198 100644 --- a/common/userinput.go +++ b/common/userinput.go @@ -14,15 +14,19 @@ import ( // Extracts a database name from GET or POST/PUT data. func GetDatabase(r *http.Request, allowGet bool) (string, error) { // Retrieve the variable from the GET or POST/PUT data - var dbName string + var d, dbName string if allowGet { - dbName = r.FormValue("dbname") + d = r.FormValue("dbname") } else { - dbName = r.PostFormValue("dbname") + d = r.PostFormValue("dbname") } - // Validate the database name - err := ValidateDB(dbName) + // Unescape, then validate the database name + dbName, err := url.QueryUnescape(d) + if err != nil { + return "", err + } + err = ValidateDB(dbName) if err != nil { log.Printf("Validation failed for database name '%s': %s", dbName, err) return "", errors.New("Invalid database name") @@ -33,20 +37,24 @@ func GetDatabase(r *http.Request, allowGet bool) (string, error) { // Returns the folder name (if any) present in GET or POST/PUT data. func GetFolder(r *http.Request, allowGet bool) (string, error) { // Retrieve the variable from the GET or POST/PUT data - var folder string + var f, folder string if allowGet { - folder = r.FormValue("folder") + f = r.FormValue("folder") } else { - folder = r.PostFormValue("folder") + f = r.PostFormValue("folder") } // If no folder given, return - if folder == "" { + if f == "" { return "", nil } - // Validate the folder name - err := ValidateFolder(folder) + // Unescape, then validate the folder name + folder, err := url.QueryUnescape(f) + if err != nil { + return "", err + } + err = ValidateFolder(folder) if err != nil { log.Printf("Validation failed for folder: '%s': %s", folder, err) return "", err @@ -68,7 +76,7 @@ func GetFormBranch(r *http.Request) (string, error) { if err != nil { return "", err } - err = Validate.Var(b, "branchortagname,min=1,max=32") // 32 seems a reasonable first guess. + err = ValidateBranchName(b) if err != nil { return "", errors.New(fmt.Sprintf("Invalid branch name: '%v'", b)) } @@ -136,7 +144,7 @@ func GetFormRelease(r *http.Request) (release string, err error) { if err != nil { return "", err } - err = Validate.Var(c, "branchortagname,min=1,max=32") // 32 seems a reasonable first guess. + err = ValidateBranchName(c) if err != nil { return "", errors.New(fmt.Sprintf("Invalid release name: '%v'", c)) } @@ -156,7 +164,7 @@ func GetFormTag(r *http.Request) (tag string, err error) { if err != nil { return "", err } - err = Validate.Var(c, "branchortagname,min=1,max=32") // 32 seems a reasonable first guess. + err = ValidateBranchName(c) if err != nil { return "", errors.New(fmt.Sprintf("Invalid tag name: '%v'", c)) } @@ -343,20 +351,24 @@ func GetUFD(r *http.Request, allowGet bool) (string, string, string, error) { // Return the username (if any) present in the GET or POST/PUT data. func GetUsername(r *http.Request, allowGet bool) (string, error) { // Retrieve the variable from the GET or POST/PUT data - var userName string + var u, userName string if allowGet { - userName = r.FormValue("username") + u = r.FormValue("username") } else { - userName = r.PostFormValue("username") + u = r.PostFormValue("username") } // If no username given, return - if userName == "" { + if u == "" { return "", nil } - // Validate the username - err := ValidateUser(userName) + // Unescape, then validate the user name + userName, err := url.QueryUnescape(u) + if err != nil { + return "", err + } + err = ValidateUser(userName) if err != nil { log.Printf("Validation failed for username: %s", err) return "", err diff --git a/common/util.go b/common/util.go index fa3ec5c1d..e66fcef06 100644 --- a/common/util.go +++ b/common/util.go @@ -111,7 +111,7 @@ func AddDatabase(r *http.Request, loggedInUser string, dbOwner string, dbFolder e.EntryType = DATABASE e.Name = dbName e.Sha256 = sha - e.Last_Modified = lastModified + e.LastModified = lastModified e.Size = int(numBytes) if licenceName == "" || licenceName == "Not specified" { // No licence was specified by the client, so check if the database is already in the system and @@ -385,6 +385,9 @@ func CreateCommitID(c CommitEntry) string { if c.Parent != "" { b.WriteString(fmt.Sprintf("parent %s\n", c.Parent)) } + for _, j := range c.OtherParents { + b.WriteString(fmt.Sprintf("parent %s\n", j)) + } b.WriteString(fmt.Sprintf("author %s <%s> %v\n", c.AuthorName, c.AuthorEmail, c.Timestamp.Format(time.UnixDate))) if c.CommitterEmail != "" { @@ -411,7 +414,7 @@ func CreateDBTreeID(entries []DBTreeEntry) string { b.WriteByte(0) b.WriteString(j.Name) b.WriteByte(0) - b.WriteString(j.Last_Modified.Format(time.RFC3339)) + b.WriteString(j.LastModified.Format(time.RFC3339)) b.WriteByte(0) b.WriteString(fmt.Sprintf("%d\n", j.Size)) } @@ -704,6 +707,80 @@ func DeleteBranchHistory(dbOwner string, dbFolder string, dbName string, branchN return } +// Determines the common ancestor commit (if any) between a source and destination branch. Returns the commit ID of +// the ancestor and a slice of the commits between them. If no common ancestor exists, the returned ancestorID will be +// an empty string. Created for use by our Merge Request functions. +func GetCommonAncestorCommits(srcOwner string, srcFolder string, srcDBName string, srcBranch string, destOwner string, + destFolder string, destName string, destBranch string) (ancestorID string, commitList []CommitEntry, err error, errType int) { + + // To determine the common ancestor, we retrieve the source and destination commit lists, then starting from the + // end of the source list, step backwards looking for a matching ID in the destination list. + // * If none is found then there's nothing in common (so abort). + // * If one is found, that one is the last common commit + // * For now, we only support merging to the head commit of the destination branch, so we only check for + // that. Adding support for merging to non-head destination commits isn't yet supported. + + // Get the details of the head commit for the source and destination database branches + branchList, err := GetBranches(destOwner, destFolder, destName) // Destination branch list + if err != nil { + errType = http.StatusInternalServerError + return + } + branchDetails, ok := branchList[destBranch] + if !ok { + errType = http.StatusInternalServerError + err = fmt.Errorf("Could not retrieve details for the destination branch") + return + } + destCommitID := branchDetails.Commit + srcBranchList, err := GetBranches(srcOwner, srcFolder, srcDBName) + if err != nil { + errType = http.StatusInternalServerError + return + } + srcBranchDetails, ok := srcBranchList[srcBranch] + if !ok { + errType = http.StatusInternalServerError + err = fmt.Errorf("Could not retrieve details for the source branch") + return + } + srcCommitID := srcBranchDetails.Commit + srcCommitList, err := GetCommitList(srcOwner, srcFolder, srcDBName) + if err != nil { + errType = http.StatusInternalServerError + return + } + + // If the source and destination commit IDs are the same, then abort + if srcCommitID == destCommitID { + errType = http.StatusBadRequest + err = fmt.Errorf("Source and destination commits are identical, no merge needs doing") + return + } + + // Look for the common ancestor + s, ok := srcCommitList[srcCommitID] + if !ok { + errType = http.StatusInternalServerError + err = fmt.Errorf("Could not retrieve details for the source branch commit") + return + } + for s.Parent != "" { + commitList = append(commitList, s) // Add this commit to the list + s, ok = srcCommitList[s.Parent] + if !ok { + errType = http.StatusInternalServerError + err = fmt.Errorf("Error when walking the source branch commit list") + return + } + if s.ID == destCommitID { + ancestorID = s.ID + break + } + } + return +} + // Returns the name of the function this was called from func GetCurrentFunctionName() (FuncName string) { stk := make([]uintptr, 1) diff --git a/common/validate.go b/common/validate.go index 00da763f2..743476732 100644 --- a/common/validate.go +++ b/common/validate.go @@ -104,8 +104,8 @@ func checkUsername(fl valid.FieldLevel) bool { // Checks a username against the list of reserved ones. func ReservedUsernamesCheck(userName string) error { - reserved := []string{"about", "admin", "blog", "dbhub", "download", "downloadcsv", "forks", "legal", "login", - "logout", "mail", "news", "pref", "printer", "public", "reference", "register", "root", "star", + reserved := []string{"about", "admin", "blog", "dbhub", "compare", "download", "downloadcsv", "forks", "legal", + "login", "logout", "mail", "news", "pref", "printer", "public", "reference", "register", "root", "star", "stars", "system", "table", "upload", "uploaddata", "vis"} for _, word := range reserved { if strings.ToLower(userName) == strings.ToLower(word) { @@ -116,7 +116,17 @@ func ReservedUsernamesCheck(userName string) error { return nil } -// Validate the SQLite field name +// Validate the provided branch, release, or tag name. +func ValidateBranchName(fieldName string) error { + err := Validate.Var(fieldName, "branchortagname,min=1,max=32") // 32 seems a reasonable first guess + if err != nil { + return err + } + + return nil +} + +// Validate the SQLite field name. func ValidateFieldName(fieldName string) error { err := Validate.Var(fieldName, "required,fieldname,min=1,max=63") // 63 char limit seems reasonable if err != nil { @@ -166,6 +176,16 @@ func ValidateLicence(licence string) error { return nil } +// Validate the provided markdown . +func ValidateMarkdown(fieldName string) error { + err := Validate.Var(fieldName, "markdownsource,max=1024") // 1024 seems like a reasonable first guess + if err != nil { + return err + } + + return nil +} + // Validate the provided PostgreSQL table name. func ValidatePGTable(table string) error { // TODO: Improve this to work with all valid SQLite identifiers @@ -181,6 +201,16 @@ func ValidatePGTable(table string) error { return nil } +// Validate the provided discussion or merge request title. +func ValidateDiscussionTitle(fieldName string) error { + err := Validate.Var(fieldName, "discussiontitle,max=120") // 120 seems a reasonable first guess. + if err != nil { + return err + } + + return nil +} + // Validate the provided username. func ValidateUser(user string) error { err := Validate.Var(user, "required,username,min=2,max=63") diff --git a/database/dbhub.sql b/database/dbhub.sql index 589863f71..e51820587 100644 --- a/database/dbhub.sql +++ b/database/dbhub.sql @@ -211,10 +211,23 @@ CREATE TABLE discussions ( open boolean DEFAULT true NOT NULL, disc_id integer DEFAULT 1 NOT NULL, last_modified timestamp with time zone DEFAULT now() NOT NULL, - comment_count integer DEFAULT 0 NOT NULL + comment_count integer DEFAULT 0 NOT NULL, + discussion_type integer DEFAULT 0 NOT NULL, + mr_source_db_id bigint, + mr_source_db_branch text, + mr_destination_branch text, + mr_state integer DEFAULT 0 NOT NULL, + mr_commits jsonb ); +-- +-- Name: COLUMN discussions.mr_source_db_id; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN discussions.mr_source_db_id IS 'Only used by Merge Requests, not standard discussions'; + + -- -- Name: discussions_disc_id_seq; Type: SEQUENCE; Schema: public; Owner: - -- @@ -504,6 +517,13 @@ CREATE INDEX database_licences_lic_sha256_idx ON database_licences USING btree ( CREATE INDEX database_licences_user_id_friendly_name_idx ON database_licences USING btree (user_id, friendly_name); +-- +-- Name: discussions_discussion_type_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX discussions_discussion_type_idx ON discussions USING btree (discussion_type); + + -- -- Name: fki_database_downloads_db_id_fkey; Type: INDEX; Schema: public; Owner: - -- @@ -539,6 +559,20 @@ CREATE INDEX fki_database_uploads_user_id_fkey ON database_uploads USING btree ( CREATE INDEX fki_discussion_comments_db_id_fkey ON discussion_comments USING btree (db_id); +-- +-- Name: fki_discussions_source_db_id_fkey; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX fki_discussions_source_db_id_fkey ON discussions USING btree (mr_source_db_id); + + +-- +-- Name: users_lower_user_name_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX users_lower_user_name_idx ON users USING btree (lower(user_name)); + + -- -- Name: users_user_id_idx; Type: INDEX; Schema: public; Owner: - -- @@ -641,6 +675,14 @@ ALTER TABLE ONLY discussions ADD CONSTRAINT discussions_db_id_fkey FOREIGN KEY (db_id) REFERENCES sqlite_databases(db_id) ON UPDATE CASCADE ON DELETE CASCADE; +-- +-- Name: discussions discussions_mr_source_db_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY discussions + ADD CONSTRAINT discussions_mr_source_db_id_fkey FOREIGN KEY (mr_source_db_id) REFERENCES sqlite_databases(db_id) ON UPDATE SET NULL ON DELETE SET NULL; + + -- -- Name: discussions discussions_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- diff --git a/webui/main.go b/webui/main.go index 4e3855f4e..d13307d73 100644 --- a/webui/main.go +++ b/webui/main.go @@ -219,6 +219,89 @@ func auth0CallbackHandler(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/"+userName, http.StatusSeeOther) } +// Returns a list of the branches present in a database +func branchNamesHandler(w http.ResponseWriter, r *http.Request) { + // Retrieve session data (if any) + var loggedInUser string + validSession := false + sess, err := store.Get(r, "dbhub-user") + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + u := sess.Values["UserName"] + if u != nil { + loggedInUser = u.(string) + validSession = true + } + + // Ensure we have a valid logged in user + if validSession != true { + w.WriteHeader(http.StatusUnauthorized) + return + } + + // Extract the required form variables + usr, dbFolder, dbName, err := com.GetUFD(r, true) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + dbOwner := strings.ToLower(usr) + + // If any of the required values were empty, indicate failure + if dbOwner == "" || dbFolder == "" || dbName == "" { + w.WriteHeader(http.StatusBadRequest) + return + } + + // Make sure the database exists in the system + exists, err := com.CheckDBExists(loggedInUser, dbOwner, dbFolder, dbName) + if err != err { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, err.Error()) + return + } + if !exists { + log.Printf("%s: Validation failed for database name: %s", com.GetCurrentFunctionName(), err) + w.WriteHeader(http.StatusNotFound) + return + } + + // Retrieve the branch info for the database + branchList, err := com.GetBranches(dbOwner, dbFolder, dbName) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, err.Error()) + return + } + defBranch, err := com.GetDefaultBranchName(dbOwner, dbFolder, dbName) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, err.Error()) + return + } + + // Prepare the branch list for sending + var b struct { + Branches []string `json:"branches"` + DefaultBranch string `json:"default_branch"` + } + for name := range branchList { + b.Branches = append(b.Branches, name) + } + b.DefaultBranch = defBranch + data, err := json.MarshalIndent(b, "", " ") + if err != nil { + log.Println(err) + return + } + + // Return the branch list + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, string(data)) +} + func createBranchHandler(w http.ResponseWriter, r *http.Request) { // Retrieve session data (if any) var loggedInUser string @@ -434,7 +517,8 @@ func createCommentHandler(w http.ResponseWriter, r *http.Request) { } // Add the comment to PostgreSQL - err = com.StoreComment(dbOwner, dbFolder, dbName, loggedInUser, discID, comText, discClose) + err = com.StoreComment(dbOwner, dbFolder, dbName, loggedInUser, discID, comText, discClose, + com.CLOSED_WITHOUT_MERGE) // com.CLOSED_WITHOUT_MERGE is ignored for discussions. It's only used for MRs if err != nil { w.WriteHeader(http.StatusInternalServerError) fmt.Fprint(w, err.Error()) @@ -488,7 +572,7 @@ func createDiscussHandler(w http.ResponseWriter, r *http.Request) { // Validate the discussions' title tl := r.PostFormValue("title") - err = com.Validate.Var(tl, "discussiontitle,max=120") // 120 seems a reasonable first guess. + err = com.ValidateDiscussionTitle(tl) if err != nil { errorPage(w, r, http.StatusBadRequest, "Invalid characters in the new discussions' title") return @@ -521,7 +605,8 @@ func createDiscussHandler(w http.ResponseWriter, r *http.Request) { } // Add the discussion detail to PostgreSQL - err = com.StoreDiscussion(dbOwner, dbFolder, dbName, loggedInUser, discTitle, discText) + id, err := com.StoreDiscussion(dbOwner, dbFolder, dbName, loggedInUser, discTitle, discText, com.DISCUSSION, + com.MergeRequestEntry{}) if err != nil { errorPage(w, r, http.StatusInternalServerError, err.Error()) return @@ -536,7 +621,295 @@ func createDiscussHandler(w http.ResponseWriter, r *http.Request) { } // Bounce to the discussions page - http.Redirect(w, r, fmt.Sprintf("/discuss/%s%s%s", dbOwner, dbFolder, dbName), http.StatusSeeOther) + http.Redirect(w, r, fmt.Sprintf("/discuss/%s%s%s?id=%d", dbOwner, dbFolder, dbName, id), http.StatusSeeOther) +} + +// Receives incoming requests from the merge request creation page, creating them if the info is correct +func createMergeHandler(w http.ResponseWriter, r *http.Request) { + // Retrieve session data (if any) + var loggedInUser string + validSession := false + sess, err := store.Get(r, "dbhub-user") + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + u := sess.Values["UserName"] + if u != nil { + loggedInUser = u.(string) + validSession = true + } + + // Ensure we have a valid logged in user + if validSession != true { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, "You need to be logged in") + return + } + + // Extract and validate the form variables + userName, err := com.GetUsername(r, false) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, err.Error()) + return + } + if userName == "" { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, "Missing username in supplied fields") + return + } + + // Retrieve source owner + o := r.PostFormValue("sourceowner") + srcOwner, err := url.QueryUnescape(o) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, err.Error()) + return + } + err = com.ValidateUser(srcOwner) + if err != nil { + log.Printf("Validation failed for username: '%s'- %s", srcOwner, err) + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, err.Error()) + return + } + + // Retrieve source folder + f := r.PostFormValue("sourcefolder") + srcFolder, err := url.QueryUnescape(f) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, err.Error()) + return + } + err = com.ValidateFolder(srcFolder) + if err != nil { + log.Printf("Validation failed for folder: '%s' - %s", srcFolder, err) + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, err.Error()) + return + } + + // Retrieve source database name + d := r.PostFormValue("sourcedbname") + srcDBName, err := url.QueryUnescape(d) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, err.Error()) + return + } + err = com.ValidateDB(srcDBName) + if err != nil { + log.Printf("Validation failed for database name '%s': %s", srcDBName, err) + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, err.Error()) + return + } + + // Retrieve source branch name + a := r.PostFormValue("sourcebranch") + srcBranch, err := url.QueryUnescape(a) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, err.Error()) + return + } + err = com.ValidateBranchName(srcBranch) + if err != nil { + log.Printf("Validation failed for branch name '%s': %s", srcBranch, err) + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, err.Error()) + return + } + + // Retrieve destination owner + o = r.PostFormValue("destowner") + destOwner, err := url.QueryUnescape(o) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, err.Error()) + return + } + err = com.ValidateUser(destOwner) + if err != nil { + log.Printf("Validation failed for username: '%s'- %s", destOwner, err) + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, err.Error()) + return + } + + // Retrieve destination folder + f = r.PostFormValue("destfolder") + destFolder, err := url.QueryUnescape(f) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, err.Error()) + return + } + err = com.ValidateFolder(destFolder) + if err != nil { + log.Printf("Validation failed for folder: '%s' - %s", destFolder, err) + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, err.Error()) + return + } + + // Retrieve destination database name + d = r.PostFormValue("destdbname") + destDBName, err := url.QueryUnescape(d) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, err.Error()) + return + } + err = com.ValidateDB(destDBName) + if err != nil { + log.Printf("Validation failed for database name '%s': %s", destDBName, err) + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, err.Error()) + return + } + + // Retrieve destination branch name + a = r.PostFormValue("destbranch") + destBranch, err := url.QueryUnescape(a) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, err.Error()) + return + } + err = com.ValidateBranchName(destBranch) + if err != nil { + log.Printf("Validation failed for branch name '%s': %s", destBranch, err) + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, err.Error()) + return + } + + // Validate the MR title + tl := r.PostFormValue("title") + if tl == "" { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, "Title can't be blank") + return + } + title, err := url.QueryUnescape(tl) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, err.Error()) + return + } + err = com.ValidateDiscussionTitle(title) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, "Invalid characters in the merge request title") + return + } + + // Validate the MR description + t := r.PostFormValue("desc") + if t == "" { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, "Merge request description can't be empty") + return + } + descrip, err := url.QueryUnescape(t) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, err.Error()) + return + } + err = com.Validate.Var(title, "markdownsource") + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, "Invalid characters in the description field") + return + } + + // Make sure none of the required fields is empty + if srcOwner == "" || srcFolder == "" || srcDBName == "" || srcBranch == "" || destOwner == "" || destFolder == + "" || destDBName == "" || destBranch == "" || title == "" || descrip == "" { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, "Some of the (required) supplied fields are empty") + return + } + + // Check the databases exist + srcExists, err := com.CheckDBExists(loggedInUser, srcOwner, srcFolder, srcDBName) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, err.Error()) + return + } + destExists, err := com.CheckDBExists(loggedInUser, destOwner, destFolder, destDBName) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, err.Error()) + return + } + if !srcExists || !destExists { + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, "Invalid database. One of the source or destination databases doesn't exist") + return + } + + // Get the details of the commits for the MR + mrDetails := com.MergeRequestEntry{ + DestBranch: destBranch, + SourceBranch: srcBranch, + SourceDBName: srcDBName, + SourceFolder: srcFolder, + SourceOwner: srcOwner, + } + var ancestorID string + ancestorID, mrDetails.Commits, err, _ = com.GetCommonAncestorCommits(srcOwner, srcFolder, srcDBName, srcBranch, + destOwner, destFolder, destDBName, destBranch) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, err.Error()) + return + } + + // Make sure the source branch will cleanly apply to the destination. eg the destination branch hasn't received + // additional commits since the source was forked + if ancestorID == "" { + w.WriteHeader(http.StatusConflict) + fmt.Fprint(w, "Source branch is not a direct descendent of the destination branch. Cannot merge.") + return + } + + // Create the merge request in PostgreSQL + var x struct { + ID int `json:"mr_id"` + } + x.ID, err = com.StoreDiscussion(destOwner, destFolder, destDBName, loggedInUser, title, descrip, com.MERGE_REQUEST, + mrDetails) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, err.Error()) + return + } + + // Invalidate the memcache data for the destination database, so the new MR count gets picked up + err = com.InvalidateCacheEntry(loggedInUser, destOwner, destFolder, destDBName, "") // Empty string indicates "for all versions" + if err != nil { + // Something went wrong when invalidating memcached entries for the database + log.Printf("Error when invalidating memcache entries: %s\n", err.Error()) + return + } + + // Indicate success to the caller, and return the ID # of the new merge request + y, err := json.MarshalIndent(x, "", " ") + if err != nil { + log.Println(err) + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, err.Error()) + return + } + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, string(y)) } func createTagHandler(w http.ResponseWriter, r *http.Request) { @@ -2431,6 +2804,7 @@ func main() { http.HandleFunc("/about", logReq(aboutPage)) http.HandleFunc("/branches/", logReq(branchesPage)) http.HandleFunc("/commits/", logReq(commitsPage)) + http.HandleFunc("/compare/", logReq(comparePage)) http.HandleFunc("/confirmdelete/", logReq(confirmDeletePage)) http.HandleFunc("/contributors/", logReq(contributorsPage)) http.HandleFunc("/createbranch/", logReq(createBranchPage)) @@ -2439,6 +2813,7 @@ func main() { http.HandleFunc("/discuss/", logReq(discussPage)) http.HandleFunc("/forks/", logReq(forksPage)) http.HandleFunc("/logout", logReq(logoutHandler)) + http.HandleFunc("/merge/", logReq(mergePage)) http.HandleFunc("/pref", logReq(prefHandler)) http.HandleFunc("/register", logReq(createUserHandler)) http.HandleFunc("/releases/", logReq(releasesPage)) @@ -2447,11 +2822,13 @@ func main() { http.HandleFunc("/stars/", logReq(starsPage)) http.HandleFunc("/tags/", logReq(tagsPage)) http.HandleFunc("/upload/", logReq(uploadPage)) + http.HandleFunc("/x/branchnames", logReq(branchNamesHandler)) http.HandleFunc("/x/callback", logReq(auth0CallbackHandler)) http.HandleFunc("/x/checkname", logReq(checkNameHandler)) http.HandleFunc("/x/createbranch", logReq(createBranchHandler)) http.HandleFunc("/x/createcomment/", logReq(createCommentHandler)) http.HandleFunc("/x/creatediscuss", logReq(createDiscussHandler)) + http.HandleFunc("/x/createmerge/", logReq(createMergeHandler)) http.HandleFunc("/x/createtag", logReq(createTagHandler)) http.HandleFunc("/x/deletebranch/", logReq(deleteBranchHandler)) http.HandleFunc("/x/deletecomment/", logReq(deleteCommentHandler)) @@ -2465,6 +2842,7 @@ func main() { http.HandleFunc("/x/forkdb/", logReq(forkDBHandler)) http.HandleFunc("/x/gencert", logReq(generateCertHandler)) http.HandleFunc("/x/markdownpreview/", logReq(markdownPreview)) + http.HandleFunc("/x/mergerequest/", logReq(mergeRequestHandler)) http.HandleFunc("/x/savesettings", logReq(saveSettingsHandler)) http.HandleFunc("/x/setdefaultbranch/", logReq(setDefaultBranchHandler)) http.HandleFunc("/x/star/", logReq(starToggleHandler)) @@ -2637,6 +3015,192 @@ func markdownPreview(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, string(renderedText)) } +// Handler which does merging to MR's. Called from the MR details page +func mergeRequestHandler(w http.ResponseWriter, r *http.Request) { + // Retrieve session data (if any) + var loggedInUser string + validSession := false + sess, err := store.Get(r, "dbhub-user") + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + u := sess.Values["UserName"] + if u != nil { + loggedInUser = u.(string) + validSession = true + } + + // Ensure we have a valid logged in user + if validSession != true { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, "You need to be logged in") + return + } + + // Extract and validate the form variables + dbOwner, dbFolder, dbName, err := com.GetUFD(r, false) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, "Missing or incorrect data supplied") + return + } + + // Ensure an MR id was given + a := r.PostFormValue("mrid") + if a == "" { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, "Missing merge request id") + return + } + mrID, err := strconv.Atoi(a) + if err != nil { + log.Printf("Error converting string '%s' to integer in function '%s': %s\n", a, + com.GetCurrentFunctionName(), err) + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, "Error when parsing merge request id value") + return + } + + // Check if the requested database exists + exists, err := com.CheckDBExists(loggedInUser, dbOwner, dbFolder, dbName) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, err.Error()) + return + } + if !exists { + w.WriteHeader(http.StatusNotFound) + fmt.Fprintf(w, "Database '%s%s%s' doesn't exist", dbOwner, dbFolder, dbName) + return + } + + // Ensure the request is coming from the database owner + if strings.ToLower(dbOwner) != strings.ToLower(loggedInUser) { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, "Only the database owner can merge in merge requests") + return + } + + // Retrieve the names of the source & destination databases and branches + disc, err := com.Discussions(dbOwner, dbFolder, dbName, com.MERGE_REQUEST, mrID) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, err.Error()) + return + } + branchName := disc[0].MRDetails.DestBranch + commitDiffList := disc[0].MRDetails.Commits + srcOwner := disc[0].MRDetails.SourceOwner + srcFolder := disc[0].MRDetails.SourceFolder + srcDBName := disc[0].MRDetails.SourceDBName + srcBranchName := disc[0].MRDetails.SourceBranch + + // Ensure the merge request isn't closed + if !disc[0].Open { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, "Cannot merge a closed merge request") + return + } + + // Get the details of the head commit for the destination database branch + branchList, err := com.GetBranches(dbOwner, dbFolder, dbName) // Destination branch list + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, err.Error()) + return + } + branchDetails, ok := branchList[branchName] + if !ok { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, "Could not retrieve details for the destination branch") + return + } + destCommitID := branchDetails.Commit + destCommitList, err := com.GetCommitList(dbOwner, dbFolder, dbName) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, err.Error()) + return + } + + // Check if the MR commits will still apply cleanly to the destination branch + finalCommit := commitDiffList[len(commitDiffList)-1] + if finalCommit.Parent != destCommitID { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, fmt.Errorf("Destination branch has changed. Merge cannot proceed.")) + return + } + + // * The required details have been collected, and sanity checks completed, so merge the MR * + + // Add the source commits directly to the destination commit list + for _, j := range commitDiffList { + destCommitList[j.ID] = j + } + + // Retrieve details for the logged in user + usr, err := com.User(loggedInUser) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, err.Error()) + return + } + + // Create a merge commit, using the details of the source commit (this gets us a correctly filled in DB tree + // structure easily) + mrg := commitDiffList[0] + mrg.AuthorEmail = usr.Email + mrg.AuthorName = usr.DisplayName + mrg.Message = fmt.Sprintf("Merge branch '%s' of '%s%s%s' into '%s'", srcBranchName, srcOwner, srcFolder, + srcDBName, branchName) + mrg.Parent = commitDiffList[0].ID + mrg.OtherParents = append(mrg.OtherParents, destCommitID) + mrg.Timestamp = time.Now() + mrg.ID = com.CreateCommitID(mrg) + + // Add the new commit to the destination db commit list, and update the branch list with it + destCommitList[mrg.ID] = mrg + b := com.BranchEntry{ + Commit: mrg.ID, + CommitCount: branchDetails.CommitCount + len(commitDiffList) + 1, + Description: branchDetails.Description, + } + branchList[branchName] = b + err = com.StoreCommits(dbOwner, dbFolder, dbName, destCommitList) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, err.Error()) + return + } + err = com.StoreBranches(dbOwner, dbFolder, dbName, branchList) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, err.Error()) + return + } + + // Change the status of the MR to closed, and indicate it was successfully merged + err = com.StoreComment(dbOwner, dbFolder, dbName, loggedInUser, mrID, "", true, + com.CLOSED_WITH_MERGE) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, err.Error()) + return + } + + // Invalidate the memcached entries for the destination database case + err = com.InvalidateCacheEntry(loggedInUser, dbOwner, dbFolder, dbName, "") // Empty string indicates "for all versions" + if err != nil { + // Something went wrong when invalidating memcached entries for the database + log.Printf("Error when invalidating memcache entries: %s\n", err.Error()) + return + } + + // Send a success message back to the caller + w.WriteHeader(http.StatusOK) +} + // This handles incoming requests for the preferences page by logged in users. func prefHandler(w http.ResponseWriter, r *http.Request) { pageName := "Preferences handler" @@ -2982,7 +3546,7 @@ func saveSettingsHandler(w http.ResponseWriter, r *http.Request) { // Create a new dbTree entry for the database file var e com.DBTreeEntry e.EntryType = com.DATABASE - e.Last_Modified = dbEntry.Last_Modified + e.LastModified = dbEntry.LastModified e.LicenceSHA = newLicSHA e.Name = dbEntry.Name e.Sha256 = dbEntry.Sha256 @@ -3671,7 +4235,7 @@ func updateBranchHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) return } - err = com.Validate.Var(nb, "branchortagname,min=1,max=32") // 32 seems a reasonable first guess. + err = com.ValidateBranchName(nb) if err != nil { w.WriteHeader(http.StatusBadRequest) return @@ -4051,7 +4615,7 @@ func updateReleaseHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) return } - err = com.Validate.Var(nr, "branchortagname,min=1,max=32") // 32 seems a reasonable first guess. + err = com.ValidateBranchName(nr) if err != nil { w.WriteHeader(http.StatusBadRequest) return @@ -4180,7 +4744,7 @@ func updateTagHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) return } - err = com.Validate.Var(nt, "branchortagname,min=1,max=32") // 32 seems a reasonable first guess. + err = com.ValidateBranchName(nt) if err != nil { w.WriteHeader(http.StatusBadRequest) return @@ -4343,7 +4907,7 @@ func uploadDataHandler(w http.ResponseWriter, r *http.Request) { var commitMsg string cm := r.PostFormValue("commitmsg") if cm != "" { - err = com.Validate.Var(cm, "markdownsource,max=1024") // 1024 seems like a reasonable first guess + err = com.ValidateMarkdown(cm) if err != nil { errorPage(w, r, http.StatusBadRequest, "Validation failed for the commit message") return diff --git a/webui/pages.go b/webui/pages.go index 42d52b9b7..72b721fd2 100644 --- a/webui/pages.go +++ b/webui/pages.go @@ -230,7 +230,7 @@ func commitsPage(w http.ResponseWriter, r *http.Request) { // Retrieve the database owner & name, and branch name // TODO: Add folder support dbFolder := "/" - dbOwner, dbName, err := com.GetOD(1, r) // 1 = Ignore "/settings/" at the start of the URL + dbOwner, dbName, err := com.GetOD(1, r) // 1 = Ignore "/commits/" at the start of the URL if err != nil { errorPage(w, r, http.StatusBadRequest, err.Error()) return @@ -402,6 +402,146 @@ func commitsPage(w http.ResponseWriter, r *http.Request) { } } +// Render the compare page, for creating new merge requests +func comparePage(w http.ResponseWriter, r *http.Request) { + // Structure to hold page data + var pageData struct { + Auth0 com.Auth0Set + DB com.SQLiteDBinfo + DestDBBranches []string + DestDBDefaultBranch string + DestDBName string + DestFolder string + DestOwner string + Forks []com.ForkEntry + Meta com.MetaInfo + SourceDBBranches []string + SourceDBDefaultBranch string + SourceDBName string + SourceFolder string + SourceOwner string + } + pageData.Meta.Title = "Create new Merge Request" + + // Retrieve session data (if any) + var loggedInUser string + sess, err := store.Get(r, "dbhub-user") + if err != nil { + errorPage(w, r, http.StatusBadRequest, err.Error()) + return + } + u := sess.Values["UserName"] + if u != nil { + loggedInUser = u.(string) + pageData.Meta.LoggedInUser = loggedInUser + } + + // Retrieve the database owner & name, and branch name + // TODO: Add folder support + dbFolder := "/" + dbOwner, dbName, err := com.GetOD(1, r) // 1 = Ignore "/compare/" at the start of the URL + if err != nil { + errorPage(w, r, http.StatusBadRequest, err.Error()) + return + } + + // Retrieve list of forks for the database + pageData.Forks, err = com.ForkTree(loggedInUser, dbOwner, dbFolder, dbName) + if err != nil { + errorPage(w, r, http.StatusInternalServerError, + fmt.Sprintf("Error retrieving fork list for '%s%s%s': %v\n", dbOwner, dbFolder, + dbName, err.Error())) + return + } + + // Use the database which the "New Merge Request" button was pressed on as the initially selected source + pageData.SourceOwner = dbOwner + pageData.SourceFolder = dbFolder + pageData.SourceDBName = dbName + + // If the source database has an (accessible) parent, use that as the default destination selected for the user. + // If it doesn't, then set the source as the destination as well and the user will have to manually choose + pageData.DestOwner, pageData.DestFolder, pageData.DestDBName, err = com.ForkParent(loggedInUser, dbOwner, dbFolder, + dbName) + if err != nil { + errorPage(w, r, http.StatusBadRequest, err.Error()) + return + } + if pageData.DestOwner == "" || pageData.DestFolder == "" || pageData.DestDBName == "" { + pageData.DestOwner = dbOwner + pageData.DestFolder = dbFolder + pageData.DestDBName = dbName + } + + // * Determine the source and destination database branches * + + // Retrieve the branch info for the source database + branchList, err := com.GetBranches(loggedInUser, dbFolder, dbName) + if err != nil { + errorPage(w, r, http.StatusBadRequest, err.Error()) + return + } + for name := range branchList { + pageData.SourceDBBranches = append(pageData.SourceDBBranches, name) + } + pageData.SourceDBDefaultBranch, err = com.GetDefaultBranchName(dbOwner, dbFolder, dbName) + if err != nil { + errorPage(w, r, http.StatusBadRequest, err.Error()) + return + } + + // Retrieve the branch info for the destination database + branchList, err = com.GetBranches(pageData.DestOwner, pageData.DestFolder, pageData.DestDBName) + if err != nil { + errorPage(w, r, http.StatusBadRequest, err.Error()) + return + } + for name := range branchList { + pageData.DestDBBranches = append(pageData.DestDBBranches, name) + } + pageData.DestDBDefaultBranch, err = com.GetDefaultBranchName(pageData.DestOwner, pageData.DestFolder, + pageData.DestDBName) + if err != nil { + errorPage(w, r, http.StatusBadRequest, err.Error()) + return + } + + // Retrieve correctly capitalised username for the database owner + usr, err := com.User(dbOwner) + if err != nil { + errorPage(w, r, http.StatusInternalServerError, err.Error()) + return + } + pageData.Meta.Owner = usr.Username + + // Retrieve the details for the logged in user + if loggedInUser != "" { + ur, err := com.User(loggedInUser) + if err != nil { + errorPage(w, r, http.StatusInternalServerError, err.Error()) + return + } + if ur.AvatarURL != "" { + pageData.Meta.AvatarURL = ur.AvatarURL + "&s=48" + } + } + + // Fill out the metadata + pageData.Meta.Database = dbName + + // Add Auth0 info to the page data + pageData.Auth0.CallbackURL = "https://" + com.Conf.Web.ServerName + "/x/callback" + pageData.Auth0.ClientID = com.Conf.Auth0.ClientID + pageData.Auth0.Domain = com.Conf.Auth0.Domain + + // Render the page + t := tmpl.Lookup("comparePage") + err = t.Execute(w, pageData) + if err != nil { + log.Printf("Error: %s", err) + } +} + // Displays a web page asking the user to confirm deleting their database. func confirmDeletePage(w http.ResponseWriter, r *http.Request) { var pageData struct { @@ -972,7 +1112,7 @@ func databasePage(w http.ResponseWriter, r *http.Request, dbOwner string, dbFold // Check if a specific release was requested releaseName := r.FormValue("release") if releaseName != "" { - err = com.Validate.Var(releaseName, "branchortagname,min=1,max=32") // 32 seems a reasonable first guess. + err = com.ValidateBranchName(releaseName) if err != nil { errorPage(w, r, http.StatusBadRequest, "Validation failed for release name") return @@ -1228,7 +1368,7 @@ func databasePage(w http.ResponseWriter, r *http.Request, dbOwner string, dbFold } for i := range branchHeads { if i != branchName { - err = com.Validate.Var(i, "branchortagname,min=1,max=32") // 32 seems a reasonable first guess. + err = com.ValidateBranchName(i) if err == nil { pageData.DB.Info.BranchList = append(pageData.DB.Info.BranchList, i) } @@ -1401,7 +1541,7 @@ func databasePage(w http.ResponseWriter, r *http.Request, dbOwner string, dbFold } for i := range branchHeads { if i != branchName { - err = com.Validate.Var(i, "branchortagname,min=1,max=32") // 32 seems a reasonable first guess. + err = com.ValidateBranchName(i) if err == nil { pageData.DB.Info.BranchList = append(pageData.DB.Info.BranchList, i) } @@ -1573,7 +1713,7 @@ func discussPage(w http.ResponseWriter, r *http.Request) { } // Retrieve the list of discussions for this database - pageData.DiscussionList, err = com.Discussions(dbOwner, dbFolder, dbName, pageData.SelectedID) + pageData.DiscussionList, err = com.Discussions(dbOwner, dbFolder, dbName, com.DISCUSSION, pageData.SelectedID) if err != nil { errorPage(w, r, http.StatusInternalServerError, err.Error()) return @@ -1718,8 +1858,8 @@ func errorPage(w http.ResponseWriter, r *http.Request, httpCode int, msg string) func forksPage(w http.ResponseWriter, r *http.Request) { var pageData struct { Auth0 com.Auth0Set - Meta com.MetaInfo Forks []com.ForkEntry + Meta com.MetaInfo } pageData.Meta.Title = "Forks" @@ -1857,6 +1997,347 @@ func frontPage(w http.ResponseWriter, r *http.Request) { } } +func mergePage(w http.ResponseWriter, r *http.Request) { + type CommitData struct { + AuthorAvatar string `json:"author_avatar"` + AuthorEmail string `json:"author_email"` + AuthorName string `json:"author_name"` + AuthorUsername string `json:"author_username"` + ID string `json:"id"` + LicenceChange string `json:"licence_change"` + Message string `json:"message"` + Timestamp time.Time `json:"timestamp"` + } + var pageData struct { + Auth0 com.Auth0Set + CommentList []com.DiscussionCommentEntry + CommitList []CommitData + DB com.SQLiteDBinfo + DestBranchNameOK bool + DestBranchUsable bool + LicenceWarning string + MRList []com.DiscussionEntry + Meta com.MetaInfo + SelectedID int + StatusMessage string + StatusMessageColour string + SourceBranchOK bool + SourceDBOK bool + MyStar bool + } + + // Retrieve session data (if any) + var loggedInUser string + sess, err := store.Get(r, "dbhub-user") + if err != nil { + errorPage(w, r, http.StatusBadRequest, err.Error()) + return + } + u := sess.Values["UserName"] + if u != nil { + loggedInUser = u.(string) + pageData.Meta.LoggedInUser = loggedInUser + } + + // Retrieve the database owner & name + // TODO: Add folder support + dbFolder := "/" + dbOwner, dbName, err := com.GetOD(1, r) // 1 = Ignore "/discuss/" at the start of the URL + if err != nil { + errorPage(w, r, http.StatusBadRequest, err.Error()) + return + } + + // Check if an MR id was provided + a := r.FormValue("id") // Optional + if a != "" && a != "{{ row.disc_id }}" { // Search engines have a habit of passing AngularJS tags, so we ignore when the field has the AngularJS tag in it + pageData.SelectedID, err = strconv.Atoi(a) + if err != nil { + log.Printf("Error converting string '%s' to integer in function '%s': %s\n", a, + com.GetCurrentFunctionName(), err) + errorPage(w, r, http.StatusBadRequest, "Error when parsing discussion id value") + return + } + } + + // Validate the supplied information + if dbOwner == "" || dbName == "" { + errorPage(w, r, http.StatusBadRequest, "Missing database owner or database name") + return + } + + // Check if the requested database exists + exists, err := com.CheckDBExists(loggedInUser, dbOwner, dbFolder, dbName) + if err != nil { + errorPage(w, r, http.StatusInternalServerError, err.Error()) + return + } + if !exists { + errorPage(w, r, http.StatusNotFound, fmt.Sprintf("Database '%s%s%s' doesn't exist", dbOwner, dbFolder, + dbName)) + return + } + + // Check if the user has access to the requested database (and get it's details if available) + err = com.DBDetails(&pageData.DB, loggedInUser, dbOwner, dbFolder, dbName, "") + if err != nil { + errorPage(w, r, http.StatusBadRequest, err.Error()) + return + } + + // Get latest star and fork count + _, pageData.DB.Info.Stars, pageData.DB.Info.Forks, err = com.SocialStats(dbOwner, dbFolder, dbName) + if err != nil { + errorPage(w, r, http.StatusInternalServerError, err.Error()) + return + } + + // Check if the database was starred by the logged in user + pageData.MyStar, err = com.CheckDBStarred(loggedInUser, dbOwner, dbFolder, dbName) + if err != nil { + errorPage(w, r, http.StatusInternalServerError, "Couldn't retrieve latest social stats") + return + } + + // Retrieve the list of MRs for this database + pageData.MRList, err = com.Discussions(dbOwner, dbFolder, dbName, com.MERGE_REQUEST, pageData.SelectedID) + if err != nil { + errorPage(w, r, http.StatusInternalServerError, err.Error()) + return + } + + // Retrieve the latest discussion and MR counts + pageData.DB.Info.Discussions, pageData.DB.Info.MRs, err = com.GetDiscussionAndMRCount(dbOwner, dbFolder, dbName) + if err != nil { + errorPage(w, r, http.StatusInternalServerError, err.Error()) + return + } + + // Retrieve correctly capitalised username for the database owner + usr, err := com.User(dbOwner) + if err != nil { + errorPage(w, r, http.StatusInternalServerError, err.Error()) + return + } + pageData.Meta.Owner = usr.Username + + // Retrieve the details for the logged in user + if loggedInUser != "" { + ur, err := com.User(loggedInUser) + if err != nil { + errorPage(w, r, http.StatusInternalServerError, err.Error()) + return + } + if ur.AvatarURL != "" { + pageData.Meta.AvatarURL = ur.AvatarURL + "&s=48" + } + } + + // Retrieve the "forked from" information + frkOwn, frkFol, frkDB, frkDel, err := com.ForkedFrom(dbOwner, dbFolder, dbName) + if err != nil { + errorPage(w, r, http.StatusInternalServerError, "Database query failure") + return + } + pageData.Meta.ForkOwner = frkOwn + pageData.Meta.ForkFolder = frkFol + pageData.Meta.ForkDatabase = frkDB + pageData.Meta.ForkDeleted = frkDel + + // Fill out the metadata + pageData.Meta.Database = dbName + pageData.Meta.Title = "Merge Requests" + + // Set the default status message colour + pageData.StatusMessageColour = "green" + + // Add Auth0 info to the page data + pageData.Auth0.CallbackURL = "https://" + com.Conf.Web.ServerName + "/x/callback" + pageData.Auth0.ClientID = com.Conf.Auth0.ClientID + pageData.Auth0.Domain = com.Conf.Auth0.Domain + + // If a specific MR ID was given, then we display the MR comments page + if pageData.SelectedID != 0 { + // Check if the MR exists, and set the page title to the MR info + found := false + for _, j := range pageData.MRList { + if pageData.SelectedID == j.ID { + pageData.Meta.Title = fmt.Sprintf("Merge Request #%d : %s", j.ID, j.Title) + found = true + } + } + if !found { + errorPage(w, r, http.StatusNotFound, "Unknown merge request ID") + return + } + + // * Check the current state of the source and destination branches * + + // Check if the source database has been deleted or renamed + mr := &pageData.MRList[0] + if mr.MRDetails.SourceDBID != 0 { + pageData.SourceDBOK, mr.MRDetails.SourceFolder, mr.MRDetails.SourceDBName, err = com.CheckDBID(loggedInUser, + mr.MRDetails.SourceOwner, mr.MRDetails.SourceDBID) + if err != nil { + errorPage(w, r, http.StatusInternalServerError, err.Error()) + return + } + + // Check if the source branch name is still available + srcBranches, err := com.GetBranches(mr.MRDetails.SourceOwner, mr.MRDetails.SourceFolder, + mr.MRDetails.SourceDBName) + if err != nil { + errorPage(w, r, http.StatusInternalServerError, err.Error()) + return + } + _, pageData.SourceBranchOK = srcBranches[mr.MRDetails.SourceBranch] + } else { + mr.MRDetails.SourceOwner = "[ unavailable" + mr.MRDetails.SourceFolder = " " + mr.MRDetails.SourceDBName = "database ]" + } + + // Check if the destination branch name is still available + destBranches, err := com.GetBranches(dbOwner, dbFolder, dbName) + if err != nil { + errorPage(w, r, http.StatusInternalServerError, err.Error()) + return + } + var destBranchHead com.BranchEntry + destBranchHead, pageData.DestBranchNameOK = destBranches[mr.MRDetails.DestBranch] + if !pageData.DestBranchNameOK { + pageData.StatusMessage = "Destination branch is no longer available. Merge cannot proceed." + pageData.StatusMessageColour = "red" + } + + // Get the head commit ID of the destination branch + destCommitID := destBranchHead.Commit + + // If the MR is still open then make sure the source and destination branches can still be merged + pageData.DestBranchUsable = true + if mr.Open { + // If we the source database (or source branch) isn't available, we can only check if the current mr list + // still applies to the destination branch + if !pageData.SourceDBOK || !pageData.SourceBranchOK { + + // Get the commit ID for the commit which would be joined to the destination head + finalCommit := mr.MRDetails.Commits[len(mr.MRDetails.Commits)-1] + + // If the parent ID of finalCommit isn't the same as the destination head commit, then the destination + // branch has changed and the merge cannot proceed + if finalCommit.Parent != destCommitID { + pageData.DestBranchUsable = false + pageData.StatusMessage = "Destination branch has changed. Merge cannot proceed." + pageData.StatusMessageColour = "red" + } + } else { + // Check if the source branch can still be applied to the destination, and also check for new/changed + // commits + ancestorID, newCommitList, err, _ := com.GetCommonAncestorCommits(mr.MRDetails.SourceOwner, + mr.MRDetails.SourceFolder, mr.MRDetails.SourceDBName, mr.MRDetails.SourceBranch, dbOwner, dbFolder, + dbName, mr.MRDetails.DestBranch) + if err != nil { + errorPage(w, r, http.StatusInternalServerError, err.Error()) + return + } + if ancestorID == "" { + // Commits have been added to the destination branch after the MR was created. This isn't yet + // a scenario we can successfully merge + pageData.DestBranchUsable = false + pageData.StatusMessage = "Destination branch has changed. Merge cannot proceed." + pageData.StatusMessageColour = "red" + } else { + // The source can still be applied to the destination. Update the merge commit list, just in case + // the source branch commit list has changed + mr.MRDetails.Commits = newCommitList + + // Save the updated commit list back to PostgreSQL + err = com.UpdateMergeRequestCommits(dbOwner, dbFolder, dbName, pageData.SelectedID, + mr.MRDetails.Commits) + if err != nil { + errorPage(w, r, http.StatusInternalServerError, err.Error()) + return + } + } + } + } + + // Retrieve the current licence for the destination branch + commitList, err := com.GetCommitList(dbOwner, dbFolder, dbName) + if err != nil { + errorPage(w, r, http.StatusInternalServerError, err.Error()) + return + } + destCommit, ok := commitList[destCommitID] + if !ok { + errorPage(w, r, http.StatusInternalServerError, "Destination commit ID not found in commit list.") + return + } + destLicenceSHA := destCommit.Tree.Entries[0].LicenceSHA + + // Add the commit author's username and avatar URL to the commit list entries, and check for licence changes + var licenceChanges bool + for _, j := range mr.MRDetails.Commits { + var c CommitData + c.AuthorEmail = j.AuthorEmail + c.AuthorName = j.AuthorName + c.ID = j.ID + c.Message = j.Message + c.Timestamp = j.Timestamp + c.AuthorUsername, c.AuthorAvatar, err = com.GetUsernameFromEmail(j.AuthorEmail) + if err != nil { + errorPage(w, r, http.StatusInternalServerError, err.Error()) + return + } + if c.AuthorAvatar != "" { + c.AuthorAvatar += "&s=18" + } + + // Check for licence changes + commitLicSHA := j.Tree.Entries[0].LicenceSHA + if commitLicSHA != destLicenceSHA { + licenceChanges = true + lName, _, err := com.GetLicenceInfoFromSha256(mr.MRDetails.SourceOwner, commitLicSHA) + if err != nil { + errorPage(w, r, http.StatusInternalServerError, err.Error()) + return + } + c.LicenceChange = fmt.Sprintf("This commit includes a licence change to '%s'", lName) + } + + pageData.CommitList = append(pageData.CommitList, c) + } + + // Warn the user if any of the commits would include a licence change + if licenceChanges { + pageData.LicenceWarning = "WARNING: At least one of the commits in the merge list includes a licence " + + "change. Proceed with caution." + } + + // Load the comments for the requested MR + pageData.CommentList, err = com.DiscussionComments(dbOwner, dbFolder, dbName, pageData.SelectedID, 0) + if err != nil { + errorPage(w, r, http.StatusInternalServerError, err.Error()) + return + } + + // Render the MR comments page + t := tmpl.Lookup("mergeRequestCommentsPage") + err = t.Execute(w, pageData) + if err != nil { + log.Printf("Error: %s", err) + } + return + } + + // Render the MR list page + t := tmpl.Lookup("mergeRequestListPage") + err = t.Execute(w, pageData) + if err != nil { + log.Printf("Error: %s", err) + } +} + // Renders the user Preferences page. func prefPage(w http.ResponseWriter, r *http.Request, loggedInUser string) { var pageData struct { diff --git a/webui/templates/compare.html b/webui/templates/compare.html new file mode 100644 index 000000000..24e9c7f23 --- /dev/null +++ b/webui/templates/compare.html @@ -0,0 +1,308 @@ +[[ define "comparePage" ]] + + +[[ template "head" . ]] + +[[ template "header" . ]] +
+
+
+   +
+
+

Create a Merge Request

+
+
+   +
+
+
+
+
+

 {{ statusMessage }}

+
+
+
+
+
+ Source database the new data is coming from: + + + + + + + + + Branch: + + + + + + + + +

+
+
+
+
+ Destination database you'd like the data merged into: + + + + + + + + + Branch: + + + + + + + + +
+
+
+
+

Title

+ +
+
+
+
 
+
+

Description of this merge request

+ Markdown (CommonMark format) is supported + + + Edit +
+ +
+
+ + Preview +
+
+
+
+
 
+
+
+
+ + +
+
+ +
+[[ template "footer" . ]] + + + +[[ end ]] \ No newline at end of file diff --git a/webui/templates/database.html b/webui/templates/database.html index d9ef53bce..26af27802 100644 --- a/webui/templates/database.html +++ b/webui/templates/database.html @@ -12,16 +12,6 @@ .colHeader:hover { text-decoration: underline; } - - .notImplementedYet { - color: gray; - text-decoration: none; - } - - .notImplementedYet:hover { - color: gray; - text-decoration: none; - } [[ template "header" . ]]
@@ -69,7 +59,7 @@

            -       +       [[ if eq .Meta.Owner .Meta.LoggedInUser ]] [[ end ]] @@ -174,12 +164,10 @@

+ [[ if .Meta.LoggedInUser ]] + + [[ end ]]

-
@@ -430,6 +418,18 @@

) }; + // Change to the page for creating Merge Requests + var forkOwner = [[ .Meta.ForkOwner ]]; + var forkDB = [[ .Meta.ForkDatabase ]]; + var forkDeleted = [[ .Meta.ForkDeleted ]]; + $scope.mergeRequest = function() { + if ((forkOwner !== "") && (forkDeleted !== false)) { + window.location = "/compare/[[ .Meta.ForkOwner ]]/[[ .Meta.ForkDatabase ]]"; + } else { + window.location = "/compare/[[ .Meta.Owner ]]/[[ .Meta.Database ]]"; + } + }; + // Refreshes the table data, moving backwards one page $scope.pageBack = function() { // Don't page up if we're at the start diff --git a/webui/templates/discussioncomments.html b/webui/templates/discussioncomments.html index 6fc524b1e..1bcd70690 100644 --- a/webui/templates/discussioncomments.html +++ b/webui/templates/discussioncomments.html @@ -3,17 +3,6 @@ [[ template "headawesome" . ]] - [[ template "header" . ]]
@@ -60,7 +49,7 @@

            -       +       [[ if eq .Meta.Owner .Meta.LoggedInUser ]] [[ end ]] @@ -87,13 +76,15 @@

 {{ statusMessage }}

- - +
- +
# {{ Disc.disc_id }}
Open
Closed
+ + +
@@ -135,8 +126,11 @@

 {{ statusMessage }}

- - + +
+   + +
{{ row.commenter }} commented {{ getTimePeriodTxt(row.creation_date, true) }} @@ -175,8 +169,9 @@

 {{ statusMessage }}

- - + +
  +   
diff --git a/webui/templates/discussionlist.html b/webui/templates/discussionlist.html index 49dcbc70c..68810a319 100644 --- a/webui/templates/discussionlist.html +++ b/webui/templates/discussionlist.html @@ -3,17 +3,6 @@ [[ template "headawesome" . ]] - [[ template "header" . ]]
@@ -60,7 +49,7 @@

            -       +       [[ if eq .Meta.Owner .Meta.LoggedInUser ]] [[ end ]] @@ -92,7 +81,7 @@

 {{ statusMessage }}

- @@ -56,7 +56,7 @@

// Display the appropriate fork icons for a database row $scope.rowIcons = function(rowData) { var returnList = ""; - rowData.IconList.forEach(function(item, index, array) { + rowData.icon_list.forEach(function(item, index, array) { switch (item) { case 0: returnList += "  "; // Space @@ -83,16 +83,18 @@

// Ensure private and deleted databases display appropriately $scope.rowURL = function(row) { // Simple placeholder for deleted databases - if (row.Deleted === true) { + if (row.deleted === true) { return "deleted database"; } // Create appropriate link or placeholder for public/private databases - if (row.Public === true) { - return '' + row.DBName + ''; - } else if (row.Owner == "[[ .Meta.LoggedInUser ]]") { + if (row.public === true) { + return '' + row.database_name + ''; + } else if (row.database_owner == "[[ .Meta.LoggedInUser ]]") { // The logged in user should see their own private databases. Make sure it's not mistaken as public though. - return '' + row.DBName + ' (private database)'; + return '' + row.database_name + ' (private database)'; } else { return "private database"; } diff --git a/webui/templates/mergerequestcomments.html b/webui/templates/mergerequestcomments.html new file mode 100644 index 000000000..1fba8a0ef --- /dev/null +++ b/webui/templates/mergerequestcomments.html @@ -0,0 +1,711 @@ +[[ define "mergeRequestCommentsPage" ]] + + +[[ template "headawesome" . ]] + +[[ template "header" . ]] +
+
+
+

+
+ + [[ if .Meta.ForkOwner ]] +
+ forked from [[ .Meta.ForkOwner ]] / + [[ if not .Meta.ForkDeleted ]] + [[ .Meta.ForkDatabase ]] + [[ else ]] + deleted database + [[ end ]] +
+ [[ end ]] +
+
+
+ + +
+
+ + +
+
+ [[ if ne .Meta.Owner .Meta.LoggedInUser ]] + + [[ else ]] + + [[ end ]] + +
+
+

+
+
+
+
+       +       +       + [[ if eq .Meta.Owner .Meta.LoggedInUser ]] + + [[ end ]] +
+
+
+
+
+ +
+

+
+
+
+
+
+

 {{ statusMessage }}

+
+
+
+
+
+
+

+
+
+
+
+
+ +

+
diff --git a/webui/templates/forks.html b/webui/templates/forks.html index 374ff951d..4a63e7eda 100644 --- a/webui/templates/forks.html +++ b/webui/templates/forks.html @@ -31,7 +31,7 @@

  - {{ row.Owner }} {{ row.Folder }} + {{ row.database_owner }} {{ row.database_folder }}
+ + + + + + + +
+
# {{ Disc.disc_id }}
+
Open
+
Closed
+
+ + +
+
+
+
+ {{ Disc.title }} + + + +
+
+ +
+
+
+
Opened {{ getTimePeriodTxt(Disc.creation_date, true) }}: {{ Disc.creator }} + wants to mergerequested a merge from + + {{ Disc.mr_details.source_owner + Disc.mr_details.source_folder + Disc.mr_details.source_database_name }} + branch + {{ Disc.mr_details.source_branch }} + + into + + [ unavailable branch ] + +
+
+
+
+ + + Edit + + + + Preview +
+
+
+ + +
+
+
+ [[ if and (or (eq (index .MRList 0).Creator .Meta.LoggedInUser) (eq .Meta.Owner .Meta.LoggedInUser)) (ne (index .MRList 0).MRDetails.State 1)]] +
+ [[ else ]] +
+ [[ end ]] +

Commit List

+ + + + + + + + + + + + + + + + + +
AuthorCommit IDCommit messageDate
+ {{ row.author_name }} + + + + + This commit has no commit message +
+
{{ getTimePeriodTxt(row.timestamp, true) }}
+
+
+ + +
+
+ + + + + + + + + +
  + + +
+ {{ row.commenter }} commented {{ getTimePeriodTxt(row.creation_date, true) }} + + + + +
+ +
+
+ + + Edit + + + + Preview +
+
+
+
+ + +
+
+
+
+
+ + + + + +
  {{ row.commenter }} merged thisclosed this {{ getTimePeriodTxt(row.creation_date, true) }}.
+
+
+ + + + + +
  {{ row.commenter }} reopened this {{ getTimePeriodTxt(row.creation_date, true) }}.
+
+
+ [[ if .Meta.LoggedInUser ]] + + + + + + + + + +
   +
+ + + Edit + + + + Preview +
+
+
+ + + + + + +
+
+
+ [[ else ]] +
+
 
+
+
+ Sign in to join the discussion +
+
+
 
+
+ [[ end ]] + +
This database doesn't have any merge requests yet
+
+

+
+[[ template "footer" . ]] + + + +[[ end ]] \ No newline at end of file diff --git a/webui/templates/mergerequestlist.html b/webui/templates/mergerequestlist.html new file mode 100644 index 000000000..d978ee3cd --- /dev/null +++ b/webui/templates/mergerequestlist.html @@ -0,0 +1,234 @@ +[[ define "mergeRequestListPage" ]] + + +[[ template "headawesome" . ]] + +[[ template "header" . ]] +
+
+
+

+
+ + [[ if .Meta.ForkOwner ]] +
+ forked from [[ .Meta.ForkOwner ]] / + [[ if not .Meta.ForkDeleted ]] + [[ .Meta.ForkDatabase ]] + [[ else ]] + deleted database + [[ end ]] +
+ [[ end ]] +
+
+
+ + +
+
+ + +
+
+ [[ if ne .Meta.Owner .Meta.LoggedInUser ]] + + [[ else ]] + + [[ end ]] + +
+
+

+
+
+
+
+       +       +       + [[ if eq .Meta.Owner .Meta.LoggedInUser ]] + + [[ end ]] +
+
+ +
+
+
+ +
+ + +
+ +
+

+
+
+
+
+
+

 {{ statusMessage }}

+
+
+
+
+
+ + + + + + + +
+
+ + +
+
# {{ row.disc_id }}
+
+ {{ row.title }} +
+ Created {{ getTimePeriodTxt(row.creation_date, true) }} by {{ row.creator }}. Last modified {{ getTimePeriodTxt(row.last_modified, true) }} + {{ row.comment_count }} comments +
+
+
This database doesn't have any merge requests yet
+
This database doesn't have any closed merge requests
+
This database doesn't have any open merge requests
+
+
+
+[[ template "footer" . ]] + + + +[[ end ]] \ No newline at end of file diff --git a/webui/templates/settings.html b/webui/templates/settings.html index e91273065..0bafb5aae 100644 --- a/webui/templates/settings.html +++ b/webui/templates/settings.html @@ -270,7 +270,7 @@

Destructive options

$scope.statusMessage = ""; $scope.statusMessageColour = "green"; }, function failure(response) { - // The retrieving table names failed, so display an error message + // Retrieving the table names failed, so display an error message $scope.statusMessageColour = "red"; $scope.statusMessage = "Retrieving table names for the branch failed"; });