From 41d70ea300efb73a05f4753ddd3e0c3730e96e0a Mon Sep 17 00:00:00 2001
From: Andre Guedes <andrebsguedes@gmail.com>
Date: Mon, 3 Oct 2016 00:12:59 -0300
Subject: [PATCH] Added Issue Board API support   - Includes documentation and
 tests

---
 CHANGELOG                        |   1 +
 doc/api/boards.md                | 251 +++++++++++++++++++++++++++++++
 lib/api/api.rb                   |   1 +
 lib/api/boards.rb                | 115 ++++++++++++++
 lib/api/entities.rb              |  18 ++-
 spec/requests/api/boards_spec.rb | 192 +++++++++++++++++++++++
 6 files changed, 577 insertions(+), 1 deletion(-)
 create mode 100644 doc/api/boards.md
 create mode 100644 lib/api/boards.rb
 create mode 100644 spec/requests/api/boards_spec.rb

diff --git a/CHANGELOG b/CHANGELOG
index 2e19aa2534c..b6a4d58e0cb 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -18,6 +18,7 @@ v 8.13.0 (unreleased)
   - Expose expires_at field when sharing project on API
   - Fix VueJS template tags being rendered in code comments
   - Fix issue with page scrolling to top when closing or pinning sidebar (lukehowell)
+  - Add Issue Board API support (andrebsguedes)
   - Allow the Koding integration to be configured through the API
   - Added soft wrap button to repository file/blob editor
   - Add word-wrap to issue title on issue and milestone boards (ClemMakesApps)
diff --git a/doc/api/boards.md b/doc/api/boards.md
new file mode 100644
index 00000000000..28681719f43
--- /dev/null
+++ b/doc/api/boards.md
@@ -0,0 +1,251 @@
+# Boards
+
+Every API call to boards must be authenticated.
+
+If a user is not a member of a project and the project is private, a `GET`
+request on that project will result to a `404` status code.
+
+## Project Board
+
+Lists Issue Boards in the given project.
+
+```
+GET /projects/:id/boards
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id`   | integer  | yes    | The ID of a project |
+
+```bash
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/:id/boards
+```
+
+Example response:
+
+```json
+[
+  {
+    "id" : 1,
+    "lists" : [
+      {
+        "id" : 1,
+        "label" : {
+          "name" : "Testing",
+          "color" : "#F0AD4E",
+          "description" : null
+        },
+        "position" : 1
+      },
+      {
+        "id" : 2,
+        "label" : {
+          "name" : "Ready",
+          "color" : "#FF0000",
+          "description" : null
+        },
+        "position" : 2
+      },
+      {
+        "id" : 3,
+        "label" : {
+          "name" : "Production",
+          "color" : "#FF5F00",
+          "description" : null
+        },
+        "position" : 3
+      }
+    ]
+  }
+]
+```
+
+## List board lists
+
+Get a list of the board's lists.
+Does not include `backlog` and `done` lists
+
+```
+GET /projects/:id/boards/:board_id/lists
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id`   | integer  | yes    | The ID of a project |
+| `board_id`   | integer  | yes    | The ID of a board |
+
+```bash
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/boards/1/lists
+```
+
+Example response:
+
+```json
+[
+  {
+    "id" : 1,
+    "label" : {
+      "name" : "Testing",
+      "color" : "#F0AD4E",
+      "description" : null
+    },
+    "position" : 1
+  },
+  {
+    "id" : 2,
+    "label" : {
+      "name" : "Ready",
+      "color" : "#FF0000",
+      "description" : null
+    },
+    "position" : 2
+  },
+  {
+    "id" : 3,
+    "label" : {
+      "name" : "Production",
+      "color" : "#FF5F00",
+      "description" : null
+    },
+    "position" : 3
+  }
+]
+```
+
+## Single board list
+
+Get a single board list.
+
+```
+GET /projects/:id/boards/:board_id/lists/:list_id
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id`      | integer | yes   | The ID of a project |
+| `board_id`   | integer  | yes    | The ID of a board |
+| `list_id`| integer | yes   | The ID of a board's list |
+
+```bash
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/boards/1/lists/1
+```
+
+Example response:
+
+```json
+{
+  "id" : 1,
+  "label" : {
+    "name" : "Testing",
+    "color" : "#F0AD4E",
+    "description" : null
+  },
+  "position" : 1
+}
+```
+
+## New board list
+
+Creates a new Issue Board list.
+
+If the operation is successful, a status code of `200` and the newly-created
+list is returned. If an error occurs, an error number and a message explaining
+the reason is returned.
+
+```
+POST /projects/:id/boards/:board_id/lists
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id`            | integer | yes | The ID of a project |
+| `board_id`   | integer  | yes    | The ID of a board |
+| `label_id`         | integer  | yes | The ID of a label |
+
+```bash
+curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/boards/1/lists?label_id=5
+```
+
+Example response:
+
+```json
+{
+  "id" : 1,
+  "label" : {
+    "name" : "Testing",
+    "color" : "#F0AD4E",
+    "description" : null
+  },
+  "position" : 1
+}
+```
+
+## Edit board list
+
+Updates an existing Issue Board list. This call is used to change list position.
+
+If the operation is successful, a code of `200` and the updated board list is
+returned. If an error occurs, an error number and a message explaining the
+reason is returned.
+
+```
+PUT /projects/:id/boards/:board_id/lists/:list_id
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id`            | integer | yes | The ID of a project |
+| `board_id`   | integer  | yes    | The ID of a board |
+| `list_id`      | integer | yes | The ID of a board's list |
+| `position`         | integer  | yes  | The position of the list |
+
+```bash
+curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/boards/1/lists/1?position=2
+```
+
+Example response:
+
+```json
+{
+  "id" : 1,
+  "label" : {
+    "name" : "Testing",
+    "color" : "#F0AD4E",
+    "description" : null
+  },
+  "position" : 1
+}
+```
+
+## Delete a board list
+
+Only for admins and project owners. Soft deletes the board list in question.
+If the operation is successful, a status code `200` is returned. In case you cannot
+destroy this board list, or it is not present, code `404` is given.
+
+```
+DELETE /projects/:id/boards/:board_id/lists/:list_id
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id`            | integer | yes | The ID of a project |
+| `board_id`   | integer  | yes    | The ID of a board |
+| `list_id`      | integer | yes | The ID of a board's list |
+
+```bash
+curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/boards/1/lists/1
+```
+Example response:
+
+```json
+{
+  "id" : 1,
+  "label" : {
+    "name" : "Testing",
+    "color" : "#F0AD4E",
+    "description" : null
+  },
+  "position" : 1
+}
+```
diff --git a/lib/api/api.rb b/lib/api/api.rb
index cb47ec8f33f..0bbf73a1b63 100644
--- a/lib/api/api.rb
+++ b/lib/api/api.rb
@@ -43,6 +43,7 @@ module API
     mount ::API::Groups
     mount ::API::Internal
     mount ::API::Issues
