From 5a8eaef42e771e9de6f6cbce715877352807f53c Mon Sep 17 00:00:00 2001 From: Oleg Zubchenko Date: Thu, 4 Jul 2019 14:59:10 +0300 Subject: Add git blame api --- changelogs/unreleased/add-git-blame-api.yml | 5 + doc/api/repository_files.md | 69 ++++++++++++ lib/api/entities.rb | 13 +++ lib/api/files.rb | 25 +++++ spec/requests/api/files_spec.rb | 162 ++++++++++++++++++++++++++++ 5 files changed, 274 insertions(+) create mode 100644 changelogs/unreleased/add-git-blame-api.yml diff --git a/changelogs/unreleased/add-git-blame-api.yml b/changelogs/unreleased/add-git-blame-api.yml new file mode 100644 index 00000000000..cdb77041433 --- /dev/null +++ b/changelogs/unreleased/add-git-blame-api.yml @@ -0,0 +1,5 @@ +--- +title: Add git blame to GitLab API +merge_request: 30675 +author: Oleg Zubchenko +type: added diff --git a/doc/api/repository_files.md b/doc/api/repository_files.md index 87c7f371de1..b292c9dd7de 100644 --- a/doc/api/repository_files.md +++ b/doc/api/repository_files.md @@ -80,6 +80,75 @@ X-Gitlab-Size: 1476 ... ``` +## Get file blame from repository + +Allows you to receive blame information. Each blame range contains lines and corresponding commit info. + +``` +GET /projects/:id/repository/files/:file_path/blame +``` + +```bash +curl --request GET --header 'PRIVATE-TOKEN: ' 'https://gitlab.example.com/api/v4/projects/13083/repository/files/path%2Fto%2Ffile.rb/blame?ref=master' +``` + +Example response: + +```json +[ + { + "commit": { + "id": "d42409d56517157c48bf3bd97d3f75974dde19fb", + "message": "Add feature\n\nalso fix bug\n", + "parent_ids": [ + "cc6e14f9328fa6d7b5a0d3c30dc2002a3f2a3822" + ], + "authored_date": "2015-12-18T08:12:22.000Z", + "author_name": "John Doe", + "author_email": "john.doe@example.com", + "committed_date": "2015-12-18T08:12:22.000Z", + "committer_name": "John Doe", + "committer_email": "john.doe@example.com" + }, + "lines": [ + "require 'fileutils'", + "require 'open3'", + "" + ] + }, + ... +] +``` + +Parameters: + +- `file_path` (required) - Url encoded full path to new file. Ex. lib%2Fclass%2Erb +- `ref` (required) - The name of branch, tag or commit + +NOTE: **Note:** +`HEAD` method return just file metadata as in [Get file from repository](repository_files.md#get-file-from-repository). + +```bash +curl --head --header 'PRIVATE-TOKEN: ' 'https://gitlab.example.com/api/v4/projects/13083/repository/files/path%2Fto%2Ffile.rb/blame?ref=master' +``` + +Example response: + +```text +HTTP/1.1 200 OK +... +X-Gitlab-Blob-Id: 79f7bbd25901e8334750839545a9bd021f0e4c83 +X-Gitlab-Commit-Id: d5a3ff139356ce33e37e73add446f16869741b50 +X-Gitlab-Content-Sha256: 4c294617b60715c1d218e61164a3abd4808a4284cbc30e6728a01ad9aada4481 +X-Gitlab-Encoding: base64 +X-Gitlab-File-Name: file.rb +X-Gitlab-File-Path: path/to/file.rb +X-Gitlab-Last-Commit-Id: 570e7b2abdd848b95f2f578043fc23bd6f6fd24d +X-Gitlab-Ref: master +X-Gitlab-Size: 1476 +... +``` + ## Get raw file from repository ``` diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 0a9515f1dd2..2b1176871fe 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -2,6 +2,19 @@ module API module Entities + class BlameRangeCommit < Grape::Entity + expose :id + expose :parent_ids + expose :message + expose :authored_date, :author_name, :author_email + expose :committed_date, :committer_name, :committer_email + end + + class BlameRange < Grape::Entity + expose :commit, using: BlameRangeCommit + expose :lines + end + class WikiPageBasic < Grape::Entity expose :format expose :slug diff --git a/lib/api/files.rb b/lib/api/files.rb index ca59d330e1c..0b438fb5bbc 100644 --- a/lib/api/files.rb +++ b/lib/api/files.rb @@ -83,6 +83,31 @@ module API resource :projects, requirements: FILE_ENDPOINT_REQUIREMENTS do allow_access_with_scope :read_repository, if: -> (request) { request.get? || request.head? } + desc 'Get blame file metadata from repository' + params do + requires :file_path, type: String, desc: 'The url encoded path to the file. Ex. lib%2Fclass%2Erb' + requires :ref, type: String, desc: 'The name of branch, tag or commit', allow_blank: false + end + head ":id/repository/files/:file_path/blame", requirements: FILE_ENDPOINT_REQUIREMENTS do + assign_file_vars! + + set_http_headers(blob_data) + end + + desc 'Get blame file from the repository' + params do + requires :file_path, type: String, desc: 'The url encoded path to the file. Ex. lib%2Fclass%2Erb' + requires :ref, type: String, desc: 'The name of branch, tag or commit', allow_blank: false + end + get ":id/repository/files/:file_path/blame", requirements: FILE_ENDPOINT_REQUIREMENTS do + assign_file_vars! + + set_http_headers(blob_data) + + blame_ranges = Gitlab::Blame.new(@blob, @commit).groups(highlight: false) + present blame_ranges, with: Entities::BlameRange + end + desc 'Get raw file metadata from repository' params do requires :file_path, type: String, desc: 'The url encoded path to the file. Ex. lib%2Fclass%2Erb' diff --git a/spec/requests/api/files_spec.rb b/spec/requests/api/files_spec.rb index 1ad536258ba..21b67357543 100644 --- a/spec/requests/api/files_spec.rb +++ b/spec/requests/api/files_spec.rb @@ -186,6 +186,14 @@ describe API::Files do expect(headers[Gitlab::Workhorse::DETECT_HEADER]).to eq "true" end + it 'returns blame file info' do + url = route(file_path) + '/blame' + + get api(url, current_user), params: params + + expect(response).to have_gitlab_http_status(200) + end + it 'sets inline content disposition by default' do url = route(file_path) + "/raw" @@ -252,6 +260,160 @@ describe API::Files do end end + describe 'GET /projects/:id/repository/files/:file_path/blame' do + shared_examples_for 'repository blame files' do + let(:expected_blame_range_sizes) do + [3, 2, 1, 2, 1, 1, 1, 1, 8, 1, 3, 1, 2, 1, 4, 1, 2, 2] + end + + let(:expected_blame_range_commit_ids) do + %w[ + 913c66a37b4a45b9769037c55c2d238bd0942d2e + 874797c3a73b60d2187ed6e2fcabd289ff75171e + 913c66a37b4a45b9769037c55c2d238bd0942d2e + 874797c3a73b60d2187ed6e2fcabd289ff75171e + 570e7b2abdd848b95f2f578043fc23bd6f6fd24d + 874797c3a73b60d2187ed6e2fcabd289ff75171e + 913c66a37b4a45b9769037c55c2d238bd0942d2e + 874797c3a73b60d2187ed6e2fcabd289ff75171e + 570e7b2abdd848b95f2f578043fc23bd6f6fd24d + 913c66a37b4a45b9769037c55c2d238bd0942d2e + 874797c3a73b60d2187ed6e2fcabd289ff75171e + 913c66a37b4a45b9769037c55c2d238bd0942d2e + 874797c3a73b60d2187ed6e2fcabd289ff75171e + 570e7b2abdd848b95f2f578043fc23bd6f6fd24d + 874797c3a73b60d2187ed6e2fcabd289ff75171e + 913c66a37b4a45b9769037c55c2d238bd0942d2e + 874797c3a73b60d2187ed6e2fcabd289ff75171e + 913c66a37b4a45b9769037c55c2d238bd0942d2e + ] + end + + it 'returns file attributes in headers' do + head api(route(file_path) + '/blame', current_user), params: params + + expect(response).to have_gitlab_http_status(200) + expect(response.headers['X-Gitlab-File-Path']).to eq(CGI.unescape(file_path)) + expect(response.headers['X-Gitlab-File-Name']).to eq('popen.rb') + expect(response.headers['X-Gitlab-Last-Commit-Id']).to eq('570e7b2abdd848b95f2f578043fc23bd6f6fd24d') + expect(response.headers['X-Gitlab-Content-Sha256']) + .to eq('c440cd09bae50c4632cc58638ad33c6aa375b6109d811e76a9cc3a613c1e8887') + end + + it 'returns blame file attributes as json' do + get api(route(file_path) + '/blame', current_user), params: params + + expect(response).to have_gitlab_http_status(200) + expect(json_response.map { |x| x['lines'].size }).to eq(expected_blame_range_sizes) + expect(json_response.map { |x| x['commit']['id'] }).to eq(expected_blame_range_commit_ids) + range = json_response[0] + expect(range['lines']).to eq(["require 'fileutils'", "require 'open3'", '']) + expect(range['commit']['id']).to eq('913c66a37b4a45b9769037c55c2d238bd0942d2e') + expect(range['commit']['parent_ids']).to eq(['cfe32cf61b73a0d5e9f13e774abde7ff789b1660']) + expect(range['commit']['message']) + .to eq("Files, encoding and much more\n\nSigned-off-by: Dmitriy Zaporozhets \n") + + expect(range['commit']['authored_date']).to eq('2014-02-27T08:14:56.000Z') + expect(range['commit']['author_name']).to eq('Dmitriy Zaporozhets') + expect(range['commit']['author_email']).to eq('dmitriy.zaporozhets@gmail.com') + + expect(range['commit']['committed_date']).to eq('2014-02-27T08:14:56.000Z') + expect(range['commit']['committer_name']).to eq('Dmitriy Zaporozhets') + expect(range['commit']['committer_email']).to eq('dmitriy.zaporozhets@gmail.com') + end + + it 'returns blame file info for files with dots' do + url = route('.gitignore') + '/blame' + + get api(url, current_user), params: params + + expect(response).to have_gitlab_http_status(200) + end + + it 'returns file by commit sha' do + # This file is deleted on HEAD + file_path = 'files%2Fjs%2Fcommit%2Ejs%2Ecoffee' + params[:ref] = '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9' + + get api(route(file_path) + '/blame', current_user), params: params + + expect(response).to have_gitlab_http_status(200) + end + + context 'when mandatory params are not given' do + it_behaves_like '400 response' do + let(:request) { get api(route('any%2Ffile/blame'), current_user) } + end + end + + context 'when file_path does not exist' do + let(:params) { { ref: 'master' } } + + it_behaves_like '404 response' do + let(:request) { get api(route('app%2Fmodels%2Fapplication%2Erb/blame'), current_user), params: params } + let(:message) { '404 File Not Found' } + end + end + + context 'when commit does not exist' do + let(:params) { { ref: '1111111111111111111111111111111111111111' } } + + it_behaves_like '404 response' do + let(:request) { get api(route(file_path + '/blame'), current_user), params: params } + let(:message) { '404 Commit Not Found' } + end + end + + context 'when repository is disabled' do + include_context 'disabled repository' + + it_behaves_like '403 response' do + let(:request) { get api(route(file_path + '/blame'), current_user), params: params } + end + end + end + + context 'when unauthenticated', 'and project is public' do + it_behaves_like 'repository blame files' do + let(:project) { create(:project, :public, :repository) } + let(:current_user) { nil } + end + end + + context 'when unauthenticated', 'and project is private' do + it_behaves_like '404 response' do + let(:request) { get api(route(file_path)), params: params } + let(:message) { '404 Project Not Found' } + end + end + + context 'when authenticated', 'as a developer' do + it_behaves_like 'repository blame files' do + let(:current_user) { user } + end + end + + context 'when authenticated', 'as a guest' do + it_behaves_like '403 response' do + let(:request) { get api(route(file_path) + '/blame', guest), params: params } + end + end + + context 'when PATs are used' do + it 'returns blame file by commit sha' do + token = create(:personal_access_token, scopes: ['read_repository'], user: user) + + # This file is deleted on HEAD + file_path = 'files%2Fjs%2Fcommit%2Ejs%2Ecoffee' + params[:ref] = '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9' + + get api(route(file_path) + '/blame', personal_access_token: token), params: params + + expect(response).to have_gitlab_http_status(200) + end + end + end + describe "GET /projects/:id/repository/files/:file_path/raw" do shared_examples_for 'repository raw files' do it 'returns raw file info' do -- cgit v1.2.1