diff --git a/dash/src/components/applications/git-config.tsx b/dash/src/components/applications/git-config.tsx index 2de925e..a4e4edd 100644 --- a/dash/src/components/applications/git-config.tsx +++ b/dash/src/components/applications/git-config.tsx @@ -4,9 +4,10 @@ import { Label } from "@/components/ui/label" import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select" import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" import { toast } from "sonner" import type { App } from "@/types/app" -import { Github } from "lucide-react" +import { Github, GitBranch } from "lucide-react" import { Skeleton } from "@/components/ui/skeleton" interface GitProviderTabProps { @@ -31,6 +32,11 @@ export const GitProviderTab = ({ app }: GitProviderTabProps) => { const [isRepoLoading, setIsRepoLoading] = useState(true) const [isBranchLoading, setIsBranchLoading] = useState(false) + // Public Git state + const [publicGitUrl, setPublicGitUrl] = useState(app.gitCloneUrl || "") + const [publicGitBranch, setPublicGitBranch] = useState(app.gitBranch || "main") + const [isSavingPublicGit, setIsSavingPublicGit] = useState(false) + // this is for github app fetching // FIX: name of this function should be changed const fetchApp = async () => { @@ -149,6 +155,38 @@ export const GitProviderTab = ({ app }: GitProviderTabProps) => { } } + const savePublicGitConfig = async () => { + if (!publicGitUrl.trim()) { + toast.error("Please enter a Git URL") + return + } + + try { + setIsSavingPublicGit(true) + const res = await fetch("/api/apps/update", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + appId: app.id, + gitProviderId: null, + gitRepository: null, + gitBranch: publicGitBranch || "main", + gitCloneUrl: publicGitUrl.trim(), + }), + }) + + const data = await res.json() + if (!data.success) throw new Error(data.error) + + toast.success("Public Git configuration saved") + } catch { + toast.error("Failed to save configuration") + } finally { + setIsSavingPublicGit(false) + } + } + return ( @@ -160,6 +198,11 @@ export const GitProviderTab = ({ app }: GitProviderTabProps) => { GitHub + + + Public Git + + {/* */} {/* */} {/* GitLab */} @@ -298,6 +341,52 @@ export const GitProviderTab = ({ app }: GitProviderTabProps) => { )} + {/* ✅ PUBLIC GIT TAB CONTENT */} + + + + Public Git Repository + + Deploy from any public Git repository by providing the URL and branch. + + + + +
+
+ + setPublicGitUrl(e.target.value)} + /> +

+ Enter the full clone URL of your public repository +

+
+ +
+ + setPublicGitBranch(e.target.value)} + /> +

+ The branch to deploy from (default: main) +

