diff --git a/ent/migrate/schema.go b/ent/migrate/schema.go index 4f0d5087..26b428ee 100644 --- a/ent/migrate/schema.go +++ b/ent/migrate/schema.go @@ -368,6 +368,7 @@ var ( ReviewsColumns = []*schema.Column{ {Name: "id", Type: field.TypeInt, Increment: true}, {Name: "status", Type: field.TypeEnum, Enums: []string{"pending", "rejected", "approved"}, Default: "pending"}, + {Name: "comment", Type: field.TypeString, Nullable: true, Size: 2147483647}, {Name: "created_at", Type: field.TypeTime}, {Name: "updated_at", Type: field.TypeTime}, {Name: "deployment_id", Type: field.TypeInt, Nullable: true}, @@ -381,13 +382,13 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "reviews_deployments_reviews", - Columns: []*schema.Column{ReviewsColumns[4]}, + Columns: []*schema.Column{ReviewsColumns[5]}, RefColumns: []*schema.Column{DeploymentsColumns[0]}, OnDelete: schema.Cascade, }, { Symbol: "reviews_users_reviews", - Columns: []*schema.Column{ReviewsColumns[5]}, + Columns: []*schema.Column{ReviewsColumns[6]}, RefColumns: []*schema.Column{UsersColumns[0]}, OnDelete: schema.SetNull, }, diff --git a/ent/mutation.go b/ent/mutation.go index 0a28460a..830cc710 100644 --- a/ent/mutation.go +++ b/ent/mutation.go @@ -8564,6 +8564,7 @@ type ReviewMutation struct { typ string id *int status *review.Status + comment *string created_at *time.Time updated_at *time.Time clearedFields map[string]struct{} @@ -8694,6 +8695,55 @@ func (m *ReviewMutation) ResetStatus() { m.status = nil } +// SetComment sets the "comment" field. +func (m *ReviewMutation) SetComment(s string) { + m.comment = &s +} + +// Comment returns the value of the "comment" field in the mutation. +func (m *ReviewMutation) Comment() (r string, exists bool) { + v := m.comment + if v == nil { + return + } + return *v, true +} + +// OldComment returns the old "comment" field's value of the Review entity. +// If the Review object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *ReviewMutation) OldComment(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, fmt.Errorf("OldComment is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, fmt.Errorf("OldComment requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldComment: %w", err) + } + return oldValue.Comment, nil +} + +// ClearComment clears the value of the "comment" field. +func (m *ReviewMutation) ClearComment() { + m.comment = nil + m.clearedFields[review.FieldComment] = struct{}{} +} + +// CommentCleared returns if the "comment" field was cleared in this mutation. +func (m *ReviewMutation) CommentCleared() bool { + _, ok := m.clearedFields[review.FieldComment] + return ok +} + +// ResetComment resets all changes to the "comment" field. +func (m *ReviewMutation) ResetComment() { + m.comment = nil + delete(m.clearedFields, review.FieldComment) +} + // SetCreatedAt sets the "created_at" field. func (m *ReviewMutation) SetCreatedAt(t time.Time) { m.created_at = &t @@ -8963,10 +9013,13 @@ func (m *ReviewMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *ReviewMutation) Fields() []string { - fields := make([]string, 0, 5) + fields := make([]string, 0, 6) if m.status != nil { fields = append(fields, review.FieldStatus) } + if m.comment != nil { + fields = append(fields, review.FieldComment) + } if m.created_at != nil { fields = append(fields, review.FieldCreatedAt) } @@ -8989,6 +9042,8 @@ func (m *ReviewMutation) Field(name string) (ent.Value, bool) { switch name { case review.FieldStatus: return m.Status() + case review.FieldComment: + return m.Comment() case review.FieldCreatedAt: return m.CreatedAt() case review.FieldUpdatedAt: @@ -9008,6 +9063,8 @@ func (m *ReviewMutation) OldField(ctx context.Context, name string) (ent.Value, switch name { case review.FieldStatus: return m.OldStatus(ctx) + case review.FieldComment: + return m.OldComment(ctx) case review.FieldCreatedAt: return m.OldCreatedAt(ctx) case review.FieldUpdatedAt: @@ -9032,6 +9089,13 @@ func (m *ReviewMutation) SetField(name string, value ent.Value) error { } m.SetStatus(v) return nil + case review.FieldComment: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetComment(v) + return nil case review.FieldCreatedAt: v, ok := value.(time.Time) if !ok { @@ -9092,7 +9156,11 @@ func (m *ReviewMutation) AddField(name string, value ent.Value) error { // ClearedFields returns all nullable fields that were cleared during this // mutation. func (m *ReviewMutation) ClearedFields() []string { - return nil + var fields []string + if m.FieldCleared(review.FieldComment) { + fields = append(fields, review.FieldComment) + } + return fields } // FieldCleared returns a boolean indicating if a field with the given name was @@ -9105,6 +9173,11 @@ func (m *ReviewMutation) FieldCleared(name string) bool { // ClearField clears the value of the field with the given name. It returns an // error if the field is not defined in the schema. func (m *ReviewMutation) ClearField(name string) error { + switch name { + case review.FieldComment: + m.ClearComment() + return nil + } return fmt.Errorf("unknown Review nullable field %s", name) } @@ -9115,6 +9188,9 @@ func (m *ReviewMutation) ResetField(name string) error { case review.FieldStatus: m.ResetStatus() return nil + case review.FieldComment: + m.ResetComment() + return nil case review.FieldCreatedAt: m.ResetCreatedAt() return nil diff --git a/ent/review.go b/ent/review.go index 1c9afa3e..ac319a96 100644 --- a/ent/review.go +++ b/ent/review.go @@ -20,6 +20,8 @@ type Review struct { ID int `json:"id,omitempty"` // Status holds the value of the "status" field. Status review.Status `json:"status"` + // Comment holds the value of the "comment" field. + Comment string `json:"comment,omitemtpy"` // CreatedAt holds the value of the "created_at" field. CreatedAt time.Time `json:"created_at"` // UpdatedAt holds the value of the "updated_at" field. @@ -90,7 +92,7 @@ func (*Review) scanValues(columns []string) ([]interface{}, error) { switch columns[i] { case review.FieldID, review.FieldUserID, review.FieldDeploymentID: values[i] = new(sql.NullInt64) - case review.FieldStatus: + case review.FieldStatus, review.FieldComment: values[i] = new(sql.NullString) case review.FieldCreatedAt, review.FieldUpdatedAt: values[i] = new(sql.NullTime) @@ -121,6 +123,12 @@ func (r *Review) assignValues(columns []string, values []interface{}) error { } else if value.Valid { r.Status = review.Status(value.String) } + case review.FieldComment: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field comment", values[i]) + } else if value.Valid { + r.Comment = value.String + } case review.FieldCreatedAt: if value, ok := values[i].(*sql.NullTime); !ok { return fmt.Errorf("unexpected type %T for field created_at", values[i]) @@ -190,6 +198,8 @@ func (r *Review) String() string { builder.WriteString(fmt.Sprintf("id=%v", r.ID)) builder.WriteString(", status=") builder.WriteString(fmt.Sprintf("%v", r.Status)) + builder.WriteString(", comment=") + builder.WriteString(r.Comment) builder.WriteString(", created_at=") builder.WriteString(r.CreatedAt.Format(time.ANSIC)) builder.WriteString(", updated_at=") diff --git a/ent/review/review.go b/ent/review/review.go index f29dc884..7caf910b 100644 --- a/ent/review/review.go +++ b/ent/review/review.go @@ -14,6 +14,8 @@ const ( FieldID = "id" // FieldStatus holds the string denoting the status field in the database. FieldStatus = "status" + // FieldComment holds the string denoting the comment field in the database. + FieldComment = "comment" // FieldCreatedAt holds the string denoting the created_at field in the database. FieldCreatedAt = "created_at" // FieldUpdatedAt holds the string denoting the updated_at field in the database. @@ -57,6 +59,7 @@ const ( var Columns = []string{ FieldID, FieldStatus, + FieldComment, FieldCreatedAt, FieldUpdatedAt, FieldUserID, diff --git a/ent/review/where.go b/ent/review/where.go index 6647c386..e6498f8e 100644 --- a/ent/review/where.go +++ b/ent/review/where.go @@ -93,6 +93,13 @@ func IDLTE(id int) predicate.Review { }) } +// Comment applies equality check predicate on the "comment" field. It's identical to CommentEQ. +func Comment(v string) predicate.Review { + return predicate.Review(func(s *sql.Selector) { + s.Where(sql.EQ(s.C(FieldComment), v)) + }) +} + // CreatedAt applies equality check predicate on the "created_at" field. It's identical to CreatedAtEQ. func CreatedAt(v time.Time) predicate.Review { return predicate.Review(func(s *sql.Selector) { @@ -169,6 +176,131 @@ func StatusNotIn(vs ...Status) predicate.Review { }) } +// CommentEQ applies the EQ predicate on the "comment" field. +func CommentEQ(v string) predicate.Review { + return predicate.Review(func(s *sql.Selector) { + s.Where(sql.EQ(s.C(FieldComment), v)) + }) +} + +// CommentNEQ applies the NEQ predicate on the "comment" field. +func CommentNEQ(v string) predicate.Review { + return predicate.Review(func(s *sql.Selector) { + s.Where(sql.NEQ(s.C(FieldComment), v)) + }) +} + +// CommentIn applies the In predicate on the "comment" field. +func CommentIn(vs ...string) predicate.Review { + v := make([]interface{}, len(vs)) + for i := range v { + v[i] = vs[i] + } + return predicate.Review(func(s *sql.Selector) { + // if not arguments were provided, append the FALSE constants, + // since we can't apply "IN ()". This will make this predicate falsy. + if len(v) == 0 { + s.Where(sql.False()) + return + } + s.Where(sql.In(s.C(FieldComment), v...)) + }) +} + +// CommentNotIn applies the NotIn predicate on the "comment" field. +func CommentNotIn(vs ...string) predicate.Review { + v := make([]interface{}, len(vs)) + for i := range v { + v[i] = vs[i] + } + return predicate.Review(func(s *sql.Selector) { + // if not arguments were provided, append the FALSE constants, + // since we can't apply "IN ()". This will make this predicate falsy. + if len(v) == 0 { + s.Where(sql.False()) + return + } + s.Where(sql.NotIn(s.C(FieldComment), v...)) + }) +} + +// CommentGT applies the GT predicate on the "comment" field. +func CommentGT(v string) predicate.Review { + return predicate.Review(func(s *sql.Selector) { + s.Where(sql.GT(s.C(FieldComment), v)) + }) +} + +// CommentGTE applies the GTE predicate on the "comment" field. +func CommentGTE(v string) predicate.Review { + return predicate.Review(func(s *sql.Selector) { + s.Where(sql.GTE(s.C(FieldComment), v)) + }) +} + +// CommentLT applies the LT predicate on the "comment" field. +func CommentLT(v string) predicate.Review { + return predicate.Review(func(s *sql.Selector) { + s.Where(sql.LT(s.C(FieldComment), v)) + }) +} + +// CommentLTE applies the LTE predicate on the "comment" field. +func CommentLTE(v string) predicate.Review { + return predicate.Review(func(s *sql.Selector) { + s.Where(sql.LTE(s.C(FieldComment), v)) + }) +} + +// CommentContains applies the Contains predicate on the "comment" field. +func CommentContains(v string) predicate.Review { + return predicate.Review(func(s *sql.Selector) { + s.Where(sql.Contains(s.C(FieldComment), v)) + }) +} + +// CommentHasPrefix applies the HasPrefix predicate on the "comment" field. +func CommentHasPrefix(v string) predicate.Review { + return predicate.Review(func(s *sql.Selector) { + s.Where(sql.HasPrefix(s.C(FieldComment), v)) + }) +} + +// CommentHasSuffix applies the HasSuffix predicate on the "comment" field. +func CommentHasSuffix(v string) predicate.Review { + return predicate.Review(func(s *sql.Selector) { + s.Where(sql.HasSuffix(s.C(FieldComment), v)) + }) +} + +// CommentIsNil applies the IsNil predicate on the "comment" field. +func CommentIsNil() predicate.Review { + return predicate.Review(func(s *sql.Selector) { + s.Where(sql.IsNull(s.C(FieldComment))) + }) +} + +// CommentNotNil applies the NotNil predicate on the "comment" field. +func CommentNotNil() predicate.Review { + return predicate.Review(func(s *sql.Selector) { + s.Where(sql.NotNull(s.C(FieldComment))) + }) +} + +// CommentEqualFold applies the EqualFold predicate on the "comment" field. +func CommentEqualFold(v string) predicate.Review { + return predicate.Review(func(s *sql.Selector) { + s.Where(sql.EqualFold(s.C(FieldComment), v)) + }) +} + +// CommentContainsFold applies the ContainsFold predicate on the "comment" field. +func CommentContainsFold(v string) predicate.Review { + return predicate.Review(func(s *sql.Selector) { + s.Where(sql.ContainsFold(s.C(FieldComment), v)) + }) +} + // CreatedAtEQ applies the EQ predicate on the "created_at" field. func CreatedAtEQ(v time.Time) predicate.Review { return predicate.Review(func(s *sql.Selector) { diff --git a/ent/review_create.go b/ent/review_create.go index dacf6f7d..0f403936 100644 --- a/ent/review_create.go +++ b/ent/review_create.go @@ -37,6 +37,20 @@ func (rc *ReviewCreate) SetNillableStatus(r *review.Status) *ReviewCreate { return rc } +// SetComment sets the "comment" field. +func (rc *ReviewCreate) SetComment(s string) *ReviewCreate { + rc.mutation.SetComment(s) + return rc +} + +// SetNillableComment sets the "comment" field if the given value is not nil. +func (rc *ReviewCreate) SetNillableComment(s *string) *ReviewCreate { + if s != nil { + rc.SetComment(*s) + } + return rc +} + // SetCreatedAt sets the "created_at" field. func (rc *ReviewCreate) SetCreatedAt(t time.Time) *ReviewCreate { rc.mutation.SetCreatedAt(t) @@ -250,6 +264,14 @@ func (rc *ReviewCreate) createSpec() (*Review, *sqlgraph.CreateSpec) { }) _node.Status = value } + if value, ok := rc.mutation.Comment(); ok { + _spec.Fields = append(_spec.Fields, &sqlgraph.FieldSpec{ + Type: field.TypeString, + Value: value, + Column: review.FieldComment, + }) + _node.Comment = value + } if value, ok := rc.mutation.CreatedAt(); ok { _spec.Fields = append(_spec.Fields, &sqlgraph.FieldSpec{ Type: field.TypeTime, diff --git a/ent/review_update.go b/ent/review_update.go index aa728633..aff1ea71 100644 --- a/ent/review_update.go +++ b/ent/review_update.go @@ -45,6 +45,26 @@ func (ru *ReviewUpdate) SetNillableStatus(r *review.Status) *ReviewUpdate { return ru } +// SetComment sets the "comment" field. +func (ru *ReviewUpdate) SetComment(s string) *ReviewUpdate { + ru.mutation.SetComment(s) + return ru +} + +// SetNillableComment sets the "comment" field if the given value is not nil. +func (ru *ReviewUpdate) SetNillableComment(s *string) *ReviewUpdate { + if s != nil { + ru.SetComment(*s) + } + return ru +} + +// ClearComment clears the value of the "comment" field. +func (ru *ReviewUpdate) ClearComment() *ReviewUpdate { + ru.mutation.ClearComment() + return ru +} + // SetCreatedAt sets the "created_at" field. func (ru *ReviewUpdate) SetCreatedAt(t time.Time) *ReviewUpdate { ru.mutation.SetCreatedAt(t) @@ -250,6 +270,19 @@ func (ru *ReviewUpdate) sqlSave(ctx context.Context) (n int, err error) { Column: review.FieldStatus, }) } + if value, ok := ru.mutation.Comment(); ok { + _spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{ + Type: field.TypeString, + Value: value, + Column: review.FieldComment, + }) + } + if ru.mutation.CommentCleared() { + _spec.Fields.Clear = append(_spec.Fields.Clear, &sqlgraph.FieldSpec{ + Type: field.TypeString, + Column: review.FieldComment, + }) + } if value, ok := ru.mutation.CreatedAt(); ok { _spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{ Type: field.TypeTime, @@ -421,6 +454,26 @@ func (ruo *ReviewUpdateOne) SetNillableStatus(r *review.Status) *ReviewUpdateOne return ruo } +// SetComment sets the "comment" field. +func (ruo *ReviewUpdateOne) SetComment(s string) *ReviewUpdateOne { + ruo.mutation.SetComment(s) + return ruo +} + +// SetNillableComment sets the "comment" field if the given value is not nil. +func (ruo *ReviewUpdateOne) SetNillableComment(s *string) *ReviewUpdateOne { + if s != nil { + ruo.SetComment(*s) + } + return ruo +} + +// ClearComment clears the value of the "comment" field. +func (ruo *ReviewUpdateOne) ClearComment() *ReviewUpdateOne { + ruo.mutation.ClearComment() + return ruo +} + // SetCreatedAt sets the "created_at" field. func (ruo *ReviewUpdateOne) SetCreatedAt(t time.Time) *ReviewUpdateOne { ruo.mutation.SetCreatedAt(t) @@ -650,6 +703,19 @@ func (ruo *ReviewUpdateOne) sqlSave(ctx context.Context) (_node *Review, err err Column: review.FieldStatus, }) } + if value, ok := ruo.mutation.Comment(); ok { + _spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{ + Type: field.TypeString, + Value: value, + Column: review.FieldComment, + }) + } + if ruo.mutation.CommentCleared() { + _spec.Fields.Clear = append(_spec.Fields.Clear, &sqlgraph.FieldSpec{ + Type: field.TypeString, + Column: review.FieldComment, + }) + } if value, ok := ruo.mutation.CreatedAt(); ok { _spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{ Type: field.TypeTime, diff --git a/ent/runtime.go b/ent/runtime.go index e7b9a14c..22d4e3cc 100644 --- a/ent/runtime.go +++ b/ent/runtime.go @@ -174,11 +174,11 @@ func init() { reviewFields := schema.Review{}.Fields() _ = reviewFields // reviewDescCreatedAt is the schema descriptor for created_at field. - reviewDescCreatedAt := reviewFields[1].Descriptor() + reviewDescCreatedAt := reviewFields[2].Descriptor() // review.DefaultCreatedAt holds the default value on creation for the created_at field. review.DefaultCreatedAt = reviewDescCreatedAt.Default.(func() time.Time) // reviewDescUpdatedAt is the schema descriptor for updated_at field. - reviewDescUpdatedAt := reviewFields[2].Descriptor() + reviewDescUpdatedAt := reviewFields[3].Descriptor() // review.DefaultUpdatedAt holds the default value on creation for the updated_at field. review.DefaultUpdatedAt = reviewDescUpdatedAt.Default.(func() time.Time) // review.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field. diff --git a/ent/schema/review.go b/ent/schema/review.go index ae16cd84..2d72c709 100644 --- a/ent/schema/review.go +++ b/ent/schema/review.go @@ -22,6 +22,8 @@ func (Review) Fields() []ent.Field { "approved", ). Default("pending"), + field.Text("comment"). + Optional(), field.Time("created_at"). Default(nowUTC), field.Time("updated_at"). diff --git a/internal/pkg/store/review.go b/internal/pkg/store/review.go index 4f106009..85f2ed82 100644 --- a/internal/pkg/store/review.go +++ b/internal/pkg/store/review.go @@ -91,6 +91,7 @@ func (s *Store) FindReviewOfUser(ctx context.Context, u *ent.User, d *ent.Deploy func (s *Store) CreateReview(ctx context.Context, rv *ent.Review) (*ent.Review, error) { rv, err := s.c.Review. Create(). + SetComment(rv.Comment). SetDeploymentID(rv.DeploymentID). SetUserID(rv.UserID). Save(ctx) @@ -111,6 +112,7 @@ func (s *Store) UpdateReview(ctx context.Context, rv *ent.Review) (*ent.Review, rv, err := s.c.Review. UpdateOne(rv). SetStatus(rv.Status). + SetComment(rv.Comment). Save(ctx) if ent.IsValidationError(err) { return nil, e.NewErrorWithMessage( diff --git a/internal/pkg/store/review_test.go b/internal/pkg/store/review_test.go new file mode 100644 index 00000000..5a118a87 --- /dev/null +++ b/internal/pkg/store/review_test.go @@ -0,0 +1,59 @@ +package store + +import ( + "context" + "testing" + + "github.com/gitploy-io/gitploy/ent/enttest" + "github.com/gitploy-io/gitploy/ent/migrate" + "github.com/gitploy-io/gitploy/ent/review" + "github.com/gitploy-io/gitploy/pkg/e" +) + +func TestStore_UpdateReview(t *testing.T) { + t.Run("Return an unprocessible entity error when the vaildation is failed.", func(t *testing.T) { + ctx := context.Background() + + client := enttest.Open(t, "sqlite3", "file:ent?mode=memory&cache=shared&_fk=1", + enttest.WithMigrateOptions(migrate.WithForeignKeys(false)), + ) + defer client.Close() + + r := client.Review. + Create(). + SetDeploymentID(1). + SetUserID(1). + SaveX(ctx) + + s := NewStore(client) + + r.Status = review.Status("UNPROCESSIBLE") + _, err := s.UpdateReview(ctx, r) + if !e.HasErrorCode(err, e.ErrorCodeUnprocessableEntity) { + t.Fatalf("UpdateReview error code = %v, wanted unprocessable_entity", err) + } + }) + + t.Run("Update the review.", func(t *testing.T) { + ctx := context.Background() + + client := enttest.Open(t, "sqlite3", "file:ent?mode=memory&cache=shared&_fk=1", + enttest.WithMigrateOptions(migrate.WithForeignKeys(false)), + ) + defer client.Close() + + r := client.Review. + Create(). + SetDeploymentID(1). + SetUserID(1). + SaveX(ctx) + + s := NewStore(client) + + r.Status = review.StatusApproved + _, err := s.UpdateReview(ctx, r) + if err != nil { + t.Fatalf("UpdateReview returns an error: %v", err) + } + }) +} diff --git a/internal/server/api/v1/repos/review.go b/internal/server/api/v1/repos/review.go index fe4a844f..93ac2bbb 100644 --- a/internal/server/api/v1/repos/review.go +++ b/internal/server/api/v1/repos/review.go @@ -23,7 +23,8 @@ import ( type ( reviewPatchPayload struct { - Status string `json:"status"` + Status string `json:"status"` + Comment *string `json:"comment"` } ) @@ -130,6 +131,14 @@ func (r *Repo) UpdateUserReview(c *gin.Context) { ) return } + if err := review.StatusValidator(review.Status(p.Status)); err != nil { + r.log.Warn("The status is invalid.", zap.Error(err)) + gb.ResponseWithError( + c, + e.NewErrorWithMessage(e.ErrorCodeInvalidRequest, "The status is invalid.", nil), + ) + return + } vu, _ := c.Get(gb.KeyUser) u := vu.(*ent.User) @@ -151,21 +160,24 @@ func (r *Repo) UpdateUserReview(c *gin.Context) { return } - if p.Status != string(rv.Status) { - rv.Status = review.Status(p.Status) - if rv, err = r.i.UpdateReview(ctx, rv); err != nil { - r.log.Check(gb.GetZapLogLevel(err), "Failed to update the review.").Write(zap.Error(err)) - gb.ResponseWithError(c, err) - return - } - - if _, err := r.i.CreateEvent(ctx, &ent.Event{ - Kind: event.KindReview, - Type: event.TypeUpdated, - ReviewID: rv.ID, - }); err != nil { - r.log.Error("Failed to create the event.", zap.Error(err)) - } + rv.Status = review.Status(p.Status) + + if p.Comment != nil { + rv.Comment = *p.Comment + } + + if rv, err = r.i.UpdateReview(ctx, rv); err != nil { + r.log.Check(gb.GetZapLogLevel(err), "Failed to update the review.").Write(zap.Error(err)) + gb.ResponseWithError(c, err) + return + } + + if _, err := r.i.CreateEvent(ctx, &ent.Event{ + Kind: event.KindReview, + Type: event.TypeUpdated, + ReviewID: rv.ID, + }); err != nil { + r.log.Error("Failed to create the event.", zap.Error(err)) } gb.Response(c, http.StatusOK, rv) diff --git a/internal/server/api/v1/repos/review_test.go b/internal/server/api/v1/repos/review_test.go new file mode 100644 index 00000000..e6655146 --- /dev/null +++ b/internal/server/api/v1/repos/review_test.go @@ -0,0 +1,47 @@ +package repos + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/gitploy-io/gitploy/internal/server/api/v1/repos/mock" + "github.com/golang/mock/gomock" +) + +func init() { + gin.SetMode(gin.ReleaseMode) +} + +func TestRepo_UpdateUserReview(t *testing.T) { + t.Run("Return 400 code when the status is invalid", func(t *testing.T) { + input := struct { + payload *reviewPatchPayload + }{ + payload: &reviewPatchPayload{ + Status: "INVALID", + }, + } + + ctrl := gomock.NewController(t) + m := mock.NewMockInteractor(ctrl) + + router := gin.New() + + r := NewRepo(RepoConfig{}, m) + router.PATCH("/deployments/:number/review", r.UpdateUserReview) + + p, _ := json.Marshal(input.payload) + req, _ := http.NewRequest("PATCH", "/deployments/1/review", bytes.NewBuffer(p)) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("Code = %v, wanted %v", w.Code, http.StatusBadRequest) + } + }) +} diff --git a/openapi/v1.yaml b/openapi/v1.yaml index a6f38197..43a97c39 100644 --- a/openapi/v1.yaml +++ b/openapi/v1.yaml @@ -844,6 +844,9 @@ paths: enum: - approved - rejected + comment: + type: string + description: Leave the comment with the review. required: - status responses: @@ -1769,6 +1772,8 @@ components: - pending - rejected - approved + comment: + type: string created_at: type: string updated_at: @@ -1783,6 +1788,7 @@ components: required: - id - status + - comment - created_at - updated_at RateLimit: diff --git a/ui/src/App.less b/ui/src/App.less index c760040c..642ea1a9 100644 --- a/ui/src/App.less +++ b/ui/src/App.less @@ -32,8 +32,8 @@ } &-pending-icon { - height: 7px; - width: 7px; + height: 6px; + width: 6px; background-color: #bf8700; border-radius: 50%; display: inline-block; diff --git a/ui/src/apis/review.ts b/ui/src/apis/review.ts index d66326af..dafd23f1 100644 --- a/ui/src/apis/review.ts +++ b/ui/src/apis/review.ts @@ -13,12 +13,13 @@ import { } from '../models' export interface ReviewData { - id: number, + id: number status: string + comment: string created_at: string updated_at: string edges: { - user: UserData, + user: UserData deployment: DeploymentData } } @@ -39,6 +40,7 @@ export const mapDataToReview = (data: ReviewData): Review => { return { id: data.id, status: mapDataToReviewStatus(data.status), + comment: data.comment, createdAt: new Date(data.created_at), updatedAt: new Date(data.updated_at), user, @@ -98,9 +100,10 @@ export const getUserReview = async (namespace: string, name: string, number: num return review } -export const approveReview = async (namespace: string, name: string, number: number): Promise => { +export const approveReview = async (namespace: string, name: string, number: number, comment?: string): Promise => { const body = { status: "approved", + comment, } const res = await _fetch(`${instance}/api/v1/repos/${namespace}/${name}/deployments/${number}/review`, { credentials: "same-origin", @@ -119,9 +122,10 @@ export const approveReview = async (namespace: string, name: string, number: num return review } -export const rejectReview = async (namespace: string, name: string, number: number): Promise => { +export const rejectReview = async (namespace: string, name: string, number: number, comment?: string): Promise => { const body = { status: "rejected", + comment, } const res = await _fetch(`${instance}/api/v1/repos/${namespace}/${name}/deployments/${number}/review`, { credentials: "same-origin", diff --git a/ui/src/components/DeployConfirm.tsx b/ui/src/components/DeployConfirm.tsx index 14d6507f..41dd4985 100644 --- a/ui/src/components/DeployConfirm.tsx +++ b/ui/src/components/DeployConfirm.tsx @@ -2,10 +2,11 @@ import { Form, Typography, Avatar, Button, Collapse, Timeline } from "antd" import moment from "moment" import { useState } from "react" -import { Deployment, Commit } from "../models" +import { Deployment, Commit, Review } from "../models" import DeploymentRefCode from "./DeploymentRefCode" import DeploymentStatusBadge from "./DeploymentStatusBadge" import DeploymentStatusSteps from "./DeploymentStatusSteps" +import ReviewerList, { ReviewStatus } from "./ReviewerList" const { Paragraph, Text } = Typography const { Panel } = Collapse @@ -15,12 +16,13 @@ interface DeployConfirmProps { deploying: boolean deployment: Deployment changes: Commit[] + reviews: Review[] onClickDeploy(): void } export default function DeployConfirm(props: DeployConfirmProps): JSX.Element { const layout = { - labelCol: { span: 6}, + labelCol: { span: 5}, wrapperCol: { span: 16 }, style: {marginBottom: 12} }; @@ -48,6 +50,7 @@ export default function DeployConfirm(props: DeployConfirmProps): JSX.Element { 0)? {marginBottom: 0} : {}} > {(props.deployment.statuses && props.deployment.statuses.length > 0)? @@ -80,6 +83,23 @@ export default function DeployConfirm(props: DeployConfirmProps): JSX.Element { > {moment(props.deployment.createdAt).format("YYYY-MM-DD HH:mm:ss")} + 0)? {marginBottom: 0} : {}} + > + {(props.reviews.length > 0)? + + } + style={{position: "relative", top: "-5px", left: "-15px"}} + > + + + : + No Reviewers} + - - - - - - - - }> - - - ) - -} \ No newline at end of file diff --git a/ui/src/components/ReviewList.tsx b/ui/src/components/ReviewList.tsx deleted file mode 100644 index 86a2455c..00000000 --- a/ui/src/components/ReviewList.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Typography } from "antd" -import { CheckOutlined, CloseOutlined } from "@ant-design/icons" - -import { Review, ReviewStatusEnum } from "../models" -import UserAvatar from "./UserAvatar" - -const { Text } = Typography - - -export interface ReviewListProps { - reviews: Review[] -} - -export default function ReviewList(props: ReviewListProps): JSX.Element { - return ( -
-
- Reviewers -
-
- {(props.reviews.length !== 0) ? -
- {props.reviews.map((r, idx) => { - return ( -
- {mapReviewStatusToIcon(r.status)}  -
- ) - })} -
: - No approvers } -
-
- ) -} - -function mapReviewStatusToIcon(status: ReviewStatusEnum): JSX.Element { - switch (status) { - case ReviewStatusEnum.Pending: - return - case ReviewStatusEnum.Approved: - return - case ReviewStatusEnum.Rejected: - return - default: - return - } -} \ No newline at end of file diff --git a/ui/src/components/ReviewModal.tsx b/ui/src/components/ReviewModal.tsx new file mode 100644 index 00000000..ad5ac54f --- /dev/null +++ b/ui/src/components/ReviewModal.tsx @@ -0,0 +1,62 @@ +import { useState } from "react" +import { Button, Modal, Space, Input } from "antd" + +import { Review } from "../models" + +const { TextArea } = Input + +interface ReviewModalProps { + review: Review + onClickApprove(comment: string): void + onClickReject(comment: string): void +} + +export default function ReviewModal(props: ReviewModalProps): JSX.Element { + const [comment, setComment] = useState(props.review.comment) + + const onChangeComment = (e: any) => { + setComment(e.target.value) + } + + const [isModalVisible, setIsModalVisible] = useState(false); + + const showModal = () => { + setIsModalVisible(true); + } + + const onClickApprove = () => { + props.onClickApprove(comment) + setIsModalVisible(false) + } + + const onClickReject = () => { + props.onClickReject(comment) + setIsModalVisible(false) + } + + const onClickCancel = () => { + setIsModalVisible(false) + } + + return ( + <> + + + + + } + > +