From cf007baf1b04c8c7b99348d52625a9fc8d409dc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Caplette?= Date: Wed, 10 Dec 2025 13:40:20 -0500 Subject: [PATCH 1/5] Show composite accounts as agents When the composite_identity_enforced property is true, we show an Agent badge in the assignee dropdown and in the user autocomplete. --- app/assets/javascripts/gfm_auto_complete.js | 31 +++++++++- ...workspace_autocomplete_users.query.graphql | 2 + .../assignees/sidebar_participant.vue | 13 ++++- .../concerns/users/participable_service.rb | 6 +- locale/gitlab.pot | 6 ++ spec/frontend/gfm_auto_complete_spec.js | 56 +++++++++++++++++++ .../assignees/sidebar_participant_spec.js | 38 ++++++++++--- spec/frontend/sidebar/mock_data.js | 21 ++++++- .../work_item_bulk_edit_assignee_spec.js | 1 + spec/frontend/work_items/mock_data.js | 2 + .../projects/participants_service_spec.rb | 9 ++- 11 files changed, 167 insertions(+), 18 deletions(-) diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 4cc34d2c984839..ce615ee03a61cf 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -58,6 +58,21 @@ const busyBadge = memoize( ).outerHTML, ); +const agentBadge = memoize( + () => + renderVueComponentForLegacyJS( + GlBadge, + { + class: 'gl-ml-2', + props: { + variant: 'neutral', + size: 'sm', + }, + }, + s__('UserProfile|Agent'), + ).outerHTML, +); + // Re-export for existing imports elsewhere export { sortCommandsAlphaSafe } from '~/editor/quick_action_suggestions'; // Frequent quick actions prioritization is imported from shared module @@ -133,6 +148,7 @@ export function membersBeforeSave(members) { search: createMemberSearchString(member), icon: avatarIcon, availability: member?.availability, + compositeIdentityEnforced: member.composite_identity_enforced, }; }); } @@ -489,7 +505,7 @@ class GfmAutoComplete { maxLen: 100, displayTpl(value) { let tmpl = GfmAutoComplete.Loading.template; - const { avatarTag, username, title, icon, availability } = value; + const { avatarTag, username, title, icon, availability, compositeIdentityEnforced } = value; if (username != null) { tmpl = GfmAutoComplete.Members.templateFunction({ avatarTag, @@ -497,6 +513,7 @@ class GfmAutoComplete { title, icon, availabilityStatus: availability && isUserBusy(availability) ? busyBadge() : '', + compositeIdentityEnforced, }); } return tmpl; @@ -1389,10 +1406,18 @@ GfmAutoComplete.Emoji = { }; // Team Members GfmAutoComplete.Members = { - templateFunction({ avatarTag, username, title, icon, availabilityStatus }) { + templateFunction({ + avatarTag, + username, + title, + icon, + availabilityStatus, + compositeIdentityEnforced, + }) { + const compositeIdBadge = compositeIdentityEnforced ? agentBadge() : ''; return `
  • ${avatarTag} ${username} ${escape( title, - )}${availabilityStatus} ${icon}
  • `; + )}${availabilityStatus}${compositeIdBadge} ${icon}`; }, nameOrUsernameStartsWith(member, query) { // `member.search` is a name:username string like `MargeSimpson msimpson` diff --git a/app/assets/javascripts/graphql_shared/queries/workspace_autocomplete_users.query.graphql b/app/assets/javascripts/graphql_shared/queries/workspace_autocomplete_users.query.graphql index aa7ac960d37d4b..f223523a23bdbb 100644 --- a/app/assets/javascripts/graphql_shared/queries/workspace_autocomplete_users.query.graphql +++ b/app/assets/javascripts/graphql_shared/queries/workspace_autocomplete_users.query.graphql @@ -9,6 +9,7 @@ query workspaceAutocompleteUsersSearch( groupWorkspace: group(fullPath: $fullPath) @skip(if: $isProject) { id users: autocompleteUsers(search: $search) { + compositeIdentityEnforced ...User ...UserAvailability } @@ -16,6 +17,7 @@ query workspaceAutocompleteUsersSearch( workspace: project(fullPath: $fullPath) { id users: autocompleteUsers(search: $search) { + compositeIdentityEnforced ...User ...UserAvailability } diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue index 826731f1467a62..85781d8520b818 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue @@ -34,12 +34,16 @@ export default { isBusy() { return this.user?.status?.availability === AVAILABILITY_STATUS.BUSY; }, + isAgent() { + return this.user?.compositeIdentityEnforced; + }, hasCannotMergeIcon() { return this.issuableType === TYPE_MERGE_REQUEST && !this.user.canMerge; }, }, i18n: { busy: __('Busy'), + agent: __('Agent'), }, }; @@ -61,9 +65,14 @@ export default { :class="{ '!gl-left-6': selected }" :size="12" /> - + +
    + {{ $options.i18n.busy }} - + + {{ $options.i18n.agent }} + +
    diff --git a/app/services/concerns/users/participable_service.rb b/app/services/concerns/users/participable_service.rb index 7d182ea94356ac..7d6e7de0fc0061 100644 --- a/app/services/concerns/users/participable_service.rb +++ b/app/services/concerns/users/participable_service.rb @@ -91,7 +91,8 @@ def user_as_hash(user) username: user.username, name: user.name, avatar_url: user.avatar_url, - availability: lazy_user_availability(user).itself # calling #itself to avoid returning a BatchLoader instance + availability: lazy_user_availability(user).itself, # calling #itself to avoid returning a BatchLoader instance + composite_identity_enforced: user.composite_identity_enforced } end @@ -115,7 +116,8 @@ def org_user_detail_as_hash(org_user_detail) avatar_url: user.avatar_url, availability: lazy_user_availability(user).itself, # calling #itself to avoid returning a BatchLoader instance original_username: user.username, - original_displayname: user.name + original_displayname: user.name, + composite_identity_enforced: user.composite_identity_enforced } end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 046770de086b2f..3ed6baafda09c2 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -6832,6 +6832,9 @@ msgstr "" msgid "After you've reviewed these contribution guidelines, you'll be all set to" msgstr "" +msgid "Agent" +msgstr "" + msgid "Agent Information" msgstr "" @@ -73155,6 +73158,9 @@ msgstr "" msgid "UserProfile|Activity" msgstr "" +msgid "UserProfile|Agent" +msgstr "" + msgid "UserProfile|An error occurred loading the activity. Please refresh the page to try again." msgstr "" diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index 301eee323aa285..7414d1d8617c83 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -639,6 +639,30 @@ describe('GfmAutoComplete', () => { }, ]); }); + + it('should include composite_identity_enforced field', () => { + expect( + membersBeforeSave([ + { + username: 'my-user', + name: 'My User', + avatar_url: './users.jpg', + type: 'User', + composite_identity_enforced: true, + }, + ]), + ).toEqual([ + { + username: 'my-user', + avatarTag: + 'my-user', + title: 'My User', + search: 'MyUser my-user', + icon: '', + compositeIdentityEnforced: true, + }, + ]); + }); }); describe('Issues.insertTemplateFunction', () => { @@ -758,6 +782,38 @@ describe('GfmAutoComplete', () => { ); }); + describe('when compositeIdentityEnforced is true', () => { + it('should add composite identity enforced badge', () => { + expect( + GfmAutoComplete.Members.templateFunction({ + avatarTag: 'IMG', + username: 'my-user', + title: '', + icon: '', + availabilityStatus: '', + compositeIdentityEnforced: true, + }), + ).toBe( + '
  • IMG my-user Agent
  • ', + ); + }); + }); + + describe('when compositeIdentityEnforced is false', () => { + it('should not add composite identity enforced badge if compositeIdentityEnforced is false', () => { + expect( + GfmAutoComplete.Members.templateFunction({ + avatarTag: 'IMG', + username: 'my-user', + title: '', + icon: '', + availabilityStatus: '', + compositeIdentityEnforced: false, + }), + ).toBe('
  • IMG my-user
  • '); + }); + }); + describe('nameOrUsernameStartsWith', () => { it.each` query | result diff --git a/spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js index 117eb2f765da39..264c72b1bdbd08 100644 --- a/spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js +++ b/spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js @@ -15,12 +15,15 @@ describe('Sidebar participant component', () => { const findAvatar = () => wrapper.findComponent(GlAvatarLabeled); const findIcon = () => wrapper.findComponent(GlIcon); + const findBusyBadge = () => wrapper.find('[data-testid="busy-badge"]'); + const findAgentBadge = () => wrapper.find('[data-testid="agent-badge"]'); const createComponent = ({ status = null, issuableType = TYPE_ISSUE, canMerge = false, selected = false, + compositeIdentityEnforced = false, } = {}) => { wrapper = shallowMount(SidebarParticipant, { propsData: { @@ -28,6 +31,7 @@ describe('Sidebar participant component', () => { ...user, canMerge, status, + compositeIdentityEnforced, }, issuableType, selected, @@ -38,17 +42,37 @@ describe('Sidebar participant component', () => { }); }; - it('does not show `Busy` status when user is not busy', () => { - createComponent(); + describe('when is not busy and is not agent', () => { + it('does not show `Busy` status', () => { + createComponent(); + + expect(findAvatar().props('label')).toBe(user.name); + expect(findBusyBadge().exists()).toBe(false); + }); + + it('does not show agent badge', () => { + createComponent(); + + expect(findAgentBadge().exists()).toBe(false); + }); + }); + + describe('when is busy', () => { + it('shows `Busy` status', () => { + createComponent({ status: { availability: 'BUSY' } }); - expect(findAvatar().props('label')).toBe(user.name); - expect(wrapper.text()).not.toContain('Busy'); + expect(findBusyBadge().exists()).toBe(true); + expect(findAgentBadge().exists()).toBe(false); + }); }); - it('shows `Busy` status when user is busy', () => { - createComponent({ status: { availability: 'BUSY' } }); + describe('when is agent', () => { + it('renders agent badge', () => { + createComponent({ compositeIdentityEnforced: true }); - expect(wrapper.text()).toContain('Busy'); + expect(findAgentBadge().exists()).toBe(true); + expect(findBusyBadge().exists()).toBe(false); + }); }); it('does not render a warning icon', () => { diff --git a/spec/frontend/sidebar/mock_data.js b/spec/frontend/sidebar/mock_data.js index 22c76218cd6121..d95a0458569a05 100644 --- a/spec/frontend/sidebar/mock_data.js +++ b/spec/frontend/sidebar/mock_data.js @@ -17,6 +17,7 @@ export const mockUser1 = createMockUser({ username: 'root', webUrl: '/root', webPath: '/root', + compositeIdentityEnforced: false, }); export const mockUserWithType1 = { @@ -31,6 +32,7 @@ export const mockUser2 = createMockUser({ username: 'rookie', webUrl: 'rookie', webPath: '/rookie', + compositeIdentityEnforced: false, }); export const mockUserWithType2 = { @@ -529,6 +531,7 @@ export const searchAutocompleteQueryResponse = { webUrl: 'root', webPath: '/root', status: null, + compositeIdentityEnforced: false, }, { id: '2', @@ -538,6 +541,17 @@ export const searchAutocompleteQueryResponse = { webUrl: 'rookie', webPath: '/rookie', status: null, + compositeIdentityEnforced: false, + }, + { + id: '3', + avatarUrl: '/avatar3', + name: 'root_external', + username: 'root_external', + webUrl: 'root_external', + webPath: '/root_external', + status: null, + compositeIdentityEnforced: false, }, ], }, @@ -714,9 +728,9 @@ export const projectAutocompleteMembersResponse = { null, null, // Remove duplicated entry https://gitlab.com/gitlab-org/gitlab/-/issues/327822 - mockUser1, - mockUser1, - mockUser2, + { ...mockUser1, compositeIdentityEnforced: false }, + { ...mockUser1, compositeIdentityEnforced: false }, + { ...mockUser2, compositeIdentityEnforced: false }, { __typename: 'UserCore', id: 'gid://gitlab/User/2', @@ -729,6 +743,7 @@ export const projectAutocompleteMembersResponse = { status: { availability: 'BUSY', }, + compositeIdentityEnforced: false, }, ], }, diff --git a/spec/frontend/work_items/components/work_item_bulk_edit/work_item_bulk_edit_assignee_spec.js b/spec/frontend/work_items/components/work_item_bulk_edit/work_item_bulk_edit_assignee_spec.js index 94495f69de90fc..51e048f2a01673 100644 --- a/spec/frontend/work_items/components/work_item_bulk_edit/work_item_bulk_edit_assignee_spec.js +++ b/spec/frontend/work_items/components/work_item_bulk_edit/work_item_bulk_edit_assignee_spec.js @@ -152,6 +152,7 @@ describe('WorkItemBulkEditAssignee component', () => { webUrl: 'rookie', webPath: '/rookie', status: null, + compositeIdentityEnforced: false, }, ], }, diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index 73671d238d51fe..301a3aee7e15f4 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -3777,6 +3777,7 @@ export const projectMembersAutocompleteResponseWithCurrentUser = { webUrl: 'rookie', webPath: '/rookie', status: null, + compositeIdentityEnforced: false, }, { __typename: 'AutocompletedUser', @@ -3788,6 +3789,7 @@ export const projectMembersAutocompleteResponseWithCurrentUser = { webUrl: '/root', webPath: '/root', status: null, + compositeIdentityEnforced: false, }, ], }, diff --git a/spec/services/projects/participants_service_spec.rb b/spec/services/projects/participants_service_spec.rb index 78bda653d26b07..0638419191478f 100644 --- a/spec/services/projects/participants_service_spec.rb +++ b/spec/services/projects/participants_service_spec.rb @@ -108,6 +108,12 @@ def run_service expect(participants.count { |p| p[:username] == noteable.author.username }).to eq 1 end + it 'includes composite_identity_enforced field for users' do + user_participants = run_service.select { |p| p[:type] == 'User' && !p[:original_username].present? } + + expect(user_participants).to all(include(:composite_identity_enforced)) + end + context 'when noteable.participants contains placeholder or import users' do let(:placeholder_user) { create(:user, :placeholder) } let(:import_user) { create(:user, :import_user) } @@ -132,7 +138,8 @@ def run_service name: org_user_detail.display_name, avatar_url: org_user_detail.user.avatar_url, original_username: org_user_detail.user.username, - original_displayname: org_user_detail.user.name + original_displayname: org_user_detail.user.name, + composite_identity_enforced: org_user_detail.user.composite_identity_enforced )) end -- GitLab From a0d3da68335e81007f2b429f81808496272e5a4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Caplette?= Date: Wed, 10 Dec 2025 14:42:50 -0500 Subject: [PATCH 2/5] Fix rspec --- ee/spec/services/groups/participants_service_spec.rb | 3 ++- spec/services/groups/participants_service_spec.rb | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/ee/spec/services/groups/participants_service_spec.rb b/ee/spec/services/groups/participants_service_spec.rb index 721f473ab69dfa..e52df629e91c1e 100644 --- a/ee/spec/services/groups/participants_service_spec.rb +++ b/ee/spec/services/groups/participants_service_spec.rb @@ -17,7 +17,8 @@ def user_to_autocompletable(user) username: user.username, name: user.name, avatar_url: user.avatar_url, - availability: user&.status&.availability + availability: user&.status&.availability, + composite_identity_enforced: user.composite_identity_enforced } end diff --git a/spec/services/groups/participants_service_spec.rb b/spec/services/groups/participants_service_spec.rb index 8af8a2740f759f..d2bdbacc4e4f0c 100644 --- a/spec/services/groups/participants_service_spec.rb +++ b/spec/services/groups/participants_service_spec.rb @@ -115,7 +115,8 @@ def user_to_autocompletable(user) username: user.username, name: user.name, avatar_url: user.avatar_url, - availability: user&.status&.availability + availability: user&.status&.availability, + composite_identity_enforced: user.composite_identity_enforced } end end -- GitLab From 3952fa98bc6a6320c9ac4927d105d3b5161f3793 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Caplette?= Date: Wed, 10 Dec 2025 14:42:50 -0500 Subject: [PATCH 3/5] Fix rspec --- .../profiles/user_edit_profile_spec.rb | 4 +++- spec/frontend/gfm_auto_complete_spec.js | 23 +++++++++---------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/spec/features/profiles/user_edit_profile_spec.rb b/spec/features/profiles/user_edit_profile_spec.rb index 8c4571d8cc5310..f8123804549f0c 100644 --- a/spec/features/profiles/user_edit_profile_spec.rb +++ b/spec/features/profiles/user_edit_profile_spec.rb @@ -337,9 +337,11 @@ def select_emoji(emoji_name) it 'shows author as busy in the assignee dropdown' do within_testid('work-item-assignees') do click_button('Edit') - select_listbox_item("#{user.name} Busy") + # The busy badge is now rendered as a separate element, so we search for just the user name + select_listbox_item(user.name) expect(page).to have_link(user.name) + expect(page).to have_css('[data-testid="busy-badge"]') end end end diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index 7414d1d8617c83..4662189ee6a250 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -784,18 +784,17 @@ describe('GfmAutoComplete', () => { describe('when compositeIdentityEnforced is true', () => { it('should add composite identity enforced badge', () => { - expect( - GfmAutoComplete.Members.templateFunction({ - avatarTag: 'IMG', - username: 'my-user', - title: '', - icon: '', - availabilityStatus: '', - compositeIdentityEnforced: true, - }), - ).toBe( - '
  • IMG my-user Agent
  • ', - ); + const result = GfmAutoComplete.Members.templateFunction({ + avatarTag: 'IMG', + username: 'my-user', + title: '', + icon: '', + availabilityStatus: '', + compositeIdentityEnforced: true, + }); + expect(result).toContain('IMG my-user'); + expect(result).toContain('Agent'); + expect(result).toContain('gl-badge'); }); }); -- GitLab From 439fbe55043520fecb29ba73f72360c727a9788f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Caplette?= Date: Thu, 11 Dec 2025 09:30:38 -0500 Subject: [PATCH 4/5] Address gl_introduced query fields --- app/assets/javascripts/gfm_auto_complete.js | 2 +- .../queries/workspace_autocomplete_users.query.graphql | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index ce615ee03a61cf..9aaa516f89c8e9 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -148,7 +148,7 @@ export function membersBeforeSave(members) { search: createMemberSearchString(member), icon: avatarIcon, availability: member?.availability, - compositeIdentityEnforced: member.composite_identity_enforced, + compositeIdentityEnforced: member?.composite_identity_enforced, }; }); } diff --git a/app/assets/javascripts/graphql_shared/queries/workspace_autocomplete_users.query.graphql b/app/assets/javascripts/graphql_shared/queries/workspace_autocomplete_users.query.graphql index f223523a23bdbb..796797c569c49d 100644 --- a/app/assets/javascripts/graphql_shared/queries/workspace_autocomplete_users.query.graphql +++ b/app/assets/javascripts/graphql_shared/queries/workspace_autocomplete_users.query.graphql @@ -9,7 +9,7 @@ query workspaceAutocompleteUsersSearch( groupWorkspace: group(fullPath: $fullPath) @skip(if: $isProject) { id users: autocompleteUsers(search: $search) { - compositeIdentityEnforced + compositeIdentityEnforced @gl_introduced(version: "18.7.0") ...User ...UserAvailability } @@ -17,7 +17,7 @@ query workspaceAutocompleteUsersSearch( workspace: project(fullPath: $fullPath) { id users: autocompleteUsers(search: $search) { - compositeIdentityEnforced + compositeIdentityEnforced @gl_introduced(version: "18.7.0") ...User ...UserAvailability } -- GitLab From 29a8b94e96b846079ae1445d242dfd3b3bf75960 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Caplette?= Date: Thu, 11 Dec 2025 13:26:16 -0500 Subject: [PATCH 5/5] Fix rspecs --- spec/features/profiles/user_edit_profile_spec.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/spec/features/profiles/user_edit_profile_spec.rb b/spec/features/profiles/user_edit_profile_spec.rb index f8123804549f0c..aad6568a4d83e8 100644 --- a/spec/features/profiles/user_edit_profile_spec.rb +++ b/spec/features/profiles/user_edit_profile_spec.rb @@ -335,12 +335,11 @@ def select_emoji(emoji_name) end it 'shows author as busy in the assignee dropdown' do + expect(page).not_to have_css('[data-testid="busy-badge"]') + within_testid('work-item-assignees') do click_button('Edit') - # The busy badge is now rendered as a separate element, so we search for just the user name - select_listbox_item(user.name) - expect(page).to have_link(user.name) expect(page).to have_css('[data-testid="busy-badge"]') end end -- GitLab