From ecefa9e724460deb70b97dd7c52fc8f4db94be93 Mon Sep 17 00:00:00 2001
From: Lunny Xiao <xiaolunwen@gmail.com>
Date: Sun, 3 Feb 2019 11:35:17 +0800
Subject: [PATCH] Add single commit API support (#5843)

* add single commit API support
---
 Gopkg.lock                                |   4 +-
 integrations/api_repo_git_commits_test.go |  32 +++++
 routers/api/v1/api.go                     |   3 +
 routers/api/v1/repo/commits.go            | 119 ++++++++++++++++++
 routers/api/v1/swagger/repo.go            |   7 ++
 templates/swagger/v1_json.tmpl            | 142 ++++++++++++++++++++++
 vendor/code.gitea.io/git/commit.go        |  52 ++++++++
 vendor/code.gitea.io/git/repo_commit.go   |   3 +
 vendor/code.gitea.io/git/submodule.go     |  16 ++-
 vendor/code.gitea.io/git/tree.go          |  22 +++-
 10 files changed, 389 insertions(+), 11 deletions(-)
 create mode 100644 integrations/api_repo_git_commits_test.go
 create mode 100644 routers/api/v1/repo/commits.go

diff --git a/Gopkg.lock b/Gopkg.lock
index 1727b91afa..65cdf7efa3 100644
--- a/Gopkg.lock
+++ b/Gopkg.lock
@@ -3,11 +3,11 @@
 
 [[projects]]
   branch = "master"
-  digest = "1:ab875622908a804a327a95a1701002b150806a3c5406df51ec231eac16d3a1ca"
+  digest = "1:8a6c3c311918c0f08fa2899feae2c938a9bf22b51378e3720d63b80aca4e80aa"
   name = "code.gitea.io/git"
   packages = ["."]
   pruneopts = "NUT"
-  revision = "389d3c803e12a30dffcbb54a15c2242521bc4333"
+  revision = "d04f81a6f8979be39da165fc034447a805071b97"
 
 [[projects]]
   branch = "master"
diff --git a/integrations/api_repo_git_commits_test.go b/integrations/api_repo_git_commits_test.go
new file mode 100644
index 0000000000..587e9de5b2
--- /dev/null
+++ b/integrations/api_repo_git_commits_test.go
@@ -0,0 +1,32 @@
+// Copyright 2019 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 integrations
+
+import (
+	"net/http"
+	"testing"
+
+	"code.gitea.io/gitea/models"
+)
+
+func TestAPIReposGitCommits(t *testing.T) {
+	prepareTestEnv(t)
+	user := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User)
+	// Login as User2.
+	session := loginUser(t, user.Name)
+	token := getTokenForLoggedInUser(t, session)
+
+	for _, ref := range [...]string{
+		"commits/master", // Branch
+		"commits/v1.1",   // Tag
+	} {
+		req := NewRequestf(t, "GET", "/api/v1/repos/%s/repo1/git/%s?token="+token, user.Name, ref)
+		session.MakeRequest(t, req, http.StatusOK)
+	}
+
+	// Test getting non-existent refs
+	req := NewRequestf(t, "GET", "/api/v1/repos/%s/repo1/git/commits/unknown?token="+token, user.Name)
+	session.MakeRequest(t, req, http.StatusNotFound)
+}
diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go
index 7e7bf6a50b..55f5c66290 100644
--- a/routers/api/v1/api.go
+++ b/routers/api/v1/api.go
@@ -617,6 +617,9 @@ func RegisterRoutes(m *macaron.Macaron) {
 					m.Get("/statuses", repo.GetCommitStatusesByRef)
 				}, reqRepoReader(models.UnitTypeCode))
 				m.Group("/git", func() {
+					m.Group("/commits", func() {
+						m.Get("/:sha", repo.GetSingleCommit)
+					})
 					m.Get("/refs", repo.GetGitAllRefs)
 					m.Get("/refs/*", repo.GetGitRefs)
 					m.Combo("/trees/:sha", context.RepoRef()).Get(repo.GetTree)
diff --git a/routers/api/v1/repo/commits.go b/routers/api/v1/repo/commits.go
new file mode 100644
index 0000000000..a4cf5037d7
--- /dev/null
+++ b/routers/api/v1/repo/commits.go
@@ -0,0 +1,119 @@
+// Copyright 2018 The Gogs Authors. All rights reserved.
+// Copyright 2019 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 (
+	"time"
+
+	"code.gitea.io/git"
+	api "code.gitea.io/sdk/gitea"
+
+	"code.gitea.io/gitea/models"
+	"code.gitea.io/gitea/modules/context"
+	"code.gitea.io/gitea/modules/setting"
+)
+
+// GetSingleCommit get a commit via
+func GetSingleCommit(ctx *context.APIContext) {
+	// swagger:operation GET /repos/{owner}/{repo}/git/commits/{sha} repository repoGetSingleCommit
+	// ---
+	// summary: Get a single commit from a repository
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: owner
+	//   in: path
+	//   description: owner of the repo
+	//   type: string
+	//   required: true
+	// - name: repo
+	//   in: path
+	//   description: name of the repo
+	//   type: string
+	//   required: true
+	// - name: sha
+	//   in: path
+	//   description: the commit hash
+	//   type: string
+	//   required: true
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/Commit"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+
+	gitRepo, err := git.OpenRepository(ctx.Repo.Repository.RepoPath())
+	if err != nil {
+		ctx.ServerError("OpenRepository", err)
+		return
+	}
+	commit, err := gitRepo.GetCommit(ctx.Params(":sha"))
+	if err != nil {
+		ctx.NotFoundOrServerError("GetCommit", git.IsErrNotExist, err)
+		return
+	}
+
+	// Retrieve author and committer information
+	var apiAuthor, apiCommitter *api.User
+	author, err := models.GetUserByEmail(commit.Author.Email)
+	if err != nil && !models.IsErrUserNotExist(err) {
+		ctx.ServerError("Get user by author email", err)
+		return
+	} else if err == nil {
+		apiAuthor = author.APIFormat()
+	}
+	// Save one query if the author is also the committer
+	if commit.Committer.Email == commit.Author.Email {
+		apiCommitter = apiAuthor
+	} else {
+		committer, err := models.GetUserByEmail(commit.Committer.Email)
+		if err != nil && !models.IsErrUserNotExist(err) {
+			ctx.ServerError("Get user by committer email", err)
+			return
+		} else if err == nil {
+			apiCommitter = committer.APIFormat()
+		}
+	}
+
+	// Retrieve parent(s) of the commit
+	apiParents := make([]*api.CommitMeta, commit.ParentCount())
+	for i := 0; i < commit.ParentCount(); i++ {
+		sha, _ := commit.ParentID(i)
+		apiParents[i] = &api.CommitMeta{
+			URL: ctx.Repo.Repository.APIURL() + "/git/commits/" + sha.String(),
+			SHA: sha.String(),
+		}
+	}
+
+	ctx.JSON(200, &api.Commit{
+		CommitMeta: &api.CommitMeta{
+			URL: setting.AppURL + ctx.Link[1:],
+			SHA: commit.ID.String(),
+		},
+		HTMLURL: ctx.Repo.Repository.HTMLURL() + "/commits/" + commit.ID.String(),
+		RepoCommit: &api.RepoCommit{
+			URL: setting.AppURL + ctx.Link[1:],
+			Author: &api.CommitUser{
+				Name:  commit.Author.Name,
+				Email: commit.Author.Email,
+				Date:  commit.Author.When.Format(time.RFC3339),
+			},
+			Committer: &api.CommitUser{
+				Name:  commit.Committer.Name,
+				Email: commit.Committer.Email,
+				Date:  commit.Committer.When.Format(time.RFC3339),
+			},
+			Message: commit.Summary(),
+			Tree: &api.CommitMeta{
+				URL: ctx.Repo.Repository.APIURL() + "/trees/" + commit.ID.String(),
+				SHA: commit.ID.String(),
+			},
+		},
+		Author:    apiAuthor,
+		Committer: apiCommitter,
+		Parents:   apiParents,
+	})
+}
diff --git a/routers/api/v1/swagger/repo.go b/routers/api/v1/swagger/repo.go
index 0c9f95f962..5b930e295e 100644
--- a/routers/api/v1/swagger/repo.go
+++ b/routers/api/v1/swagger/repo.go
@@ -140,3 +140,10 @@ type swaggerGitTreeResponse struct {
 	//in: body
 	Body api.GitTreeResponse `json:"body"`
 }
+
+// Commit
+// swagger:response Commit
+type swaggerCommit struct {
+	//in: body
+	Body api.Commit `json:"body"`
+}
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index 801bab51f6..0ce6b805f7 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -1622,6 +1622,49 @@
         }
       }
     },
