From 0f05470cb84a4dcfd00e69e5af51b4420b74e9d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20=22BKC=22=20Carlb=C3=A4cker?= Date: Fri, 2 Dec 2016 12:10:39 +0100 Subject: [PATCH] [API] Pull Requests (#248) --- models/error.go | 22 ++ models/issue.go | 16 ++ models/pull.go | 84 +++++++ modules/context/api.go | 22 ++ routers/api/v1/api.go | 15 ++ routers/api/v1/repo/pull.go | 433 ++++++++++++++++++++++++++++++++++++ 6 files changed, 592 insertions(+) create mode 100644 routers/api/v1/repo/pull.go diff --git a/models/error.go b/models/error.go index 33815cdc9..d11a9eeb1 100644 --- a/models/error.go +++ b/models/error.go @@ -584,6 +584,28 @@ func (err ErrPullRequestNotExist) Error() string { err.ID, err.IssueID, err.HeadRepoID, err.BaseRepoID, err.HeadBarcnh, err.BaseBranch) } +// ErrPullRequestAlreadyExists represents a "PullRequestAlreadyExists"-error +type ErrPullRequestAlreadyExists struct { + ID int64 + IssueID int64 + HeadRepoID int64 + BaseRepoID int64 + HeadBranch string + BaseBranch string +} + +// IsErrPullRequestAlreadyExists checks if an error is a ErrPullRequestAlreadyExists. +func IsErrPullRequestAlreadyExists(err error) bool { + _, ok := err.(ErrPullRequestAlreadyExists) + return ok +} + +// Error does pretty-printing :D +func (err ErrPullRequestAlreadyExists) Error() string { + return fmt.Sprintf("pull request already exists for these targets [id: %d, issue_id: %d, head_repo_id: %d, base_repo_id: %d, head_branch: %s, base_branch: %s]", + err.ID, err.IssueID, err.HeadRepoID, err.BaseRepoID, err.HeadBranch, err.BaseBranch) +} + // _________ __ // \_ ___ \ ____ _____ _____ ____ _____/ |_ // / \ \/ / _ \ / \ / \_/ __ \ / \ __\ diff --git a/models/issue.go b/models/issue.go index 990d4a4bc..7ef3d00e4 100644 --- a/models/issue.go +++ b/models/issue.go @@ -170,6 +170,22 @@ func (issue *Issue) HTMLURL() string { return fmt.Sprintf("%s/%s/%d", issue.Repo.HTMLURL(), path, issue.Index) } +// DiffURL returns the absolute URL to this diff +func (issue *Issue) DiffURL() string { + if issue.IsPull { + return fmt.Sprintf("%s/pulls/%d.diff", issue.Repo.HTMLURL(), issue.Index) + } + return "" +} + +// PatchURL returns the absolute URL to this patch +func (issue *Issue) PatchURL() string { + if issue.IsPull { + return fmt.Sprintf("%s/pulls/%d.patch", issue.Repo.HTMLURL(), issue.Index) + } + return "" +} + // State returns string representation of issue status. func (issue *Issue) State() api.StateType { if issue.IsClosed { diff --git a/models/pull.go b/models/pull.go index eef9a7557..daed84bf2 100644 --- a/models/pull.go +++ b/models/pull.go @@ -121,7 +121,26 @@ func (pr *PullRequest) LoadIssue() (err error) { // Required - Issue // Optional - Merger func (pr *PullRequest) APIFormat() *api.PullRequest { + apiIssue := pr.Issue.APIFormat() + baseBranch, _ := pr.BaseRepo.GetBranch(pr.BaseBranch) + baseCommit, _ := baseBranch.GetCommit() + headBranch, _ := pr.HeadRepo.GetBranch(pr.HeadBranch) + headCommit, _ := headBranch.GetCommit() + apiBaseBranchInfo := &api.PRBranchInfo{ + Name: pr.BaseBranch, + Ref: pr.BaseBranch, + Sha: baseCommit.ID.String(), + RepoID: pr.BaseRepoID, + Repository: pr.BaseRepo.APIFormat(nil), + } + apiHeadBranchInfo := &api.PRBranchInfo{ + Name: pr.HeadBranch, + Ref: pr.HeadBranch, + Sha: headCommit.ID.String(), + RepoID: pr.HeadRepoID, + Repository: pr.HeadRepo.APIFormat(nil), + } apiPullRequest := &api.PullRequest{ ID: pr.ID, Index: pr.Index, @@ -134,7 +153,12 @@ func (pr *PullRequest) APIFormat() *api.PullRequest { State: apiIssue.State, Comments: apiIssue.Comments, HTMLURL: pr.Issue.HTMLURL(), + DiffURL: pr.Issue.DiffURL(), + PatchURL: pr.Issue.PatchURL(), HasMerged: pr.HasMerged, + Base: apiBaseBranchInfo, + Head: apiHeadBranchInfo, + MergeBase: pr.MergeBase, } if pr.Status != PullRequestStatusChecking { @@ -472,6 +496,46 @@ func NewPullRequest(repo *Repository, pull *Issue, labelIDs []int64, uuids []str return nil } +// PullRequestsOptions holds the options for PRs +type PullRequestsOptions struct { + Page int + State string + SortType string + Labels []string + MilestoneID int64 +} + +func listPullRequestStatement(baseRepoID int64, opts *PullRequestsOptions) *xorm.Session { + sess := x.Where("pull_request.base_repo_id=?", baseRepoID) + + sess.Join("INNER", "issue", "pull_request.issue_id = issue.id") + switch opts.State { + case "closed", "open": + sess.And("issue.is_closed=?", opts.State == "closed") + } + + return sess +} + +// PullRequests returns all pull requests for a base Repo by the given conditions +func PullRequests(baseRepoID int64, opts *PullRequestsOptions) ([]*PullRequest, int64, error) { + if opts.Page <= 0 { + opts.Page = 1 + } + + countSession := listPullRequestStatement(baseRepoID, opts) + maxResults, err := countSession.Count(new(PullRequest)) + if err != nil { + log.Error(4, "Count PRs", err) + return nil, maxResults, err + } + + prs := make([]*PullRequest, 0, ItemsPerPage) + findSession := listPullRequestStatement(baseRepoID, opts) + findSession.Limit(ItemsPerPage, (opts.Page-1)*ItemsPerPage) + return prs, maxResults, findSession.Find(&prs) +} + // GetUnmergedPullRequest returnss a pull request that is open and has not been merged // by given head/base and repo/branch. func GetUnmergedPullRequest(headRepoID, baseRepoID int64, headBranch, baseBranch string) (*PullRequest, error) { @@ -512,6 +576,26 @@ func GetUnmergedPullRequestsByBaseInfo(repoID int64, branch string) ([]*PullRequ Find(&prs) } +// GetPullRequestByIndex returns a pull request by the given index +func GetPullRequestByIndex(repoID int64, index int64) (*PullRequest, error) { + pr := &PullRequest{ + BaseRepoID: repoID, + Index: index, + } + + has, err := x.Get(pr) + if err != nil { + return nil, err + } else if !has { + return nil, ErrPullRequestNotExist{0, repoID, index, 0, "", ""} + } + + pr.LoadAttributes() + pr.LoadIssue() + + return pr, nil +} + func getPullRequestByID(e Engine, id int64) (*PullRequest, error) { pr := new(PullRequest) has, err := e.Id(id).Get(pr) diff --git a/modules/context/api.go b/modules/context/api.go index 7706e9c49..7a3ff990b 100644 --- a/modules/context/api.go +++ b/modules/context/api.go @@ -8,6 +8,7 @@ import ( "fmt" "strings" + "code.gitea.io/git" "code.gitea.io/gitea/models" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/log" @@ -103,3 +104,24 @@ func ExtractOwnerAndRepo() macaron.Handler { ctx.Data["Repository"] = repo } } + +// ReferencesGitRepo injects the GitRepo into the Context +func ReferencesGitRepo() macaron.Handler { + return func(ctx *APIContext) { + // Empty repository does not have reference information. + if ctx.Repo.Repository.IsBare { + return + } + + // For API calls. + if ctx.Repo.GitRepo == nil { + repoPath := models.RepoPath(ctx.Repo.Owner.Name, ctx.Repo.Repository.Name) + gitRepo, err := git.OpenRepository(repoPath) + if err != nil { + ctx.Error(500, "RepoRef Invalid repo "+repoPath, err) + return + } + ctx.Repo.GitRepo = gitRepo + } + } +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 506a61562..b920a2359 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -167,6 +167,13 @@ func mustEnableIssues(ctx *context.APIContext) { } } +func mustAllowPulls(ctx *context.Context) { + if !ctx.Repo.Repository.AllowsPulls() { + ctx.Status(404) + return + } +} + // RegisterRoutes registers all v1 APIs routes to web application. // FIXME: custom form error response func RegisterRoutes(m *macaron.Macaron) { @@ -305,6 +312,14 @@ func RegisterRoutes(m *macaron.Macaron) { Delete(reqRepoWriter(), repo.DeleteMilestone) }) m.Get("/editorconfig/:filename", context.RepoRef(), repo.GetEditorconfig) + m.Group("/pulls", func() { + m.Combo("").Get(bind(api.ListPullRequestsOptions{}), repo.ListPullRequests).Post(reqRepoWriter(), bind(api.CreatePullRequestOption{}), repo.CreatePullRequest) + m.Group("/:index", func() { + m.Combo("").Get(repo.GetPullRequest).Patch(reqRepoWriter(), bind(api.EditPullRequestOption{}), repo.EditPullRequest) + m.Combo("/merge").Get(repo.IsPullRequestMerged).Post(reqRepoWriter(), repo.MergePullRequest) + }) + + }, mustAllowPulls, context.ReferencesGitRepo()) }, repoAssignment()) }, reqToken()) diff --git a/routers/api/v1/repo/pull.go b/routers/api/v1/repo/pull.go new file mode 100644 index 000000000..4c10024bf --- /dev/null +++ b/routers/api/v1/repo/pull.go @@ -0,0 +1,433 @@ +// Copyright 2016 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package repo + +import ( + "fmt" + "strings" + + "code.gitea.io/git" + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + + api "code.gitea.io/sdk/gitea" +) + +// ListPullRequests returns a list of all PRs +func ListPullRequests(ctx *context.APIContext, form api.ListPullRequestsOptions) { + prs, maxResults, err := models.PullRequests(ctx.Repo.Repository.ID, &models.PullRequestsOptions{ + Page: ctx.QueryInt("page"), + State: ctx.QueryTrim("state"), + SortType: ctx.QueryTrim("sort"), + Labels: ctx.QueryStrings("labels"), + MilestoneID: ctx.QueryInt64("milestone"), + }) + + /*prs, maxResults, err := models.PullRequests(ctx.Repo.Repository.ID, &models.PullRequestsOptions{ + Page: form.Page, + State: form.State, + })*/ + if err != nil { + ctx.Error(500, "PullRequests", err) + return + } + + apiPrs := make([]*api.PullRequest, len(prs)) + for i := range prs { + prs[i].LoadIssue() + prs[i].LoadAttributes() + prs[i].GetBaseRepo() + prs[i].GetHeadRepo() + apiPrs[i] = prs[i].APIFormat() + } + + ctx.SetLinkHeader(int(maxResults), models.ItemsPerPage) + ctx.JSON(200, &apiPrs) +} + +// GetPullRequest returns a single PR based on index +func GetPullRequest(ctx *context.APIContext) { + pr, err := models.GetPullRequestByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if models.IsErrPullRequestNotExist(err) { + ctx.Status(404) + } else { + ctx.Error(500, "GetPullRequestByIndex", err) + } + return + } + + pr.GetBaseRepo() + pr.GetHeadRepo() + ctx.JSON(200, pr.APIFormat()) +} + +// CreatePullRequest does what it says +func CreatePullRequest(ctx *context.APIContext, form api.CreatePullRequestOption) { + var ( + repo = ctx.Repo.Repository + labelIDs []int64 + assigneeID int64 + milestoneID int64 + ) + + // Get repo/branch information + headUser, headRepo, headGitRepo, prInfo, baseBranch, headBranch := parseCompareInfo(ctx, form) + if ctx.Written() { + return + } + + // Check if another PR exists with the same targets + existingPr, err := models.GetUnmergedPullRequest(headRepo.ID, ctx.Repo.Repository.ID, headBranch, baseBranch) + if err != nil { + if !models.IsErrPullRequestNotExist(err) { + ctx.Error(500, "GetUnmergedPullRequest", err) + return + } + } else { + err = models.ErrPullRequestAlreadyExists{ + ID: existingPr.ID, + IssueID: existingPr.Index, + HeadRepoID: existingPr.HeadRepoID, + BaseRepoID: existingPr.BaseRepoID, + HeadBranch: existingPr.HeadBranch, + BaseBranch: existingPr.BaseBranch, + } + ctx.Error(409, "GetUnmergedPullRequest", err) + return + } + + if len(form.Labels) > 0 { + labels, err := models.GetLabelsInRepoByIDs(ctx.Repo.Repository.ID, form.Labels) + if err != nil { + ctx.Error(500, "GetLabelsInRepoByIDs", err) + return + } + + labelIDs = make([]int64, len(labels)) + for i := range labels { + labelIDs[i] = labels[i].ID + } + } + + if form.Milestone > 0 { + milestone, err := models.GetMilestoneByRepoID(ctx.Repo.Repository.ID, milestoneID) + if err != nil { + if models.IsErrMilestoneNotExist(err) { + ctx.Status(404) + } else { + ctx.Error(500, "GetMilestoneByRepoID", err) + } + return + } + + milestoneID = milestone.ID + } + + if len(form.Assignee) > 0 { + assigneeUser, err := models.GetUserByName(form.Assignee) + if err != nil { + if models.IsErrUserNotExist(err) { + ctx.Error(422, "", fmt.Sprintf("assignee does not exist: [name: %s]", form.Assignee)) + } else { + ctx.Error(500, "GetUserByName", err) + } + return + } + + assignee, err := repo.GetAssigneeByID(assigneeUser.ID) + if err != nil { + ctx.Error(500, "GetAssigneeByID", err) + return + } + + assigneeID = assignee.ID + } + + patch, err := headGitRepo.GetPatch(prInfo.MergeBase, headBranch) + if err != nil { + ctx.Error(500, "GetPatch", err) + return + } + + prIssue := &models.Issue{ + RepoID: repo.ID, + Index: repo.NextIssueIndex(), + Title: form.Title, + PosterID: ctx.User.ID, + Poster: ctx.User, + MilestoneID: milestoneID, + AssigneeID: assigneeID, + IsPull: true, + Content: form.Body, + } + pr := &models.PullRequest{ + HeadRepoID: headRepo.ID, + BaseRepoID: repo.ID, + HeadUserName: headUser.Name, + HeadBranch: headBranch, + BaseBranch: baseBranch, + HeadRepo: headRepo, + BaseRepo: repo, + MergeBase: prInfo.MergeBase, + Type: models.PullRequestGitea, + } + + if err := models.NewPullRequest(repo, prIssue, labelIDs, []string{}, pr, patch); err != nil { + ctx.Error(500, "NewPullRequest", err) + return + } else if err := pr.PushToBaseRepo(); err != nil { + ctx.Error(500, "PushToBaseRepo", err) + return + } + + log.Trace("Pull request created: %d/%d", repo.ID, prIssue.ID) + ctx.JSON(201, pr.APIFormat()) +} + +// EditPullRequest does what it says +func EditPullRequest(ctx *context.APIContext, form api.EditPullRequestOption) { + pr, err := models.GetPullRequestByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if models.IsErrPullRequestNotExist(err) { + ctx.Status(404) + } else { + ctx.Error(500, "GetPullRequestByIndex", err) + } + return + } + + pr.LoadIssue() + issue := pr.Issue + + if !issue.IsPoster(ctx.User.ID) && !ctx.Repo.IsWriter() { + ctx.Status(403) + return + } + + if len(form.Title) > 0 { + issue.Title = form.Title + } + if len(form.Body) > 0 { + issue.Content = form.Body + } + + if ctx.Repo.IsWriter() && len(form.Assignee) > 0 && + (issue.Assignee == nil || issue.Assignee.LowerName != strings.ToLower(form.Assignee)) { + if len(form.Assignee) == 0 { + issue.AssigneeID = 0 + } else { + assignee, err := models.GetUserByName(form.Assignee) + if err != nil { + if models.IsErrUserNotExist(err) { + ctx.Error(422, "", fmt.Sprintf("assignee does not exist: [name: %s]", form.Assignee)) + } else { + ctx.Error(500, "GetUserByName", err) + } + return + } + issue.AssigneeID = assignee.ID + } + + if err = models.UpdateIssueUserByAssignee(issue); err != nil { + ctx.Error(500, "UpdateIssueUserByAssignee", err) + return + } + } + if ctx.Repo.IsWriter() && form.Milestone != 0 && + issue.MilestoneID != form.Milestone { + oldMilestoneID := issue.MilestoneID + issue.MilestoneID = form.Milestone + if err = models.ChangeMilestoneAssign(issue, oldMilestoneID); err != nil { + ctx.Error(500, "ChangeMilestoneAssign", err) + return + } + } + + if err = models.UpdateIssue(issue); err != nil { + ctx.Error(500, "UpdateIssue", err) + return + } + if form.State != nil { + if err = issue.ChangeStatus(ctx.User, ctx.Repo.Repository, api.StateClosed == api.StateType(*form.State)); err != nil { + ctx.Error(500, "ChangeStatus", err) + return + } + } + + // Refetch from database + pr, err = models.GetPullRequestByIndex(ctx.Repo.Repository.ID, pr.Index) + if err != nil { + if models.IsErrPullRequestNotExist(err) { + ctx.Status(404) + } else { + ctx.Error(500, "GetPullRequestByIndex", err) + } + return + } + + ctx.JSON(201, pr.APIFormat()) +} + +// IsPullRequestMerged checks if a PR exists given an index +// - Returns 204 if it exists +// Otherwise 404 +func IsPullRequestMerged(ctx *context.APIContext) { + pr, err := models.GetPullRequestByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if models.IsErrPullRequestNotExist(err) { + ctx.Status(404) + } else { + ctx.Error(500, "GetPullRequestByIndex", err) + } + return + } + + if pr.HasMerged { + ctx.Status(204) + } + ctx.Status(404) +} + +// MergePullRequest merges a PR given an index +func MergePullRequest(ctx *context.APIContext) { + pr, err := models.GetPullRequestByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if models.IsErrPullRequestNotExist(err) { + ctx.Handle(404, "GetPullRequestByIndex", err) + } else { + ctx.Error(500, "GetPullRequestByIndex", err) + } + return + } + + if err = pr.GetHeadRepo(); err != nil { + ctx.Handle(500, "GetHeadRepo", err) + return + } + + pr.LoadIssue() + pr.Issue.Repo = ctx.Repo.Repository + + if ctx.IsSigned { + // Update issue-user. + if err = pr.Issue.ReadBy(ctx.User.ID); err != nil { + ctx.Error(500, "ReadBy", err) + return + } + } + + if pr.Issue.IsClosed { + ctx.Status(404) + return + } + + if !pr.CanAutoMerge() || pr.HasMerged { + ctx.Status(405) + return + } + + if err := pr.Merge(ctx.User, ctx.Repo.GitRepo); err != nil { + ctx.Error(500, "Merge", err) + return + } + + log.Trace("Pull request merged: %d", pr.ID) + ctx.Status(200) +} + +func parseCompareInfo(ctx *context.APIContext, form api.CreatePullRequestOption) (*models.User, *models.Repository, *git.Repository, *git.PullRequestInfo, string, string) { + baseRepo := ctx.Repo.Repository + + // Get compared branches information + // format: ...[:] + // base<-head: master...head:feature + // same repo: master...feature + + // TODO: Validate form first? + + baseBranch := form.Base + + var ( + headUser *models.User + headBranch string + isSameRepo bool + err error + ) + + // If there is no head repository, it means pull request between same repository. + headInfos := strings.Split(form.Head, ":") + if len(headInfos) == 1 { + isSameRepo = true + headUser = ctx.Repo.Owner + headBranch = headInfos[0] + + } else if len(headInfos) == 2 { + headUser, err = models.GetUserByName(headInfos[0]) + if err != nil { + if models.IsErrUserNotExist(err) { + ctx.Handle(404, "GetUserByName", nil) + } else { + ctx.Handle(500, "GetUserByName", err) + } + return nil, nil, nil, nil, "", "" + } + headBranch = headInfos[1] + + } else { + ctx.Status(404) + return nil, nil, nil, nil, "", "" + } + + ctx.Repo.PullRequest.SameRepo = isSameRepo + log.Info("Base branch: %s", baseBranch) + log.Info("Repo path: %s", ctx.Repo.GitRepo.Path) + // Check if base branch is valid. + if !ctx.Repo.GitRepo.IsBranchExist(baseBranch) { + ctx.Status(404) + return nil, nil, nil, nil, "", "" + } + + // Check if current user has fork of repository or in the same repository. + headRepo, has := models.HasForkedRepo(headUser.ID, baseRepo.ID) + if !has && !isSameRepo { + log.Trace("parseCompareInfo[%d]: does not have fork or in same repository", baseRepo.ID) + ctx.Status(404) + return nil, nil, nil, nil, "", "" + } + + var headGitRepo *git.Repository + if isSameRepo { + headRepo = ctx.Repo.Repository + headGitRepo = ctx.Repo.GitRepo + } else { + headGitRepo, err = git.OpenRepository(models.RepoPath(headUser.Name, headRepo.Name)) + if err != nil { + ctx.Error(500, "OpenRepository", err) + return nil, nil, nil, nil, "", "" + } + } + + if !ctx.User.IsWriterOfRepo(headRepo) && !ctx.User.IsAdmin { + log.Trace("ParseCompareInfo[%d]: does not have write access or site admin", baseRepo.ID) + ctx.Status(404) + return nil, nil, nil, nil, "", "" + } + + // Check if head branch is valid. + if !headGitRepo.IsBranchExist(headBranch) { + ctx.Status(404) + return nil, nil, nil, nil, "", "" + } + + prInfo, err := headGitRepo.GetPullRequestInfo(models.RepoPath(baseRepo.Owner.Name, baseRepo.Name), baseBranch, headBranch) + if err != nil { + ctx.Error(500, "GetPullRequestInfo", err) + return nil, nil, nil, nil, "", "" + } + + return headUser, headRepo, headGitRepo, prInfo, baseBranch, headBranch +}