diff --git a/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/data_table/data_table.vue b/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/data_table/data_table.vue index 72192b005af0b539e01e28cc8eb1cf885570a2e2..c6262bd4802815c44b9dfb365eeeff0482bc4e4b 100644 --- a/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/data_table/data_table.vue +++ b/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/data_table/data_table.vue @@ -23,6 +23,7 @@ export default { ChangePercentageIndicator: () => import('./change_percentage_indicator.vue'), MetricLabel: () => import('./metric_label.vue'), TrendLine: () => import('./trend_line.vue'), + UserLink: () => import('./user_link.vue'), }, props: { data: { diff --git a/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/data_table/user_link.stories.js b/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/data_table/user_link.stories.js new file mode 100644 index 0000000000000000000000000000000000000000..d65e7b81bf06889456ff12898067095f3a35af6f --- /dev/null +++ b/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/data_table/user_link.stories.js @@ -0,0 +1,66 @@ +import DataTable from './data_table.vue'; +import UserLink from './user_link.vue'; + +export default { + component: UserLink, + title: 'ee/analytics/analytics_dashboards/components/visualizations/data_table/user_link', +}; + +const firstChild = { + user: { + name: 'Ayanami Rei', + avatarUrl: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + username: 'ramiel', + webUrl: 'https://gitlab.com/fakeuser', + }, +}; + +const nodes = [ + firstChild, + { + user: { + name: 'Shikinami-Langley Asuka', + avatarUrl: + 'https://www.gravatar.com/avatar/c4ab964b90c3049c47882b319d3c5cc0?s=80\u0026d=identicon', + username: 'sachiel', + webUrl: 'https://gitlab.com/fakeuser', + }, + }, + { + user: { + name: 'Makinami Mari', + avatarUrl: + 'https://www.gravatar.com/avatar/00afb8fb6ab07c3ee3e9c1f38777e2f4?s=80&d=identicon', + username: 'leliel', + webUrl: 'https://gitlab.com/fakeuser', + }, + }, +]; + +const Template = (args, { argTypes }) => ({ + components: { UserLink }, + props: Object.keys(argTypes), + template: ``, +}); + +const TableTemplate = (args, { argTypes }) => ({ + components: { DataTable, UserLink }, + props: Object.keys(argTypes), + template: ``, +}); + +export const Default = Template.bind({}); +Default.args = { ...firstChild.user }; + +export const InTable = TableTemplate.bind({}); +InTable.args = { + data: { + nodes, + }, + options: { + fields: [ + { key: 'user.name', label: 'Name' }, + { key: 'user', label: 'User', component: 'UserLink' }, + ], + }, +}; diff --git a/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/data_table/user_link.vue b/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/data_table/user_link.vue new file mode 100644 index 0000000000000000000000000000000000000000..9cfb59e934fdeeb978ccbdd9a299b0f4b075454e --- /dev/null +++ b/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/data_table/user_link.vue @@ -0,0 +1,43 @@ + + diff --git a/ee/app/assets/javascripts/analytics/analytics_dashboards/data_sources/index.js b/ee/app/assets/javascripts/analytics/analytics_dashboards/data_sources/index.js index 287ea8c16509595882dcd21362fd87867b4b3475..735ab7fb17cf50fd07652169f77b67406b569de3 100644 --- a/ee/app/assets/javascripts/analytics/analytics_dashboards/data_sources/index.js +++ b/ee/app/assets/javascripts/analytics/analytics_dashboards/data_sources/index.js @@ -49,4 +49,5 @@ export default { code_suggestions_acceptance_by_language: () => import('./code_suggestions_acceptance_by_language'), code_generation_volume_over_time: () => import('./code_generation_volume_over_time'), + user_ai_usage_data: () => import('./user_ai_usage_data'), }; diff --git a/ee/app/assets/javascripts/analytics/analytics_dashboards/data_sources/user_ai_usage_data.js b/ee/app/assets/javascripts/analytics/analytics_dashboards/data_sources/user_ai_usage_data.js new file mode 100644 index 0000000000000000000000000000000000000000..2632104251842f9a78ae164c8f611aec9cc67b46 --- /dev/null +++ b/ee/app/assets/javascripts/analytics/analytics_dashboards/data_sources/user_ai_usage_data.js @@ -0,0 +1,29 @@ +import UserAiUserMetricsQuery from 'ee/analytics/analytics_dashboards/graphql/queries/get_user_ai_user_metrics.query.graphql'; +import { startOfTomorrow } from 'ee/analytics/dora/components/static_data/shared'; +import { getStartDate } from 'ee/analytics/analytics_dashboards/components/filters/utils'; +import { DATE_RANGE_OPTION_LAST_30_DAYS } from 'ee/analytics/analytics_dashboards/components/filters/constants'; +import { extractQueryResponseFromNamespace } from '~/analytics/shared/utils'; +import { defaultClient } from '../graphql/client'; + +export default async function fetch({ + namespace: fullPath, + query: { dateRange = DATE_RANGE_OPTION_LAST_30_DAYS }, + queryOverrides: { pagination = { first: 20 } } = {}, +}) { + const startDate = getStartDate(dateRange); + + const response = await defaultClient.query({ + query: UserAiUserMetricsQuery, + variables: { + fullPath, + startDate, + endDate: startOfTomorrow, + first: pagination.first, + last: pagination.last, + before: pagination.startCursor, + after: pagination.endCursor, + }, + }); + + return extractQueryResponseFromNamespace({ result: response, resultKey: 'aiUserMetrics' }); +} diff --git a/ee/app/assets/javascripts/analytics/analytics_dashboards/graphql/fragments/ai_user_metric_item.fragment.graphql b/ee/app/assets/javascripts/analytics/analytics_dashboards/graphql/fragments/ai_user_metric_item.fragment.graphql new file mode 100644 index 0000000000000000000000000000000000000000..2ba2c7b951f6fda6160b4fbdd1b207b9ef59b4d7 --- /dev/null +++ b/ee/app/assets/javascripts/analytics/analytics_dashboards/graphql/fragments/ai_user_metric_item.fragment.graphql @@ -0,0 +1,12 @@ +fragment AiUserMetricItem on AiUserMetrics { + user { + id + name + avatarUrl + username + webUrl + lastDuoActivityOn + } + codeSuggestionsAcceptedCount + duoChatInteractionsCount +} diff --git a/ee/app/assets/javascripts/analytics/analytics_dashboards/graphql/queries/get_user_ai_user_metrics.query.graphql b/ee/app/assets/javascripts/analytics/analytics_dashboards/graphql/queries/get_user_ai_user_metrics.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..7ac4f3621686ffd975514c7443c726f4276f01c6 --- /dev/null +++ b/ee/app/assets/javascripts/analytics/analytics_dashboards/graphql/queries/get_user_ai_user_metrics.query.graphql @@ -0,0 +1,49 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" +#import "../fragments/ai_user_metric_item.fragment.graphql" + +query getUserAiUserMetrics( + $fullPath: ID! + $startDate: Date! + $endDate: Date! + $before: String + $after: String + $last: Int + $first: Int +) { + group(fullPath: $fullPath) { + id + aiUserMetrics( + startDate: $startDate + endDate: $endDate + after: $after + before: $before + first: $first + last: $last + ) { + nodes { + ...AiUserMetricItem + } + pageInfo { + ...PageInfo + } + } + } + project(fullPath: $fullPath) { + id + aiUserMetrics( + startDate: $startDate + endDate: $endDate + after: $after + before: $before + first: $first + last: $last + ) { + nodes { + ...AiUserMetricItem + } + pageInfo { + ...PageInfo + } + } + } +} diff --git a/ee/app/models/analytics/dashboards/visualization.rb b/ee/app/models/analytics/dashboards/visualization.rb index 5396ce0077357ea41efa832e2d5bc6395480ed92..d9cc40491d3cc064abe78ccc301292696a0b4ed4 100644 --- a/ee/app/models/analytics/dashboards/visualization.rb +++ b/ee/app/models/analytics/dashboards/visualization.rb @@ -70,6 +70,7 @@ class Visualization pipeline_metrics_table code_suggestions_acceptance_rate_by_language_chart code_generation_volume_trends_chart + user_metrics_table ].freeze CONTRIBUTIONS_DASHBOARD_PATH = 'ee/lib/gitlab/analytics/contributions_dashboard/visualizations' diff --git a/ee/app/validators/json_schemas/analytics_visualization.json b/ee/app/validators/json_schemas/analytics_visualization.json index 25b56765a265e0ce57dd768616e93111f9272d3d..4d9d3e803738eb7ed04f36850f0e68aefda6c414 100644 --- a/ee/app/validators/json_schemas/analytics_visualization.json +++ b/ee/app/validators/json_schemas/analytics_visualization.json @@ -79,7 +79,8 @@ "merge_request_counts", "mean_time_to_merge", "code_suggestions_acceptance_by_language", - "code_generation_volume_over_time" + "code_generation_volume_over_time", + "user_ai_usage_data" ] }, "query": { @@ -146,7 +147,8 @@ "MergeRequestLink", "ChangePercentageIndicator", "MetricLabel", - "TrendLine" + "TrendLine", + "UserLink" ] } } diff --git a/ee/lib/gitlab/analytics/ai_impact_dashboard/dashboard.yaml b/ee/lib/gitlab/analytics/ai_impact_dashboard/dashboard.yaml index 33dd51c5d9b4e790441a4dfebe1413a4a71e8949..a06a454f69e096b0a7f8b68a62e14dce34af9fc5 100644 --- a/ee/lib/gitlab/analytics/ai_impact_dashboard/dashboard.yaml +++ b/ee/lib/gitlab/analytics/ai_impact_dashboard/dashboard.yaml @@ -74,3 +74,10 @@ panels: width: 12 height: 4 options: {} + - title: 'Code Suggestions accepted by user' + visualization: user_metrics_table + gridAttributes: + yPos: 20 + xPos: 0 + width: 12 + height: 12 diff --git a/ee/lib/gitlab/analytics/ai_impact_dashboard/visualizations/user_metrics_table.yaml b/ee/lib/gitlab/analytics/ai_impact_dashboard/visualizations/user_metrics_table.yaml new file mode 100644 index 0000000000000000000000000000000000000000..19852c82302b7f82d90c6da4a49ec6f60f567911 --- /dev/null +++ b/ee/lib/gitlab/analytics/ai_impact_dashboard/visualizations/user_metrics_table.yaml @@ -0,0 +1,16 @@ +--- +version: 1 +type: DataTable +data: + type: user_ai_usage_data + query: {} +options: + fields: + - key: 'user' + component: 'UserLink' + - key: 'user.lastDuoActivityOn' + label: 'Last duo activity' + - key: 'codeSuggestionsAcceptedCount' + label: 'Code suggestions accepted' + thClass: "gl-text-right" + tdClass: "gl-text-right" diff --git a/ee/spec/frontend/analytics/analytics_dashboards/components/data_sources/__snapshots__/user_ai_usage_data_spec.js.snap b/ee/spec/frontend/analytics/analytics_dashboards/components/data_sources/__snapshots__/user_ai_usage_data_spec.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..d373444b5a3635f940890486b679d37f67d12528 --- /dev/null +++ b/ee/spec/frontend/analytics/analytics_dashboards/components/data_sources/__snapshots__/user_ai_usage_data_spec.js.snap @@ -0,0 +1,43 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`User ai usage data source with data available returns data and pagination information 1`] = ` +{ + "nodes": [ + { + "__typename": "AiUserMetrics", + "codeSuggestionsAcceptedCount": 0, + "duoChatInteractionsCount": 0, + "user": { + "__typename": "AddOnUser", + "avatarUrl": "https://www.gravatar.com/avatar/3dd3516a13d37b25b66c7915ab9bedfe7b20ffaf32da3f50c3cb5ee9f3f8aba6?s=80&d=identicon", + "id": "gid://gitlab/User/106", + "lastDuoActivityOn": "2025-10-01", + "name": "P Dawn Turner", + "username": "p-user-dawn-turner-e5c194af9850", + "webUrl": "http://gdk.test:3001/p-user-dawn-turner-e5c194af9850", + }, + }, + { + "__typename": "AiUserMetrics", + "codeSuggestionsAcceptedCount": 0, + "duoChatInteractionsCount": 0, + "user": { + "__typename": "AddOnUser", + "avatarUrl": "https://www.gravatar.com/avatar/142fcdcab9580b9edd2b623335dde4e6559a628daea89773acf60ba419da66bb?s=80&d=identicon", + "id": "gid://gitlab/User/105", + "lastDuoActivityOn": "2025-10-04", + "name": "P Homer Hodkiewicz", + "username": "p-user-homer-hodkiewicz-e5c194af9850", + "webUrl": "http://gdk.test:3001/p-user-homer-hodkiewicz-e5c194af9850", + }, + }, + ], + "pageInfo": { + "__typename": "PageInfo", + "endCursor": "this-is-an-end-cursor", + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "this-is-a-start-cursor", + }, +} +`; diff --git a/ee/spec/frontend/analytics/analytics_dashboards/components/data_sources/user_ai_usage_data_spec.js b/ee/spec/frontend/analytics/analytics_dashboards/components/data_sources/user_ai_usage_data_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..4374374a656a786bd5dd53b4e2375f89fd9880a9 --- /dev/null +++ b/ee/spec/frontend/analytics/analytics_dashboards/components/data_sources/user_ai_usage_data_spec.js @@ -0,0 +1,163 @@ +import fetch from 'ee/analytics/analytics_dashboards/data_sources/user_ai_usage_data'; +import { defaultClient } from 'ee/analytics/analytics_dashboards/graphql/client'; + +import { + DATE_RANGE_OPTION_LAST_30_DAYS, + DATE_RANGE_OPTION_LAST_60_DAYS, +} from 'ee/analytics/analytics_dashboards/components/filters/constants'; + +const mockPageInfo = { + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'this-is-a-start-cursor', + endCursor: 'this-is-an-end-cursor', + __typename: 'PageInfo', +}; + +const mockUserAiMetrics = [ + { + user: { + id: 'gid://gitlab/User/106', + name: 'P Dawn Turner', + avatarUrl: + 'https://www.gravatar.com/avatar/3dd3516a13d37b25b66c7915ab9bedfe7b20ffaf32da3f50c3cb5ee9f3f8aba6?s=80\u0026d=identicon', + username: 'p-user-dawn-turner-e5c194af9850', + webUrl: 'http://gdk.test:3001/p-user-dawn-turner-e5c194af9850', + lastDuoActivityOn: '2025-10-01', + __typename: 'AddOnUser', + }, + codeSuggestionsAcceptedCount: 0, + duoChatInteractionsCount: 0, + __typename: 'AiUserMetrics', + }, + { + user: { + id: 'gid://gitlab/User/105', + name: 'P Homer Hodkiewicz', + avatarUrl: + 'https://www.gravatar.com/avatar/142fcdcab9580b9edd2b623335dde4e6559a628daea89773acf60ba419da66bb?s=80\u0026d=identicon', + username: 'p-user-homer-hodkiewicz-e5c194af9850', + webUrl: 'http://gdk.test:3001/p-user-homer-hodkiewicz-e5c194af9850', + lastDuoActivityOn: '2025-10-04', + __typename: 'AddOnUser', + }, + codeSuggestionsAcceptedCount: 0, + duoChatInteractionsCount: 0, + __typename: 'AiUserMetrics', + }, +]; + +const mockUserAiUsageDataResponseData = { + aiUserMetrics: { + nodes: mockUserAiMetrics, + pageInfo: mockPageInfo, + }, +}; + +const mockResolvedQuery = ({ aiUserMetrics = [] } = {}) => + jest.spyOn(defaultClient, 'query').mockResolvedValue({ data: { project: { aiUserMetrics } } }); + +const expectQueryWithVariables = (variables) => + expect(defaultClient.query).toHaveBeenCalledWith( + expect.objectContaining({ + variables: expect.objectContaining({ + ...variables, + }), + }), + ); + +describe('User ai usage data source', () => { + let res; + + const namespace = 'test-namespace'; + const defaultQueryParams = { + dateRange: DATE_RANGE_OPTION_LAST_30_DAYS, + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('can override default query parameters', async () => { + mockResolvedQuery(); + + res = await fetch({ + namespace, + query: { + ...defaultQueryParams, + dateRange: DATE_RANGE_OPTION_LAST_60_DAYS, + }, + }); + + expectQueryWithVariables({ + fullPath: namespace, + startDate: new Date('2020-05-08'), + endDate: new Date('2020-07-07'), + }); + + expect(defaultClient.query).toHaveBeenCalledTimes(1); + }); + + it('can override default pagination', async () => { + mockResolvedQuery(); + + res = await fetch({ + namespace, + query: { + ...defaultQueryParams, + }, + queryOverrides: { + pagination: { startCursor: 'start' }, + }, + }); + + expectQueryWithVariables({ + fullPath: namespace, + startDate: new Date('2020-06-07'), + endDate: new Date('2020-07-07'), + before: 'start', + }); + + expect(defaultClient.query).toHaveBeenCalledTimes(1); + }); + + describe('with data available', () => { + beforeEach(async () => { + mockResolvedQuery(mockUserAiUsageDataResponseData); + + res = await fetch({ + namespace, + query: defaultQueryParams, + }); + }); + + it('sets the correct query parameters', () => { + expectQueryWithVariables({ + fullPath: namespace, + startDate: new Date('2020-06-07'), + endDate: new Date('2020-07-07'), + }); + + expect(defaultClient.query).toHaveBeenCalledTimes(1); + }); + + it('returns data and pagination information', () => { + expect(res).toMatchSnapshot(); + }); + }); + + describe('with no data available', () => { + beforeEach(async () => { + mockResolvedQuery(); + + res = await fetch({ + namespace, + query: defaultQueryParams, + }); + }); + + it('returns an empty array', () => { + expect(res).toHaveLength(0); + }); + }); +}); diff --git a/ee/spec/frontend/analytics/analytics_dashboards/components/visualizations/data_table/user_link_spec.js b/ee/spec/frontend/analytics/analytics_dashboards/components/visualizations/data_table/user_link_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..57d97f44ee11ad93978e6e9000141de5c9168a58 --- /dev/null +++ b/ee/spec/frontend/analytics/analytics_dashboards/components/visualizations/data_table/user_link_spec.js @@ -0,0 +1,42 @@ +import { GlAvatarLabeled, GlAvatarLink } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import UserLink from 'ee/analytics/analytics_dashboards/components/visualizations/data_table/user_link.vue'; + +const mockData = { + name: 'Shikinami-Langley Asuka', + avatarUrl: + 'https://www.gravatar.com/avatar/c4ab964b90c3049c47882b319d3c5cc0?s=80\u0026d=identicon', + username: 'sachiel', + webUrl: 'https://gitlab.com/fakeuser', +}; + +describe('UserLink', () => { + let wrapper; + + const findAvatarLink = () => wrapper.findComponent(GlAvatarLink); + const findLabeledAvatar = () => wrapper.findComponent(GlAvatarLabeled); + + describe('default', () => { + beforeEach(() => { + wrapper = shallowMountExtended(UserLink, { + propsData: { ...mockData }, + }); + }); + + it('renders the avatar link', () => { + expect(findAvatarLink().attributes()).toEqual({ + href: 'https://gitlab.com/fakeuser', + target: 'blank', + }); + }); + + it('renders the labeled avatar', () => { + expect(findLabeledAvatar().attributes()).toMatchObject({ + label: 'Shikinami-Langley Asuka', + sublabel: '@sachiel', + alt: 'sachiel avatar', + size: '32', + }); + }); + }); +}); diff --git a/ee/spec/models/analytics/dashboards/visualization_spec.rb b/ee/spec/models/analytics/dashboards/visualization_spec.rb index 8e308e63f54d638414994f17de6cead32f9f246e..e3c13c9f41488145d3f1b6c93311c70f99405173 100644 --- a/ee/spec/models/analytics/dashboards/visualization_spec.rb +++ b/ee/spec/models/analytics/dashboards/visualization_spec.rb @@ -51,6 +51,7 @@ pipeline_metrics_table code_suggestions_acceptance_rate_by_language_chart code_generation_volume_trends_chart + user_metrics_table ] end diff --git a/ee/spec/requests/api/graphql/analytics/dashboards/visualizations_spec.rb b/ee/spec/requests/api/graphql/analytics/dashboards/visualizations_spec.rb index 66a53310045f3efc9bbb4b79f458718d56a4c3ff..da3c9d403f7d9b4a28dabf51f37b08075d919f30 100644 --- a/ee/spec/requests/api/graphql/analytics/dashboards/visualizations_spec.rb +++ b/ee/spec/requests/api/graphql/analytics/dashboards/visualizations_spec.rb @@ -74,6 +74,7 @@ 6 | 'AiImpactTable' | 'Pipeline metrics for the %{namespaceName} %{namespaceType}' 7 | 'BarChart' | 'Code suggestions acceptance rate by language (Last 30 days)' 8 | 'AreaChart' | 'Code generation volume trends (Last 180 days)' + 9 | 'DataTable' | 'Code Suggestions accepted by user' end with_them do