From 72f21ce260ec73c3ea0d7e97ed1411a86bb1d753 Mon Sep 17 00:00:00 2001 From: Kin NG <59541661+k1nho@users.noreply.github.com> Date: Wed, 27 Sep 2023 11:49:15 -0400 Subject: [PATCH] feat: Pizza show (#24) --- cmd/root/root.go | 2 + cmd/show/constants.go | 63 ++++++++ cmd/show/contributors.go | 256 ++++++++++++++++++++++++++++++ cmd/show/dashboard.go | 328 +++++++++++++++++++++++++++++++++++++++ cmd/show/show.go | 96 ++++++++++++ cmd/show/tui.go | 95 ++++++++++++ go.mod | 4 +- go.sum | 9 +- pkg/utils/github.go | 39 +++-- 9 files changed, 873 insertions(+), 19 deletions(-) create mode 100644 cmd/show/constants.go create mode 100644 cmd/show/contributors.go create mode 100644 cmd/show/dashboard.go create mode 100644 cmd/show/show.go create mode 100644 cmd/show/tui.go diff --git a/cmd/root/root.go b/cmd/root/root.go index af1a376..ab86928 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -8,6 +8,7 @@ import ( "github.com/open-sauced/pizza-cli/cmd/bake" "github.com/open-sauced/pizza-cli/cmd/insights" repoquery "github.com/open-sauced/pizza-cli/cmd/repo-query" + "github.com/open-sauced/pizza-cli/cmd/show" "github.com/open-sauced/pizza-cli/cmd/version" "github.com/open-sauced/pizza-cli/pkg/constants" "github.com/spf13/cobra" @@ -41,6 +42,7 @@ func NewRootCommand() (*cobra.Command, error) { cmd.AddCommand(auth.NewLoginCommand()) cmd.AddCommand(insights.NewInsightsCommand()) cmd.AddCommand(version.NewVersionCommand()) + cmd.AddCommand(show.NewShowCommand()) return cmd, nil } diff --git a/cmd/show/constants.go b/cmd/show/constants.go new file mode 100644 index 0000000..9c68085 --- /dev/null +++ b/cmd/show/constants.go @@ -0,0 +1,63 @@ +package show + +import ( + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// WindowSize stores the size of the terminal +var WindowSize tea.WindowSizeMsg + +// Keymaps +var OpenPR = key.NewBinding(key.WithKeys("O"), key.WithHelp("O", "open pr")) +var BackToDashboard = key.NewBinding(key.WithKeys("B"), key.WithHelp("B", "back")) +var ToggleHelpMenu = key.NewBinding(key.WithKeys("H"), key.WithHelp("H", "toggle help")) + +// STYLES +// Viewport: The viewport of the tui (my:2, mx:2) +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")) + +// ItemStyle: style applied to items in a list.Model +var ItemStyle = lipgloss.NewStyle().PaddingLeft(4) + +// SelectedItemStyle: style applied when the item is selected in a list.Model +var SelectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(Color) + +// ListItemTitle: style for the list.Model title +var ListItemTitleStyle = lipgloss.NewStyle().MarginLeft(2) + +// PaginationStyle: style for pagination of list.Model +var PaginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4) + +// HelpStyle: style for help menu +var HelpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1) diff --git a/cmd/show/contributors.go b/cmd/show/contributors.go new file mode 100644 index 0000000..5872940 --- /dev/null +++ b/cmd/show/contributors.go @@ -0,0 +1,256 @@ +package show + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + "sync" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/cli/browser" + client "github.com/open-sauced/go-api/client" +) + +// prItem: type for pull request to satisfy the list.Item interface +type prItem client.DbPullRequest + +func (i prItem) FilterValue() string { return i.Title } +func (i prItem) GetRepoName() string { + if i.FullName != nil { + return *i.FullName + } + return "" +} + +type itemDelegate struct{} + +func (d itemDelegate) Height() int { return 1 } +func (d itemDelegate) Spacing() int { return 1 } +func (d itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } +func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { + i, ok := listItem.(prItem) + if !ok { + return + } + + prTitle := i.Title + if len(prTitle) >= 60 { + prTitle = fmt.Sprintf("%s...", prTitle[:60]) + } + + str := fmt.Sprintf("#%d %s\n%s\n(%s)", i.Number, i.GetRepoName(), prTitle, i.State) + + fn := ItemStyle.Render + if index == m.Index() { + fn = func(s ...string) string { + return SelectedItemStyle.Render("🍕 " + strings.Join(s, " ")) + } + } + + fmt.Fprint(w, fn(str)) +} + +// ContributorModel holds all the information related to a contributor +type ContributorModel struct { + username string + userInfo *client.DbUser + prList list.Model + APIClient *client.APIClient + serverContext context.Context +} + +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(opts *Options) (tea.Model, error) { + var model ContributorModel + model.APIClient = opts.APIClient + model.serverContext = opts.ServerContext + + return model, 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.WindowSizeMsg: + WindowSize = msg + case SelectMsg: + m.username = msg.contributorName + model, err := m.fetchUser() + if err != nil { + return m, func() tea.Msg { return ContributorErrMsg{name: msg.contributorName, err: err} } + } + return model, func() tea.Msg { return SuccessMsg{} } + case tea.KeyMsg: + switch msg.String() { + case "B": + if !m.prList.SettingFilter() { + return m, func() tea.Msg { return BackMsg{} } + } + case "H": + if !m.prList.SettingFilter() { + m.prList.SetShowHelp(!m.prList.ShowHelp()) + return m, nil + } + case "O": + if !m.prList.SettingFilter() { + pr, ok := m.prList.SelectedItem().(prItem) + if ok { + err := browser.OpenURL(fmt.Sprintf("https://github.com/%s/pull/%d", pr.GetRepoName(), pr.Number)) + if err != nil { + fmt.Println("could not open pull request in browser") + } + } + } + case "q", "ctrl+c", "ctrl+d": + if !m.prList.SettingFilter() { + return m, tea.Quit + } + } + } + m.prList, cmd = m.prList.Update(msg) + 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() (tea.Model, error) { + var ( + wg sync.WaitGroup + errChan = make(chan error, 2) + ) + + wg.Add(1) + go func() { + defer wg.Done() + err := m.fetchContributorInfo(m.username) + if err != nil { + errChan <- err + return + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + err := m.fetchContributorPRs(m.username) + if err != nil { + errChan <- err + return + } + }() + + wg.Wait() + close(errChan) + if len(errChan) > 0 { + var allErrors error + for err := range errChan { + allErrors = errors.Join(allErrors, err) + } + return m, allErrors + } + + return m, nil +} + +// fetchContributorInfo: fetches the contributor info +func (m *ContributorModel) fetchContributorInfo(name string) error { + resp, r, err := m.APIClient.UserServiceAPI.FindOneUserByUserame(m.serverContext, name).Execute() + if err != nil { + return err + } + + if r.StatusCode != 200 { + return fmt.Errorf("HTTP failed: %d", r.StatusCode) + } + + m.userInfo = resp + return nil +} + +// fetchContributorPRs: fetches the contributor pull requests and creates pull request list +func (m *ContributorModel) fetchContributorPRs(name string) error { + resp, r, err := m.APIClient.UserServiceAPI.FindContributorPullRequests(m.serverContext, name).Limit(10).Execute() + if err != nil { + return err + } + + if r.StatusCode != 200 { + return fmt.Errorf("HTTP failed: %d", r.StatusCode) + } + + // create contributor pull request list + var items []list.Item + for _, pr := range resp.Data { + items = append(items, prItem(pr)) + } + + l := list.New(items, itemDelegate{}, WindowSize.Width, 14) + l.Title = "✨ Latest Pull Requests" + l.Styles.Title = ListItemTitleStyle + l.Styles.HelpStyle = HelpStyle + l.Styles.NoItems = ItemStyle + l.SetShowStatusBar(false) + l.SetStatusBarItemName("pull request", "pull requests") + l.AdditionalShortHelpKeys = func() []key.Binding { + return []key.Binding{ + OpenPR, + BackToDashboard, + ToggleHelpMenu, + } + } + + m.prList = l + return nil +} + +// drawContributorView: view of the contributor model +func (m *ContributorModel) drawContributorView() string { + contributorInfo := m.drawContributorInfo() + + contributorView := lipgloss.JoinVertical(lipgloss.Left, lipgloss.NewStyle().PaddingLeft(2).Render(contributorInfo), + WidgetContainer.Render(m.prList.View())) + + _, h := lipgloss.Size(contributorView) + if WindowSize.Height < h { + contributorView = lipgloss.JoinHorizontal(lipgloss.Center, contributorInfo, m.prList.View()) + } + + return contributorView +} + +// 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) +} diff --git a/cmd/show/dashboard.go b/cmd/show/dashboard.go new file mode 100644 index 0000000..14efb55 --- /dev/null +++ b/cmd/show/dashboard.go @@ -0,0 +1,328 @@ +package show + +import ( + "context" + "errors" + "fmt" + "strconv" + "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) (tea.Model, error) { + var model DashboardModel + err := validateShowQuery(opts) + if err != nil { + return model, err + } + + resp, r, err := opts.APIClient.RepositoryServiceAPI.FindOneByOwnerAndRepo(opts.ServerContext, opts.Owner, opts.RepoName).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 the contributor tables + 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 tea.WindowSizeMsg: + WindowSize = msg + + case ErrMsg: + fmt.Printf("Failed to retrieve contributors table data: %s", msg.err.Error()) + return m, tea.Quit + + case ContributorErrMsg: + m.contributorErr = fmt.Sprintf("🚧 could not fetch %s: %s", msg.name, msg.err.Error()) + default: + m.contributorErr = "" + + 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": + switch m.tableView { + case newContributorsView: + if len(m.newContributorsTable.Rows()) > 0 { + return m, func() tea.Msg { return SelectMsg{contributorName: m.newContributorsTable.SelectedRow()[1]} } + } + case alumniContributorsView: + if len(m.alumniContributorsTable.Rows()) > 0 { + return m, func() tea.Msg { return SelectMsg{contributorName: m.alumniContributorsTable.SelectedRow()[1]} } + } + } + } + + 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..." + } + titleView, repoInfoView, metricsView := m.drawTitle(), m.drawRepositoryInfo(), m.drawMetrics() + 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) + contentRight := lipgloss.JoinVertical(lipgloss.Center, metricsView, m.contributorErr) + mainView = lipgloss.JoinHorizontal(lipgloss.Center, contentLeft, contentRight) + } + frame := Viewport.Render(mainView) + return frame +} + +// validateShowQuery: validates fields set to query the contributor tables +func validateShowQuery(opts *Options) error { + 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(1) + go func() { + defer wg.Done() + newContributors, err := m.FetchNewContributors() + if err != nil { + errorChan <- err + return + } + m.newContributorsTable = setupContributorsTable(newContributors) + }() + + wg.Add(1) + 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 +} diff --git a/cmd/show/show.go b/cmd/show/show.go new file mode 100644 index 0000000..9ab79e0 --- /dev/null +++ b/cmd/show/show.go @@ -0,0 +1,96 @@ +// 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/open-sauced/pizza-cli/pkg/constants" + "github.com/open-sauced/pizza-cli/pkg/utils" + "github.com/spf13/cobra" +) + +// Options are the options for the pizza show command including user +// defined configurations +type Options struct { + // Owner: the owner of the repository + Owner string + + // RepoName: the name of the repository + 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 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") + } + + owner, name, err := utils.GetOwnerAndRepoFromURL(args[0]) + if err != nil { + return err + } + opts.Owner = owner + opts.RepoName = name + + 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 = constants.EndpointBeta + } + + 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/tui.go b/cmd/show/tui.go new file mode 100644 index 0000000..141367e --- /dev/null +++ b/cmd/show/tui.go @@ -0,0 +1,95 @@ +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 + + // SuccessMsg: message to represent success when fetching + SuccessMsg struct{} + + // ErrMsg: this message represents an error when fetching + ErrMsg struct { + err error + } +) + +// 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 +} + +// 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 SuccessMsg: + m.state = contributorView + case SelectMsg: + newContributor, newCmd := m.contributor.Update(msg) + m.contributor = newContributor + cmds = append(cmds, newCmd) + } + + switch m.state { + case dashboardView: + newDashboard, newCmd := m.dashboard.Update(msg) + m.dashboard = newDashboard + cmd = newCmd + case contributorView: + 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 + } + + contributorModel, err := InitContributor(opts) + if err != nil { + return err + } + + model := MainModel{dashboard: dashboardModel, contributor: contributorModel} + 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 d536864..c589872 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ 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/open-sauced/go-api/client v0.0.0-20230825180028-30fe67759eff @@ -15,8 +16,8 @@ require ( ) require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/bubbletea v0.24.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 @@ -29,6 +30,7 @@ require ( github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.15.1 // indirect github.com/rivo/uniseg v0.2.0 // indirect + github.com/sahilm/fuzzy v0.1.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 faaf92a..eadba7c 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,12 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +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/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.1 h1:LpdYfnu+Qc6XtvMz6d/6rRY71yttHTP5HtrjMgWvixc= -github.com/charmbracelet/bubbletea v0.24.1/go.mod h1:rK3g/2+T8vOSEkNHvtq40umJpeVYDn6bLaqbgzhL/hg= +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= @@ -17,6 +19,7 @@ github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 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= @@ -44,6 +47,8 @@ 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.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= +github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 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= github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= diff --git a/pkg/utils/github.go b/pkg/utils/github.go index 83a8ac1..05dbcb2 100644 --- a/pkg/utils/github.go +++ b/pkg/utils/github.go @@ -2,27 +2,34 @@ package utils import ( "fmt" + "net/url" "strings" ) -func GetOwnerAndRepoFromURL(url string) (owner, repo string, err error) { - if !strings.HasPrefix(url, "https://github.com/") { - return "", "", fmt.Errorf("invalid URL: %s", url) - } - - // Remove the "https://github.com/" prefix from the URL - url = strings.TrimPrefix(url, "https://github.com/") +// GetOwnerAndRepoFromURL: extracts the owner and repository name +func GetOwnerAndRepoFromURL(input string) (owner, repo string, err error) { + var repoOwner, repoName string - // Split the remaining URL path into segments - segments := strings.Split(url, "/") + // check (https://github.com/owner/repo) format + u, err := url.Parse(input) + if err == nil && u.Host == "github.com" { + path := strings.Trim(u.Path, "/") + parts := strings.Split(path, "/") + if len(parts) != 2 { + return "", "", fmt.Errorf("invalid URL: %s", input) + } + repoOwner = parts[0] + repoName = parts[1] + return repoOwner, repoName, nil + } - // The first segment is the owner, and the second segment is the repository name - if len(segments) >= 2 { - owner = segments[0] - repo = segments[1] - } else { - return "", "", fmt.Errorf("invalid URL: %s", url) + // check (owner/repo) format + parts := strings.Split(input, "/") + if len(parts) != 2 { + return "", "", fmt.Errorf("invalid URL: %s", input) } + repoOwner = parts[0] + repoName = parts[1] - return owner, repo, nil + return repoOwner, repoName, nil }