diff --git a/.gitignore b/.gitignore index 5f147f3..9a38ccb 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ [Dd]ebug/ [Rr]elease/ +# This project +temp.git/ + # Compiled resource file *.res diff --git a/cli/.devcontainer/devcontainer.json b/cli/.devcontainer/devcontainer.json index cb0ef95..1dc0565 100644 --- a/cli/.devcontainer/devcontainer.json +++ b/cli/.devcontainer/devcontainer.json @@ -1,6 +1,6 @@ { "name": "golang", - "image": "golang:1.25.1", + "image": "golang:1.25.3", "customizations": { "vscode": { "extensions": ["golang.go"] diff --git a/cli/application/bitbucket.go b/cli/application/bitbucket.go new file mode 100644 index 0000000..99a352a --- /dev/null +++ b/cli/application/bitbucket.go @@ -0,0 +1,345 @@ +package application + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +type BitbucketApp struct { + config AppConfig +} + +func (g *BitbucketApp) GetOrganizations() ([]Organization, error) { + req, _ := http.NewRequest("GET", g.config.ApiUrl+"/2.0/workspaces", nil) + req.SetBasicAuth(g.config.Username, g.config.Password) + req.Header.Set("Accept", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, nil // treat as "no teams" + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode) + } + + body, _ := io.ReadAll(resp.Body) + + // Bitbucket workspaces response structure + var response struct { + Values []struct { + Slug string `json:"slug"` + DisplayName string `json:"name"` + } `json:"values"` + } + + if err := json.Unmarshal(body, &response); err != nil { + return nil, err + } + + var orgs []Organization + for _, workspace := range response.Values { + org := Organization{ + Name: workspace.Slug, + Description: workspace.DisplayName, + } + orgs = append(orgs, org) + } + + return orgs, nil +} + +func (g *BitbucketApp) GetAuthenticatedUser() (string, error) { + req, _ := http.NewRequest("GET", g.config.ApiUrl+"/2.0/user", nil) + req.SetBasicAuth(g.config.Username, g.config.Password) + req.Header.Set("Accept", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + // Bitbucket user response structure + var data struct { + Username string `json:"username"` + Nickname string `json:"nickname"` + } + if err := json.Unmarshal(body, &data); err != nil { + return "", err + } + + // Bitbucket uses "username" field instead of "login" + if data.Username != "" { + return data.Username, nil + } + return data.Nickname, nil +} + +func (g *BitbucketApp) GetRepositories(endpoint ApiEndpoint, owner string, authUser string) ([]Repository, error) { + var url string + if endpoint == EndpointOrganization { + // For teams/organizations in Bitbucket + url = g.config.ApiUrl + "/2.0/repositories/" + owner + } else { + // For user repositories + if owner == authUser { + url = g.config.ApiUrl + "/2.0/repositories/" + authUser + } else { + url = g.config.ApiUrl + "/2.0/repositories/" + owner + } + } + + var repos []Repository + + for url != "" { + req, _ := http.NewRequest("GET", url, nil) + req.SetBasicAuth(g.config.Username, g.config.Password) + req.Header.Set("Accept", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + // Bitbucket repositories response structure + var response struct { + Values []struct { + Name string `json:"name"` + FullName string `json:"full_name"` + Description string `json:"description"` + IsPrivate bool `json:"is_private"` + HasWiki bool `json:"has_wiki"` + HasIssues bool `json:"has_issues"` + Owner struct { + Username string `json:"username"` + } `json:"owner"` + Links struct { + Clone []struct { + Name string `json:"name"` + Href string `json:"href"` + } `json:"clone"` + } `json:"links"` + } `json:"values"` + Next string `json:"next"` + } + + if err := json.Unmarshal(body, &response); err != nil { + return nil, err + } + + for _, item := range response.Values { + repo := Repository{ + Name: item.Name, + FullName: item.FullName, + Description: item.Description, + Private: item.IsPrivate, + HasWiki: item.HasWiki, + HasIssues: item.HasIssues, + Owner: User{ + Login: item.Owner.Username, + }, + } + + // Find HTTPS clone URL + for _, link := range item.Links.Clone { + if link.Name == "https" { + repo.CloneUrl = link.Href + break + } + } + + repos = append(repos, repo) + } + + url = response.Next // Bitbucket uses "next" field for pagination + } + + return repos, nil +} + +func (g *BitbucketApp) GetIssues(repo Repository) ([]Issue, error) { + url := g.config.ApiUrl + "/2.0/repositories/" + repo.Owner.Login + "/" + repo.Name + "/issues" + var issues []Issue + + req, _ := http.NewRequest("GET", url, nil) + req.SetBasicAuth(g.config.Username, g.config.Password) + req.Header.Set("Accept", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + // Bitbucket issues response structure + var response struct { + Values []struct { + ID int `json:"id"` + Title string `json:"title"` + Content struct { + Raw string `json:"raw"` + } `json:"content"` + State string `json:"state"` + } `json:"values"` + } + + if err := json.Unmarshal(body, &response); err != nil { + return nil, err + } + + for _, item := range response.Values { + issue := Issue{ + Number: item.ID, + Title: item.Title, + Body: item.Content.Raw, + State: item.State, + } + issues = append(issues, issue) + } + + return issues, nil +} + +func (g *BitbucketApp) CreateRepo(endpoint ApiEndpoint, owner string, source Repository) (Repository, error) { + var url string + if endpoint == EndpointOrganization { + url = g.config.ApiUrl + "/2.0/repositories/" + owner + "/" + source.Name + } else { + url = g.config.ApiUrl + "/2.0/repositories/" + owner + "/" + source.Name + } + + payload := map[string]interface{}{ + "name": source.Name, + "description": source.Description, + "is_private": source.Private, + "has_wiki": source.HasWiki, + "has_issues": source.HasIssues, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return Repository{}, err + } + + req, _ := http.NewRequest("POST", url, strings.NewReader(string(jsonData))) + req.SetBasicAuth(g.config.Username, g.config.Password) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return Repository{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + return Repository{}, fmt.Errorf("failed to create repo: %s", resp.Status) + } + + body, _ := io.ReadAll(resp.Body) + + // Parse response + var response struct { + Name string `json:"name"` + FullName string `json:"full_name"` + Description string `json:"description"` + IsPrivate bool `json:"is_private"` + HasWiki bool `json:"has_wiki"` + HasIssues bool `json:"has_issues"` + Owner struct { + Username string `json:"username"` + } `json:"owner"` + Links struct { + Clone []struct { + Name string `json:"name"` + Href string `json:"href"` + } `json:"clone"` + } `json:"links"` + } + + if err := json.Unmarshal(body, &response); err != nil { + return Repository{}, err + } + + repo := Repository{ + Name: response.Name, + FullName: response.FullName, + Description: response.Description, + Private: response.IsPrivate, + HasWiki: response.HasWiki, + HasIssues: response.HasIssues, + Owner: User{ + Login: response.Owner.Username, + }, + } + + // Find HTTPS clone URL + for _, link := range response.Links.Clone { + if link.Name == "https" { + repo.CloneUrl = link.Href + break + } + } + + return repo, nil +} + +func (g *BitbucketApp) GetApplicationName() string { + return "Bitbucket" +} + +func (g *BitbucketApp) GetApiUrl() string { + return g.config.ApiUrl +} + +func (g *BitbucketApp) SetApiUrl(url string) { + g.config.ApiUrl = url +} + +func (g *BitbucketApp) GetToken() string { + return g.config.Token +} + +func (g *BitbucketApp) SetToken(token string) { + g.config.Token = token +} + +func (g *BitbucketApp) GetUser() string { + return g.config.User +} + +func (g *BitbucketApp) SetUser(user string) { + g.config.User = user +} + +func (g *BitbucketApp) GetUsername() string { + return g.config.Username +} + +func (g *BitbucketApp) SetUsername(username string) { + g.config.Username = username +} + +func (g *BitbucketApp) GetPassword() string { + return g.config.Password +} + +func (g *BitbucketApp) SetPassword(password string) { + g.config.Password = password +} + +func (g *BitbucketApp) GetEndpoint() ApiEndpoint { + return g.config.Endpoint +} + +func (g *BitbucketApp) SetEndpoint(endpoint ApiEndpoint) { + g.config.Endpoint = endpoint +} diff --git a/cli/application/common.go b/cli/application/common.go new file mode 100644 index 0000000..5c4331d --- /dev/null +++ b/cli/application/common.go @@ -0,0 +1,379 @@ +package application + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "regexp" + "strings" +) + +// GetOrganizations retrieves the list of organizations for the authenticated user. +func GetOrganizations(config AppConfig) ([]Organization, error) { + req, _ := http.NewRequest("GET", config.ApiUrl+"/user/orgs", nil) + req.Header.Set("Authorization", "token "+config.Token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // GitBucket may return 404 if not supported + if resp.StatusCode == http.StatusNotFound { + return nil, nil // treat as "no orgs" + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var rawOrgs []map[string]any + if err := json.Unmarshal(body, &rawOrgs); err != nil { + return nil, err + } + + var orgs []Organization + for _, obj := range rawOrgs { + org := Organization{} + // Try "login" or "username" for compatibility + if login, ok := obj["login"].(string); ok { + org.Name = login + } else if username, ok := obj["username"].(string); ok { + org.Name = username + } + if desc, ok := obj["description"].(string); ok { + org.Description = desc + } + orgs = append(orgs, org) + } + return orgs, nil +} + +// GetAuthenticatedUser retrieves the username of the authenticated user. +func GetAuthenticatedUser(config AppConfig) (string, error) { + req, _ := http.NewRequest("GET", config.ApiUrl+"/user", nil) + req.Header.Set("Authorization", "token "+config.Token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + var data struct { + Login string `json:"login"` + } + if err := json.Unmarshal(body, &data); err != nil { + return "", err + } + return data.Login, nil +} + +// GetNextUrl extracts the 'next' URL from the Link header for pagination. +func GetNextUrl(resp *http.Response) string { + // Get the Link header + linkHeader := resp.Header.Get("Link") + if linkHeader == "" { + return "" + } + // Regular expression to match the 'next' URL + re := regexp.MustCompile(`<(\S+)>;\s*rel="next"`) + match := re.FindStringSubmatch(linkHeader) + if len(match) >= 2 { + return match[1] + } + return "" +} + +// JsonToUser parses a JSON object to fill a User struct. +func JsonToUser(obj map[string]any, u *User) error { + if obj == nil { + return errors.New("invalid JSON input for User") + } + if login, ok := obj["login"].(string); ok { + u.Login = login + } + return nil +} + +// JsonToRepo parses a JSON object to fill a Repository struct. +func JsonToRepo(obj map[string]any, repo *Repository) error { + if obj == nil { + return errors.New("invalid JSON input for Repository") + } + + // Owner (required) + if ownerObj, ok := obj["owner"].(map[string]any); ok { + if err := JsonToUser(ownerObj, &repo.Owner); err != nil { + return err + } + } else { + return errors.New("owner not found") + } + + // Name (required) + if name, ok := obj["name"].(string); ok { + repo.Name = name + } else { + return errors.New("name not found") + } + + // FullName (required) + if fullName, ok := obj["full_name"].(string); ok { + repo.FullName = fullName + } else { + return errors.New("full_name not found") + } + + // Private (required) + if priv, ok := obj["private"].(bool); ok { + repo.Private = priv + } else { + return errors.New("private not found") + } + + // Description + if desc, ok := obj["description"].(string); ok { + repo.Description = desc + } else { + repo.Description = "" + } + + // Fork + if fork, ok := obj["fork"].(bool); ok { + repo.Fork = fork + } else { + repo.Fork = false // Default is false + } + + // CloneUrl (required) + if cloneURL, ok := obj["clone_url"].(string); ok { + repo.CloneUrl = cloneURL + } else { + return errors.New("clone_url not found") + } + + // MirrorUrl + if mirrorURL, ok := obj["mirror_url"].(string); ok { + repo.MirrorUrl = mirrorURL + } else { + repo.MirrorUrl = "" + } + + // OpenIssueCount + if issues, ok := obj["open_issues_count"].(float64); ok { + repo.OpenIssueCount = int(issues) + } else { + repo.OpenIssueCount = 0 + } + + // HasWiki (default true) + if hasWiki, ok := obj["has_wiki"].(bool); ok { + repo.HasWiki = hasWiki + } else { + repo.HasWiki = true + } + + // HasIssues (default true) + if hasIssues, ok := obj["has_issues"].(bool); ok { + repo.HasIssues = hasIssues + } else { + repo.HasIssues = true + } + + // HasProjects (default true) + if hasProjects, ok := obj["has_projects"].(bool); ok { + repo.HasProjects = hasProjects + } else { + repo.HasProjects = true + } + + // HasDownloads (default true) + if hasDownloads, ok := obj["has_downloads"].(bool); ok { + repo.HasDownloads = hasDownloads + } else { + repo.HasDownloads = true + } + + // Homepage + if homepage, ok := obj["homepage"].(string); ok { + repo.Homepage = homepage + } else { + repo.Homepage = "" + } + + return nil +} + +// GetRepositories retrieves repositories for a given owner and endpoint. +func GetRepositories(config AppConfig, endpoint ApiEndpoint, owner string, authUser string) ([]Repository, error) { + url := config.ApiUrl + if endpoint == EndpointOrganization { + url += "/orgs/" + owner + "/repos" + } else { + if owner == authUser { + url += "/user/repos" + } else { + url += "/users/" + owner + "/repos" + } + } + + var repos []Repository + + for url != "" { + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "token "+config.Token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + var obj []map[string]any + if err := json.Unmarshal(body, &obj); err != nil { + return nil, err + } + + for _, item := range obj { + repo := Repository{} + JsonToRepo(item, &repo) + if endpoint == EndpointUser && repo.Owner.Login != owner { + continue + } + repos = append(repos, repo) + } + + url = GetNextUrl(resp) + } + return repos, nil +} + +// JsonToIssue parses a JSON object (map[string]interface{}) to fill an Issue struct. +func JsonToIssue(obj map[string]any, issue *Issue) error { + if obj == nil { + return errors.New("invalid JSON input") + } + + // Title + issue.Title = "" + if title, ok := obj["title"].(string); ok { + issue.Title = title + } + + // Body + issue.Body = "" + if body, ok := obj["body"].(string); ok { + issue.Body = body + } + + // State + issue.State = "" + if state, ok := obj["state"].(string); ok { + issue.State = state + } + + // Number + if number, ok := obj["number"].(float64); ok { + issue.Number = int(number) + } else { + issue.Number = 0 + } + + return nil +} + +// GetIssues retrieves issues for a given repository. +func GetIssues(config AppConfig, repo Repository) ([]Issue, error) { + url := config.ApiUrl + "/repos/" + repo.Owner.Login + "/" + repo.Name + "/issues" + var issues []Issue + + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "token "+config.Token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + var obj []map[string]any + if err := json.Unmarshal(body, &obj); err != nil { + return nil, err + } + + for _, item := range obj { + issue := Issue{} + JsonToIssue(item, &issue) + issues = append(issues, issue) + } + + return issues, nil +} + +// RepoToJson converts a Repository struct to a GitHub-compatible JSON string for the repo API. +// Only the allowed fields are included in the output JSON. +func RepoToJson(repo Repository) (string, error) { + // Create a map with the relevant fields + out := map[string]any{ + "name": repo.Name, + "description": repo.Description, + "homepage": repo.Homepage, + "private": repo.Private, + "has_issues": repo.HasIssues, + "has_projects": repo.HasProjects, + "has_wiki": repo.HasWiki, + "has_downloads": repo.HasDownloads, + } + + // Marshal to JSON with indentation (for readability; remove for compact) + jsonBytes, err := json.MarshalIndent(out, "", " ") + if err != nil { + return "", err + } + return string(jsonBytes), nil +} + +// CreateRepo creates a new repository. +func CreateRepo(config AppConfig, endpoint ApiEndpoint, owner string, source Repository) (Repository, error) { + url := config.ApiUrl + if endpoint == EndpointOrganization { + url += "/orgs/" + owner + "/repos" + } else { + url += "/user/repos" + } + + jsonData, err := RepoToJson(source) + if err != nil { + return Repository{}, err + } + + req, _ := http.NewRequest("POST", url, strings.NewReader(jsonData)) + req.Header.Set("Authorization", "token "+config.Token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return Repository{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + return Repository{}, fmt.Errorf("failed to create repo: %s", resp.Status) + } + + body, _ := io.ReadAll(resp.Body) + var obj map[string]any + if err := json.Unmarshal(body, &obj); err != nil { + return Repository{}, err + } + var repo Repository + if err := JsonToRepo(obj, &repo); err != nil { + return Repository{}, err + } + return repo, nil +} diff --git a/cli/application/gitbucket.go b/cli/application/gitbucket.go new file mode 100644 index 0000000..7c6f97b --- /dev/null +++ b/cli/application/gitbucket.go @@ -0,0 +1,77 @@ +package application + +type GitBucketApp struct { + config AppConfig +} + +func (g *GitBucketApp) GetOrganizations() ([]Organization, error) { + return GetOrganizations(g.config) +} + +func (g *GitBucketApp) GetAuthenticatedUser() (string, error) { + return GetAuthenticatedUser(g.config) +} + +func (g *GitBucketApp) GetRepositories(endpoint ApiEndpoint, owner string, authUser string) ([]Repository, error) { + return GetRepositories(g.config, endpoint, owner, authUser) +} + +func (g *GitBucketApp) GetIssues(repo Repository) ([]Issue, error) { + return GetIssues(g.config, repo) +} + +func (g *GitBucketApp) CreateRepo(endpoint ApiEndpoint, owner string, source Repository) (Repository, error) { + return CreateRepo(g.config, endpoint, owner, source) +} + +func (g *GitBucketApp) GetApplicationName() string { + return "GitBucket" +} + +func (g *GitBucketApp) GetApiUrl() string { + return g.config.ApiUrl +} + +func (g *GitBucketApp) SetApiUrl(url string) { + g.config.ApiUrl = url +} + +func (g *GitBucketApp) GetToken() string { + return g.config.Token +} + +func (g *GitBucketApp) SetToken(token string) { + g.config.Token = token +} + +func (g *GitBucketApp) GetUser() string { + return g.config.User +} + +func (g *GitBucketApp) SetUser(user string) { + g.config.User = user +} + +func (g *GitBucketApp) GetUsername() string { + return g.config.Username +} + +func (g *GitBucketApp) SetUsername(username string) { + g.config.Username = username +} + +func (g *GitBucketApp) GetPassword() string { + return g.config.Password +} + +func (g *GitBucketApp) SetPassword(password string) { + g.config.Password = password +} + +func (g *GitBucketApp) GetEndpoint() ApiEndpoint { + return g.config.Endpoint +} + +func (g *GitBucketApp) SetEndpoint(endpoint ApiEndpoint) { + g.config.Endpoint = endpoint +} diff --git a/cli/application/github.go b/cli/application/github.go new file mode 100644 index 0000000..a84c643 --- /dev/null +++ b/cli/application/github.go @@ -0,0 +1,77 @@ +package application + +type GitHubApp struct { + config AppConfig +} + +func (g *GitHubApp) GetOrganizations() ([]Organization, error) { + return GetOrganizations(g.config) +} + +func (g *GitHubApp) GetAuthenticatedUser() (string, error) { + return GetAuthenticatedUser(g.config) +} + +func (g *GitHubApp) GetRepositories(endpoint ApiEndpoint, owner string, authUser string) ([]Repository, error) { + return GetRepositories(g.config, endpoint, owner, authUser) +} + +func (g *GitHubApp) GetIssues(repo Repository) ([]Issue, error) { + return GetIssues(g.config, repo) +} + +func (g *GitHubApp) CreateRepo(endpoint ApiEndpoint, owner string, source Repository) (Repository, error) { + return CreateRepo(g.config, endpoint, owner, source) +} + +func (g *GitHubApp) GetApplicationName() string { + return "GitHub" +} + +func (g *GitHubApp) GetApiUrl() string { + return g.config.ApiUrl +} + +func (g *GitHubApp) SetApiUrl(url string) { + g.config.ApiUrl = url +} + +func (g *GitHubApp) GetToken() string { + return g.config.Token +} + +func (g *GitHubApp) SetToken(token string) { + g.config.Token = token +} + +func (g *GitHubApp) GetUser() string { + return g.config.User +} + +func (g *GitHubApp) SetUser(user string) { + g.config.User = user +} + +func (g *GitHubApp) GetUsername() string { + return g.config.Username +} + +func (g *GitHubApp) SetUsername(username string) { + g.config.Username = username +} + +func (g *GitHubApp) GetPassword() string { + return g.config.Password +} + +func (g *GitHubApp) SetPassword(password string) { + g.config.Password = password +} + +func (g *GitHubApp) GetEndpoint() ApiEndpoint { + return g.config.Endpoint +} + +func (g *GitHubApp) SetEndpoint(endpoint ApiEndpoint) { + g.config.Endpoint = endpoint +} diff --git a/cli/application/gitlab.go b/cli/application/gitlab.go new file mode 100644 index 0000000..305db0e --- /dev/null +++ b/cli/application/gitlab.go @@ -0,0 +1,82 @@ +package application + +type GitLabApp struct { + config AppConfig +} + +func (g *GitLabApp) GetOrganizations() ([]Organization, error) { + // TODO: implement + return nil, nil +} + +func (g *GitLabApp) GetAuthenticatedUser() (string, error) { + // TODO: implement + return "", nil +} + +func (g *GitLabApp) GetRepositories(endpoint ApiEndpoint, owner string, authUser string) ([]Repository, error) { + // TODO: implement + return nil, nil +} + +func (g *GitLabApp) GetIssues(repo Repository) ([]Issue, error) { + // TODO: implement + return nil, nil +} + +func (g *GitLabApp) CreateRepo(endpoint ApiEndpoint, owner string, source Repository) (Repository, error) { + // TODO: implement + return Repository{}, nil +} + +func (g *GitLabApp) GetApplicationName() string { + return "GitLab" +} + +func (g *GitLabApp) GetApiUrl() string { + return g.config.ApiUrl +} + +func (g *GitLabApp) SetApiUrl(url string) { + g.config.ApiUrl = url +} + +func (g *GitLabApp) GetToken() string { + return g.config.Token +} + +func (g *GitLabApp) SetToken(token string) { + g.config.Token = token +} + +func (g *GitLabApp) GetUser() string { + return g.config.User +} + +func (g *GitLabApp) SetUser(user string) { + g.config.User = user +} + +func (g *GitLabApp) GetUsername() string { + return g.config.Username +} + +func (g *GitLabApp) SetUsername(username string) { + g.config.Username = username +} + +func (g *GitLabApp) GetPassword() string { + return g.config.Password +} + +func (g *GitLabApp) SetPassword(password string) { + g.config.Password = password +} + +func (g *GitLabApp) GetEndpoint() ApiEndpoint { + return g.config.Endpoint +} + +func (g *GitLabApp) SetEndpoint(endpoint ApiEndpoint) { + g.config.Endpoint = endpoint +} diff --git a/cli/application/gogs.go b/cli/application/gogs.go new file mode 100644 index 0000000..116ae40 --- /dev/null +++ b/cli/application/gogs.go @@ -0,0 +1,77 @@ +package application + +type GogsApp struct { + config AppConfig +} + +func (g *GogsApp) GetOrganizations() ([]Organization, error) { + return GetOrganizations(g.config) +} + +func (g *GogsApp) GetAuthenticatedUser() (string, error) { + return GetAuthenticatedUser(g.config) +} + +func (g *GogsApp) GetRepositories(endpoint ApiEndpoint, owner string, authUser string) ([]Repository, error) { + return GetRepositories(g.config, endpoint, owner, authUser) +} + +func (g *GogsApp) GetIssues(repo Repository) ([]Issue, error) { + return GetIssues(g.config, repo) +} + +func (g *GogsApp) CreateRepo(endpoint ApiEndpoint, owner string, source Repository) (Repository, error) { + return CreateRepo(g.config, endpoint, owner, source) +} + +func (g *GogsApp) GetApplicationName() string { + return "Gogs" +} + +func (g *GogsApp) GetApiUrl() string { + return g.config.ApiUrl +} + +func (g *GogsApp) SetApiUrl(url string) { + g.config.ApiUrl = url +} + +func (g *GogsApp) GetToken() string { + return g.config.Token +} + +func (g *GogsApp) SetToken(token string) { + g.config.Token = token +} + +func (g *GogsApp) GetUser() string { + return g.config.User +} + +func (g *GogsApp) SetUser(user string) { + g.config.User = user +} + +func (g *GogsApp) GetUsername() string { + return g.config.Username +} + +func (g *GogsApp) SetUsername(username string) { + g.config.Username = username +} + +func (g *GogsApp) GetPassword() string { + return g.config.Password +} + +func (g *GogsApp) SetPassword(password string) { + g.config.Password = password +} + +func (g *GogsApp) GetEndpoint() ApiEndpoint { + return g.config.Endpoint +} + +func (g *GogsApp) SetEndpoint(endpoint ApiEndpoint) { + g.config.Endpoint = endpoint +} diff --git a/cli/application/interface.go b/cli/application/interface.go new file mode 100644 index 0000000..fdba58d --- /dev/null +++ b/cli/application/interface.go @@ -0,0 +1,103 @@ +package application + +// Represents an organization. +type Organization struct { + Name string + Description string +} + +// Represents a user. +type User struct { + Login string +} + +// Represents a repository. +type Repository struct { + Owner User + Name string + FullName string + Private bool + Description string + Fork bool + CloneUrl string + MirrorUrl string + OpenIssueCount int + HasWiki bool + HasIssues bool + HasProjects bool + HasDownloads bool + Homepage string +} + +// Represents an issue. +type Issue struct { + Title string + Body string + State string + Number int +} + +type Application interface { + GetOrganizations() ([]Organization, error) + GetAuthenticatedUser() (string, error) + GetRepositories(endpoint ApiEndpoint, owner string, authUser string) ([]Repository, error) + GetIssues(repo Repository) ([]Issue, error) + CreateRepo(endpoint ApiEndpoint, owner string, source Repository) (Repository, error) + + GetApplicationName() string + GetApiUrl() string + SetApiUrl(url string) + GetToken() string + SetToken(token string) + GetUser() string + SetUser(user string) + GetUsername() string + SetUsername(username string) + GetPassword() string + SetPassword(password string) +} + +type ApiEndpoint int + +const ( + EndpointUser ApiEndpoint = iota + EndpointOrganization +) + +// Application configuration +type AppConfig struct { + ApiUrl string + Token string + User string + Username string + Password string + Endpoint ApiEndpoint +} + +type ApplicationType string + +const ( + AppGogs ApplicationType = "Gogs" + AppGitBucket ApplicationType = "GitBucket" + AppGitHub ApplicationType = "GitHub" + AppGitLab ApplicationType = "GitLab" + AppBitbucket ApplicationType = "Bitbucket" +) + +// NewApplication creates a new Application instance based on the given type. +func NewApplication(appType ApplicationType) Application { + switch appType { + case AppGogs: + return &GogsApp{} + case AppGitBucket: + return &GitBucketApp{} + case AppGitHub: + return &GitHubApp{config: AppConfig{ApiUrl: "https://api.github.com"}} + case AppGitLab: + return &GitLabApp{config: AppConfig{ApiUrl: "https://gitlab.com/api/v4"}} + case AppBitbucket: + return &BitbucketApp{config: AppConfig{ApiUrl: "https://api.bitbucket.org"}} + default: + return nil + } +} diff --git a/cli/go.mod b/cli/go.mod index a61bd9f..4199004 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -2,7 +2,10 @@ module gitconduit-cli go 1.25 -require github.com/go-git/go-git/v5 v5.16.0 +require ( + github.com/go-git/go-git/v5 v5.16.0 + github.com/rivo/tview v0.42.0 +) require ( dario.cat/mergo v1.0.0 // indirect @@ -11,17 +14,24 @@ require ( github.com/cloudflare/circl v1.6.1 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect + github.com/gdamore/encoding v1.0.1 // indirect + github.com/gdamore/tcell/v2 v2.8.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.6.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect golang.org/x/crypto v0.37.0 // indirect golang.org/x/net v0.39.0 // indirect golang.org/x/sys v0.32.0 // indirect + golang.org/x/term v0.31.0 // indirect + golang.org/x/text v0.24.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/cli/go.sum b/cli/go.sum index b92c8fc..cd7a267 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -20,6 +20,10 @@ github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= +github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= +github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU= +github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= @@ -32,6 +36,7 @@ github.com/go-git/go-git/v5 v5.16.0 h1:k3kuOEpkc0DeY7xlL6NaaNg39xdgQbtH5mwCafHO9 github.com/go-git/go-git/v5 v5.16.0/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= @@ -45,6 +50,10 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= @@ -53,6 +62,12 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c= +github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= @@ -67,29 +82,87 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/cli/main.go b/cli/main.go index fc051cc..34024b9 100644 --- a/cli/main.go +++ b/cli/main.go @@ -3,10 +3,457 @@ package main import ( "flag" "fmt" + "gitconduit-cli/application" "os" + "strings" + "time" + + "github.com/rivo/tview" ) +// RepoItem holds a repository with its selected state and current label +type RepoItem struct { + Repo application.Repository + Selected bool + Label string +} + +// updateLabel updates the Label field of RepoItem based on its properties +func updateLabel(it *RepoItem) { + label := it.Repo.Name + if it.Repo.Private { + label += " [red:{bk}][white:red]Private[red:{bk}]" + } else { + label += " [green:{bk}][white:green]Public[green:{bk}]" + } + if it.Repo.Fork { + label += " [yellow:{bk}][white:yellow]Fork[yellow:{bk}]" + } + if it.Repo.MirrorUrl != "" { + label += " [cyan:{bk}][white:cyan]Mirror[cyan:{bk}]" + } + it.Label = label + " [-:-:-]" +} + +// updateItem updates the displayed text for a single list item at index i +func updateItem(l *tview.List, it RepoItem, i int) { + checked := "☐ " + if it.Selected { + checked = "☑ " + } + // determine whether this item is currently highlighted + current := l.GetCurrentItem() + var text string + if i == current { + text = strings.ReplaceAll(it.Label, "{bk}", "white") + } else { + text = strings.ReplaceAll(it.Label, "{bk}", l.GetBackgroundColor().Name()) + } + l.SetItemText(i, checked+text, " "+it.Repo.Description) +} + +func showAnimatedLoading(app *tview.Application, pages *tview.Pages, prevPage, nextPage string, loadFunc func() error) { + loading := tview.NewTextView() + loading.SetTextAlign(tview.AlignCenter) + loading.SetTitle("Loading").SetTitleAlign(tview.AlignLeft).SetBorder(true) + + pages.AddPage("loading", loading, true, true) + done := make(chan struct{}) + + // Animation goroutine + go func() { + frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + i := 0 + for { + select { + case <-done: + return + default: + app.QueueUpdateDraw(func() { + loading.SetText("Please wait " + frames[i%len(frames)]) + }) + i++ + time.Sleep(400 * time.Millisecond) + } + } + }() + + // Simulate loading + go func() { + err := loadFunc() + close(done) // Stop animation + app.QueueUpdateDraw(func() { + pages.RemovePage("loading") + if err != nil { + // show modal with error and go back to prevPage when dismissed + modal := tview.NewModal(). + SetText("Error: " + err.Error()). + AddButtons([]string{"OK"}).SetDoneFunc(func(buttonIndex int, buttonLabel string) { + pages.RemovePage("errorModal") + pages.SwitchToPage(prevPage) + }) + pages.AddPage("errorModal", modal, true, true) + return + } + pages.SwitchToPage(nextPage) + }) + }() +} + func main() { + appOptions := []string{"Gogs", "GitBucket", "GitHub", "Bitbucket"} + sourceApplication := application.NewApplication(application.AppGitHub) + destApplication := application.NewApplication(application.AppGitHub) + + pages := tview.NewPages() + app := tview.NewApplication().SetRoot(pages, true) + authUser := "" + + // Create form1 with controls + form1Flex := tview.NewFlex().SetDirection(tview.FlexRow) + form1Flex.SetBorder(true) + form1 := tview.NewForm() + sourceAppDropDown := tview.NewDropDown().SetLabel("Application:"). + SetOptions(appOptions, nil). + SetCurrentOption(0) + sourceAPIInput := tview.NewInputField().SetLabel("API URL:").SetFieldWidth(50) + sourceAuthTokenInput := tview.NewInputField().SetLabel("Authorization Token:").SetFieldWidth(50) + sourceUsernameInput := tview.NewInputField().SetLabel("Username:").SetFieldWidth(50) + sourcePasswordInput := tview.NewInputField().SetLabel("Password:").SetFieldWidth(50).SetMaskCharacter('*') + + // Create form2 with controls + form2Flex := tview.NewFlex().SetDirection(tview.FlexRow) + form2Flex.SetBorder(true) + form2 := tview.NewForm() + sourceTypeDropDown := tview.NewDropDown().SetLabel("Type:") + sourceUserInput := tview.NewInputField().SetLabel("User: ").SetFieldWidth(50) + sourceOrgInput := tview.NewInputField().SetLabel("Organization:").SetFieldWidth(50) + sourceOrgDropDown := tview.NewDropDown().SetLabel("Organization:") + + // Create form3 with controls + form3Flex := tview.NewFlex().SetDirection(tview.FlexRow) + form3Flex.SetBorder(true) + form3 := tview.NewForm() + var repoItems []RepoItem + repoList := tview.NewList() + repoList.SetChangedFunc(func(index int, mainText, secondaryText string, shortcut rune) { + for i := 0; i < len(repoItems); i++ { + updateItem(repoList, repoItems[i], i) + } + }) + + // Create form4 with controls + form4Flex := tview.NewFlex().SetDirection(tview.FlexRow) + form4Flex.SetBorder(true) + form4 := tview.NewForm() + destAppDropDown := tview.NewDropDown().SetLabel("Application:"). + SetOptions(appOptions, nil). + SetCurrentOption(0) + destAPIInput := tview.NewInputField().SetLabel("API URL:").SetFieldWidth(50) + destAuthTokenInput := tview.NewInputField().SetLabel("Authorization Token:").SetFieldWidth(50) + destUsernameInput := tview.NewInputField().SetLabel("Username:").SetFieldWidth(50) + destPasswordInput := tview.NewInputField().SetLabel("Password:").SetFieldWidth(50).SetMaskCharacter('*') + + // Create form5 with controls + form5Flex := tview.NewFlex().SetDirection(tview.FlexRow) + form5Flex.SetBorder(true) + form5 := tview.NewForm() + destTypeDropDown := tview.NewDropDown().SetLabel("Type:") + destUserInput := tview.NewInputField().SetLabel("User: ").SetFieldWidth(50) + destOrgInput := tview.NewInputField().SetLabel("Organization:").SetFieldWidth(50) + destOrgDropDown := tview.NewDropDown().SetLabel("Organization:") + + form6Flex := tview.NewFlex().SetDirection(tview.FlexRow) + form6Flex.SetBorder(true) + form6 := tview.NewForm() + + beforeForm1 := func() func() { + return func() { + } + } + + beforeForm2 := func(app *tview.Application, prevPage, nextPage string) func() { + return func() { + showAnimatedLoading(app, pages, prevPage, nextPage, func() error { + _, opt := sourceAppDropDown.GetCurrentOption() + sourceApplication = application.NewApplication(application.ApplicationType(opt)) + sourceApplication.SetApiUrl(sourceAPIInput.GetText()) + sourceApplication.SetToken(sourceAuthTokenInput.GetText()) + sourceApplication.SetUsername(sourceUsernameInput.GetText()) + sourceApplication.SetPassword(sourcePasswordInput.GetText()) + + var err error + authUser, err = sourceApplication.GetAuthenticatedUser() + if err != nil { + return fmt.Errorf("GetAuthenticatedUser failed: %w", err) + } + sourceUserInput.SetText(authUser) + + orgs, err := sourceApplication.GetOrganizations() + if err != nil { + return fmt.Errorf("GetOrganizations failed: %w", err) + } + orgNames := make([]string, len(orgs)) + for i, org := range orgs { + orgNames[i] = org.Name + } + switch len(orgs) { + case 0: + sourceOrgInput.SetText("") + case 1: + sourceOrgInput.SetText(orgNames[0]) + default: + sourceOrgDropDown.SetOptions(orgNames, nil).SetCurrentOption(0) + } + return nil + }) + } + } + + beforeForm3 := func(app *tview.Application, prevPage, nextPage string) func() { + return func() { + showAnimatedLoading(app, pages, prevPage, nextPage, func() error { + index, _ := sourceTypeDropDown.GetCurrentOption() + endpoint := application.EndpointUser + if index != 0 { + endpoint = application.EndpointOrganization + } + + owner := "" + if endpoint == application.EndpointUser { + owner = sourceUserInput.GetText() + } else { + if sourceOrgDropDown.GetOptionCount() > 1 { + _, owner = sourceOrgDropDown.GetCurrentOption() + } else { + owner = sourceOrgInput.GetText() + } + } + + repositories, err := sourceApplication.GetRepositories(endpoint, owner, authUser) + if err != nil { + return fmt.Errorf("GetRepositories failed: %w", err) + } + + repoList.Clear() + repoItems = make([]RepoItem, 0, len(repositories)) + for i := range repositories { + // default selected + item := RepoItem{Repo: repositories[i], Selected: true} + updateLabel(&item) + // capture values for closure + idx := i + repoItems = append(repoItems, item) + // add list item with initial text + repoList.AddItem("", "", 0, func() { + // toggle selection state + repoItems[idx].Selected = !repoItems[idx].Selected + updateItem(repoList, repoItems[idx], idx) + }) + updateItem(repoList, repoItems[i], i) + } + // ensure focus goes to the repo list and first item is selected + app.QueueUpdateDraw(func() { + app.SetFocus(repoList) + if repoList.GetItemCount() > 0 { + repoList.SetCurrentItem(0) + } + }) + + return nil + }) + } + } + + beforeForm4 := func(_ *tview.Application, _, nextPage string) func() { + return func() { + pages.SwitchToPage(nextPage) + } + } + + beforeForm5 := func(app *tview.Application, prevPage, nextPage string) func() { + return func() { + showAnimatedLoading(app, pages, prevPage, nextPage, func() error { + _, opt := destAppDropDown.GetCurrentOption() + destApplication = application.NewApplication(application.ApplicationType(opt)) + destApplication.SetApiUrl(destAPIInput.GetText()) + destApplication.SetToken(destAuthTokenInput.GetText()) + destApplication.SetUsername(destUsernameInput.GetText()) + destApplication.SetPassword(destPasswordInput.GetText()) + + orgs, err := destApplication.GetOrganizations() + if err != nil { + return fmt.Errorf("GetOrganizations failed: %w", err) + } + orgNames := make([]string, len(orgs)) + for i, org := range orgs { + orgNames[i] = org.Name + } + switch len(orgs) { + case 0: + destOrgInput.SetText("") + case 1: + destOrgInput.SetText(orgNames[0]) + default: + destOrgDropDown.SetOptions(orgNames, nil).SetCurrentOption(0) + } + return nil + }) + } + } + + beforeForm6 := func(_ *tview.Application, _, nextPage string) func() { + return func() { + pages.SwitchToPage(nextPage) + } + } + + // Setup form1 + form1. + AddFormItem(sourceAppDropDown). + AddFormItem(sourceAPIInput). + AddFormItem(sourceAuthTokenInput). + AddFormItem(sourceUsernameInput). + AddFormItem(sourcePasswordInput). + AddButton("Next", beforeForm2(app, "form1", "form2")). + AddButton("Quit", func() { + app.Stop() + }) + form1Flex.AddItem(tview.NewTextView().SetText("Source").SetTextAlign(tview.AlignCenter), 1, 0, false) + form1Flex.AddItem(form1, 0, 1, true) + pages.AddPage("form1", form1Flex, true, true) + + // Setup form2 + form2.AddFormItem(sourceTypeDropDown). + AddFormItem(sourceUserInput). + AddButton("Back", func() { + pages.SwitchToPage("form1") + }). + AddButton("Next", beforeForm3(app, "form2", "form3")). + AddButton("Quit", func() { + app.Stop() + }) + form2Flex.AddItem(tview.NewTextView().SetText("Source Owner").SetTextAlign(tview.AlignCenter), 1, 0, false) + form2Flex.AddItem(form2, 0, 1, true) + pages.AddPage("form2", form2Flex, true, false) + sourceTypeDropDown.SetOptions([]string{"User", "Organization"}, func(option string, index int) { + // Remove both, then add back only the selected one + form2.Clear(false) // keep buttons intact + form2.AddFormItem(sourceTypeDropDown) + if index == 0 { + form2.AddFormItem(sourceUserInput) + } else { + if sourceOrgDropDown.GetOptionCount() > 1 { + form2.AddFormItem(sourceOrgDropDown) + } else { + form2.AddFormItem(sourceOrgInput) + } + } + }).SetCurrentOption(0) + + // Setup form3 + form3. + AddButton("Back", func() { + pages.SwitchToPage("form2") + }). + AddButton("Next", beforeForm4(app, "form3", "form4")). + AddButton("Quit", func() { + app.Stop() + }) + form3Flex.AddItem(tview.NewTextView().SetText("List of repositories").SetTextAlign(tview.AlignCenter), 1, 0, false) + form3Flex.AddItem(repoList, 0, 1, true) + form3Flex.AddItem(form3, 3, 0, false) + pages.AddPage("form3", form3Flex, true, false) + + // Setup form4 + form4. + AddFormItem(destAppDropDown). + AddFormItem(destAPIInput). + AddFormItem(destAuthTokenInput). + AddFormItem(destUsernameInput). + AddFormItem(destPasswordInput). + AddButton("Back", func() { + pages.SwitchToPage("form3") + }). + AddButton("Next", beforeForm5(app, "form4", "form5")). + AddButton("Quit", func() { + app.Stop() + }) + form4Flex.AddItem(tview.NewTextView().SetText("Destination").SetTextAlign(tview.AlignCenter), 1, 0, false) + form4Flex.AddItem(form4, 0, 1, true) + pages.AddPage("form4", form4Flex, true, false) + + // Setup form5 + form5.AddFormItem(destTypeDropDown). + AddFormItem(destUserInput). + AddButton("Back", func() { + pages.SwitchToPage("form4") + }). + AddButton("Next", beforeForm6(app, "form5", "form6")). + AddButton("Quit", func() { + app.Stop() + }) + form5Flex.AddItem(tview.NewTextView().SetText("Destination Owner").SetTextAlign(tview.AlignCenter), 1, 0, false) + form5Flex.AddItem(form5, 0, 1, true) + pages.AddPage("form5", form5Flex, true, false) + destTypeDropDown.SetOptions([]string{"User", "Organization"}, func(option string, index int) { + // Remove both, then add back only the selected one + form5.Clear(false) // keep buttons intact + form5.AddFormItem(destTypeDropDown) + if index == 0 { + form5.AddFormItem(destUserInput) + } else { + if destOrgDropDown.GetOptionCount() > 1 { + form5.AddFormItem(destOrgDropDown) + } else { + form5.AddFormItem(destOrgInput) + } + } + }).SetCurrentOption(0) + + // Setup form6 + form6Flex.AddItem(tview.NewTextView().SetText("Creating repositories").SetTextAlign(tview.AlignCenter), 1, 0, false) + form6Flex.AddItem(form6, 0, 1, true) + pages.AddPage("form6", form6Flex, true, false) + + beforeForm1()() + + if err := app.EnableMouse(true).EnablePaste(true).Run(); err != nil { + panic(err) + } + + os.Exit(0) + + appType := application.AppGitHub + url := "https://api.github.com" + token := "" + user := "" + username := "" + password := "" + + sourceApp := application.NewApplication(appType) + sourceApp.SetToken(token) + sourceApp.SetApiUrl(url) + sourceApp.SetUser(user) + sourceApp.SetUsername(username) + sourceApp.SetPassword(password) + + // Test getting organizations + orgs, err := sourceApp.GetOrganizations() + if err != nil { + fmt.Fprintf(os.Stderr, "GetOrganizations failed: %v\n", err) + } else { + fmt.Printf("Organizations: %+v\n", orgs) + } + // Test getting authenticated user + authUser, err = sourceApp.GetAuthenticatedUser() + if err != nil { + fmt.Fprintf(os.Stderr, "GetAuthenticatedUser failed: %v\n", err) + } else { + fmt.Printf("Authenticated User: %s\n", authUser) + } + os.Exit(1) + // Define the main arguments cloneAndPushCmd := flag.NewFlagSet("cloneandpush", flag.ExitOnError) diff --git a/cli/source_control.go b/cli/source_control.go index 0acae89..8be7c1c 100644 --- a/cli/source_control.go +++ b/cli/source_control.go @@ -10,6 +10,7 @@ import ( gitHttp "github.com/go-git/go-git/v5/plumbing/transport/http" ) +// cloneRepository clones a repository to a local directory. func cloneRepository(repo, directory, username, password string) { var err error cloneOptions := &git.CloneOptions{ @@ -32,6 +33,7 @@ func cloneRepository(repo, directory, username, password string) { fmt.Println("Repository cloned successfully.") } +// addRemote adds a new remote to the local repository. func addRemote(directory, repo string) { r, err := git.PlainOpen(directory) if err != nil { @@ -50,6 +52,7 @@ func addRemote(directory, repo string) { fmt.Println("Remote added successfully.") } +// pushToRemote pushes the local repository to the specified remote. func pushToRemote(directory, username, password string) { r, err := git.PlainOpen(directory) if err != nil { @@ -82,6 +85,7 @@ func pushToRemote(directory, username, password string) { fmt.Println("Pushed to remote successfully.") } +// CloneAndPush clones a repository from the source URL and pushes it to the destination URL. func CloneAndPush(sourcerepo, sourceusername, sourcepassword, destrepo, destusername, destpassword string, hasWiki bool) { cnpdirectory := "temp.git"