+    "/repos/{owner}/{repo}/git/commits/{sha}": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "repository"
+        ],
+        "summary": "Get a single commit from a repository",
+        "operationId": "repoGetSingleCommit",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "owner of the repo",
+            "name": "owner",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "string",
+            "description": "name of the repo",
+            "name": "repo",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "string",
+            "description": "the commit hash",
+            "name": "sha",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/Commit"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          }
+        }
+      }
+    },
     "/repos/{owner}/{repo}/git/refs": {
       "get": {
         "produces": [
@@ -6174,6 +6217,75 @@
       },
       "x-go-package": "code.gitea.io/gitea/vendor/code.gitea.io/sdk/gitea"
     },
+    "Commit": {
+      "type": "object",
+      "title": "Commit contains information generated from a Git commit.",
+      "properties": {
+        "author": {
+          "$ref": "#/definitions/User"
+        },
+        "commit": {
+          "$ref": "#/definitions/RepoCommit"
+        },
+        "committer": {
+          "$ref": "#/definitions/User"
+        },
+        "html_url": {
+          "type": "string",
+          "x-go-name": "HTMLURL"
+        },
+        "parents": {
+          "type": "array",
+          "items": {
+            "$ref": "#/definitions/CommitMeta"
+          },
+          "x-go-name": "Parents"
+        },
+        "sha": {
+          "type": "string",
+          "x-go-name": "SHA"
+        },
+        "url": {
+          "type": "string",
+          "x-go-name": "URL"
+        }
+      },
+      "x-go-package": "code.gitea.io/gitea/vendor/code.gitea.io/sdk/gitea"
+    },
+    "CommitMeta": {
+      "type": "object",
+      "title": "CommitMeta contains meta information of a commit in terms of API.",
+      "properties": {
+        "sha": {
+          "type": "string",
+          "x-go-name": "SHA"
+        },
+        "url": {
+          "type": "string",
+          "x-go-name": "URL"
+        }
+      },
+      "x-go-package": "code.gitea.io/gitea/vendor/code.gitea.io/sdk/gitea"
+    },
+    "CommitUser": {
+      "type": "object",
+      "title": "CommitUser contains information of a user in the context of a commit.",
+      "properties": {
+        "date": {
+          "type": "string",
+          "x-go-name": "Date"
+        },
+        "email": {
+          "type": "string",
+          "x-go-name": "Email"
+        },
+        "name": {
+          "type": "string",
+          "x-go-name": "Name"
+        }
+      },
+      "x-go-package": "code.gitea.io/gitea/vendor/code.gitea.io/sdk/gitea"
+    },
     "CreateEmailOption": {
       "description": "CreateEmailOption options when creating email addresses",
       "type": "object",
@@ -7952,6 +8064,30 @@
       },
       "x-go-package": "code.gitea.io/gitea/vendor/code.gitea.io/sdk/gitea"
     },
