Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
590 changes: 590 additions & 0 deletions .github/scripts/check_config_changes_ci.py

Large diffs are not rendered by default.

66 changes: 66 additions & 0 deletions .github/workflows/config-change-checker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# .github/workflows/config-change-checker.yml
#
# Automatically detects notable additions/removals across four source files
# and appends structured release-note entries to the PR description under
# the "## Release Notes" section.
#
# Tracked files / directories:
# • server/public/model/config.go — config struct field changes
# • server/channels/api4/ — API endpoint additions/removals
# • server/public/model/audit_events.go — audit log event constant changes
# • server/build/Dockerfile.buildenv — Go runtime version changes
#
# No secrets needed — uses the built-in GITHUB_TOKEN.

name: Config Change Checker

on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- 'server/public/model/config.go'
- 'server/channels/api4/**'
- 'server/public/model/audit_events.go'
- 'server/build/Dockerfile.buildenv'

# Cancel any in-progress run for the same PR when a new commit is pushed.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
check-release-notes:
name: Detect release-note-worthy changes
runs-on: ubuntu-latest
# Skip bot-authored PRs (Dependabot, mattermost-bot, etc.) — they will
# not touch these paths intentionally and cannot receive description updates
# via GITHUB_TOKEN anyway (fork-like restrictions apply to most bots).
if: github.event.pull_request.user.type != 'Bot'

permissions:
pull-requests: write # needed to update the PR description
contents: read

steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Fetch enough history to diff against the base branch
fetch-depth: 0

- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.11'

- name: Install dependencies
run: pip install requests==2.32.3 --quiet

- name: Detect changes and update PR description
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
REPO: ${{ github.repository }}
run: python3 .github/scripts/check_config_changes_ci.py
4 changes: 4 additions & 0 deletions api/v4/source/definitions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1151,6 +1151,10 @@ components:
description: The time in milliseconds a incoming webhook was deleted
type: integer
format: int64
last_used:
description: The time in milliseconds this incoming webhook was last used to post a message
type: integer
format: int64
channel_id:
description: The ID of a public channel or private group that receives the
webhook payloads
Expand Down
12 changes: 11 additions & 1 deletion server/channels/app/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,7 @@ func (a *App) UpdateIncomingWebhook(oldHook, updatedHook *model.IncomingWebhook)
updatedHook.UpdateAt = model.GetMillis()
updatedHook.TeamId = oldHook.TeamId
updatedHook.DeleteAt = oldHook.DeleteAt
updatedHook.LastUsed = oldHook.LastUsed

newWebhook, err := a.Srv().Store().Webhook().UpdateIncoming(updatedHook)
if err != nil {
Expand Down Expand Up @@ -903,7 +904,16 @@ func (a *App) HandleIncomingWebhook(rctx request.CTX, hookID string, req *model.
}

_, err := a.CreateWebhookPost(rctx, hook.UserId, channel, text, overrideUsername, overrideIconURL, req.IconEmoji, req.Props, webhookType, threadRootID, req.Priority)
return err
if err != nil {
return err
}

now := model.GetMillis()
if nErr := a.Srv().Store().Webhook().UpdateIncomingLastUsed(hook.Id, now); nErr != nil {
rctx.Logger().Warn("Failed to update incoming webhook LastUsed", mlog.String("hook_id", hook.Id), mlog.Err(nErr))
}

return nil
}

