Merge remote-tracking branch 'origin' into abhinav/CODE-830

This commit is contained in:
Abhinav Singh 2023-09-12 16:20:15 -07:00
commit 3ec8d18fbf
111 changed files with 6162 additions and 506 deletions

View File

@ -22,7 +22,7 @@ import (
"github.com/harness/gitness/internal/api/controller/pipeline"
"github.com/harness/gitness/internal/api/controller/plugin"
"github.com/harness/gitness/internal/api/controller/principal"
"github.com/harness/gitness/internal/api/controller/pullreq"
pullreq2 "github.com/harness/gitness/internal/api/controller/pullreq"
"github.com/harness/gitness/internal/api/controller/repo"
"github.com/harness/gitness/internal/api/controller/secret"
"github.com/harness/gitness/internal/api/controller/service"
@ -52,7 +52,7 @@ import (
"github.com/harness/gitness/internal/services/exporter"
"github.com/harness/gitness/internal/services/importer"
"github.com/harness/gitness/internal/services/job"
pullreq2 "github.com/harness/gitness/internal/services/pullreq"
"github.com/harness/gitness/internal/services/pullreq"
trigger2 "github.com/harness/gitness/internal/services/trigger"
"github.com/harness/gitness/internal/services/webhook"
"github.com/harness/gitness/internal/store"
@ -107,6 +107,10 @@ func initSystem(ctx context.Context, config *types.Config) (*server.System, erro
if err != nil {
return nil, err
}
encrypter, err := encrypt.ProvideEncrypter(config)
if err != nil {
return nil, err
}
jobStore := database.ProvideJobStore(db)
pubsubConfig := pubsub.ProvideConfig(config)
universalClient, err := server.ProvideRedis(config)
@ -121,7 +125,7 @@ func initSystem(ctx context.Context, config *types.Config) (*server.System, erro
if err != nil {
return nil, err
}
repository, err := importer.ProvideRepoImporter(config, provider, gitrpcInterface, repoStore, jobScheduler, executor)
repository, err := importer.ProvideRepoImporter(config, provider, gitrpcInterface, repoStore, encrypter, jobScheduler, executor)
if err != nil {
return nil, err
}
@ -149,14 +153,10 @@ func initSystem(ctx context.Context, config *types.Config) (*server.System, erro
return nil, err
}
spaceController := space.ProvideController(db, provider, eventsStreamer, pathUID, authorizer, pathStore, pipelineStore, secretStore, connectorStore, templateStore, spaceStore, repoStore, principalStore, repoController, membershipStore, repository, exporterRepository)
pipelineController := pipeline.ProvideController(db, pathUID, pathStore, repoStore, authorizer, pipelineStore)
encrypter, err := encrypt.ProvideEncrypter(config)
if err != nil {
return nil, err
}
secretController := secret.ProvideController(db, pathUID, pathStore, encrypter, secretStore, authorizer, spaceStore)
triggerStore := database.ProvideTriggerStore(db)
triggerController := trigger.ProvideController(db, authorizer, triggerStore, pipelineStore, repoStore)
pipelineController := pipeline.ProvideController(db, pathUID, pathStore, repoStore, triggerStore, authorizer, pipelineStore)
secretController := secret.ProvideController(db, pathUID, pathStore, encrypter, secretStore, authorizer, spaceStore)
triggerController := trigger.ProvideController(db, authorizer, triggerStore, pathUID, pipelineStore, repoStore)
connectorController := connector.ProvideController(db, pathUID, connectorStore, authorizer, spaceStore)
templateController := template.ProvideController(db, pathUID, templateStore, authorizer, spaceStore)
pluginStore := database.ProvidePluginStore(db)
@ -179,10 +179,6 @@ func initSystem(ctx context.Context, config *types.Config) (*server.System, erro
return nil, err
}
migrator := codecomments.ProvideMigrator(gitrpcInterface)
pullreqController := pullreq.ProvideController(db, provider, authorizer, pullReqStore, pullReqActivityStore, codeCommentView, pullReqReviewStore, pullReqReviewerStore, repoStore, principalStore, gitrpcInterface, reporter, mutexManager, migrator)
webhookConfig := server.ProvideWebhookConfig(config)
webhookStore := database.ProvideWebhookStore(db)
webhookExecutionStore := database.ProvideWebhookExecutionStore(db)
readerFactory, err := events4.ProvideReaderFactory(eventsSystem)
if err != nil {
return nil, err
@ -191,6 +187,16 @@ func initSystem(ctx context.Context, config *types.Config) (*server.System, erro
if err != nil {
return nil, err
}
repoGitInfoView := database.ProvideRepoGitInfoView(db)
repoGitInfoCache := cache.ProvideRepoGitInfoCache(repoGitInfoView)
pullreqService, err := pullreq.ProvideService(ctx, config, readerFactory, eventsReaderFactory, reporter, gitrpcInterface, db, repoGitInfoCache, repoStore, pullReqStore, pullReqActivityStore, codeCommentView, migrator, pubSub, provider)
if err != nil {
return nil, err
}
pullreqController := pullreq2.ProvideController(db, provider, authorizer, pullReqStore, pullReqActivityStore, codeCommentView, pullReqReviewStore, pullReqReviewerStore, repoStore, principalStore, gitrpcInterface, reporter, mutexManager, migrator, pullreqService)
webhookConfig := server.ProvideWebhookConfig(config)
webhookStore := database.ProvideWebhookStore(db)
webhookExecutionStore := database.ProvideWebhookExecutionStore(db)
webhookService, err := webhook.ProvideService(ctx, webhookConfig, readerFactory, eventsReaderFactory, webhookStore, webhookExecutionStore, repoStore, pullReqStore, provider, principalStore, gitrpcInterface)
if err != nil {
return nil, err
@ -233,14 +239,8 @@ func initSystem(ctx context.Context, config *types.Config) (*server.System, erro
return nil, err
}
cronManager := cron.ProvideManager(serverConfig)
repoGitInfoView := database.ProvideRepoGitInfoView(db)
repoGitInfoCache := cache.ProvideRepoGitInfoCache(repoGitInfoView)
pullreqService, err := pullreq2.ProvideService(ctx, config, readerFactory, eventsReaderFactory, reporter, gitrpcInterface, db, repoGitInfoCache, repoStore, pullReqStore, pullReqActivityStore, codeCommentView, migrator, pubSub, provider)
if err != nil {
return nil, err
}
triggerConfig := server.ProvideTriggerConfig(config)
triggerService, err := trigger2.ProvideService(ctx, triggerConfig, readerFactory, eventsReaderFactory)
triggerService, err := trigger2.ProvideService(ctx, triggerConfig, triggerStore, commitService, pullReqStore, repoStore, pipelineStore, triggererTriggerer, readerFactory, eventsReaderFactory)
if err != nil {
return nil, err
}

View File

@ -70,7 +70,6 @@ func (c *Controller) Create(
Sender: session.Principal.UID,
Source: branch,
Target: branch,
Action: enum.TriggerEventCustom,
Params: map[string]string{},
Timestamp: commit.Author.When.UnixMilli(),
}

View File

@ -18,6 +18,7 @@ type Controller struct {
uidCheck check.PathUID
pathStore store.PathStore
repoStore store.RepoStore
triggerStore store.TriggerStore
authorizer authz.Authorizer
pipelineStore store.PipelineStore
}
@ -28,6 +29,7 @@ func NewController(
authorizer authz.Authorizer,
pathStore store.PathStore,
repoStore store.RepoStore,
triggerStore store.TriggerStore,
pipelineStore store.PipelineStore,
) *Controller {
return &Controller{
@ -35,6 +37,7 @@ func NewController(
uidCheck: uidCheck,
pathStore: pathStore,
repoStore: repoStore,
triggerStore: triggerStore,
authorizer: authorizer,
pipelineStore: pipelineStore,
}

View File

@ -16,6 +16,8 @@ import (
"github.com/harness/gitness/types"
"github.com/harness/gitness/types/check"
"github.com/harness/gitness/types/enum"
"github.com/rs/zerolog/log"
)
var (
@ -69,6 +71,27 @@ func (c *Controller) Create(
return nil, fmt.Errorf("pipeline creation failed: %w", err)
}
// Try to create a default trigger on pipeline creation.
// Default trigger operations are set on pull request created, reopened or updated.
// We log an error on failure but don't fail the op.
trigger := &types.Trigger{
Description: "auto-created trigger on pipeline creation",
Created: now,
Updated: now,
PipelineID: pipeline.ID,
RepoID: pipeline.RepoID,
CreatedBy: session.Principal.ID,
UID: "default",
Actions: []enum.TriggerAction{enum.TriggerActionPullReqCreated,
enum.TriggerActionPullReqReopened, enum.TriggerActionPullReqBranchUpdated},
Enabled: true,
Version: 0,
}
err = c.triggerStore.Create(ctx, trigger)
if err != nil {
log.Ctx(ctx).Err(err).Msg("failed to create auto trigger on pipeline creation")
}
return pipeline, nil
}

View File

@ -22,8 +22,10 @@ func ProvideController(db *sqlx.DB,
uidCheck check.PathUID,
pathStore store.PathStore,
repoStore store.RepoStore,
triggerStore store.TriggerStore,
authorizer authz.Authorizer,
pipelineStore store.PipelineStore,
) *Controller {
return NewController(db, uidCheck, authorizer, pathStore, repoStore, pipelineStore)
return NewController(db, uidCheck, authorizer, pathStore,
repoStore, triggerStore, pipelineStore)
}

View File

@ -16,6 +16,7 @@ import (
"github.com/harness/gitness/internal/auth/authz"
pullreqevents "github.com/harness/gitness/internal/events/pullreq"
"github.com/harness/gitness/internal/services/codecomments"
"github.com/harness/gitness/internal/services/pullreq"
"github.com/harness/gitness/internal/store"
"github.com/harness/gitness/internal/url"
"github.com/harness/gitness/lock"
@ -40,6 +41,7 @@ type Controller struct {
eventReporter *pullreqevents.Reporter
mtxManager lock.MutexManager
codeCommentMigrator *codecomments.Migrator
pullreqService *pullreq.Service
}
func NewController(
@ -57,6 +59,7 @@ func NewController(
eventReporter *pullreqevents.Reporter,
mtxManager lock.MutexManager,
codeCommentMigrator *codecomments.Migrator,
pullreqService *pullreq.Service,
) *Controller {
return &Controller{
db: db,
@ -73,6 +76,7 @@ func NewController(
codeCommentMigrator: codeCommentMigrator,
eventReporter: eventReporter,
mtxManager: mtxManager,
pullreqService: pullreqService,
}
}

View File

@ -0,0 +1,33 @@
// Copyright 2022 Harness Inc. All rights reserved.
// Use of this source code is governed by the Polyform Free Trial License
// that can be found in the LICENSE.md file for this repository.
package pullreq
import (
"context"
"fmt"
"github.com/harness/gitness/internal/auth"
"github.com/harness/gitness/types/enum"
)
// Recheck re-checks all system PR checks (mergeability check, ...).
func (c *Controller) Recheck(
ctx context.Context,
session *auth.Session,
repoRef string,
prNum int64,
) error {
repo, err := c.getRepoCheckAccess(ctx, session, repoRef, enum.PermissionRepoPush)
if err != nil {
return fmt.Errorf("failed to acquire access to repo: %w", err)
}
err = c.pullreqService.UpdateMergeDataIfRequired(ctx, repo.ID, prNum)
if err != nil {
return fmt.Errorf("failed to refresh merge data: %w", err)
}
return nil
}

View File

@ -9,6 +9,7 @@ import (
"github.com/harness/gitness/internal/auth/authz"
pullreqevents "github.com/harness/gitness/internal/events/pullreq"
"github.com/harness/gitness/internal/services/codecomments"
"github.com/harness/gitness/internal/services/pullreq"
"github.com/harness/gitness/internal/store"
"github.com/harness/gitness/internal/url"
"github.com/harness/gitness/lock"
@ -28,12 +29,14 @@ func ProvideController(db *sqlx.DB, urlProvider *url.Provider, authorizer authz.
pullReqReviewStore store.PullReqReviewStore, pullReqReviewerStore store.PullReqReviewerStore,
repoStore store.RepoStore, principalStore store.PrincipalStore,
rpcClient gitrpc.Interface, eventReporter *pullreqevents.Reporter,
mtxManager lock.MutexManager, codeCommentMigrator *codecomments.Migrator) *Controller {
mtxManager lock.MutexManager, codeCommentMigrator *codecomments.Migrator,
pullreqService *pullreq.Service,
) *Controller {
return NewController(db, urlProvider, authorizer,
pullReqStore, pullReqActivityStore,
codeCommentsView,
pullReqReviewStore, pullReqReviewerStore,
repoStore, principalStore,
rpcClient, eventReporter,
mtxManager, codeCommentMigrator)
mtxManager, codeCommentMigrator, pullreqService)
}

View File

@ -7,12 +7,12 @@ package repo
import (
"context"
"fmt"
"github.com/harness/gitness/internal/api/usererror"
"strconv"
"strings"
"time"
apiauth "github.com/harness/gitness/internal/api/auth"
"github.com/harness/gitness/internal/api/usererror"
"github.com/harness/gitness/internal/auth"
"github.com/harness/gitness/internal/paths"
"github.com/harness/gitness/store/database/dbtx"

View File

@ -15,6 +15,7 @@ import (
"github.com/harness/gitness/internal/auth"
"github.com/harness/gitness/internal/writer"
"github.com/harness/gitness/types/enum"
"github.com/rs/zerolog/log"
)

View File

@ -0,0 +1,58 @@
// Copyright 2022 Harness Inc. All rights reserved.
// Use of this source code is governed by the Polyform Free Trial License
// that can be found in the LICENSE.md file for this repository.
package trigger
import (
"github.com/harness/gitness/types/check"
"github.com/harness/gitness/types/enum"
)
const (
// triggerMaxSecretLength defines the max allowed length of a trigger secret.
// TODO: Check whether this is sufficient for other SCM providers once we
// add support. For now it's good to have a limit and increase if needed.
triggerMaxSecretLength = 4096
)
// checkSecret validates the secret of a trigger.
func checkSecret(secret string) error {
if len(secret) > triggerMaxSecretLength {
return check.NewValidationErrorf("The secret of a trigger can be at most %d characters long.",
triggerMaxSecretLength)
}
return nil
}
// checkActions validates the trigger actions.
func checkActions(actions []enum.TriggerAction) error {
// ignore duplicates here, should be deduplicated later
for _, action := range actions {
if _, ok := action.Sanitize(); !ok {
return check.NewValidationErrorf("The provided trigger action '%s' is invalid.", action)
}
}
return nil
}
// deduplicateActions de-duplicates the actions provided by in the trigger.
func deduplicateActions(in []enum.TriggerAction) []enum.TriggerAction {
if len(in) == 0 {
return []enum.TriggerAction{}
}
actionSet := make(map[enum.TriggerAction]struct{})
out := make([]enum.TriggerAction, 0, len(in))
for _, action := range in {
if _, ok := actionSet[action]; ok {
continue
}
actionSet[action] = struct{}{}
out = append(out, action)
}
return out
}

View File

@ -7,6 +7,7 @@ package trigger
import (
"github.com/harness/gitness/internal/auth/authz"
"github.com/harness/gitness/internal/store"
"github.com/harness/gitness/types/check"
"github.com/jmoiron/sqlx"
)
@ -15,6 +16,7 @@ type Controller struct {
db *sqlx.DB
authorizer authz.Authorizer
triggerStore store.TriggerStore
uidCheck check.PathUID
pipelineStore store.PipelineStore
repoStore store.RepoStore
}
@ -23,6 +25,7 @@ func NewController(
db *sqlx.DB,
authorizer authz.Authorizer,
triggerStore store.TriggerStore,
uidCheck check.PathUID,
pipelineStore store.PipelineStore,
repoStore store.RepoStore,
) *Controller {
@ -30,6 +33,7 @@ func NewController(
db: db,
authorizer: authorizer,
triggerStore: triggerStore,
uidCheck: uidCheck,
pipelineStore: pipelineStore,
repoStore: repoStore,
}

View File

@ -12,13 +12,17 @@ import (
apiauth "github.com/harness/gitness/internal/api/auth"
"github.com/harness/gitness/internal/auth"
"github.com/harness/gitness/types"
"github.com/harness/gitness/types/check"
"github.com/harness/gitness/types/enum"
)
// TODO: Add more as needed.
type CreateInput struct {
Description string `json:"description"`
UID string `json:"uid"`
Description string `json:"description"`
UID string `json:"uid"`
Secret string `json:"secret"`
Enabled bool `json:"enabled"`
Actions []enum.TriggerAction `json:"actions"`
}
func (c *Controller) Create(
@ -39,6 +43,11 @@ func (c *Controller) Create(
return nil, fmt.Errorf("failed to authorize pipeline: %w", err)
}
err = c.checkCreateInput(in)
if err != nil {
return nil, fmt.Errorf("invalid input: %w", err)
}
pipeline, err := c.pipelineStore.FindByUID(ctx, repo.ID, pipelineUID)
if err != nil {
return nil, fmt.Errorf("failed to find pipeline: %w", err)
@ -47,6 +56,11 @@ func (c *Controller) Create(
now := time.Now().UnixMilli()
trigger := &types.Trigger{
Description: in.Description,
Enabled: in.Enabled,
Secret: in.Secret,
CreatedBy: session.Principal.ID,
RepoID: repo.ID,
Actions: deduplicateActions(in.Actions),
UID: in.UID,
PipelineID: pipeline.ID,
Created: now,
@ -60,3 +74,20 @@ func (c *Controller) Create(
return trigger, nil
}
func (c *Controller) checkCreateInput(in *CreateInput) error {
if err := check.Description(in.Description); err != nil {
return err
}
if err := checkSecret(in.Secret); err != nil {
return err
}
if err := checkActions(in.Actions); err != nil {
return err
}
if err := c.uidCheck(in.UID, false); err != nil {
return err
}
return nil
}

View File

@ -11,13 +11,17 @@ import (
apiauth "github.com/harness/gitness/internal/api/auth"
"github.com/harness/gitness/internal/auth"
"github.com/harness/gitness/types"
"github.com/harness/gitness/types/check"
"github.com/harness/gitness/types/enum"
)
// UpdateInput is used for updating a trigger.
type UpdateInput struct {
Description string `json:"description"`
UID string `json:"uid"`
Description *string `json:"description"`
UID *string `json:"uid"`
Actions []enum.TriggerAction `json:"actions"`
Secret *string `json:"secret"`
Enabled *bool `json:"enabled"` // can be nil, so keeping it a pointer
}
func (c *Controller) Update(
@ -38,6 +42,11 @@ func (c *Controller) Update(
return nil, fmt.Errorf("failed to authorize pipeline: %w", err)
}
err = c.checkUpdateInput(in)
if err != nil {
return nil, fmt.Errorf("invalid input: %w", err)
}
pipeline, err := c.pipelineStore.FindByUID(ctx, repo.ID, pipelineUID)
if err != nil {
return nil, fmt.Errorf("failed to find pipeline: %w", err)
@ -51,12 +60,48 @@ func (c *Controller) Update(
return c.triggerStore.UpdateOptLock(ctx,
trigger, func(original *types.Trigger) error {
// update values only if provided
if in.Description != "" {
original.Description = in.Description
if in.Description != nil {
original.Description = *in.Description
}
if in.UID != "" {
original.UID = in.UID
if in.UID != nil {
original.UID = *in.UID
}
if in.Actions != nil {
original.Actions = deduplicateActions(in.Actions)
}
if in.Secret != nil {
original.Secret = *in.Secret
}
if in.Enabled != nil {
original.Enabled = *in.Enabled
}
return nil
})
}
func (c *Controller) checkUpdateInput(in *UpdateInput) error {
if in.Description != nil {
if err := check.Description(*in.Description); err != nil {
return err
}
}
if in.Secret != nil {
if err := checkSecret(*in.Secret); err != nil {
return err
}
}
if in.Actions != nil {
if err := checkActions(in.Actions); err != nil {
return err
}
}
if in.UID != nil {
if err := c.uidCheck(*in.UID, false); err != nil {
return err
}
}
return nil
}

View File

@ -7,6 +7,7 @@ package trigger
import (
"github.com/harness/gitness/internal/auth/authz"
"github.com/harness/gitness/internal/store"
"github.com/harness/gitness/types/check"
"github.com/google/wire"
"github.com/jmoiron/sqlx"
@ -20,8 +21,9 @@ var WireSet = wire.NewSet(
func ProvideController(db *sqlx.DB,
authorizer authz.Authorizer,
triggerStore store.TriggerStore,
uidCheck check.PathUID,
pipelineStore store.PipelineStore,
repoStore store.RepoStore,
) *Controller {
return NewController(db, authorizer, triggerStore, pipelineStore, repoStore)
return NewController(db, authorizer, triggerStore, uidCheck, pipelineStore, repoStore)
}

View File

@ -0,0 +1,41 @@
// Copyright 2022 Harness Inc. All rights reserved.
// Use of this source code is governed by the Polyform Free Trial License
// that can be found in the LICENSE.md file for this repository.
package pullreq
import (
"net/http"
"github.com/harness/gitness/internal/api/controller/pullreq"
"github.com/harness/gitness/internal/api/render"
"github.com/harness/gitness/internal/api/request"
)
// HandleRecheck handles API that re-checks all system PR checks (mergeability check, ...).
func HandleRecheck(pullreqCtrl *pullreq.Controller) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
session, _ := request.AuthSessionFrom(ctx)
repoRef, err := request.GetRepoRefFromPath(r)
if err != nil {
render.TranslatedUserError(w, err)
return
}
pullreqNumber, err := request.GetPullReqNumberFromPath(r)
if err != nil {
render.TranslatedUserError(w, err)
return
}
err = pullreqCtrl.Recheck(ctx, session, repoRef, pullreqNumber)
if err != nil {
render.TranslatedUserError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}

View File

@ -11,6 +11,7 @@ import (
"github.com/harness/gitness/internal/api/render"
"github.com/harness/gitness/internal/api/request"
"github.com/harness/gitness/internal/writer"
"github.com/rs/zerolog/log"
)

View File

@ -469,4 +469,15 @@ func pullReqOperations(reflector *openapi3.Reflector) {
_ = reflector.SetJSONResponse(&opMetaData, new(usererror.Error), http.StatusForbidden)
_ = reflector.SetJSONResponse(&opMetaData, new(usererror.Error), http.StatusNotFound)
_ = reflector.Spec.AddOperation(http.MethodGet, "/repos/{repo_ref}/pullreq/{pullreq_number}/metadata", opMetaData)
recheckPullReq := openapi3.Operation{}
recheckPullReq.WithTags("pullreq")
recheckPullReq.WithMapOfAnything(map[string]interface{}{"operationId": "recheckPullReq"})
_ = reflector.SetRequest(&recheckPullReq, nil, http.MethodPost)
_ = reflector.SetJSONResponse(&recheckPullReq, nil, http.StatusNoContent)
_ = reflector.SetJSONResponse(&recheckPullReq, new(usererror.Error), http.StatusBadRequest)
_ = reflector.SetJSONResponse(&recheckPullReq, new(usererror.Error), http.StatusInternalServerError)
_ = reflector.SetJSONResponse(&recheckPullReq, new(usererror.Error), http.StatusUnauthorized)
_ = reflector.SetJSONResponse(&recheckPullReq, new(usererror.Error), http.StatusForbidden)
_ = reflector.Spec.AddOperation(http.MethodPost, "/repos/{repo_ref}/pullreq/{pullreq_number}/recheck", recheckPullReq)
}

View File

@ -42,3 +42,24 @@ func (f *service) FindRef(
// convert the RPC commit output to a types.Commit.
return controller.MapCommit(branchOutput.Branch.Commit)
}
// FindCommit finds information about a commit in gitness for the git SHA
func (f *service) FindCommit(
ctx context.Context,
repo *types.Repository,
sha string,
) (*types.Commit, error) {
readParams := gitrpc.ReadParams{
RepoUID: repo.GitUID,
}
commitOutput, err := f.gitRPCClient.GetCommit(ctx, &gitrpc.GetCommitParams{
ReadParams: readParams,
SHA: sha,
})
if err != nil {
return nil, err
}
// convert the RPC commit output to a types.Commit.
return controller.MapCommit(&commitOutput.Commit)
}

View File

@ -17,5 +17,8 @@ type (
CommitService interface {
// ref is the ref to fetch the commit from, eg refs/heads/master
FindRef(ctx context.Context, repo *types.Repository, ref string) (*types.Commit, error)
// FindCommit returns information about a commit in a repo.
FindCommit(ctx context.Context, repo *types.Repository, sha string) (*types.Commit, error)
}
)

View File

@ -9,7 +9,6 @@ import (
"errors"
"fmt"
"io"
"time"
"github.com/harness/gitness/internal/pipeline/events"
"github.com/harness/gitness/internal/pipeline/file"
@ -202,7 +201,6 @@ func (m *Manager) Accept(ctx context.Context, id int64, machine string) (*types.
stage.Machine = machine
stage.Status = enum.CIStatusPending
stage.Updated = time.Now().Unix()
err = m.Stages.Update(noContext, stage)
if errors.Is(err, gitness_store.ErrVersionConflict) {
log.Debug().Err(err).Msg("manager: stage processed by another agent")

View File

@ -7,8 +7,6 @@ package triggerer
import (
"strings"
"github.com/harness/gitness/types/enum"
"github.com/drone/drone-yaml/yaml"
)
@ -44,27 +42,6 @@ func skipCron(document *yaml.Pipeline, cron string) bool {
return !document.Trigger.Cron.Match(cron)
}
func skipMessage(hook *Hook) bool {
switch {
case hook.Event == enum.TriggerEventTag:
return false
case hook.Event == enum.TriggerEventCron:
return false
case hook.Event == enum.TriggerEventCustom:
return false
case hook.Event == enum.TriggerEventPromote:
return false
case hook.Event == enum.TriggerEventRollback:
return false
case skipMessageEval(hook.Message):
return true
case skipMessageEval(hook.Title):
return true
default:
return false
}
}
func skipMessageEval(str string) bool {
lower := strings.ToLower(str)
switch {

View File

@ -23,42 +23,35 @@ import (
"github.com/rs/zerolog/log"
)
// Trigger types
const (
TriggerHook = "@hook"
TriggerCron = "@cron"
)
var _ Triggerer = (*triggerer)(nil)
// Hook represents the payload of a post-commit hook.
type Hook struct {
Parent int64 `json:"parent"`
Trigger string `json:"trigger"`
Event string `json:"event"`
Action string `json:"action"`
Link string `json:"link"`
Timestamp int64 `json:"timestamp"`
Title string `json:"title"`
Message string `json:"message"`
Before string `json:"before"`
After string `json:"after"`
Ref string `json:"ref"`
Fork string `json:"fork"`
Source string `json:"source"`
Target string `json:"target"`
AuthorLogin string `json:"author_login"`
AuthorName string `json:"author_name"`
AuthorEmail string `json:"author_email"`
AuthorAvatar string `json:"author_avatar"`
Debug bool `json:"debug"`
Cron string `json:"cron"`
Sender string `json:"sender"`
Params map[string]string `json:"params"`
Parent int64 `json:"parent"`
Trigger string `json:"trigger"`
Action enum.TriggerAction `json:"action"`
Link string `json:"link"`
Timestamp int64 `json:"timestamp"`
Title string `json:"title"`
Message string `json:"message"`
Before string `json:"before"`
After string `json:"after"`
Ref string `json:"ref"`
Fork string `json:"fork"`
Source string `json:"source"`
Target string `json:"target"`
AuthorLogin string `json:"author_login"`
AuthorName string `json:"author_name"`
AuthorEmail string `json:"author_email"`
AuthorAvatar string `json:"author_avatar"`
Debug bool `json:"debug"`
Cron string `json:"cron"`
Sender string `json:"sender"`
Params map[string]string `json:"params"`
}
// Triggerer is responsible for triggering a Execution from an
// incoming drone. If an execution is skipped a nil value is
// incoming hook (could be manual or webhook). If an execution is skipped a nil value is
// returned.
type Triggerer interface {
Trigger(ctx context.Context, pipeline *types.Pipeline, hook *Hook) (*types.Execution, error)
@ -115,16 +108,14 @@ func (t *triggerer) Trigger(
}
}()
event := string(base.Action.GetTriggerEvent())
repo, err := t.repoStore.Find(ctx, pipeline.RepoID)
if err != nil {
log.Error().Err(err).Msg("could not find repo")
return nil, err
}
// if skipMessage(base) {
// logger.Infoln("trigger: skipping hook. found skip directive")
// return nil, nil
// }
// if base.Event == core.TriggerEventPullRequest {
// if repo.IgnorePulls {
// logger.Infoln("trigger: skipping hook. project ignores pull requests")
@ -251,9 +242,9 @@ func (t *triggerer) Trigger(
if skipBranch(pipeline, base.Target) {
log.Info().Str("pipeline", pipeline.Name).Msg("trigger: skipping pipeline, does not match branch")
} else if skipEvent(pipeline, base.Event) {
} else if skipEvent(pipeline, event) {
log.Info().Str("pipeline", pipeline.Name).Msg("trigger: skipping pipeline, does not match event")
} else if skipAction(pipeline, base.Action) {
} else if skipAction(pipeline, string(base.Action)) {
log.Info().Str("pipeline", pipeline.Name).Msg("trigger: skipping pipeline, does not match action")
} else if skipRef(pipeline, base.Ref) {
log.Info().Str("pipeline", pipeline.Name).Msg("trigger: skipping pipeline, does not match ref")
@ -282,6 +273,8 @@ func (t *triggerer) Trigger(
return nil, err
}
now := time.Now().UnixMilli()
execution := &types.Execution{
RepoID: repo.ID,
PipelineID: pipeline.ID,
@ -289,8 +282,8 @@ func (t *triggerer) Trigger(
Number: pipeline.Seq,
Parent: base.Parent,
Status: enum.CIStatusPending,
Event: base.Event,
Action: base.Action,
Event: event,
Action: string(base.Action),
Link: base.Link,
// Timestamp: base.Timestamp,
Title: trunc(base.Title, 2000),
@ -309,8 +302,8 @@ func (t *triggerer) Trigger(
Debug: base.Debug,
Sender: base.Sender,
Cron: base.Cron,
Created: time.Now().Unix(),
Updated: time.Now().Unix(),
Created: now,
Updated: now,
}
stages := make([]*types.Stage, len(matched))
@ -321,6 +314,8 @@ func (t *triggerer) Trigger(
onFailure = false
}
now := time.Now().UnixMilli()
stage := &types.Stage{
RepoID: repo.ID,
Number: int64(i + 1),
@ -337,8 +332,8 @@ func (t *triggerer) Trigger(
OnSuccess: onSuccess,
OnFailure: onFailure,
Labels: match.Node,
Created: time.Now().Unix(),
Updated: time.Now().Unix(),
Created: now,
Updated: now,
}
if stage.Kind == "pipeline" && stage.Type == "" {
stage.Type = "docker"
@ -454,14 +449,16 @@ func (t *triggerer) createExecutionWithError(
return nil, err
}
now := time.Now().UnixMilli()
execution := &types.Execution{
RepoID: pipeline.RepoID,
Number: pipeline.Seq,
Parent: base.Parent,
Status: enum.CIStatusError,
Error: message,
Event: base.Event,
Action: base.Action,
Event: string(base.Action.GetTriggerEvent()),
Action: string(base.Action),
Link: base.Link,
Title: base.Title,
Message: base.Message,
@ -477,10 +474,10 @@ func (t *triggerer) createExecutionWithError(
AuthorAvatar: base.AuthorAvatar,
Debug: base.Debug,
Sender: base.Sender,
Created: time.Now().Unix(),
Updated: time.Now().Unix(),
Started: time.Now().Unix(),
Finished: time.Now().Unix(),
Created: now,
Updated: now,
Started: now,
Finished: now,
}
err = t.executionStore.Create(ctx, execution)

View File

@ -462,6 +462,7 @@ func SetupPullReq(r chi.Router, pullreqCtrl *pullreq.Controller) {
r.Get("/", handlerpullreq.HandleFind(pullreqCtrl))
r.Patch("/", handlerpullreq.HandleUpdate(pullreqCtrl))
r.Post("/state", handlerpullreq.HandleState(pullreqCtrl))
r.Post("/recheck", handlerpullreq.HandleRecheck(pullreqCtrl))
r.Get("/activities", handlerpullreq.HandleListActivities(pullreqCtrl))
r.Route("/comments", func(r chi.Router) {
r.Post("/", handlerpullreq.HandleCommentCreate(pullreqCtrl))

View File

@ -23,18 +23,14 @@ import (
type ProviderType string
const (
ProviderTypeGitHub ProviderType = "github"
ProviderTypeGitHubEnterprise ProviderType = "github-enterprise"
ProviderTypeGitLab ProviderType = "gitlab"
ProviderTypeGitLabEnterprise ProviderType = "gitlab-enterprise"
ProviderTypeGitHub ProviderType = "github"
ProviderTypeGitLab ProviderType = "gitlab"
)
func (p ProviderType) Enum() []any {
return []any{
ProviderTypeGitHub,
ProviderTypeGitHubEnterprise,
ProviderTypeGitLab,
ProviderTypeGitLabEnterprise,
}
}
@ -83,67 +79,48 @@ func (r *RepositoryInfo) ToRepo(
}
func getClient(provider Provider) (*scm.Client, error) {
if provider.Username == "" || provider.Password == "" {
return nil, usererror.BadRequest("scm provider authentication credentials missing")
}
var c *scm.Client
var err error
switch provider.Type {
case "":
return nil, usererror.BadRequest("provider can not be empty")
return nil, usererror.BadRequest("scm provider can not be empty")
case ProviderTypeGitHub:
c := github.NewDefault()
if provider.Password != "" {
c.Client = &http.Client{
Transport: &oauth2.Transport{
Source: oauth2.StaticTokenSource(&scm.Token{Token: provider.Password}),
},
if provider.Host != "" {
c, err = github.New(provider.Host)
if err != nil {
return nil, usererror.BadRequestf("scm provider Host invalid: %s", err.Error())
}
} else {
c = github.NewDefault()
}
return c, nil
case ProviderTypeGitHubEnterprise:
c, err := github.New(provider.Host)
if err != nil {
return nil, usererror.BadRequestf("provider Host invalid: %s", err.Error())
}
if provider.Password != "" {
c.Client = &http.Client{
Transport: &oauth2.Transport{
Source: oauth2.StaticTokenSource(&scm.Token{Token: provider.Password}),
},
}
}
return c, nil
case ProviderTypeGitLab:
c := gitlab.NewDefault()
if provider.Password != "" {
c.Client = &http.Client{
Transport: &oauth2.Transport{
Source: oauth2.StaticTokenSource(&scm.Token{Token: provider.Password}),
},
if provider.Host != "" {
c, err = gitlab.New(provider.Host)
if err != nil {
return nil, usererror.BadRequestf("scm provider Host invalid: %s", err.Error())
}
} else {
c = gitlab.NewDefault()
}
return c, nil
case ProviderTypeGitLabEnterprise:
c, err := gitlab.New(provider.Host)
if err != nil {
return nil, usererror.BadRequestf("provider Host invalid: %s", err.Error())
}
if provider.Password != "" {
c.Client = &http.Client{
Transport: &oauth2.Transport{
Source: oauth2.StaticTokenSource(&scm.Token{Token: provider.Password}),
},
}
}
return c, nil
default:
return nil, usererror.BadRequestf("unsupported provider: %s", provider)
return nil, usererror.BadRequestf("unsupported scm provider: %s", provider)
}
c.Client = &http.Client{
Transport: &oauth2.Transport{
Source: oauth2.StaticTokenSource(&scm.Token{Token: provider.Password}),
},
}
return c, nil
}
func LoadRepositoryFromProvider(ctx context.Context, provider Provider, repoSlug string) (RepositoryInfo, error) {
scmClient, err := getClient(provider)
@ -155,18 +132,24 @@ func LoadRepositoryFromProvider(ctx context.Context, provider Provider, repoSlug
return RepositoryInfo{}, usererror.BadRequest("provider repository identifier is missing")
}
scmRepo, _, err := scmClient.Repositories.Find(ctx, repoSlug)
if errors.Is(err, scm.ErrNotFound) {
var statusCode int
scmRepo, scmResp, err := scmClient.Repositories.Find(ctx, repoSlug)
if scmResp != nil {
statusCode = scmResp.Status
}
if errors.Is(err, scm.ErrNotFound) || statusCode == http.StatusNotFound {
return RepositoryInfo{},
usererror.BadRequestf("repository %s not found at %s", repoSlug, provider.Type)
}
if errors.Is(err, scm.ErrNotAuthorized) {
if errors.Is(err, scm.ErrNotAuthorized) || statusCode == http.StatusUnauthorized {
return RepositoryInfo{},
usererror.BadRequestf("bad credentials provided for %s at %s", repoSlug, provider.Type)
}
if err != nil {
if err != nil || statusCode > 299 {
return RepositoryInfo{},
fmt.Errorf("failed to fetch repository %s from %s: %w", repoSlug, provider.Type, err)
fmt.Errorf("failed to fetch repository %s from %s, status=%d: %w",
repoSlug, provider.Type, statusCode, err)
}
return RepositoryInfo{
@ -188,33 +171,38 @@ func LoadRepositoriesFromProviderSpace(ctx context.Context, provider Provider, s
return nil, usererror.BadRequest("provider space identifier is missing")
}
repos := make([]RepositoryInfo, 0)
const pageSize = 100
opts := scm.ListOptions{Page: 0, Size: pageSize}
const pageSize = 50
page := 1
repos := make([]RepositoryInfo, 0)
for {
scmRepos, scmResponse, err := scmClient.Repositories.ListV2(ctx, scm.RepoListOptions{
ListOptions: scm.ListOptions{
URL: "",
Page: page,
Size: pageSize,
},
RepoSearchTerm: scm.RepoSearchTerm{
RepoName: "",
User: spaceSlug,
},
})
if errors.Is(err, scm.ErrNotFound) {
opts.Page++
var statusCode int
scmRepos, scmResp, err := scmClient.Repositories.List(ctx, opts)
if scmResp != nil {
statusCode = scmResp.Status
}
if errors.Is(err, scm.ErrNotFound) || statusCode == http.StatusNotFound {
return nil, usererror.BadRequestf("space %s not found at %s", spaceSlug, provider.Type)
}
if errors.Is(err, scm.ErrNotAuthorized) {
if errors.Is(err, scm.ErrNotAuthorized) || statusCode == http.StatusUnauthorized {
return nil, usererror.BadRequestf("bad credentials provided for %s at %s", spaceSlug, provider.Type)
}
if err != nil {
return nil, fmt.Errorf("failed to fetch space %s from %s: %w", spaceSlug, provider.Type, err)
if err != nil || statusCode > 299 {
return nil, fmt.Errorf("failed to fetch space %s from %s, status=%d: %w",
spaceSlug, provider.Type, statusCode, err)
}
if len(scmRepos) == 0 {
break
}
for _, scmRepo := range scmRepos {
if scmRepo.Namespace != spaceSlug {
continue
}
repos = append(repos, RepositoryInfo{
Space: scmRepo.Namespace,
UID: scmRepo.Name,
@ -223,12 +211,6 @@ func LoadRepositoriesFromProviderSpace(ctx context.Context, provider Provider, s
DefaultBranch: scmRepo.Branch,
})
}
if len(scmRepos) == 0 || page == scmResponse.Page.Last {
break
}
page++
}
return repos, nil

View File

@ -6,6 +6,7 @@ package importer
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@ -13,6 +14,7 @@ import (
"strings"
"time"
"github.com/harness/gitness/encrypt"
"github.com/harness/gitness/gitrpc"
"github.com/harness/gitness/internal/bootstrap"
"github.com/harness/gitness/internal/githook"
@ -35,6 +37,7 @@ type Repository struct {
urlProvider *gitnessurl.Provider
git gitrpc.Interface
repoStore store.RepoStore
encrypter encrypt.Encrypter
scheduler *job.Scheduler
}
@ -55,27 +58,17 @@ func (r *Repository) Register(executor *job.Executor) error {
// Run starts a background job that imports the provided repository from the provided clone URL.
func (r *Repository) Run(ctx context.Context, provider Provider, repo *types.Repository, cloneURL string) error {
input := Input{
jobDef, err := r.getJobDef(*repo.ImportingJobUID, Input{
RepoID: repo.ID,
GitUser: provider.Username,
GitPass: provider.Password,
CloneURL: cloneURL,
}
data, err := json.Marshal(input)
if err != nil {
return fmt.Errorf("failed to marshal job input json: %w", err)
}
strData := strings.TrimSpace(string(data))
return r.scheduler.RunJob(ctx, job.Definition{
UID: *repo.ImportingJobUID,
Type: jobType,
MaxRetries: importJobMaxRetries,
Timeout: importJobMaxDuration,
Data: strData,
})
if err != nil {
return err
}
return r.scheduler.RunJob(ctx, jobDef)
}
// RunMany starts background jobs that import the provided repositories from the provided clone URLs.
@ -91,34 +84,23 @@ func (r *Repository) RunMany(ctx context.Context,
}
n := len(repos)
defs := make([]job.Definition, n)
for k := 0; k < n; k++ {
repo := repos[k]
cloneURL := cloneURLs[k]
input := Input{
jobDef, err := r.getJobDef(*repo.ImportingJobUID, Input{
RepoID: repo.ID,
GitUser: provider.Username,
GitPass: provider.Password,
CloneURL: cloneURL,
}
data, err := json.Marshal(input)
})
if err != nil {
return fmt.Errorf("failed to marshal job input json: %w", err)
return err
}
strData := strings.TrimSpace(string(data))
defs[k] = job.Definition{
UID: *repo.ImportingJobUID,
Type: jobType,
MaxRetries: importJobMaxRetries,
Timeout: importJobMaxDuration,
Data: strData,
}
defs[k] = jobDef
}
err := r.scheduler.RunJobs(ctx, groupID, defs)
@ -129,13 +111,56 @@ func (r *Repository) RunMany(ctx context.Context,
return nil
}
func (r *Repository) getJobDef(jobUID string, input Input) (job.Definition, error) {
data, err := json.Marshal(input)
if err != nil {
return job.Definition{}, fmt.Errorf("failed to marshal job input json: %w", err)
}
strData := strings.TrimSpace(string(data))
encryptedData, err := r.encrypter.Encrypt(strData)
if err != nil {
return job.Definition{}, fmt.Errorf("failed to encrypt job input: %w", err)
}
return job.Definition{
UID: jobUID,
Type: jobType,
MaxRetries: importJobMaxRetries,
Timeout: importJobMaxDuration,
Data: base64.StdEncoding.EncodeToString(encryptedData),
}, nil
}
func (r *Repository) getJobInput(data string) (Input, error) {
encrypted, err := base64.StdEncoding.DecodeString(data)
if err != nil {
return Input{}, fmt.Errorf("failed to base64 decode job input: %w", err)
}
decrypted, err := r.encrypter.Decrypt(encrypted)
if err != nil {
return Input{}, fmt.Errorf("failed to decrypt job input: %w", err)
}
var input Input
err = json.NewDecoder(strings.NewReader(decrypted)).Decode(&input)
if err != nil {
return Input{}, fmt.Errorf("failed to unmarshal job input json: %w", err)
}
return input, nil
}
// Handle is repository import background job handler.
func (r *Repository) Handle(ctx context.Context, data string, _ job.ProgressReporter) (string, error) {
systemPrincipal := bootstrap.NewSystemServiceSession().Principal
var input Input
if err := json.NewDecoder(strings.NewReader(data)).Decode(&input); err != nil {
return "", fmt.Errorf("failed to unmarshal job input: %w", err)
input, err := r.getJobInput(data)
if err != nil {
return "", err
}
if input.CloneURL == "" {

View File

@ -5,6 +5,7 @@
package importer
import (
"github.com/harness/gitness/encrypt"
"github.com/harness/gitness/gitrpc"
"github.com/harness/gitness/internal/services/job"
"github.com/harness/gitness/internal/store"
@ -23,6 +24,7 @@ func ProvideRepoImporter(
urlProvider *url.Provider,
git gitrpc.Interface,
repoStore store.RepoStore,
encrypter encrypt.Encrypter,
scheduler *job.Scheduler,
executor *job.Executor,
) (*Repository, error) {
@ -31,6 +33,7 @@ func ProvideRepoImporter(
urlProvider: urlProvider,
git: git,
repoStore: repoStore,
encrypter: encrypter,
scheduler: scheduler,
}

View File

@ -106,6 +106,28 @@ func (s *Service) deleteMergeRef(ctx context.Context, repoID int64, prNum int64)
return nil
}
// UpdateMergeDataIfRequired rechecks the merge data of a PR.
// TODO: This is a temporary solution - doesn't fix changed merge-base or other things.
func (s *Service) UpdateMergeDataIfRequired(
ctx context.Context,
repoID int64,
prNum int64,
) error {
pr, err := s.pullreqStore.FindByNumber(ctx, repoID, prNum)
if err != nil {
return fmt.Errorf("failed to get pull request number %d: %w", prNum, err)
}
// nothing to-do if check was already performed
if pr.MergeCheckStatus != enum.MergeCheckStatusUnchecked {
return nil
}
// WARNING: This CAN lead to two (or more) merge-checks on the same SHA
// running on different machines at the same time.
return s.updateMergeDataInner(ctx, pr, "", pr.SourceSHA)
}
//nolint:funlen // refactor if required.
func (s *Service) updateMergeData(
ctx context.Context,
@ -114,21 +136,31 @@ func (s *Service) updateMergeData(
oldSHA string,
newSHA string,
) error {
// TODO: Merge check should not update the merge base.
// TODO: Instead it should accept it as an argument and fail if it doesn't match.
// Then is would not longer be necessary to cancel already active mergeability checks.
pr, err := s.pullreqStore.FindByNumber(ctx, repoID, prNum)
if err != nil {
return fmt.Errorf("failed to get pull request number %d: %w", prNum, err)
}
return s.updateMergeDataInner(ctx, pr, oldSHA, newSHA)
}
//nolint:funlen // refactor if required.
func (s *Service) updateMergeDataInner(
ctx context.Context,
pr *types.PullReq,
oldSHA string,
newSHA string,
) error {
// TODO: Merge check should not update the merge base.
// TODO: Instead it should accept it as an argument and fail if it doesn't match.
// Then is would not longer be necessary to cancel already active mergeability checks.
if pr.State != enum.PullReqStateOpen {
return fmt.Errorf("cannot do mergability check on closed PR %d", prNum)
return fmt.Errorf("cannot do mergability check on closed PR %d", pr.Number)
}
// cancel all previous mergability work for this PR based on oldSHA
if err = s.pubsub.Publish(ctx, cancelMergeCheckKey, []byte(oldSHA),
if err := s.pubsub.Publish(ctx, cancelMergeCheckKey, []byte(oldSHA),
pubsub.WithPublishNamespace("pullreq")); err != nil {
return err
}
@ -137,6 +169,13 @@ func (s *Service) updateMergeData(
ctx, cancel = context.WithCancel(ctx)
s.cancelMutex.Lock()
// NOTE: Temporary workaround to avoid overwriting existing cancel method on same machine.
// This doesn't avoid same SHA running on multiple machines
if _, ok := s.cancelMergeability[newSHA]; ok {
s.cancelMutex.Unlock()
cancel()
return nil
}
s.cancelMergeability[newSHA] = cancel
s.cancelMutex.Unlock()
@ -175,7 +214,7 @@ func (s *Service) updateMergeData(
HeadRepoUID: sourceRepo.GitUID,
HeadBranch: pr.SourceBranch,
RefType: gitrpcenum.RefTypePullReqMerge,
RefName: strconv.Itoa(int(prNum)),
RefName: strconv.Itoa(int(pr.Number)),
HeadExpectedSHA: newSHA,
Force: true,

View File

@ -6,17 +6,76 @@ package trigger
import (
"context"
"fmt"
"strings"
"github.com/harness/gitness/events"
gitevents "github.com/harness/gitness/internal/events/git"
"github.com/harness/gitness/internal/pipeline/triggerer"
"github.com/harness/gitness/types/enum"
)
// TODO: This can be moved to SCM library
func ExtractBranch(ref string) string {
return strings.TrimPrefix(ref, "refs/heads/")
}
func (s *Service) handleEventBranchCreated(ctx context.Context,
event *events.Event[*gitevents.BranchCreatedPayload]) error {
return events.NewDiscardEventErrorf("not implemented")
hook := &triggerer.Hook{
Trigger: enum.TriggerHook,
Action: enum.TriggerActionBranchCreated,
Ref: event.Payload.Ref,
Source: ExtractBranch(event.Payload.Ref),
Target: ExtractBranch(event.Payload.Ref),
After: event.Payload.SHA,
}
err := s.augmentCommitInfo(ctx, hook, event.Payload.RepoID, event.Payload.SHA)
if err != nil {
return fmt.Errorf("could not augment commit info: %w", err)
}
return s.trigger(ctx, event.Payload.RepoID, enum.TriggerActionBranchCreated, hook)
}
func (s *Service) handleEventBranchUpdated(ctx context.Context,
event *events.Event[*gitevents.BranchUpdatedPayload]) error {
return events.NewDiscardEventErrorf("not implemented")
hook := &triggerer.Hook{
Trigger: enum.TriggerHook,
Action: enum.TriggerActionBranchUpdated,
Ref: event.Payload.Ref,
Before: event.Payload.OldSHA,
After: event.Payload.NewSHA,
Source: ExtractBranch(event.Payload.Ref),
Target: ExtractBranch(event.Payload.Ref),
}
err := s.augmentCommitInfo(ctx, hook, event.Payload.RepoID, event.Payload.NewSHA)
if err != nil {
return fmt.Errorf("could not augment commit info: %w", err)
}
return s.trigger(ctx, event.Payload.RepoID, enum.TriggerActionBranchUpdated, hook)
}
// augmentCommitInfo adds information about the commit to the hook by interacting with
// the commit service.
func (s *Service) augmentCommitInfo(
ctx context.Context,
hook *triggerer.Hook,
repoID int64,
sha string,
) error {
repo, err := s.repoStore.Find(ctx, repoID)
if err != nil {
return fmt.Errorf("could not find repo: %w", err)
}
commit, err := s.commitSvc.FindCommit(ctx, repo, sha)
if err != nil {
return fmt.Errorf("could not find commit info")
}
hook.AuthorName = commit.Author.Identity.Name
hook.Title = commit.Title
hook.Timestamp = commit.Committer.When.UnixMilli()
hook.AuthorLogin = commit.Author.Identity.Name
hook.AuthorEmail = commit.Author.Identity.Email
hook.Message = commit.Message
return nil
}

View File

@ -6,22 +6,79 @@ package trigger
import (
"context"
"fmt"
"github.com/harness/gitness/events"
pullreqevents "github.com/harness/gitness/internal/events/pullreq"
"github.com/harness/gitness/internal/pipeline/triggerer"
"github.com/harness/gitness/types/enum"
"github.com/drone/go-scm/scm"
)
func (s *Service) handleEventPullReqCreated(ctx context.Context,
event *events.Event[*pullreqevents.CreatedPayload]) error {
return events.NewDiscardEventErrorf("not implemented")
hook := &triggerer.Hook{
Trigger: enum.TriggerHook,
Action: enum.TriggerActionPullReqCreated,
After: event.Payload.SourceSHA,
}
err := s.augmentPullReqInfo(ctx, hook, event.Payload.PullReqID)
if err != nil {
return fmt.Errorf("could not augment pull request info: %w", err)
}
return s.trigger(ctx, event.Payload.SourceRepoID, enum.TriggerActionPullReqCreated, hook)
}
func (s *Service) handleEventPullReqReopened(ctx context.Context,
event *events.Event[*pullreqevents.ReopenedPayload]) error {
return events.NewDiscardEventErrorf("not implemented")
hook := &triggerer.Hook{
Trigger: enum.TriggerHook,
Action: enum.TriggerActionPullReqReopened,
After: event.Payload.SourceSHA,
}
err := s.augmentPullReqInfo(ctx, hook, event.Payload.PullReqID)
if err != nil {
return fmt.Errorf("could not augment pull request info: %w", err)
}
return s.trigger(ctx, event.Payload.SourceRepoID, enum.TriggerActionPullReqReopened, hook)
}
func (s *Service) handleEventPullReqBranchUpdated(ctx context.Context,
event *events.Event[*pullreqevents.BranchUpdatedPayload]) error {
return events.NewDiscardEventErrorf("not implemented")
hook := &triggerer.Hook{
Trigger: enum.TriggerHook,
Action: enum.TriggerActionPullReqBranchUpdated,
After: event.Payload.NewSHA,
}
err := s.augmentPullReqInfo(ctx, hook, event.Payload.PullReqID)
if err != nil {
return fmt.Errorf("could not augment pull request info: %w", err)
}
return s.trigger(ctx, event.Payload.SourceRepoID, enum.TriggerActionPullReqBranchUpdated, hook)
}
// augmentPullReqInfo adds in information into the hook pertaining to the pull request
// by querying the database.
func (s *Service) augmentPullReqInfo(
ctx context.Context,
hook *triggerer.Hook,
pullReqID int64,
) error {
pullreq, err := s.pullReqStore.Find(ctx, pullReqID)
if err != nil {
return fmt.Errorf("could not find pull request: %w", err)
}
hook.Title = pullreq.Title
hook.Timestamp = pullreq.Created
hook.AuthorLogin = pullreq.Author.UID
hook.AuthorName = pullreq.Author.DisplayName
hook.AuthorEmail = pullreq.Author.Email
hook.Message = pullreq.Description
hook.Before = pullreq.MergeBaseSHA
hook.Target = pullreq.TargetBranch
hook.Source = pullreq.SourceBranch
// expand the branch to a git reference.
hook.Ref = scm.ExpandRef(pullreq.SourceBranch, "refs/heads")
return nil
}

View File

@ -6,17 +6,46 @@ package trigger
import (
"context"
"fmt"
"github.com/harness/gitness/events"
gitevents "github.com/harness/gitness/internal/events/git"
"github.com/harness/gitness/internal/pipeline/triggerer"
"github.com/harness/gitness/types/enum"
)
func (s *Service) handleEventTagCreated(ctx context.Context,
event *events.Event[*gitevents.TagCreatedPayload]) error {
return events.NewDiscardEventErrorf("not implemented")
hook := &triggerer.Hook{
Trigger: enum.TriggerHook,
Action: enum.TriggerActionTagCreated,
Ref: event.Payload.Ref,
Before: event.Payload.SHA,
After: event.Payload.SHA,
Source: event.Payload.Ref,
Target: event.Payload.Ref,
}
err := s.augmentCommitInfo(ctx, hook, event.Payload.RepoID, event.Payload.SHA)
if err != nil {
return fmt.Errorf("could not augment commit info: %w", err)
}
return s.trigger(ctx, event.Payload.RepoID, enum.TriggerActionTagCreated, hook)
}
func (s *Service) handleEventTagUpdated(ctx context.Context,
event *events.Event[*gitevents.TagUpdatedPayload]) error {
return events.NewDiscardEventErrorf("not implemented")
hook := &triggerer.Hook{
Trigger: enum.TriggerHook,
Action: enum.TriggerActionTagUpdated,
Ref: event.Payload.Ref,
Before: event.Payload.OldSHA,
After: event.Payload.NewSHA,
Source: event.Payload.Ref,
Target: event.Payload.Ref,
}
err := s.augmentCommitInfo(ctx, hook, event.Payload.RepoID, event.Payload.NewSHA)
if err != nil {
return fmt.Errorf("could not augment commit info: %w", err)
}
return s.trigger(ctx, event.Payload.RepoID, enum.TriggerActionTagUpdated, hook)
}

View File

@ -13,7 +13,14 @@ import (
"github.com/harness/gitness/events"
gitevents "github.com/harness/gitness/internal/events/git"
pullreqevents "github.com/harness/gitness/internal/events/pullreq"
"github.com/harness/gitness/internal/pipeline/commit"
"github.com/harness/gitness/internal/pipeline/triggerer"
"github.com/harness/gitness/internal/store"
"github.com/harness/gitness/stream"
"github.com/harness/gitness/types"
"github.com/harness/gitness/types/enum"
"github.com/hashicorp/go-multierror"
)
const (
@ -43,11 +50,24 @@ func (c *Config) Prepare() error {
return nil
}
type Service struct{}
type Service struct {
triggerStore store.TriggerStore
pullReqStore store.PullReqStore
repoStore store.RepoStore
pipelineStore store.PipelineStore
triggerSvc triggerer.Triggerer
commitSvc commit.CommitService
}
func New(
ctx context.Context,
config Config,
triggerStore store.TriggerStore,
pullReqStore store.PullReqStore,
repoStore store.RepoStore,
pipelineStore store.PipelineStore,
triggerSvc triggerer.Triggerer,
commitSvc commit.CommitService,
gitReaderFactory *events.ReaderFactory[*gitevents.Reader],
pullreqEvReaderFactory *events.ReaderFactory[*pullreqevents.Reader],
) (*Service, error) {
@ -55,7 +75,14 @@ func New(
return nil, fmt.Errorf("provided trigger service config is invalid: %w", err)
}
service := &Service{}
service := &Service{
triggerStore: triggerStore,
pullReqStore: pullReqStore,
repoStore: repoStore,
commitSvc: commitSvc,
pipelineStore: pipelineStore,
triggerSvc: triggerSvc,
}
_, err := gitReaderFactory.Launch(ctx, eventsReaderGroupName, config.EventReaderName,
func(r *gitevents.Reader) error {
@ -101,3 +128,40 @@ func New(
return service, nil
}
// trigger a build given an action on a repo and a hook.
// It tries to find all enabled triggers, see if the action is the same
// as the trigger action - and if so, find the pipeline for the trigger
// and fire an execution.
func (s *Service) trigger(ctx context.Context, repoID int64,
action enum.TriggerAction, hook *triggerer.Hook) error {
// Get all enabled triggers for a repo.
ret, err := s.triggerStore.ListAllEnabled(ctx, repoID)
if err != nil {
return fmt.Errorf("failed to list all enabled triggers: %w", err)
}
validTriggers := []*types.Trigger{}
// Check which triggers are eligible to be fired
for _, t := range ret {
for _, a := range t.Actions {
if a == action {
validTriggers = append(validTriggers, t)
break
}
}
}
var errs error
for _, t := range validTriggers {
pipeline, err := s.pipelineStore.Find(ctx, t.PipelineID)
if err != nil {
errs = multierror.Append(errs, err)
}
_, err = s.triggerSvc.Trigger(ctx, pipeline, hook)
if err != nil {
errs = multierror.Append(errs, err)
}
}
return errs
}

View File

@ -10,6 +10,9 @@ import (
"github.com/harness/gitness/events"
gitevents "github.com/harness/gitness/internal/events/git"
pullreqevents "github.com/harness/gitness/internal/events/pullreq"
"github.com/harness/gitness/internal/pipeline/commit"
"github.com/harness/gitness/internal/pipeline/triggerer"
"github.com/harness/gitness/internal/store"
"github.com/google/wire"
)
@ -21,8 +24,15 @@ var WireSet = wire.NewSet(
func ProvideService(
ctx context.Context,
config Config,
triggerStore store.TriggerStore,
commitSvc commit.CommitService,
pullReqStore store.PullReqStore,
repoStore store.RepoStore,
pipelineStore store.PipelineStore,
triggerSvc triggerer.Triggerer,
gitReaderFactory *events.ReaderFactory[*gitevents.Reader],
pullReqEvFactory *events.ReaderFactory[*pullreqevents.Reader],
) (*Service, error) {
return New(ctx, config, gitReaderFactory, pullReqEvFactory)
return New(ctx, config, triggerStore, pullReqStore, repoStore, pipelineStore, triggerSvc,
commitSvc, gitReaderFactory, pullReqEvFactory)
}

View File

@ -694,6 +694,10 @@ type (
// Count the number of triggers in a pipeline.
Count(ctx context.Context, pipelineID int64, filter types.ListQueryFilter) (int64, error)
// ListAllEnabled lists all enabled triggers for a given repo without pagination.
// It's used only internally to trigger builds.
ListAllEnabled(ctx context.Context, repoID int64) ([]*types.Trigger, error)
}
PluginStore interface {

View File

@ -261,8 +261,13 @@ CREATE TABLE templates (
CREATE TABLE triggers (
trigger_id INTEGER PRIMARY KEY AUTOINCREMENT
,trigger_uid TEXT NOT NULL
,trigger_description TEXT NOT NULL
,trigger_pipeline_id INTEGER NOT NULL
,trigger_repo_id INTEGER NOT NULL
,trigger_secret TEXT NOT NULL
,trigger_description TEXT NOT NULL
,trigger_enabled BOOLEAN NOT NULL
,trigger_created_by INTEGER NOT NULL
,trigger_actions TEXT NOT NULL
,trigger_created INTEGER NOT NULL
,trigger_updated INTEGER NOT NULL
,trigger_version INTEGER NOT NULL
@ -270,11 +275,17 @@ CREATE TABLE triggers (
-- Ensure unique combination of pipeline ID and UID
,UNIQUE (trigger_pipeline_id, trigger_uid)
-- Foreign key to spaces table
-- Foreign key to pipelines table
,CONSTRAINT fk_triggers_pipeline_id FOREIGN KEY (trigger_pipeline_id)
REFERENCES pipelines (pipeline_id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE CASCADE
-- Foreign key to repositories table
,CONSTRAINT fk_triggers_repo_id FOREIGN KEY (trigger_repo_id)
REFERENCES repositories (repo_id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE CASCADE
);
CREATE TABLE plugins (

View File

@ -6,6 +6,7 @@ package database
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
@ -15,13 +16,82 @@ import (
"github.com/harness/gitness/store/database"
"github.com/harness/gitness/store/database/dbtx"
"github.com/harness/gitness/types"
"github.com/harness/gitness/types/enum"
"github.com/jmoiron/sqlx"
sqlxtypes "github.com/jmoiron/sqlx/types"
"github.com/pkg/errors"
)
var _ store.TriggerStore = (*triggerStore)(nil)
type trigger struct {
ID int64 `db:"trigger_id"`
UID string `db:"trigger_uid"`
Description string `db:"trigger_description"`
Secret string `db:"trigger_secret"`
PipelineID int64 `db:"trigger_pipeline_id"`
RepoID int64 `db:"trigger_repo_id"`
CreatedBy int64 `db:"trigger_created_by"`
Enabled bool `db:"trigger_enabled"`
Actions sqlxtypes.JSONText `db:"trigger_actions"`
Created int64 `db:"trigger_created"`
Updated int64 `db:"trigger_updated"`
Version int64 `db:"trigger_version"`
}
func mapInternalToTrigger(trigger *trigger) (*types.Trigger, error) {
var actions []enum.TriggerAction
err := json.Unmarshal(trigger.Actions, &actions)
if err != nil {
return nil, errors.Wrap(err, "could not unmarshal trigger.actions")
}
return &types.Trigger{
ID: trigger.ID,
Description: trigger.Description,
Secret: trigger.Secret,
PipelineID: trigger.PipelineID,
RepoID: trigger.RepoID,
CreatedBy: trigger.CreatedBy,
Enabled: trigger.Enabled,
Actions: actions,
UID: trigger.UID,
Created: trigger.Created,
Updated: trigger.Updated,
Version: trigger.Version,
}, nil
}
func mapInternalToTriggerList(triggers []*trigger) ([]*types.Trigger, error) {
ret := make([]*types.Trigger, len(triggers))
for i, t := range triggers {
trigger, err := mapInternalToTrigger(t)
if err != nil {
return nil, err
}
ret[i] = trigger
}
return ret, nil
}
func mapTriggerToInternal(t *types.Trigger) *trigger {
return &trigger{
ID: t.ID,
UID: t.UID,
Description: t.Description,
PipelineID: t.PipelineID,
Secret: t.Secret,
RepoID: t.RepoID,
CreatedBy: t.CreatedBy,
Enabled: t.Enabled,
Actions: EncodeToSQLXJSON(t.Actions),
Created: t.Created,
Updated: t.Updated,
Version: t.Version,
}
}
// NewTriggerStore returns a new TriggerStore.
func NewTriggerStore(db *sqlx.DB) *triggerStore {
return &triggerStore{
@ -37,6 +107,8 @@ const (
triggerColumns = `
trigger_id
,trigger_uid
,trigger_enabled
,trigger_actions
,trigger_description
,trigger_pipeline_id
,trigger_created
@ -53,33 +125,44 @@ func (s *triggerStore) FindByUID(ctx context.Context, pipelineID int64, uid stri
WHERE trigger_pipeline_id = $1 AND trigger_uid = $2`
db := dbtx.GetAccessor(ctx, s.db)
dst := new(types.Trigger)
dst := new(trigger)
if err := db.GetContext(ctx, dst, findQueryStmt, pipelineID, uid); err != nil {
return nil, database.ProcessSQLErrorf(err, "Failed to find trigger")
}
return dst, nil
return mapInternalToTrigger(dst)
}
// Create creates a new trigger in the datastore.
func (s *triggerStore) Create(ctx context.Context, trigger *types.Trigger) error {
func (s *triggerStore) Create(ctx context.Context, t *types.Trigger) error {
const triggerInsertStmt = `
INSERT INTO triggers (
trigger_uid
,trigger_description
,trigger_actions
,trigger_enabled
,trigger_secret
,trigger_created_by
,trigger_pipeline_id
,trigger_repo_id
,trigger_created
,trigger_updated
,trigger_version
) VALUES (
:trigger_uid
,:trigger_description
,:trigger_actions
,:trigger_enabled
,:trigger_secret
,:trigger_created_by
,:trigger_pipeline_id
,:trigger_repo_id
,:trigger_created
,:trigger_updated
,:trigger_version
) RETURNING trigger_id`
db := dbtx.GetAccessor(ctx, s.db)
trigger := mapTriggerToInternal(t)
query, arg, err := db.BindNamed(triggerInsertStmt, trigger)
if err != nil {
return database.ProcessSQLErrorf(err, "Failed to bind trigger object")
@ -93,17 +176,18 @@ func (s *triggerStore) Create(ctx context.Context, trigger *types.Trigger) error
}
// Update tries to update an trigger in the datastore with optimistic locking.
func (s *triggerStore) Update(ctx context.Context, e *types.Trigger) error {
func (s *triggerStore) Update(ctx context.Context, t *types.Trigger) error {
const triggerUpdateStmt = `
UPDATE triggers
SET
trigger_uid = :trigger_uid
,trigger_description = :trigger_description
,trigger_updated = :trigger_updated
,trigger_actions = :trigger_actions
,trigger_version = :trigger_version
WHERE trigger_id = :trigger_id AND trigger_version = :trigger_version - 1`
updatedAt := time.Now()
trigger := *e
trigger := mapTriggerToInternal(t)
trigger.Version++
trigger.Updated = updatedAt.UnixMilli()
@ -129,8 +213,8 @@ func (s *triggerStore) Update(ctx context.Context, e *types.Trigger) error {
return gitness_store.ErrVersionConflict
}
e.Version = trigger.Version
e.Updated = trigger.Updated
t.Version = trigger.Version
t.Updated = trigger.Updated
return nil
}
@ -186,12 +270,37 @@ func (s *triggerStore) List(
db := dbtx.GetAccessor(ctx, s.db)
dst := []*types.Trigger{}
dst := []*trigger{}
if err = db.SelectContext(ctx, &dst, sql, args...); err != nil {
return nil, database.ProcessSQLErrorf(err, "Failed executing custom list query")
}
return dst, nil
return mapInternalToTriggerList(dst)
}
// ListAllEnabled lists all enabled triggers for a given repo without pagination
func (s *triggerStore) ListAllEnabled(
ctx context.Context,
repoID int64,
) ([]*types.Trigger, error) {
stmt := database.Builder.
Select(triggerColumns).
From("triggers").
Where("trigger_repo_id = ? AND trigger_enabled = true", fmt.Sprint(repoID))
sql, args, err := stmt.ToSql()
if err != nil {
return nil, errors.Wrap(err, "Failed to convert query to sql")
}
db := dbtx.GetAccessor(ctx, s.db)
dst := []*trigger{}
if err = db.SelectContext(ctx, &dst, sql, args...); err != nil {
return nil, database.ProcessSQLErrorf(err, "Failed executing custom list query")
}
return mapInternalToTriggerList(dst)
}
// Count of triggers under a given pipeline.

View File

@ -0,0 +1,60 @@
package enum
// TriggerAction defines the different actions on triggers will fire.
type TriggerAction string
// These are similar to enums defined in webhook enum but can diverge
// as these are different entities.
const (
// TriggerActionBranchCreated gets triggered when a branch gets created.
TriggerActionBranchCreated TriggerAction = "branch_created"
// TriggerActionBranchUpdated gets triggered when a branch gets updated.
TriggerActionBranchUpdated TriggerAction = "branch_updated"
// TriggerActionTagCreated gets triggered when a tag gets created.
TriggerActionTagCreated TriggerAction = "tag_created"
// TriggerActionTagUpdated gets triggered when a tag gets updated.
TriggerActionTagUpdated TriggerAction = "tag_updated"
// TriggerActionPullReqCreated gets triggered when a pull request gets created.
TriggerActionPullReqCreated TriggerAction = "pullreq_created"
// TriggerActionPullReqReopened gets triggered when a pull request gets reopened.
TriggerActionPullReqReopened TriggerAction = "pullreq_reopened"
// TriggerActionPullReqBranchUpdated gets triggered when a pull request source branch gets updated.
TriggerActionPullReqBranchUpdated TriggerAction = "pullreq_branch_updated"
)
func (TriggerAction) Enum() []interface{} { return toInterfaceSlice(triggerActions) }
func (s TriggerAction) Sanitize() (TriggerAction, bool) { return Sanitize(s, GetAllTriggerActions) }
func (t TriggerAction) GetTriggerEvent() TriggerEvent {
if t == TriggerActionPullReqCreated ||
t == TriggerActionPullReqBranchUpdated ||
t == TriggerActionPullReqReopened {
return TriggerEventPullRequest
} else if t == TriggerActionTagCreated || t == TriggerActionTagUpdated {
return TriggerEventTag
} else if t == "" {
return TriggerEventManual
}
return TriggerEventPush
}
func GetAllTriggerActions() ([]TriggerAction, TriggerAction) {
return triggerActions, "" // No default value
}
var triggerActions = sortEnum([]TriggerAction{
TriggerActionBranchCreated,
TriggerActionBranchUpdated,
TriggerActionTagCreated,
TriggerActionTagUpdated,
TriggerActionPullReqCreated,
TriggerActionPullReqReopened,
TriggerActionPullReqBranchUpdated,
})
// Trigger types
const (
TriggerHook = "@hook"
TriggerCron = "@cron"
)

View File

@ -4,13 +4,14 @@
package enum
// TriggerEvent defines the different kinds of events in triggers.
type TriggerEvent string
// Hook event constants.
const (
TriggerEventCron = "cron"
TriggerEventCustom = "custom"
TriggerEventManual = "manual"
TriggerEventPush = "push"
TriggerEventPullRequest = "pull_request"
TriggerEventTag = "tag"
TriggerEventPromote = "promote"
TriggerEventRollback = "rollback"
)

View File

@ -4,12 +4,19 @@
package types
import "github.com/harness/gitness/types/enum"
type Trigger struct {
ID int64 `db:"trigger_id" json:"id"`
Description string `db:"trigger_description" json:"description"`
PipelineID int64 `db:"trigger_pipeline_id" json:"pipeline_id"`
UID string `db:"trigger_uid" json:"uid"`
Created int64 `db:"trigger_created" json:"created"`
Updated int64 `db:"trigger_updated" json:"updated"`
Version int64 `db:"trigger_version" json:"-"`
ID int64 `json:"id"`
Description string `json:"description"`
PipelineID int64 `json:"pipeline_id"`
Secret string `json:"-"`
RepoID int64 `json:"repo_id"`
CreatedBy int64 `json:"created_by"`
Enabled bool `json:"enabled"`
Actions []enum.TriggerAction `json:"actions"`
UID string `json:"uid"`
Created int64 `json:"created"`
Updated int64 `json:"updated"`
Version int64 `json:"-"`
}

View File

@ -35,6 +35,7 @@ module.exports = {
'./Settings': './src/pages/RepositorySettings/RepositorySettings.tsx',
'./Webhooks': './src/pages/Webhooks/Webhooks.tsx',
'./WebhookNew': './src/pages/WebhookNew/WebhookNew.tsx',
'./Search': './src/pages/Search/Search.tsx',
'./WebhookDetails': './src/pages/WebhookDetails/WebhookDetails.tsx',
'./NewRepoModalButton': './src/components/NewRepoModalButton/NewRepoModalButton.tsx'
},

View File

@ -221,6 +221,18 @@ module.exports = {
'vb',
'xml',
'yaml'
],
globalAPI: true,
filename: '[name].worker.[contenthash:6].js',
customLanguages: [
{
label: 'yaml',
entry: 'monaco-yaml',
worker: {
id: 'monaco-yaml/yamlWorker',
entry: 'monaco-yaml/yaml.worker'
}
}
]
})
]

View File

@ -65,6 +65,7 @@
"moment": "^2.25.3",
"monaco-editor": "^0.40.0",
"monaco-editor-webpack-plugin": "^7.1.0",
"monaco-yaml": "^4.0.4",
"qs": "^6.9.4",
"react": "^17.0.2",
"react-complex-tree": "^1.1.11",

View File

@ -46,7 +46,7 @@ export interface CODERoutes {
toCODESpaceAccessControl: (args: Required<Pick<CODEProps, 'space'>>) => string
toCODESpaceSettings: (args: Required<Pick<CODEProps, 'space'>>) => string
toCODEPipelines: (args: Required<Pick<CODEProps, 'repoPath'>>) => string
toCODEPipelinesNew: (args: Required<Pick<CODEProps, 'space'>>) => string
toCODEPipelineEdit: (args: Required<Pick<CODEProps, 'repoPath' | 'pipeline'>>) => string
toCODESecrets: (args: Required<Pick<CODEProps, 'space'>>) => string
toCODEGlobalSettings: () => string
@ -73,7 +73,7 @@ export interface CODERoutes {
toCODEWebhookNew: (args: Required<Pick<CODEProps, 'repoPath'>>) => string
toCODEWebhookDetails: (args: Required<Pick<CODEProps, 'repoPath' | 'webhookId'>>) => string
toCODESettings: (args: Required<Pick<CODEProps, 'repoPath'>>) => string
toCODESearch: (args: Required<Pick<CODEProps, 'repoPath'>>) => string
toCODEExecutions: (args: Required<Pick<CODEProps, 'repoPath' | 'pipeline'>>) => string
toCODEExecution: (args: Required<Pick<CODEProps, 'repoPath' | 'pipeline' | 'execution'>>) => string
toCODESecret: (args: Required<Pick<CODEProps, 'space' | 'secret'>>) => string
@ -97,7 +97,7 @@ export const routes: CODERoutes = {
toCODESpaceAccessControl: ({ space }) => `/access-control/${space}`,
toCODESpaceSettings: ({ space }) => `/settings/${space}`,
toCODEPipelines: ({ repoPath }) => `/${repoPath}/pipelines`,
toCODEPipelinesNew: ({ space }) => `/pipelines/${space}/new`,
toCODEPipelineEdit: ({ repoPath, pipeline }) => `/${repoPath}/pipelines/${pipeline}/edit`,
toCODESecrets: ({ space }) => `/secrets/${space}`,
toCODEGlobalSettings: () => '/settings',
@ -126,6 +126,7 @@ export const routes: CODERoutes = {
toCODEBranches: ({ repoPath }) => `/${repoPath}/branches`,
toCODETags: ({ repoPath }) => `/${repoPath}/tags`,
toCODESettings: ({ repoPath }) => `/${repoPath}/settings`,
toCODESearch: ({ repoPath }) => `/${repoPath}/search`,
toCODEWebhooks: ({ repoPath }) => `/${repoPath}/webhooks`,
toCODEWebhookNew: ({ repoPath }) => `/${repoPath}/webhooks/new`,
toCODEWebhookDetails: ({ repoPath, webhookId }) => `/${repoPath}/webhook/${webhookId}`,

View File

@ -27,16 +27,17 @@ import ChangePassword from 'pages/ChangePassword/ChangePassword'
import SpaceAccessControl from 'pages/SpaceAccessControl/SpaceAccessControl'
import SpaceSettings from 'pages/SpaceSettings/SpaceSettings'
import { useStrings } from 'framework/strings'
import { useFeatureFlag } from 'hooks/useFeatureFlag'
import ExecutionList from 'pages/ExecutionList/ExecutionList'
import Execution from 'pages/Execution/Execution'
import Secret from 'pages/Secret/Secret'
import NewPipeline from 'pages/NewPipeline/NewPipeline'
import Search from 'pages/Search/Search'
import AddUpdatePipeline from 'pages/AddUpdatePipeline/AddUpdatePipeline'
import { useAppContext } from 'AppContext'
export const RouteDestinations: React.FC = React.memo(function RouteDestinations() {
const { getString } = useStrings()
const repoPath = `${pathProps.space}/${pathProps.repoName}`
const { OPEN_SOURCE_PIPELINES, OPEN_SOURCE_SECRETS } = useFeatureFlag()
const { standalone } = useAppContext()
return (
<BrowserRouter>
@ -163,7 +164,7 @@ export const RouteDestinations: React.FC = React.memo(function RouteDestinations
</LayoutWithSideNav>
</Route>
{OPEN_SOURCE_PIPELINES && (
{standalone && (
<Route
path={routes.toCODEExecution({
repoPath,
@ -177,7 +178,7 @@ export const RouteDestinations: React.FC = React.memo(function RouteDestinations
</Route>
)}
{OPEN_SOURCE_PIPELINES && (
{standalone && (
<Route path={routes.toCODEExecutions({ repoPath, pipeline: pathProps.pipeline })} exact>
<LayoutWithSideNav title={getString('pageTitle.executions')}>
<ExecutionList />
@ -185,15 +186,15 @@ export const RouteDestinations: React.FC = React.memo(function RouteDestinations
</Route>
)}
{OPEN_SOURCE_PIPELINES && (
<Route path={routes.toCODEPipelinesNew({ space: pathProps.space })} exact>
{standalone && (
<Route path={routes.toCODEPipelineEdit({ repoPath, pipeline: pathProps.pipeline })} exact>
<LayoutWithSideNav title={getString('pageTitle.pipelines')}>
<NewPipeline />
<AddUpdatePipeline />
</LayoutWithSideNav>
</Route>
)}
{OPEN_SOURCE_PIPELINES && (
{standalone && (
<Route path={routes.toCODEPipelines({ repoPath })} exact>
<LayoutWithSideNav title={getString('pageTitle.pipelines')}>
<PipelineList />
@ -201,7 +202,7 @@ export const RouteDestinations: React.FC = React.memo(function RouteDestinations
</Route>
)}
{OPEN_SOURCE_SECRETS && (
{standalone && (
<Route path={routes.toCODESecret({ space: pathProps.space, secret: pathProps.secret })} exact>
<LayoutWithSideNav title={getString('pageTitle.secrets')}>
<Secret />
@ -209,7 +210,7 @@ export const RouteDestinations: React.FC = React.memo(function RouteDestinations
</Route>
)}
{OPEN_SOURCE_SECRETS && (
{standalone && (
<Route path={routes.toCODESecrets({ space: pathProps.space })} exact>
<LayoutWithSideNav title={getString('pageTitle.secrets')}>
<SecretList />
@ -249,6 +250,12 @@ export const RouteDestinations: React.FC = React.memo(function RouteDestinations
</LayoutWithSideNav>
</Route>
<Route path={routes.toCODESearch({ repoPath })} exact>
<LayoutWithSideNav title={getString('pageTitle.search')}>
<Search />
</LayoutWithSideNav>
</Route>
<Route
path={routes.toCODEFileEdit({
repoPath,

View File

@ -26,6 +26,16 @@
}
}
.error {
position: sticky !important;
top: 0 !important;
background-color: var(--red) !important;
height: var(--log-content-header-height) !important;
border-bottom: 1px solid var(--grey-800) !important;
padding: var(--spacing-medium) !important;
font-weight: 600 !important;
}
.steps {
padding: var(--spacing-medium) !important;
}

View File

@ -1,6 +1,7 @@
/* eslint-disable */
// This is an auto-generated file
export declare const container: string
export declare const error: string
export declare const header: string
export declare const headerLayout: string
export declare const log: string

View File

@ -20,6 +20,13 @@ const Console: FC<ConsoleProps> = ({ stage, repoPath }) => {
return (
<div className={css.container}>
{stage?.error && (
<Container className={css.error}>
<Text font={{ variation: FontVariation.BODY }} color={Color.WHITE}>
{stage?.error}
</Text>
</Container>
)}
<Container className={css.header}>
<Layout.Horizontal className={css.headerLayout} spacing="small">
<Text font={{ variation: FontVariation.H4 }} color={Color.WHITE} padding={{ left: 'large', right: 'large' }}>

View File

@ -20,3 +20,16 @@
transform: rotate(360deg);
}
}
.timeoutIcon {
svg {
g {
circle {
fill: black !important;
}
path {
fill: white !important;
}
}
}
}

View File

@ -3,3 +3,4 @@
export declare const loading: string
export declare const spin: string
export declare const stepLayout: string
export declare const timeoutIcon: string

View File

@ -70,6 +70,10 @@ const ConsoleStep: FC<ConsoleStepProps> = ({ step, stageNumber, repoPath, pipeli
icon = <Icon name="circle" />
} else if (step?.status === ExecutionState.RUNNING) {
icon = <Icon className={css.spin} name="pending" />
} else if (step?.status === ExecutionState.FAILURE) {
icon = <Icon name="danger-icon" />
} else if (step?.status === ExecutionState.SKIPPED) {
icon = <Icon className={css.timeoutIcon} name="execution-timeout" />
} else {
icon = <Icon name="circle" /> // Default icon in case of other statuses or unknown status
}
@ -93,7 +97,7 @@ const ConsoleStep: FC<ConsoleStepProps> = ({ step, stageNumber, repoPath, pipeli
className={css.stepLayout}
spacing="medium"
onClick={() => {
if (!isPending) {
if (!isPending && step?.status !== ExecutionState.SKIPPED) {
setIsOpened(!isOpened)
if (shouldUseGet && !isOpened) refetch()
}

View File

@ -11,7 +11,7 @@ import { EditorView, keymap, placeholder as placeholderExtension } from '@codemi
import { Compartment, EditorState, Extension } from '@codemirror/state'
import { color } from '@uiw/codemirror-extensions-color'
import { hyperLink } from '@uiw/codemirror-extensions-hyper-link'
import { githubLight as theme } from '@uiw/codemirror-themes-all'
import { githubLight, githubDark } from '@uiw/codemirror-themes-all'
import css from './Editor.module.scss'
export interface EditorProps {
@ -28,6 +28,7 @@ export interface EditorProps {
setDirty?: React.Dispatch<React.SetStateAction<boolean>>
onChange?: (doc: Text, viewUpdate: ViewUpdate, isDirty: boolean) => void
onViewUpdate?: (viewUpdate: ViewUpdate) => void
darkTheme?: boolean
}
export const Editor = React.memo(function CodeMirrorReactEditor({
@ -43,8 +44,10 @@ export const Editor = React.memo(function CodeMirrorReactEditor({
viewRef,
setDirty,
onChange,
onViewUpdate
onViewUpdate,
darkTheme
}: EditorProps) {
const contentRef = useRef(content)
const view = useRef<EditorView>()
const ref = useRef<HTMLDivElement>()
const languageConfig = useMemo(() => new Compartment(), [])
@ -70,7 +73,7 @@ export const Editor = React.memo(function CodeMirrorReactEditor({
color,
hyperLink,
theme,
darkTheme ? githubDark : githubLight,
EditorView.lineWrapping,
@ -136,5 +139,14 @@ export const Editor = React.memo(function CodeMirrorReactEditor({
}
}, [filename, forMarkdown, view, languageConfig, markdownLanguageSupport])
useEffect(() => {
if (contentRef.current !== content) {
contentRef.current = content
viewRef?.current?.dispatch({
changes: { from: 0, to: viewRef?.current?.state.doc.length, insert: content }
})
}
}, [content, viewRef])
return <Container ref={ref} className={cx(css.editor, className)} style={style} />
})

View File

@ -79,7 +79,7 @@ export function ExecutionPageHeader({
content={
executionInfo && (
<Container className={css.executionInfo}>
<ExecutionStatus status={getStatus(executionInfo.status)} iconOnly noBackground iconSize={18} />
<ExecutionStatus status={getStatus(executionInfo.status)} iconOnly noBackground iconSize={18} isCi />
<Text inline color={Color.GREY_800} font={{ size: 'small' }}>
{executionInfo.message}
</Text>

View File

@ -34,6 +34,7 @@ const ExecutionStage: FC<ExecutionStageProps> = ({ stage, isSelected = false, se
noBackground
iconSize={18}
className={css.statusIcon}
isCi
/>
<Text className={css.uid} lineClamp={1}>
{stage.name}

View File

@ -32,6 +32,14 @@
--bgColor: transparent !important;
}
&.waiting {
svg {
> circle {
fill: var(--orange-700) !important;
}
}
}
&.pending {
--fgColor: var(--grey-600);
--bgColor: var(--grey-100);
@ -48,6 +56,14 @@
}
}
&.runningBlue {
svg {
> circle {
fill: var(--primary-6) !important;
}
}
}
&.success {
--fgColor: var(--green-800);
--bgColor: var(--green-50);

View File

@ -7,4 +7,6 @@ export declare const main: string
export declare const noBackground: string
export declare const pending: string
export declare const running: string
export declare const runningBlue: string
export declare const success: string
export declare const waiting: string

View File

@ -10,7 +10,8 @@ export enum ExecutionState {
RUNNING = 'running',
SUCCESS = 'success',
FAILURE = 'failure',
ERROR = 'error'
ERROR = 'error',
SKIPPED = 'skipped'
}
interface ExecutionStatusProps {
@ -19,6 +20,7 @@ interface ExecutionStatusProps {
noBackground?: boolean
iconSize?: number
className?: string
isCi?: boolean
}
export const ExecutionStatus: React.FC<ExecutionStatusProps> = ({
@ -26,19 +28,20 @@ export const ExecutionStatus: React.FC<ExecutionStatusProps> = ({
iconSize = 20,
iconOnly = false,
noBackground = false,
className
className,
isCi = false
}) => {
const { getString } = useStrings()
const maps = useMemo(
() => ({
[ExecutionState.PENDING]: {
icon: 'ci-pending-build',
css: css.pending,
icon: isCi ? 'execution-waiting' : 'ci-pending-build',
css: isCi ? css.waiting : css.pending,
title: getString('pending').toLocaleUpperCase()
},
[ExecutionState.RUNNING]: {
icon: 'running-filled',
css: css.running,
css: isCi ? css.runningBlue : css.running,
title: getString('running').toLocaleUpperCase()
},
[ExecutionState.SUCCESS]: {
@ -55,9 +58,14 @@ export const ExecutionStatus: React.FC<ExecutionStatusProps> = ({
icon: 'solid-error',
css: css.error,
title: getString('error').toLocaleUpperCase()
},
[ExecutionState.SKIPPED]: {
icon: 'execution-timeout',
css: null,
title: getString('skipped').toLocaleUpperCase()
}
}),
[getString]
[getString, isCi]
)
const map = useMemo(() => maps[status], [maps, status])

View File

@ -0,0 +1,21 @@
.author {
color: var(--grey-500) !important;
font-weight: 600 !important;
}
.hash {
color: var(--primary-7) !important;
font-family: Roboto Mono !important;
font-size: var(--font-size-small) !important;
font-weight: 500 !important;
}
.pillContainer {
background-color: var(--grey-100) !important;
color: var(--grey-500) !important;
padding: 2px 4px !important;
}
.pillText {
font-weight: 600 !important;
}

View File

@ -0,0 +1,6 @@
/* eslint-disable */
// This is an auto-generated file
export declare const author: string
export declare const hash: string
export declare const pillContainer: string
export declare const pillText: string

View File

@ -0,0 +1,162 @@
import React from 'react'
import { Avatar, Layout, Text, Utils } from '@harnessio/uicore'
import { Link } from 'react-router-dom'
import { GitCommit, GitFork, Label } from 'iconoir-react'
import { Color } from '@harnessio/design-system'
import { PipeSeparator } from 'components/PipeSeparator/PipeSeparator'
import { useAppContext } from 'AppContext'
import type { EnumTriggerAction } from 'services/code'
import css from './ExecutionText.module.scss'
export enum ExecutionTrigger {
CRON = 'cron',
MANUAL = 'manual',
PUSH = 'push',
PULL = 'pull_request',
TAG = 'tag'
}
enum PillType {
BRANCH = 'branch',
TAG = 'tag',
COMMIT = 'commit'
}
interface PillProps {
type: PillType
text: string
}
interface ExecutionTextProps {
authorName: string
authorEmail: string
repoPath: string
commitRef: string
event: ExecutionTrigger
target: string
beforeRef: string
source: string
action: EnumTriggerAction | undefined
}
const Pill: React.FC<PillProps> = ({ type, text }) => {
let Icon
switch (type) {
case PillType.BRANCH:
Icon = GitFork
break
case PillType.TAG:
Icon = Label
break
case PillType.COMMIT:
Icon = GitCommit
break
default:
Icon = GitCommit
}
return (
<Layout.Horizontal
spacing={'xsmall'}
style={{ alignItems: 'center', borderRadius: '4px' }}
className={css.pillContainer}>
<Icon height={12} width={12} color={Utils.getRealCSSColor(Color.GREY_500)} />
<Text className={css.pillText} font={{ size: 'xsmall' }}>
{text}
</Text>
</Layout.Horizontal>
)
}
export const ExecutionText: React.FC<ExecutionTextProps> = ({
authorName,
authorEmail,
repoPath,
commitRef,
event,
target,
beforeRef,
source,
action
}) => {
const { routes } = useAppContext()
let componentToRender
switch (event) {
case ExecutionTrigger.CRON:
componentToRender = (
<Text font={{ size: 'small' }} className={css.author}>
Triggered by CRON job
</Text>
)
break
case ExecutionTrigger.MANUAL:
componentToRender = (
<Text font={{ size: 'small' }} className={css.author}>{`${authorName} triggered manually`}</Text>
)
break
case ExecutionTrigger.PUSH:
componentToRender = (
<>
<Text font={{ size: 'small' }} className={css.author}>{`${authorName} pushed`}</Text>
<Pill type={PillType.COMMIT} text={beforeRef.slice(0, 6)} />
<Text font={{ size: 'small' }} className={css.author}>
to
</Text>
<Pill type={PillType.BRANCH} text={target} />
</>
)
break
case ExecutionTrigger.PULL:
componentToRender = (
<>
<Text font={{ size: 'small' }} className={css.author}>{`${authorName} ${
action === 'pullreq_reopened' ? 'reopened' : action === 'pullreq_branch_updated' ? 'updated' : 'created'
} pull request`}</Text>
<Pill type={PillType.BRANCH} text={source} />
<Text font={{ size: 'small' }} className={css.author}>
to
</Text>
<Pill type={PillType.BRANCH} text={target} />
</>
)
break
case ExecutionTrigger.TAG:
componentToRender = (
<>
<Text font={{ size: 'small' }} className={css.author}>{`${authorName} ${
action === 'branch_updated' ? 'updated' : 'created'
}`}</Text>
<Pill type={PillType.TAG} text={target.split('/').pop() as string} />
</>
)
break
default:
componentToRender = (
<Text font={{ size: 'small' }} className={css.author}>
Unknown trigger
</Text>
)
}
return (
<Layout.Horizontal spacing={'small'} style={{ alignItems: 'center', marginLeft: '1.2rem' }}>
<Avatar email={authorEmail} name={authorName} size="small" hoverCard={false} />
{componentToRender}
<PipeSeparator height={7} />
<Link
to={routes.toCODECommit({
repoPath: repoPath,
commitRef: commitRef
})}
className={css.hash}
onClick={e => {
e.stopPropagation()
}}>
{commitRef?.slice(0, 6)}
</Link>
</Layout.Horizontal>
)
}

View File

@ -0,0 +1,17 @@
.branchSelect {
:global {
.bp3-popover-wrapper {
width: 100% !important;
.bp3-popover-target {
width: 100% !important;
}
}
.bp3-button {
justify-content: start;
width: 100%;
}
.bp3-icon-chevron-down {
margin-left: auto;
}
}
}

View File

@ -0,0 +1,3 @@
/* eslint-disable */
// This is an auto-generated file
export declare const branchSelect: string

View File

@ -0,0 +1,162 @@
import React, { useMemo, useState } from 'react'
import { useHistory } from 'react-router-dom'
import { useMutate } from 'restful-react'
import * as yup from 'yup'
import { capitalize } from 'lodash-es'
import {
Button,
ButtonVariation,
Container,
Dialog,
FormInput,
Formik,
FormikForm,
Layout,
Text,
useToaster
} from '@harnessio/uicore'
import { FontVariation } from '@harnessio/design-system'
import { useModalHook } from 'hooks/useModalHook'
import type { OpenapiCreatePipelineRequest, TypesPipeline, TypesRepository } from 'services/code'
import { useStrings } from 'framework/strings'
import { BranchTagSelect } from 'components/BranchTagSelect/BranchTagSelect'
import { useAppContext } from 'AppContext'
import { getErrorMessage } from 'utils/Utils'
import { DEFAULT_YAML_PATH_PREFIX, DEFAULT_YAML_PATH_SUFFIX } from '../../pages/AddUpdatePipeline/Constants'
import css from './NewPipelineModal.module.scss'
interface FormData {
name: string
branch: string
yamlPath: string
}
const useNewPipelineModal = () => {
const { routes } = useAppContext()
const { getString } = useStrings()
const history = useHistory()
const { showError } = useToaster()
const [repo, setRepo] = useState<TypesRepository | undefined>()
const repoPath = useMemo(() => repo?.path || '', [repo])
const { mutate: savePipeline } = useMutate<TypesPipeline>({
verb: 'POST',
path: `/api/v1/repos/${repoPath}/+/pipelines`
})
const handleCreatePipeline = (formData: FormData): void => {
const { name, branch, yamlPath } = formData
try {
const payload: OpenapiCreatePipelineRequest = {
config_path: yamlPath,
default_branch: branch,
uid: name
}
savePipeline(payload, { pathParams: { path: `/api/v1/repos/${repoPath}/+/pipelines` } })
.then(() => {
hideModal()
history.push(routes.toCODEPipelineEdit({ repoPath, pipeline: name }))
})
.catch(error => {
showError(getErrorMessage(error), 0, 'pipelines.failedToCreatePipeline')
})
} catch (exception) {
showError(getErrorMessage(exception), 0, 'pipelines.failedToCreatePipeline')
}
}
const [openModal, hideModal] = useModalHook(() => {
const onClose = () => {
hideModal()
}
return (
<Dialog isOpen enforceFocus={false} onClose={onClose} title={getString('pipelines.createNewPipeline')}>
<Formik<FormData>
initialValues={{ name: '', branch: repo?.default_branch || '', yamlPath: '' }}
formName="createNewPipeline"
enableReinitialize={true}
validationSchema={yup.object().shape({
name: yup
.string()
.trim()
.required(`${getString('name')} ${getString('isRequired')}`),
branch: yup
.string()
.trim()
.required(`${getString('branch')} ${getString('isRequired')}`),
yamlPath: yup
.string()
.trim()
.required(`${getString('pipelines.yamlPath')} ${getString('isRequired')}`)
})}
validateOnChange
validateOnBlur
onSubmit={handleCreatePipeline}>
{formik => {
return (
<FormikForm>
<Layout.Vertical spacing="small">
<Layout.Vertical spacing="small">
<FormInput.Text
name="name"
label={getString('name')}
placeholder={getString('pipelines.enterPipelineName')}
inputGroup={{ autoFocus: true }}
onChange={event => {
const input = (event.target as HTMLInputElement)?.value
formik?.setFieldValue('name', input)
if (input) {
// Keeping minimal validation for now, this could be much more exhaustive
const path = input.trim().replace(/\s/g, '')
formik?.setFieldValue(
'yamlPath',
DEFAULT_YAML_PATH_PREFIX.concat(path).concat(DEFAULT_YAML_PATH_SUFFIX)
)
}
}}
/>
<Layout.Vertical spacing="xsmall" padding={{ bottom: 'medium' }}>
<Text font={{ variation: FontVariation.BODY }}>{capitalize(getString('branch'))}</Text>
<Container className={css.branchSelect}>
<BranchTagSelect
gitRef={formik?.values?.branch || repo?.default_branch || ''}
onSelect={(ref: string) => {
formik?.setFieldValue('branch', ref)
}}
repoMetadata={repo || {}}
disableBranchCreation
disableViewAllBranches
forBranchesOnly
/>
</Container>
</Layout.Vertical>
<FormInput.Text
name="yamlPath"
label={getString('pipelines.yamlPath')}
placeholder={getString('pipelines.enterYAMLPath')}
/>
</Layout.Vertical>
<Layout.Horizontal spacing="medium" width="100%">
<Button variation={ButtonVariation.PRIMARY} text={getString('create')} type="submit" />
<Button variation={ButtonVariation.SECONDARY} text={getString('cancel')} onClick={onClose} />
</Layout.Horizontal>
</Layout.Vertical>
</FormikForm>
)
}}
</Formik>
</Dialog>
)
}, [repo])
return {
openModal: ({ repoMetadata }: { repoMetadata?: TypesRepository }) => {
setRepo(repoMetadata)
openModal()
},
hideModal
}
}
export default useNewPipelineModal

View File

@ -0,0 +1,53 @@
.main {
height: 100%;
:global {
.bp3-tabs {
width: 100%;
height: 100%;
}
}
}
.mainTabPanel {
&:global(.bp3-tab-panel[role='tabpanel']) {
height: calc(100% - 30px);
margin-top: 0 !important;
margin-bottom: 0 !important;
}
}
.pluginDetailsPanel {
height: 100%;
border-top: 1px solid var(--grey-100);
}
.pluginIcon {
background: var(--teal-200) !important;
border-radius: 5px;
}
.plugin {
border: 1px solid var(--grey-100);
&:hover {
cursor: pointer;
}
}
.form {
height: 100%;
width: 100%;
:global {
.FormikForm--main {
height: 100%;
& > div {
height: 100%;
}
}
}
}
.arrow {
&:hover {
cursor: pointer;
}
}

View File

@ -0,0 +1,9 @@
/* eslint-disable */
// This is an auto-generated file
export declare const arrow: string
export declare const form: string
export declare const main: string
export declare const mainTabPanel: string
export declare const plugin: string
export declare const pluginDetailsPanel: string
export declare const pluginIcon: string

View File

@ -0,0 +1,301 @@
import React, { useCallback, useEffect, useState } from 'react'
import { Formik } from 'formik'
import { capitalize, get } from 'lodash-es'
import { useGet } from 'restful-react'
import { Color, FontVariation } from '@harnessio/design-system'
import { Icon, type IconName } from '@harnessio/icons'
import { Button, ButtonVariation, Container, FormInput, FormikForm, Layout, Tab, Tabs, Text } from '@harnessio/uicore'
import { useStrings } from 'framework/strings'
import { LIST_FETCHING_LIMIT } from 'utils/Utils'
import type { TypesPlugin } from 'services/code'
import { YamlVersion } from 'pages/AddUpdatePipeline/Constants'
import css from './PluginsPanel.module.scss'
enum PluginCategory {
Harness,
Drone
}
enum PluginPanelView {
Category,
Listing,
Configuration
}
interface PluginInterface {
category: PluginCategory
name: string
description: string
icon: IconName
}
const PluginCategories: PluginInterface[] = [
{
category: PluginCategory.Harness,
name: 'Run',
description: 'Run a script on macOS, Linux, or Windows',
icon: 'run-step'
},
{ category: PluginCategory.Drone, name: 'Drone', description: 'Run Drone plugins', icon: 'ci-infra' }
]
const dronePluginSpecMockData = {
inputs: {
channel: {
type: 'string'
},
token: {
type: 'string'
}
},
steps: [
{
type: 'script',
spec: {
image: 'plugins/slack'
},
envs: {
PLUGIN_CHANNEL: '<+inputs.channel>'
}
}
]
}
const runStepSpec = {
inputs: {
script: {
type: 'string'
}
}
}
export interface PluginsPanelInterface {
version?: YamlVersion
onPluginAddUpdate: (isUpdate: boolean, pluginFormData: Record<string, any>) => void
}
export const PluginsPanel = ({ version = YamlVersion.V0, onPluginAddUpdate }: PluginsPanelInterface): JSX.Element => {
const { getString } = useStrings()
const [category, setCategory] = useState<PluginCategory>()
const [panelView, setPanelView] = useState<PluginPanelView>(PluginPanelView.Category)
const [plugin, setPlugin] = useState<TypesPlugin>()
const {
data: plugins,
loading,
refetch: fetchPlugins
} = useGet<TypesPlugin[]>({
path: `/api/v1/plugins`,
queryParams: {
limit: LIST_FETCHING_LIMIT,
page: 1
},
lazy: true
})
useEffect(() => {
if (category === PluginCategory.Drone) {
fetchPlugins()
}
}, [category])
const renderPluginCategories = (): JSX.Element => {
return (
<>
{PluginCategories.map((item: PluginInterface) => {
const { name, category: pluginCategory, description, icon } = item
return (
<Layout.Horizontal
onClick={() => {
setCategory(pluginCategory)
if (pluginCategory === PluginCategory.Drone) {
setPanelView(PluginPanelView.Listing)
} else if (pluginCategory === PluginCategory.Harness) {
setPlugin({ uid: getString('run') })
setPanelView(PluginPanelView.Configuration)
}
}}
key={pluginCategory}
padding={{ left: 'medium', right: 'medium', top: 'medium', bottom: 'medium' }}
flex={{ justifyContent: 'flex-start' }}
className={css.plugin}>
<Container padding="small" className={css.pluginIcon}>
<Icon name={icon} />
</Container>
<Layout.Vertical padding={{ left: 'small' }}>
<Text color={Color.PRIMARY_7} font={{ variation: FontVariation.BODY2 }}>
{name}
</Text>
<Text font={{ variation: FontVariation.SMALL }}>{description}</Text>
</Layout.Vertical>
</Layout.Horizontal>
)
})}
</>
)
}
const renderPlugins = useCallback((): JSX.Element => {
return loading ? (
<Container flex={{ justifyContent: 'center' }} padding="large">
<Icon name="steps-spinner" color={Color.PRIMARY_7} size={25} />
</Container>
) : (
<Layout.Vertical spacing="small" padding={{ top: 'small' }}>
<Layout.Horizontal
flex={{ justifyContent: 'flex-start', alignItems: 'center' }}
spacing="small"
padding={{ left: 'medium' }}>
<Icon
name="arrow-left"
size={18}
onClick={() => {
setPanelView(PluginPanelView.Category)
}}
className={css.arrow}
/>
<Text font={{ variation: FontVariation.H5 }} flex={{ justifyContent: 'center' }}>
{getString('plugins.addAPlugin', { category: PluginCategory[category as PluginCategory] })}
</Text>
</Layout.Horizontal>
<Container>
{plugins?.map((_plugin: TypesPlugin) => {
const { uid, description } = _plugin
return (
<Layout.Horizontal
flex={{ justifyContent: 'flex-start' }}
padding={{ left: 'large', top: 'medium', bottom: 'medium', right: 'large' }}
className={css.plugin}
onClick={() => {
setPanelView(PluginPanelView.Configuration)
setPlugin(_plugin)
}}
key={uid}>
<Icon name={'gear'} size={25} />
<Layout.Vertical padding={{ left: 'small' }}>
<Text font={{ variation: FontVariation.BODY2 }} color={Color.PRIMARY_7}>
{uid}
</Text>
<Text font={{ variation: FontVariation.SMALL }}>{description}</Text>
</Layout.Vertical>
</Layout.Horizontal>
)
})}
</Container>
</Layout.Vertical>
)
}, [loading, plugins])
const renderPluginFormField = ({ name, type }: { name: string; type: 'string' }): JSX.Element => {
return type === 'string' ? (
<FormInput.Text
name={name}
label={<Text font={{ variation: FontVariation.FORM_INPUT_TEXT }}>{capitalize(name)}</Text>}
style={{ width: '100%' }}
key={name}
/>
) : (
<></>
)
}
const constructPayloadForYAMLInsertion = (isUpdate: boolean, pluginFormData: Record<string, any>) => {
let constructedPayload = { ...pluginFormData }
switch (category) {
case PluginCategory.Drone:
case PluginCategory.Harness:
constructedPayload =
version === YamlVersion.V1
? { type: 'script', spec: constructedPayload }
: { name: 'run step', commands: [get(constructedPayload, 'script', '')] }
}
onPluginAddUpdate?.(isUpdate, constructedPayload)
}
const renderPluginConfigForm = useCallback((): JSX.Element => {
// TODO obtain plugin input spec by parsing YAML
const inputs = get(category === PluginCategory.Drone ? dronePluginSpecMockData : runStepSpec, 'inputs', {})
return (
<Layout.Vertical
spacing="medium"
margin={{ left: 'xxlarge', top: 'large', right: 'xxlarge', bottom: 'xxlarge' }}
height="95%">
<Layout.Horizontal spacing="small" flex={{ justifyContent: 'flex-start' }}>
<Icon
name="arrow-left"
size={18}
onClick={() => {
setPlugin(undefined)
if (category === PluginCategory.Drone) {
setPanelView(PluginPanelView.Listing)
} else if (category === PluginCategory.Harness) {
setPanelView(PluginPanelView.Category)
}
}}
className={css.arrow}
/>
{plugin?.uid ? (
<Text font={{ variation: FontVariation.H5 }}>
{getString('addLabel')} {plugin.uid} {getString('plugins.stepLabel')}
</Text>
) : (
<></>
)}
</Layout.Horizontal>
<Container className={css.form}>
<Formik
initialValues={{}}
onSubmit={formData => {
constructPayloadForYAMLInsertion(false, formData)
}}>
<FormikForm>
<Layout.Vertical flex={{ alignItems: 'flex-start' }} height="100%">
<Layout.Vertical width="100%">
{Object.keys(inputs).map((field: string) => {
const fieldType = get(inputs, `${field}.type`, '') as 'string'
return renderPluginFormField({ name: field, type: fieldType })
})}
</Layout.Vertical>
<Button variation={ButtonVariation.PRIMARY} text={getString('addLabel')} type="submit" />
</Layout.Vertical>
</FormikForm>
</Formik>
</Container>
</Layout.Vertical>
)
}, [plugin, category])
const renderPluginsPanel = useCallback((): JSX.Element => {
switch (panelView) {
case PluginPanelView.Category:
return renderPluginCategories()
case PluginPanelView.Listing:
return renderPlugins()
case PluginPanelView.Configuration:
return renderPluginConfigForm()
default:
return <></>
}
}, [loading, plugins, panelView, category])
return (
<Container className={css.main}>
<Tabs id={'pluginsPanel'} defaultSelectedTabId={'plugins'}>
<Tab
panelClassName={css.mainTabPanel}
id="plugins"
title={
<Text
font={{ variation: FontVariation.BODY2 }}
padding={{ left: 'small', bottom: 'xsmall', top: 'xsmall' }}
color={Color.PRIMARY_7}>
{getString('plugins.title')}
</Text>
}
panel={<Container className={css.pluginDetailsPanel}>{renderPluginsPanel()}</Container>}
/>
</Tabs>
</Container>
)
}

View File

@ -1,5 +1,5 @@
import React, { Fragment } from 'react'
import { Container, Layout, Text, PageHeader } from '@harnessio/uicore'
import { Container, Layout, Text, PageHeader, PageHeaderProps } from '@harnessio/uicore'
import { Icon } from '@harnessio/icons'
import { Color, FontVariation } from '@harnessio/design-system'
import { Link, useParams } from 'react-router-dom'
@ -19,33 +19,35 @@ interface RepositoryPageHeaderProps extends Optional<Pick<GitInfoProps, 'repoMet
title: string | JSX.Element
dataTooltipId: string
extraBreadcrumbLinks?: BreadcrumbLink[]
className?: string
content?: PageHeaderProps['content']
}
export function RepositoryPageHeader({
repoMetadata,
title,
dataTooltipId,
extraBreadcrumbLinks = []
extraBreadcrumbLinks = [],
className,
content
}: RepositoryPageHeaderProps) {
const { gitRef } = useParams<CODEProps>()
const { getString } = useStrings()
const space = useGetSpaceParam()
const { routes } = useAppContext()
if (!repoMetadata) {
return null
}
return (
<PageHeader
className={className}
content={content}
title=""
breadcrumbs={
<Container className={css.header}>
<Layout.Horizontal spacing="small" className={css.breadcrumb}>
<Link to={routes.toCODERepositories({ space })}>{getString('repositories')}</Link>
<Icon name="main-chevron-right" size={8} color={Color.GREY_500} />
<Link to={routes.toCODERepository({ repoPath: repoMetadata.path as string, gitRef })}>
{repoMetadata.uid}
<Link to={routes.toCODERepository({ repoPath: (repoMetadata?.path as string) || '', gitRef })}>
{repoMetadata?.uid || ''}
</Link>
{extraBreadcrumbLinks.map(link => (
<Fragment key={link.url}>

View File

@ -0,0 +1,17 @@
.branchSelect {
:global {
.bp3-popover-wrapper {
width: 100% !important;
.bp3-popover-target {
width: 100% !important;
}
}
.bp3-button {
justify-content: start;
width: 100%;
}
.bp3-icon-chevron-down {
margin-left: auto;
}
}
}

View File

@ -0,0 +1,3 @@
/* eslint-disable */
// This is an auto-generated file
export declare const branchSelect: string

View File

@ -0,0 +1,130 @@
import React, { useMemo, useState } from 'react'
import { useHistory } from 'react-router'
import { useMutate } from 'restful-react'
import * as yup from 'yup'
import { capitalize } from 'lodash-es'
import { FontVariation } from '@harnessio/design-system'
import {
Button,
ButtonVariation,
Container,
Dialog,
Formik,
FormikForm,
Layout,
Text,
useToaster
} from '@harnessio/uicore'
import { useStrings } from 'framework/strings'
import { useModalHook } from 'hooks/useModalHook'
import type { CreateExecutionQueryParams, TypesExecution, TypesRepository } from 'services/code'
import { getErrorMessage } from 'utils/Utils'
import { useAppContext } from 'AppContext'
import { BranchTagSelect } from 'components/BranchTagSelect/BranchTagSelect'
import css from './RunPipelineModal.module.scss'
interface FormData {
branch: string
}
const useRunPipelineModal = () => {
const { routes } = useAppContext()
const { getString } = useStrings()
const { showSuccess, showError, clear: clearToaster } = useToaster()
const history = useHistory()
const [repo, setRepo] = useState<TypesRepository>()
const [pipeline, setPipeline] = useState<string>('')
const repoPath = useMemo(() => repo?.path || '', [repo])
const { mutate: startExecution } = useMutate<TypesExecution>({
verb: 'POST',
path: `/api/v1/repos/${repoPath}/+/pipelines/${pipeline}/executions`
})
const runPipeline = (formData: FormData): void => {
const { branch } = formData
try {
startExecution(
{},
{
pathParams: { path: `/api/v1/repos/${repoPath}/+/pipelines/${pipeline}/executions` },
queryParams: { branch } as CreateExecutionQueryParams
}
)
.then(response => {
clearToaster()
showSuccess(getString('pipelines.executionStarted'))
if (response?.number && !isNaN(response.number)) {
history.push(routes.toCODEExecution({ repoPath, pipeline, execution: response.number.toString() }))
}
hideModal()
})
.catch(error => {
showError(getErrorMessage(error), 0, 'pipelines.executionCouldNotStart')
})
} catch (exception) {
showError(getErrorMessage(exception), 0, 'pipelines.executionCouldNotStart')
}
}
const [openModal, hideModal] = useModalHook(() => {
const onClose = () => {
hideModal()
}
return (
<Dialog isOpen enforceFocus={false} onClose={onClose} title={getString('pipelines.run')}>
<Formik
formName="run-pipeline-form"
initialValues={{ branch: repo?.default_branch || '' }}
validationSchema={yup.object().shape({
branch: yup
.string()
.trim()
.required(`${getString('branch')} ${getString('isRequired')}`)
})}
onSubmit={runPipeline}
enableReinitialize>
{formik => {
return (
<FormikForm>
<Layout.Vertical spacing="medium">
<Layout.Vertical spacing="xsmall" padding={{ bottom: 'medium' }}>
<Text font={{ variation: FontVariation.BODY }}>{capitalize(getString('branch'))}</Text>
<Container className={css.branchSelect}>
<BranchTagSelect
gitRef={formik?.values?.branch || repo?.default_branch || ''}
onSelect={(ref: string) => {
formik?.setFieldValue('branch', ref)
}}
repoMetadata={repo || {}}
disableBranchCreation
disableViewAllBranches
forBranchesOnly
/>
</Container>
</Layout.Vertical>
<Layout.Horizontal spacing="medium">
<Button variation={ButtonVariation.PRIMARY} type="submit" text={getString('pipelines.run')} />
<Button variation={ButtonVariation.SECONDARY} text={getString('cancel')} onClick={onClose} />
</Layout.Horizontal>
</Layout.Vertical>
</FormikForm>
)
}}
</Formik>
</Dialog>
)
}, [repo?.default_branch, pipeline])
return {
openModal: ({ repoMetadata, pipeline: pipelineUid }: { repoMetadata: TypesRepository; pipeline: string }) => {
setRepo(repoMetadata)
setPipeline(pipelineUid)
openModal()
},
hideModal
}
}
export default useRunPipelineModal

View File

@ -16,6 +16,7 @@ interface SearchInputWithSpinnerProps {
icon?: IconName
spinnerIcon?: IconName
spinnerPosition?: 'left' | 'right'
onSearch?: (searchTerm: string) => void
}
export const SearchInputWithSpinner: React.FC<SearchInputWithSpinnerProps> = ({
@ -26,7 +27,8 @@ export const SearchInputWithSpinner: React.FC<SearchInputWithSpinnerProps> = ({
placeholder,
icon = 'search',
spinnerIcon = 'steps-spinner',
spinnerPosition = 'left'
spinnerPosition = 'left',
onSearch
}) => {
const { getString } = useStrings()
const spinner = <Icon name={spinnerIcon as IconName} color={Color.PRIMARY_7} />
@ -37,6 +39,7 @@ export const SearchInputWithSpinner: React.FC<SearchInputWithSpinnerProps> = ({
<Layout.Horizontal className={css.layout}>
<Render when={loading && !spinnerOnRight}>{spinner}</Render>
<TextInput
type="search"
value={query}
wrapperClassName={cx(css.wrapper, { [css.spinnerOnRight]: spinnerOnRight })}
className={css.input}
@ -45,7 +48,14 @@ export const SearchInputWithSpinner: React.FC<SearchInputWithSpinnerProps> = ({
style={{ width }}
autoFocus
onFocus={event => event.target.select()}
onInput={event => setQuery(event.currentTarget.value || '')}
onInput={event => {
setQuery(event.currentTarget.value || '')
}}
onKeyDown={(e: React.KeyboardEvent<HTMLElement>) => {
if (e.key === 'Enter') {
onSearch?.((e as unknown as React.FormEvent<HTMLInputElement>).currentTarget.value || '')
}
}}
/>
<Render when={loading && spinnerOnRight}>{spinner}</Render>
</Layout.Horizontal>

View File

@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'
import * as monaco from 'monaco-editor'
import type monacoEditor from 'monaco-editor/esm/vs/editor/editor.api'
import MonacoEditor, { MonacoDiffEditor } from 'react-monaco-editor'
import { setDiagnosticsOptions } from 'monaco-yaml'
import { noop } from 'lodash-es'
import { SourceCodeEditorProps, PLAIN_TEXT } from 'utils/Utils'
import { useEventListener } from 'hooks/useEventListener'
@ -39,7 +40,8 @@ export default function MonacoSourceCodeEditor({
height,
autoHeight,
wordWrap = true,
onChange = noop
onChange = noop,
schema
}: SourceCodeEditorProps) {
const [editor, setEditor] = useState<monacoEditor.editor.IStandaloneCodeEditor>()
const scrollbar = autoHeight ? 'hidden' : 'auto'
@ -50,6 +52,24 @@ export default function MonacoSourceCodeEditor({
monaco.languages.typescript?.typescriptDefaults?.setCompilerOptions?.(compilerOptions)
}, [])
useEffect(() => {
if (language === 'yaml' && schema) {
setDiagnosticsOptions({
validate: true,
enableSchemaRequest: false,
hover: true,
completion: true,
schemas: [
{
fileMatch: ['*'],
schema,
uri: 'https://github.com/harness/harness-schema'
}
]
})
}
}, [language, schema])
useEventListener('resize', () => {
editor?.layout({ width: 0, height: 0 })
window.requestAnimationFrame(() => editor?.layout())

View File

@ -48,7 +48,7 @@
width: min(calc(100vw - var(--nav-menu-width)), 840px) !important;
height: 100vh;
position: fixed;
left: 5px;
left: 241px;
top: -5px;
> div {
@ -79,6 +79,13 @@
background-color: rgba(26, 26, 26, 0.4);
}
:global {
.bp3-popover-arrow {
left: -11px !important;
transform: rotate(180deg);
}
}
> div {
height: 100%;
width: 100%;

View File

@ -0,0 +1,48 @@
.main {
:global {
.Resizer {
background-color: var(--grey-300);
opacity: 0.2;
z-index: 1;
box-sizing: border-box;
background-clip: padding-box;
}
.Resizer:hover {
transition: all 2s ease;
}
.Resizer.horizontal {
margin: -5px 0;
border-top: 5px solid rgba(255, 255, 255, 0);
border-bottom: 5px solid rgba(255, 255, 255, 0);
cursor: row-resize;
}
.Resizer.horizontal:hover {
border-top: 5px solid rgba(0, 0, 0, 0.5);
border-bottom: 5px solid rgba(0, 0, 0, 0.5);
}
.Resizer.vertical {
width: 11px;
margin: 0 -5px;
border-left: 5px solid rgba(255, 255, 255, 0);
border-right: 5px solid rgba(255, 255, 255, 0);
cursor: col-resize;
}
.Resizer.vertical:hover {
border-left: 5px solid rgba(0, 0, 0, 0.5);
border-right: 5px solid rgba(0, 0, 0, 0.5);
}
.Resizer.disabled {
cursor: not-allowed;
}
.Resizer.disabled:hover {
border-color: transparent;
}
}
}

View File

@ -1,4 +1,3 @@
/* eslint-disable */
// This is an auto-generated file
export declare const layout: string
export declare const main: string

View File

@ -0,0 +1,8 @@
import React from 'react'
import cx from 'classnames'
import SplitPane, { type SplitPaneProps } from 'react-split-pane'
import css from './Split.module.scss'
export const Split: React.FC<SplitPaneProps> = ({ className, ...props }) => (
<SplitPane className={cx(css.main, className)} {...props} />
)

View File

@ -11,11 +11,13 @@ export interface StringsMap {
add: string
addComment: string
addGitIgnore: string
addLabel: string
addLicense: string
addMember: string
addNewFile: string
addReadMe: string
admin: string
aiSearch: string
all: string
allBranches: string
allComments: string
@ -64,6 +66,7 @@ export interface StringsMap {
cloneText: string
close: string
closed: string
codeSearch: string
comment: string
commentDeleted: string
commit: string
@ -190,6 +193,7 @@ export interface StringsMap {
failedToCreateSpace: string
failedToDeleteBranch: string
failedToDeleteWebhook: string
failedToSavePipeline: string
fileDeleted: string
fileTooLarge: string
files: string
@ -212,6 +216,7 @@ export interface StringsMap {
'homepage.welcomeText': string
in: string
inactiveBranches: string
isRequired: string
leaveAComment: string
license: string
lineBreaks: string
@ -304,6 +309,7 @@ export interface StringsMap {
'pageTitle.repositories': string
'pageTitle.repository': string
'pageTitle.repositorySettings': string
'pageTitle.search': string
'pageTitle.secrets': string
'pageTitle.signin': string
'pageTitle.spaceSettings': string
@ -318,11 +324,27 @@ export interface StringsMap {
payloadUrl: string
payloadUrlLabel: string
pending: string
'pipelines.createNewPipeline': string
'pipelines.created': string
'pipelines.editPipeline': string
'pipelines.enterPipelineName': string
'pipelines.enterYAMLPath': string
'pipelines.executionCouldNotStart': string
'pipelines.executionStarted': string
'pipelines.failedToCreatePipeline': string
'pipelines.lastExecution': string
'pipelines.name': string
'pipelines.newPipelineButton': string
'pipelines.noData': string
'pipelines.run': string
'pipelines.saveAndRun': string
'pipelines.time': string
poweredByAI: string
'pipelines.updated': string
'pipelines.yamlPath': string
'plugins.addAPlugin': string
'plugins.stepLabel': string
'plugins.title': string
'pr.ableToMerge': string
'pr.addDescription': string
'pr.authorCommentedPR': string
@ -391,6 +413,7 @@ export interface StringsMap {
'prChecks.notFound': string
'prChecks.pending': string
'prChecks.running': string
'prChecks.skipped': string
'prChecks.success': string
'prChecks.viewExternal': string
prMustSelectSourceAndTargetBranches: string
@ -455,6 +478,7 @@ export interface StringsMap {
reviewerNotFound: string
reviewers: string
role: string
run: string
running: string
samplePayloadUrl: string
save: string
@ -462,6 +486,7 @@ export interface StringsMap {
scrollToTop: string
search: string
searchBranches: string
searchResult: string
secret: string
'secrets.createSecret': string
'secrets.createSuccess': string
@ -488,6 +513,7 @@ export interface StringsMap {
showMore: string
signIn: string
signUp: string
skipped: string
space: string
'spaceMemberships.addMember': string
'spaceMemberships.changeRole': string
@ -506,6 +532,7 @@ export interface StringsMap {
'spaceSetting.setting': string
spaces: string
sslVerificationLabel: string
startSearching: string
status: string
submitReview: string
success: string
@ -565,6 +592,7 @@ export interface StringsMap {
viewAllTags: string
viewCommitDetails: string
viewFile: string
viewFileHistory: string
viewFiles: string
viewRaw: string
viewRepo: string

View File

@ -1,8 +0,0 @@
// temp file to hide open source pipelines and secrets - can be extended if needs be
const featureFlags = {
OPEN_SOURCE_PIPELINES: false,
OPEN_SOURCE_SECRETS: false
}
export const useFeatureFlag = (): Record<keyof typeof featureFlags, boolean> => featureFlags

View File

@ -63,6 +63,11 @@ export function usePRChecksDecision({
setColor(Color.GREY_600)
setBackground(Color.GREY_100)
setMessage(stringSubstitute(getString('prChecks.pending'), { count: _count.pending, total }) as string)
} else if (_count.skipped) {
_status = ExecutionState.SKIPPED
setColor(Color.GREY_600)
setBackground(Color.GREY_100)
setMessage(stringSubstitute(getString('prChecks.skipped'), { count: _count.pending, total }) as string)
} else if (_count.success) {
_status = ExecutionState.SUCCESS
setColor(Color.GREEN_800)
@ -110,5 +115,6 @@ const DEFAULT_COUNTS = {
failure: 0,
pending: 0,
running: 0,
success: 0
success: 0,
skipped: 0
}

View File

@ -96,6 +96,7 @@ pageTitle:
pipelines: Pipelines
secrets: Secrets
executions: Executions
search: Search powered by AI
repos:
name: Repo Name
data: Repo Data
@ -298,6 +299,7 @@ prChecks:
failure: '{count}/{total} {count|1:check,checks} failed.'
running: '{count}/{total} {count|1:check,checks} running.'
pending: '{count}/{total} {count|1:check,checks} pending.'
skipped: '{count}/{total} {count|1:check,checks} skipped.'
success: '{count}/{total} {count|1:check,checks} succeeded.'
notFound: No pipelines or external checks found for this repository.
viewExternal: View Details
@ -416,6 +418,7 @@ makeRequired: Make Required
makeOptional: Make Optional
remove: Remove
required: Required
isRequired: is required
noneYet: None Yet
noOptionalReviewers: No Optional Reviewers
noRequiredReviewers: No Required Reviewers
@ -480,13 +483,14 @@ newTag: New Tag
overview: Overview
fileTooLarge: File is too large to open. {download}
clickHereToDownload: Click here to download.
viewFile: View the file at this point in the history
viewFileHistory: View the file at this point in the history
viewRepo: View the repository at this point in the history
hideCommitHistory: Renamed from {file} - Hide History
showCommitHistory: Renamed from {file} - Show History
noReviewers: No Reviewers
assignPeople: Assign people
add: Add +
addLabel: Add
users: Users
findAUser: Find a user
reviewerNotFound: 'Reviewer <strong>{{reviewer}}</strong> not found.'
@ -586,6 +590,7 @@ spaceMemberships:
memberAdded: Member added successfully.
failedToCreateSpace: Failed to create Space. Please try again.
failedToCreatePipeline: Failed to create Pipeline. Please try again.
failedToSavePipeline: Failed to save Pipeline. Please try again.
enterName: Enter the name
createASpace: Create a space
createSpace: Create Space
@ -613,6 +618,7 @@ running: Running
success: Success
failed: Failed
error: Error
skipped: Skipped
repoDelete:
title: Delete Repository
deleteConfirm1: This will permanently delete the "{{repo}}" repository, wiki, issues, comments, packages, secrets, workflow runs, and remove all collaborator associations.
@ -624,8 +630,20 @@ pipelines:
noData: There are no pipelines :(
newPipelineButton: New Pipeline
name: Pipeline Name
createNewPipeline: Create New Pipeline
enterPipelineName: Enter pipeline name
yamlPath: YAML Path
enterYAMLPath: Enter YAML path
failedToCreatePipeline: Failed to create pipeline
saveAndRun: Save and Run
editPipeline: Edit pipeline {{pipeline}}
run: Run pipeline
time: Time
lastExecution: Last Execution
created: Pipeline created successfully
updated: Pipeline updated successfully
executionStarted: Pipeline execution started successfully
executionCouldNotStart: Failure while starting Pipeline execution
executions:
noData: There are no executions :(
newExecutionButton: Run Pipeline
@ -652,3 +670,14 @@ secrets:
failedToDeleteSecret: Failed to delete Secret. Please try again.
deleteSecret: Delete Secrets
userUpdateSuccess: 'User updated successfully'
viewFile: View File
searchResult: 'Search Result {count}'
aiSearch: AI Search
codeSearch: Code Search
startSearching: Begin search by describing what you are looking for.
poweredByAI: Unlock the power of AI with Semantic Code search. Try phrases like "Locate the code for authentication".
run: Run
plugins:
title: Plugins
addAPlugin: Add a {{category}} plugin
stepLabel: step

View File

@ -11,6 +11,7 @@
height: 100%;
display: flex;
flex-direction: column;
overflow: auto;
.settings {
margin: 0 var(--spacing-medium);

View File

@ -7,13 +7,12 @@ import { useStrings } from 'framework/strings'
import type { TypesSpace } from 'services/code'
import { SpaceSelector } from 'components/SpaceSelector/SpaceSelector'
import { useAppContext } from 'AppContext'
import { useFeatureFlag } from 'hooks/useFeatureFlag'
import { NavMenuItem } from './NavMenuItem'
import css from './DefaultMenu.module.scss'
export const DefaultMenu: React.FC = () => {
const history = useHistory()
const { routes } = useAppContext()
const { routes, standalone } = useAppContext()
const [selectedSpace, setSelectedSpace] = useState<TypesSpace | undefined>()
const { repoMetadata, gitRef, commitRef } = useGetRepositoryMetadata()
const { getString } = useStrings()
@ -25,7 +24,6 @@ export const DefaultMenu: React.FC = () => {
)
const isCommitSelected = useMemo(() => routeMatch.path === '/:space*/:repoName/commit/:commitRef*', [routeMatch])
const { OPEN_SOURCE_PIPELINES, OPEN_SOURCE_SECRETS } = useFeatureFlag()
return (
<Container className={css.main}>
<Layout.Vertical spacing="small">
@ -118,7 +116,7 @@ export const DefaultMenu: React.FC = () => {
})}
/>
{OPEN_SOURCE_PIPELINES && (
{standalone && (
<NavMenuItem
data-code-repo-section="pipelines"
isSubLink
@ -128,20 +126,30 @@ export const DefaultMenu: React.FC = () => {
})}
/>
)}
<NavMenuItem
data-code-repo-section="settings"
data-code-repo-section="pipelines"
isSubLink
label={getString('settings')}
to={routes.toCODESettings({
label={getString('pageTitle.pipelines')}
to={routes.toCODEPipelines({
repoPath
})}
/>
{!standalone && (
<NavMenuItem
data-code-repo-section="search"
isSubLink
label={getString('search')}
to={routes.toCODESearch({
repoPath
})}
/>
)}
</Layout.Vertical>
</Container>
</Render>
{OPEN_SOURCE_SECRETS && (
{standalone && (
<Render when={selectedSpace}>
{/* icon is placeholder */}
<NavMenuItem

View File

@ -0,0 +1,34 @@
.main {
--header-height: 96px;
--heading-height: 58px;
min-height: var(--page-height);
background-color: var(--primary-bg) !important;
.layout {
align-items: center;
}
}
.editorContainer {
width: calc(100% - 30vw);
height: calc(100vh - var(--header-height)) !important;
}
.pluginsContainer {
width: 30vw;
}
.header {
a {
font-size: var(--font-size-small);
color: var(--primary-7);
}
}
.breadcrumb {
align-items: center;
}
.drawer {
height: calc(100% - 40px);
}

View File

@ -0,0 +1,9 @@
/* eslint-disable */
// This is an auto-generated file
export declare const breadcrumb: string
export declare const drawer: string
export declare const editorContainer: string
export declare const header: string
export declare const layout: string
export declare const main: string
export declare const pluginsContainer: string

View File

@ -0,0 +1,327 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { useGet, useMutate } from 'restful-react'
import { Link, useParams } from 'react-router-dom'
import { get, isEmpty, isUndefined, set } from 'lodash-es'
import { stringify } from 'yaml'
import { Menu, PopoverPosition } from '@blueprintjs/core'
import {
Container,
PageHeader,
PageBody,
Layout,
ButtonVariation,
Text,
useToaster,
SplitButton,
Button
} from '@harnessio/uicore'
import { Icon } from '@harnessio/icons'
import { Color, FontVariation } from '@harnessio/design-system'
import type { OpenapiCommitFilesRequest, RepoCommitFilesResponse, RepoFileContent, TypesPipeline } from 'services/code'
import { useStrings } from 'framework/strings'
import { useGetRepositoryMetadata } from 'hooks/useGetRepositoryMetadata'
import { useGetResourceContent } from 'hooks/useGetResourceContent'
import MonacoSourceCodeEditor from 'components/SourceCodeEditor/MonacoSourceCodeEditor'
import { PluginsPanel } from 'components/PluginsPanel/PluginsPanel'
import useRunPipelineModal from 'components/RunPipelineModal/RunPipelineModal'
import { LoadingSpinner } from 'components/LoadingSpinner/LoadingSpinner'
import { useAppContext } from 'AppContext'
import type { CODEProps } from 'RouteDefinitions'
import { getErrorMessage } from 'utils/Utils'
import { decodeGitContent } from 'utils/GitUtils'
import pipelineSchemaV1 from './schema/pipeline-schema-v1.json'
import pipelineSchemaV0 from './schema/pipeline-schema-v0.json'
import { YamlVersion } from './Constants'
import css from './AddUpdatePipeline.module.scss'
const StarterPipelineV1: Record<string, any> = {
version: 1,
stages: [
{
type: 'ci',
spec: {
steps: [
{
type: 'script',
spec: {
run: 'echo hello world'
}
}
]
}
}
]
}
const StarterPipelineV0: Record<string, any> = {
kind: 'pipeline',
type: 'docker',
name: 'default',
steps: [
{
name: 'test',
image: 'alpine',
commands: ['echo hello world']
}
]
}
enum PipelineSaveAndRunAction {
SAVE,
RUN,
SAVE_AND_RUN
}
interface PipelineSaveAndRunOption {
title: string
action: PipelineSaveAndRunAction
}
const AddUpdatePipeline = (): JSX.Element => {
const version = YamlVersion.V0
const { routes } = useAppContext()
const { getString } = useStrings()
const { pipeline } = useParams<CODEProps>()
const { repoMetadata } = useGetRepositoryMetadata()
const { showError, showSuccess, clear: clearToaster } = useToaster()
const [pipelineAsObj, setPipelineAsObj] = useState<Record<string, any>>(
version === YamlVersion.V0 ? StarterPipelineV0 : StarterPipelineV1
)
const [pipelineAsYAML, setPipelineAsYaml] = useState<string>('')
const { openModal: openRunPipelineModal } = useRunPipelineModal()
const repoPath = useMemo(() => repoMetadata?.path || '', [repoMetadata])
const [isExistingPipeline, setIsExistingPipeline] = useState<boolean>(false)
const [isDirty, setIsDirty] = useState<boolean>(false)
const pipelineSaveOption: PipelineSaveAndRunOption = {
title: getString('save'),
action: PipelineSaveAndRunAction.SAVE
}
const pipelineRunOption: PipelineSaveAndRunOption = {
title: getString('run'),
action: PipelineSaveAndRunAction.RUN
}
const pipelineSaveAndRunOption: PipelineSaveAndRunOption = {
title: getString('pipelines.saveAndRun'),
action: PipelineSaveAndRunAction.SAVE_AND_RUN
}
const pipelineSaveAndRunOptions: PipelineSaveAndRunOption[] = [pipelineSaveAndRunOption, pipelineSaveOption]
const [selectedOption, setSelectedOption] = useState<PipelineSaveAndRunOption>()
const { mutate, loading } = useMutate<RepoCommitFilesResponse>({
verb: 'POST',
path: `/api/v1/repos/${repoPath}/+/commits`
})
// Fetch pipeline metadata to fetch pipeline YAML file content
const { data: pipelineData, loading: fetchingPipeline } = useGet<TypesPipeline>({
path: `/api/v1/repos/${repoPath}/+/pipelines/${pipeline}`,
lazy: !repoMetadata
})
const {
data: pipelineYAMLFileContent,
loading: fetchingPipelineYAMLFileContent,
refetch: fetchPipelineYAMLFileContent
} = useGetResourceContent({
repoMetadata,
gitRef: pipelineData?.default_branch || '',
resourcePath: pipelineData?.config_path || ''
})
const originalPipelineYAMLFileContent = useMemo(
(): string => decodeGitContent((pipelineYAMLFileContent?.content as RepoFileContent)?.data),
[pipelineYAMLFileContent?.content]
)
// check if file already exists and has some content
useEffect(() => {
setIsExistingPipeline(!isEmpty(originalPipelineYAMLFileContent) && !isUndefined(originalPipelineYAMLFileContent))
}, [originalPipelineYAMLFileContent])
// load initial content on the editor
useEffect(() => {
if (isExistingPipeline) {
setPipelineAsYaml(originalPipelineYAMLFileContent)
} else {
// load with starter pipeline
try {
setPipelineAsYaml(stringify(pipelineAsObj))
} catch (ex) {
// ignore exception
}
}
}, [isExistingPipeline, originalPipelineYAMLFileContent, pipelineAsObj])
// find if editor content was modified
useEffect(() => {
setIsDirty(originalPipelineYAMLFileContent !== pipelineAsYAML)
}, [originalPipelineYAMLFileContent, pipelineAsYAML])
// set initial CTA title
useEffect(() => {
setSelectedOption(isDirty ? pipelineSaveAndRunOption : pipelineRunOption)
}, [isDirty])
const handleSaveAndRun = (option: PipelineSaveAndRunOption): void => {
if ([PipelineSaveAndRunAction.SAVE_AND_RUN, PipelineSaveAndRunAction.SAVE].includes(option?.action)) {
try {
const data: OpenapiCommitFilesRequest = {
actions: [
{
action: isExistingPipeline ? 'UPDATE' : 'CREATE',
path: pipelineData?.config_path,
payload: pipelineAsYAML,
sha: isExistingPipeline ? pipelineYAMLFileContent?.sha : ''
}
],
branch: pipelineData?.default_branch || '',
title: `${isExistingPipeline ? getString('updated') : getString('created')} pipeline ${pipeline}`,
message: ''
}
mutate(data)
.then(() => {
fetchPipelineYAMLFileContent()
clearToaster()
showSuccess(getString(isExistingPipeline ? 'pipelines.updated' : 'pipelines.created'))
if (option?.action === PipelineSaveAndRunAction.SAVE_AND_RUN && repoMetadata && pipeline) {
openRunPipelineModal({ repoMetadata, pipeline })
}
setSelectedOption(pipelineRunOption)
})
.catch(error => {
showError(getErrorMessage(error), 0, 'pipelines.failedToSavePipeline')
})
} catch (exception) {
showError(getErrorMessage(exception), 0, 'pipelines.failedToSavePipeline')
}
}
}
const updatePipeline = (payload: Record<string, any>): Record<string, any> => {
const pipelineAsObjClone = { ...pipelineAsObj }
const stepInsertPath = version === YamlVersion.V0 ? 'steps' : 'stages.0.spec.steps'
let existingSteps: [unknown] = get(pipelineAsObjClone, stepInsertPath, [])
if (existingSteps.length > 0) {
existingSteps.push(payload)
} else {
existingSteps = [payload]
}
set(pipelineAsObjClone, stepInsertPath, existingSteps)
return pipelineAsObjClone
}
const addUpdatePluginToPipelineYAML = (_isUpdate: boolean, pluginFormData: Record<string, any>): void => {
try {
const updatedPipelineAsObj = updatePipeline(pluginFormData)
setPipelineAsObj(updatedPipelineAsObj)
setPipelineAsYaml(stringify(updatedPipelineAsObj))
} catch (ex) {
// ignore exception
}
}
const renderCTA = useCallback(() => {
switch (selectedOption?.action) {
case PipelineSaveAndRunAction.RUN:
return (
<Button
variation={ButtonVariation.PRIMARY}
text={getString('run')}
onClick={() => {
if (repoMetadata && pipeline) {
openRunPipelineModal({ repoMetadata, pipeline })
}
}}
/>
)
case PipelineSaveAndRunAction.SAVE:
case PipelineSaveAndRunAction.SAVE_AND_RUN:
return isExistingPipeline ? (
<Button
variation={ButtonVariation.PRIMARY}
text={getString('save')}
onClick={() => {
handleSaveAndRun(pipelineSaveOption)
}}
disabled={loading || !isDirty}
/>
) : (
<SplitButton
text={selectedOption?.title}
disabled={loading || !isDirty}
variation={ButtonVariation.PRIMARY}
popoverProps={{
interactionKind: 'click',
usePortal: true,
position: PopoverPosition.BOTTOM_RIGHT,
transitionDuration: 1000
}}
intent="primary"
onClick={() => handleSaveAndRun(selectedOption)}>
{pipelineSaveAndRunOptions.map(option => {
return (
<Menu.Item
key={option.title}
text={
<Text color={Color.BLACK} font={{ variation: FontVariation.SMALL_BOLD }}>
{option.title}
</Text>
}
onClick={() => {
setSelectedOption(option)
}}
/>
)
})}
</SplitButton>
)
default:
return <></>
}
}, [loading, fetchingPipeline, isDirty, repoMetadata, pipeline, selectedOption, isExistingPipeline, pipelineAsYAML])
return (
<>
<Container className={css.main}>
<PageHeader
title={getString('pipelines.editPipeline', { pipeline })}
breadcrumbs={
<Container className={css.header}>
<Layout.Horizontal spacing="small" className={css.breadcrumb}>
<Link to={routes.toCODEPipelines({ repoPath })}>{getString('pageTitle.pipelines')}</Link>
<Icon name="main-chevron-right" size={8} color={Color.GREY_500} />
<Text font={{ size: 'small' }}>{pipeline}</Text>
</Layout.Horizontal>
</Container>
}
content={<Layout.Horizontal flex={{ justifyContent: 'space-between' }}>{renderCTA()}</Layout.Horizontal>}
/>
<PageBody>
<LoadingSpinner visible={fetchingPipeline || fetchingPipelineYAMLFileContent} />
<Layout.Horizontal>
<Container className={css.editorContainer}>
<MonacoSourceCodeEditor
language={'yaml'}
schema={version === YamlVersion.V0 ? pipelineSchemaV0 : pipelineSchemaV1}
source={pipelineAsYAML}
onChange={(value: string) => setPipelineAsYaml(value)}
/>
</Container>
<Container className={css.pluginsContainer}>
<PluginsPanel onPluginAddUpdate={addUpdatePluginToPipelineYAML} />
</Container>
</Layout.Horizontal>
</PageBody>
</Container>
</>
)
}
export default AddUpdatePipeline

View File

@ -0,0 +1,7 @@
export enum YamlVersion {
V0,
V1
}
export const DEFAULT_YAML_PATH_PREFIX = '.harness/'
export const DEFAULT_YAML_PATH_SUFFIX = '.yaml'

View File

@ -0,0 +1,72 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": ["pipeline"]
},
"name": {
"type": "string"
},
"trigger": {
"type": "object",
"properties": {
"event": {
"type": "string"
},
"branch": {
"type": "array",
"items": {
"type": "string"
}
},
"path": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": ["event"]
},
"steps": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"image": {
"type": "string"
},
"commands": {
"type": "array",
"items": {
"type": "string"
}
},
"when": {
"type": "string",
"enum": ["on_success", "on_failure", "always"]
},
"depends_on": {
"type": "array",
"items": {
"type": "string"
}
},
"environment": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"required": ["name", "commands"]
}
}
},
"required": ["kind", "name", "steps"]
}

File diff suppressed because it is too large Load Diff

View File

@ -1,53 +1,6 @@
.main {
min-height: var(--page-height);
background-color: var(--primary-bg) !important;
:global {
.Resizer {
background-color: var(--grey-300);
opacity: 0.2;
z-index: 1;
box-sizing: border-box;
background-clip: padding-box;
}
.Resizer:hover {
transition: all 2s ease;
}
.Resizer.horizontal {
margin: -5px 0;
border-top: 5px solid rgba(255, 255, 255, 0);
border-bottom: 5px solid rgba(255, 255, 255, 0);
cursor: row-resize;
}
.Resizer.horizontal:hover {
border-top: 5px solid rgba(0, 0, 0, 0.5);
border-bottom: 5px solid rgba(0, 0, 0, 0.5);
}
.Resizer.vertical {
width: 11px;
margin: 0 -5px;
border-left: 5px solid rgba(255, 255, 255, 0);
border-right: 5px solid rgba(255, 255, 255, 0);
cursor: col-resize;
}
.Resizer.vertical:hover {
border-left: 5px solid rgba(0, 0, 0, 0.5);
border-right: 5px solid rgba(0, 0, 0, 0.5);
}
.Resizer.disabled {
cursor: not-allowed;
}
.Resizer.disabled:hover {
border-color: transparent;
}
}
}
.container {

View File

@ -3,7 +3,6 @@ import React, { useEffect, useState } from 'react'
import cx from 'classnames'
import { useParams } from 'react-router-dom'
import { useGet } from 'restful-react'
import SplitPane from 'react-split-pane'
import { routes, type CODEProps } from 'RouteDefinitions'
import type { TypesExecution } from 'services/code'
import ExecutionStageList from 'components/ExecutionStageList/ExecutionStageList'
@ -12,6 +11,7 @@ import { getErrorMessage, voidFn } from 'utils/Utils'
import { useStrings } from 'framework/strings'
import { LoadingSpinner } from 'components/LoadingSpinner/LoadingSpinner'
import { useGetRepositoryMetadata } from 'hooks/useGetRepositoryMetadata'
import { Split } from 'components/Split/Split'
import { ExecutionPageHeader } from 'components/ExecutionPageHeader/ExecutionPageHeader'
import usePipelineEventStream from 'hooks/usePipelineEventStream'
import { ExecutionState } from 'components/ExecutionStatus/ExecutionStatus'
@ -102,7 +102,7 @@ const Execution = () => {
}}>
<LoadingSpinner visible={loading || isInitialLoad} withBorder={!!execution && isInitialLoad} />
{execution && (
<SplitPane split="vertical" size={300} minSize={200} maxSize={400}>
<Split split="vertical" size={300} minSize={200} maxSize={400}>
<ExecutionStageList
stages={execution?.stages || []}
setSelectedStage={setSelectedStage}
@ -111,7 +111,7 @@ const Execution = () => {
{selectedStage && (
<Console stage={execution?.stages?.[selectedStage - 1]} repoPath={repoMetadata?.path as string} />
)}
</SplitPane>
</Split>
)}
</PageBody>
</Container>

View File

@ -51,21 +51,4 @@
font-size: var(--font-size-normal) !important;
font-weight: 600 !important;
}
.author {
color: var(--grey500) !important;
font-size: var(--font-size-small) !important;
font-weight: 600 !important;
}
.hash {
color: var(--primary-7) !important;
font-family: Roboto Mono !important;
font-size: var(--font-size-small) !important;
font-weight: 500 !important;
}
.triggerLayout {
align-items: center !important;
}
}

View File

@ -1,13 +1,10 @@
/* eslint-disable */
// This is an auto-generated file
export declare const author: string
export declare const desc: string
export declare const hash: string
export declare const layout: string
export declare const main: string
export declare const name: string
export declare const nameContainer: string
export declare const number: string
export declare const pinned: string
export declare const triggerLayout: string
export declare const withError: string

View File

@ -1,6 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react'
import {
Avatar,
Button,
ButtonVariation,
Container,
@ -15,7 +14,7 @@ import {
import { Color } from '@harnessio/design-system'
import cx from 'classnames'
import type { CellProps, Column } from 'react-table'
import { Link, useHistory, useParams } from 'react-router-dom'
import { useHistory, useParams } from 'react-router-dom'
import { useGet, useMutate } from 'restful-react'
import { Timer, Calendar } from 'iconoir-react'
import { useStrings } from 'framework/strings'
@ -24,7 +23,7 @@ import { useAppContext } from 'AppContext'
import { NoResultCard } from 'components/NoResultCard/NoResultCard'
import { LIST_FETCHING_LIMIT, PageBrowserProps, getErrorMessage, timeDistance, voidFn } from 'utils/Utils'
import type { CODEProps } from 'RouteDefinitions'
import type { TypesExecution } from 'services/code'
import type { EnumTriggerAction, TypesExecution } from 'services/code'
import { useQueryParams } from 'hooks/useQueryParams'
import { usePageIndex } from 'hooks/usePageIndex'
import { ResourceListingPagination } from 'components/ResourceListingPagination/ResourceListingPagination'
@ -32,8 +31,8 @@ import { useGetRepositoryMetadata } from 'hooks/useGetRepositoryMetadata'
import { RepositoryPageHeader } from 'components/RepositoryPageHeader/RepositoryPageHeader'
import { ExecutionStatus } from 'components/ExecutionStatus/ExecutionStatus'
import { getStatus } from 'utils/PipelineUtils'
import { PipeSeparator } from 'components/PipeSeparator/PipeSeparator'
import usePipelineEventStream from 'hooks/usePipelineEventStream'
import { ExecutionText, ExecutionTrigger } from 'components/ExecutionText/ExecutionText'
import noExecutionImage from '../RepositoriesListing/no-repo.svg'
import css from './ExecutionList.module.scss'
@ -94,6 +93,7 @@ const ExecutionList = () => {
//TODO - this should NOT be hardcoded to master branch - need a modal to insert branch - but useful for testing until then
await mutate({ branch: 'master' })
showSuccess('Build started')
executionsRefetch()
} catch {
showError('Failed to start build')
}
@ -118,27 +118,21 @@ const ExecutionList = () => {
return (
<Layout.Vertical className={css.nameContainer}>
<Layout.Horizontal spacing={'small'} style={{ alignItems: 'center' }}>
<ExecutionStatus status={getStatus(record.status)} iconOnly noBackground iconSize={20} />
<ExecutionStatus status={getStatus(record.status)} iconOnly noBackground iconSize={20} isCi />
<Text className={css.number}>{`#${record.number}.`}</Text>
<Text className={css.desc}>{record.message}</Text>
</Layout.Horizontal>
<Layout.Horizontal spacing={'small'} style={{ alignItems: 'center', marginLeft: '1.2rem' }}>
<Avatar email={record.author_email} name={record.author_name} size="small" hoverCard={false} />
{/* TODO need logic here for different trigger types */}
<Text className={css.author}>{`${record.author_name} triggered manually`}</Text>
<PipeSeparator height={7} />
<Link
to={routes.toCODECommit({
repoPath: repoMetadata?.path as string,
commitRef: record.after as string
})}
className={css.hash}
onClick={e => {
e.stopPropagation()
}}>
{record.after?.slice(0, 6)}
</Link>
</Layout.Horizontal>
<ExecutionText
authorEmail={record.author_email as string}
authorName={record.author_name as string}
repoPath={repoMetadata?.path as string}
commitRef={record.after as string}
event={record.event as ExecutionTrigger}
action={record.action as EnumTriggerAction}
target={record.target as string}
beforeRef={record.before as string}
source={record.source as string}
/>
</Layout.Vertical>
)
}

View File

@ -1,8 +0,0 @@
.main {
min-height: var(--page-height);
background-color: var(--primary-bg) !important;
.layout {
align-items: center;
}
}

View File

@ -1,15 +0,0 @@
import React from 'react'
import { Container, PageHeader } from '@harnessio/uicore'
import { useStrings } from 'framework/strings'
import css from './NewPipeline.module.scss'
const NewPipeline = () => {
const { getString } = useStrings()
return (
<Container className={css.main}>
<PageHeader title={getString('pipelines.newPipelineButton')} />
</Container>
)
}
export default NewPipeline

View File

@ -1,4 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Classes, Menu, MenuItem, Popover, Position } from '@blueprintjs/core'
import {
Avatar,
Button,
@ -33,6 +34,7 @@ import { RepositoryPageHeader } from 'components/RepositoryPageHeader/Repository
import { ExecutionStatus, ExecutionState } from 'components/ExecutionStatus/ExecutionStatus'
import { getStatus } from 'utils/PipelineUtils'
import { PipeSeparator } from 'components/PipeSeparator/PipeSeparator'
import useNewPipelineModal from 'components/NewPipelineModal/NewPipelineModal'
import { useGetSpaceParam } from 'hooks/useGetSpaceParam'
import usePipelineEventStream from 'hooks/usePipelineEventStream'
import noPipelineImage from '../RepositoriesListing/no-repo.svg'
@ -40,13 +42,13 @@ import css from './PipelineList.module.scss'
const PipelineList = () => {
const { routes } = useAppContext()
const space = useGetSpaceParam()
const history = useHistory()
const { getString } = useStrings()
const [searchTerm, setSearchTerm] = useState<string | undefined>()
const pageBrowser = useQueryParams<PageBrowserProps>()
const pageInit = pageBrowser.page ? parseInt(pageBrowser.page) : 1
const [page, setPage] = usePageIndex(pageInit)
const space = useGetSpaceParam()
const { repoMetadata, error, loading, refetch } = useGetRepositoryMetadata()
@ -62,6 +64,7 @@ const PipelineList = () => {
debounce: 500
})
const { openModal } = useNewPipelineModal()
//TODO - do not want to show load between refetchs - remove if/when we move to event stream method
const [isInitialLoad, setIsInitialLoad] = useState(true)
@ -90,15 +93,17 @@ const PipelineList = () => {
variation={ButtonVariation.PRIMARY}
icon="plus"
onClick={() => {
history.push(routes.toCODEPipelinesNew({ space }))
}}></Button>
openModal({ repoMetadata })
}}
disabled={loading}
/>
)
const columns: Column<TypesPipeline>[] = useMemo(
() => [
{
Header: getString('pipelines.name'),
width: 'calc(50% - 90px)',
width: 'calc(100% - 210px)',
Cell: ({ row }: CellProps<TypesPipeline>) => {
const record = row.original
return (
@ -109,6 +114,7 @@ const PipelineList = () => {
noBackground
iconSize={24}
className={css.statusIcon}
isCi
/>
<Text className={css.repoName}>
<Keywords value={searchTerm}>{record.uid}</Keywords>
@ -128,7 +134,7 @@ const PipelineList = () => {
<Layout.Horizontal spacing={'small'} style={{ alignItems: 'center' }}>
<Text className={css.desc}>{`#${record.number}`}</Text>
<PipeSeparator height={7} />
<Text className={css.desc}>{record.message}</Text>
<Text className={css.desc}>{record.title || record.message}</Text>
</Layout.Horizontal>
<Layout.Horizontal spacing={'xsmall'} style={{ alignItems: 'center' }}>
<Avatar
@ -140,9 +146,13 @@ const PipelineList = () => {
/>
{/* TODO need logic here for different trigger types */}
<Text className={css.author}>{record.author_name}</Text>
<PipeSeparator height={7} />
<GitFork height={12} width={12} color={Utils.getRealCSSColor(Color.GREY_500)} />
<Text className={css.author}>{record.source}</Text>
{record.target && (
<>
<PipeSeparator height={7} />
<GitFork height={12} width={12} color={Utils.getRealCSSColor(Color.GREY_500)} />
<Text className={css.author}>{record.target.split('/').pop()}</Text>
</>
)}
<PipeSeparator height={7} />
<Link
to={routes.toCODECommit({
@ -188,6 +198,46 @@ const PipelineList = () => {
)
},
disableSortBy: true
},
{
Header: ' ',
width: '30px',
Cell: ({ row }: CellProps<TypesPipeline>) => {
const [menuOpen, setMenuOpen] = useState(false)
const record = row.original
const { uid } = record
return (
<Popover
isOpen={menuOpen}
onInteraction={nextOpenState => {
setMenuOpen(nextOpenState)
}}
className={Classes.DARK}
position={Position.BOTTOM_RIGHT}>
<Button
variation={ButtonVariation.ICON}
icon="Options"
data-testid={`menu-${record.uid}`}
onClick={e => {
e.stopPropagation()
setMenuOpen(true)
}}
/>
<Menu>
<MenuItem
icon="edit"
text={getString('edit')}
onClick={e => {
e.stopPropagation()
history.push(
routes.toCODEPipelineEdit({ repoPath: repoMetadata?.path || '', pipeline: uid as string })
)
}}
/>
</Menu>
</Popover>
)
}
}
],
[getString, repoMetadata?.path, routes, searchTerm]

View File

@ -139,53 +139,6 @@
}
}
}
:global {
.Resizer {
background-color: var(--grey-300);
opacity: 0.2;
z-index: 1;
box-sizing: border-box;
background-clip: padding-box;
}
.Resizer:hover {
transition: all 2s ease;
}
.Resizer.horizontal {
margin: -5px 0;
border-top: 5px solid rgba(255, 255, 255, 0);
border-bottom: 5px solid rgba(255, 255, 255, 0);
cursor: row-resize;
}
.Resizer.horizontal:hover {
border-top: 5px solid rgba(0, 0, 0, 0.5);
border-bottom: 5px solid rgba(0, 0, 0, 0.5);
}
.Resizer.vertical {
width: 11px;
margin: 0 -5px;
border-left: 5px solid rgba(255, 255, 255, 0);
border-right: 5px solid rgba(255, 255, 255, 0);
cursor: col-resize;
}
.Resizer.vertical:hover {
border-left: 5px solid rgba(0, 0, 0, 0.5);
border-right: 5px solid rgba(0, 0, 0, 0.5);
}
.Resizer.disabled {
cursor: not-allowed;
}
.Resizer.disabled:hover {
border-color: transparent;
}
}
}
.status {

View File

@ -1,7 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Falsy, Match, Render, Truthy } from 'react-jsx-match'
import { /*CheckCircle,*/ NavArrowRight } from 'iconoir-react'
import SplitPane from 'react-split-pane'
import { get } from 'lodash-es'
import cx from 'classnames'
import { useHistory } from 'react-router-dom'
@ -24,6 +23,7 @@ import type { GitInfoProps } from 'utils/GitUtils'
import { useAppContext } from 'AppContext'
import { useQueryParams } from 'hooks/useQueryParams'
import { useStrings } from 'framework/strings'
import { Split } from 'components/Split/Split'
import { MarkdownViewer } from 'components/MarkdownViewer/MarkdownViewer'
import type { PRChecksDecisionResult } from 'hooks/usePRChecksDecision'
import type { TypesCheck } from 'services/code'
@ -56,7 +56,7 @@ export const Checks: React.FC<ChecksProps> = props => {
<Container className={css.main}>
<Match expr={props.prChecksDecisionResult?.overallStatus}>
<Truthy>
<SplitPane
<Split
split="vertical"
size="calc(100% - 400px)"
minSize={800}
@ -118,7 +118,7 @@ export const Checks: React.FC<ChecksProps> = props => {
</Falsy>
</Match>
</Container>
</SplitPane>
</Split>
</Truthy>
<Falsy>
<Container flex={{ align: 'center-center' }} height="90%">

View File

@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Container, Layout, PageBody, Tabs, Text } from '@harnessio/uicore'
import { FontVariation } from '@harnessio/design-system'
import { useGet } from 'restful-react'
import { useGet, useMutate } from 'restful-react'
import { Render } from 'react-jsx-match'
import { useHistory } from 'react-router-dom'
import { compact } from 'lodash-es'
@ -9,7 +9,7 @@ import { useAppContext } from 'AppContext'
import { useGetRepositoryMetadata } from 'hooks/useGetRepositoryMetadata'
import { useStrings } from 'framework/strings'
import { RepositoryPageHeader } from 'components/RepositoryPageHeader/RepositoryPageHeader'
import { voidFn, getErrorMessage, PullRequestSection } from 'utils/Utils'
import { voidFn, getErrorMessage, PullRequestSection, MergeCheckStatus } from 'utils/Utils'
import { CodeIcon } from 'utils/GitUtils'
import type { TypesPullReq, TypesPullReqStats, TypesRepository } from 'services/code'
import { LoadingSpinner } from 'components/LoadingSpinner/LoadingSpinner'
@ -83,6 +83,14 @@ export default function PullRequest() {
})
)
}, [history, routes, repoMetadata?.path, pullRequestId])
const recheckPath = useMemo(
() => `/api/v1/repos/${repoMetadata?.path}/+/pullreq/${pullRequestId}/recheck`,
[repoMetadata?.path, pullRequestId]
)
const { mutate : recheckPR, loading: loadingRecheckPR } = useMutate({
verb: 'POST',
path: recheckPath,
})
useEffect(
function setStatsIfNotSet() {
@ -98,6 +106,27 @@ export default function PullRequest() {
useEffect(
function setPrDataIfNotSet() {
if (pullRequestData) {
// recheck pr (merge-check, ...) in case it's unavailable
// Approximation of identifying target branch update:
// 1. branch got updated before page was loaded (status is unchecked and prData is empty)
// NOTE: This doesn't guarantee the status is UNCHECKED due to target branch update and can cause duplicate
// PR merge checks being run on PR creation or source branch update.
// 2. branch got updated while we are on the page (same source_sha but status changed to UNCHECKED)
// NOTE: This doesn't cover the case in which the status changed back to UNCHECKED before the PR is refetched.
// In that case, the user will have to re-open the PR - better than us spamming the backend with rechecks.
// This is a TEMPORARY SOLUTION and will most likely change in the future (more so on backend side)
if (pullRequestData.state == 'open' &&
pullRequestData.merge_check_status == MergeCheckStatus.UNCHECKED &&
(
// case 1:
!prData ||
// case 2:
(prData?.merge_check_status != MergeCheckStatus.UNCHECKED && prData?.source_sha == pullRequestData.source_sha)
) && !loadingRecheckPR) {
// best effort attempt to recheck PR - fail silently
recheckPR({})
}
setPrData(pullRequestData)
}
},

View File

@ -1,5 +1,6 @@
.main {
padding: var(--spacing-large) var(--spacing-xlarge) 0 var(--spacing-xlarge) !important;
position: relative;
div[class*='TextInput'] {
margin-bottom: 0 !important;
@ -54,7 +55,31 @@
}
}
.breadcrumbItem {
white-space: nowrap !important;
.searchBox {
position: absolute;
right: 16px;
top: -50px;
z-index: 2;
padding-bottom: 0 !important;
margin: 0;
input,
input:focus {
border: 1px solid var(--ai-purple-600) !important;
}
input {
width: 350px !important;
}
svg path {
fill: var(--ai-purple-600) !important;
}
img {
position: absolute;
top: 5px;
right: 13px;
}
}
}

View File

@ -1,7 +1,7 @@
/* eslint-disable */
// This is an auto-generated file
export declare const breadcrumbItem: string
export declare const btnColorFix: string
export declare const main: string
export declare const refRoot: string
export declare const rootSlash: string
export declare const searchBox: string

Some files were not shown because too many files have changed in this diff Show More