diff --git a/app/graphql/types/diff_stats_summary_type.rb b/app/graphql/types/diff_stats_summary_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..5920f3de3da6fcea86dd6634fc7b9e94a3d0c798 --- /dev/null +++ b/app/graphql/types/diff_stats_summary_type.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Types + # rubocop: disable Graphql/AuthorizeTypes + # Types that use DiffStatsType should have their own authorization + class DiffStatsSummaryType < BaseObject + graphql_name 'DiffStatsSummary' + + description 'Aggregated summary of changes' + + field :additions, GraphQL::INT_TYPE, null: false, + description: 'Number of lines added' + field :deletions, GraphQL::INT_TYPE, null: false, + description: 'Number of lines deleted' + field :changes, GraphQL::INT_TYPE, null: false, + description: 'Number of lines changed' + + def changes + object[:additions] + object[:deletions] + end + end + # rubocop: enable Graphql/AuthorizeTypes +end diff --git a/app/graphql/types/diff_stats_type.rb b/app/graphql/types/diff_stats_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..6c79a4c389d17f312dbf8b404a7b1d1cbcea80c4 --- /dev/null +++ b/app/graphql/types/diff_stats_type.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + # rubocop: disable Graphql/AuthorizeTypes + # Types that use DiffStatsType should have their own authorization + class DiffStatsType < BaseObject + graphql_name 'DiffStats' + + description 'Changes to a single file' + + field :path, GraphQL::STRING_TYPE, null: false, + description: 'File path, relative to repository root' + field :additions, GraphQL::INT_TYPE, null: false, + description: 'Number of lines added to this file' + field :deletions, GraphQL::INT_TYPE, null: false, + description: 'Number of lines deleted from this file' + end + # rubocop: enable Graphql/AuthorizeTypes +end diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index cb4ff7ea0c57ba7a40eadd2f47cc4afbe95bc96e..5c1f82cdf1d49723ce184622055a652007241205 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -54,6 +54,13 @@ class MergeRequestType < BaseObject description: 'Indicates if the merge has been set to be merged when its pipeline succeeds (MWPS)' field :diff_head_sha, GraphQL::STRING_TYPE, null: true, description: 'Diff head SHA of the merge request' + field :diff_stats, [Types::DiffStatsType], null: true, calls_gitaly: true, + description: 'Details about which files were changed in this merge request' do + argument :path, GraphQL::STRING_TYPE, required: false, description: 'A specific file-path' + end + + field :diff_stats_summary, Types::DiffStatsSummaryType, null: true, calls_gitaly: true, + description: 'Summary of which files were changed in this merge request' field :merge_commit_sha, GraphQL::STRING_TYPE, null: true, description: 'SHA of the merge request commit (set once merged)' field :user_notes_count, GraphQL::INT_TYPE, null: true, @@ -134,5 +141,24 @@ class MergeRequestType < BaseObject end field :task_completion_status, Types::TaskCompletionStatus, null: false, description: Types::TaskCompletionStatus.description + + def diff_stats(path: nil) + stats = Array.wrap(object.diff_stats&.to_a) + + if path.present? + stats.select { |s| s.path == path } + else + stats + end + end + + def diff_stats_summary + nil_stats = { additions: 0, deletions: 0 } + return nil_stats unless object.diff_stats.present? + + object.diff_stats.each_with_object(nil_stats) do |status, hash| + hash.merge!(additions: status.additions, deletions: status.deletions) { |_, x, y| x + y } + end + end end end diff --git a/changelogs/unreleased/ajk-gql-mr-diff-stats.yml b/changelogs/unreleased/ajk-gql-mr-diff-stats.yml new file mode 100644 index 0000000000000000000000000000000000000000..430919472741061c9902fb02f43b1e03512f6283 --- /dev/null +++ b/changelogs/unreleased/ajk-gql-mr-diff-stats.yml @@ -0,0 +1,5 @@ +--- +title: Add diff stats fields to merge request type +merge_request: 34966 +author: +type: added diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index 502dac666ee1d0d8a008277ccea75fb511ed4095..bfe46f86de1a953bf81bd26cce1b931bdf9e4561 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -3076,6 +3076,46 @@ type DiffRefs { startSha: String! } +""" +Changes to a single file +""" +type DiffStats { + """ + Number of lines added to this file + """ + additions: Int! + + """ + Number of lines deleted from this file + """ + deletions: Int! + + """ + File path, relative to repository root + """ + path: String! +} + +""" +Aggregated summary of changes +""" +type DiffStatsSummary { + """ + Number of lines added + """ + additions: Int! + + """ + Number of lines changed + """ + changes: Int! + + """ + Number of lines deleted + """ + deletions: Int! +} + type Discussion implements ResolvableInterface { """ Timestamp of the discussion's creation @@ -6665,6 +6705,21 @@ type MergeRequest implements Noteable { """ diffRefs: DiffRefs + """ + Details about which files were changed in this merge request + """ + diffStats( + """ + A specific file-path + """ + path: String + ): [DiffStats!] + + """ + Summary of which files were changed in this merge request + """ + diffStatsSummary: DiffStatsSummary + """ Indicates if comments on the merge request are locked to members only """ diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index aecc533931e9483d5ddde679004b7ce307a556d9..f2613304ef2367866f910ba8eb06e826861199de 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -8508,6 +8508,140 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "DiffStats", + "description": "Changes to a single file", + "fields": [ + { + "name": "additions", + "description": "Number of lines added to this file", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "deletions", + "description": "Number of lines deleted from this file", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "path", + "description": "File path, relative to repository root", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DiffStatsSummary", + "description": "Aggregated summary of changes", + "fields": [ + { + "name": "additions", + "description": "Number of lines added", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "changes", + "description": "Number of lines changed", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "deletions", + "description": "Number of lines deleted", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "Discussion", @@ -18512,6 +18646,51 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "diffStats", + "description": "Details about which files were changed in this merge request", + "args": [ + { + "name": "path", + "description": "A specific file-path", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DiffStats", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "diffStatsSummary", + "description": "Summary of which files were changed in this merge request", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "DiffStatsSummary", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "discussionLocked", "description": "Indicates if comments on the merge request are locked to members only", diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index bba6cd98b2763d582a9c9d415b0544aba7306438..87479a08f17e58d0d37bc1c5755f502debee9b2a 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -517,6 +517,26 @@ Autogenerated return type of DestroySnippet | `headSha` | String! | SHA of the HEAD at the time the comment was made | | `startSha` | String! | SHA of the branch being compared against | +## DiffStats + +Changes to a single file + +| Name | Type | Description | +| --- | ---- | ---------- | +| `additions` | Int! | Number of lines added to this file | +| `deletions` | Int! | Number of lines deleted from this file | +| `path` | String! | File path, relative to repository root | + +## DiffStatsSummary + +Aggregated summary of changes + +| Name | Type | Description | +| --- | ---- | ---------- | +| `additions` | Int! | Number of lines added | +| `changes` | Int! | Number of lines changed | +| `deletions` | Int! | Number of lines deleted | + ## Discussion | Name | Type | Description | @@ -1002,6 +1022,8 @@ Autogenerated return type of MarkAsSpamSnippet | `descriptionHtml` | String | The GitLab Flavored Markdown rendering of `description` | | `diffHeadSha` | String | Diff head SHA of the merge request | | `diffRefs` | DiffRefs | References of the base SHA, the head SHA, and the start SHA for this merge request | +| `diffStats` | DiffStats! => Array | Details about which files were changed in this merge request | +| `diffStatsSummary` | DiffStatsSummary | Summary of which files were changed in this merge request | | `discussionLocked` | Boolean! | Indicates if comments on the merge request are locked to members only | | `downvotes` | Int! | Number of downvotes for the merge request | | `forceRemoveSourceBranch` | Boolean | Indicates if the project settings will lead to source branch deletion after merge | diff --git a/spec/graphql/types/merge_request_type_spec.rb b/spec/graphql/types/merge_request_type_spec.rb index 6c3817392cd327f0df1a6709d934cf84ef6642d6..b3dccde8ce3f092af0cf3a72c77522ce6c5a051b 100644 --- a/spec/graphql/types/merge_request_type_spec.rb +++ b/spec/graphql/types/merge_request_type_spec.rb @@ -15,7 +15,8 @@ description_html state created_at updated_at source_project target_project project project_id source_project_id target_project_id source_branch target_branch work_in_progress merge_when_pipeline_succeeds diff_head_sha - merge_commit_sha user_notes_count should_remove_source_branch diff_refs + merge_commit_sha user_notes_count should_remove_source_branch + diff_refs diff_stats diff_stats_summary force_remove_source_branch merge_status in_progress_merge_commit_sha merge_error allow_collaboration should_be_rebased rebase_commit_sha rebase_in_progress merge_commit_message default_merge_commit_message diff --git a/spec/requests/api/graphql/project/merge_request_spec.rb b/spec/requests/api/graphql/project/merge_request_spec.rb index f4f1cfaaab5ba5047ee7cf2cb2c15f37e759082f..068be1fa300e4823ad83c6811afb3a9d54934396 100644 --- a/spec/requests/api/graphql/project/merge_request_spec.rb +++ b/spec/requests/api/graphql/project/merge_request_spec.rb @@ -43,6 +43,54 @@ expect(merge_request_graphql_data['author']['username']).to eq(merge_request.author.username) end + it 'includes diff stats' do + be_natural = an_instance_of(Integer).and(be >= 0) + + post_graphql(query, current_user: current_user) + + sums = merge_request_graphql_data['diffStats'].reduce([0, 0, 0]) do |(a, d, c), node| + a_, d_ = node.values_at('additions', 'deletions') + [a + a_, d + d_, c + a_ + d_] + end + + expect(merge_request_graphql_data).to include( + 'diffStats' => all(a_hash_including('path' => String, 'additions' => be_natural, 'deletions' => be_natural)), + 'diffStatsSummary' => a_hash_including('additions' => be_natural, 'deletions' => be_natural, 'changes' => be_natural) + ) + + # diff_stats is consistent with summary + expect(merge_request_graphql_data['diffStatsSummary'] + .values_at('additions', 'deletions', 'changes')).to eq(sums) + + # diff_stats_summary is internally consistent + expect(merge_request_graphql_data['diffStatsSummary'] + .values_at('additions', 'deletions').sum) + .to eq(merge_request_graphql_data.dig('diffStatsSummary', 'changes')) + .and be_positive + end + + context 'requesting a specific diff stat' do + let(:diff_stat) { merge_request.diff_stats.first } + + let(:query) do + graphql_query_for(:project, { full_path: project.full_path }, + query_graphql_field(:merge_request, { iid: merge_request.iid.to_s }, [ + query_graphql_field(:diff_stats, { path: diff_stat.path }, all_graphql_fields_for('DiffStats')) + ]) + ) + end + + it 'includes only the requested stats' do + post_graphql(query, current_user: current_user) + + expect(merge_request_graphql_data).to include( + 'diffStats' => contain_exactly( + a_hash_including('path' => diff_stat.path, 'additions' => diff_stat.additions, 'deletions' => diff_stat.deletions) + ) + ) + end + end + it 'includes correct mergedAt value when merged' do time = 1.week.ago merge_request.mark_as_merged