func (a *App) CreateCommandWebhook(commandID string, args *model.CommandArgs) (*model.CommandWebhook, *model.AppError) {
Expand Down
2 changes: 2 additions & 0 deletions server/channels/db/migrations/migrations.list
Original file line number Diff line number Diff line change
Expand Up @@ -363,3 +363,5 @@ channels/db/migrations/postgres/000182_create_channel_join_requests_channel_stat
channels/db/migrations/postgres/000182_create_channel_join_requests_channel_status_index.up.sql
channels/db/migrations/postgres/000183_create_channel_join_requests_user_status_index.down.sql
channels/db/migrations/postgres/000183_create_channel_join_requests_user_status_index.up.sql
channels/db/migrations/postgres/000184_add_lastused_to_incoming_webhooks.down.sql
channels/db/migrations/postgres/000184_add_lastused_to_incoming_webhooks.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE incomingwebhooks DROP COLUMN IF EXISTS lastused;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE incomingwebhooks ADD COLUMN IF NOT EXISTS lastused bigint NOT NULL DEFAULT 0;
10 changes: 10 additions & 0 deletions server/channels/store/localcachelayer/webhook_layer.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,13 @@ func (s LocalCacheWebhookStore) PermanentDeleteIncomingByChannel(channelId strin
s.ClearCaches()
return nil
}

func (s LocalCacheWebhookStore) UpdateIncomingLastUsed(webhookID string, lastUsed int64) error {
err := s.WebhookStore.UpdateIncomingLastUsed(webhookID, lastUsed)
if err != nil {
return err
}

s.InvalidateWebhookCache(webhookID)
return nil
}
18 changes: 16 additions & 2 deletions server/channels/store/sqlstore/webhook_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ func newSqlWebhookStore(sqlStore *SqlStore, metrics einterfaces.MetricsInterface
"Username",
"IconURL",
"ChannelLocked",
"LastUsed",
).
From("IncomingWebhooks")

Expand Down Expand Up @@ -88,9 +89,9 @@ func (s SqlWebhookStore) SaveIncoming(webhook *model.IncomingWebhook) (*model.In
}

if _, err := s.GetMaster().NamedExec(`INSERT INTO IncomingWebhooks
(Id, CreateAt, UpdateAt, DeleteAt, UserId, ChannelId, TeamId, DisplayName, Description, Username, IconURL, ChannelLocked)
(Id, CreateAt, UpdateAt, DeleteAt, UserId, ChannelId, TeamId, DisplayName, Description, Username, IconURL, ChannelLocked, LastUsed)
VALUES
(:Id, :CreateAt, :UpdateAt, :DeleteAt, :UserId, :ChannelId, :TeamId, :DisplayName, :Description, :Username, :IconURL, :ChannelLocked)`, webhook); err != nil {
(:Id, :CreateAt, :UpdateAt, :DeleteAt, :UserId, :ChannelId, :TeamId, :DisplayName, :Description, :Username, :IconURL, :ChannelLocked, :LastUsed)`, webhook); err != nil {
return nil, errors.Wrapf(err, "failed to save IncomingWebhook with id=%s", webhook.Id)
}

Expand All @@ -111,6 +112,19 @@ func (s SqlWebhookStore) UpdateIncoming(hook *model.IncomingWebhook) (*model.Inc
return hook, nil
}

func (s SqlWebhookStore) UpdateIncomingLastUsed(webhookID string, lastUsed int64) error {
_, err := s.GetMaster().Exec(
`UPDATE IncomingWebhooks SET LastUsed = ? WHERE Id = ? AND DeleteAt = 0`,
lastUsed,
webhookID,
)
if err != nil {
return errors.Wrapf(err, "failed to update LastUsed for IncomingWebhook id=%s", webhookID)
}

return nil
}

func (s SqlWebhookStore) GetIncoming(id string, allowFromCache bool) (*model.IncomingWebhook, error) {
var webhook model.IncomingWebhook

Expand Down
1 change: 1 addition & 0 deletions server/channels/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,7 @@ type WebhookStore interface {
GetIncomingByTeam(teamID string, offset, limit int) ([]*model.IncomingWebhook, error)
GetIncomingByTeamByUser(teamID string, userID string, offset, limit int) ([]*model.IncomingWebhook, error)
UpdateIncoming(webhook *model.IncomingWebhook) (*model.IncomingWebhook, error)
UpdateIncomingLastUsed(webhookID string, lastUsed int64) error
GetIncomingByChannel(channelID string) ([]*model.IncomingWebhook, error)
DeleteIncoming(webhookID string, timestamp int64) error
PermanentDeleteIncomingByChannel(channelID string) error
Expand Down
18 changes: 18 additions & 0 deletions server/channels/store/storetest/mocks/WebhookStore.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 41 additions & 0 deletions server/channels/store/storetest/webhook_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import (
func TestWebhookStore(t *testing.T, rctx request.CTX, ss store.Store) {
t.Run("SaveIncoming", func(t *testing.T) { testWebhookStoreSaveIncoming(t, rctx, ss) })
t.Run("UpdateIncoming", func(t *testing.T) { testWebhookStoreUpdateIncoming(t, rctx, ss) })
t.Run("UpdateIncomingPreservesLastUsed", func(t *testing.T) { testWebhookStoreUpdateIncomingPreservesLastUsed(t, rctx, ss) })
t.Run("UpdateIncomingLastUsed", func(t *testing.T) { testWebhookStoreUpdateIncomingLastUsed(t, rctx, ss) })
t.Run("GetIncoming", func(t *testing.T) { testWebhookStoreGetIncoming(t, rctx, ss) })
t.Run("GetIncomingList", func(t *testing.T) { testWebhookStoreGetIncomingList(t, rctx, ss) })
t.Run("GetIncomingListByUser", func(t *testing.T) { testWebhookStoreGetIncomingListByUser(t, rctx, ss) })
Expand Down Expand Up @@ -73,6 +75,45 @@ func testWebhookStoreUpdateIncoming(t *testing.T, rctx request.CTX, ss store.Sto
require.Equal(t, "TestHook", webhook.DisplayName, "display name is not updated")
}

// testWebhookStoreUpdateIncomingPreservesLastUsed ensures the generic UpdateIncoming path does not
// overwrite LastUsed; only UpdateIncomingLastUsed should change that column.
func testWebhookStoreUpdateIncomingPreservesLastUsed(t *testing.T, rctx request.CTX, ss store.Store) {
o1 := buildIncomingWebhook()
saved, err := ss.Webhook().SaveIncoming(o1)
require.NoError(t, err)
require.Zero(t, saved.LastUsed, "new webhook should have LastUsed 0")

lastUsed := model.GetMillis()
err = ss.Webhook().UpdateIncomingLastUsed(saved.Id, lastUsed)
require.NoError(t, err)

withStaleLastUsed := *saved
withStaleLastUsed.DisplayName = "RenamedHook"
withStaleLastUsed.LastUsed = 0

_, err = ss.Webhook().UpdateIncoming(&withStaleLastUsed)
require.NoError(t, err)

fromDB, err := ss.Webhook().GetIncoming(saved.Id, false)
require.NoError(t, err)
require.Equal(t, lastUsed, fromDB.LastUsed, "UpdateIncoming must not clear LastUsed when struct has LastUsed 0")
require.Equal(t, "RenamedHook", fromDB.DisplayName)
}

func testWebhookStoreUpdateIncomingLastUsed(t *testing.T, rctx request.CTX, ss store.Store) {
o1 := buildIncomingWebhook()
o1, err := ss.Webhook().SaveIncoming(o1)
require.NoError(t, err)

lastUsed := model.GetMillis()
err = ss.Webhook().UpdateIncomingLastUsed(o1.Id, lastUsed)
require.NoError(t, err)

updated, err := ss.Webhook().GetIncoming(o1.Id, false)
require.NoError(t, err)
require.Equal(t, lastUsed, updated.LastUsed)
}

func testWebhookStoreGetIncoming(t *testing.T, rctx request.CTX, ss store.Store) {
var err error

Expand Down
4 changes: 4 additions & 0 deletions server/channels/web/webhook_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ func TestIncomingWebhook(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)

refreshed, appErr := th.App.GetIncomingWebhook(hook.Id)
require.Nil(t, appErr)
require.NotZero(t, refreshed.LastUsed)

payload = "payload={\"text\": \"\"}"
resp, err = http.Post(url, "application/x-www-form-urlencoded", strings.NewReader(payload))
require.NoError(t, err)
Expand Down
2 changes: 2 additions & 0 deletions server/public/model/incoming_webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type IncomingWebhook struct {
Username string `json:"username"`
IconURL string `json:"icon_url"`
ChannelLocked bool `json:"channel_locked"`
LastUsed int64 `json:"last_used"`
}

func (o *IncomingWebhook) Auditable() map[string]any {
Expand All @@ -44,6 +45,7 @@ func (o *IncomingWebhook) Auditable() map[string]any {
"username": o.Username,
"icon_url:": o.IconURL,
"channel_locked": o.ChannelLocked,
"last_used": o.LastUsed,
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ const AdvancedTextEditor = ({
const teammateDisplayName = useSelector((state: GlobalState) => (teammateId ? getDisplayName(state, teammateId) : ''));
const showDndWarning = useSelector((state: GlobalState) => (teammateId ? getStatusForUserId(state, teammateId) === UserStatuses.DND : false));
const selectedPostFocussedAt = useSelector((state: GlobalState) => getSelectedPostFocussedAt(state));
const aiActionMenuItems = useSelector((state: GlobalState) => state.plugins.components.AIActionMenuItem);
const {available: aiRewriteEnabled} = useGetAgentsBridgeEnabled();

const canPost = useSelector((state: GlobalState) => {
Expand Down Expand Up @@ -317,6 +318,7 @@ const AdvancedTextEditor = ({
rewriteMenuProps,
isProcessing: rewriteIsProcessing,
} = useRewrite(draft, handleDraftChange, textboxRef, focusTextbox, setServerError);
const hasAIActionsMenu = (aiActionMenuItems?.length ?? 0) > 0 || (aiRewriteEnabled && Boolean(rewriteMenuProps));
const isDisabled = Boolean(readOnlyChannel || (!enableSharedChannelsDMs && isDMOrGMRemote) || rewriteIsProcessing);

const [attachmentPreview, fileUploadJSX] = useUploadFiles(
Expand Down Expand Up @@ -707,6 +709,10 @@ const AdvancedTextEditor = ({
}, [handleDraftChange, draft]);

const aiActionsMenu = useMemo(() => {
if (!hasAIActionsMenu) {
return null;
}

return (
<AIActionsMenu
draft={draft}
Expand All @@ -718,7 +724,7 @@ const AdvancedTextEditor = ({
aiRewriteEnabled={aiRewriteEnabled}
/>
);
}, [draft, getSelectedText, updateText, channelId, location, rewriteMenuProps, aiRewriteEnabled]);
}, [draft, getSelectedText, updateText, channelId, location, rewriteMenuProps, aiRewriteEnabled, hasAIActionsMenu]);

const formattingBar = (
<AutoHeightSwitcher
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,4 +107,26 @@ describe('FormattingBar', () => {

expect(fireEvent.mouseDown(screen.getByLabelText('code'))).toBe(false);
});

test('should only render separator before bold when AI actions menu is present', () => {
jest.spyOn(Hooks, 'useFormattingBarControls').mockReturnValue({layoutMode: LayoutModes.Wide, ...splitFormattingBarControls('wide')});

const {container, rerender} = renderWithContext(
<FormattingBar
{...baseProps}
aiActionsMenu={<button type='button'>{'AI Actions'}</button>}
/>,
);

expect(container.querySelectorAll('[data-testid="formatting-bar-separator"]')).toHaveLength(2);

rerender(
<FormattingBar
{...baseProps}
aiActionsMenu={null}
/>,
);

expect(container.querySelectorAll('[data-testid="formatting-bar-separator"]')).toHaveLength(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type {ApplyMarkdownOptions, MarkdownMode} from 'utils/markdown/apply_mark
import FormattingIcon, {IconContainer} from './formatting_icon';
import {LayoutModes, useFormattingBarControls} from './hooks';

export const Separator = styled.div`
export const Separator = styled.div.attrs({'data-testid': 'formatting-bar-separator'})`
display: block;
position: relative;
width: 1px;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import React from 'react';

import * as UserAgent from '@mattermost/shared/utils/user_agent';
import type {UserProfile} from '@mattermost/types/users';
import type {DeepPartial} from '@mattermost/types/utilities';

Expand All @@ -14,6 +15,11 @@ import type {GlobalState} from 'types/store';
import ProductMenuList from './product_menu_list';
import type {Props as ProductMenuListProps} from './product_menu_list';

const isDesktopAppMock = jest.mocked(UserAgent.isDesktopApp);

jest.mock('@mattermost/shared/utils/user_agent', () => ({
isDesktopApp: jest.fn(() => false),
}));
jest.mock('components/widgets/menu/menu_items/menu_cloud_trial', () => () => null);
jest.mock('components/widgets/menu/menu_items/menu_item_cloud_limit', () => () => null);
jest.mock('components/permissions_gates/system_permission_gate', () => ({children}: {children: React.ReactNode}) => <>{children}</>);
Expand Down Expand Up @@ -238,4 +244,17 @@ describe('components/global/product_switcher_menu', () => {
expect(container).toMatchSnapshot();
});
});

test('shows Download Apps link when appDownloadLink configured and not in desktop app', () => {
isDesktopAppMock.mockReturnValue(false);
const {container} = renderWithContext(<ProductMenuList {...defaultProps}/>, adminState);
expect(container.querySelector('#nativeAppLink')).not.toBeNull();
});

test('hides Download Apps link when in desktop app', () => {
isDesktopAppMock.mockReturnValue(true);
const {container} = renderWithContext(<ProductMenuList {...defaultProps}/>, adminState);
expect(container.querySelector('#nativeAppLink')).toBeNull();
isDesktopAppMock.mockReturnValue(false);
});
});
Loading
Loading