+
+
+ + +
+
+
+ {/* ✅ OTHER PROVIDERS (Disabled) */} diff --git a/dash/src/features/applications/AppPage.tsx b/dash/src/features/applications/AppPage.tsx index 1b89ce0..6e08e3e 100644 --- a/dash/src/features/applications/AppPage.tsx +++ b/dash/src/features/applications/AppPage.tsx @@ -97,18 +97,18 @@ export const AppPage = () => {
Info - {app.appType !== 'database' && Git} + {app.appType !== 'database' && Sources} Environment {app.appType === 'web' && Domains} Deployments Stats Logs Settings - -
+ + {/* ✅ INFO TAB */} - + < TabsContent value="info" className="space-y-6" >
@@ -117,13 +117,15 @@ export const AppPage = () => {
-
+
- {app.appType !== 'database' && ( - - - - )} + { + app.appType !== 'database' && ( + + + + ) + } {/* ✅ ENVIRONMENT TAB */} @@ -131,11 +133,13 @@ export const AppPage = () => { {/* ✅ DOMAINS TAB */} - {app.appType === 'web' && ( - - - - )} + { + app.appType === 'web' && ( + + + + ) + } {/* ✅ DEPLOYMENTS TAB */} @@ -155,20 +159,21 @@ export const AppPage = () => { -
- + + {/* Edit Modal */} - setIsModalOpen(false)} title="Edit App" - fields={[ - { label: "App Name", name: "name", type: "text", defaultValue: app.name }, - { label: "Description", name: "description", type: "textarea", defaultValue: app.description || "" }, - ]} + fields={ + [ + { label: "App Name", name: "name", type: "text", defaultValue: app.name }, + { label: "Description", name: "description", type: "textarea", defaultValue: app.description || "" }, + ]} onSubmit={(data) => handleUpdateApp(data as { name: string; description: string })} /> - + ); }; diff --git a/server/api/handlers/applications/getLatestCommit.go b/server/api/handlers/applications/getLatestCommit.go index 057300b..5ee2c22 100644 --- a/server/api/handlers/applications/getLatestCommit.go +++ b/server/api/handlers/applications/getLatestCommit.go @@ -6,7 +6,7 @@ import ( "github.com/corecollectives/mist/api/handlers" "github.com/corecollectives/mist/api/middleware" - "github.com/corecollectives/mist/github" + "github.com/corecollectives/mist/git" "github.com/corecollectives/mist/models" ) @@ -56,7 +56,7 @@ func GetLatestCommit(w http.ResponseWriter, r *http.Request) { return } - commit, err := github.GetLatestCommit(req.AppID, userInfo.ID) + commit, err := git.GetLatestCommit(req.AppID, userInfo.ID) if err != nil { handlers.SendResponse(w, http.StatusInternalServerError, false, nil, "Failed to get latest commit", err.Error()) return diff --git a/server/api/handlers/deployments/AddDeployHandler.go b/server/api/handlers/deployments/AddDeployHandler.go index 315881f..ef6ae33 100644 --- a/server/api/handlers/deployments/AddDeployHandler.go +++ b/server/api/handlers/deployments/AddDeployHandler.go @@ -6,7 +6,7 @@ import ( "github.com/corecollectives/mist/api/handlers" "github.com/corecollectives/mist/api/middleware" - "github.com/corecollectives/mist/github" + "github.com/corecollectives/mist/git" "github.com/corecollectives/mist/models" "github.com/corecollectives/mist/queue" "github.com/rs/zerolog/log" @@ -39,7 +39,7 @@ func AddDeployHandler(w http.ResponseWriter, r *http.Request) { if app.AppType != models.AppTypeDatabase { userId := int64(user.ID) - commit, err := github.GetLatestCommit(int64(req.AppId), userId) + commit, err := git.GetLatestCommit(int64(req.AppId), userId) if err != nil { log.Error().Err(err).Int("app_id", req.AppId).Msg("Error getting latest commit") handlers.SendResponse(w, http.StatusInternalServerError, false, nil, "failed to get latest commit", err.Error()) diff --git a/server/git/clone.go b/server/git/clone.go index 1f9865f..41d3f3f 100644 --- a/server/git/clone.go +++ b/server/git/clone.go @@ -4,13 +4,16 @@ import ( "context" "fmt" "os" + "time" + "github.com/corecollectives/mist/github" + "github.com/corecollectives/mist/models" "github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6/plumbing" "github.com/rs/zerolog/log" ) -func CloneRepo(ctx context.Context, url string, branch string, logFile *os.File, path string) error { +func CloneGitRepo(ctx context.Context, url string, branch string, logFile *os.File, path string) error { _, err := fmt.Fprintf(logFile, "[GIT]: Cloning into %s\n", path) if err != nil { log.Warn().Msg("error logging into log file") @@ -30,3 +33,92 @@ func CloneRepo(ctx context.Context, url string, branch string, logFile *os.File, return nil } + +func CloneRepo(ctx context.Context, appId int64, logFile *os.File) error { + log.Info().Int64("app_id", appId).Msg("Starting repository clone") + + userId, err := models.GetUserIDByAppID(appId) + if err != nil { + return fmt.Errorf("failed to get user id by app id: %w", err) + } + + cloneURL, accessToken, shouldMigrate, err := models.GetAppCloneURL(appId, *userId) + if err != nil { + return fmt.Errorf("failed to get clone URL: %w", err) + } + + _, _, branch, _, projectId, name, err := models.GetAppGitInfo(appId) + if err != nil { + return fmt.Errorf("failed to fetch app: %w", err) + } + + if shouldMigrate { + log.Info().Int64("app_id", appId).Msg("Migrating legacy app to new git format") + // for legacy GitHub apps, we don't have a git_provider_id + // we just update the git_clone_url + err = models.UpdateAppGitCloneURL(appId, cloneURL, nil) + if err != nil { + log.Warn().Err(err).Int64("app_id", appId).Msg("Failed to migrate app git info, continuing anyway") + } + } + + // construct authenticated clone URL if we have an access token + repoURL := cloneURL + if accessToken != "" { + // insert token into the URL + // for GitHub: https://x-access-token:TOKEN@github.com/user/repo.git + // for GitLab: https://oauth2:TOKEN@gitlab.com/user/repo.git + // for Bitbucket: https://x-token-auth:TOKEN@bitbucket.org/user/repo.git + // for Gitea: https://TOKEN@gitea.com/user/repo.git + + // simple approach: insert token after https:// + // if len(cloneURL) > 8 && cloneURL[:8] == "https://" { + // repoURL = fmt.Sprintf("https://x-access-token:%s@%s", accessToken, cloneURL[8:]) + // } + repoURL = github.CreateCloneUrl(accessToken, repoURL) + } + + path := fmt.Sprintf("/var/lib/mist/projects/%d/apps/%s", projectId, name) + + if _, err := os.Stat(path + "/.git"); err == nil { + log.Info().Str("path", path).Msg("Repository already exists, removing directory") + + if err := os.RemoveAll(path); err != nil { + return fmt.Errorf("failed to remove existing repository: %w", err) + + } + } + + if err := os.MkdirAll(path, 0o755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + log.Info().Str("clone_url", cloneURL).Str("branch", branch).Str("path", path).Msg("Cloning repository") + + ctx, cancel := context.WithTimeout(ctx, 10*time.Minute) + defer cancel() + + // old command implementation + // + // + // cmd := exec.CommandContext(ctx, "git", "clone", "--branch", branch, repoURL, path) + // output, err := cmd.CombinedOutput() + // lines := strings.Split(string(output), "\n") + // for _, line := range lines { + // if len(line) > 0 { + // fmt.Fprintf(logFile, "[GIT] %s\n", line) + // } + // } + + // new git sdk implementation + err = CloneGitRepo(ctx, repoURL, branch, logFile, path) + if err != nil { + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("git clone timed out after 10 minutes") + } + return fmt.Errorf("error cloning repository: %v\n", err) + } + + log.Info().Int64("app_id", appId).Str("path", path).Msg("Repository cloned successfully") + return nil +} diff --git a/server/git/commit.go b/server/git/commit.go new file mode 100644 index 0000000..efd977e --- /dev/null +++ b/server/git/commit.go @@ -0,0 +1,101 @@ +package git + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/corecollectives/mist/constants" + "github.com/corecollectives/mist/github" + "github.com/corecollectives/mist/models" +) + +func GetLatestCommit(appID int64, userID int64) (*models.LatestCommit, error) { + gitProvider, err := models.GetGitProviderNameByAppID(appID) + if err != nil { + fmt.Printf("error getting provider name by app id", err.Error()) + return nil, err + } + + gitCloneUrl, err := models.GetCloneUrlfromAppID(appID) + if err != nil { + return nil, err + } + if gitProvider == nil && gitCloneUrl != nil { + fmt.Printf("no provider found") + return latestRemoteCommit(*gitCloneUrl) + } else if gitProvider == nil && gitCloneUrl == nil { + return nil, fmt.Errorf("git url or provider not given") + } + + if *gitProvider == models.GitProviderGitHub { + fmt.Printf("github provider found") + return github.GetLatestCommit(appID, userID) + } + return nil, fmt.Errorf("failed to get latest commit") + +} + +func latestRemoteCommit(repoURL string) (*models.LatestCommit, error) { + + tmpPath := filepath.Join(constants.Constants["RootPath"].(string), "git-meta") + if err := os.MkdirAll(tmpPath, 0o755); err != nil { + return nil, err + } + tmpDir, err := os.MkdirTemp(tmpPath, "repo-") + if err != nil { + return nil, err + } + defer os.RemoveAll(tmpDir) + cmd := exec.Command("git", "init") + cmd.Dir = tmpDir + if out, err := cmd.CombinedOutput(); err != nil { + return nil, fmt.Errorf("git init failed: %w: %s", err, out) + } + + cmd = exec.Command("git", "remote", "add", "origin", repoURL) + cmd.Dir = tmpDir + if out, err := cmd.CombinedOutput(); err != nil { + return nil, fmt.Errorf("git remote add failed: %w: %s", err, out) + } + cmd = exec.Command( + "git", + "fetch", + "--depth=1", + "--filter=blob:none", + "origin", + "HEAD", + ) + cmd.Dir = tmpDir + if out, err := cmd.CombinedOutput(); err != nil { + return nil, fmt.Errorf("git fetch failed: %w: %s", err, out) + } + + cmd = exec.Command( + "git", + "show", + "-s", + "--format=%H%n%an%n%s", + "FETCH_HEAD", + ) + cmd.Dir = tmpDir + + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("git show failed: %w", err) + } + + parts := strings.SplitN(strings.TrimSpace(string(out)), "\n", 3) + if len(parts) < 3 { + return nil, fmt.Errorf("unexpected git show output") + } + + return &models.LatestCommit{ + SHA: parts[0], + Author: parts[1], + Message: parts[2], + URL: "", + }, nil +} diff --git a/server/git/types.go b/server/git/types.go new file mode 100644 index 0000000..73a3b68 --- /dev/null +++ b/server/git/types.go @@ -0,0 +1,8 @@ +package git + +type LatestCommit struct { + SHA string `json:"sha"` + Message string `json:"message"` + URL string `json:"html_url"` + Author string `json:"author"` +} diff --git a/server/github/cloneRepo.go b/server/github/cloneRepo.go deleted file mode 100644 index 19f0019..0000000 --- a/server/github/cloneRepo.go +++ /dev/null @@ -1,100 +0,0 @@ -package github - -import ( - "context" - "fmt" - "os" - "time" - - "github.com/corecollectives/mist/git" - "github.com/corecollectives/mist/models" - "github.com/rs/zerolog/log" -) - -func CloneRepo(ctx context.Context, appId int64, logFile *os.File) error { - log.Info().Int64("app_id", appId).Msg("Starting repository clone") - - userId, err := models.GetUserIDByAppID(appId) - if err != nil { - return fmt.Errorf("failed to get user id by app id: %w", err) - } - - cloneURL, accessToken, shouldMigrate, err := models.GetAppCloneURL(appId, *userId) - if err != nil { - return fmt.Errorf("failed to get clone URL: %w", err) - } - - _, _, branch, _, projectId, name, err := models.GetAppGitInfo(appId) - if err != nil { - return fmt.Errorf("failed to fetch app: %w", err) - } - - if shouldMigrate { - log.Info().Int64("app_id", appId).Msg("Migrating legacy app to new git format") - // for legacy GitHub apps, we don't have a git_provider_id - // we just update the git_clone_url - err = models.UpdateAppGitCloneURL(appId, cloneURL, nil) - if err != nil { - log.Warn().Err(err).Int64("app_id", appId).Msg("Failed to migrate app git info, continuing anyway") - } - } - - // construct authenticated clone URL if we have an access token - repoURL := cloneURL - if accessToken != "" { - // insert token into the URL - // for GitHub: https://x-access-token:TOKEN@github.com/user/repo.git - // for GitLab: https://oauth2:TOKEN@gitlab.com/user/repo.git - // for Bitbucket: https://x-token-auth:TOKEN@bitbucket.org/user/repo.git - // for Gitea: https://TOKEN@gitea.com/user/repo.git - - // simple approach: insert token after https:// - if len(cloneURL) > 8 && cloneURL[:8] == "https://" { - repoURL = fmt.Sprintf("https://x-access-token:%s@%s", accessToken, cloneURL[8:]) - } - } - - path := fmt.Sprintf("/var/lib/mist/projects/%d/apps/%s", projectId, name) - - if _, err := os.Stat(path + "/.git"); err == nil { - log.Info().Str("path", path).Msg("Repository already exists, removing directory") - - if err := os.RemoveAll(path); err != nil { - return fmt.Errorf("failed to remove existing repository: %w", err) - - } - } - - if err := os.MkdirAll(path, 0o755); err != nil { - return fmt.Errorf("failed to create directory: %w", err) - } - - log.Info().Str("clone_url", cloneURL).Str("branch", branch).Str("path", path).Msg("Cloning repository") - - ctx, cancel := context.WithTimeout(ctx, 10*time.Minute) - defer cancel() - - // old command implementation - // - // - // cmd := exec.CommandContext(ctx, "git", "clone", "--branch", branch, repoURL, path) - // output, err := cmd.CombinedOutput() - // lines := strings.Split(string(output), "\n") - // for _, line := range lines { - // if len(line) > 0 { - // fmt.Fprintf(logFile, "[GIT] %s\n", line) - // } - // } - - // new git sdk implementation - err = git.CloneRepo(ctx, repoURL, branch, logFile, path) - if err != nil { - if ctx.Err() == context.DeadlineExceeded { - return fmt.Errorf("git clone timed out after 10 minutes") - } - return fmt.Errorf("error cloning repository: %v\n", err) - } - - log.Info().Int64("app_id", appId).Str("path", path).Msg("Repository cloned successfully") - return nil -} diff --git a/server/github/getLatestCommit.go b/server/github/getLatestCommit.go index ccd631d..65f2b66 100644 --- a/server/github/getLatestCommit.go +++ b/server/github/getLatestCommit.go @@ -17,7 +17,7 @@ import ( "github.com/corecollectives/mist/models" ) -func GetLatestCommit(appID, userID int64) (*LatestCommit, error) { +func GetLatestCommit(appID, userID int64) (*models.LatestCommit, error) { repoName, branch, err := models.GetAppRepoAndBranch(appID) if err != nil { return nil, fmt.Errorf("failed to fetch app repo: %w", err) @@ -74,7 +74,7 @@ func GetLatestCommit(appID, userID int64) (*LatestCommit, error) { return nil, fmt.Errorf("failed to decode GitHub response: %w", err) } - return &LatestCommit{ + return &models.LatestCommit{ SHA: data.SHA, Message: data.Commit.Message, URL: data.HTMLURL, diff --git a/server/github/repo.go b/server/github/repo.go new file mode 100644 index 0000000..45c91cf --- /dev/null +++ b/server/github/repo.go @@ -0,0 +1,10 @@ +package github + +import "fmt" + +func CreateCloneUrl(accessToken string, repoURL string) string { + if len(repoURL) > 8 && repoURL[:8] == "https://" { + repoURL = fmt.Sprintf("https://x-access-token:%s@%s", accessToken, repoURL[8:]) + } + return repoURL +} diff --git a/server/models/app.go b/server/models/app.go index 4315c38..8e9038a 100644 --- a/server/models/app.go +++ b/server/models/app.go @@ -278,3 +278,47 @@ func GetAppCloneURL(appID int64, userID int64) (string, string, bool, error) { return "", "", false, fmt.Errorf("app has no git repository or clone URL configured") } + +func GetCloneUrlfromAppID(appID int64) (*string, error) { + var URL string + if err := db.Model(&App{}).Select("git_clone_url").Where("id = ?", appID).Take(&URL).Error; err != nil { + fmt.Printf(err.Error(), "eror getting clone git_clone_url") + return nil, err + } + return &URL, nil +} + +func GetGitProviderNameByAppID( + appID int64, +) (*GitProviderType, error) { + + var app struct { + GitProviderID *int64 + } + + if err := db. + Model(&App{}). + Select("git_provider_id"). + Where("id = ?", appID). + Take(&app).Error; err != nil { + return nil, err + } + + if app.GitProviderID == nil { + return nil, nil + } + + var result struct { + Provider GitProviderType `gorm:"column:provider"` + } + + if err := db. + Model(&GitProvider{}). + Select("provider"). + Where("id = ?", *app.GitProviderID). + Take(&result).Error; err != nil { + return nil, err + } + + return &result.Provider, nil +} diff --git a/server/models/misc.go b/server/models/misc.go new file mode 100644 index 0000000..725a0c8 --- /dev/null +++ b/server/models/misc.go @@ -0,0 +1,8 @@ +package models + +type LatestCommit struct { + SHA string `json:"sha"` + Message string `json:"message"` + URL string `json:"html_url"` + Author string `json:"author"` +} diff --git a/server/queue/handleWork.go b/server/queue/handleWork.go index 06e6188..e9b1940 100644 --- a/server/queue/handleWork.go +++ b/server/queue/handleWork.go @@ -8,7 +8,7 @@ import ( "github.com/corecollectives/mist/compose" "github.com/corecollectives/mist/docker" "github.com/corecollectives/mist/fs" - "github.com/corecollectives/mist/github" + "github.com/corecollectives/mist/git" "github.com/corecollectives/mist/models" "github.com/corecollectives/mist/utils" "gorm.io/gorm" @@ -86,7 +86,7 @@ func (q *Queue) HandleWork(id int64, db *gorm.DB) { logger.Info("Cloning repository") models.UpdateDeploymentStatus(id, "cloning", "cloning", 20, nil) - err = github.CloneRepo(ctx, appId, logFile) + err = git.CloneRepo(ctx, appId, logFile) if err != nil { if ctx.Err() == context.Canceled { logger.Info("Deployment cancelled by user") @@ -97,6 +97,7 @@ func (q *Queue) HandleWork(id int64, db *gorm.DB) { logger.Error(err, "Failed to clone repository") errMsg := fmt.Sprintf("Failed to clone repository: %v", err) models.UpdateDeploymentStatus(id, "failed", "failed", 0, &errMsg) + fmt.Fprint(logFile, "error cloning repository: ", err.Error()) return } @@ -121,6 +122,7 @@ func (q *Queue) HandleWork(id int64, db *gorm.DB) { logger.Error(err, "Deployment failed") errMsg := fmt.Sprintf("Deployment failed: %v", err) models.UpdateDeploymentStatus(id, "failed", "failed", 0, &errMsg) + fmt.Fprint(logFile, "error deploying docker: ", err.Error()) return }