diff --git a/cmd/root/root.go b/cmd/root/root.go index e19a6da..cb3d28a 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -10,6 +10,7 @@ import ( "github.com/open-sauced/pizza-cli/cmd/bake" repoquery "github.com/open-sauced/pizza-cli/cmd/repo-query" "github.com/open-sauced/pizza-cli/cmd/version" + "github.com/open-sauced/pizza-cli/cmd/show" "github.com/open-sauced/pizza-cli/pkg/api" ) @@ -30,6 +31,7 @@ func NewRootCommand() (*cobra.Command, error) { cmd.AddCommand(repoquery.NewRepoQueryCommand()) cmd.AddCommand(auth.NewLoginCommand()) cmd.AddCommand(version.NewVersionCommand()) + cmd.AddCommand(show.NewShowCommand()) return cmd, nil } diff --git a/cmd/show/contributors.go b/cmd/show/contributors.go new file mode 100644 index 0000000..3e04ce2 --- /dev/null +++ b/cmd/show/contributors.go @@ -0,0 +1,190 @@ +package show + +import ( + "context" + "errors" + "fmt" + "sync" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + client "github.com/open-sauced/go-api/client" +) + +// ContributorModel holds all the information related to a contributor +type ContributorModel struct { + username string + userInfo *client.DbUser + userPrs []client.DbPullRequest +} + +type ( + // BackMsg: message to signal main model that we are back to dashboard when backspace is pressed + BackMsg struct{} + + // ContributorErrMsg: message to signal that an error occurred when fetching contributor information + ContributorErrMsg struct { + name string + err error + } +) + +// InitContributor: initializes the contributorModel +func InitContributor(contributorName string) (ContributorModel, error) { + var contributorModel ContributorModel + contributorModel.username = contributorName + + err := contributorModel.fetchUser() + if err != nil { + return contributorModel, err + } + + return contributorModel, nil +} + +func (m ContributorModel) Init() tea.Cmd { return nil } + +func (m ContributorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "backspace": + return m, func() tea.Msg { return BackMsg{} } + case "q", "esc", "ctrl+c", "ctrl+d": + return m, tea.Quit + } + } + return m, cmd +} + +func (m ContributorModel) View() string { + return m.drawContributorView() +} + +// fetchUser: fetches all the user information (general info, and pull requests) +func (m *ContributorModel) fetchUser() error { + var ( + wg sync.WaitGroup + errChan = make(chan error, 2) + ) + + wg.Add(2) + go func() { + defer wg.Done() + userInfo, err := fetchContributorInfo(m.username) + if err != nil { + errChan <- err + return + } + m.userInfo = userInfo + + }() + + go func() { + defer wg.Done() + userPRs, err := fetchContributorPRs(m.username) + if err != nil { + errChan <- err + return + } + m.userPrs = userPRs + }() + + wg.Wait() + close(errChan) + if len(errChan) > 0 { + var allErrors error + for err := range errChan { + allErrors = errors.Join(allErrors, err) + } + return allErrors + } + + return nil +} + +// fetchContributorInfo: fetches the contributor info +func fetchContributorInfo(name string) (*client.DbUser, error) { + config := client.NewConfiguration() + apiClient := client.NewAPIClient(config) + ctx := context.WithValue(context.Background(), client.ContextServerIndex, 1) + + resp, r, err := apiClient.UserServiceAPI.FindOneUserByUserame(ctx, name).Execute() + if err != nil { + return nil, err + } + + if r.StatusCode != 200 { + return nil, fmt.Errorf("HTTP failed: %d", r.StatusCode) + } + + return resp, nil +} + +// fetchContributorPRs: fetches the contributor pull requests +func fetchContributorPRs(name string) ([]client.DbPullRequest, error) { + config := client.NewConfiguration() + apiClient := client.NewAPIClient(config) + ctx := context.WithValue(context.Background(), client.ContextServerIndex, 1) + + resp, r, err := apiClient.UserServiceAPI.FindContributorPullRequests(ctx, name).Execute() + if err != nil { + return nil, err + } + + if r.StatusCode != 200 { + return nil, fmt.Errorf("HTTP failed: %d", r.StatusCode) + } + + return resp.Data, nil +} + +// drawContributorView: view of the contributor model +func (m *ContributorModel) drawContributorView() string { + return Viewport.Copy().Render(lipgloss.JoinVertical(lipgloss.Center, m.drawContributorInfo(), m.drawPullRequests())) +} + +// drawContributorInfo: view of the contributor info (open issues, pr velocity, pr count, maintainer) +func (m *ContributorModel) drawContributorInfo() string { + userOpenIssues := fmt.Sprintf("📄 Issues: %d", m.userInfo.OpenIssues) + isUserMaintainer := fmt.Sprintf("🔨 Maintainer: %t", m.userInfo.GetIsMaintainer()) + prVelocity := fmt.Sprintf("🔥 PR Velocity (30d): %d%%", m.userInfo.RecentPullRequestVelocityCount) + prCount := fmt.Sprintf("🚀 PR Count (30d): %d", m.userInfo.RecentPullRequestsCount) + + prStats := lipgloss.JoinVertical(lipgloss.Left, TextContainer.Render(prVelocity), TextContainer.Render(prCount)) + issuesAndMaintainer := lipgloss.JoinVertical(lipgloss.Center, TextContainer.Render(userOpenIssues), TextContainer.Render(isUserMaintainer)) + + contributorInfo := lipgloss.JoinHorizontal(lipgloss.Center, prStats, issuesAndMaintainer) + contributorView := lipgloss.JoinVertical(lipgloss.Center, m.userInfo.Login, contributorInfo) + + return SquareBorder.Render(contributorView) +} + +// drawPullRequests: view of the contributor pull requests (draws the last 5 pull requests) +func (m *ContributorModel) drawPullRequests() string { + if len(m.userPrs) == 0 { + return "" + } + + pullRequests := []string{} + var numberOfPrs int + + if len(m.userPrs) > 5 { + numberOfPrs = 5 + } else { + numberOfPrs = len(m.userPrs) + } + + for i := 0; i < numberOfPrs; i++ { + prContainer := TextContainer.Render(fmt.Sprintf("#%d %s\n%s\n(%s)", m.userPrs[i].Number, m.userPrs[i].GetFullName(), + m.userPrs[i].Title, m.userPrs[i].State)) + pullRequests = append(pullRequests, prContainer) + } + + formattedPrs := lipgloss.JoinVertical(lipgloss.Left, pullRequests...) + title := lipgloss.NewStyle().AlignHorizontal(lipgloss.Center).Render("✨ Latest Pull Requests") + + pullRequestView := lipgloss.JoinVertical(lipgloss.Center, title, formattedPrs) + return WidgetContainer.Render(pullRequestView) +} diff --git a/cmd/show/dashboard.go b/cmd/show/dashboard.go new file mode 100644 index 0000000..e6a056f --- /dev/null +++ b/cmd/show/dashboard.go @@ -0,0 +1,360 @@ +package show + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + "sync" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + client "github.com/open-sauced/go-api/client" +) + +const ( + newContributorsView = iota + alumniContributorsView +) + +// DashboardModel holds all the information related to the repository queried (issues, stars, new contributors, alumni contributors) +type DashboardModel struct { + newContributorsTable table.Model + alumniContributorsTable table.Model + RepositoryInfo *client.DbRepo + contributorErr string + tableView int + queryOptions [3]int + APIClient *client.APIClient + serverContext context.Context +} + +// SelectMsg: message to signal the main model that we want to go to the contributor model when 'enter' is pressed +type SelectMsg struct { + contributorName string +} + +// FetchRepoInfo: initializes the dashboard model +func InitDashboard(opts *Options) (DashboardModel, error) { + model := DashboardModel{} + err := validateShowQuery(opts) + if err != nil { + return model, err + } + ownerRepo := strings.Split(opts.RepoName, "/") + + resp, r, err := opts.APIClient.RepositoryServiceAPI.FindOneByOwnerAndRepo(opts.ServerContext, ownerRepo[0], ownerRepo[1]).Execute() + if err != nil { + return model, err + } + + if r.StatusCode != 200 { + return model, fmt.Errorf("HTTP status: %d", r.StatusCode) + } + + // configuring the dashboardModel + model.RepositoryInfo = resp + model.queryOptions = [3]int{opts.Page, opts.Limit, opts.Range} + model.APIClient = opts.APIClient + model.serverContext = opts.ServerContext + + // Fetching all contributors + err = model.FetchAllContributors() + if err != nil { + return model, err + } + + return model, nil +} + +func (m DashboardModel) Init() tea.Cmd { return nil } + +func (m DashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { + case ContributorErrMsg: + m.contributorErr = fmt.Sprintf("🚧 could not fetch %s: %s", msg.name, msg.err.Error()) + default: + m.contributorErr = "" + + case tea.WindowSizeMsg: + WindowSize = msg + width, _ := m.alumniContributorsTable.Width(), m.alumniContributorsTable.Height() + m.alumniContributorsTable.SetWidth(max(msg.Width-width, 5)) + m.newContributorsTable.SetWidth(max(msg.Width-width, 5)) + + case tea.KeyMsg: + switch msg.String() { + case "right", "l": + m.tableView = (m.tableView + 1) % 2 + case "left", "h": + if m.tableView-1 <= 0 { + m.tableView = 0 + } else { + m.tableView-- + } + case "q", "esc", "ctrl+c", "ctrl+d": + return m, tea.Quit + case "enter": + var contributorName string + switch m.tableView { + case newContributorsView: + if len(m.newContributorsTable.Rows()) > 0 { + contributorName = m.newContributorsTable.SelectedRow()[1] + return m, func() tea.Msg { return SelectMsg{contributorName} } + } + case alumniContributorsView: + if len(m.alumniContributorsTable.Rows()) > 0 { + contributorName = m.alumniContributorsTable.SelectedRow()[1] + return m, func() tea.Msg { return SelectMsg{contributorName} } + } + } + } + + switch m.tableView { + case newContributorsView: + m.newContributorsTable, cmd = m.newContributorsTable.Update(msg) + case alumniContributorsView: + m.alumniContributorsTable, cmd = m.alumniContributorsTable.Update(msg) + } + } + + return m, cmd +} + +func (m DashboardModel) View() string { + return m.drawDashboardView() +} + +// drawTitle: view of PIZZA +func (m *DashboardModel) drawTitle() string { + titleRunes1 := []rune{'█', '█', '█', '█', '█', '█', '╗', ' ', '█', '█', '╗', '█', '█', '█', '█', '█', '█', '█', '╗', '█', '█', '█', '█', '█', '█', '█', '╗', ' ', '█', '█', '█', '█', '█', '╗', ' '} + titleRunes2 := []rune{'█', '█', '╔', '═', '═', '█', '█', '╗', '█', '█', '║', '╚', '═', '═', '█', '█', '█', '╔', '╝', '╚', '═', '═', '█', '█', '█', '╔', '╝', '█', '█', '╔', '═', '═', '█', '█', '╗'} + titleRunes3 := []rune{'█', '█', '█', '█', '█', '█', '╔', '╝', '█', '█', '║', ' ', ' ', '█', '█', '█', '╔', '╝', ' ', ' ', ' ', '█', '█', '█', '╔', '╝', ' ', '█', '█', '█', '█', '█', '█', '█', '║'} + titleRunes4 := []rune{'█', '█', '╔', '═', '═', '═', '╝', ' ', '█', '█', '║', ' ', '█', '█', '█', '╔', '╝', ' ', ' ', ' ', '█', '█', '█', '╔', '╝', ' ', ' ', '█', '█', '╔', '═', '═', '█', '█', '║'} + titleRunes5 := []rune{'█', '█', '║', ' ', ' ', ' ', ' ', ' ', '█', '█', '║', '█', '█', '█', '█', '█', '█', '█', '╗', '█', '█', '█', '█', '█', '█', '█', '╗', '█', '█', '║', ' ', ' ', '█', '█', '║'} + titleRunes6 := []rune{'╚', '═', '╝', ' ', ' ', ' ', ' ', ' ', '╚', '═', '╝', '╚', '═', '═', '═', '═', '═', '═', '╝', '╚', '═', '═', '═', '═', '═', '═', '╝', '╚', '═', '╝', ' ', ' ', '╚', '═', '╝'} + + title1 := lipgloss.JoinHorizontal(lipgloss.Left, string(titleRunes1)) + title2 := lipgloss.JoinHorizontal(lipgloss.Left, string(titleRunes2)) + title3 := lipgloss.JoinHorizontal(lipgloss.Left, string(titleRunes3)) + title4 := lipgloss.JoinHorizontal(lipgloss.Left, string(titleRunes4)) + title5 := lipgloss.JoinHorizontal(lipgloss.Left, string(titleRunes5)) + title6 := lipgloss.JoinHorizontal(lipgloss.Left, string(titleRunes6)) + title := lipgloss.JoinVertical(lipgloss.Center, title1, title2, title3, title4, title5, title6) + titleView := lipgloss.NewStyle().Foreground(Color).Render(title) + + return titleView +} + +// drawRepositoryInfo: view of the repository info (name, stars, size, and issues) +func (m *DashboardModel) drawRepositoryInfo() string { + repoName := lipgloss.NewStyle().Bold(true).AlignHorizontal(lipgloss.Center).Render(fmt.Sprintf("Repository: %s", m.RepositoryInfo.FullName)) + repoStars := fmt.Sprintf("🌟 stars: %d", m.RepositoryInfo.Stars) + repoSize := fmt.Sprintf("💾 size: %dB", m.RepositoryInfo.Size) + repoIssues := fmt.Sprintf("📄 issues: %d", m.RepositoryInfo.Issues) + repoForks := fmt.Sprintf("⑂ forks: %d", m.RepositoryInfo.Forks) + + issuesAndForks := lipgloss.JoinVertical(lipgloss.Center, TextContainer.Render(repoIssues), TextContainer.Render(repoForks)) + sizeAndStars := lipgloss.JoinVertical(lipgloss.Left, TextContainer.Render(repoSize), TextContainer.Render(repoStars)) + + repoGeneralSection := lipgloss.JoinHorizontal(lipgloss.Center, issuesAndForks, sizeAndStars) + repositoryInfoView := lipgloss.JoinVertical(lipgloss.Center, repoName, repoGeneralSection) + frame := SquareBorder.Render(repositoryInfoView) + + return frame +} + +// drawMetrics: view of metrics includes. +// - new contributors table +// - alumni contributors table +func (m *DashboardModel) drawMetrics() string { + var newContributorsDisplay, alumniContributorsDisplay string + + switch m.tableView { + case newContributorsView: + newContributorsDisplay = lipgloss.JoinVertical(lipgloss.Center, TableTitle.Render("🍕 New Contributors"), ActiveStyle.Render(m.newContributorsTable.View())) + alumniContributorsDisplay = lipgloss.JoinVertical(lipgloss.Center, TableTitle.Render("🍁 Alumni Contributors"), InactiveStyle.Render(m.alumniContributorsTable.View())) + case alumniContributorsView: + newContributorsDisplay = lipgloss.JoinVertical(lipgloss.Center, TableTitle.Render("🍕 New Contributors"), InactiveStyle.Render(m.newContributorsTable.View())) + alumniContributorsDisplay = lipgloss.JoinVertical(lipgloss.Center, TableTitle.Render("🍁 Alumni Contributors"), ActiveStyle.Render(m.alumniContributorsTable.View())) + } + + contributorsMetrics := lipgloss.JoinHorizontal(lipgloss.Center, WidgetContainer.Render(newContributorsDisplay), WidgetContainer.Render(alumniContributorsDisplay)) + + return contributorsMetrics +} + +// drawDashboardView: this is the main model view (shows repository info and tables) +func (m *DashboardModel) drawDashboardView() string { + if WindowSize.Width == 0 { + return "Loading..." + } + var wg sync.WaitGroup + + wg.Add(3) + var titleView, repoInfoView, metricsView string + go func() { + defer wg.Done() + titleView = m.drawTitle() + }() + + go func() { + defer wg.Done() + repoInfoView = m.drawRepositoryInfo() + }() + + go func() { + defer wg.Done() + metricsView = m.drawMetrics() + }() + wg.Wait() + + mainView := lipgloss.JoinVertical(lipgloss.Center, titleView, repoInfoView, metricsView, m.contributorErr) + _, h := lipgloss.Size(mainView) + if WindowSize.Height < h { + contentLeft := lipgloss.JoinVertical(lipgloss.Center, titleView, repoInfoView) + mainView = lipgloss.JoinHorizontal(lipgloss.Center, contentLeft, metricsView) + } + frame := Viewport.Render(mainView) + return frame +} + +// validateShowQuery: validates fields set to query the contributor tables +func validateShowQuery(opts *Options) error { + if opts.RepoName == "" { + return errors.New("no repository was provided") + } + + if len(strings.Split(opts.RepoName, "/")) != 2 { + return errors.New("wrong repository name format. Must be in the form owner/name, i.e(open-sauced/pizza)") + } + if opts.Limit < 1 { + return errors.New("--limit flag must be a positive integer value") + } + if opts.Range < 1 { + return errors.New("--range flag must be a positive integer value") + } + + if opts.Page < 1 { + return errors.New("--page flag must be a positive integer value") + } + + return nil +} + +// FetchAllContributors: fetchs and sets all the contributors (new, alumni) +func (m *DashboardModel) FetchAllContributors() error { + var ( + errorChan = make(chan error, 2) + wg sync.WaitGroup + ) + + wg.Add(2) + go func() { + defer wg.Done() + newContributors, err := m.FetchNewContributors() + if err != nil { + errorChan <- err + return + } + m.newContributorsTable = setupContributorsTable(newContributors) + }() + + go func() { + defer wg.Done() + alumniContributors, err := m.FetchAlumniContributors() + if err != nil { + errorChan <- err + return + } + m.alumniContributorsTable = setupContributorsTable(alumniContributors) + }() + + wg.Wait() + close(errorChan) + if len(errorChan) > 0 { + var allErrors error + for err := range errorChan { + allErrors = errors.Join(allErrors, err) + } + return allErrors + } + + return nil +} + +// FetchNewContributors: Returns all the new contributors +func (m *DashboardModel) FetchNewContributors() ([]client.DbPullRequestContributor, error) { + resp, r, err := m.APIClient.ContributorsServiceAPI.NewPullRequestContributors(m.serverContext).Page(int32(m.queryOptions[0])). + Limit(int32(m.queryOptions[1])).RepoIds(strconv.Itoa(int(m.RepositoryInfo.Id))).Execute() + if err != nil { + return nil, err + } + + if r.StatusCode != 200 { + return nil, fmt.Errorf("HTTP status: %d", r.StatusCode) + } + + return resp.Data, nil + +} + +// FetchAlumniContributors: Returns all alumni contributors +func (m *DashboardModel) FetchAlumniContributors() ([]client.DbPullRequestContributor, error) { + resp, r, err := m.APIClient.ContributorsServiceAPI.FindAllChurnPullRequestContributors(m.serverContext). + Page(int32(m.queryOptions[0])).Limit(int32(m.queryOptions[1])). + Range_(int32(m.queryOptions[2])).RepoIds(strconv.Itoa(int(m.RepositoryInfo.Id))).Execute() + if err != nil { + return nil, err + } + + if r.StatusCode != 200 { + return nil, fmt.Errorf("HTTP status: %d", r.StatusCode) + } + + return resp.Data, nil +} + +// setupContributorsTable: sets the contributor table UI +func setupContributorsTable(contributors []client.DbPullRequestContributor) table.Model { + columns := []table.Column{ + {Title: "ID", Width: 5}, + {Title: "Name", Width: 20}, + } + + rows := []table.Row{} + + for i, contributor := range contributors { + rows = append(rows, table.Row{strconv.Itoa(i), contributor.AuthorLogin}) + } + + contributorTable := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + table.WithHeight(15), + ) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderBottom(true). + Bold(true) + + s.Selected = s.Selected. + Foreground(lipgloss.Color("#FFFFFF")). + Background(lipgloss.Color("#FF4500")) + + contributorTable.SetStyles(s) + return contributorTable +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/cmd/show/show.go b/cmd/show/show.go new file mode 100644 index 0000000..c7c59a2 --- /dev/null +++ b/cmd/show/show.go @@ -0,0 +1,85 @@ +// Package show contains the bootstrapping and tooling for the pizza show +// cobra command +package show + +import ( + "context" + "errors" + + client "github.com/open-sauced/go-api/client" + "github.com/open-sauced/pizza-cli/pkg/api" + "github.com/spf13/cobra" +) + +// Options are the options for the pizza show command including user +// defined configurations +type Options struct { + // RepoName is the git repo name to get statistics + RepoName string + + // Page is the page to be requested + Page int + + // Limit is the number of records to be retrieved + Limit int + + // Range is the number of days to take into account when retrieving statistics + Range int + + // APIClient is the api client to interface with open sauced api + APIClient *client.APIClient + + ServerContext context.Context +} + +const showLongDesc string = `WARNING: Proof of concept feature. + +The show command accepts the name of a git repository in the format 'owner/name' and uses OpenSauced api +to retrieve metrics of the repository to be displayed as a TUI.` + +// NewShowCommand returns a new cobra command for 'pizza show' +func NewShowCommand() *cobra.Command { + opts := &Options{} + + cmd := &cobra.Command{ + Use: "show repository-name [flags]", + Short: "Get visual metrics of a repository", + Long: showLongDesc, + Args: func(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return errors.New("must specify the URL of a git repository to analyze") + } + opts.RepoName = args[0] + + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + var endpoint string + customEndpoint, _ := cmd.Flags().GetString("endpoint") + if customEndpoint != "" { + endpoint = customEndpoint + } + + useBeta, _ := cmd.Flags().GetBool("beta") + if useBeta { + endpoint = api.BetaAPIEndpoint + } + + opts.APIClient = api.NewGoClient(endpoint) + opts.ServerContext = context.TODO() + return run(opts) + }, + } + + cmd.Flags().IntVarP(&opts.Range, "range", "r", 30, "The last N number of days to consider") + cmd.Flags().IntVarP(&opts.Limit, "limit", "l", 10, "The number of records to retrieve") + cmd.Flags().IntVarP(&opts.Page, "page", "p", 1, "The page number to retrieve") + + return cmd +} + +func run(opts *Options) error { + // Load the pizza TUI + err := pizzaTUI(opts) + return err +} diff --git a/cmd/show/styles.go b/cmd/show/styles.go new file mode 100644 index 0000000..6262be2 --- /dev/null +++ b/cmd/show/styles.go @@ -0,0 +1,42 @@ +package show + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// WindowSize stores the size of the terminal +var WindowSize tea.WindowSizeMsg + +// STYLES + +// Viewport: The viewport of the tui (my:2, mx:40) +var Viewport = lipgloss.NewStyle().Margin(1, 2) + +// Container: container styling (width: 80, py: 0, px: 5) +var Container = lipgloss.NewStyle().Width(80).Padding(0, 5) + +// WidgetContainer: container for tables, and graphs (py:2, px:2) +var WidgetContainer = lipgloss.NewStyle().Padding(2, 2) + +// SquareBorder: Style to draw a border around a section +var SquareBorder = lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(Color) + +// TextContainer: container for text +var TextContainer = lipgloss.NewStyle().Padding(1, 1) + +// TableTitle: The style for table titles (width:25, align-horizontal:center, bold:true) +var TableTitle = lipgloss.NewStyle().Width(25).AlignHorizontal(lipgloss.Center).Bold(true) + +// Color: the color palette (Light: #000000, Dark: #FF4500) +var Color = lipgloss.AdaptiveColor{Light: "#000000", Dark: "#FF4500"} + +// ActiveStyle: table when selected (border:normal, border-foreground:#FF4500) +var ActiveStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("#FF4500")) + +// InactiveStyle: table when not selected (border:normal, border-foreground:#FFFFFF) +var InactiveStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("#FFFFFF")) diff --git a/cmd/show/tui.go b/cmd/show/tui.go new file mode 100644 index 0000000..989d70b --- /dev/null +++ b/cmd/show/tui.go @@ -0,0 +1,85 @@ +package show + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" +) + +const ( + dashboardView = iota + contributorView +) + +type ( + // sessionState serves as the variable to reference when looking for which model the user is in + sessionState int +) + +// MainModel: the main model is the central state manager of the TUI, decides which model is focused based on certain commands +type MainModel struct { + state sessionState + dashboard tea.Model + contributor tea.Model + authorName string +} + +// View: the view of the TUI +func (m MainModel) View() string { + switch m.state { + case contributorView: + return m.contributor.View() + default: + return m.dashboard.View() + } +} + +// Init: initial IO before program start +func (m MainModel) Init() tea.Cmd { return nil } + +// Update: Handle IO and Commands +func (m MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + switch msg := msg.(type) { + case BackMsg: + m.state = dashboardView + case SelectMsg: + m.state = contributorView + m.authorName = msg.contributorName + } + + switch m.state { + case dashboardView: + newDashboard, newCmd := m.dashboard.Update(msg) + m.dashboard = newDashboard + cmd = newCmd + case contributorView: + // TODO: process cmd error if contributor fails to fetch + var err error + m.contributor, err = InitContributor(m.authorName) + if err != nil { + m.state = dashboardView + return m, func() tea.Msg { return ContributorErrMsg{name: m.authorName, err: err} } + } + newContributor, newCmd := m.contributor.Update(msg) + m.contributor = newContributor + cmd = newCmd + } + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) +} + +func pizzaTUI(opts *Options) error { + dashboardModel, err := InitDashboard(opts) + if err != nil { + return err + } + + model := MainModel{dashboard: dashboardModel} + if _, err := tea.NewProgram(model, tea.WithAltScreen()).Run(); err != nil { + return fmt.Errorf("Error running program: %s", err.Error()) + } + + return nil +} diff --git a/go.mod b/go.mod index 64f3be1..f1cf158 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,9 @@ module github.com/open-sauced/pizza-cli go 1.20 require ( + github.com/charmbracelet/bubbles v0.16.1 + github.com/charmbracelet/bubbletea v0.24.2 + github.com/charmbracelet/lipgloss v0.7.1 github.com/cli/browser v1.2.0 github.com/posthog/posthog-go v0.0.0-20230801140217-d607812dee69 github.com/spf13/cobra v1.7.0 @@ -12,7 +15,21 @@ require ( ) require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect github.com/google/uuid v1.3.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.18 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.15.1 // indirect + github.com/open-sauced/go-api/client v0.0.0-20230825180028-30fe67759eff + github.com/rivo/uniseg v0.2.0 // indirect + golang.org/x/sync v0.1.0 // indirect golang.org/x/sys v0.9.0 // indirect + golang.org/x/text v0.3.8 // indirect ) diff --git a/go.sum b/go.sum index 35b5fbe..296ea99 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,18 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/cli/browser v1.2.0 h1:yvU7e9qf97kZqGFX6n2zJPHsmSObY9ske+iCvKelvXg= github.com/cli/browser v1.2.0/go.mod h1:xFFnXLVcAyW9ni0cuo6NnrbCP75JxJ0RO7VtCBiH/oI= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +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/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY= +github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc= +github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY= +github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg= +github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E= +github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c= +github.com/cli/browser v1.2.0 h1:yvU7e9qf97kZqGFX6n2zJPHsmSObY9ske+iCvKelvXg= +github.com/cli/browser v1.2.0/go.mod h1:xFFnXLVcAyW9ni0cuo6NnrbCP75JxJ0RO7VtCBiH/oI= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -11,6 +23,28 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/posthog/posthog-go v0.0.0-20230801140217-d607812dee69 h1:01dHVodha5BzrMtVmcpPeA4VYbZEsTXQ6m4123zQXJk= github.com/posthog/posthog-go v0.0.0-20230801140217-d607812dee69/go.mod h1:migYMxlAqcnQy+3eN8mcL0b2tpKy6R+8Zc0lxwk4dKM= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= +github.com/mattn/go-isatty v0.0.18/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.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= +github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +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.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs= +github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ= +github.com/open-sauced/go-api/client v0.0.0-20230825180028-30fe67759eff h1:sHfgCRtAg3xzNXoqqJ2MhDLwPj4GhC99ksDYC+K1BtM= +github.com/open-sauced/go-api/client v0.0.0-20230825180028-30fe67759eff/go.mod h1:W/TRuLUqYpMvkmElDUQvQ07xlxhK8TOfpwRh8SCAuNA= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= @@ -19,10 +53,17 @@ 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/urfave/cli v1.22.5/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s= golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.9.0 h1:GRRCnKYhdQrD8kfRAdQ6Zcw1P0OcELxGLKJvtjVMZ28= golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/pkg/api/client.go b/pkg/api/client.go index e519936..7cdaf47 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -1,6 +1,10 @@ package api -import "net/http" +import ( + "net/http" + + "github.com/open-sauced/go-api/client" +) type Client struct { // The configured http client for making API requests @@ -18,3 +22,11 @@ func NewClient(endpoint string) *Client { Endpoint: endpoint, } } + +func NewGoClient(endpoint string) *client.APIClient { + config := client.NewConfiguration() + config.Servers = client.ServerConfigurations{ + {URL: endpoint}, + } + return client.NewAPIClient(config) +} diff --git a/pkg/api/constants.go b/pkg/api/constants.go index 66a71e7..5f4a0d0 100644 --- a/pkg/api/constants.go +++ b/pkg/api/constants.go @@ -1,6 +1,6 @@ package api const ( - APIEndpoint = "https://api.opensauced.pizza/v1" - BetaAPIEndpoint = "https://beta.api.opensauced.pizza/v1" + APIEndpoint = "https://api.opensauced.pizza" + BetaAPIEndpoint = "https://beta.api.opensauced.pizza" )