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
4 changes: 4 additions & 0 deletions .github/workflows/proto-push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
2 changes: 2 additions & 0 deletions .github/workflows/proto.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ jobs:

- uses: bufbuild/buf-action@v1
with:
version: 1.71.0
lint: true

proto-check:
Expand All @@ -36,6 +37,7 @@ jobs:

- uses: bufbuild/buf-action@v1
with:
version: 1.71.0
setup_only: true
github_token: ${{ secrets.GITHUB_TOKEN }}

Expand Down
111 changes: 68 additions & 43 deletions internal/coss/storage/environments/git/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down
29 changes: 26 additions & 3 deletions rpc/v2/environments/environments.pb.go

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

7 changes: 7 additions & 0 deletions rpc/v2/environments/environments.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
11 changes: 11 additions & 0 deletions rpc/v2/environments/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion ui/src/app/environments/environmentsApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
});
Expand All @@ -58,14 +65,21 @@ 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;
}) => {
try {
await proposeEnvironment({
environmentKey: environment.configuration?.base ?? '',
key: environment.key,
title: values.title || undefined,
body: values.description,
draft: values.draft
}).unwrap();
Expand Down Expand Up @@ -155,11 +169,19 @@ export function CreateMergeProposalModal({
</div>
)}
<Formik
initialValues={{ description: '', draft: false }}
enableReinitialize
initialValues={{
title: defaultTitle,
description: defaultDescription,
draft: false
}}
validationSchema={validationSchema}
onSubmit={async (values, actions) => {
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);
}}
Expand All @@ -171,6 +193,34 @@ export function CreateMergeProposalModal({
data?.changes?.length == 0;
return (
<Form>
<div className="mb-3">
<label
className="block text-sm font-medium mb-1"
htmlFor="proposal-title"
>
Proposal Title{' '}
<span className="text-muted-foreground">(optional)</span>
</label>
<Field
type="text"
id="proposal-title"
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={MAX_TITLE}
onChange={formik.handleChange}
value={formik.values.title}
disabled={isError}
/>
<div className="text-xs text-muted-foreground text-right mt-1">
{formik.values.title.trim().length}/{MAX_TITLE}
</div>
{formik.errors.title && formik.touched.title && (
<div className="text-xs text-destructive mt-1">
{formik.errors.title}
</div>
)}
</div>
<div>
<label
className="block text-sm font-medium mb-1"
Expand All @@ -186,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}
/>
<div className="text-xs text-muted-foreground text-right mt-1">
{formik.values.description.trim().length}/500
{formik.values.description.trim().length}/{MAX_BODY}
</div>
{formik.errors.description && formik.touched.description && (
<div className="text-xs text-destructive mt-1">
Expand Down
Loading
Loading