diff options
-rw-r--r-- | CHANGELOG | 1 | ||||
-rw-r--r-- | doc/api/boards.md | 251 | ||||
-rw-r--r-- | lib/api/api.rb | 1 | ||||
-rw-r--r-- | lib/api/boards.rb | 115 | ||||
-rw-r--r-- | lib/api/entities.rb | 18 | ||||
-rw-r--r-- | spec/requests/api/boards_spec.rb | 192 |
6 files changed, 577 insertions, 1 deletions
diff --git a/CHANGELOG b/CHANGELOG index e3b85b20ed0..da89e591ef5 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -19,6 +19,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 |