diff --git a/.gitignore b/.gitignore index 6f56295..1464804 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,6 @@ go.work /vendor/ /Godeps/ -.vscode/ \ No newline at end of file +.vscode/ +.idea/ +/claro \ No newline at end of file diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..820ecfe --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,39 @@ +before: + hooks: + - go mod tidy + +builds: + - env: + - CGO_ENABLED=0 + goos: + - freebsd + - linux + - windows + - darwin + goarch: + - amd64 + - arm64 + - arm + - "386" + goarm: + - 6 + - 7 + ldflags: + - -s -w + - -X github.com/emersonmello/claro/cmd.Version={{.Version}} + - -X github.com/emersonmello/claro/cmd.Commit={{.Commit}} + - -X github.com/emersonmello/claro/cmd.Date={{.CommitDate}} + +archives: + - format: binary + name_template: "{{ .Os }}-{{ .Arch }}" +checksum: + name_template: "checksums.txt" +snapshot: + name_template: "{{ incpatch .Version }}-next" +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" \ No newline at end of file diff --git a/LICENSE b/LICENSE index e2f7dfd..bd7447d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 Emerson Ribeiro de Mello +Copyright (c) 2022-2024 Emerson Ribeiro de Mello Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Readme.md b/Readme.md index 892a82d..889eb49 100644 --- a/Readme.md +++ b/Readme.md @@ -9,145 +9,72 @@ [![Go Report Card](https://goreportcard.com/badge/github.com/emersonmello/claro)](https://goreportcard.com/report/github.com/emersonmello/claro) [![Compiling](https://github.com/emersonmello/claro/actions/workflows/release.yaml/badge.svg)](https://github.com/emersonmello/claro/actions/workflows/release.yaml) [![Plataform](https://img.shields.io/badge/Download%20binaries%20for-Linux%20%7C%20macOS-lightgreen)](https://github.com/emersonmello/claro/releases/latest) -# Overview - -**claro** (**cla**ss**ro**om) is a cli tool that offers a simple interface that allows the teacher to clone all student repositories at once for grading and then send grades at once to all these repositories. - -**claro** was inspired by the [Git-Gud-tool](https://github.com/NikolaiMagnussen/Git-Gud-tool) and it was created to make my life simpler as a teacher who relies on Github Classroom. It is suitable for scenarios where the teacher needs to manually grade each assignment and the Github Classroom autograding is not an option. - -**claro** relies on [Github's REST API](https://docs.github.com/en/rest) because currently Github Classroom does not offer an API and it has features that are not present in [Github Classroom Assistant](https://classroom.github.com/assistant). The best one: it is a [CLI](https://clig.dev) :heart:! +# Overview -**claro** provides: -- mass clone of Github repositories -- a template in Markdown for grading (it creates one file per repository) -- customization (commit message, grade sheet title, grading file, etc.) -- access to [Github Personal Access Token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token) from operating system keyring (i.e. macOS Keychain, Gnome Keyring), environment var or claro's config file - -## Requirements -- [git](https://git-scm.com/docs/git) command line application configured properly - ```bash - git config --global user.email "you@example.com" - git config --global user.name "Your Name" - ``` -- [Git credential store](https://git-scm.com/book/en/v2/Git-Tools-Credential-Storage) configured properly - - See [section below](#authenticating-with-git-command-line-to-access-repositories-on-github) +**claro** (**cla**ss**ro**om) is a [CLI](https://clig.dev) tool designed to simplify the grading process for educators using [GitHub Classroom](classroom.github.com), especially in cases where manual grading is necessary and autograding is not an option. **claro** is an alternative to the [GitHub Classroom Assistant](https://classroom.github.com/assistant) and [Github CLI](https://cli.github.com), which currently lacks bulk processing features for grading. With claro, you can efficiently clone all student repositories, grade them, and push the grades back. +**claro** offers: +- Bulk cloning and pushing of student assignment repositories +- Automatic generation of a Markdown grading template (one file per repository) +- Customization options (commit messages, grade sheet titles, grading files, etc.) +- Integration with [Github Personal Access Token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token) via your operating system’s keyring (e.g., macOS Keychain, Gnome Keyring) -## Usage +![claro help message](images/claro.png) -### Straight workflow +## Straight workflow -1. Create a Github Classroom assignment with a distinguish repository prefix - - Example: `2022-01-assignment-01` -2. Clone all repositories from a Github Classroom organization with a specific assignment prefix - - Example: `claro clone github-organization 2022-01-assignment-01` +1. Clone all repositories from a Github Classroom assignment + - Example: `claro clone` ![cloning](images/clone.gif) -3. Grade each student's work and write down the feedback in the respective Markdown file +2. Grade each student's work and write down the feedback in the respective Markdown file - Tip: Use your best Markdown editor for this ![grading](images/grading.gif) -4. Push the grading - - Example: `claro push 2022-01-assignment-01` +3. Upload student grades to GitHub + - Example: `claro push ` ![pushing](images/push.gif) -## Other commands +## Additional commands -![claro help message](images/claro.png) +### Pulls the latest changes from all student repositories + +- Example: `claro pull ` -### Pull students repositories +![pulling](images/pull.gif) -**claro** offers a `pull` command suitable for workflows where students make incremental deliveries to the same GitHub Classroom repository. +### Add a GitHub Personal Access Token to the operating system keyring -- Example: - ```bash - claro pull 2022-01-assignment-01 - ``` -### List all repositories which start with a specific prefix +- Example: `claro token add` -Before cloning multiple repositories, you might want to check which repositories will be cloned with a specific prefix. **claro** offers the `list` command for that. +### Remove a GitHub Personal Access Token from the operating system keyring -- Example: - ```bash - claro list 2022-01-assignment-01 - ``` +- Example: `claro token del` ### Customize commit message, grading filename, grading string +You can customize the commit message, grading filename, and grading string using the `config` command. The new values will be stored in the **claro**'s config file (default `$HOME/.config/claro/config.env`). + The **claro** default strings are: - **Grading filename:** `GRADING.md` - It will be created and pushed to student repository - **Grade string:** `Grade: ` - It will be inside grading file -- **Commit message:** `Graded project, the file containing the grade is in the root directory` +- **Commit message:** `This project has been graded. The file containing the grade is located in the root directory.` - It is the commit message - **Grade sheet title** `Feedback` - It will be inside grading file as title 1 (# Feedback) -You can change the values using **claro** `config` command. The new values will be stored in the **claro**'s config file (default `$HOME/.claro.env`). -- Examples: - - `claro config filename "Correcao.md"` - - `claro config grade "Nota: "` - - `claro config message "Correção finalizada, veja arquivo na raiz do repositório"` - - `claro config title "Comentários"` - -## Storing claro's GitHub Personal Access Token in the OS keyring - -**claro** only supports HTTPS remote URL (git over SSH is so [annoying](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/about-authentication-to-github#authenticating-with-the-command-line)). - -**claro** consumes [Github's REST API](https://docs.github.com/en/rest) to fetch the list of repositories (assignments) from a GitHub Classroom organization. So, **claro** uses a [GitHub Personal Access Token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token). - - **claro** will try to get GitHub Personal Access Token from: (1) operating system keyring; (2) environment var (`GH_TOKEN`); (3) **claro**'s config file (default `$HOME/.claro.env`) - -You can inform the token whenever you clone repositories, or you can save the token in the operating system keyring (recommended) or in the **claro**'s config file. Please, have a look at [Authenticating with git command line to access repositories on GitHub](#authenticating-with-git-command-line-to-access-repositories-on-github). - -- To save the token (**claro** will try to save to the OS keyring first and then to the config file.) - - `claro config token add` -- To delete the token from OS keyring - - `claro config token del` - -## Authenticating with git command line to access repositories on GitHub - - - -> Using an HTTPS remote URL has some advantages compared with using SSH. It's easier to set up than SSH, and usually works through strict firewalls and proxies. However, it also prompts you to enter your GitHub credentials every time you pull or push a repository. ([GitHub Docs](https://docs.github.com/en/get-started/getting-started-with-git/why-is-git-always-asking-for-my-password)). - -To access repositories on GitHub from the command line application over HTTPS you must authenticate with a [GitHub personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/about-authentication-to-github#authenticating-with-the-command-line). - - You can avoid being prompted for your password (personal access token) by configuring Git to cache your credentials for you on [git credential storage](https://git-scm.com/book/en/v2/Git-Tools-Credential-Storage). Git works with several credential helper: - - - **Unsafe and simple way** (not recommended!) - - This method stores your password in plaintext on disk, protected only by filesystem permissions (default `$HOME/.git-credentials`) - - on Linux, macOS or Windows - ```bash - git config --global credential.helper store - ``` - - The first time git will ask the username and password. Subsequent request will use the credentials from the store (default `$HOME/.git-credentials`). - - Example: - `https://username:ghp_token@github.com` - - **Safe with a little complexity** - - This method stores your password encrypted on disk in the operating system keyring service. - - You can use native operating system keyring - - Linux [Secret Service](https://specifications.freedesktop.org/secret-service/latest/) dbus interface, which is provided by [GNOME Keyring](https://wiki.gnome.org/Projects/GnomeKeyring) - - Use [Seahorse app](https://wiki.gnome.org/Apps/Seahorse) to create a default collection with `login` name - - Open `seahorse`; go to `file->new->password keyring`; when asked for a name, use: `login` - - ```bash - # ------------------------------------------------# - # on Ubuntu Linux 22.04 LTS - # ------------------------------------------------# - sudo apt-get install libsecret-1-0 libsecret-1-dev g++ make - - cd /usr/share/doc/git/contrib/credential/libsecret && sudo make - - git config --global credential.helper /usr/share/doc/git/contrib/credential/libsecret/git-credential-libsecret - # ------------------------------------------------# - ``` - - macOS Keychain (`/usr/bin/security`) - - `git config --global credential.helper osxkeychain` - - Multi-plataform (Linux, macOS or Windows) - - You can also use a credential helper like [Git Credential Manager](https://github.com/GitCredentialManager/git-credential-manager) \ No newline at end of file +![alt text](images/config.gif) + + +## GitHub Personal Access Token + +**claro** uses Git to clone, pull, and push repositories. To streamline this process, **claro** will prompt you for your GitHub credentials each time you perform these actions. This can be tedious, especially when handling multiple repositories. To avoid repeated prompts, **claro** will check if [Git's credential storage](https://git-scm.com/book/en/v2/Git-Tools-Credential-Storage) is configured on its first run. If not, it will set up Git to store credentials in cache (e.g., `git config --global credential.helper cache`). You will be asked for your GitHub Personal Access Token only once, and it will remain cached until the cache expires. + +**claro** also requires a [GitHub Personal Access Token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token) to access the [Github's Classroom API](https://docs.github.com/en/rest/classroom?apiVersion=2022-11-28) when cloning repositories. This token is not stored in Git's credential storage, so it is best kept in your operating system's keyring. You can store it using the token add command. Once saved, you won’t need to re-enter it each time you use **claro**. For instructions on how to use your operating system's keyring, [see this guide](https://git-scm.com/doc/credential-helpers). \ No newline at end of file diff --git a/cmd/clone.go b/cmd/clone.go deleted file mode 100644 index 4d424e6..0000000 --- a/cmd/clone.go +++ /dev/null @@ -1,78 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - - "github.com/emersonmello/claro/utils" - "github.com/pterm/pterm" - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -var OutputDir string - -var cloneCmd = &cobra.Command{ - Use: "clone ", - Short: "Clone all students assignment repositories in an organization", - Example: "claro clone ifsc-classroom 2022-01-assignment-01", - SilenceErrors: true, - Args: cobra.ExactArgs(2), - - Run: func(cmd *cobra.Command, args []string) { - - if OutputDir == "repository prefix" { - OutputDir = args[1] - } - - if _, err := os.Stat(OutputDir); !os.IsNotExist(err) { - pterm.Error.Printf("The output directory '%s' already exists on the current directory!\n", OutputDir) - os.Exit(1) - } - - repositories := utils.GetRepositoryList(args[0], args[1]) - - if len(repositories) <= 0 { - pterm.Warning.Printfln("Organization %s does not contains repositories with prefix %s\n", args[0], args[1]) - os.Exit(0) - } else { - fmt.Printf("%d repositories were found\n", len(repositories)) - } - - if !utils.PromptUser("Would you like to proceed (Y/n)? ", "yes") { - os.Exit(0) - } - - s, _ := pterm.DefaultSpinner.Start("Create " + OutputDir + " directory") - if e := os.Mkdir(OutputDir, os.ModePerm); e != nil { - OutputDir = "" - s.Fail(e.Error()) - os.Exit(1) - } - s.Success() - - utils.CloneRepositories(OutputDir, repositories) - - e := os.Chdir(OutputDir) - checkError(e) - - s, _ = pterm.DefaultSpinner.Start("Create Markdown files") - for _, r := range repositories { - f, e := os.Create("grade-" + r.Name + ".md") - if e != nil { - s.Fail(e.Error()) - } - _, e = f.WriteString("# " + viper.GetString("grade_title") + "\n\n- ...\n- " + viper.GetString("grade_string") + " \n\n") - defer f.Close() - if e != nil { - s.Fail(e.Error()) - } - } - s.Success() - }, -} - -func init() { - rootCmd.AddCommand(cloneCmd) - cloneCmd.Flags().StringVarP(&OutputDir, "output-directory", "o", "repository prefix", "Directory where the cloned repositories should be stored") -} diff --git a/cmd/clone/clone.go b/cmd/clone/clone.go new file mode 100644 index 0000000..b747b04 --- /dev/null +++ b/cmd/clone/clone.go @@ -0,0 +1,33 @@ +// Package clone +package clone + +/* +Copyright © 2022-2024 Emerson Ribeiro de Mello +*/ + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/emersonmello/claro/internal" + "github.com/emersonmello/claro/internal/tui" + "github.com/spf13/cobra" +) + +// Clone represents the clone command +func Clone() *cobra.Command { + cloneCmd := &cobra.Command{ + Use: "clone", + Short: "Clone all students assignments from a GitHub Classroom", + RunE: func(cmd *cobra.Command, args []string) error { + if !tui.GitHubCliInstalled { + tui.UserGitHubPAT = internal.GetAndSaveToken() + } + if _, err := tea.NewProgram(internal.NewCloneModel()).Run(); err != nil { + fmt.Println("Error running program:", err) + } + return nil + }, + } + return cloneCmd +} diff --git a/cmd/config.go b/cmd/config.go deleted file mode 100644 index df08166..0000000 --- a/cmd/config.go +++ /dev/null @@ -1,15 +0,0 @@ -package cmd - -import ( - "github.com/spf13/cobra" -) - -// configCmd represents the config command -var configCmd = &cobra.Command{ - Use: "config", - Short: "Configure claro's properties (github token, commit message, etc.)", -} - -func init() { - rootCmd.AddCommand(configCmd) -} diff --git a/cmd/config/config.go b/cmd/config/config.go new file mode 100644 index 0000000..fb34764 --- /dev/null +++ b/cmd/config/config.go @@ -0,0 +1,21 @@ +// Package config +package config + +/* +Copyright © 2022-2024 Emerson Ribeiro de Mello +*/ + +import ( + "github.com/emersonmello/claro/internal" + "github.com/spf13/cobra" +) + +// Config represents the clone command +func Config() *cobra.Command { + configCmd := &cobra.Command{ + Use: "config", + Short: "Configure claro's properties (commit message, filename, etc)", + RunE: internal.ConfigCmd, + } + return configCmd +} diff --git a/cmd/filename.go b/cmd/filename.go deleted file mode 100644 index 0fdac6b..0000000 --- a/cmd/filename.go +++ /dev/null @@ -1,21 +0,0 @@ -package cmd - -import ( - "github.com/emersonmello/claro/utils" - "github.com/spf13/cobra" -) - -// filenameCmd represents the filename command -var filenameCmd = &cobra.Command{ - Use: "filename ", - Example: "claro config filename \"GRADING.md\" ", - Short: "define the name of the file that will be created in the student repository containing the feedback", - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - utils.WriteConfigFile("grade_filename", args[0], "Grade sheet filename has been set successfully!") - }, -} - -func init() { - configCmd.AddCommand(filenameCmd) -} diff --git a/cmd/grade.go b/cmd/grade.go deleted file mode 100644 index 71360f6..0000000 --- a/cmd/grade.go +++ /dev/null @@ -1,21 +0,0 @@ -package cmd - -import ( - "github.com/emersonmello/claro/utils" - "github.com/spf13/cobra" -) - -// gradeCmd represents the grade command -var gradeCmd = &cobra.Command{ - Use: "grade <\"string\">", - Example: "claro config grade \"Grade: \"", - Short: "define the grade string inserted in the file representing the grade sheet", - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - utils.WriteConfigFile("grade_string", args[0], "Grade string has been set successfully!") - }, -} - -func init() { - configCmd.AddCommand(gradeCmd) -} diff --git a/cmd/list.go b/cmd/list.go deleted file mode 100644 index 17d8dd1..0000000 --- a/cmd/list.go +++ /dev/null @@ -1,33 +0,0 @@ -package cmd - -import ( - "fmt" - - "github.com/emersonmello/claro/utils" - "github.com/spf13/cobra" -) - -// listCmd represents the list command -var listCmd = &cobra.Command{ - Use: "list", - Short: "List all student assignment repositories in an organization", - Example: "claro list ifsc-classroom 2022-01-assignment-01", - SilenceErrors: true, - Args: cobra.ExactArgs(2), - Run: func(cmd *cobra.Command, args []string) { - repositories := utils.GetRepositoryList(args[0], args[1]) - - if len(repositories) > 0 { - fmt.Printf("%d repositories were found with %s prefix!\n", len(repositories), args[1]) - for _, r := range repositories { - fmt.Println(r.Name) - } - } else { - fmt.Printf("Organization %s does not contains repositories with prefix %s\n", args[0], args[1]) - } - }, -} - -func init() { - rootCmd.AddCommand(listCmd) -} diff --git a/cmd/message.go b/cmd/message.go deleted file mode 100644 index e3529ad..0000000 --- a/cmd/message.go +++ /dev/null @@ -1,21 +0,0 @@ -package cmd - -import ( - "github.com/emersonmello/claro/utils" - "github.com/spf13/cobra" -) - -// messageCmd represents the message command -var messageCmd = &cobra.Command{ - Use: "message <\"commit message\">", - Example: "claro config message \"Graded project, the file containing the grade is in the root directory\"", - Short: "define the commit message for grading", - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - utils.WriteConfigFile("commit_message", args[0], "Commit message has been set successfully!") - }, -} - -func init() { - configCmd.AddCommand(messageCmd) -} diff --git a/cmd/pull.go b/cmd/pull.go deleted file mode 100644 index 435caac..0000000 --- a/cmd/pull.go +++ /dev/null @@ -1,27 +0,0 @@ -package cmd - -import ( - "github.com/emersonmello/claro/utils" - - "os" - - "github.com/spf13/cobra" -) - -// pullCmd represents the pull command -var pullCmd = &cobra.Command{ - Use: "pull ", - Short: "Incorporate changes from students' remote repositories into local copy", - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - files, err := os.ReadDir(args[0]) - if err != nil { - checkError(err) - } - utils.Pull(files, args[0]) - }, -} - -func init() { - rootCmd.AddCommand(pullCmd) -} diff --git a/cmd/pull/pull.go b/cmd/pull/pull.go new file mode 100644 index 0000000..c5df258 --- /dev/null +++ b/cmd/pull/pull.go @@ -0,0 +1,36 @@ +// Package pull +package pull + +/* +Copyright © 2022-2024 Emerson Ribeiro de Mello +*/ + +import ( + "errors" + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/emersonmello/claro/internal" + "github.com/emersonmello/claro/internal/tui" + "github.com/spf13/cobra" +) + +// Pull represents the pull command +func Pull() *cobra.Command { + pullCmd := &cobra.Command{ + Use: "pull ", + Short: "Incorporate changes from students' remote repositories into local copy", + Long: tui.LongHelpMsg("Incorporate changes from students' remote repositories into local copy"), + //Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) < 1 { + return errors.New(tui.UseErrorMsg("pull")) + } + if _, err := tea.NewProgram(internal.NewPullModel(args[0])).Run(); err != nil { + fmt.Println("Error running program:", err) + } + return nil + }, + } + return pullCmd +} diff --git a/cmd/push.go b/cmd/push.go deleted file mode 100644 index 14420eb..0000000 --- a/cmd/push.go +++ /dev/null @@ -1,98 +0,0 @@ -package cmd - -import ( - "fmt" - "io/fs" - "io/ioutil" - "os" - "regexp" - "strings" - - "github.com/emersonmello/claro/utils" - "github.com/pterm/pterm" - - "github.com/spf13/cobra" -) - -func getRepositoriesAndGradeFiles(files []fs.FileInfo) map[string]fs.FileInfo { - fileNamePattern := "^(grade-).*(\\.md)$" - regexpPattern, _ := regexp.Compile(fileNamePattern) - - oneFilePerRepo := make(map[string]fs.FileInfo) - reposMissing := make([]string, 0) - gradeMissing := make([]string, 0) - - for _, f := range files { - if f.IsDir() { - oneFilePerRepo[f.Name()] = nil - } - } - - for _, f := range files { - if f.Mode().IsRegular() && regexpPattern.MatchString(f.Name()) { - if _, present := oneFilePerRepo[strings.Split(strings.Split(f.Name(), "grade-")[1], ".md")[0]]; present { - oneFilePerRepo[strings.Split(strings.Split(f.Name(), "grade-")[1], ".md")[0]] = f - } else { - reposMissing = append(reposMissing, strings.Split(strings.Split(f.Name(), "grade-")[1], ".md")[0]) - } - } - } - - for k, v := range oneFilePerRepo { - if v == nil { - gradeMissing = append(gradeMissing, k) - } - } - - if len(gradeMissing) > 0 { - pterm.Warning.Println("Attention! There is no grade file for repositories below") - for _, v := range gradeMissing { - fmt.Println(" --> " + v) - } - } - - if len(reposMissing) > 0 { - pterm.Warning.Println("Attention! There is no repositories for grade files below") - for _, v := range reposMissing { - fmt.Println(" --> " + v) - } - } - - return oneFilePerRepo -} - -// pushCmd represents the push command -var pushCmd = &cobra.Command{ - Use: "push ", - Short: "Add and commit the grading file in each student repository", - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - files, err := ioutil.ReadDir(args[0]) - if err != nil { - checkError(err) - } - - oneFilePerRepo := getRepositoriesAndGradeFiles(files) - - e := os.Chdir(args[0]) - checkError(e) - - p := pterm.DefaultProgressbar.WithRemoveWhenDone(true) - p.ShowPercentage = false - p.ShowCount = false - p.ShowCount = false - p.ShowElapsedTime = false - p.Start() - for repo, gradeFile := range oneFilePerRepo { - - if gradeFile != nil { - utils.AddAndCommitGradeFile(gradeFile.Name(), repo, p) - } - } - fmt.Println() - }, -} - -func init() { - rootCmd.AddCommand(pushCmd) -} diff --git a/cmd/push/push.go b/cmd/push/push.go new file mode 100644 index 0000000..655d6fe --- /dev/null +++ b/cmd/push/push.go @@ -0,0 +1,36 @@ +// Package push +package push + +/* +Copyright © 2022-2024 Emerson Ribeiro de Mello +*/ + +import ( + "errors" + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/emersonmello/claro/internal" + "github.com/emersonmello/claro/internal/tui" + "github.com/spf13/cobra" +) + +// Push represents the push command +func Push() *cobra.Command { + pushCmd := &cobra.Command{ + Use: "push ", + Short: "Add, commit, and push the grading file to each student's remote repository", + Long: tui.LongHelpMsg("Use this command to add, commit, and push the grading file for each student's repository to the remote repository"), + //Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) < 1 { + return errors.New(tui.UseErrorMsg("push")) + } + if _, err := tea.NewProgram(internal.NewPushModel(args[0])).Run(); err != nil { + fmt.Println("Error running program:", err) + } + return nil + }, + } + return pushCmd +} diff --git a/cmd/root.go b/cmd/root.go index 59b7fd1..8255a88 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,47 +1,156 @@ +// Package cmd package cmd +/* +Copyright © 2022-2024 Emerson Ribeiro de Mello +*/ + import ( + "errors" + "fmt" "os" + "os/exec" + "runtime/debug" - "github.com/emersonmello/claro/utils" - "github.com/pterm/pterm" - + "github.com/emersonmello/claro/cmd/clone" + "github.com/emersonmello/claro/cmd/config" + "github.com/emersonmello/claro/cmd/pull" + "github.com/emersonmello/claro/cmd/push" + "github.com/emersonmello/claro/cmd/token" + "github.com/emersonmello/claro/internal" + "github.com/emersonmello/claro/internal/tui" "github.com/spf13/cobra" + "github.com/spf13/viper" ) -// rootCmd represents the base command when called without any subcommands -var ( - rootCmd = &cobra.Command{ - Use: "claro", - Short: "A GitHub Classroom CLI for teachers", - Version: "0.1.4", - } -) +var cfgFile string +var pathConfigFile string -func checkError(e error) { - if e != nil { - pterm.Error.Println(e.Error()) - os.Exit(1) +var version = "1.0.0" + +// Print program version +func programVersion() string { + result := version + if info, ok := debug.ReadBuildInfo(); ok { + for _, setting := range info.Settings { + if setting.Key == "vcs.revision" { + commit := setting.Value[0:7] + result = fmt.Sprintf("%s\ncommit: %s", result, commit) + } + if setting.Key == "vcs.time" { + date := setting.Value[0:10] + result = fmt.Sprintf("%s, built at: %s", result, date) + } + } } + return result } -// Execute adds all child commands to the root command and sets flags appropriately. -// This is called by main.main(). It only needs to happen once to the rootCmd. -func Execute() { - utils.CheckExternalsCommands() - err := rootCmd.Execute() - if err != nil { - os.Exit(1) - } +var rootCmd = &cobra.Command{ + Use: "claro", + Short: "A GitHub Classroom CLI for teachers", + Version: programVersion(), +} + +func Execute() error { + return rootCmd.Execute() } func init() { cobra.OnInitialize(initConfig) - rootCmd.CompletionOptions.DisableDefaultCmd = true - rootCmd.CompletionOptions.DisableDescriptions = true - // rootCmd.PersistentFlags().StringVar(&cfgFile, "config-file", "", "config file (default is $HOME/.claro.env)") + + str := fmt.Sprintf("config file (default is %s/config.env)", internal.ConfigDir()) + + rootCmd.PersistentFlags().StringVar(&cfgFile, + "config", + "", + str) + + // Add subcommands + + pullCmd := pull.Pull() + pullCmd.Example = fmt.Sprintf("%s %s assignment-01-submissions", rootCmd.CommandPath(), pullCmd.Name()) + + pushCmd := push.Push() + pushCmd.Example = fmt.Sprintf("%s %s assignment-01-submissions", rootCmd.CommandPath(), pushCmd.Name()) + + tokenCmd := token.Token() + tokenCmd.Example = fmt.Sprintf("%s %s add\n%s %s del", rootCmd.CommandPath(), tokenCmd.Name(), rootCmd.CommandPath(), tokenCmd.Name()) + + rootCmd.AddCommand(clone.Clone()) + rootCmd.AddCommand(config.Config()) + rootCmd.AddCommand(tokenCmd) + rootCmd.AddCommand(pullCmd) + rootCmd.AddCommand(pushCmd) } +// initConfig reads in config file and ENV variables if set. func initConfig() { - utils.GetConf() + if cfgFile != "" { + // Use config file from the flag. + viper.SetConfigFile(cfgFile) + } else { + // Search config in home directory $HOME/.config/claro/config.env + pathConfigFile = internal.ConfigDir() + viper.AddConfigPath(pathConfigFile) + viper.SetConfigType("env") + viper.SetConfigName("config") + } + + viper.SetDefault("version", internal.ClaroConfigStrings.Version) + viper.SetDefault("message", internal.ClaroConfigStrings.Message) + viper.SetDefault("filename", internal.ClaroConfigStrings.Filename) + viper.SetDefault("title", internal.ClaroConfigStrings.Title) + viper.SetDefault("grade", internal.ClaroConfigStrings.Grade) + + viper.AutomaticEnv() // read in environment variables that match + + // If a config file is found, read it in. + if err := viper.ReadInConfig(); err != nil { + var configFileNotFoundError viper.ConfigFileNotFoundError + // Config file not found + if errors.As(err, &configFileNotFoundError) { + // Creating config directory + if _, err = os.Stat(pathConfigFile); os.IsNotExist(err) { + if e := os.MkdirAll(pathConfigFile, 0755); e != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + } + } + } + // Creating config file + if err = viper.SafeWriteConfig(); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + } + } + + // unmarshal config and storing it on runtime conf var + if err := viper.Unmarshal(internal.ClaroConfigStrings); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + } + + if !checkGitAndCredentials() { + os.Exit(1) + } +} + +func checkGitAndCredentials() bool { + if _, err := exec.LookPath("git"); err != nil { + fmt.Println(tui.ErrorStyle.Render("I can't find 'git' command. Please, be sure that 'git' is installed and in the user PATH")) + return false + } + dirname, _ := os.UserHomeDir() + cmd := exec.Command("git", "config", "--global", "--get", "credential.helper") + cmd.Dir = dirname + if out, _ := cmd.Output(); out != nil { + if len(out) == 0 { + cmd = exec.Command("git", "config", "--global", "--add", "credential.helper", "cache") + cmd.Dir = dirname + _ = cmd.Run() + } + } + // Checking if you have GitHub CLI installed + if _, err := exec.LookPath("gh"); err == nil { + tui.GitHubCliInstalled = true + } + return true } diff --git a/cmd/title.go b/cmd/title.go deleted file mode 100644 index b0e77d4..0000000 --- a/cmd/title.go +++ /dev/null @@ -1,21 +0,0 @@ -package cmd - -import ( - "github.com/emersonmello/claro/utils" - "github.com/spf13/cobra" -) - -// titleCmd represents the title command -var titleCmd = &cobra.Command{ - Use: "title <\"string\">", - Example: "claro config title \"Feedback\"", - Short: "define the title string in the file representing the grade sheet", - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - utils.WriteConfigFile("grade_title", args[0], "Title string has been set successfully!") - }, -} - -func init() { - configCmd.AddCommand(titleCmd) -} diff --git a/cmd/token.go b/cmd/token.go deleted file mode 100644 index 82ebb25..0000000 --- a/cmd/token.go +++ /dev/null @@ -1,39 +0,0 @@ -package cmd - -import ( - "fmt" - - "github.com/emersonmello/claro/utils" - - "github.com/spf13/cobra" -) - -// tokenCmd represents the token command -var tokenCmd = &cobra.Command{ - Use: "token ", - Short: "Add or remove GitHub Personal Access Token in OS Keychain", - Long: `Add or remove GitHub Personal Access Token in OS Keychain - -If the OS Keychain is not available then you can store the token in the config file`, - Example: "claro config token add", - ValidArgs: []string{"add", "del"}, - Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), - Run: func(cmd *cobra.Command, args []string) { - switch args[0] { - case "add": - utils.SaveGHToken(utils.ReadTokenFromStdIn()) - case "del": - if !utils.PromptUser("Do you want to remove the token from the keychain? (y/N):", "no") { - if e := utils.DeletePasswordItem(); e == nil { - fmt.Println("Done!") - } else { - checkError(e) - } - } - } - }, -} - -func init() { - configCmd.AddCommand(tokenCmd) -} diff --git a/cmd/token/token.go b/cmd/token/token.go new file mode 100644 index 0000000..10bb69f --- /dev/null +++ b/cmd/token/token.go @@ -0,0 +1,33 @@ +// Package token +package token + +/* +Copyright © 2022-2024 Emerson Ribeiro de Mello +*/ + +import ( + "github.com/emersonmello/claro/internal" + "github.com/spf13/cobra" +) + +// Token represents the token command +func Token() *cobra.Command { + tokenCmd := &cobra.Command{ + Use: "token ", + Short: "add or remove a claro's GitHub Personal Access Token in the OS Keychain", + ValidArgs: []string{"add", "del"}, + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + RunE: configureToken, + } + return tokenCmd +} + +func configureToken(cmd *cobra.Command, args []string) error { + switch args[0] { + case "add": + internal.AddTokenToKeyring() + case "del": + internal.DeleteTokenFromKeyring() + } + return nil +} diff --git a/go.mod b/go.mod index fa8651a..0d877ea 100644 --- a/go.mod +++ b/go.mod @@ -1,55 +1,68 @@ module github.com/emersonmello/claro -go 1.21 - -toolchain go1.22.0 - -require github.com/spf13/viper v1.18.2 +go 1.23 require ( - atomicgo.dev/cursor v0.2.0 // indirect - atomicgo.dev/keyboard v0.2.9 // indirect - atomicgo.dev/schedule v0.1.0 // indirect - github.com/alessio/shellescape v1.4.2 // indirect - github.com/containerd/console v1.0.4 // indirect - github.com/danieljoos/wincred v1.2.1 // indirect - github.com/godbus/dbus/v5 v5.1.0 // indirect - github.com/golang/protobuf v1.5.3 // indirect - github.com/google/go-querystring v1.1.0 // indirect - github.com/gookit/color v1.5.4 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/lithammer/fuzzysearch v1.1.8 // indirect - github.com/mattn/go-runewidth v0.0.15 // indirect - github.com/rivo/uniseg v0.4.7 // indirect - github.com/sagikazarmark/locafero v0.4.0 // indirect - github.com/sagikazarmark/slog-shim v0.1.0 // indirect - github.com/sourcegraph/conc v0.3.0 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.21.0 // indirect - golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect - golang.org/x/term v0.18.0 // indirect - google.golang.org/appengine v1.6.8 // indirect - google.golang.org/protobuf v1.33.0 // indirect + github.com/charmbracelet/bubbles v0.20.0 + github.com/charmbracelet/bubbletea v1.1.1 + github.com/charmbracelet/huh v0.6.0 + github.com/charmbracelet/lipgloss v0.13.0 + github.com/cli/go-gh/v2 v2.9.0 + github.com/github/gh-classroom v0.1.14 + github.com/spf13/cobra v1.8.1 + github.com/spf13/viper v1.19.0 + github.com/zalando/go-keyring v0.2.5 ) require ( + github.com/alessio/shellescape v1.4.2 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.2.0 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect + github.com/charmbracelet/x/ansi v0.3.1 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240913162256-9ef7ff40e654 // indirect + github.com/charmbracelet/x/exp/term v0.0.0-20240814160751-e2dc8b53b604 // indirect + github.com/charmbracelet/x/term v0.2.0 // indirect + github.com/cli/go-gh v1.2.1 // indirect + github.com/cli/safeexec v1.0.1 // indirect + github.com/cli/shurcooL-graphql v0.0.4 // indirect + github.com/danieljoos/wincred v1.2.2 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/google/go-github/v47 v47.1.0 + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect + github.com/henvic/httpretty v0.1.3 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/pelletier/go-toml/v2 v2.1.1 // indirect - github.com/pterm/pterm v0.12.79 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sagikazarmark/locafero v0.6.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect - github.com/spf13/cast v1.6.0 // indirect - github.com/spf13/cobra v1.8.0 + github.com/spf13/cast v1.7.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/zalando/go-keyring v0.2.3 - golang.org/x/oauth2 v0.18.0 - golang.org/x/sys v0.18.0 // indirect - golang.org/x/text v0.14.0 // indirect + github.com/thlib/go-timezone-local v0.0.3 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.25.0 // indirect + golang.org/x/term v0.24.0 // indirect + golang.org/x/text v0.18.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 4032d62..7628918 100644 --- a/go.sum +++ b/go.sum @@ -1,94 +1,124 @@ -atomicgo.dev/assert v0.0.2 h1:FiKeMiZSgRrZsPo9qn/7vmr7mCsh5SZyXY4YGYiYwrg= -atomicgo.dev/assert v0.0.2/go.mod h1:ut4NcI3QDdJtlmAxQULOmA13Gz6e2DWbSAS8RUOmNYQ= -atomicgo.dev/cursor v0.2.0 h1:H6XN5alUJ52FZZUkI7AlJbUc1aW38GWZalpYRPpoPOw= -atomicgo.dev/cursor v0.2.0/go.mod h1:Lr4ZJB3U7DfPPOkbH7/6TOtJ4vFGHlgj1nc+n900IpU= -atomicgo.dev/keyboard v0.2.9 h1:tOsIid3nlPLZ3lwgG8KZMp/SFmr7P0ssEN5JUsm78K8= -atomicgo.dev/keyboard v0.2.9/go.mod h1:BC4w9g00XkxH/f1HXhW2sXmJFOCWbKn9xrOunSFtExQ= -atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs= -atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU= -github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs= -github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8= -github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII= -github.com/MarvinJWendt/testza v0.2.10/go.mod h1:pd+VWsoGUiFtq+hRKSU1Bktnn+DMCSrDrXDpX2bG66k= -github.com/MarvinJWendt/testza v0.2.12/go.mod h1:JOIegYyV7rX+7VZ9r77L/eH6CfJHHzXjB69adAhzZkI= -github.com/MarvinJWendt/testza v0.3.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/2oUqKc6bF2c= -github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE= -github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi+zB4= -github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc4n3EjyIYvEY= +github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= +github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= github.com/alessio/shellescape v1.4.2 h1:MHPfaU+ddJ0/bYWpgIeUnQUqKrlJ1S7BfEYPM4uEoM0= github.com/alessio/shellescape v1.4.2/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= -github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= -github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= -github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro= -github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= +github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= +github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= +github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= +github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= +github.com/charmbracelet/bubbletea v0.26.2 h1:Eeb+n75Om9gQ+I6YpbCXQRKHt5Pn4vMwusQpwLiEgJQ= +github.com/charmbracelet/bubbletea v0.26.2/go.mod h1:6I0nZ3YHUrQj7YHIHlM8RySX4ZIthTliMY+W8X8b+Gs= +github.com/charmbracelet/bubbletea v1.1.1 h1:KJ2/DnmpfqFtDNVTvYZ6zpPFL9iRCRr0qqKOCvppbPY= +github.com/charmbracelet/bubbletea v1.1.1/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/huh v0.3.0 h1:CxPplWkgW2yUTDDG0Z4S5HH8SJOosWHd4LxCvi0XsKE= +github.com/charmbracelet/huh v0.3.0/go.mod h1:fujUdKX8tC45CCSaRQdw789O6uaCRwx8l2NDyKfC4jA= +github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8= +github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU= +github.com/charmbracelet/lipgloss v0.10.1-0.20240413172830-d0be07ea6b9c h1:0FwZb0wTiyalb8QQlILWyIuh3nF5wok6j9D9oUQwfQY= +github.com/charmbracelet/lipgloss v0.10.1-0.20240413172830-d0be07ea6b9c/go.mod h1:EPP2QJ0ectp3zo6gx9f8oJGq8keirqPJ3XpYEI8wrrs= +github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= +github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= +github.com/charmbracelet/x/ansi v0.3.1 h1:CRO6lc/6HCx2/D6S/GZ87jDvRvk6GtPyFP+IljkNtqI= +github.com/charmbracelet/x/ansi v0.3.1/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/exp/strings v0.0.0-20240913162256-9ef7ff40e654 h1:S5PM0Io8HMxKOLQT66ovnXrgZSspXsk+gTpgHiE7nAE= +github.com/charmbracelet/x/exp/strings v0.0.0-20240913162256-9ef7ff40e654/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/exp/term v0.0.0-20240510181320-e66de7a51531 h1:RPb7LB50mGIt9KAGz0rJ+SBYRi5gg74TwvKquCN7XBM= +github.com/charmbracelet/x/exp/term v0.0.0-20240510181320-e66de7a51531/go.mod h1:YBotIGhfoWhHDlnUpJMkjebGV2pdGRCn1Y4/Nk/vVcU= +github.com/charmbracelet/x/exp/term v0.0.0-20240814160751-e2dc8b53b604 h1:Dd3IMfj+uWPNYXGOiqP698ssKfJcKZGjAW1T5H7Btqc= +github.com/charmbracelet/x/exp/term v0.0.0-20240814160751-e2dc8b53b604/go.mod h1:3yyfTUvntvRMtnNv2YRxn5q0HzBiShrse/DjoGHtM18= +github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= +github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= +github.com/cli/go-gh v1.2.1 h1:xFrjejSsgPiwXFP6VYynKWwxLQcNJy3Twbu82ZDlR/o= +github.com/cli/go-gh v1.2.1/go.mod h1:Jxk8X+TCO4Ui/GarwY9tByWm/8zp4jJktzVZNlTW5VM= +github.com/cli/go-gh/v2 v2.9.0 h1:D3lTjEneMYl54M+WjZ+kRPrR5CEJ5BHS05isBPOV3LI= +github.com/cli/go-gh/v2 v2.9.0/go.mod h1:MeRoKzXff3ygHu7zP+NVTT+imcHW6p3tpuxHAzRM2xE= +github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00= +github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= +github.com/cli/shurcooL-graphql v0.0.4 h1:6MogPnQJLjKkaXPyGqPRXOI2qCsQdqNfUY1QSJu2GuY= +github.com/cli/shurcooL-graphql v0.0.4/go.mod h1:3waN4u02FiZivIV+p1y4d0Jo1jc6BViMA73C+sZo2fk= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/danieljoos/wincred v1.2.1 h1:dl9cBrupW8+r5250DYkYxocLeZ1Y4vB1kxgtjxw8GQs= -github.com/danieljoos/wincred v1.2.1/go.mod h1:uGaFL9fDn3OLTvzCGulzE+SzjEe5NGlh5FdCcyfPwps= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0= +github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0= +github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/github/gh-classroom v0.1.13 h1:cFrcmlw0prPm4BpEXmKFav2tDPoUNfgWQgZ7fbA1nTA= +github.com/github/gh-classroom v0.1.13/go.mod h1:Yb8iVDyBxBfR9/hfO2OuE8OLSyYFihwnuMD4u5YhVYA= +github.com/github/gh-classroom v0.1.14 h1:dTTGBSzJwVN2Pp+SUr5+Ti2QOrdWgYreO4xbdsT9p3U= +github.com/github/gh-classroom v0.1.14/go.mod h1:huwl1rvnaUfPnJQ8lUoK9NDcnprIpskc/fy8dYzJJfg= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-github/v47 v47.1.0 h1:Cacm/WxQBOa9lF0FT0EMjZ2BWMetQ1TQfyurn4yF1z8= -github.com/google/go-github/v47 v47.1.0/go.mod h1:VPZBXNbFSJGjyjFRUKo9vZGawTajnWzC/YjGw/oFKi0= -github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= -github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= -github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ= -github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo= -github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= -github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/henvic/httpretty v0.1.3 h1:4A6vigjz6Q/+yAfTD4wqipCv+Px69C7Th/NhT0ApuU8= +github.com/henvic/httpretty v0.1.3/go.mod h1:UUEv7c2kHZ5SPQ51uS3wBpzPDibg2U3Y+IaXyHy5GBg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= -github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= -github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU= -github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -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/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= -github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +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/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +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/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI= -github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg= +github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pterm/pterm v0.12.27/go.mod h1:PhQ89w4i95rhgE+xedAoqous6K9X+r6aSOI2eFF7DZI= -github.com/pterm/pterm v0.12.29/go.mod h1:WI3qxgvoQFFGKGjGnJR849gU0TsEOvKn5Q8LlY1U7lg= -github.com/pterm/pterm v0.12.30/go.mod h1:MOqLIyMOgmTDz9yorcYbcw+HsgoZo3BQfg2wtl3HEFE= -github.com/pterm/pterm v0.12.31/go.mod h1:32ZAWZVXD7ZfG0s8qqHXePte42kdz8ECtRyEejaWgXU= -github.com/pterm/pterm v0.12.33/go.mod h1:x+h2uL+n7CP/rel9+bImHD5lF3nM9vJj80k9ybiiTTE= -github.com/pterm/pterm v0.12.36/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5bUw8T8= -github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkGTYf8s= -github.com/pterm/pterm v0.12.79 h1:lH3yrYMhdpeqX9y5Ep1u7DejyHy7NSQg9qrBjF9dFT4= -github.com/pterm/pterm v0.12.79/go.mod h1:1v/gzOF1N0FsjbgTHZ1wVycRkKiatFvJSJC4IGaQAAo= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -97,108 +127,86 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk= +github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= -github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= -github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= +github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms= -github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= +github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e h1:BuzhfgfWQbX0dWzYzT1zsORLnHRv3bcRcsaUk0VmXA8= +github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI= +github.com/thlib/go-timezone-local v0.0.3 h1:ie5XtZWG5lQ4+1MtC5KZ/FeWlOKzW2nPoUnXYUbV/1s= +github.com/thlib/go-timezone-local v0.0.3/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI= +github.com/zalando/go-keyring v0.2.2 h1:f0xmpYiSrHtSNAVgwip93Cg8tuF45HJM6rHq/A5RI/4= +github.com/zalando/go-keyring v0.2.2/go.mod h1:sI3evg9Wvpw3+n4SqplGSJUMwtDeROfD4nsFz4z9PG0= +github.com/zalando/go-keyring v0.2.5 h1:Bc2HHpjALryKD62ppdEzaFG6VxL6Bc+5v0LYpN8Lba8= +github.com/zalando/go-keyring v0.2.5/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -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.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= -golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= -golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ= -golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= -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/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-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/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI= -golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8= -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/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -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-20210330210617-4fbd30eecc44/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-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/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-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -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.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= -golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= -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.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -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.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -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/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= -google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= -google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= +golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= +golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= +golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM= +golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/tools v0.21.0 h1:qc0xYgIbsSDt9EyWz05J5wfa7LOVW0YTLOXrqdLAWIw= +golang.org/x/tools v0.21.0/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= +gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/images/claro.png b/images/claro.png index c576771..3e6210d 100644 Binary files a/images/claro.png and b/images/claro.png differ diff --git a/images/clone.gif b/images/clone.gif index 64edfaf..2701602 100644 Binary files a/images/clone.gif and b/images/clone.gif differ diff --git a/images/config.gif b/images/config.gif new file mode 100644 index 0000000..cc0d3b1 Binary files /dev/null and b/images/config.gif differ diff --git a/images/grading.gif b/images/grading.gif index 258ceb8..2249437 100644 Binary files a/images/grading.gif and b/images/grading.gif differ diff --git a/images/pull.gif b/images/pull.gif new file mode 100644 index 0000000..5214f3d Binary files /dev/null and b/images/pull.gif differ diff --git a/images/push.gif b/images/push.gif index 939d43e..e76db77 100644 Binary files a/images/push.gif and b/images/push.gif differ diff --git a/internal/clone.go b/internal/clone.go new file mode 100644 index 0000000..4ecf58c --- /dev/null +++ b/internal/clone.go @@ -0,0 +1,300 @@ +// Package internal +package internal + +/* +Copyright © 2022-2024 Emerson Ribeiro de Mello +*/ + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/progress" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/emersonmello/claro/internal/tui" + "github.com/github/gh-classroom/pkg/classroom" +) + +type state int + +const ( + initial state = iota + listClassrooms + fetchAssignmentsList + listAssignments + fetchRepositoriesList + cloningAssignment +) + +// CloneModel represents the model for the clone command +type CloneModel struct { + state state + cL tui.ClassroomList + aL tui.AssignmentsList + repoL []classroom.AcceptedAssignment + totalCloned int + index int + classroomList list.Model + assignmentsList list.Model + spinner spinner.Model + progress progress.Model + styles tui.ClaroStyles + keyMap *tui.KeyMap + help help.Model + done bool + width int + height int + credentialSet bool +} + +// NewCloneModel creates a new CloneModel +func NewCloneModel() CloneModel { + styles := tui.CreateDefaultStyles() + keys := tui.ClaroKeyMap() + h := help.New() + sp := spinner.New() + sp.Spinner = spinner.Dot + sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) + p := progress.New( + progress.WithDefaultGradient(), + progress.WithWidth(50), + progress.WithoutPercentage(), + ) + return CloneModel{ + state: initial, + styles: styles, + keyMap: keys, + help: h, + spinner: sp, + progress: p, + index: 0, + totalCloned: 0, + credentialSet: false, + } +} + +func (m CloneModel) Init() tea.Cmd { + return tea.Batch(m.spinner.Tick, restGetClassrooms(0)) +} + +func (m CloneModel) View() string { + switch m.state { + case initial: + return m.classroomView() + case listClassrooms: + return m.classroomList.View() + case fetchAssignmentsList: + return m.assignmentsView() + case listAssignments: + return m.assignmentsList.View() + case fetchRepositoriesList: + return m.acceptedAssignmentsView() + case cloningAssignment: + return m.cloneView() + default: + return "" + } +} + +func (m CloneModel) classroomView() string { + str := fmt.Sprintf("%s fetching your classrooms", m.spinner.View()) + return lipgloss.JoinVertical(lipgloss.Top, lipgloss.NewStyle().MarginLeft(1).Render(str)) +} + +func (m CloneModel) assignmentsView() string { + str := fmt.Sprintf("%s retrieving your assignments", m.spinner.View()) + return lipgloss.JoinVertical(lipgloss.Top, lipgloss.NewStyle().MarginLeft(1).Render(str)) +} + +func (m CloneModel) acceptedAssignmentsView() string { + str := fmt.Sprintf("%s Retrieving accepted assignments.", m.spinner.View()) + return lipgloss.JoinVertical(lipgloss.Top, lipgloss.NewStyle().MarginLeft(1).Render(str)) +} + +func (m CloneModel) cloneView() string { + n := len(m.repoL) + w := lipgloss.Width(fmt.Sprintf("%d", n)) + + if m.done { + return tui.DoneStyle.Render(fmt.Sprintf("Cloned %d repositories\n", m.totalCloned)) + } + count := fmt.Sprintf(" %*d/%*d", w, m.index+1, w, n) + per := float64(m.index) / float64(len(m.repoL)-1) + prog := m.progress.ViewAs(per) + cellsAvail := max(0, m.width-lipgloss.Width(prog+count)) + + repository := tui.CurrentRepositoryStyle.Render(m.repoL[m.index].Repository.Name) + info := lipgloss.NewStyle().MaxWidth(cellsAvail).Render(fmt.Sprintf("%s %s ", tui.BowtieMark, repository)) + newLine := lipgloss.NewStyle().Render("\n") + //cellsRemaining := max(0, m.width-lipgloss.Width(spin+info+prog+count)-2) + //gap := strings.Repeat(" ", cellsRemaining) + //return info + gap + prog + count + newLine + return info + newLine + " " + prog + count + newLine +} + +func (m CloneModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc", "q": + return m, tea.Quit + } + } + switch m.state { + case initial: + return listUpdate(msg, m) + case listClassrooms: + return classroomUpdate(msg, m) + case fetchAssignmentsList: + return listUpdate(msg, m) + case listAssignments: + return assignmentUpdate(msg, m) + case fetchRepositoriesList: + return listUpdate(msg, m) + case cloningAssignment: + return cloneUpdate(msg, m) + default: + return m, nil + } +} + +func classroomUpdate(msg tea.Msg, m CloneModel) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.classroomList.SetSize(msg.Width, msg.Height) + return m, nil + case tea.KeyMsg: + switch msg.String() { + case "enter", "right": + var i, ok = m.classroomList.SelectedItem().(tui.Item) + if ok { + m.state = fetchAssignmentsList + return m, tea.Batch(m.spinner.Tick, restGetAssignments(i.Id, 0, 0)) + } + } + } + var cmd tea.Cmd + m.classroomList, cmd = m.classroomList.Update(msg) + return m, cmd +} + +func assignmentUpdate(msg tea.Msg, m CloneModel) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.assignmentsList.SetSize(msg.Width, msg.Height) + return m, nil + case tea.KeyMsg: + switch msg.String() { + case "enter", "right": + var i, ok = m.assignmentsList.SelectedItem().(tui.Item) + if ok { + m.state = fetchRepositoriesList + return m, tea.Batch(m.spinner.Tick, restGetAcceptedAssignmentsList(i.Id, 0, 0)) + } + case "left": + m.state = listClassrooms + var cmd tea.Cmd + m.classroomList, cmd = m.classroomList.Update(msg) + return m, cmd + } + } + var cmd tea.Cmd + m.assignmentsList, cmd = m.assignmentsList.Update(msg) + return m, cmd +} + +func listUpdate(msg tea.Msg, m CloneModel) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + return m, nil + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + case tui.ClassroomList: + m.cL = msg + if m.cL != nil { + if len(m.cL) > 0 { + m.state = listClassrooms + styles := tui.CreateDefaultStyles() + keys := tui.ClaroKeyMap() + height := min(len(msg)+8, m.height) - 2 + l := list.New(tui.MakeClassroomList(m.cL), tui.NewItemDelegate(&styles, keys), tui.DefaultWidth, height) + l = tui.FormatList(l, "Select a classroom") + l.AdditionalShortHelpKeys = m.keyMap.ShortHelp + m.classroomList = l + return m, nil + } + } + return m, tea.Sequence(tea.Printf(m.styles.QuitText.Render("You don't have GitHub Classrooms, or you do not have permission to access them.")), tea.Quit) + case tui.AssignmentsList: + m.aL = msg + if m.aL != nil { + if len(m.aL) > 0 { + m.state = listAssignments + styles := tui.CreateDefaultStyles() + keys := tui.ClaroKeyMap() + height := min(len(msg)+8, m.height) - 2 + l := list.New(tui.MakeAssignmentsList(m.aL), tui.NewItemDelegate(&styles, keys), tui.DefaultWidth, height) + l = tui.FormatList(l, "Select an assignment") + l.AdditionalShortHelpKeys = m.keyMap.ShortHelp + m.assignmentsList = l + return m, nil + } + } + return m, tea.Sequence(tea.Printf(m.styles.QuitText.Render("No assignments were found for this classroom, or you do not have permission to access them.")), tea.Quit) + case []classroom.AcceptedAssignment: + m.repoL = msg + if len(m.repoL) > 0 { + m.state = cloningAssignment + m.index = 0 + return m, tea.Sequence(tea.Printf("Found %d repositories. Cloning...\n", len(m.repoL)), gitCloneAssignment(m.repoL[m.index]), m.spinner.Tick) + } + return m, tea.Sequence(tea.Printf(m.styles.QuitText.Render("No student submissions were found for this assignment, or you do not have permission to access them.")), tea.Quit) + case tui.ErrorMsg: + return m, tea.Sequence(tea.Printf(m.styles.ErrorText.Render(string(msg))), tea.Quit) + } + return m, nil +} + +func cloneUpdate(msg tea.Msg, m CloneModel) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + case tui.SuccessfullMsg: + m.totalCloned++ + cmd := tea.Printf("%s %s", tui.CheckMark, m.repoL[m.index].Repository.Name) + if m.index >= len(m.repoL)-1 { + m.done = true + return m, tea.Sequence(cmd, tea.Quit) + } + m.index++ + return m, tea.Sequence(cmd, gitCloneAssignment(m.repoL[m.index])) + case tui.ErrorMsg: + reason := lipgloss.NewStyle().Foreground(lipgloss.Color("#783D38")).Italic(true).SetString(string(msg)) + cmd := tea.Printf("%s %s %s", tui.ErrorMark, m.repoL[m.index].Repository.Name, reason) + if m.index >= len(m.repoL)-1 { + m.done = true + return m, tea.Sequence(cmd, tea.Quit) + } + m.index++ + return m, tea.Sequence(cmd, gitCloneAssignment(m.repoL[m.index])) + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + case progress.FrameMsg: + newModel, cmd := m.progress.Update(msg) + if newModel, ok := newModel.(progress.Model); ok { + m.progress = newModel + } + return m, cmd + } + return m, nil +} diff --git a/internal/config.go b/internal/config.go new file mode 100644 index 0000000..b0ae7b7 --- /dev/null +++ b/internal/config.go @@ -0,0 +1,134 @@ +// Package internal +package internal + +/* +Copyright © 2022-2024 Emerson Ribeiro de Mello +*/ + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +const ( + xdgConfigHome = "XDG_CONFIG_HOME" +) + +type ClaroCfg struct { + Version int `mapstructure:"version"` + Message string `mapstructure:"message"` + Filename string `mapstructure:"filename"` + Title string `mapstructure:"title"` + Grade string `mapstructure:"grade"` +} +type choice int + +const ( + filename choice = iota + message + title + grade + quit +) + +const configFilename = "claro" + +func ConfigDir() string { + var path string + + if l := os.Getenv(xdgConfigHome); l != "" { + path = filepath.Join(l, configFilename) + } else if a := os.Getenv("AppData"); runtime.GOOS == "windows" && a != "" { + path = filepath.Join(a, configFilename) + } else { + home, _ := os.UserHomeDir() + path = filepath.Join(home, ".config", configFilename) + } + return path +} + +var ClaroConfigStrings = &ClaroCfg{ + Version: 1, + Message: "This project has been graded. The file containing the grade is located in the root directory.", + Filename: "GRADING.md", + Title: "Feedback", + Grade: "Grade: ", +} + +func ConfigCmd(cmd *cobra.Command, args []string) error { + + var option choice + + for { + + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[choice](). + Title("Which option do you want to configure?"). + Options( + huh.NewOption("Grade filename", filename), + huh.NewOption("Commit message", message), + huh.NewOption("Grade file's title", title), + huh.NewOption("Grade file's grade string", grade), + huh.NewOption("Quit", quit), + ). + Value(&option), + ), + ) + + if err := form.Run(); err != nil { + fmt.Println("There was an error running the program:", err) + } + + var group *huh.Group + + switch option { + case filename: + group = huh.NewGroup( + huh.NewInput(). + Value(&ClaroConfigStrings.Filename). + Title("The name of the file that will be created in the student repository containing the feedback."), + ) + case message: + group = huh.NewGroup( + huh.NewInput(). + Value(&ClaroConfigStrings.Message). + Title("Commit message for grading"), + ) + case title: + group = huh.NewGroup( + huh.NewInput(). + Value(&ClaroConfigStrings.Title). + Title("The file's title representing the grade sheet"), + ) + case grade: + group = huh.NewGroup( + huh.NewInput(). + Value(&ClaroConfigStrings.Grade). + Title("The grade string inserted in the file representing the grade sheet."), + ) + case quit: + // Saving config file + viper.Set("Title", ClaroConfigStrings.Title) + viper.Set("Message", ClaroConfigStrings.Message) + viper.Set("Filename", ClaroConfigStrings.Filename) + viper.Set("Grade", ClaroConfigStrings.Grade) + if err := viper.WriteConfig(); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + } + return nil + } + + form = huh.NewForm(group) + + if err := form.Run(); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + } + } +} diff --git a/internal/credential.go b/internal/credential.go new file mode 100644 index 0000000..82d9865 --- /dev/null +++ b/internal/credential.go @@ -0,0 +1,138 @@ +// Package internal +package internal + +/* +Copyright © 2022-2024 Emerson Ribeiro de Mello +*/ + +import ( + "fmt" + "os" + "strings" + + "github.com/charmbracelet/huh" + "github.com/emersonmello/claro/internal/tui" + "github.com/zalando/go-keyring" +) + +const service = "a github classroom cli" +const user = "claro" + +// DeletePasswordItem Delete the user's GitHub personal access token from the operating system keyring +func deletePasswordItem() error { + return keyring.Delete(service, user) +} + +// CreateKey Store the user's GitHub personal access token in the operating system keyring +func createKey(password string, removeIfExist bool) error { + if removeIfExist { + _ = deletePasswordItem() + } + e := keyring.Set(service, user, password) + if e != nil { + fmt.Println(tui.ErrorStyle.Render(fmt.Sprintf("Could not store token in operating system keyring:\n => %s", e))) + } else { + fmt.Println(tui.DoneStyle.Render("Your github personal access token has been successfully set in the operating system keyring!")) + } + return e +} + +// GetPassword Retrieve the user's GitHub personal access token from the operating system keyring +func getPassword() (string, error) { + return keyring.Get(service, user) +} + +// ReadTokenFromStdIn To obtain the user's GitHub Personal Access Token +func readTokenFromStdIn() string { + var userToken string + group := huh.NewGroup( + huh.NewInput(). + Value(&userToken).Placeholder("Ex: ghp_1873SsDhdjf...."). + Title("Provide your GitHub Personal Access Token (classic):"). + EchoMode(huh.EchoModePassword), + ) + form := huh.NewForm(group) + if err := form.Run(); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(0) + } + return userToken +} + +// yesNoDialog displays a yes/no confirmation dialog with the given message. +// It returns true if the user confirms, otherwise false. +func yesNoDialog(msg string) bool { + var confirm bool + group := huh.NewGroup( + huh.NewConfirm(). + Title(msg). + Value(&confirm), + ) + form := huh.NewForm(group) + if e := form.Run(); e != nil { + _, _ = fmt.Fprintln(os.Stderr, e) + os.Exit(0) + } + return confirm +} + +// DeleteTokenFromKeyring deletes the GitHub Personal Access Token from the OS keyring. +func DeleteTokenFromKeyring() { + if ghToken, _ := getPassword(); ghToken != "" { + confirm := yesNoDialog("Are you sure you want to delete the GitHub Personal Access Token from the operating system keyring?") + if confirm { + if err := deletePasswordItem(); err != nil { + if strings.Contains(err.Error(), "secret not found") { + fmt.Println(tui.ErrorStyle.Render("Secret not found in OS Keychain.")) + } else { + fmt.Println(tui.ErrorStyle.Render(fmt.Sprintf("Error deleting token from OS Keychain: %s", err))) + } + } else { + fmt.Println(tui.DoneStyle.Render("Token deleted from OS Keychain")) + } + } + } else { + fmt.Println(tui.ErrorStyle.Render("No token found in OS Keychain. Nothing to delete.")) + } +} + +// AddTokenToKeyring adds a GitHub Personal Access Token to the OS keyring. +func AddTokenToKeyring() { + if ghToken, _ := getPassword(); ghToken != "" { + confirm := yesNoDialog("A GitHub Personal Access Token for claro is already stored in the operating system keyring. Would you like to override it?") + if !confirm { + return + } + } + if ghToken := readTokenFromStdIn(); ghToken != "" { + _ = createKey(ghToken, true) + } +} + +// GetAndSaveToken retrieves the GitHub Personal Access Token from the OS keyring. +// Returns the GitHub Personal Access Token. +func GetAndSaveToken() string { + var ghToken string + if ghToken, _ = getPassword(); ghToken == "" { + if ghToken = readTokenFromStdIn(); ghToken != "" { + // If the token is not found, it prompts the user to input the token and optionally saves it in the OS keyring. + //persist := yesNoDialog("Would you like to save this token in the OS keyring?") + //if persist { + // _ = createKey(ghToken, true) + //} + } + } + return ghToken +} + +// writeConfigFile key/value in the config file +// func writeConfigFile(key, value, returnMessage string) { +// viper.Set(key, value) +// if viper.WriteConfig() != nil { +// err := viper.SafeWriteConfig() +// if err != nil { +// fmt.Println(tui.ErrorStyle.Render(fmt.Sprintf("Error writing config to file: %s", err))) +// } +// } +// fmt.Println(returnMessage) +// } diff --git a/internal/ghrest.go b/internal/ghrest.go new file mode 100644 index 0000000..5a1d925 --- /dev/null +++ b/internal/ghrest.go @@ -0,0 +1,110 @@ +// Package internal +package internal + +/* +Copyright © 2022-2024 Emerson Ribeiro de Mello +*/ + +import ( + "errors" + "fmt" + "net/http" + + tea "github.com/charmbracelet/bubbletea" + "github.com/cli/go-gh/v2/pkg/api" + "github.com/emersonmello/claro/internal/tui" + "github.com/github/gh-classroom/pkg/classroom" +) + +func restGetClassrooms(page int) tea.Cmd { + return func() tea.Msg { + client, er := getAPIRESTClient() + if client == nil { + return er + } + var classroomList tui.ClassroomList + var path = "classrooms" + if page != 0 { + path = fmt.Sprintf("%s?page=%d", path, page) + } + if e := client.Get(path, &classroomList); e != nil { + return checkIfBadCredentialError(e, "Failed to retrieve the classrooms list") + } + + return classroomList + //return generateRandomClassrooms(30) + } +} +func restGetAssignments(classroomId string, page int, perPage int) tea.Cmd { + return func() tea.Msg { + client, er := getAPIRESTClient() + if client == nil { + return er + } + var assignments tui.AssignmentsList + var path = fmt.Sprintf("classrooms/%s/assignments", classroomId) + if page != 0 { + path += fmt.Sprintf("?page=%v", page) + } + if perPage != 0 { + path += fmt.Sprintf("&per_page=%v", perPage) + } + if e := client.Get(path, &assignments); e != nil { + return checkIfBadCredentialError(e, "Failed to retrieve the assignments list") + } + return assignments + //return generateRandomAssignments(30) + } +} + +func restGetAcceptedAssignmentsList(assignmentId string, page int, perPage int) tea.Cmd { + return func() tea.Msg { + client, er := getAPIRESTClient() + if client == nil { + return er + } + var repos = make([]classroom.AcceptedAssignment, 0) + var path = fmt.Sprintf("assignments/%v/accepted_assignments", assignmentId) + if page != 0 { + path += fmt.Sprintf("?page=%v", page) + } + if perPage != 0 { + path += fmt.Sprintf("&per_page=%v", perPage) + } + if e := client.Get(path, &repos); e != nil { + return checkIfBadCredentialError(e, "Failed to retrieve the accepted assignments list") + } + return repos + } +} + +func getAPIRESTClient() (*api.RESTClient, tui.ErrorMsg) { + var client *api.RESTClient + var err error + // If you have GitHub CLI installed, you can use it to connect to the GitHub REST API. + //if tui.GitHubCliInstalled { + // client, err = api.DefaultRESTClient() + //} else { + // Ok, no problem. Since I'm not using GitHub CLI, I need to have access to a Personal Access Token + opts := api.ClientOptions{AuthToken: tui.UserGitHubPAT} + client, err = api.NewRESTClient(opts) + //} + var errorMsg tui.ErrorMsg + if client == nil { + errorMsg = tui.ErrorMsg(fmt.Sprintf("An error occurred while retrieving the GitHub REST API client. %s", err)) + } + return client, errorMsg +} + +func checkIfBadCredentialError(e error, msg string) tui.ErrorMsg { + var hE *api.HTTPError + s := "claro config" + if errors.As(e, &hE) { + if hE.StatusCode == http.StatusUnauthorized { + return tui.ErrorMsg(fmt.Sprintf("%s => HTTP %d: %s."+ + "\nVisit https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens to generate a new PAT"+ + "\nThen, execute '%s' to update your GitHub Personal Access Token.", msg, hE.StatusCode, hE.Message, s)) + } + } + return tui.ErrorMsg(fmt.Sprintf("%s. %s", msg, e)) +} diff --git a/internal/git.go b/internal/git.go new file mode 100644 index 0000000..0ea2091 --- /dev/null +++ b/internal/git.go @@ -0,0 +1,162 @@ +// Package internal +package internal + +/* +Copyright © 2022-2024 Emerson Ribeiro de Mello +*/ + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/emersonmello/claro/internal/tui" + "github.com/github/gh-classroom/pkg/classroom" + "github.com/spf13/viper" +) + +func gitCloneAssignment(assignment classroom.AcceptedAssignment) tea.Cmd { + var directory string + + if strings.HasPrefix(directory, "~") { + dirname, _ := os.UserHomeDir() + directory = filepath.Join(dirname, directory[1:]) + } + + fullPath, _ := filepath.Abs(filepath.Join(directory, assignment.Assignment.Slug+"-submissions")) + + if _, err := os.Stat(fullPath); os.IsNotExist(err) { + err = os.MkdirAll(fullPath, 0755) + if err != nil { + return func() tea.Msg { + return tui.ErrorMsg(fmt.Sprintf("Error creating directory: %s", fullPath)) + } + } + } + clonePath := filepath.Join(fullPath, assignment.Repository.Name) + if _, err := os.Stat(clonePath); os.IsNotExist(err) { + cmd := exec.Command("git", "clone", "-q", assignment.Repository.HtmlUrl, clonePath) + return tea.ExecProcess(cmd, func(err error) tea.Msg { + if err != nil { + return tui.ErrorMsg(fmt.Sprintf("Error '%s' encountered while cloning: %s", err, assignment.Repository.FullName)) + } + // Getting the commit hash to be used in the grade file + cmd := exec.Command("git", "rev-parse", "--short", "HEAD") + commit, _ := executeCommand(cmd, clonePath) + // Getting commit date + cmd = exec.Command("git", "show", "-s", "--format=%ci") + commitDate, _ := executeCommand(cmd, clonePath) + commitStr := fmt.Sprintf("> Commit: %s | %s", strings.ReplaceAll(string(commit), "\n", ""), strings.ReplaceAll(string(commitDate), "\n", "")) + // Creating grade file .md + gradeFileName := filepath.Join(fullPath, "grade-"+assignment.Repository.Name+".md") + if _, err = os.Stat(gradeFileName); os.IsNotExist(err) { + if f, e := os.Create(gradeFileName); e != nil { + return tui.ErrorMsg(fmt.Sprintf("Unable to create grade file: %s", e)) + } else { + mdText := fmt.Sprintf("# %s\n%s\n\n- ...\n- **%s** \n\n", viper.GetString("title"), commitStr, viper.GetString("grade")) + if _, e = f.WriteString(mdText); e != nil { + return tui.ErrorMsg(fmt.Sprintf("Unable to write to markdown file: %s", e)) + } + defer func(f *os.File) { + _ = f.Close() + }(f) + } + } + return tui.SuccessfullMsg(assignment.Repository.Name) + }) + } + return func() tea.Msg { + return tui.ErrorMsg("Repository already exists, skipping clone") + } +} + +func gitPullCmd(directory string, repositoryName string) tea.Cmd { + //pause := time.Duration(rand.Int63n(1000)+3000) * time.Millisecond + //time.Sleep(pause) + + cmd := exec.Command("git", "reset", "-q") + if _, e := executeCommand(cmd, directory); e != nil { + return func() tea.Msg { return tui.ErrorMsg("This is not a git repository") } + } + cmd = exec.Command("git", "stash", "-a", "-q") + _, _ = executeCommand(cmd, directory) + cmd = exec.Command("git", "rev-list", "--all", "--count") + totalCommitsBeforePull, _ := executeCommand(cmd, directory) + cmd = exec.Command("git", "pull", "-q", "--rebase") + cmd.Dir = directory + return tea.ExecProcess(cmd, func(err error) tea.Msg { + cmd = exec.Command("git", "rev-list", "--all", "--count") + totalCommitsAfterPull, _ := executeCommand(cmd, directory) + cmd = exec.Command("git", "stash", "pop") + _, _ = executeCommand(cmd, directory) + if err != nil { + return tui.ErrorMsg("Failed to execute 'git pull'") + } + if !bytes.Equal(totalCommitsAfterPull, totalCommitsBeforePull) { + str := lipgloss.NewStyle().Foreground(lipgloss.Color("#E9E64D")).Italic(true).SetString("new commits").Render() + return tui.SuccessfullPullMsg(fmt.Sprintf("%s %s", repositoryName, str)) + } + return tui.SuccessfullPullMsg(repositoryName) + }) +} + +func gitCommitAndPushCmd(directory string, repositoryName string, submission pair) tea.Cmd { + gradeFileName := viper.GetString("filename") + parentDir := filepath.Dir(directory) + srcName, _ := filepath.Abs(filepath.Join(parentDir, submission.gradeFilename.Name())) + dstName, _ := filepath.Abs(filepath.Join(directory, gradeFileName)) + cmd := exec.Command("git", "reset", "-q") + _, _ = executeCommand(cmd, directory) + cmd = exec.Command("git", "stash", "-a", "-q") + _, _ = executeCommand(cmd, directory) + if errCopy := copyFile(srcName, dstName); errCopy == nil { + cmd := exec.Command("git", "add", gradeFileName) + _, _ = executeCommand(cmd, directory) + cmd = exec.Command("git", "status", "--porcelain") + o, _ := executeCommand(cmd, directory) + cmd = exec.Command("git", "stash", "pop") + _, _ = executeCommand(cmd, directory) + var str string + if string(o) == "" { + str = lipgloss.NewStyle().Foreground(lipgloss.Color("#E9E64D")).Italic(true).SetString("nothing to commit, working tree clean").Render() + } else { + cmd = exec.Command("git", "commit", "-q", "-m", viper.GetString("message")) + _, _ = executeCommand(cmd, directory) + } + cmd = exec.Command("git", "push", "-q") + cmd.Dir = directory + return tea.ExecProcess(cmd, func(err error) tea.Msg { + if err != nil { + return tui.ErrorMsg(fmt.Sprintf("Failed to execute 'git push' for %s", repositoryName)) + } + return tui.SuccessfullMsg(fmt.Sprintf("%s %s", repositoryName, str)) + }) + } + cmd = exec.Command("git", "stash", "pop") + _, _ = executeCommand(cmd, directory) + return func() tea.Msg { + return tui.ErrorMsg(fmt.Sprintf("Error copying grade file: %s", gradeFileName)) + } +} + +func checkIfDirectoryIsAGitRepo(directory os.DirEntry, sourceDirectory string) bool { + baseDir, _ := os.Getwd() + fullpath, _ := filepath.Abs(filepath.Join(sourceDirectory, directory.Name())) + if err := os.Chdir(fullpath); err != nil { + return false + } + cmd := exec.Command("git", "rev-parse", "--show-toplevel") + if out, err := cmd.Output(); err == nil { + _ = os.Chdir(baseDir) + if strings.Compare(string(out), fullpath+"\n") == 0 { + return true + } + } + _ = os.Chdir(baseDir) + return false +} diff --git a/internal/pull.go b/internal/pull.go new file mode 100644 index 0000000..4191cb0 --- /dev/null +++ b/internal/pull.go @@ -0,0 +1,203 @@ +// Package internal +package internal + +/* +Copyright © 2022-2024 Emerson Ribeiro de Mello +*/ + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/progress" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/emersonmello/claro/internal/tui" +) + +type statePull int + +const ( + initialPull statePull = iota + pullDir +) + +type PullModel struct { + state statePull + submissionsDirectory string + repositories []os.DirEntry + totalPulled int + index int + styles tui.ClaroStyles + keyMap *tui.KeyMap + help help.Model + progress progress.Model + done bool + width int + height int +} + +func NewPullModel(directory string) PullModel { + styles := tui.CreateDefaultStyles() + keys := tui.ClaroKeyMap() + h := help.New() + p := progress.New( + progress.WithDefaultGradient(), + progress.WithWidth(50), + progress.WithoutPercentage(), + ) + return PullModel{ + state: initialPull, + submissionsDirectory: directory, + totalPulled: 0, + index: 0, + styles: styles, + keyMap: keys, + help: h, + progress: p, + done: false, + width: 0, + height: 0, + } +} + +func (m PullModel) Init() tea.Cmd { + cmd := getReposDirectoryList(m.submissionsDirectory) + return cmd +} + +func (m PullModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc", "q": + return m, tea.Quit + } + } + switch m.state { + case initialPull: + return initialPullUpdate(msg, m) + case pullDir: + return pullUpdate(msg, m) + } + return m, nil +} + +func initialPullUpdate(msg tea.Msg, m PullModel) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tui.AssignmentDirError: + cmd := tea.Printf("%s", msg) + return m, tea.Sequence(cmd, tea.Quit) + case []os.DirEntry: + m.repositories = msg + if len(m.repositories) > 0 { + m.state = pullDir + m.index = 0 + fullpath, _ := filepath.Abs(filepath.Join(m.submissionsDirectory, m.repositories[m.index].Name())) + return m, tea.Sequence(tea.Printf("Pulling %d repositories\n", len(m.repositories)), gitPullCmd(fullpath, m.repositories[m.index].Name())) + } else { + return m, tea.Sequence(tea.Printf(tui.ErrorStyle.Render(fmt.Sprintf("No repositories found in %s\n", m.submissionsDirectory))), tea.Quit) + } + + } + return m, nil +} + +func pullUpdate(msg tea.Msg, m PullModel) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tui.SuccessfullMsg, tui.SuccessfullPullMsg: + m.totalPulled++ + cmd := tea.Printf("%s %s", tui.CheckMark, msg) + if m.index >= len(m.repositories)-1 { + m.done = true + return m, tea.Sequence(cmd, tea.Quit) + } + m.index++ + fullpath, _ := filepath.Abs(filepath.Join(m.submissionsDirectory, m.repositories[m.index].Name())) + return m, tea.Sequence(cmd, gitPullCmd(fullpath, m.repositories[m.index].Name())) + case tui.ErrorMsg: + reason := lipgloss.NewStyle().Foreground(lipgloss.Color("#783D38")).Italic(true).SetString(string(msg)).Render() + cmd := tea.Printf("%s %s %s", tui.ErrorMark, m.repositories[m.index].Name(), reason) + if m.index >= len(m.repositories)-1 { + m.done = true + return m, tea.Sequence(cmd, tea.Quit) + } + m.index++ + fullpath, _ := filepath.Abs(filepath.Join(m.submissionsDirectory, m.repositories[m.index].Name())) + return m, tea.Sequence(cmd, gitPullCmd(fullpath, m.repositories[m.index].Name())) + case progress.FrameMsg: + newModel, cmd := m.progress.Update(msg) + if newModel, ok := newModel.(progress.Model); ok { + m.progress = newModel + } + return m, cmd + } + return m, nil +} + +// View renders the current view of the PullModel based on its state. +// +// If the state is `pullDir` and repositories are available, it will display +// the current repository being processed. If all repositories have been pulled, +// it will display a message indicating the total number of repositories pulled. +// +// Returns a string representing the current view of the PullModel. +func (m PullModel) View() string { + if m.state == pullDir { + return m.PullView() + } + return "" +} + +func (m PullModel) PullView() string { + if m.done { + return tui.DoneStyle.Render(fmt.Sprintf("Pulled %d repositories\n", m.totalPulled)) + } + n := len(m.repositories) + w := lipgloss.Width(fmt.Sprintf("%d", n)) + count := fmt.Sprintf(" %*d/%*d", w, m.index+1, w, n) + per := float64(m.index) / float64(len(m.repositories)-1) + prog := m.progress.ViewAs(per) + + repository := tui.CurrentRepositoryStyle.Render(m.repositories[m.index].Name()) + info := lipgloss.NewStyle().Render(fmt.Sprintf("%s %s ", tui.BowtieMark, repository)) + newLine := lipgloss.NewStyle().Render("\n") + return info + newLine + " " + prog + count + newLine +} + +// getReposDirectoryList returns a tea.Cmd that lists all directories in the given source directory. +// If the source directory starts with "~", it will be expanded to the user's home directory. +// If the source directory does not exist, it returns a message indicating the error. +// +// Parameters: +// - sourceDirectory: the path to the directory to list. +// +// Returns: +// - tea.Cmd: a command that, when executed, returns a tea.Msg containing a list of directories. +func getReposDirectoryList(sourceDirectory string) tea.Cmd { + return func() tea.Msg { + if strings.HasPrefix(sourceDirectory, "~") { + dirname, _ := os.UserHomeDir() + sourceDirectory = filepath.Join(dirname, sourceDirectory[1:]) + } + if _, err := os.Stat(sourceDirectory); os.IsNotExist(err) { + return tui.AssignmentDirError(tui.ErrorStyle.Render("The assignment directory does not exist. Please run the clone command first.\n")) + } + entries, err := os.ReadDir(sourceDirectory) + if err != nil { + return tui.AssignmentDirError(tui.ErrorStyle.Render(fmt.Sprintf("Failed to read the directory: %v\n", err))) + } + onlyDirs := entries[:0] + for _, entry := range entries { + if entry.IsDir() { + onlyDirs = append(onlyDirs, entry) + } + } + return onlyDirs + } +} diff --git a/internal/push.go b/internal/push.go new file mode 100644 index 0000000..23ad5d8 --- /dev/null +++ b/internal/push.go @@ -0,0 +1,252 @@ +// Package internal +package internal + +/* +Copyright © 2022-2024 Emerson Ribeiro de Mello +*/ + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/progress" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/emersonmello/claro/internal/tui" +) + +type statePush int + +const ( + initialPush = iota + pushDir +) + +type pair struct { + repository os.DirEntry + gradeFilename os.DirEntry +} + +type repo struct { + repoMap map[string]pair + repositories []os.DirEntry +} + +type PushModel struct { + state statePush + submissionsDirectory string + repos repo + totalPushed int + index int + styles tui.ClaroStyles + keyMap *tui.KeyMap + help help.Model + progress progress.Model + done bool + width int + height int +} + +func NewPushModel(directory string) PushModel { + styles := tui.CreateDefaultStyles() + keys := tui.ClaroKeyMap() + h := help.New() + p := progress.New( + progress.WithDefaultGradient(), + progress.WithWidth(50), + progress.WithoutPercentage(), + ) + return PushModel{ + state: initialPush, + submissionsDirectory: directory, + totalPushed: 0, + index: 0, + styles: styles, + keyMap: keys, + help: h, + progress: p, + done: false, + width: 0, + height: 0, + } +} + +func (m PushModel) Init() tea.Cmd { + return getRepositoriesAndGradeFiles(m.submissionsDirectory) +} + +func (m PushModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc", "q": + return m, tea.Quit + } + } + switch m.state { + case initialPush: + return initialPushUpdate(msg, m) + case pushDir: + return pushUpdate(msg, m) + } + return m, nil +} + +func initialPushUpdate(msg tea.Msg, m PushModel) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case repo: + m.repos = msg + if len(m.repos.repoMap) > 0 { + m.state = pushDir + fullpath, _ := filepath.Abs(filepath.Join(m.submissionsDirectory, m.repos.repoMap[m.repos.repositories[m.index].Name()].repository.Name())) + return m, tea.Sequence(tea.Printf("Grading submissions\n"), gitPullCmd(fullpath, m.repos.repoMap[m.repos.repositories[m.index].Name()].repository.Name())) + } else { + return m, tea.Sequence(tea.Printf(tui.ErrorStyle.Render(fmt.Sprintf("No repositories or grade files found in %s\nPlease see the help for more information\n", m.submissionsDirectory))), tea.Quit) + } + case tui.AssignmentDirError: + cmd := tea.Printf("%s", msg) + return m, tea.Sequence(cmd, tea.Quit) + + } + return m, nil +} + +func pushUpdate(msg tea.Msg, m PushModel) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + // Handle successful push message + case tui.SuccessfullMsg: + m.totalPushed++ + cmd := tea.Printf("%s %s ", tui.CheckMark, msg) + if m.index >= len(m.repos.repositories)-1 { + // If all repositories have been processed, mark as done and quit + m.done = true + return m, tea.Sequence(cmd, tea.Quit) + } + // Move to the next repository + m.index++ + fullpath, _ := filepath.Abs(filepath.Join(m.submissionsDirectory, m.repos.repoMap[m.repos.repositories[m.index].Name()].repository.Name())) + return m, tea.Sequence(cmd, gitPullCmd(fullpath, m.repos.repoMap[m.repos.repositories[m.index].Name()].repository.Name())) + // Handle successful pull message + case tui.SuccessfullPullMsg: + fullpath, _ := filepath.Abs(filepath.Join(m.submissionsDirectory, m.repos.repoMap[m.repos.repositories[m.index].Name()].repository.Name())) + return m, gitCommitAndPushCmd(fullpath, m.repos.repositories[m.index].Name(), m.repos.repoMap[m.repos.repositories[m.index].Name()]) + case tui.ErrorMsg: + reason := lipgloss.NewStyle().Foreground(lipgloss.Color("#783D38")).Italic(true).Render(string(msg)) + cmd := tea.Printf("%s %s %s", tui.ErrorMark, m.repos.repositories[m.index].Name(), reason) + if m.index >= len(m.repos.repositories)-1 { + m.done = true + return m, tea.Sequence(cmd, tea.Quit) + } + m.index++ + fullpath, _ := filepath.Abs(filepath.Join(m.submissionsDirectory, m.repos.repoMap[m.repos.repositories[m.index].Name()].repository.Name())) + return m, tea.Sequence(cmd, gitPullCmd(fullpath, m.repos.repoMap[m.repos.repositories[m.index].Name()].repository.Name())) + case tui.ErrorPullMsg: + return m, tea.Sequence(tea.Printf(m.styles.ErrorText.Render(string(msg))), tea.Quit) + case progress.FrameMsg: + newModel, cmd := m.progress.Update(msg) + if newModel, ok := newModel.(progress.Model); ok { + m.progress = newModel + } + return m, cmd + } + return m, nil +} + +func (m PushModel) View() string { + if m.state == pushDir { + return m.PushView() + } + return "" +} + +func (m PushModel) PushView() string { + if m.done { + return tui.DoneStyle.Render(fmt.Sprintf("Graded %d submissions\n", m.totalPushed)) + } + n := len(m.repos.repositories) + w := lipgloss.Width(fmt.Sprintf("%d", n)) + count := fmt.Sprintf(" %*d/%*d", w, m.index+1, w, n) + per := float64(m.index) / float64(len(m.repos.repositories)-1) + prog := m.progress.ViewAs(per) + + repository := tui.CurrentRepositoryStyle.Render(m.repos.repositories[m.index].Name()) + info := lipgloss.NewStyle().Render(fmt.Sprintf("%s %s ", tui.BowtieMark, repository)) + newLine := lipgloss.NewStyle().Render("\n") + return info + newLine + " " + prog + count + newLine +} + +// getRepositoriesAndGradeFiles returns a tea.Cmd that lists all directories and grade files in the given source directory. +// It matches grade files with the corresponding repository directories based on a specific filename pattern. +// +// Parameters: +// - sourceDirectory: the path to the directory to list. +// +// Returns: +// - tea.Cmd: a command that, when executed, returns a tea.Msg containing a repo struct with matched repositories and grade files. +func getRepositoriesAndGradeFiles(sourceDirectory string) tea.Cmd { + return func() tea.Msg { + fileNamePattern := "^(grade-).*(\\.md)$" + regexpPattern, _ := regexp.Compile(fileNamePattern) + + r := repo{ + repoMap: make(map[string]pair), + repositories: []os.DirEntry{}, + } + + if strings.HasPrefix(sourceDirectory, "~") { + dirname, _ := os.UserHomeDir() + sourceDirectory = filepath.Join(dirname, sourceDirectory[1:]) + } + if _, err := os.Stat(sourceDirectory); os.IsNotExist(err) { + return tui.AssignmentDirError(tui.ErrorStyle.Render("The assignment directory does not exist. Please run the clone command first.\n")) + + } + entries, err := os.ReadDir(sourceDirectory) + if err != nil { + return tui.AssignmentDirError(tui.ErrorStyle.Render(fmt.Sprintf("Failed to read the directory: %v\n", err))) + } + + regularEntries := make([]os.DirEntry, 0, len(entries)) + directories := make([]os.DirEntry, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + if !checkIfDirectoryIsAGitRepo(entry, sourceDirectory) { + continue + } + directories = append(directories, entry) + } else { + if info, e := entry.Info(); e == nil { + if info.Mode().IsRegular() { + regularEntries = append(regularEntries, entry) + } + } + } + } + + for _, entry := range directories { + for _, file := range regularEntries { + if regexpPattern.MatchString(file.Name()) { + fN := strings.Split(file.Name(), "grade-") + if len(fN) == 2 { + nN := strings.Split(fN[1], ".md") + if len(nN) == 2 { + key := nN[0] + if key == entry.Name() { + r.repoMap[key] = pair{repository: entry, gradeFilename: file} + r.repositories = append(r.repositories, entry) + break + } + } + } + } + } + } + return r + } +} diff --git a/internal/tui/delegate.go b/internal/tui/delegate.go new file mode 100644 index 0000000..9f19762 --- /dev/null +++ b/internal/tui/delegate.go @@ -0,0 +1,59 @@ +// Package tui provides the text user interface for the claro CLI tool. +package tui + +/* +Copyright © 2022-2024 Emerson Ribeiro de Mello +*/ + +import ( + "fmt" + legal "io" + "strings" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" +) + +// Item represents an item in the list +type Item struct { + Id string + Name string + Url string +} + +// ItemDelegate represents the delegate for the list +type ItemDelegate struct { + styles *ClaroStyles + keys *KeyMap +} + +// NewItemDelegate creates a new ItemDelegate +func NewItemDelegate(styles *ClaroStyles, keys *KeyMap) *ItemDelegate { + return &ItemDelegate{ + styles: styles, + keys: keys, + } +} + +func (i Item) FilterValue() string { return i.Name } +func (d ItemDelegate) Height() int { return 1 } +func (d ItemDelegate) Spacing() int { return 0 } +func (d ItemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } +func (d ItemDelegate) Render(w legal.Writer, m list.Model, index int, listItem list.Item) { + i, ok := listItem.(Item) + if !ok { + return + } + str := fmt.Sprintf("%s", i.Name) + + fn := d.styles.Item.Render + if index == m.Index() { + fn = func(s ...string) string { + return d.styles.SelectedItem.Render("> " + strings.Join(s, " ")) + } + } + _, err := fmt.Fprint(w, fn(str)) + if err != nil { + return + } +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go new file mode 100644 index 0000000..8e17215 --- /dev/null +++ b/internal/tui/keys.go @@ -0,0 +1,33 @@ +// Package tui provides the text user interface for the claro CLI tool. +package tui + +/* +Copyright © 2022-2024 Emerson Ribeiro de Mello +*/ + +import "github.com/charmbracelet/bubbles/key" + +type KeyMap struct { + Left key.Binding + Right key.Binding +} + +// ShortHelp returns keybindings to be shown in the mini help view. It's part +// of the key.Map interface. +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Left, k.Right} +} + +// ClaroKeyMap returns the keybindings for the Claro TUI +func ClaroKeyMap() *KeyMap { + return &KeyMap{ + Left: key.NewBinding( + key.WithKeys("left", "h"), + key.WithHelp("←/h", "to go back"), + ), + Right: key.NewBinding( + key.WithKeys("right", "enter"), + key.WithHelp("→/enter", "confirm"), + ), + } +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go new file mode 100644 index 0000000..bf1a186 --- /dev/null +++ b/internal/tui/styles.go @@ -0,0 +1,48 @@ +// Package tui provides the text user interface for the claro CLI tool. +package tui + +/* +Copyright © 2022-2024 Emerson Ribeiro de Mello +*/ + +import ( + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/lipgloss" +) + +var ( + colorLight = "#43BF6D" + colorDark = "#73F59F" + CurrentRepositoryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("211")) + DoneStyle = lipgloss.NewStyle().Margin(1, 2) + TextStyle = lipgloss.NewStyle().Margin(1, 2) + ErrorStyle = lipgloss.NewStyle().Margin(1, 2).Foreground(lipgloss.AdaptiveColor{Light: "#FF2D27", Dark: "#FF644E"}) + CheckMark = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).SetString("✓") + BowtieMark = lipgloss.NewStyle().Foreground(lipgloss.Color("#F8BA00")).SetString("⧖") + ErrorMark = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF2D27")).SetString("𐄂") +) + +// ClaroStyles represents the styles used in the Claro TUI +type ClaroStyles struct { + Title lipgloss.Style + Item lipgloss.Style + SelectedItem lipgloss.Style + Pagination lipgloss.Style + Help lipgloss.Style + QuitText lipgloss.Style + ErrorText lipgloss.Style + NormalText lipgloss.Style +} + +// CreateDefaultStyles creates the default styles for the Claro TUI +func CreateDefaultStyles() (s ClaroStyles) { + s.Title = lipgloss.NewStyle().Padding(0, 1).Foreground(lipgloss.Color("#1B42A3")) + s.Item = lipgloss.NewStyle().PaddingLeft(4) + s.SelectedItem = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: colorLight, Dark: colorDark}).PaddingLeft(2) + s.Pagination = list.DefaultStyles().PaginationStyle.PaddingLeft(4) + s.Help = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1) + s.QuitText = lipgloss.NewStyle().Margin(1, 0, 2, 4) + s.NormalText = lipgloss.NewStyle().Margin(1, 0, 2, 4) + s.ErrorText = lipgloss.NewStyle().Margin(1, 0, 2, 4).Foreground(lipgloss.AdaptiveColor{Light: "#FF2D27", Dark: "#FF644E"}) + return s +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go new file mode 100644 index 0000000..df1c30b --- /dev/null +++ b/internal/tui/tui.go @@ -0,0 +1,75 @@ +// Package tui provides the text user interface for the claro CLI tool. +package tui + +/* +Copyright © 2022-2024 Emerson Ribeiro de Mello +*/ + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/list" + "github.com/github/gh-classroom/pkg/classroom" +) + +type ClassroomList []classroom.Classroom +type AssignmentsList []classroom.Assignment + +type SuccessfullMsg string +type NewCommits string +type ErrorMsg string +type SuccessfullPullMsg string +type ErrorPullMsg string +type AssignmentDirError string + +var GitHubCliInstalled bool +var UserGitHubPAT string + +const ( + DefaultWidth = 80 + useMsg = "\nThe directory should have been created using the 'clone' command and should include:" + + "\n- Subdirectories, each named after a student's repository (e.g., assignment-01-JohnDoeStudent)." + + "\n- Markdown files, each named with the pattern 'grade-.md' (e.g., grade-assignment-01-JohnDoeStudent.md)." +) + +func UseErrorMsg(cmdName string) string { + return ErrorStyle.Render(fmt.Sprintf("The '%s' command requires a directory containing student repositories and their corresponding grade files.", cmdName)) + + useMsg + + fmt.Sprintf("\n\nEnsure that this structure is followed for the '%s' command to work correctly.\n", cmdName) +} + +func LongHelpMsg(shortHelpMsg string) string { + return fmt.Sprintf("%s\n%s", shortHelpMsg, useMsg) +} + +// MakeClassroomList creates a list of classrooms +func MakeClassroomList(listC ClassroomList) []list.Item { + var items []list.Item + for _, c := range listC { + if c.Archived == false { + items = append(items, Item{Id: fmt.Sprintf("%d", c.Id), Name: c.Name, Url: c.Url}) + } + } + return items +} + +// MakeAssignmentsList creates a list of assignments +func MakeAssignmentsList(listA AssignmentsList) []list.Item { + var items []list.Item + for _, c := range listA { + items = append(items, Item{Id: fmt.Sprintf("%d", c.Id), Name: c.Title}) + } + return items +} + +// FormatList formats a list using the claro default styles +func FormatList(l list.Model, title string) list.Model { + styles := CreateDefaultStyles() + l.Title = title + l.SetShowStatusBar(false) + l.SetFilteringEnabled(true) + l.Styles.Title = styles.Title + l.Styles.PaginationStyle = styles.Pagination + l.Styles.HelpStyle = styles.Help + return l +} diff --git a/internal/utils.go b/internal/utils.go new file mode 100644 index 0000000..975a7d2 --- /dev/null +++ b/internal/utils.go @@ -0,0 +1,47 @@ +// Package internal +package internal + +import ( + "io" + "os" + "os/exec" +) + +/* +Copyright © 2022-2024 Emerson Ribeiro de Mello +*/ + +func executeCommand(cmd *exec.Cmd, directory string) ([]byte, error) { + cmd.Dir = directory + return cmd.Output() +} + +func copyFile(src, dst string) error { + sourceFile, err := os.Open(src) + if err != nil { + return err + } + defer func(sourceFile *os.File) { + _ = sourceFile.Close() + }(sourceFile) + + destinationFile, err := os.Create(dst) + if err != nil { + return err + } + defer func(destinationFile *os.File) { + _ = destinationFile.Close() + }(destinationFile) + + _, err = io.Copy(destinationFile, sourceFile) + if err != nil { + return err + } + + err = destinationFile.Sync() + if err != nil { + return err + } + + return nil +} diff --git a/main.go b/main.go index 7afed4a..cd592dd 100644 --- a/main.go +++ b/main.go @@ -1,23 +1,21 @@ /* -Copyright © 2022 Emerson Ribeiro de Mello +Copyright © 2022-2024 Emerson Ribeiro de Mello -claro is a GitHub Classroom CLI tool that offers a simple interface that allows the teacher to clone all student repositories at once for grading and then send grades at once to all these repositories. +claro is a GitHub Classroom CLI tool that provides a simple interface for teachers to clone all student repositories at once for grading and then send grades to all these repositories simultaneously. Usage: -claro [command] + claro [command] Available Commands: - clone Clone all students assignment repositories in an organization - config Configure claro's properties (github token, commit message, etc.) - help Help about any command - list List all student assignment repositories in an organization - pull Incorporate changes from students' remote repositories into local copy - push Add and commit the grading file in each student repository -claro consumes Github's REST API to fetch the list of repositories (assignments) from a GitHub Classroom organization. So, claro uses a GitHub Personal Access Token. - -claro will try to get GitHub Personal Access Token from: (1) operating system keyring; (2) environment var (GH_TOKEN); (3) claro's config file (default $HOME/.claro.env) + clone Clone all students assignments from a GitHub Classroom + completion Generate the autocompletion script for the specified shell + config Configure claro's properties (commit message, filename, etc) + help Help about any command + pull Incorporate changes from students' remote repositories into local copy + push Add, commit, and push the grading file to each student's remote repository + token add or remove a claro's GitHub Personal Access Token in the OS Keychain */ package main @@ -26,17 +24,5 @@ import ( ) func main() { - cmd.Execute() + _ = cmd.Execute() } - -// func init() { -// c := make(chan os.Signal) -// signal.Notify(c, os.Interrupt, syscall.SIGTERM) -// go func() { -// // handling CTRL + C signal -// <-c -// should I remove the output directory created by clone command? -// if yes, use ioutil.ReadDir() and os.RemoveAll() -// os.Exit(1) -// }() -// } diff --git a/utils/credentials.go b/utils/credentials.go deleted file mode 100644 index 7bb7d44..0000000 --- a/utils/credentials.go +++ /dev/null @@ -1,97 +0,0 @@ -// A set of utility functions to handle with user's github personal access token -// -// The user's GitHub Personal Access Token could be stored in operating system keyring, environment var or claro's config file (default $HOME/.claro.env) -package utils - -import ( - "bufio" - "fmt" - "os" - "strings" - - "github.com/spf13/viper" - - "github.com/zalando/go-keyring" -) - -const service = "a github classroom cli" -const user = "claro" - -// Delete user's github personal access token from operating system keyring -func DeletePasswordItem() error { - return keyring.Delete(service, user) -} - -// Store a user's github personal access token in operating system keyring -func CreateKey(password string, removeIfExist bool) error { - if removeIfExist { - DeletePasswordItem() - } - return keyring.Set(service, user, password) -} - -// Get the user's github personal access token from operating system keyring -func GetPassword() (string, error) { - secret, err := keyring.Get(service, user) - return secret, err -} - -// To get user's github personal access token -func GetAndSaveToken(save bool) string { - - // form operating system keyring - ghToken, err := GetPassword() - - if ghToken == "" || err != nil { - // from environment var - ghToken = viper.GetString("gh_token") - if ghToken == "" { - // ok, user should provides it right now - ghToken = ReadTokenFromStdIn() - if !save { - if !PromptUser("Would you like to save this token in the os keyring or in the config file?(Y/n)? ", "yes") { - return ghToken - } - } - SaveGHToken(ghToken) - } else { - fmt.Println("Got GitHub Personal Access Token from envvar or claro config file") - } - } else { - fmt.Println("Got GitHub Personal Access Token from OS keyring") - } - return ghToken -} - -// Ask user about github personal access token -func ReadTokenFromStdIn() string { - reader := bufio.NewReader(os.Stdin) - fmt.Print("Inform your github personal access token: ") - text, _ := reader.ReadString('\n') - // convert CRLF to LF - return strings.Replace(text, "\n", "", -1) -} - -// Try to save the github personal access token: (1) os keyring; (2) claro config file -func SaveGHToken(ghToken string) { - tokenKC, err := GetPassword() - if tokenKC != "" { - if PromptUser("A github personal access token for claro is already stored in the operating system keyring.\nWould you like to override it? (y/N): ", "no") { - return - } - } - if err != nil { - e := CreateKey(ghToken, true) - if e == nil { - fmt.Println("Your github personal access token has been successfully set in the operating system keyring!") - } else { - if PromptUser("Could not store token in operating system keyring.\nWould you like to save it in the config file?(Y/n):", "yes") { - WriteConfigFile("gh_token", ghToken, "Your github personal access token has been successfully set in the configuration file!") - } - } - } else { - if PromptUser("Could not store token in operating system keyring.\nWould you like to save it in the config file?(Y/n):", "yes") { - WriteConfigFile("gh_token", ghToken, "Your github personal access token has been successfully set in the configuration file!") - } - } -} diff --git a/utils/general.go b/utils/general.go deleted file mode 100644 index a5ba1d7..0000000 --- a/utils/general.go +++ /dev/null @@ -1,80 +0,0 @@ -package utils - -import ( - "bufio" - "fmt" - "os" - "os/exec" - "regexp" - "strings" - - "github.com/pterm/pterm" - "github.com/spf13/viper" -) - -// Struct to store config file vars -type Config struct { - CommitMessage string `mapstructure:"COMMIT_MESSAGE"` - GradeFileName string `mapstructure:"GRADE_FILENAME"` - GradeTitle string `mapstructure:"GRADE_TITLE"` - GradeString string `mapstructure:"GRADE_STRING"` - GHToken string `mapstructure:"GH_TOKEN"` -} - -func CheckExternalsCommands() { - if _, err := exec.LookPath("git"); err != nil { - pterm.Error.Println("I can't find 'git' command. Please, be sure that 'git' is installed and in the user PATH") - os.Exit(1) - } -} - -// A Yes/No prompt for user -func PromptUser(message string, question string) bool { - regexpYes := "([Yy](es)?|^$)" - regexpNo := "([Nn](o)?|^$)" - var match bool - - reader := bufio.NewReader(os.Stdin) - fmt.Print(message) - text, _ := reader.ReadString('\n') - text = strings.Replace(text, "\n", "", -1) - - switch question { - case "yes": - match, _ = regexp.MatchString(regexpYes, text) - case "no": - match, _ = regexp.MatchString(regexpNo, text) - } - return match -} - -// Write key/value in the config file -func WriteConfigFile(key, value, returnMessage string) { - viper.Set(key, value) - if viper.WriteConfig() != nil { - viper.SafeWriteConfig() - } - fmt.Println(returnMessage) -} - -// user viper to read envvar and returns a Config struct -func GetConf() *Config { - viper.SetConfigType("env") - home, _ := os.UserHomeDir() - viper.AddConfigPath(home) - viper.SetConfigName(".claro") - - conf := Config{} - - viper.SetDefault("grade_filename", "GRADING.md") - viper.SetDefault("grade_title", "Feedback") - viper.SetDefault("grade_string", "Grade: ") - viper.SetDefault("commit_message", "Graded project, the file containing the grade is in the root directory") - - // read in environment variables that match - viper.AutomaticEnv() - viper.ReadInConfig() - - viper.Unmarshal(&conf) - return &conf -} diff --git a/utils/gh.go b/utils/gh.go deleted file mode 100644 index 216896b..0000000 --- a/utils/gh.go +++ /dev/null @@ -1,78 +0,0 @@ -// A set of utility functions to handle with GitHub REST API -package utils - -import ( - "context" - "os" - "strings" - - "github.com/google/go-github/v47/github" - "github.com/pterm/pterm" - "golang.org/x/oauth2" -) - -type RepData struct { - Name string - Url string - Size uint64 -} - -// Get repository list from an organization that has a specific prefix -func GetRepositoryList(org, rep string) []RepData { - ghToken := GetAndSaveToken(false) - - s, _ := pterm.DefaultSpinner.Start("Searching for '" + rep) - repositories, response, err := getRepoOrg(ghToken, org, rep) - if err != nil { - s.Fail(response.Request.URL.String() + " -> " + response.Status + "\n") - os.Exit(1) - } - s.Success() - return repositories -} - -// Get all repositories from an organization that has a specific prefix -func getRepoOrg(ghToken string, org string, repoPrefix string) ([]RepData, *github.Response, error) { - ctx := context.Background() - ts := oauth2.StaticTokenSource( - &oauth2.Token{AccessToken: ghToken}, - ) - tc := oauth2.NewClient(ctx, ts) - client := github.NewClient(tc) - - reposData := make([]RepData, 0) - - nextPage := 1 - lastPage := int(^uint(0) >> 1) - - for nextPage <= lastPage { - page := github.ListOptions{ - Page: nextPage, - PerPage: 100, - } - repoOpts := github.RepositoryListByOrgOptions{ - Type: "all", - Sort: "full_name", - Direction: "desc", - ListOptions: page, - } - // lists the repositories for an organization - repos, response, err := client.Repositories.ListByOrg(ctx, org, &repoOpts) - - if err != nil { - return nil, response, err - } - lastPage = response.LastPage - - for _, r := range repos { - url := github.Stringify(r.HTMLURL) - url = strings.ReplaceAll(url, "\"", "") - if strings.HasPrefix(*r.Name, repoPrefix) { - n := RepData{Name: *r.Name, Url: url, Size: uint64(*r.Size)} - reposData = append(reposData, n) - } - } - nextPage++ - } - return reposData, nil, nil -} diff --git a/utils/git.go b/utils/git.go deleted file mode 100644 index daa53f7..0000000 --- a/utils/git.go +++ /dev/null @@ -1,144 +0,0 @@ -// A set of utility functions to handle with git command -package utils - -import ( - "bytes" - "fmt" - "io" - "io/fs" - "os" - "os/exec" - - "github.com/pterm/pterm" - "github.com/spf13/viper" -) - -// To clone repositories from GitHub -func CloneRepositories(outputDir string, repositories []RepData) ([]string, []string) { - - successCloned := make([]string, 0) - errorCloned := make([]string, 0) - - p, _ := pterm.DefaultProgressbar.WithTotal(len(repositories)).WithRemoveWhenDone(true).WithTitle("Cloning students repositories").Start() - - for _, r := range repositories { - p.UpdateTitle("Cloning " + r.Name + " ") - cmd := exec.Command("git", "clone", r.Url) - cmd.Dir = outputDir - if err := cmd.Run(); err == nil { - pterm.Success.Println("Cloning " + r.Name) - successCloned = append(successCloned, r.Name) - } else { - pterm.Error.Printfln("To clone " + r.Name) - errorCloned = append(errorCloned, err.Error()) - } - p.Increment() - } - return successCloned, errorCloned -} - -// To add and commit a grade file in a GitHub repository -func AddAndCommitGradeFile(filename, repositoryDir string, p *pterm.ProgressbarPrinter) { - gradeFileName := viper.GetString("grade_filename") - - p.UpdateTitle("Push '" + gradeFileName + "' to " + repositoryDir) - if src, err := os.Open(filename); err == nil { - defer src.Close() - if err := os.Chdir(repositoryDir); err == nil { - if dst, err := os.Create(gradeFileName); err == nil { - defer dst.Close() - if _, err = io.Copy(dst, src); err == nil { - - exec.Command("git", "reset").Run() - exec.Command("git", "add", gradeFileName).Run() - - if o, _ := exec.Command("git", "status", "--porcelain").CombinedOutput(); string(o) == "" { - pterm.Warning.Println(repositoryDir + ": nothing to commit, working tree clean") - } else { - - if e := exec.Command("git", "commit", "-m", viper.GetString("commit_message")).Run(); e == nil { - if e := exec.Command("git", "pull", "--rebase").Run(); e != nil { - pterm.Error.Println(repositoryDir + ": could not execute git pull") - } else { - if e := exec.Command("git", "push", "--porcelain").Run(); e != nil { - pterm.Error.Println(repositoryDir + ": could not execute git push") - } else { - pterm.Success.Println("Push '" + gradeFileName + "' to " + repositoryDir) - } - } - } else { - pterm.Error.Println(repositoryDir + ": could not execute git commit") - } - } - } else { - pterm.Error.Println("Could not copy the file: " + filename + " to " + repositoryDir + "/" + gradeFileName) - } - } else { - pterm.Error.Println("Could not create the file: " + filename) - } - os.Chdir("..") - } else { - pterm.Error.Println("Could not access the directory: " + repositoryDir) - } - } else { - pterm.Error.Println("Could not open the file: " + filename) - } -} - -// To pull all GitHub repositories inside a specific local directory -func Pull(files []fs.DirEntry, directory string) { - e := os.Chdir(directory) - checkError(e) - p := pterm.DefaultProgressbar.WithRemoveWhenDone(true) - p.ShowPercentage = false - p.ShowCount = false - p.ShowCount = false - p.ShowElapsedTime = false - p.Start() - - previousPrefix := pterm.Success.Prefix - newCommitPrefix := pterm.Prefix{Text: "NEW COMMITS", Style: pterm.NewStyle(pterm.BgLightYellow, pterm.FgBlack)} - - for _, f := range files { - if f.IsDir() { - p.UpdateTitle("Pull " + f.Name()) - cmd := exec.Command("git", "stash", "--include-untracked") - cmd.Dir = f.Name() - err := cmd.Run() - if err != nil { - pterm.Error.Println("Could not run git stash command. Is the '" + f.Name() + "' directory a git repository?") - } else { - cmd = exec.Command("git", "rev-list", "--all", "--count") - cmd.Dir = f.Name() - totalCommitsBeforePull, _ := cmd.Output() - cmd = exec.Command("git", "pull", "--rebase") - cmd.Dir = f.Name() - err = cmd.Run() - if err != nil { - pterm.Error.Println(f.Name() + " is not clean!") - } else { - cmd = exec.Command("git", "rev-list", "--all", "--count") - cmd.Dir = f.Name() - totalCommitsAfterPull, _ := cmd.Output() - cmd := exec.Command("git", "stash", "pop") - cmd.Dir = f.Name() - _ = cmd.Run() - if !bytes.Equal(totalCommitsAfterPull, totalCommitsBeforePull) { - pterm.Success.Prefix = newCommitPrefix - } - pterm.Success.Println("Pull " + f.Name()) - pterm.Success.Prefix = previousPrefix - } - } - } - } - fmt.Println() -} - -// check error -func checkError(e error) { - if e != nil { - fmt.Printf("Error: %s\n", e) - os.Exit(1) - } -}