+    mount ::API::Boards
     mount ::API::Keys
     mount ::API::Labels
     mount ::API::LicenseTemplates
diff --git a/lib/api/boards.rb b/lib/api/boards.rb
new file mode 100644
index 00000000000..4d5d144a02e
--- /dev/null
+++ b/lib/api/boards.rb
@@ -0,0 +1,115 @@
+module API
+  # Boards API
+  class Boards < Grape::API
+    before { authenticate! }
+
+    resource :projects do
+      # Get the project board
+      get ':id/boards' do
+        authorize!(:read_board, user_project)
+        present [user_project.board], with: Entities::Board
+      end
+
+      segment ':id/boards/:board_id' do
+        helpers do
+          def project_board
+            board = user_project.board
+            if params[:board_id].to_i == board.id
+              board
+            else
+              not_found!('Board')
+            end
+          end
+
+          def board_lists
+            project_board.lists.destroyable
+          end
+        end
+
+        # Get the lists of a project board
+        # Does not include `backlog` and `done` lists
+        get '/lists' do
+          authorize!(:read_board, user_project)
+          present board_lists, with: Entities::List
+        end
+
+        # Get a list of a project board
+        get '/lists/:list_id' do
+          authorize!(:read_board, user_project)
+          present board_lists.find(params[:list_id]), with: Entities::List
+        end
+
+        # Create a new board list
+        #
+        # Parameters:
+        #   id (required)           - The ID of a project
+        #   label_id (required)     - The ID of an existing label
+        # Example Request:
+        #   POST /projects/:id/boards/:board_id/lists
+        post '/lists' do
+          required_attributes! [:label_id]
+
+          unless user_project.labels.exists?(params[:label_id])
+            render_api_error!({ error: "Label not found!" }, 400)
+          end
+
+          authorize!(:admin_list, user_project)
+
+          list = ::Boards::Lists::CreateService.new(user_project, current_user,
+              { label_id: params[:label_id] }).execute
+
+          if list.valid?
+            present list, with: Entities::List
+          else
+            render_validation_error!(list)
+          end
+        end
+
+        # Moves a board list to a new position
+        #
+        # Parameters:
+        #   id (required) - The ID of a project
+        #   board_id (required) - The ID of a board
+        #   position (required) - The position of the list
+        # Example Request:
+        #   PUT /projects/:id/boards/:board_id/lists/:list_id
+        put '/lists/:list_id' do
+          list = project_board.lists.movable.find(params[:list_id])
+
+          authorize!(:admin_list, user_project)
+
+          moved = ::Boards::Lists::MoveService.new(user_project, current_user,
+              { position: params[:position].to_i }).execute(list)
+
+          if moved
+            present list, with: Entities::List
+          else
+            render_api_error!({ error: "List could not be moved!" }, 400)
+          end
+        end
+
+        # Delete a board list
+        #
+        # Parameters:
+        #   id (required) - The ID of a project
+        #   board_id (required) - The ID of a board
+        #   list_id (required) - The ID of a board list
+        # Example Request:
+        #   DELETE /projects/:id/boards/:board_id/lists/:list_id
+        delete "/lists/:list_id" do
+          list = board_lists.find_by(id: params[:list_id])
+
+          authorize!(:admin_list, user_project)
+
+          if list
+            destroyed_list = ::Boards::Lists::DestroyService.new(
+              user_project, current_user).execute(list)
+            present destroyed_list, with: Entities::List
+          else
+            not_found!('List')
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/lib/api/entities.rb b/lib/api/entities.rb
index 04437322ec1..feaa0c213bf 100644
--- a/lib/api/entities.rb
+++ b/lib/api/entities.rb
@@ -432,8 +432,11 @@ module API
       end
     end
 
