diff --git a/changelogs/unreleased/add-git-blame-api.yml b/changelogs/unreleased/add-git-blame-api.yml
new file mode 100644
index 0000000000000000000000000000000000000000..cdb770414335e01198413e2b707a0f3380a1b218
--- /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 87c7f371de1e23fda59908861881cba818d50b31..b292c9dd7dea3c5472cbf9ef71a5b964726c6174 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: <your_access_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: <your_access_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 2e78331df6ccc68a5017701616a6358ca3dfff13..2d6dd18d4ea8b737c0c2d9fec13c018b8a0c976a 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 ca59d330e1c8c81809885ae82bbf6758d49a51aa..0b438fb5bbc51abf6368f365b3e7fc59de5e6aae 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 1ad536258ba9d0176cd476611937eeb977be1eea..21b673575437d64e6f5d47f444ff09e2a76537ca 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 <dmitriy.zaporozhets@gmail.com>\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