+    "RepoCommit": {
+      "type": "object",
+      "title": "RepoCommit contains information of a commit in the context of a repository.",
+      "properties": {
+        "author": {
+          "$ref": "#/definitions/CommitUser"
+        },
+        "committer": {
+          "$ref": "#/definitions/CommitUser"
+        },
+        "message": {
+          "type": "string",
+          "x-go-name": "Message"
+        },
+        "tree": {
+          "$ref": "#/definitions/CommitMeta"
+        },
+        "url": {
+          "type": "string",
+          "x-go-name": "URL"
+        }
+      },
+      "x-go-package": "code.gitea.io/gitea/vendor/code.gitea.io/sdk/gitea"
+    },
     "Repository": {
       "description": "Repository represents a repository",
       "type": "object",
@@ -8382,6 +8518,12 @@
         }
       }
     },
+    "Commit": {
+      "description": "Commit",
+      "schema": {
+        "$ref": "#/definitions/Commit"
+      }
+    },
     "DeployKey": {
       "description": "DeployKey",
       "schema": {
diff --git a/vendor/code.gitea.io/git/commit.go b/vendor/code.gitea.io/git/commit.go
index 5e8c91d303..227df09b7d 100644
--- a/vendor/code.gitea.io/git/commit.go
+++ b/vendor/code.gitea.io/git/commit.go
@@ -1,4 +1,5 @@
 // Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2018 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.
 
@@ -9,6 +10,7 @@ import (
 	"bytes"
 	"container/list"
 	"fmt"
+	"io"
 	"net/http"
 	"strconv"
 	"strings"
@@ -279,6 +281,56 @@ func (c *Commit) GetSubModule(entryname string) (*SubModule, error) {
 	return nil, nil
 }
 
+// CommitFileStatus represents status of files in a commit.
+type CommitFileStatus struct {
+	Added    []string
+	Removed  []string
+	Modified []string
+}
+
+// NewCommitFileStatus creates a CommitFileStatus
+func NewCommitFileStatus() *CommitFileStatus {
+	return &CommitFileStatus{
+		[]string{}, []string{}, []string{},
+	}
+}
+
+// GetCommitFileStatus returns file status of commit in given repository.
+func GetCommitFileStatus(repoPath, commitID string) (*CommitFileStatus, error) {
+	stdout, w := io.Pipe()
+	done := make(chan struct{})
+	fileStatus := NewCommitFileStatus()
+	go func() {
+		scanner := bufio.NewScanner(stdout)
+		for scanner.Scan() {
+			fields := strings.Fields(scanner.Text())
+			if len(fields) < 2 {
+				continue
+			}
+
+			switch fields[0][0] {
+			case 'A':
+				fileStatus.Added = append(fileStatus.Added, fields[1])
+			case 'D':
+				fileStatus.Removed = append(fileStatus.Removed, fields[1])
+			case 'M':
+				fileStatus.Modified = append(fileStatus.Modified, fields[1])
+			}
+		}
+		done <- struct{}{}
+	}()
+
+	stderr := new(bytes.Buffer)
+	err := NewCommand("show", "--name-status", "--pretty=format:''", commitID).RunInDirPipeline(repoPath, w, stderr)
+	w.Close() // Close writer to exit parsing goroutine
+	if err != nil {
+		return nil, concatenateError(err, stderr.String())
+	}
+
+	<-done
+	return fileStatus, nil
+}
+
 // GetFullCommitID returns full length (40) of commit ID by given short SHA in a repository.
 func GetFullCommitID(repoPath, shortID string) (string, error) {
 	if len(shortID) >= 40 {
diff --git a/vendor/code.gitea.io/git/repo_commit.go b/vendor/code.gitea.io/git/repo_commit.go
index d5cab8f873..484568585f 100644
--- a/vendor/code.gitea.io/git/repo_commit.go
+++ b/vendor/code.gitea.io/git/repo_commit.go
@@ -140,6 +140,9 @@ func (repo *Repository) GetCommit(commitID string) (*Commit, error) {
 		var err error
 		commitID, err = NewCommand("rev-parse", commitID).RunInDir(repo.Path)
 		if err != nil {
+			if strings.Contains(err.Error(), "unknown revision or path") {
+				return nil, ErrNotExist{commitID, ""}
+			}
 			return nil, err
 		}
 	}
diff --git a/vendor/code.gitea.io/git/submodule.go b/vendor/code.gitea.io/git/submodule.go
index a0fe7b4a56..294df3986a 100644
--- a/vendor/code.gitea.io/git/submodule.go
+++ b/vendor/code.gitea.io/git/submodule.go
@@ -29,13 +29,12 @@ func NewSubModuleFile(c *Commit, refURL, refID string) *SubModuleFile {
 	}
 }
 
-// RefURL guesses and returns reference URL.
-func (sf *SubModuleFile) RefURL(urlPrefix string, parentPath string) string {
-	if sf.refURL == "" {
+func getRefURL(refURL, urlPrefix, parentPath string) string {
+	if refURL == "" {
 		return ""
 	}
 
-	url := strings.TrimSuffix(sf.refURL, ".git")
+	url := strings.TrimSuffix(refURL, ".git")
 
 	// git://xxx/user/repo
 	if strings.HasPrefix(url, "git://") {
@@ -67,12 +66,21 @@ func (sf *SubModuleFile) RefURL(urlPrefix string, parentPath string) string {
 		if strings.Contains(urlPrefix, url[i+1:j]) {
 			return urlPrefix + url[j+1:]
 		}
+		if strings.HasPrefix(url, "ssh://") || strings.HasPrefix(url, "git+ssh://") {
+			k := strings.Index(url[j+1:], "/")
+			return "http://" + url[i+1:j] + "/" + url[j+1:][k+1:]
+		}
 		return "http://" + url[i+1:j] + "/" + url[j+1:]
 	}
 
 	return url
 }
 
+// RefURL guesses and returns reference URL.
+func (sf *SubModuleFile) RefURL(urlPrefix string, parentPath string) string {
+	return getRefURL(sf.refURL, urlPrefix, parentPath)
+}
+
 // RefID returns reference ID.
 func (sf *SubModuleFile) RefID() string {
 	return sf.refID
diff --git a/vendor/code.gitea.io/git/tree.go b/vendor/code.gitea.io/git/tree.go
index b67bf55840..b65fe19409 100644
--- a/vendor/code.gitea.io/git/tree.go
+++ b/vendor/code.gitea.io/git/tree.go
@@ -18,6 +18,9 @@ type Tree struct {
 
 	entries       Entries
 	entriesParsed bool
+
+	entriesRecursive 	Entries
+	entriesRecursiveParsed 	bool
 }
 
 // NewTree create a new tree according the repository and commit id
@@ -67,20 +70,29 @@ func (t *Tree) ListEntries() (Entries, error) {
 	if err != nil {
 		return nil, err
 	}
+
 	t.entries, err = parseTreeEntries(stdout, t)
+	if err == nil {
+		t.entriesParsed = true
+	}
+
 	return t.entries, err
 }
 
 // ListEntriesRecursive returns all entries of current tree recursively including all subtrees
 func (t *Tree) ListEntriesRecursive() (Entries, error) {
-	if t.entriesParsed {
-		return t.entries, nil
+	if t.entriesRecursiveParsed {
+		return t.entriesRecursive, nil
 	}
 	stdout, err := NewCommand("ls-tree", "-t", "-r", t.ID.String()).RunInDirBytes(t.repo.Path)
-
 	if err != nil {
 		return nil, err
 	}
-	t.entries, err = parseTreeEntries(stdout, t)
-	return t.entries, err
+
+	t.entriesRecursive, err = parseTreeEntries(stdout, t)
+	if err == nil {
+		t.entriesRecursiveParsed = true
+	}
+
+	return t.entriesRecursive, err
 }