-    class Label < Grape::Entity
+    class LabelBasic < Grape::Entity
       expose :name, :color, :description
+    end
+
+    class Label < LabelBasic
       expose :open_issues_count, :closed_issues_count, :open_merge_requests_count
 
       expose :subscribed do |label, options|
@@ -441,6 +444,19 @@ module API
       end
     end
 
+    class List < Grape::Entity
+      expose :id
+      expose :label, using: Entities::LabelBasic
+      expose :position
+    end
+
+    class Board < Grape::Entity
+      expose :id
+      expose :lists, using: Entities::List do |board|
+        board.lists.destroyable
+      end
+    end
+
     class Compare < Grape::Entity
       expose :commit, using: Entities::RepoCommit do |compare, options|
         Commit.decorate(compare.commits, nil).last
diff --git a/spec/requests/api/boards_spec.rb b/spec/requests/api/boards_spec.rb
new file mode 100644
index 00000000000..f4b04445c6c
--- /dev/null
+++ b/spec/requests/api/boards_spec.rb
@@ -0,0 +1,192 @@
+require 'spec_helper'
+
+describe API::API, api: true  do
+  include ApiHelpers
+
+  let(:user)        { create(:user) }
+  let(:user2)       { create(:user) }
+  let(:non_member)  { create(:user) }
+  let(:guest)       { create(:user) }
+  let(:admin)       { create(:user, :admin) }
+  let!(:project)    { create(:project, :public, creator_id: user.id, namespace: user.namespace ) }
+
+  let!(:dev_label) do
+    create(:label, title: 'Development', color: '#FFAABB', project: project)
+  end
+
+  let!(:test_label) do
+    create(:label, title: 'Testing', color: '#FFAACC', project: project)
+  end
+
+  let!(:ux_label) do
+    create(:label, title: 'UX', color: '#FF0000', project: project)
+  end
+
+  let!(:dev_list) do
+    create(:list, label: dev_label, position: 1)
+  end
+
+  let!(:test_list) do
+    create(:list, label: test_label, position: 2)
+  end
+
+  let!(:board) do
+    create(:board, project: project, lists: [dev_list, test_list])
+  end
+
+  before do
+    project.team << [user, :reporter]
+    project.team << [guest, :guest]
+  end
+
+  describe "GET /projects/:id/boards" do
+    let(:base_url) { "/projects/#{project.id}/boards" }
+
+    context "when unauthenticated" do
+      it "returns authentication error" do
+        get api(base_url)
+
+        expect(response).to have_http_status(401)
+      end
+    end
+
+    context "when authenticated" do
+      it "returns the project issue board" do
+        get api(base_url, user)
+
+        expect(response).to have_http_status(200)
+        expect(json_response).to be_an Array
+        expect(json_response.length).to eq(1)
+        expect(json_response.first['id']).to eq(board.id)
+        expect(json_response.first['lists']).to be_an Array
+        expect(json_response.first['lists'].length).to eq(2)
+        expect(json_response.first['lists'].last).to have_key('position')
+      end
+    end
+  end
+
+  describe "GET /projects/:id/boards/:board_id/lists" do
+    let(:base_url) { "/projects/#{project.id}/boards/#{board.id}/lists" }
+
+    it 'returns issue board lists' do
+      get api(base_url, user)
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(json_response.length).to eq(2)
+      expect(json_response.first['label']['name']).to eq(dev_label.title)
+    end
+
+    it 'returns 404 if board not found' do
+      get api("/projects/#{project.id}/boards/22343/lists", user)
+
+      expect(response).to have_http_status(404)
+    end
+  end
+
+  describe "GET /projects/:id/boards/:board_id/lists/:list_id" do
+    let(:base_url) { "/projects/#{project.id}/boards/#{board.id}/lists" }
+
+    it 'returns a list' do
+      get api("#{base_url}/#{dev_list.id}", user)
+
+      expect(response).to have_http_status(200)
+      expect(json_response['id']).to eq(dev_list.id)
+      expect(json_response['label']['name']).to eq(dev_label.title)
+      expect(json_response['position']).to eq(1)
+    end
+
+    it 'returns 404 if list not found' do
+      get api("#{base_url}/5324", user)
+
+      expect(response).to have_http_status(404)
+    end
+  end
+
+  describe "POST /projects/:id/board/lists" do
+    let(:base_url) { "/projects/#{project.id}/boards/#{board.id}/lists" }
+
+    it 'creates a new issue board list' do
+      post api(base_url, user),
+        label_id: ux_label.id
+
+      expect(response).to have_http_status(201)
+      expect(json_response['label']['name']).to eq(ux_label.title)
+      expect(json_response['position']).to eq(3)
+    end
+
+    it 'returns 400 when creating a new list if label_id is invalid' do
+      post api(base_url, user),
+        label_id: 23423
+
+      expect(response).to have_http_status(400)
+    end
+
+    it "returns 403 for project members with guest role" do
+      put api("#{base_url}/#{test_list.id}", guest),
+        position: 1
+
+      expect(response).to have_http_status(403)
+    end
+  end
+
+  describe "PUT /projects/:id/boards/:board_id/lists/:list_id to update only position" do
+    let(:base_url) { "/projects/#{project.id}/boards/#{board.id}/lists" }
+
+    it "updates a list" do
+      put api("#{base_url}/#{test_list.id}", user),
+        position: 1
+
+      expect(response).to have_http_status(200)
+      expect(json_response['position']).to eq(1)
+    end
+
+    it "returns 404 error if list id not found" do
+      put api("#{base_url}/44444", user),
+        position: 1
+
+      expect(response).to have_http_status(404)
+    end
+
+    it "returns 403 for project members with guest role" do
+      put api("#{base_url}/#{test_list.id}", guest),
+        position: 1
+
+      expect(response).to have_http_status(403)
+    end
+  end
+
+  describe "DELETE /projects/:id/board/lists/:list_id" do
+    let(:base_url) { "/projects/#{project.id}/boards/#{board.id}/lists" }
+
+    it "rejects a non member from deleting a list" do
+      delete api("#{base_url}/#{dev_list.id}", non_member)
+
+      expect(response).to have_http_status(403)
+    end
+
+    it "rejects a user with guest role from deleting a list" do
+      delete api("#{base_url}/#{dev_list.id}", guest)
+
+      expect(response).to have_http_status(403)
+    end
+
+    it "returns 404 error if list id not found" do
+      delete api("#{base_url}/44444", user)
+
+      expect(response).to have_http_status(404)
+    end
+
+    context "when the user is project owner" do
+      let(:owner)     { create(:user) }
+      let(:project)   { create(:project, namespace: owner.namespace) }
+
+      it "deletes the list if an admin requests it" do
+        delete api("#{base_url}/#{dev_list.id}", owner)
+
+        expect(response).to have_http_status(200)
+        expect(json_response['position']).to eq(1)
+      end
+    end
+  end
+end
-- 
GitLab