diff --git a/config/tsconfig.build.json b/config/tsconfig.build.json index e771e8e..f6f5ae7 100644 --- a/config/tsconfig.build.json +++ b/config/tsconfig.build.json @@ -1,5 +1,4 @@ { "extends": "../tsconfig.json", - "include": ["src/**/*.ts"], - "exclude": ["node_modules", "bin", "test", "src/**/*.test.ts"] + "exclude": ["node_modules", "bin", "test", "../src/**/*.test.ts"] } diff --git a/src/app.module.ts b/src/app.module.ts index b57b2b7..fa6217a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,10 +1,11 @@ import { Module } from "@nestjs/common"; import { Configuration } from "./config"; -import { AppController, BranchesController, HealthCheckController } from "./controllers"; +import { AppController, BranchesController, CommitsController, HealthCheckController } from "./controllers"; import { AppService, BranchService, + CommitService, FSService, GitFetchService, HttpService, @@ -15,7 +16,7 @@ import { @Module({ imports: [], - controllers: [AppController, HealthCheckController, BranchesController], + controllers: [AppController, HealthCheckController, BranchesController, CommitsController], providers: [ AppService, RepoService, @@ -26,6 +27,7 @@ import { PermissionCacheService, HttpService, Configuration, + CommitService, ], }) export class AppModule {} diff --git a/src/controllers/branches/branches.controller.test.ts b/src/controllers/branches/branches.controller.test.ts index 1b4f03f..fa7230a 100644 --- a/src/controllers/branches/branches.controller.test.ts +++ b/src/controllers/branches/branches.controller.test.ts @@ -15,7 +15,7 @@ const b2 = { }, }; -describe("BranchController", () => { +describe("BranchesController", () => { let controller: BranchesController; const branchServiceSpy = { list: jest.fn(() => [b1, b2]), diff --git a/src/controllers/commits/commits.controller.test.ts b/src/controllers/commits/commits.controller.test.ts new file mode 100644 index 0000000..1c17daf --- /dev/null +++ b/src/controllers/commits/commits.controller.test.ts @@ -0,0 +1,35 @@ +import { NotFoundException } from "@nestjs/common"; + +import { RepoAuth } from "../../core"; +import { CommitsController } from "./commits.controller"; + +const c1 = { + sha: "sha1", +}; + +describe("CommitsController", () => { + let controller: CommitsController; + const commitServiceSpy = { + get: jest.fn((_, sha) => (sha === "sha1" ? c1 : undefined)), + }; + + beforeEach(() => { + jest.clearAllMocks(); + controller = new CommitsController(commitServiceSpy as any); + }); + + it("get a commit", async () => { + const auth = new RepoAuth(); + const commit = await controller.get("github.com/Azure/git-rest-api", "sha1", auth); + expect(commitServiceSpy.get).toHaveBeenCalledTimes(1); + expect(commitServiceSpy.get).toHaveBeenCalledWith("github.com/Azure/git-rest-api", "sha1", { auth }); + expect(commit).toEqual(c1); + }); + + it("throw a NotFoundException if commit doesn't exists", async () => { + const auth = new RepoAuth(); + await expect(controller.get("github.com/Azure/git-rest-api", "sha-not-found", auth)).rejects.toThrow( + NotFoundException, + ); + }); +}); diff --git a/src/controllers/commits/commits.controller.ts b/src/controllers/commits/commits.controller.ts new file mode 100644 index 0000000..924e474 --- /dev/null +++ b/src/controllers/commits/commits.controller.ts @@ -0,0 +1,24 @@ +import { Controller, Get, NotFoundException, Param } from "@nestjs/common"; +import { ApiNotFoundResponse, ApiOkResponse } from "@nestjs/swagger"; + +import { ApiHasPassThruAuth, Auth, RepoAuth } from "../../core"; +import { GitCommit } from "../../dtos"; +import { CommitService } from "../../services"; + +@Controller("/repos/:remote/commits") +export class CommitsController { + constructor(private commitService: CommitService) {} + + @Get(":commitSha") + @ApiHasPassThruAuth() + @ApiOkResponse({ type: GitCommit, isArray: true }) + @ApiNotFoundResponse({}) + public async get(@Param("remote") remote: string, @Param("commitSha") commitSha: string, @Auth() auth: RepoAuth) { + const commit = await this.commitService.get(remote, commitSha, { auth }); + + if (!commit) { + throw new NotFoundException(`Commit with sha ${commitSha} was not found`); + } + return commit; + } +} diff --git a/src/controllers/index.ts b/src/controllers/index.ts index 90de6da..268b8e7 100644 --- a/src/controllers/index.ts +++ b/src/controllers/index.ts @@ -1,3 +1,4 @@ export * from "./app.controller"; +export * from "./commits/commits.controller"; export * from "./health-check/health-check.controller"; export * from "./branches/branches.controller"; diff --git a/src/core/repo-auth/repo-auth-decorators.ts b/src/core/repo-auth/repo-auth-decorators.ts index eb0d38e..deb8e65 100644 --- a/src/core/repo-auth/repo-auth-decorators.ts +++ b/src/core/repo-auth/repo-auth-decorators.ts @@ -30,7 +30,7 @@ export function ApiHasPassThruAuth(): MethodDecorator { }), ); const badRequestResponse = ApiBadRequestResponse({ - description: "When the api request header is malformed", + description: "When the x-authorization header is malformed", }); return (...args) => { diff --git a/src/dtos/git-branch.ts b/src/dtos/git-branch.ts index 4e37cd0..d714af2 100644 --- a/src/dtos/git-branch.ts +++ b/src/dtos/git-branch.ts @@ -1,21 +1,21 @@ import { ApiModelProperty } from "@nestjs/swagger"; -import { GitCommit, IGitCommit } from "./git-commit"; +import { GitCommitRef, IGitCommitRef } from "./git-commit-ref"; export interface IGitBranch { name: string; - commit: IGitCommit; + commit: IGitCommitRef; } export class GitBranch implements IGitBranch { @ApiModelProperty({ type: String }) public name: string; - @ApiModelProperty({ type: GitCommit }) - public commit: GitCommit; + @ApiModelProperty({ type: GitCommitRef }) + public commit: GitCommitRef; constructor(branch: IGitBranch) { this.name = branch.name; - this.commit = new GitCommit(branch.commit); + this.commit = new GitCommitRef(branch.commit); } } diff --git a/src/dtos/git-commit-ref.ts b/src/dtos/git-commit-ref.ts new file mode 100644 index 0000000..9174d17 --- /dev/null +++ b/src/dtos/git-commit-ref.ts @@ -0,0 +1,14 @@ +import { ApiModelProperty } from "@nestjs/swagger"; + +export interface IGitCommitRef { + sha: string; +} + +export class GitCommitRef implements IGitCommitRef { + @ApiModelProperty({ type: String }) + public sha: string; + + constructor(commit: IGitCommitRef) { + this.sha = commit.sha; + } +} diff --git a/src/dtos/git-commit.ts b/src/dtos/git-commit.ts index bcf68a4..10cc7f5 100644 --- a/src/dtos/git-commit.ts +++ b/src/dtos/git-commit.ts @@ -1,14 +1,34 @@ import { ApiModelProperty } from "@nestjs/swagger"; -export interface IGitCommit { +import { GitCommitRef, IGitCommitRef } from "./git-commit-ref"; +import { GitSignature, IGitSignature } from "./git-signature"; + +export interface IGitCommit extends IGitCommitRef { sha: string; + message: string; + author: IGitSignature; + committer: IGitSignature; + parents: IGitCommitRef[]; } -export class GitCommit implements IGitCommit { +export class GitCommit extends GitCommitRef implements IGitCommit { @ApiModelProperty({ type: String }) - public sha: string; + public message: string; - constructor(commit: { sha: string }) { - this.sha = commit.sha; + @ApiModelProperty({ type: GitSignature }) + public author: GitSignature; + + @ApiModelProperty({ type: GitSignature }) + public committer: GitSignature; + + @ApiModelProperty({ type: GitCommitRef, isArray: true }) + public parents: GitCommitRef[]; + + constructor(commit: IGitCommit) { + super(commit); + this.message = commit.message; + this.author = commit.author; + this.committer = commit.committer; + this.parents = commit.parents.map(x => new GitCommitRef(x)); } } diff --git a/src/dtos/git-signature.ts b/src/dtos/git-signature.ts new file mode 100644 index 0000000..c4068ed --- /dev/null +++ b/src/dtos/git-signature.ts @@ -0,0 +1,22 @@ +import { ApiModelProperty } from "@nestjs/swagger"; + +export interface IGitSignature { + name: string; + email: string; + date: Date; +} + +export class GitSignature implements IGitSignature { + @ApiModelProperty({ type: String }) + public name: string; + @ApiModelProperty({ type: String }) + public email: string; + @ApiModelProperty({ type: String, format: "date-time" }) + public date: Date; + + constructor(sig: IGitSignature) { + this.name = sig.name; + this.email = sig.email; + this.date = sig.date; + } +} diff --git a/src/dtos/index.ts b/src/dtos/index.ts index a94ef3b..36e2407 100644 --- a/src/dtos/index.ts +++ b/src/dtos/index.ts @@ -1,2 +1,4 @@ export * from "./git-branch"; export * from "./git-commit"; +export * from "./git-commit-ref"; +export * from "./git-signature"; diff --git a/src/services/commit/commit.service.ts b/src/services/commit/commit.service.ts new file mode 100644 index 0000000..b8525fc --- /dev/null +++ b/src/services/commit/commit.service.ts @@ -0,0 +1,67 @@ +import { Injectable } from "@nestjs/common"; +import { Commit, Repository, Signature, Time } from "nodegit"; + +import { GitCommit, GitCommitRef } from "../../dtos"; +import { GitSignature } from "../../dtos/git-signature"; +import { GitBaseOptions, RepoService } from "../repo"; + +@Injectable() +export class CommitService { + constructor(private repoService: RepoService) {} + + public async get(remote: string, commitSha: string, options: GitBaseOptions = {}): Promise { + const repo = await this.repoService.get(remote, options); + const commit = await this.getCommit(repo, commitSha); + if (!commit) { + return undefined; + } + const [author, committer, parents] = await Promise.all([ + getAuthor(commit), + getCommitter(commit), + getParents(commit), + ]); + return new GitCommit({ + sha: commit.sha(), + message: commit.message(), + author, + committer, + parents, + }); + } + + public async getCommit(repo: Repository, commitSha: string): Promise { + try { + return await repo.getCommit(commitSha); + } catch { + return undefined; + } + } +} + +/** + * Get the list of the parents of the commit + */ +export async function getParents(commit: Commit): Promise { + const parents = await commit.getParents(10); + return parents.map(parent => { + return new GitCommitRef({ sha: parent.sha() }); + }); +} + +export async function getAuthor(commit: Commit): Promise { + const author = await commit.author(); + return getSignature(author); +} + +export async function getCommitter(commit: Commit): Promise { + const committer = await commit.committer(); + return getSignature(committer); +} + +export function getSignature(sig: Signature): GitSignature { + return new GitSignature({ email: sig.email(), name: sig.name(), date: getDateFromTime(sig.when()) }); +} + +export function getDateFromTime(time: Time): Date { + return new Date(time.time() * 1000); +} diff --git a/src/services/commit/index.ts b/src/services/commit/index.ts new file mode 100644 index 0000000..67a3ba9 --- /dev/null +++ b/src/services/commit/index.ts @@ -0,0 +1 @@ +export * from "./commit.service"; diff --git a/src/services/index.ts b/src/services/index.ts index 5fb4b75..42e80b2 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -1,5 +1,6 @@ export * from "./app.service"; export * from "./branch"; +export * from "./commit"; export * from "./fs"; export * from "./repo"; export * from "./permission"; diff --git a/swagger-spec.json b/swagger-spec.json index 4f01f7a..f84bd35 100644 --- a/swagger-spec.json +++ b/swagger-spec.json @@ -75,7 +75,60 @@ } }, "400": { - "description": "When the api request header is malformed" + "description": "When the x-authorization header is malformed" + }, + "404": { + "description": "" + } + }, + "produces": [ + "application/json" + ], + "consumes": [ + "application/json" + ] + } + }, + "/repos/{remote}/commits/{commitSha}": { + "get": { + "parameters": [ + { + "type": "string", + "name": "commitSha", + "required": true, + "in": "path" + }, + { + "type": "string", + "name": "remote", + "required": true, + "in": "path" + }, + { + "name": "x-authorization", + "required": false, + "in": "header", + "type": "string" + }, + { + "name": "x-github-token", + "required": false, + "in": "header", + "type": "string" + } + ], + "responses": { + "200": { + "description": "", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/GitCommit" + } + } + }, + "400": { + "description": "When the x-authorization header is malformed" }, "404": { "description": "" @@ -91,7 +144,7 @@ } }, "definitions": { - "GitCommit": { + "GitCommitRef": { "type": "object", "properties": { "sha": { @@ -109,13 +162,63 @@ "type": "string" }, "commit": { - "$ref": "#/definitions/GitCommit" + "$ref": "#/definitions/GitCommitRef" } }, "required": [ "name", "commit" ] + }, + "GitSignature": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "date": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "name", + "email", + "date" + ] + }, + "GitCommit": { + "type": "object", + "properties": { + "sha": { + "type": "string" + }, + "message": { + "type": "string" + }, + "author": { + "$ref": "#/definitions/GitSignature" + }, + "committer": { + "$ref": "#/definitions/GitSignature" + }, + "parents": { + "type": "array", + "items": { + "$ref": "#/definitions/GitCommitRef" + } + } + }, + "required": [ + "sha", + "message", + "author", + "committer", + "parents" + ] } } } \ No newline at end of file