From 880fb10905cd9f65f4e244fe1a272b7b8975977b Mon Sep 17 00:00:00 2001 From: Jon Gaul Date: Thu, 2 Jul 2026 17:45:36 +0900 Subject: [PATCH 1/4] feat(ui): allow user to customize PR title Signed-off-by: Jon Gaul --- internal/info/flipt.go | 12 +++++ internal/info/flipt_test.go | 45 ++++++++++++++++ .../branches/CreateMergeProposalModal.tsx | 52 ++++++++++++++++++- ui/src/types/Meta.ts | 6 +++ 4 files changed, 113 insertions(+), 2 deletions(-) diff --git a/internal/info/flipt.go b/internal/info/flipt.go index 3003060875..d0aa471bea 100644 --- a/internal/info/flipt.go +++ b/internal/info/flipt.go @@ -49,6 +49,12 @@ func WithConfig(cfg *config.Config) Option { f.Authentication = &Authentication{Required: cfg.Authentication.Required} f.Analytics = &Analytics{Enabled: cfg.Analytics.Enabled()} f.UI = &UI{Theme: cfg.UI.DefaultTheme, TopbarColor: cfg.UI.Topbar.Color} + if cfg.Templates.ProposalTitle != "" || cfg.Templates.ProposalBody != "" { + f.Templates = &Templates{ + ProposalTitle: cfg.Templates.ProposalTitle, + ProposalBody: cfg.Templates.ProposalBody, + } + } } } @@ -84,12 +90,18 @@ type UI struct { TopbarColor string `json:"topbarColor,omitempty"` } +type Templates struct { + ProposalTitle string `json:"proposalTitle,omitempty"` + ProposalBody string `json:"proposalBody,omitempty"` +} + type Flipt struct { licenseManager license.Manager Build *Build `json:"build,omitempty"` Authentication *Authentication `json:"authentication,omitempty"` Analytics *Analytics `json:"analytics,omitempty"` UI *UI `json:"ui,omitempty"` + Templates *Templates `json:"templates,omitempty"` } func (f Flipt) IsDevelopment() bool { diff --git a/internal/info/flipt_test.go b/internal/info/flipt_test.go index 8ba59fa786..f75bd84743 100644 --- a/internal/info/flipt_test.go +++ b/internal/info/flipt_test.go @@ -30,6 +30,51 @@ func TestNew(t *testing.T) { assert.False(t, f.Analytics.Enabled) } +func TestWithConfig_Templates(t *testing.T) { + t.Run("no templates configured", func(t *testing.T) { + f := New(WithConfig(config.Default())) + assert.Nil(t, f.Templates) + }) + + t.Run("proposal title configured", func(t *testing.T) { + cfg := config.Default() + cfg.Templates.ProposalTitle = "Flipt: {{.Base.Ref}} from {{.Branch.Ref}}" + f := New(WithConfig(cfg)) + assert.NotNil(t, f.Templates) + assert.Equal(t, "Flipt: {{.Base.Ref}} from {{.Branch.Ref}}", f.Templates.ProposalTitle) + assert.Empty(t, f.Templates.ProposalBody) + }) + + t.Run("both templates configured", func(t *testing.T) { + cfg := config.Default() + cfg.Templates.ProposalTitle = "title template" + cfg.Templates.ProposalBody = "body template" + f := New(WithConfig(cfg)) + assert.NotNil(t, f.Templates) + assert.Equal(t, "title template", f.Templates.ProposalTitle) + assert.Equal(t, "body template", f.Templates.ProposalBody) + }) + + t.Run("templates marshaled to JSON", func(t *testing.T) { + cfg := config.Default() + cfg.Templates.ProposalTitle = "custom title" + + mockLicenseManager := license.NewMockManager(t) + mockLicenseManager.EXPECT().Product().Return(product.OSS) + + f := New(WithConfig(cfg), WithLicenseManager(mockLicenseManager)) + data, err := json.Marshal(f) + assert.NoError(t, err) + + var out map[string]any + assert.NoError(t, json.Unmarshal(data, &out)) + + templates, ok := out["templates"].(map[string]any) + assert.True(t, ok) + assert.Equal(t, "custom title", templates["proposalTitle"]) + }) +} + func TestFlipt_ProductField_Marshaling(t *testing.T) { tests := []struct { name string diff --git a/ui/src/components/environments/branches/CreateMergeProposalModal.tsx b/ui/src/components/environments/branches/CreateMergeProposalModal.tsx index 07af9a51b9..d968820533 100644 --- a/ui/src/components/environments/branches/CreateMergeProposalModal.tsx +++ b/ui/src/components/environments/branches/CreateMergeProposalModal.tsx @@ -22,6 +22,7 @@ import Loading from '~/components/Loading'; import { IEnvironment } from '~/types/Environment'; import { useError } from '~/data/hooks/error'; +import { useAppSelector } from '~/data/hooks/store'; import { useSuccess } from '~/data/hooks/success'; interface CreateMergeProposalModalProps { @@ -31,6 +32,10 @@ interface CreateMergeProposalModalProps { } const validationSchema = Yup.object().shape({ + title: Yup.string() + .optional() + .max(200, 'Title must be at most 200 characters') + .trim(), description: Yup.string() .optional() .max(500, 'Description must be at most 500 characters') @@ -38,6 +43,9 @@ const validationSchema = Yup.object().shape({ draft: Yup.boolean() }); +const BUILTIN_TITLE_TEMPLATE = + 'Flipt: Update features {{with .Base.Directory}}in {{.}} {{end}}on {{.Base.Ref}}'; + const MAX_COMMITS = 10; export function CreateMergeProposalModal({ @@ -56,9 +64,15 @@ export function CreateMergeProposalModal({ const { setError, clearError } = useError(); const { setSuccess } = useSuccess(); + const { info } = useAppSelector((state) => state.meta); + const [proposeEnvironment] = useProposeEnvironmentMutation(); + const defaultTitle = + info.templates?.proposalTitle ?? BUILTIN_TITLE_TEMPLATE; + const handleProposeEnvironment = async (values: { + title: string; description: string; draft: boolean; }) => { @@ -66,6 +80,7 @@ export function CreateMergeProposalModal({ await proposeEnvironment({ environmentKey: environment.configuration?.base ?? '', key: environment.key, + title: values.title || undefined, body: values.description, draft: values.draft }).unwrap(); @@ -155,11 +170,16 @@ export function CreateMergeProposalModal({ )} { + const trimmedTitle = values.title.trim().slice(0, 200); const trimmed = values.description.trim().slice(0, 500); - const submitValues = { ...values, description: trimmed }; + const submitValues = { + ...values, + title: trimmedTitle, + description: trimmed + }; await handleProposeEnvironment(submitValues); actions.setSubmitting(false); }} @@ -171,6 +191,34 @@ export function CreateMergeProposalModal({ data?.changes?.length == 0; return (
+
+ + +
+ {formik.values.title.trim().length}/200 +
+ {formik.errors.title && formik.touched.title && ( +
+ {formik.errors.title} +
+ )} +
)} { - const trimmedTitle = values.title.trim().slice(0, 200); - const trimmed = values.description.trim().slice(0, 500); const submitValues = { ...values, - title: trimmedTitle, - description: trimmed + title: values.title.trim().slice(0, MAX_TITLE), + description: values.description.trim().slice(0, MAX_BODY) }; await handleProposeEnvironment(submitValues); actions.setSubmitting(false); @@ -205,13 +207,13 @@ export function CreateMergeProposalModal({ name="title" className="w-full rounded-md border-input bg-secondary/20 dark:bg-input/20 px-3 py-2 text-sm focus:ring-2 focus:ring-brand focus:border-brand transition disabled:opacity-80 disabled:cursor-not-allowed" placeholder="Enter a title for this merge proposal..." - maxLength={200} + maxLength={MAX_TITLE} onChange={formik.handleChange} value={formik.values.title} disabled={isError} />
- {formik.values.title.trim().length}/200 + {formik.values.title.trim().length}/{MAX_TITLE}
{formik.errors.title && formik.touched.title && (
@@ -234,13 +236,13 @@ export function CreateMergeProposalModal({ className="w-full rounded-md border-input bg-secondary/20 dark:bg-input/20 px-3 py-2 text-sm focus:ring-2 focus:ring-brand focus:border-brand transition disabled:opacity-80 disabled:cursor-not-allowed" rows={3} placeholder="Add context or reasoning for this merge proposal..." - maxLength={500} + maxLength={MAX_BODY} onChange={formik.handleChange} value={formik.values.description} disabled={isError} />
- {formik.values.description.trim().length}/500 + {formik.values.description.trim().length}/{MAX_BODY}
{formik.errors.description && formik.touched.description && (
diff --git a/ui/src/components/forms/Input.tsx b/ui/src/components/forms/Input.tsx index 0d300a74c5..637ddfb72c 100644 --- a/ui/src/components/forms/Input.tsx +++ b/ui/src/components/forms/Input.tsx @@ -8,7 +8,7 @@ type InputProps = { type?: string; className?: string; autoComplete?: boolean; - forwardRef?: React.RefObject; + forwardRef?: React.Ref; onChange?: (e: React.ChangeEvent) => void; } & React.InputHTMLAttributes; diff --git a/ui/src/types/Meta.ts b/ui/src/types/Meta.ts index 18cc5462f4..9ed3220464 100644 --- a/ui/src/types/Meta.ts +++ b/ui/src/types/Meta.ts @@ -1,16 +1,10 @@ import { Theme } from './Preferences'; -export interface ITemplates { - proposalTitle?: string; - proposalBody?: string; -} - export interface IInfo { build: IBuild; analytics?: IAnalytics; ui?: IUI; product?: Product; - templates?: ITemplates; } export interface IBuild { From 5943424ef67862e44a5af9639187b66908d5d2d0 Mon Sep 17 00:00:00 2001 From: Mark Phelps <209477+markphelps@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:24:01 -0400 Subject: [PATCH 3/4] fix(ci): pin buf action version Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com> --- .github/workflows/proto-push.yml | 4 ++++ .github/workflows/proto.yml | 2 ++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/proto-push.yml b/.github/workflows/proto-push.yml index 5efcf5457b..8e974b6d1b 100644 --- a/.github/workflows/proto-push.yml +++ b/.github/workflows/proto-push.yml @@ -16,22 +16,26 @@ jobs: - uses: bufbuild/buf-action@v1 with: + version: 1.70.0 setup_only: true github_token: ${{ secrets.GITHUB_TOKEN }} - uses: bufbuild/buf-action@v1 with: + version: 1.70.0 input: "rpc/v2" lint: true - uses: bufbuild/buf-action@v1 with: + version: 1.70.0 input: "rpc/v2" breaking: true breaking_against: "https://github.com/${GITHUB_REPOSITORY}.git#branch=v2" - uses: bufbuild/buf-action@v1 with: + version: 1.70.0 input: "rpc/v2" push: true token: ${{ secrets.BUF_TOKEN }} diff --git a/.github/workflows/proto.yml b/.github/workflows/proto.yml index 0f2f382f88..3bccb4ece9 100644 --- a/.github/workflows/proto.yml +++ b/.github/workflows/proto.yml @@ -25,6 +25,7 @@ jobs: - uses: bufbuild/buf-action@v1 with: + version: 1.70.0 lint: true proto-check: @@ -36,6 +37,7 @@ jobs: - uses: bufbuild/buf-action@v1 with: + version: 1.70.0 setup_only: true github_token: ${{ secrets.GITHUB_TOKEN }} From 4de80aadff1f6749301942d17c3c922e8142dc93 Mon Sep 17 00:00:00 2001 From: Roman Dmytrenko Date: Wed, 8 Jul 2026 21:20:12 +0100 Subject: [PATCH 4/4] update CI to use buf v1.71.0, matching the mise configuration. Signed-off-by: Roman Dmytrenko --- .github/workflows/proto-push.yml | 8 ++++---- .github/workflows/proto.yml | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/proto-push.yml b/.github/workflows/proto-push.yml index 8e974b6d1b..28b973cdd5 100644 --- a/.github/workflows/proto-push.yml +++ b/.github/workflows/proto-push.yml @@ -16,26 +16,26 @@ jobs: - uses: bufbuild/buf-action@v1 with: - version: 1.70.0 + version: 1.71.0 setup_only: true github_token: ${{ secrets.GITHUB_TOKEN }} - uses: bufbuild/buf-action@v1 with: - version: 1.70.0 + version: 1.71.0 input: "rpc/v2" lint: true - uses: bufbuild/buf-action@v1 with: - version: 1.70.0 + version: 1.71.0 input: "rpc/v2" breaking: true breaking_against: "https://github.com/${GITHUB_REPOSITORY}.git#branch=v2" - uses: bufbuild/buf-action@v1 with: - version: 1.70.0 + version: 1.71.0 input: "rpc/v2" push: true token: ${{ secrets.BUF_TOKEN }} diff --git a/.github/workflows/proto.yml b/.github/workflows/proto.yml index 3bccb4ece9..33647b2502 100644 --- a/.github/workflows/proto.yml +++ b/.github/workflows/proto.yml @@ -25,7 +25,7 @@ jobs: - uses: bufbuild/buf-action@v1 with: - version: 1.70.0 + version: 1.71.0 lint: true proto-check: @@ -37,7 +37,7 @@ jobs: - uses: bufbuild/buf-action@v1 with: - version: 1.70.0 + version: 1.71.0 setup_only: true github_token: ${{ secrets.GITHUB_TOKEN }}