diff --git a/.github/workflows/proto-push.yml b/.github/workflows/proto-push.yml index 5efcf5457b..28b973cdd5 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.71.0 setup_only: true github_token: ${{ secrets.GITHUB_TOKEN }} - uses: bufbuild/buf-action@v1 with: + version: 1.71.0 input: "rpc/v2" lint: true - uses: bufbuild/buf-action@v1 with: + 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.71.0 input: "rpc/v2" push: true token: ${{ secrets.BUF_TOKEN }} diff --git a/.github/workflows/proto.yml b/.github/workflows/proto.yml index 0f2f382f88..33647b2502 100644 --- a/.github/workflows/proto.yml +++ b/.github/workflows/proto.yml @@ -25,6 +25,7 @@ jobs: - uses: bufbuild/buf-action@v1 with: + version: 1.71.0 lint: true proto-check: @@ -36,6 +37,7 @@ jobs: - uses: bufbuild/buf-action@v1 with: + version: 1.71.0 setup_only: true github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/internal/coss/storage/environments/git/store.go b/internal/coss/storage/environments/git/store.go index be51493a50..c6e4d4194d 100644 --- a/internal/coss/storage/environments/git/store.go +++ b/internal/coss/storage/environments/git/store.go @@ -21,6 +21,13 @@ import ( var _ serverenvs.Environment = (*Environment)(nil) +// templateContext is the data made available to the proposal title and body +// templates when they are rendered. +type templateContext struct { + Base *environments.EnvironmentConfiguration + Branch *environments.EnvironmentConfiguration +} + type SCM interface { Propose(context.Context, ProposalRequest) (*environments.EnvironmentProposalDetails, error) ListChanges(context.Context, ListChangesRequest) (*environments.ListBranchedEnvironmentChangesResponse, error) @@ -66,29 +73,34 @@ func (e *Environment) ListBranchedChanges(ctx context.Context, branch serverenvs return nil, errors.ErrInvalidf("environment %q is not a based on environment %q", e.Key(), branch.Key()) } - return e.SCM.ListChanges(ctx, ListChangesRequest{ + resp, err = e.SCM.ListChanges(ctx, ListChangesRequest{ Base: baseCfg.Ref, Head: branchCfg.Ref, Limit: 10, }) -} - -func (e *Environment) Propose(ctx context.Context, base serverenvs.Environment, opts serverenvs.ProposalOptions) (resp *environments.EnvironmentProposalDetails, err error) { - var ( - baseCfg = e.Configuration() - branchCfg = base.Configuration() - ) - - if branchCfg.Base != nil && *branchCfg.Base != e.Key() { - return nil, errors.ErrInvalidf("environment %q is not a based on environment %q", e.Key(), base.Key()) + if err != nil { + return nil, err } - type templateContext struct { - Base *environments.EnvironmentConfiguration - Branch *environments.EnvironmentConfiguration + // Render the hydrated proposal defaults so the UI can pre-fill (and let the + // user override) the title and body. Failures here should not prevent the + // caller from listing changes, so degrade gracefully. + title, body, rerr := e.renderProposalDefaults(ctx, baseCfg, branchCfg) + if rerr != nil { + e.logger.Warn("rendering proposal defaults", zap.Error(rerr)) + } else { + resp.ProposalTitle = title + resp.ProposalBody = body } - if err := e.Repository().View(ctx, branchCfg.Ref, func(hash plumbing.Hash, src environmentsfs.Filesystem) error { + return resp, nil +} + +// renderProposalDefaults renders the default proposal title and body for the +// given branch by executing the hydrated templates (built-in defaults overlaid +// with server-level and repository-level overrides) against the branch context. +func (e *Environment) renderProposalDefaults(ctx context.Context, baseCfg, branchCfg *environments.EnvironmentConfiguration) (title, body string, err error) { + err = e.Repository().View(ctx, branchCfg.Ref, func(hash plumbing.Hash, src environmentsfs.Filesystem) error { // chroot our filesystem to the configured directory dir := "" if baseCfg.Directory != nil { @@ -101,43 +113,56 @@ func (e *Environment) Propose(ctx context.Context, base serverenvs.Environment, return err } - var ( - title = &bytes.Buffer{} - body = &bytes.Buffer{} - ) - tmplCtx := templateContext{Base: baseCfg, Branch: branchCfg} - if opts.Title != "" { - title.WriteString(opts.Title) - } else { - if err := conf.Templates.ProposalTitleTemplate.Execute(title, tmplCtx); err != nil { - return err - } + var titleBuf, bodyBuf bytes.Buffer + if err := conf.Templates.ProposalTitleTemplate.Execute(&titleBuf, tmplCtx); err != nil { + return err } - - if opts.Body != "" { - body.WriteString(opts.Body) - } else { - if err := conf.Templates.ProposalBodyTemplate.Execute(body, tmplCtx); err != nil { - return err - } + if err := conf.Templates.ProposalBodyTemplate.Execute(&bodyBuf, tmplCtx); err != nil { + return err } - resp, err = e.SCM.Propose(ctx, ProposalRequest{ - Base: baseCfg.Ref, - Head: branchCfg.Ref, - Title: title.String(), - Body: body.String(), - Draft: opts.Draft, - }) + title, body = titleBuf.String(), bodyBuf.String() + return nil + }) - return err - }); err != nil { + return title, body, err +} + +func (e *Environment) Propose(ctx context.Context, base serverenvs.Environment, opts serverenvs.ProposalOptions) (resp *environments.EnvironmentProposalDetails, err error) { + var ( + baseCfg = e.Configuration() + branchCfg = base.Configuration() + ) + + if branchCfg.Base != nil && *branchCfg.Base != e.Key() { + return nil, errors.ErrInvalidf("environment %q is not a based on environment %q", e.Key(), base.Key()) + } + + // Start from the hydrated defaults, then let any caller-supplied title/body + // override them. Supplied values are used verbatim (already rendered by the + // UI), matching the previous behavior. + title, body, err := e.renderProposalDefaults(ctx, baseCfg, branchCfg) + if err != nil { return nil, err } - return + if opts.Title != "" { + title = opts.Title + } + + if opts.Body != "" { + body = opts.Body + } + + return e.SCM.Propose(ctx, ProposalRequest{ + Base: baseCfg.Ref, + Head: branchCfg.Ref, + Title: title, + Body: body, + Draft: opts.Draft, + }) } func (e *Environment) ListBranches(ctx context.Context) (*environments.ListEnvironmentBranchesResponse, error) { diff --git a/rpc/v2/environments/environments.pb.go b/rpc/v2/environments/environments.pb.go index 3ce8beba31..788de051e1 100644 --- a/rpc/v2/environments/environments.pb.go +++ b/rpc/v2/environments/environments.pb.go @@ -967,7 +967,14 @@ func (x *ListBranchedEnvironmentChangesRequest) GetLimit() int32 { type ListBranchedEnvironmentChangesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The list of changes. - Changes []*Change `protobuf:"bytes,1,rep,name=changes,proto3" json:"changes,omitempty"` + Changes []*Change `protobuf:"bytes,1,rep,name=changes,proto3" json:"changes,omitempty"` + // The hydrated default proposal title for this branch, rendered from the + // applicable (built-in, server, or repository-level) template. The UI uses + // this as an editable default when opening a merge proposal. + ProposalTitle string `protobuf:"bytes,2,opt,name=proposal_title,json=proposalTitle,proto3" json:"proposal_title,omitempty"` + // The hydrated default proposal body for this branch, rendered from the + // applicable template. + ProposalBody string `protobuf:"bytes,3,opt,name=proposal_body,json=proposalBody,proto3" json:"proposal_body,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1009,6 +1016,20 @@ func (x *ListBranchedEnvironmentChangesResponse) GetChanges() []*Change { return nil } +func (x *ListBranchedEnvironmentChangesResponse) GetProposalTitle() string { + if x != nil { + return x.ProposalTitle + } + return "" +} + +func (x *ListBranchedEnvironmentChangesResponse) GetProposalBody() string { + if x != nil { + return x.ProposalBody + } + return "" +} + // Namespace represents a grouping of related flags and segments. type Namespace struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -2089,9 +2110,11 @@ const file_environments_environments_proto_rawDesc = "" + "\x04from\x18\x03 \x01(\tH\x00R\x04from\x88\x01\x01\x12\x19\n" + "\x05limit\x18\x04 \x01(\x05H\x01R\x05limit\x88\x01\x01B\a\n" + "\x05_fromB\b\n" + - "\x06_limit\"X\n" + + "\x06_limit\"\xa4\x01\n" + "&ListBranchedEnvironmentChangesResponse\x12.\n" + - "\achanges\x18\x01 \x03(\v2\x14.environments.ChangeR\achanges\"\x99\x01\n" + + "\achanges\x18\x01 \x03(\v2\x14.environments.ChangeR\achanges\x12%\n" + + "\x0eproposal_title\x18\x02 \x01(\tR\rproposalTitle\x12#\n" + + "\rproposal_body\x18\x03 \x01(\tR\fproposalBody\"\x99\x01\n" + "\tNamespace\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12%\n" + diff --git a/rpc/v2/environments/environments.proto b/rpc/v2/environments/environments.proto index eb112316dc..516c20e911 100644 --- a/rpc/v2/environments/environments.proto +++ b/rpc/v2/environments/environments.proto @@ -291,6 +291,13 @@ message ListBranchedEnvironmentChangesRequest { message ListBranchedEnvironmentChangesResponse { // The list of changes. repeated Change changes = 1; + // The hydrated default proposal title for this branch, rendered from the + // applicable (built-in, server, or repository-level) template. The UI uses + // this as an editable default when opening a merge proposal. + string proposal_title = 2; + // The hydrated default proposal body for this branch, rendered from the + // applicable template. + string proposal_body = 3; } /* Namespace */ diff --git a/rpc/v2/environments/openapi.yaml b/rpc/v2/environments/openapi.yaml index 3b246a3824..8f40ec7ed7 100644 --- a/rpc/v2/environments/openapi.yaml +++ b/rpc/v2/environments/openapi.yaml @@ -660,6 +660,17 @@ components: items: $ref: '#/components/schemas/Change' description: The list of changes. + proposalTitle: + type: string + description: |- + The hydrated default proposal title for this branch, rendered from the + applicable (built-in, server, or repository-level) template. The UI uses + this as an editable default when opening a merge proposal. + proposalBody: + type: string + description: |- + The hydrated default proposal body for this branch, rendered from the + applicable template. description: The response message for listing changes in a branched environment. ListEnvironmentBranchesResponse: type: object diff --git a/ui/src/app/environments/environmentsApi.ts b/ui/src/app/environments/environmentsApi.ts index 8a1f68ad70..9bbe3d243a 100644 --- a/ui/src/app/environments/environmentsApi.ts +++ b/ui/src/app/environments/environmentsApi.ts @@ -168,7 +168,7 @@ export const environmentsApi = createApi({ ] }), listBranchEnvironmentChanges: builder.query< - { changes: IChange[] }, + { changes: IChange[]; proposalTitle?: string; proposalBody?: string }, { environmentKey: string; key: string } >({ query: ({ environmentKey, key }) => diff --git a/ui/src/components/environments/branches/CreateMergeProposalModal.tsx b/ui/src/components/environments/branches/CreateMergeProposalModal.tsx index 07af9a51b9..7dc28c43a3 100644 --- a/ui/src/components/environments/branches/CreateMergeProposalModal.tsx +++ b/ui/src/components/environments/branches/CreateMergeProposalModal.tsx @@ -30,10 +30,17 @@ interface CreateMergeProposalModalProps { environment: IEnvironment; } +const MAX_TITLE = 256; +const MAX_BODY = 10000; + const validationSchema = Yup.object().shape({ + title: Yup.string() + .optional() + .max(MAX_TITLE, `Title must be at most ${MAX_TITLE} characters`) + .trim(), description: Yup.string() .optional() - .max(500, 'Description must be at most 500 characters') + .max(MAX_BODY, `Description must be at most ${MAX_BODY} characters`) .trim(), draft: Yup.boolean() }); @@ -58,7 +65,13 @@ export function CreateMergeProposalModal({ const [proposeEnvironment] = useProposeEnvironmentMutation(); + // Hydrated defaults rendered by the server from the applicable proposal + // templates for this branch. Used to pre-fill the form; the user may override. + const defaultTitle = data?.proposalTitle ?? ''; + const defaultDescription = data?.proposalBody ?? ''; + const handleProposeEnvironment = async (values: { + title: string; description: string; draft: boolean; }) => { @@ -66,6 +79,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 +169,19 @@ export function CreateMergeProposalModal({ )} { - const trimmed = values.description.trim().slice(0, 500); - const submitValues = { ...values, description: trimmed }; + const submitValues = { + ...values, + title: values.title.trim().slice(0, MAX_TITLE), + description: values.description.trim().slice(0, MAX_BODY) + }; await handleProposeEnvironment(submitValues); actions.setSubmitting(false); }} @@ -171,6 +193,34 @@ export function CreateMergeProposalModal({ data?.changes?.length == 0; return (
+
+ + +
+ {formik.values.title.trim().length}/{MAX_TITLE} +
+ {formik.errors.title && formik.touched.title && ( +
+ {formik.errors.title} +
+ )} +